├── .prettierignore
├── lib
├── offline.ts
├── index.ts
├── types.d.ts
├── global.d.ts
├── container.ts
├── filters
│ ├── nodefilter.ts
│ ├── hostname.ts
│ ├── filtergui.ts
│ └── genericnode.ts
├── load.ts
├── utils
│ ├── math.ts
│ ├── language.ts
│ ├── version.ts
│ ├── router.ts
│ ├── node.ts
│ └── helper.ts
├── title.ts
├── map
│ ├── locationmarker.js
│ ├── clientlayer.ts
│ ├── button.js
│ └── activearea.js
├── tabs.ts
├── sidebar.ts
├── legend.ts
├── simplenodelist.ts
├── sorttable.ts
├── infobox
│ ├── main.ts
│ ├── location.ts
│ ├── link.ts
│ └── node.ts
├── about.ts
├── linklist.ts
├── datadistributor.ts
├── nodelist.ts
├── main.ts
├── forcegraph
│ └── draw.ts
├── gui.ts
├── proportions.ts
└── map.ts
├── .prettierrc.json
├── scss
├── mixins
│ ├── _icon.scss
│ └── _font.scss
├── modules
│ ├── font
│ │ ├── _icon.scss
│ │ └── _font.scss
│ ├── _loader.scss
│ ├── _forcegraph.scss
│ ├── _proportion.scss
│ ├── _node.scss
│ ├── _tabs.scss
│ ├── _infobox.scss
│ ├── _reset.scss
│ ├── _map.scss
│ ├── _filter.scss
│ ├── _table.scss
│ ├── _variables.scss
│ ├── _legend.scss
│ ├── _base.scss
│ ├── _button.scss
│ └── _sidebar.scss
├── custom
│ ├── _variables.scss
│ └── _custom.scss
├── main.scss
└── night.scss
├── public
├── favicon.ico
├── pwa-64x64.png
├── pwa-192x192.png
├── pwa-512x512.png
├── maskable-icon-512x512.png
├── apple-touch-icon-180x180.png
└── locale
│ ├── cz.json
│ ├── ru.json
│ ├── tr.json
│ ├── fr.json
│ ├── en.json
│ └── de.json
├── assets
├── fonts
│ ├── meshviewer.ttf
│ ├── meshviewer.woff
│ ├── meshviewer.woff2
│ ├── Assistant-Bold.ttf
│ ├── Assistant-Bold.woff
│ ├── Assistant-Bold.woff2
│ ├── Assistant-Light.ttf
│ ├── Assistant-Light.woff
│ └── Assistant-Light.woff2
├── icons
│ ├── _icon-mixin.scss
│ └── icon.scss
└── logo.svg
├── compose.yml
├── tsconfig.json
├── .gitignore
├── .stylelintrc
├── .github
├── CONTRIBUTING.md
├── workflows
│ ├── build-meshviewer.yml
│ ├── release-meshviewer.yml
│ ├── build-docker.yml
│ └── publish-docker.yml
├── dependabot.yml
├── ISSUE_TEMPLATE
│ ├── feature_request.md
│ └── bug_report.md
└── PULL_REQUEST_TEMPLATE.md
├── .editorconfig
├── Dockerfile
├── eslint.config.mjs
├── offline.html
├── index.html
├── vite.config.js
├── package.json
├── DEVELOPMENT.md
├── README.md
└── config.example.json
/.prettierignore:
--------------------------------------------------------------------------------
1 | /build
2 | /dev-dist
3 |
--------------------------------------------------------------------------------
/lib/offline.ts:
--------------------------------------------------------------------------------
1 | import "../scss/main.scss";
2 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120
3 | }
4 |
--------------------------------------------------------------------------------
/scss/mixins/_icon.scss:
--------------------------------------------------------------------------------
1 | ../../assets/icons/_icon-mixin.scss
--------------------------------------------------------------------------------
/scss/modules/font/_icon.scss:
--------------------------------------------------------------------------------
1 | ../../../assets/icons/icon.scss
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/pwa-64x64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/HEAD/public/pwa-64x64.png
--------------------------------------------------------------------------------
/lib/index.ts:
--------------------------------------------------------------------------------
1 | import "../scss/main.scss";
2 | import { load } from "./load.js";
3 |
4 | load();
5 |
--------------------------------------------------------------------------------
/public/pwa-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/HEAD/public/pwa-192x192.png
--------------------------------------------------------------------------------
/public/pwa-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/HEAD/public/pwa-512x512.png
--------------------------------------------------------------------------------
/assets/fonts/meshviewer.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/HEAD/assets/fonts/meshviewer.ttf
--------------------------------------------------------------------------------
/assets/fonts/meshviewer.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/HEAD/assets/fonts/meshviewer.woff
--------------------------------------------------------------------------------
/assets/fonts/meshviewer.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/HEAD/assets/fonts/meshviewer.woff2
--------------------------------------------------------------------------------
/assets/fonts/Assistant-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/HEAD/assets/fonts/Assistant-Bold.ttf
--------------------------------------------------------------------------------
/assets/fonts/Assistant-Bold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/HEAD/assets/fonts/Assistant-Bold.woff
--------------------------------------------------------------------------------
/assets/fonts/Assistant-Bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/HEAD/assets/fonts/Assistant-Bold.woff2
--------------------------------------------------------------------------------
/assets/fonts/Assistant-Light.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/HEAD/assets/fonts/Assistant-Light.ttf
--------------------------------------------------------------------------------
/assets/fonts/Assistant-Light.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/HEAD/assets/fonts/Assistant-Light.woff
--------------------------------------------------------------------------------
/assets/fonts/Assistant-Light.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/HEAD/assets/fonts/Assistant-Light.woff2
--------------------------------------------------------------------------------
/public/maskable-icon-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/HEAD/public/maskable-icon-512x512.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-180x180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/HEAD/public/apple-touch-icon-180x180.png
--------------------------------------------------------------------------------
/assets/icons/_icon-mixin.scss:
--------------------------------------------------------------------------------
1 | @mixin icon($name, $code, $prefix: "ion-") {
2 | .#{$prefix}#{$name} {
3 | &::before {
4 | content: "#{$code}";
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | meshviewer:
3 | image: ghcr.io/freifunk/meshviewer:latest
4 | volumes:
5 | - ./public/config.json:/usr/share/nginx/html/config.json
6 | ports:
7 | - "8080:80"
8 |
--------------------------------------------------------------------------------
/scss/custom/_variables.scss:
--------------------------------------------------------------------------------
1 | // Example of overwriting variables. Take a look at modules/variables
2 | //$color-black: #fff;
3 | //$color-white: invert($color-white);
4 | //$color-primary: invert($color-primary);
5 |
--------------------------------------------------------------------------------
/scss/modules/_loader.scss:
--------------------------------------------------------------------------------
1 | @use "variables";
2 |
3 | .loader {
4 | color: variables.$color-primary;
5 | font-size: 1.8em;
6 | line-height: 2;
7 | margin: 30vh auto;
8 | text-align: center;
9 | }
10 |
--------------------------------------------------------------------------------
/lib/types.d.ts:
--------------------------------------------------------------------------------
1 | interface Point {
2 | x: number;
3 | y: number;
4 | }
5 |
6 | interface LatLon {
7 | latitude: number;
8 | longitude: number;
9 | }
10 |
11 | interface ReplaceMapping {
12 | [x: string]: string;
13 | }
14 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2020",
4 | "allowJs": true,
5 | "module": "ESNext",
6 | "moduleResolution": "Node",
7 | "esModuleInterop": true
8 | },
9 | "include": ["lib"]
10 | }
11 |
--------------------------------------------------------------------------------
/scss/modules/font/_font.scss:
--------------------------------------------------------------------------------
1 | @use "../../mixins/font";
2 | @use "../variables";
3 |
4 | @if variables.$use-included-font == 1 {
5 | @include font.load-font("Assistant", "Light", 300, normal);
6 | @include font.load-font("Assistant", "Bold", 700, normal);
7 | }
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Generic cache files
2 | *~
3 | .~*
4 | *.tmp
5 | *.temp
6 | *.DS_Store
7 | .*.swp
8 | *.out
9 | *.cache
10 | Thumbs.db
11 |
12 | # IDE files
13 | /.idea
14 |
15 | # Project files
16 | /node_modules
17 | /build
18 | *.zip
19 | /dev-dist
20 | /public/config.json
21 |
--------------------------------------------------------------------------------
/lib/global.d.ts:
--------------------------------------------------------------------------------
1 | import { Config } from "./config_default.js";
2 | import { Router } from "./utils/router.js";
3 |
4 | export {};
5 |
6 | declare global {
7 | const __APP_VERSION__: string;
8 |
9 | interface Window {
10 | config: Config;
11 | router: Router;
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/scss/modules/_forcegraph.scss:
--------------------------------------------------------------------------------
1 | @use "variables";
2 |
3 | .graph {
4 | background: variables.$color-gray-dark;
5 | font: variables.$font-size-small variables.$font-family;
6 | height: 100%;
7 | width: 100%;
8 |
9 | canvas {
10 | display: block;
11 | position: absolute;
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/.stylelintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "stylelint-config-standard",
3 | "rules": {
4 | "at-rule-no-unknown": [
5 | true,
6 | {
7 | "ignoreAtRules": ["function", "if", "each", "include", "mixin"]
8 | }
9 | ],
10 | "number-leading-zero": "never",
11 | "no-descending-specificity": null
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/.github/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | ## Contributing is welcome
2 |
3 | Pull requests are welcome without the need of opening an issue. If you're unsure
4 | about your feature or your implementation, open an issue and discuss your
5 | suggested changes. Meshviewer is a frontend application and the code needs to be
6 | loaded fast and perform with many nodes and clients on slow mobile devices.
7 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig is awesome: http://EditorConfig.org
2 |
3 | # top-most EditorConfig file
4 | root = true
5 |
6 | [*]
7 | end_of_line = lf
8 | insert_final_newline = true
9 | charset = utf-8
10 |
11 | # Get rid of whitespace to avoid diffs with a bunch of EOL changes
12 | trim_trailing_whitespace = true
13 |
14 | [*.{js,html,scss,json,yml,md}]
15 | indent_size = 2
16 | indent_style = space
17 |
18 |
19 | [assets/favicon/manifest.json]
20 | indent_size = 4
21 |
--------------------------------------------------------------------------------
/scss/custom/_custom.scss:
--------------------------------------------------------------------------------
1 | // Example of overwriting variables. Take a look at modules/variables
2 | // .node-links {
3 | // color: $color-primary;
4 | // }
5 |
6 | // You can also include additional files for style example https://github.com/ffrgb/meshviewer/tree/ffrgb-config/scss/custom
7 | // Include syntax: @include "name" -> Filename: _name.scss
8 |
9 | // SCSS supports css with a lot of additional features like variables or mixins.
10 | // Autoprefixer runs in postcss, no need to add browser-prefixes like -webkit, -moz or -ms
11 |
--------------------------------------------------------------------------------
/scss/mixins/_font.scss:
--------------------------------------------------------------------------------
1 | @mixin load-font($name, $type, $weight, $style, $alias: "") {
2 | @if $alias == "" {
3 | $alias: $name;
4 | }
5 |
6 | @font-face {
7 | font-family: "#{$alias}";
8 | font-style: $style;
9 | font-weight: $weight;
10 | src:
11 | local("#{$name} #{$type}"),
12 | local("#{$name}-#{$type}"),
13 | url("@fonts/#{$name}-#{$type}.woff2") format("woff2"),
14 | url("@fonts/#{$name}-#{$type}.woff") format("woff"),
15 | url("@fonts/#{$name}-#{$type}.ttf") format("truetype");
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/scss/modules/_proportion.scss:
--------------------------------------------------------------------------------
1 | @use "variables";
2 |
3 | .proportion-header {
4 | cursor: pointer;
5 | }
6 |
7 | .proportion {
8 | th {
9 | font-size: 0.95em;
10 | font-weight: normal;
11 | padding-right: 0.71em;
12 | text-align: right;
13 | }
14 |
15 | td {
16 | text-align: left;
17 | }
18 |
19 | span {
20 | box-sizing: border-box;
21 | color: variables.$color-white;
22 | display: inline-block;
23 | font-weight: bold;
24 | min-width: 1.5em;
25 | padding: 0.25em 0.5em;
26 | }
27 |
28 | a {
29 | cursor: pointer;
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/.github/workflows/build-meshviewer.yml:
--------------------------------------------------------------------------------
1 | name: Build Meshviewer
2 | on:
3 | push:
4 | branches:
5 | - main
6 | pull_request:
7 | types: [opened, synchronize, reopened]
8 | jobs:
9 | meshviewer-build:
10 | runs-on: ubuntu-latest
11 | strategy:
12 | matrix:
13 | node-version: [24.x]
14 | steps:
15 | - uses: actions/checkout@v6
16 | - name: Use Node.js ${{ matrix.node-version }}
17 | uses: actions/setup-node@v6
18 | with:
19 | node-version: ${{ matrix.node-version }}
20 | - run: npm install
21 | - run: npm audit
22 | - run: npm run lint
23 | - run: npm run build
24 |
--------------------------------------------------------------------------------
/lib/container.ts:
--------------------------------------------------------------------------------
1 | export interface CanRender {
2 | render: (element: HTMLElement) => any;
3 | }
4 |
5 | export interface CanAdd {
6 | add: (element: CanRender) => any;
7 | }
8 |
9 | export const Container = function (tag?: string): CanRender & CanAdd {
10 | if (!tag) {
11 | tag = "div";
12 | }
13 |
14 | const self = {
15 | add: undefined,
16 | render: undefined,
17 | };
18 |
19 | let container = document.createElement(tag);
20 |
21 | self.add = function add(d: CanRender) {
22 | d.render(container);
23 | };
24 |
25 | self.render = function render(el: HTMLElement) {
26 | el.appendChild(container);
27 | };
28 |
29 | return self;
30 | };
31 |
--------------------------------------------------------------------------------
/lib/filters/nodefilter.ts:
--------------------------------------------------------------------------------
1 | import { FilterMethod, ObjectsLinksAndNodes } from "../datadistributor.js";
2 |
3 | export const NodeFilter = function (filter: FilterMethod) {
4 | return function (data: ObjectsLinksAndNodes) {
5 | let node: ObjectsLinksAndNodes = Object.create(data);
6 | node.nodes = { all: [], lost: [], new: [], offline: [], online: [] };
7 |
8 | for (let key in data.nodes) {
9 | if (data.nodes.hasOwnProperty(key)) {
10 | node.nodes[key] = data.nodes[key].filter(filter);
11 | }
12 | }
13 |
14 | node.links = data.links.filter(function (d) {
15 | return filter(d.source) && filter(d.target);
16 | });
17 |
18 | return node;
19 | };
20 | };
21 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | ### Build stage for the website frontend
2 | FROM --platform=$BUILDPLATFORM node:25-trixie-slim AS build
3 | RUN apt-get update && apt-get install -y python3 --no-install-recommends && \
4 | apt-get clean && rm -rf /var/lib/apt/lists/*
5 |
6 |
7 | WORKDIR /code
8 | # Copy only dependency files first for better caching
9 | COPY package*.json ./
10 |
11 | # Install production dependencies only
12 | RUN npm ci --no-audit --prefer-offline
13 |
14 | # Copy the rest of the application files
15 | COPY . .
16 |
17 | RUN npm run lint && npm run build
18 |
19 | FROM nginx:1.29.3-alpine
20 | COPY --from=build /code/build/ /usr/share/nginx/html
21 | COPY --from=build /code/config.example.json /usr/share/nginx/html/
22 | EXPOSE 80
23 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # Please see the documentation for all configuration options:
2 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
3 |
4 | version: 2
5 | updates:
6 | - package-ecosystem: "github-actions"
7 | directory: "/"
8 | schedule:
9 | # Check for updates to GitHub Actions every weekday
10 | interval: "monthly"
11 | commit-message:
12 | prefix: "actions"
13 | - package-ecosystem: "npm"
14 | directory: "/"
15 | schedule:
16 | interval: "monthly"
17 | commit-message:
18 | prefix: "npm"
19 | - package-ecosystem: "docker"
20 | directory: "/"
21 | schedule:
22 | interval: "monthly"
23 | commit-message:
24 | prefix: "docker"
25 |
--------------------------------------------------------------------------------
/scss/main.scss:
--------------------------------------------------------------------------------
1 | // Set variables
2 | @use "modules/variables";
3 | @use "custom/variables" as variables2;
4 |
5 | // Mixins
6 | @use "mixins/icon";
7 | @use "mixins/font";
8 |
9 | // Add modules
10 | @use "modules/reset";
11 | @use "modules/font/font" as font2;
12 | @use "modules/base";
13 | @use "modules/font/icon" as icon2;
14 | @use "modules/loader";
15 | @use "modules/leaflet";
16 | @use "modules/table";
17 | @use "modules/filter";
18 | @use "modules/sidebar";
19 | @use "modules/map";
20 | @use "modules/forcegraph";
21 | @use "modules/legend";
22 | @use "modules/proportion";
23 | @use "modules/tabs";
24 | @use "modules/node";
25 | @use "modules/infobox";
26 | @use "modules/button";
27 |
28 | // Make adjustments in custom scss
29 | @use "custom/custom";
30 | @use "night";
31 |
--------------------------------------------------------------------------------
/lib/load.ts:
--------------------------------------------------------------------------------
1 | import { config as defaultConfig } from "./config_default.js";
2 | import { main } from "./main.js";
3 |
4 | export const load = async () => {
5 | const configResponse = await fetch("config.json");
6 | if (!configResponse.ok) {
7 | document.querySelector(".loader").innerHTML =
8 | "config.json can not be loaded:" +
9 | " " +
10 | configResponse.statusText +
11 | " " +
12 | '' +
13 | "Try to reload" +
14 | " " +
15 | "or report to your community";
16 | return;
17 | }
18 | const config = await configResponse.json();
19 | globalThis.config = Object.assign(defaultConfig, config);
20 | main();
21 | };
22 |
--------------------------------------------------------------------------------
/scss/modules/_node.scss:
--------------------------------------------------------------------------------
1 | @use "sass:color";
2 | @use "variables";
3 |
4 | .bar {
5 | background: color.mix(variables.$color-new, variables.$color-white, 60%);
6 | display: block;
7 | height: 1.4em;
8 | position: relative;
9 |
10 | &.warning {
11 | background: color.mix(variables.$color-offline, variables.$color-white, 60%);
12 |
13 | span {
14 | background: variables.$color-offline;
15 | }
16 | }
17 |
18 | span {
19 | background: variables.$color-new;
20 | display: inline-block;
21 | height: 1.4em;
22 | max-width: 100%;
23 | }
24 |
25 | label {
26 | color: variables.$color-white;
27 | font-weight: bold;
28 | position: absolute;
29 | right: 0.5em;
30 | top: 0.1em;
31 | white-space: nowrap;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/scss/modules/_tabs.scss:
--------------------------------------------------------------------------------
1 | @use "sass:color";
2 | @use "variables";
3 |
4 | .tabs {
5 | background: color.adjust(variables.$color-black, $alpha: -0.98);
6 | border: 0 solid color.adjust(variables.$color-white, $lightness: -10%);
7 | border-bottom-width: 1px;
8 | display: flex;
9 | display: -webkit-flex;
10 | list-style: none;
11 | margin: 0;
12 | padding: 0;
13 |
14 | li {
15 | -webkit-flex: 1 1 auto;
16 | color: color.adjust(variables.$color-black, $alpha: -0.5);
17 | cursor: pointer;
18 | flex: 1 1 auto;
19 | padding: 1.3em 0.5em 1em;
20 | text-align: center;
21 | text-transform: uppercase;
22 |
23 | &:hover {
24 | color: variables.$color-black;
25 | }
26 | }
27 |
28 | .visible {
29 | border-bottom: 2px solid variables.$color-primary;
30 | color: variables.$color-primary;
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | ---
5 |
6 |
7 |
8 |
9 |
10 | ## Is your feature request related to a problem? Please describe.
11 |
12 |
13 |
14 | ## Describe the solution you'd like
15 |
16 |
17 |
18 | ## Describe alternatives you've considered
19 |
20 |
21 |
22 | ## Additional context
23 |
24 |
25 |
--------------------------------------------------------------------------------
/lib/utils/math.ts:
--------------------------------------------------------------------------------
1 | const self = {
2 | distance: undefined,
3 | distancePoint: undefined,
4 | distanceLink: undefined,
5 | };
6 |
7 | self.distance = function distance(a: Point, b: Point) {
8 | return (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y);
9 | };
10 |
11 | self.distancePoint = function distancePoint(a: Point, b: Point) {
12 | return Math.sqrt(self.distance(a, b));
13 | };
14 |
15 | self.distanceLink = function distanceLink(p: Point, a: Point, b: Point) {
16 | /* http://stackoverflow.com/questions/849211 */
17 | let l2 = self.distance(a, b);
18 | if (l2 === 0) {
19 | return self.distance(p, a);
20 | }
21 | let t = ((p.x - a.x) * (b.x - a.x) + (p.y - a.y) * (b.y - a.y)) / l2;
22 | if (t < 0) {
23 | return self.distance(p, a);
24 | } else if (t > 1) {
25 | return self.distance(p, b);
26 | }
27 | return self.distancePoint(p, {
28 | x: a.x + t * (b.x - a.x),
29 | y: a.y + t * (b.y - a.y),
30 | });
31 | };
32 |
33 | export default self;
34 |
--------------------------------------------------------------------------------
/lib/title.ts:
--------------------------------------------------------------------------------
1 | import { Link, Node } from "./utils/node.js";
2 |
3 | export const Title = function () {
4 | const self = {
5 | resetView: undefined,
6 | gotoNode: undefined,
7 | gotoLink: undefined,
8 | gotoLocation: undefined,
9 | destroy: undefined,
10 | };
11 |
12 | function setTitle(addedTitle?: string) {
13 | let config = window.config;
14 | let title = [config.siteName];
15 |
16 | if (addedTitle !== undefined) {
17 | title.unshift(addedTitle);
18 | }
19 |
20 | document.title = title.join(" - ");
21 | }
22 |
23 | self.resetView = function resetView() {
24 | setTitle();
25 | };
26 |
27 | self.gotoNode = function gotoNode(node: Node) {
28 | setTitle(node.hostname);
29 | };
30 |
31 | self.gotoLink = function gotoLink(link: Link[]) {
32 | setTitle(link[0].source.hostname + " \u21D4 " + link[0].target.hostname);
33 | };
34 |
35 | self.gotoLocation = function gotoLocation() {
36 | // ignore
37 | };
38 |
39 | self.destroy = function destroy() {};
40 |
41 | return self;
42 | };
43 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { defineConfig, globalIgnores } from "eslint/config";
2 | import globals from "globals";
3 | import path from "node:path";
4 | import { fileURLToPath } from "node:url";
5 | import js from "@eslint/js";
6 | import { FlatCompat } from "@eslint/eslintrc";
7 |
8 | const __filename = fileURLToPath(import.meta.url);
9 | const __dirname = path.dirname(__filename);
10 | const compat = new FlatCompat({
11 | baseDirectory: __dirname,
12 | recommendedConfig: js.configs.recommended,
13 | allConfig: js.configs.all,
14 | });
15 |
16 | export default defineConfig([
17 | globalIgnores(["build/**", "dev-dist/**"]),
18 | {
19 | extends: compat.extends("eslint:recommended", "prettier"),
20 |
21 | languageOptions: {
22 | globals: {
23 | ...globals.browser,
24 | ...globals.node,
25 | },
26 |
27 | ecmaVersion: 2020,
28 | sourceType: "module",
29 | },
30 |
31 | rules: {
32 | "no-undef": "off",
33 | "no-prototype-builtins": "off",
34 | "no-useless-escape": "off",
35 | },
36 | },
37 | ]);
38 |
--------------------------------------------------------------------------------
/scss/modules/_infobox.scss:
--------------------------------------------------------------------------------
1 | @use "variables";
2 |
3 | .infobox {
4 | .clients,
5 | .gateway {
6 | display: flex;
7 | flex-flow: wrap;
8 |
9 | span {
10 | flex-grow: 1;
11 | text-align: center;
12 | }
13 |
14 | .ion-people,
15 | .ion-arrow-right-c {
16 | font-size: 1.5em;
17 | }
18 | }
19 |
20 | .node-links {
21 | table-layout: fixed;
22 |
23 | th,
24 | td {
25 | &:nth-child(3),
26 | &:nth-child(5) {
27 | width: 12%;
28 | }
29 | }
30 | }
31 |
32 | input,
33 | textarea {
34 | border: 1px solid variables.$color-gray-light;
35 | font-family: variables.$font-family-monospace;
36 | font-size: 1.15em;
37 | line-height: 1.67em;
38 | margin-right: 0.7em;
39 | max-width: 500px;
40 | min-height: 42px;
41 | padding: 3px 6px;
42 | vertical-align: bottom;
43 | width: calc(100% - 80px);
44 | }
45 |
46 | textarea {
47 | font-size: 0.8em;
48 | height: 100px;
49 | max-height: 300px;
50 | overflow: auto;
51 | resize: vertical;
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/lib/map/locationmarker.js:
--------------------------------------------------------------------------------
1 | import * as L from "leaflet";
2 |
3 | export const LocationMarker = L.CircleMarker.extend({
4 | initialize: function (latlng) {
5 | let config = window.config;
6 | this.accuracyCircle = L.circle(latlng, 0, config.locate.accuracyCircle);
7 | this.outerCircle = L.circleMarker(latlng, config.locate.outerCircle);
8 | L.CircleMarker.prototype.initialize.call(this, latlng, config.locate.innerCircle);
9 |
10 | this.on("remove", function () {
11 | this._map.removeLayer(this.accuracyCircle);
12 | this._map.removeLayer(this.outerCircle);
13 | });
14 | },
15 |
16 | setLatLng: function (latlng) {
17 | this.accuracyCircle.setLatLng(latlng);
18 | this.outerCircle.setLatLng(latlng);
19 | L.CircleMarker.prototype.setLatLng.call(this, latlng);
20 | },
21 |
22 | setAccuracy: function (accuracy) {
23 | this.accuracyCircle.setRadius(accuracy);
24 | },
25 |
26 | onAdd: function (map) {
27 | this.accuracyCircle.addTo(map).bringToBack();
28 | this.outerCircle.addTo(map);
29 | L.CircleMarker.prototype.onAdd.call(this, map);
30 | },
31 | });
32 |
--------------------------------------------------------------------------------
/offline.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Meshviewer Loading
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | You are Offline!
19 |
20 |
21 | No connection available.
22 |
23 |
24 | Try to reload
25 |
26 |
27 | JavaScript required
28 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/.github/workflows/release-meshviewer.yml:
--------------------------------------------------------------------------------
1 | name: Release Meshviewer
2 | on:
3 | push:
4 | # Sequence of patterns matched against refs/tags
5 | tags:
6 | - "v*" # Push events to matching v*, i.e. v1.0, v20.15.10
7 | jobs:
8 | meshviewer-release:
9 | runs-on: ubuntu-latest
10 | strategy:
11 | matrix:
12 | node-version: [24.x]
13 | steps:
14 | - uses: actions/checkout@v6
15 | - name: Use Node.js ${{ matrix.node-version }}
16 | uses: actions/setup-node@v6
17 | with:
18 | node-version: ${{ matrix.node-version }}
19 | - run: npm install
20 | - run: npm run build
21 | - run: "cd build; zip -r ../meshviewer-build.zip .; cd .."
22 | - name: Create and Upload Release Asset
23 | id: create_release
24 | uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
25 | env:
26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
27 | with:
28 | tag_name: ${{ github.ref }}
29 | name: Release ${{ github.ref }}
30 | draft: true
31 | prerelease: false
32 | files: |
33 | ./meshviewer-build.zip
34 | generate_release_notes: true
35 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | ## Description
5 |
6 |
7 |
8 | ## Motivation and Context
9 |
10 |
11 |
12 |
13 | ## How Has This Been Tested?
14 |
15 |
16 |
17 | ## Screenshots/links:
18 |
19 |
20 |
21 |
22 | ## Checklist:
23 |
24 |
25 |
26 |
27 | - [ ] My code follows the code style of this project. (CI will test it anyway and also needs approval)
28 | - [ ] My change requires a change to the documentation.
29 | - [ ] I have updated the documentation accordingly.
30 |
--------------------------------------------------------------------------------
/lib/filters/hostname.ts:
--------------------------------------------------------------------------------
1 | import { _ } from "../utils/language.js";
2 | import { Node } from "../utils/node.js";
3 | import { CanRender } from "../container.js";
4 | import { Filter } from "../datadistributor.js";
5 |
6 | export const HostnameFilter = function (): CanRender & Filter {
7 | let refreshFunctions: (() => any)[] = [];
8 | let timer: ReturnType;
9 | let input = document.createElement("input");
10 |
11 | function refresh() {
12 | clearTimeout(timer);
13 | timer = setTimeout(function () {
14 | refreshFunctions.forEach(function (f) {
15 | f();
16 | });
17 | }, 250);
18 | }
19 |
20 | function run(node: Node) {
21 | return node.hostname.toLowerCase().includes(input.value.toLowerCase());
22 | }
23 |
24 | function setRefresh(f: () => any) {
25 | refreshFunctions.push(f);
26 | }
27 |
28 | function render(el: HTMLElement) {
29 | input.type = "search";
30 | input.placeholder = _.t("sidebar.nodeFilter");
31 | input.setAttribute("aria-label", _.t("sidebar.nodeFilter"));
32 | input.addEventListener("input", refresh);
33 | el.classList.add("filter-node");
34 | el.classList.add("ion-filter");
35 | el.appendChild(input);
36 | }
37 |
38 | return {
39 | run,
40 | setRefresh,
41 | render,
42 | };
43 | };
44 |
--------------------------------------------------------------------------------
/scss/modules/_reset.scss:
--------------------------------------------------------------------------------
1 | // Eric Meyer's Reset CSS v2.0 (http://meyerweb.com/eric/tools/css/reset/)
2 | // http://cssreset.com
3 | html,
4 | body,
5 | div,
6 | span,
7 | applet,
8 | object,
9 | iframe,
10 | h1,
11 | h2,
12 | h3,
13 | h4,
14 | h5,
15 | h6,
16 | p,
17 | blockquote,
18 | pre,
19 | a,
20 | abbr,
21 | acronym,
22 | address,
23 | big,
24 | cite,
25 | code,
26 | del,
27 | dfn,
28 | em,
29 | img,
30 | ins,
31 | kbd,
32 | q,
33 | s,
34 | samp,
35 | small,
36 | strike,
37 | strong,
38 | sub,
39 | sup,
40 | tt,
41 | var,
42 | b,
43 | u,
44 | i,
45 | center,
46 | dl,
47 | dt,
48 | dd,
49 | ol,
50 | ul,
51 | li,
52 | fieldset,
53 | form,
54 | label,
55 | legend,
56 | table,
57 | caption,
58 | tbody,
59 | tfoot,
60 | thead,
61 | tr,
62 | th,
63 | td,
64 | article,
65 | aside,
66 | canvas,
67 | details,
68 | embed,
69 | figure,
70 | figcaption,
71 | footer,
72 | header,
73 | menu,
74 | nav,
75 | output,
76 | ruby,
77 | section,
78 | summary,
79 | time,
80 | mark,
81 | audio,
82 | video {
83 | border: 0;
84 | font: inherit;
85 | font-size: 100%;
86 | margin: 0;
87 | padding: 0;
88 | vertical-align: baseline;
89 | }
90 |
91 | body {
92 | line-height: 1;
93 | }
94 |
95 | ol,
96 | ul {
97 | list-style: none;
98 | }
99 |
100 | blockquote,
101 | q {
102 | quotes: none;
103 | }
104 |
105 | table {
106 | border-collapse: collapse;
107 | border-spacing: 0;
108 | }
109 |
--------------------------------------------------------------------------------
/lib/filters/filtergui.ts:
--------------------------------------------------------------------------------
1 | import { _ } from "../utils/language.js";
2 | import { CanFiltersChanged, DataDistributor, Filter } from "../datadistributor.js";
3 | import { CanRender } from "../container.js";
4 |
5 | export const FilterGui = function (distributor: ReturnType): CanFiltersChanged & CanRender {
6 | let container = document.createElement("ul");
7 | container.classList.add("filters");
8 | let div = document.createElement("div");
9 |
10 | function render(el: HTMLElement) {
11 | el.appendChild(div);
12 | }
13 |
14 | function filtersChanged(filters: Filter[] & CanRender[]) {
15 | while (container.firstChild) {
16 | container.removeChild(container.firstChild);
17 | }
18 |
19 | filters.forEach(function (filter: Filter & CanRender) {
20 | let li = document.createElement("li");
21 | container.appendChild(li);
22 | filter.render(li);
23 |
24 | let button = document.createElement("button");
25 | button.classList.add("ion-close");
26 | button.setAttribute("aria-label", _.t("remove"));
27 | button.onclick = function onclick() {
28 | distributor.removeFilter(filter);
29 | };
30 | li.appendChild(button);
31 | });
32 |
33 | if (container.parentNode === div && filters.length === 0) {
34 | div.removeChild(container);
35 | } else if (filters.length > 0) {
36 | div.appendChild(container);
37 | }
38 | }
39 |
40 | return {
41 | render,
42 | filtersChanged,
43 | };
44 | };
45 |
--------------------------------------------------------------------------------
/scss/modules/_map.scss:
--------------------------------------------------------------------------------
1 | @use "sass:map";
2 | @use "variables";
3 |
4 | .content {
5 | height: 100vh;
6 | position: fixed;
7 | width: 100%;
8 |
9 | .buttons {
10 | direction: rtl;
11 | position: absolute;
12 | right: variables.$button-distance;
13 | top: variables.$button-distance;
14 | unicode-bidi: bidi-override;
15 | z-index: 1001;
16 |
17 | button {
18 | margin-left: variables.$button-distance;
19 | }
20 |
21 | @media screen and (max-width: map.get(variables.$grid-breakpoints, lg) - 1) {
22 | right: 0.1rem;
23 | top: 0;
24 | transform: scale(0.8);
25 | transform-origin: right;
26 | }
27 | }
28 |
29 | @media screen and (max-width: map.get(variables.$grid-breakpoints, lg) - 1) {
30 | height: 80vh;
31 | min-height: 240px;
32 | position: relative;
33 | width: auto;
34 | }
35 |
36 | @media all and (device-height: 1024px) and (orientation: portrait) {
37 | height: 800px;
38 | }
39 |
40 | @media all and (device-width: 768px) and (orientation: landscape) {
41 | height: 768px;
42 | }
43 |
44 | @media only screen and (device-height: 568px) and (orientation: portrait) {
45 | height: 320px;
46 | }
47 |
48 | @media only screen and (device-width: 320px) and (orientation: landscape) {
49 | height: 240px;
50 | }
51 | }
52 |
53 | .stroke-first {
54 | paint-order: stroke;
55 | }
56 |
57 | .pick-coordinates {
58 | cursor: crosshair;
59 | }
60 |
61 | .map {
62 | height: 100%;
63 | width: 100%;
64 | }
65 |
--------------------------------------------------------------------------------
/.github/workflows/build-docker.yml:
--------------------------------------------------------------------------------
1 | name: Build Docker image
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | types: [opened, synchronize, reopened]
9 |
10 | env:
11 | REGISTRY: ghcr.io
12 | IMAGE_NAME: ${{ github.repository }}
13 |
14 | jobs:
15 | build:
16 | runs-on: ubuntu-latest
17 | steps:
18 | - name: Checkout
19 | uses: actions/checkout@v6
20 | - name: Setup QEMU
21 | uses: docker/setup-qemu-action@v3
22 | - name: Setup Docker buildx
23 | uses: docker/setup-buildx-action@v3
24 | - name: Retrieve author data
25 | run: |
26 | echo AUTHOR=$(curl -sSL ${{ github.event.repository.owner.url }} | jq -r '.name') >> $GITHUB_ENV
27 | - name: Extract metadata (tags, labels) for Docker
28 | id: meta
29 | uses: docker/metadata-action@v5
30 | with:
31 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
32 | labels: |
33 | org.opencontainers.image.authors=${{ env.AUTHOR }}
34 | - name: Build Docker image
35 | uses: docker/build-push-action@v6
36 | with:
37 | context: .
38 | platforms: linux/amd64
39 | push: false
40 | load: true
41 | cache-from: type=gha
42 | cache-to: type=gha,mode=max
43 | tags: ${{ steps.meta.outputs.tags }}
44 | labels: ${{ steps.meta.outputs.labels }}
45 |
46 | - name: Inspect Docker image
47 | run: docker image inspect ${{ steps.meta.outputs.tags }}
48 |
--------------------------------------------------------------------------------
/scss/modules/_filter.scss:
--------------------------------------------------------------------------------
1 | @use "sass:color";
2 | @use "variables";
3 |
4 | .filters {
5 | display: flex;
6 | flex-wrap: wrap;
7 | font-size: 0.83em;
8 | padding: 0 8px 8px;
9 |
10 | li {
11 | align-items: center;
12 | background: transparent;
13 | border: 1px solid variables.$color-primary;
14 | color: variables.$color-primary;
15 | display: flex;
16 | margin: 4px;
17 | padding: 0 0 0 10px;
18 |
19 | label {
20 | cursor: pointer;
21 | }
22 |
23 | button {
24 | background: none;
25 | color: variables.$color-gray-light;
26 | font-size: variables.$font-size-small;
27 | height: 24px;
28 | margin: 3px;
29 | width: 24px;
30 |
31 | &:hover {
32 | color: variables.$color-primary;
33 | }
34 | }
35 |
36 | &.not {
37 | label {
38 | color: variables.$color-primary;
39 | text-decoration: line-through;
40 | }
41 | }
42 | }
43 |
44 | .filter-node {
45 | border: 0;
46 | padding: 0 5px;
47 | width: 100%;
48 |
49 | &::before {
50 | font-size: 1.8em;
51 | }
52 |
53 | input {
54 | background: transparent;
55 | border: 0;
56 | border-bottom: 1px solid variables.$color-primary;
57 | font-family: variables.$font-family;
58 | font-size: variables.$font-size;
59 | margin: 0 15px 0 3px;
60 | outline: none;
61 | padding: 0 2px;
62 | width: 100%;
63 |
64 | &:focus {
65 | background: color.adjust(variables.$color-primary, $alpha: -0.95);
66 | }
67 | }
68 |
69 | button {
70 | display: none;
71 | }
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/scss/modules/_table.scss:
--------------------------------------------------------------------------------
1 | @use "variables";
2 |
3 | table {
4 | border-collapse: separate;
5 | border-spacing: 0 0.5em;
6 | padding: 0 variables.$button-distance;
7 | width: 100%;
8 |
9 | &.attributes {
10 | line-height: 1.41em;
11 |
12 | th {
13 | font-weight: bold;
14 | padding-right: 1em;
15 | text-align: left;
16 | vertical-align: top;
17 | white-space: nowrap;
18 | }
19 |
20 | td {
21 | text-align: left;
22 | width: 100%;
23 | }
24 | }
25 | }
26 |
27 | tr {
28 | &.header {
29 | font-size: 1.2em;
30 |
31 | th {
32 | padding-top: 1em;
33 | }
34 | }
35 | }
36 |
37 | td,
38 | th {
39 | line-height: 1.41em;
40 | text-align: right;
41 |
42 | &:first-child {
43 | text-align: left;
44 | }
45 | }
46 |
47 | th {
48 | font-weight: bold;
49 |
50 | &[class*=" ion-"] {
51 | &::before {
52 | font-size: 1.3em;
53 | }
54 | }
55 |
56 | &.sort-header {
57 | cursor: pointer;
58 |
59 | &::selection {
60 | background: transparent;
61 | }
62 |
63 | &::after {
64 | content: "\f10d";
65 | font-family: variables.$font-family-icons;
66 | padding-left: 0.25em;
67 | visibility: hidden;
68 | }
69 |
70 | &:hover {
71 | &::after {
72 | visibility: visible;
73 | }
74 | }
75 | }
76 |
77 | &.sort-up {
78 | &::after {
79 | content: "\f104";
80 | }
81 | }
82 |
83 | &.sort-up,
84 | &.sort-down {
85 | &::after {
86 | opacity: 0.4;
87 | visibility: visible;
88 | }
89 | }
90 | }
91 |
92 | .tab {
93 | table {
94 | table-layout: fixed;
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/lib/filters/genericnode.ts:
--------------------------------------------------------------------------------
1 | import * as helper from "../utils/helper.js";
2 | import { Filter } from "../datadistributor.js";
3 | import { CanRender } from "../container.js";
4 | import { Node } from "../utils/node.js";
5 |
6 | export const GenericNodeFilter = function (
7 | name: string,
8 | keys: string[],
9 | value: string,
10 | nodeValueModifier: (a: any) => string,
11 | ): Filter & CanRender {
12 | let negate = false;
13 | let refresh: () => any;
14 |
15 | let label = document.createElement("label");
16 | let strong = document.createElement("strong");
17 | label.textContent = name + ": ";
18 | label.appendChild(strong);
19 |
20 | function run(node: Node) {
21 | let nodeValue = helper.dictGet(node, keys.slice(0));
22 |
23 | if (nodeValueModifier) {
24 | nodeValue = nodeValueModifier(nodeValue);
25 | }
26 |
27 | return nodeValue === value ? !negate : negate;
28 | }
29 |
30 | function setRefresh(f: () => any) {
31 | refresh = f;
32 | }
33 |
34 | function draw(el: HTMLElement) {
35 | if (negate) {
36 | el.classList.add("not");
37 | } else {
38 | el.classList.remove("not");
39 | }
40 |
41 | strong.textContent = value;
42 | }
43 |
44 | function render(el: HTMLElement) {
45 | el.appendChild(label);
46 | draw(el);
47 |
48 | label.onclick = function onclick() {
49 | negate = !negate;
50 |
51 | draw(el);
52 |
53 | if (refresh) {
54 | refresh();
55 | }
56 | };
57 | }
58 |
59 | function getKey() {
60 | return value.concat(name);
61 | }
62 |
63 | return {
64 | run,
65 | setRefresh,
66 | render,
67 | getKey,
68 | };
69 | };
70 |
--------------------------------------------------------------------------------
/lib/tabs.ts:
--------------------------------------------------------------------------------
1 | import { _ } from "./utils/language.js";
2 | import { CanRender } from "./container.js";
3 |
4 | export const Tabs = function () {
5 | const self = {
6 | add: undefined,
7 | render: undefined,
8 | };
9 |
10 | let tabs = document.createElement("ul");
11 | tabs.classList.add("tabs");
12 |
13 | let container = document.createElement("div");
14 |
15 | function gotoTab(li: HTMLLIElement) {
16 | for (let i = 0; i < tabs.children.length; i++) {
17 | tabs.children[i].classList.remove("visible");
18 | }
19 |
20 | while (container.firstChild) {
21 | container.removeChild(container.firstChild);
22 | }
23 |
24 | li.classList.add("visible");
25 |
26 | let tab = document.createElement("div");
27 | tab.classList.add("tab");
28 | container.appendChild(tab);
29 | // @ts-ignore
30 | li.child.render(tab);
31 | }
32 |
33 | function switchTab() {
34 | gotoTab(this);
35 |
36 | return false;
37 | }
38 |
39 | self.add = function add(title: string, child: CanRender) {
40 | let li = document.createElement("li");
41 | li.textContent = _.t(title);
42 | li.onclick = switchTab;
43 | // @ts-ignore
44 | li.child = child;
45 | tabs.appendChild(li);
46 |
47 | let anyVisible = false;
48 |
49 | for (let i = 0; i < tabs.children.length; i++) {
50 | if (tabs.children[i].classList.contains("visible")) {
51 | anyVisible = true;
52 | break;
53 | }
54 | }
55 |
56 | if (!anyVisible) {
57 | gotoTab(li);
58 | }
59 | };
60 |
61 | self.render = function render(el: HTMLElement) {
62 | el.appendChild(tabs);
63 | el.appendChild(container);
64 | };
65 |
66 | return self;
67 | };
68 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
23 |
24 |
25 |
26 | Meshviewer - loading...
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 | Lade
39 |
40 |
41 | Karten & Knoten...
42 |
43 |
44 | JavaScript required
45 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/scss/modules/_variables.scss:
--------------------------------------------------------------------------------
1 | @use "sass:color";
2 | @use "sass:map";
3 | $color-white: #fff !default;
4 | $color-black: #000 !default;
5 |
6 | $color-gray-light: color.adjust($color-white, $lightness: -30%) !default;
7 | $color-gray-dark: color.adjust($color-black, $lightness: 20%) !default;
8 |
9 | $color-primary: #dc0067 !default;
10 |
11 | $color-new: #459c18 !default;
12 | $color-online: #1566a9 !default;
13 | $color-offline: #cf3e2a !default;
14 |
15 | $color-24ghz: $color-primary !default;
16 | $color-5ghz: #e3a619 !default;
17 | $color-others: #0a9c92 !default;
18 |
19 | $color-warning: Orange !default;
20 | $color-error: #c20000 !default;
21 |
22 | $color-map-background: #f8f4f0 !default;
23 |
24 | $font-family: "Assistant", sans-serif !default;
25 | $font-family-icons: ionicons !default;
26 | $font-family-monospace: monospace !default;
27 | $font-size: 15px !default;
28 | $font-size-small: 11px !default;
29 | $font-size-map-control: 12px !default;
30 |
31 | $button-font-size: 1.6rem !default;
32 | $button-distance: 16px !default;
33 |
34 | // Bootstrap breakpoints
35 | // In lib/sidebar to avoid render blocking onload
36 | $grid-breakpoints: (
37 | // Extra small screen / phone
38 | xs: 0,
39 | // Small screen / phone
40 | sm: 544px,
41 | // Medium screen / tablet
42 | md: 768px,
43 | // Large screen / desktop
44 | lg: 992px,
45 | // Extra large screen / wide desktop
46 | xl: 1200px
47 | ) !default;
48 |
49 | // 45% sidebar - based on viewport
50 | // In lib/sidebar to avoid render blocking onload
51 | $sidebar-width: map.get($grid-breakpoints, xl) * 0.45 !default;
52 | $sidebar-width-small: map.get($grid-breakpoints, lg) * 0.45 !default;
53 |
54 | // En/disable included font
55 | $use-included-font: 1 !default;
56 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve the software
4 | ---
5 |
6 |
7 |
8 |
9 |
10 | ## Expected Behavior
11 |
12 |
13 |
14 |
15 | ## Current Behavior
16 |
17 |
18 |
19 |
20 | ## Possible Solution
21 |
22 |
23 |
24 | ## Steps to Reproduce
25 |
26 |
27 |
28 |
29 | 1.
30 | 2.
31 | 3.
32 | 4.
33 |
34 | ## Context
35 |
36 |
37 |
38 |
39 | ## Your Environment
40 |
41 |
42 |
43 | - Version used: ``
44 | - Browser Name and version: ``
45 | - Operating System and version (desktop or mobile): ``
46 | - Link to your project: ``
47 |
48 | ## Screenshots
49 |
50 |
51 |
--------------------------------------------------------------------------------
/scss/modules/_legend.scss:
--------------------------------------------------------------------------------
1 | @use "sass:color";
2 | @use "variables";
3 |
4 | header {
5 | h1 {
6 | display: inline-block;
7 | }
8 | }
9 |
10 | .language-switch {
11 | background: transparent;
12 | border: 0;
13 | color: variables.$color-black;
14 | float: right;
15 | margin: 20px 16px 0 0;
16 |
17 | option {
18 | background: variables.$color-white;
19 | }
20 | }
21 |
22 | .legend {
23 | a {
24 | margin-right: 10px;
25 | }
26 |
27 | span {
28 | &:not(:first-child) {
29 | margin-left: 1em;
30 | }
31 | }
32 | }
33 |
34 | .symbol {
35 | border-radius: 50%;
36 | display: inline-block;
37 | height: 1em;
38 | vertical-align: -5%;
39 | width: 1em;
40 | }
41 |
42 | // Dot looks compared to thin font a bit darker - lighten it 10%
43 | .legend-new {
44 | .symbol {
45 | background-color: color.adjust(variables.$color-new, $lightness: 10%);
46 | }
47 | }
48 |
49 | .legend-online {
50 | .symbol {
51 | background-color: color.adjust(variables.$color-online, $lightness: 10%);
52 | }
53 | }
54 |
55 | .legend-offline {
56 | .symbol {
57 | background-color: color.adjust(variables.$color-offline, $lightness: 10%);
58 | }
59 | }
60 |
61 | .legend-24ghz {
62 | .symbol {
63 | background-color: variables.$color-24ghz;
64 | }
65 | }
66 |
67 | .legend-5ghz {
68 | .symbol {
69 | background-color: variables.$color-5ghz;
70 | }
71 | }
72 |
73 | .legend-uplink {
74 | .symbol {
75 | background-color: color.adjust(variables.$color-online, $lightness: 50%);
76 | border-color: color.adjust(variables.$color-online, $lightness: 10%);
77 | border-style: solid;
78 | border-width: 0.27em;
79 | height: 0.47em;
80 | width: 0.47em;
81 | }
82 | }
83 |
84 | .legend-others {
85 | .symbol {
86 | background-color: variables.$color-others;
87 | }
88 | }
89 |
--------------------------------------------------------------------------------
/.github/workflows/publish-docker.yml:
--------------------------------------------------------------------------------
1 | name: Publish Docker image
2 | on:
3 | push:
4 | branches:
5 | - "main"
6 | tags:
7 | - "v*.*.*"
8 |
9 | env:
10 | REGISTRY: ghcr.io
11 | IMAGE_NAME: ${{ github.repository }}
12 |
13 | jobs:
14 | push_to_registry:
15 | name: Push Docker image to GitHub Packages
16 | runs-on: ubuntu-latest
17 | permissions:
18 | packages: write
19 | contents: read
20 | steps:
21 | - name: Checkout
22 | uses: actions/checkout@v6
23 | - name: Set up QEMU
24 | uses: docker/setup-qemu-action@v3
25 | with:
26 | platforms: all
27 | - name: Set up Docker Buildx
28 | uses: docker/setup-buildx-action@v3
29 | - name: Login to DockerHub
30 | uses: docker/login-action@v3
31 | with:
32 | registry: ${{ env.REGISTRY }}
33 | username: ${{ github.actor }}
34 | password: ${{ secrets.GITHUB_TOKEN }}
35 | - name: Retrieve author data
36 | run: |
37 | echo AUTHOR=$(curl -sSL ${{ github.event.repository.owner.url }} | jq -r '.name') >> $GITHUB_ENV
38 | - name: Extract metadata (tags, labels) for Docker
39 | id: meta
40 | uses: docker/metadata-action@v5
41 | with:
42 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
43 | labels: |
44 | org.opencontainers.image.authors=${{ env.AUTHOR }}
45 | - name: Build container image
46 | uses: docker/build-push-action@v6
47 | with:
48 | context: .
49 | platforms: linux/amd64,linux/arm64/v8,linux/arm/v7,linux/ppc64le,linux/s390x
50 | push: true
51 | cache-from: type=gha
52 | cache-to: type=gha,mode=max
53 | tags: ${{ steps.meta.outputs.tags }}
54 | labels: ${{ steps.meta.outputs.labels }}
55 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import { resolve } from "path";
2 | import { defineConfig } from "vite";
3 | import { checker } from "vite-plugin-checker";
4 | import { VitePWA } from "vite-plugin-pwa";
5 | import pkg from "./package.json";
6 |
7 | export default defineConfig({
8 | base: "./",
9 | resolve: {
10 | alias: {
11 | "@fonts": resolve(__dirname, "assets/fonts"),
12 | },
13 | },
14 | define: {
15 | __APP_VERSION__: JSON.stringify(pkg.version),
16 | },
17 | build: {
18 | outDir: "build",
19 | sourcemap: true,
20 | rollupOptions: {
21 | input: {
22 | index: resolve(__dirname, "index.html"),
23 | offline: resolve(__dirname, "offline.html"),
24 | },
25 | },
26 | },
27 | plugins: [
28 | new VitePWA({
29 | workbox: {
30 | globPatterns: ["**/*.{js,css,html,ico,png,svg,ttf,woff,woff2}"],
31 | navigateFallbackDenylist: [new RegExp(".*\.json")],
32 | },
33 | manifest: {
34 | name: "Meshviewer",
35 | short_name: "Meshviewer",
36 | description:
37 | "Meshviewer is an online visualization app to represent nodes and links on a map for Freifunk open mesh network.",
38 | theme_color: "#ffffff",
39 | icons: [
40 | {
41 | src: "pwa-64x64.png",
42 | sizes: "64x64",
43 | type: "image/png",
44 | },
45 | {
46 | src: "pwa-192x192.png",
47 | sizes: "192x192",
48 | type: "image/png",
49 | },
50 | {
51 | src: "pwa-512x512.png",
52 | sizes: "512x512",
53 | type: "image/png",
54 | },
55 | ],
56 | },
57 | devOptions: {
58 | enabled: true,
59 | },
60 | }),
61 | checker({
62 | // Run TypeScript checks
63 | typescript: true,
64 | }),
65 | ],
66 | });
67 |
--------------------------------------------------------------------------------
/assets/icons/icon.scss:
--------------------------------------------------------------------------------
1 | @use "sass:string";
2 | @use "../../scss/mixins/icon" as icon;
3 | @use "../../scss/modules/variables" as variables;
4 |
5 | // Needed for standalone scss
6 | // @import 'icon-mixin';
7 |
8 | $cache-breaker: string.unique-id();
9 |
10 | @font-face {
11 | font-family: "ionicons";
12 | font-style: normal;
13 | font-weight: normal;
14 | src:
15 | url("@fonts/meshviewer.woff2?rel=#{$cache-breaker}") format("woff2"),
16 | url("@fonts/meshviewer.woff?rel=#{$cache-breaker}") format("woff"),
17 | url("@fonts/meshviewer.ttf?rel=#{$cache-breaker}") format("truetype");
18 | }
19 |
20 | [class^="ion-"],
21 | [class*=" ion-"] {
22 | &::before {
23 | display: inline-block;
24 | font-family: variables.$font-family-icons;
25 | font-style: normal;
26 | font-variant: normal;
27 | font-weight: normal;
28 | line-height: 1;
29 | speak: none;
30 | text-rendering: auto;
31 | text-transform: none;
32 | vertical-align: 0;
33 | }
34 | }
35 |
36 | @include icon.icon("chevron-left", "\f124");
37 | @include icon.icon("chevron-right", "\f125");
38 | @include icon.icon("pin", "\f3a3");
39 | @include icon.icon("wifi", "\f25c");
40 | @include icon.icon("eye", "\f133");
41 | @include icon.icon("up-b", "\f10d");
42 | @include icon.icon("down-b", "\f104");
43 | @include icon.icon("locate", "\f2e9");
44 | @include icon.icon("close", "\f2d7");
45 | @include icon.icon("location", "\f456");
46 | @include icon.icon("layer", "\f229");
47 | @include icon.icon("filter", "\f38B");
48 | @include icon.icon("connection-bars", "\f274");
49 | @include icon.icon("share-alt", "\f3ac");
50 | @include icon.icon("clipboard", "\f376");
51 | @include icon.icon("people", "\f39e");
52 | @include icon.icon("person", "\f3a0");
53 | @include icon.icon("time", "\f3b3");
54 | @include icon.icon("arrow-resize", "\f264");
55 | @include icon.icon("arrow-left-c", "\f108");
56 | @include icon.icon("arrow-right-c", "\f10b");
57 | @include icon.icon("full-enter", "\e901");
58 | @include icon.icon("full-exit", "\e900");
59 |
--------------------------------------------------------------------------------
/lib/sidebar.ts:
--------------------------------------------------------------------------------
1 | import { _ } from "./utils/language.js";
2 | import { CanRender } from "./container.js";
3 |
4 | export const Sidebar = function (el: HTMLElement) {
5 | const self = {
6 | getWidth: undefined,
7 | add: undefined,
8 | ensureVisible: undefined,
9 | hide: undefined,
10 | reveal: undefined,
11 | container: undefined,
12 | button: undefined,
13 | };
14 |
15 | // Needed to avoid render blocking
16 | let gridBreakpoints = {
17 | lg: [992, 446],
18 | xl: [1200, 560],
19 | };
20 |
21 | let sidebar = document.createElement("div");
22 | sidebar.classList.add("sidebar");
23 | el.appendChild(sidebar);
24 |
25 | let button = document.createElement("button");
26 | let visibility = new CustomEvent("visibility");
27 | sidebar.appendChild(button);
28 |
29 | button.classList.add("sidebarhandle");
30 | button.setAttribute("aria-label", _.t("sidebar.toggle"));
31 | button.onclick = function onclick() {
32 | button.dispatchEvent(visibility);
33 | sidebar.classList.toggle("hidden");
34 | };
35 |
36 | let container = document.createElement("div");
37 | container.classList.add("container");
38 | sidebar.appendChild(container);
39 |
40 | self.getWidth = function getWidth() {
41 | if (gridBreakpoints.lg[0] > window.innerWidth || sidebar.classList.contains("hidden")) {
42 | return 0;
43 | } else if (gridBreakpoints.xl[0] > window.innerWidth) {
44 | return gridBreakpoints.lg[1];
45 | }
46 | return gridBreakpoints.xl[1];
47 | };
48 |
49 | self.add = function add(d: CanRender) {
50 | d.render(container);
51 | };
52 |
53 | self.ensureVisible = function ensureVisible() {
54 | sidebar.classList.remove("hidden");
55 | };
56 |
57 | self.hide = function hide() {
58 | container.children[1].classList.add("hide");
59 | container.children[2].classList.add("hide");
60 | };
61 |
62 | self.reveal = function reveal() {
63 | container.children[1].classList.remove("hide");
64 | container.children[2].classList.remove("hide");
65 | };
66 |
67 | self.container = sidebar;
68 | self.button = button;
69 |
70 | return self;
71 | };
72 |
--------------------------------------------------------------------------------
/lib/legend.ts:
--------------------------------------------------------------------------------
1 | import { _ } from "./utils/language.js";
2 | import * as helper from "./utils/helper.js";
3 | import { Language } from "./utils/language.js";
4 | import { ObjectsLinksAndNodes } from "./datadistributor.js";
5 |
6 | export const Legend = function (language: ReturnType) {
7 | const self = {
8 | setData: undefined,
9 | render: undefined,
10 | };
11 | let stats = document.createTextNode("");
12 | let timestamp = document.createTextNode("");
13 |
14 | self.setData = function setData(data: ObjectsLinksAndNodes) {
15 | let totalNodes = Object.keys(data.nodeDict).length;
16 | let totalOnlineNodes = data.nodes.online.length;
17 | let totalClients = helper.sum(
18 | data.nodes.online.map(function (node) {
19 | return node.clients;
20 | }),
21 | );
22 | let totalGateways = helper.sum(
23 | data.nodes.online
24 | .filter(function (node) {
25 | return node.is_gateway;
26 | })
27 | .map(helper.one),
28 | );
29 |
30 | stats.textContent =
31 | _.t("sidebar.nodes", { total: totalNodes, online: totalOnlineNodes }) +
32 | " " +
33 | _.t("sidebar.clients", { smart_count: totalClients }) +
34 | " " +
35 | _.t("sidebar.gateway", { smart_count: totalGateways });
36 |
37 | timestamp.textContent = _.t("sidebar.lastUpdate") + " " + data.timestamp.fromNow();
38 | };
39 |
40 | self.render = function render(el: HTMLElement) {
41 | let config = window.config;
42 | let h1 = document.createElement("h1");
43 | h1.textContent = config.siteName;
44 | el.appendChild(h1);
45 |
46 | language.languageSelect(el);
47 |
48 | let p = document.createElement("p");
49 | p.classList.add("legend");
50 |
51 | p.appendChild(stats);
52 | p.appendChild(document.createElement("br"));
53 | p.appendChild(timestamp);
54 |
55 | if (config.linkList) {
56 | p.appendChild(document.createElement("br"));
57 | config.linkList.forEach(function (link) {
58 | let a = document.createElement("a");
59 | a.innerText = link.title;
60 | a.href = link.href;
61 | p.appendChild(a);
62 | });
63 | }
64 |
65 | el.appendChild(p);
66 | };
67 |
68 | return self;
69 | };
70 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "meshviewer",
3 | "version": "12.7.0",
4 | "license": "AGPL-3.0",
5 | "repository": {
6 | "type": "git",
7 | "url": "https://github.com/freifunk/meshviewer.git"
8 | },
9 | "bugs": {
10 | "url": "https://github.com/freifunk/meshviewer/issues"
11 | },
12 | "type": "module",
13 | "devDependencies": {
14 | "@types/node": "^24.10.1",
15 | "@typescript-eslint/parser": "^8.48.1",
16 | "eslint": "^9.39.1",
17 | "eslint-config-prettier": "^10.1.8",
18 | "prettier": "^3.7.4",
19 | "sass": "^1.94.2",
20 | "typescript": "^5.9.3",
21 | "vite": "^7.1.12",
22 | "vite-plugin-checker": "^0.11.0",
23 | "vite-plugin-pwa": "^1.2.0"
24 | },
25 | "dependencies": {
26 | "@types/d3-collection": "^1.0.13",
27 | "@types/d3-drag": "^3.0.7",
28 | "@types/d3-ease": "^3.0.2",
29 | "@types/d3-force": "^3.0.10",
30 | "@types/d3-interpolate": "^3.0.4",
31 | "@types/d3-selection": "^3.0.11",
32 | "@types/d3-timer": "^3.0.2",
33 | "@types/d3-zoom": "^3.0.8",
34 | "@types/leaflet": "^1.9.21",
35 | "@types/node-polyglot": "^2.5.0",
36 | "@types/rbush": "^4.0.0",
37 | "d3-collection": "^1.0.7",
38 | "d3-drag": "^3.0.0",
39 | "d3-ease": "^3.0.1",
40 | "d3-force": "^3.0.0",
41 | "d3-interpolate": "^3.0.1",
42 | "d3-selection": "^3.0.0",
43 | "d3-timer": "^3.0.1",
44 | "d3-zoom": "^3.0.0",
45 | "leaflet": "^1.9.4",
46 | "moment": "^2.30.1",
47 | "navigo": "^8.11.1",
48 | "node-polyglot": "2.6.0",
49 | "promise-polyfill": "^8.2.0",
50 | "rbush": "^4.0.1",
51 | "snabbdom": "^3.6.3"
52 | },
53 | "scripts": {
54 | "dev": "vite",
55 | "build": "vite build",
56 | "preview": "vite preview",
57 | "lint": "npm run lint:prettier && npm run lint:eslint",
58 | "lint:eslint": "./node_modules/.bin/eslint .",
59 | "lint:prettier": "./node_modules/.bin/prettier --check .",
60 | "lint:fix": "npm run lint:fix:prettier && npm run lint:fix:eslint",
61 | "lint:fix:eslint": "./node_modules/.bin/eslint --fix .",
62 | "lint:fix:prettier": "./node_modules/.bin/prettier --log-level warn --write .",
63 | "generate-pwa-assets": "pwa-assets-generator --preset minimal assets/logo.svg"
64 | },
65 | "browserslist": [
66 | "> 1% in DE"
67 | ]
68 | }
69 |
--------------------------------------------------------------------------------
/lib/map/clientlayer.ts:
--------------------------------------------------------------------------------
1 | import * as L from "leaflet";
2 | import RBush from "rbush";
3 | import * as helper from "../utils/helper.js";
4 | import { Node } from "../utils/node.js";
5 | import { ObjectsLinksAndNodes } from "../datadistributor.js";
6 | import { Coords } from "leaflet";
7 |
8 | export const ClientLayer = L.GridLayer.extend({
9 | mapRTree: function mapRTree(node: Node) {
10 | return {
11 | minX: node.location.latitude,
12 | minY: node.location.longitude,
13 | maxX: node.location.latitude,
14 | maxY: node.location.longitude,
15 | node: node,
16 | };
17 | },
18 | setData: function (data: ObjectsLinksAndNodes) {
19 | let rtreeOnlineAll = new RBush(9);
20 |
21 | this.data = rtreeOnlineAll.load(data.nodes.online.filter(helper.hasLocation).map(this.mapRTree));
22 |
23 | // pre-calculate start angles
24 | this.data.all().forEach(function (positionedNode: { startAngle: number; node: Node }) {
25 | positionedNode.startAngle = (parseInt(positionedNode.node.node_id.substr(10, 2), 16) / 255) * 2 * Math.PI;
26 | });
27 | this.redraw();
28 | },
29 | createTile: function (tilePoint: Coords) {
30 | let tile: HTMLElement & {
31 | width?: number;
32 | height?: number;
33 | getContext?: (type: string) => CanvasRenderingContext2D;
34 | } = L.DomUtil.create("canvas", "leaflet-tile");
35 |
36 | let tileSize = this.options.tileSize;
37 | tile.width = tileSize;
38 | tile.height = tileSize;
39 |
40 | if (!this.data) {
41 | return tile;
42 | }
43 |
44 | let ctx = tile.getContext("2d");
45 | let size = tilePoint.multiplyBy(tileSize);
46 | let map = this._map;
47 |
48 | let margin = 50;
49 | let bbox = helper.getTileBBox(size, map, tileSize, margin);
50 |
51 | let nodes = this.data.search(bbox);
52 |
53 | if (nodes.length === 0) {
54 | return tile;
55 | }
56 |
57 | let startDistance = 10;
58 |
59 | nodes.forEach(function (node: { node: Node; startAngle: number }) {
60 | let point = map.project([node.node.location.latitude, node.node.location.longitude]);
61 |
62 | point.x -= size.x;
63 | point.y -= size.y;
64 |
65 | helper.positionClients(ctx, point, node.startAngle, node.node, startDistance);
66 | });
67 |
68 | return tile;
69 | },
70 | });
71 |
--------------------------------------------------------------------------------
/lib/simplenodelist.ts:
--------------------------------------------------------------------------------
1 | import moment from "moment";
2 | import { classModule, eventListenersModule, h, init, propsModule, styleModule, VNode } from "snabbdom";
3 |
4 | import { _ } from "./utils/language.js";
5 | import * as helper from "./utils/helper.js";
6 | import { ObjectsLinksAndNodes } from "./datadistributor.js";
7 | import { Node } from "./utils/node.js";
8 |
9 | const patch = init([classModule, propsModule, styleModule, eventListenersModule]);
10 |
11 | export const SimpleNodelist = function (nodesState: string, field: string, title: string) {
12 | const self = {
13 | render: undefined,
14 | setData: undefined,
15 | };
16 | let listContainer: VNode = h("div");
17 |
18 | self.render = function render(d: HTMLElement) {
19 | let containerEl = document.createElement("div");
20 | d.appendChild(containerEl);
21 | listContainer = patch(containerEl, listContainer);
22 | };
23 |
24 | self.setData = function setData(data: ObjectsLinksAndNodes) {
25 | let nodeList = data.nodes[nodesState];
26 |
27 | let newContainer = h("div");
28 |
29 | if (nodeList.length > 0) {
30 | let items = nodeList.map(function (node: Node) {
31 | let router = window.router;
32 | let td0Content: null | VNode = null;
33 | if (helper.hasLocation(node)) {
34 | td0Content = h("span", { props: { className: "icon ion-location", title: _.t("location.location") } });
35 | }
36 |
37 | let td1Content = h(
38 | "a",
39 | {
40 | props: {
41 | className: ["hostname", node.is_online ? "online" : "offline"].join(" "),
42 | href: router.generateLink({ node: node.node_id }),
43 | },
44 | on: {
45 | click: function (e: Event) {
46 | router.fullUrl({ node: node.node_id }, e);
47 | },
48 | },
49 | },
50 | node.hostname,
51 | );
52 |
53 | return h("tr", [h("td", td0Content), h("td", td1Content), h("td", moment(node[field]).from(data.now))]);
54 | });
55 |
56 | newContainer.children = [
57 | h("h2", title),
58 | h(
59 | "table",
60 | {
61 | props: { className: "node-list" },
62 | },
63 | h("tbody", items),
64 | ),
65 | ];
66 | }
67 |
68 | listContainer = patch(listContainer, newContainer);
69 | };
70 |
71 | return self;
72 | };
73 |
--------------------------------------------------------------------------------
/DEVELOPMENT.md:
--------------------------------------------------------------------------------
1 | # Meshviewer Development
2 |
3 | ## Building
4 |
5 | ### Build yourself
6 |
7 | - Clone this repository
8 | - Run `npm install`
9 | - Place your config file in `public/config.json`.
10 | You can copy the example config for testing/development: `cp config.example.json public/config.json`.
11 | - Run `npm run build`
12 | - A production build can then be found in [`/build`](./build)
13 |
14 | Hint: You can start a development server with `npm run dev`
15 |
16 | ### Build and run using Docker
17 |
18 | You have to copy `config.example.json` to `public/config.json`.
19 |
20 | Static local instance:
21 |
22 | ```bash
23 | docker run -it --rm -u $(id -u):$(id -g) -v "$PWD":/app -w /app node npm install
24 | docker run -it --rm -u $(id -u):$(id -g) -v "$PWD":/app -w /app node npm run build
25 | docker run -it --rm -v "$PWD/build":/usr/share/nginx/html -p 8080:80 --name nginx nginx
26 | ```
27 |
28 | The map is reachable at [localhost:8080](http://localhost:8080).
29 | Start a development environment with hot-reload:
30 |
31 | ```bash
32 | docker run -it --rm --name meshviewer-dev \
33 | -u $(id -u):$(id -g) \
34 | -v "$PWD":/app -w /app \
35 | -e NODE_ENV=development \
36 | -p 5173:5173 \
37 | node npm run dev -- --host 0.0.0.0
38 | ```
39 |
40 | ## Workflow
41 |
42 | To submit a feature, you should fork this repository and commit your changes on a branch of your fork.
43 | Then you can open a PR against this repository.
44 |
45 | To align your changes with the linter of this project run
46 |
47 | `npm run lint:fix`
48 |
49 | ## Conventions
50 |
51 | Following you can find some wording and used functionality for this project.
52 |
53 | Normally you should use meaningful and self explaining names for variables and functions
54 | but sometimes using common conventions might help as well, for example `i` / `j` for index, `e` for exceptions or events etc.
55 | but also names based on elements like `p`, `a`, `div`..
56 |
57 | ### Functions
58 |
59 | `_.t("[translation.selector]")`
60 | : Lookup translation based on dotted path from `public/locale/[language].json`
61 |
62 | ## Variables
63 |
64 | `a` / `b`
65 | : Used when sorting data
66 |
67 | `d`
68 | : Is normally used to represent a selected dom node but can be (sadly) any data object.
69 |
70 | `el`
71 | : An element or dom node
72 |
73 | `f`
74 | : Functions / callbacks
75 |
76 | `L`
77 | : [Leaflet.js](https://github.com/Leaflet/Leaflet)
78 |
79 | `V`
80 | : [Snabbdom](https://github.com/snabbdom/snabbdom) virtual dom
81 |
--------------------------------------------------------------------------------
/lib/sorttable.ts:
--------------------------------------------------------------------------------
1 | import { classModule, eventListenersModule, h, init, propsModule, styleModule, VNode } from "snabbdom";
2 | import { _ } from "./utils/language.js";
3 |
4 | export interface Heading {
5 | name: string;
6 | sort?: (a: any, b: any) => number;
7 | reverse?: Boolean;
8 | class?: string;
9 | }
10 |
11 | const patch = init([classModule, propsModule, styleModule, eventListenersModule]);
12 |
13 | export const SortTable = function (
14 | headings: Heading[],
15 | sortIndex: number,
16 | renderRow: (element: any, i: number, all: []) => any,
17 | className: string[] = [],
18 | ) {
19 | const self: {
20 | el: HTMLElement;
21 | vnode: VNode;
22 | setData: (data: any[]) => void;
23 | } = { el: undefined, setData: undefined, vnode: null };
24 | let data: any[];
25 | let sortReverse = false;
26 | self.el = document.createElement("table");
27 |
28 | function sortTable(i: number) {
29 | sortReverse = i === sortIndex ? !sortReverse : false;
30 | sortIndex = i;
31 |
32 | updateView();
33 | }
34 |
35 | function sortTableHandler(i: number) {
36 | return function () {
37 | sortTable(i);
38 | };
39 | }
40 |
41 | function updateView() {
42 | let children = [];
43 |
44 | if (data.length !== 0) {
45 | let th = headings.map(function (row, i) {
46 | let name = _.t(row.name);
47 | let properties = {
48 | onclick: sortTableHandler(i),
49 | className: "sort-header",
50 | title: undefined,
51 | };
52 |
53 | if (row.class) {
54 | properties.className += " " + row.class;
55 | properties.title = name;
56 | name = "";
57 | }
58 |
59 | if (sortIndex === i) {
60 | properties.className += sortReverse ? " sort-up" : " sort-down";
61 | }
62 |
63 | return h("th", { props: properties }, name);
64 | });
65 |
66 | let links = data.slice(0).sort(headings[sortIndex].sort);
67 |
68 | if (headings[sortIndex].reverse ? !sortReverse : sortReverse) {
69 | links = links.reverse();
70 | }
71 |
72 | children.push(h("thead", h("tr", th)));
73 | children.push(h("tbody", links.map(renderRow)));
74 | }
75 |
76 | let elNew = h("table", { props: { className } }, children);
77 | self.vnode = patch(self.vnode ?? self.el, elNew);
78 | }
79 |
80 | self.setData = function setData(d: any[]) {
81 | data = d;
82 | updateView();
83 | };
84 |
85 | return self;
86 | };
87 |
--------------------------------------------------------------------------------
/lib/infobox/main.ts:
--------------------------------------------------------------------------------
1 | import { _ } from "../utils/language.js";
2 | import { Link } from "./link.js";
3 | import { Node } from "./node.js";
4 | import { location } from "./location.js";
5 | import { Link as LinkData, Node as NodeData, NodeId } from "../utils/node.js";
6 | import { Sidebar } from "../sidebar.js";
7 | import { TargetLocation } from "../utils/router.js";
8 | import { ObjectsLinksAndNodes } from "../datadistributor.js";
9 |
10 | export const Main = function (sidebar: ReturnType, linkScale: (t: any) => any) {
11 | const self = {
12 | resetView: undefined,
13 | gotoNode: undefined,
14 | gotoLink: undefined,
15 | gotoLocation: undefined,
16 | setData: undefined,
17 | };
18 | let el: HTMLDivElement;
19 | let node: ReturnType;
20 | let link: ReturnType;
21 |
22 | function destroy() {
23 | if (el && el.parentNode) {
24 | el.parentNode.removeChild(el);
25 | node = link = el = undefined;
26 | sidebar.reveal();
27 | }
28 | }
29 |
30 | function create() {
31 | destroy();
32 | sidebar.ensureVisible();
33 | sidebar.hide();
34 |
35 | el = document.createElement("div");
36 | sidebar.container.children[1].appendChild(el);
37 |
38 | el.scrollIntoView(false);
39 | el.classList.add("infobox");
40 | // @ts-ignore
41 | el.destroy = destroy;
42 |
43 | let router = window.router;
44 | let closeButton = document.createElement("button");
45 | closeButton.classList.add("close");
46 | closeButton.classList.add("ion-close");
47 | closeButton.setAttribute("aria-label", _.t("close"));
48 | closeButton.onclick = function () {
49 | router.fullUrl();
50 | };
51 | el.appendChild(closeButton);
52 | }
53 |
54 | self.resetView = destroy;
55 |
56 | self.gotoNode = function gotoNode(nodeData: NodeData, nodeDict: { [k: NodeId]: NodeData }) {
57 | create();
58 | node = Node(el, nodeData, linkScale, nodeDict);
59 | node.render();
60 | };
61 |
62 | self.gotoLink = function gotoLink(linkData: LinkData[]) {
63 | create();
64 | link = Link(el, linkData, linkScale);
65 | link.render();
66 | };
67 |
68 | self.gotoLocation = function gotoLocation(locationData: TargetLocation) {
69 | create();
70 | location(el, locationData);
71 | };
72 |
73 | self.setData = function setData(nodeOrLinkData: ObjectsLinksAndNodes) {
74 | if (typeof node === "object") {
75 | node.setData(nodeOrLinkData);
76 | }
77 | if (typeof link === "object") {
78 | link.setData(nodeOrLinkData);
79 | }
80 | };
81 |
82 | return self;
83 | };
84 |
--------------------------------------------------------------------------------
/lib/utils/language.ts:
--------------------------------------------------------------------------------
1 | import moment from "moment";
2 | import * as helper from "./helper.js";
3 | import Polyglot from "node-polyglot";
4 | import { Router } from "./router.js";
5 |
6 | export type LanguageCode = string;
7 |
8 | export let _: Polyglot & { phrases?: { [k: string]: any } };
9 |
10 | export const Language = function () {
11 | let router: Router;
12 | let config = globalThis.config;
13 |
14 | function languageSelect(el: HTMLElement) {
15 | let select = document.createElement("select");
16 | select.className = "language-switch";
17 | select.setAttribute("aria-label", "Language");
18 | select.addEventListener("change", setSelectLocale);
19 | el.appendChild(select);
20 |
21 | // Keep english
22 | select.innerHTML = "Language ";
23 | for (let i = 0; i < config.supportedLocale.length; i++) {
24 | select.innerHTML +=
25 | '' + config.supportedLocale[i] + " ";
26 | }
27 | }
28 |
29 | function setSelectLocale(event: any) {
30 | router.fullUrl({ lang: event.target.value }, false, true);
31 | }
32 |
33 | function getLocale(input?: LanguageCode): LanguageCode {
34 | let language: LanguageCode = input || (navigator.languages && navigator.languages[0]) || navigator.language;
35 | let locale = config.supportedLocale[0];
36 | config.supportedLocale.some(function (item: string) {
37 | if (language.indexOf(item) !== -1) {
38 | locale = item;
39 | return true;
40 | }
41 | return false;
42 | });
43 | return locale;
44 | }
45 |
46 | function setTranslation(translationJson: { [k: string]: any }) {
47 | _.extend(translationJson);
48 |
49 | if (moment.locale(_.locale()) !== _.locale()) {
50 | moment.defineLocale(_.locale(), {
51 | longDateFormat: {
52 | LT: "HH:mm",
53 | LTS: "HH:mm:ss",
54 | L: "DD.MM.YYYY",
55 | LL: "D. MMMM YYYY",
56 | LLL: "D. MMMM YYYY HH:mm",
57 | LLLL: "dddd, D. MMMM YYYY HH:mm",
58 | },
59 | calendar: translationJson.momentjs.calendar,
60 | relativeTime: translationJson.momentjs.relativeTime,
61 | });
62 | }
63 | }
64 |
65 | function init(routing: Router) {
66 | router = routing;
67 | /** global: _ */
68 | _ = new Polyglot({ locale: getLocale(routing.getLang()), allowMissing: true });
69 | helper.getJSON("locale/" + _.locale() + ".json?" + config.cacheBreaker).then(setTranslation);
70 | document.querySelector("html").setAttribute("lang", _.locale());
71 | }
72 |
73 | return {
74 | init,
75 | getLocale,
76 | languageSelect,
77 | };
78 | };
79 |
--------------------------------------------------------------------------------
/scss/modules/_base.scss:
--------------------------------------------------------------------------------
1 | @use "sass:color";
2 | @use "variables";
3 |
4 | body {
5 | -webkit-tap-highlight-color: transparent;
6 | background: variables.$color-white;
7 | color: variables.$color-black;
8 | font-family: variables.$font-family;
9 | font-size: variables.$font-size;
10 | overflow: hidden;
11 | overflow-y: scroll;
12 | }
13 |
14 | header {
15 | background: color.adjust(variables.$color-black, $alpha: -0.98);
16 | border-bottom: 1px solid color.adjust(variables.$color-white, $lightness: -10%);
17 | }
18 |
19 | textarea,
20 | input {
21 | background: transparent;
22 | color: variables.$color-black, 100;
23 | }
24 |
25 | h1,
26 | h2,
27 | h3,
28 | h4,
29 | h5,
30 | h6 {
31 | font-weight: bold;
32 | }
33 |
34 | h1,
35 | h2 {
36 | font-size: 1.5em;
37 | padding: 0.83em 0;
38 | }
39 |
40 | h3 {
41 | font-size: 1.17em;
42 | padding: 1em 0;
43 | }
44 |
45 | h1,
46 | h2,
47 | h3 {
48 | padding-left: variables.$button-distance;
49 | padding-right: variables.$button-distance;
50 | }
51 |
52 | p,
53 | pre,
54 | ul,
55 | h4 {
56 | padding: 0 variables.$button-distance 1em;
57 | }
58 |
59 | img {
60 | max-width: 100%;
61 | height: auto;
62 | }
63 |
64 | a {
65 | color: variables.$color-online;
66 | text-decoration: none;
67 |
68 | &:focus {
69 | color: color.adjust(variables.$color-online, $lightness: -15%);
70 | }
71 | }
72 |
73 | p {
74 | line-height: 1.67em;
75 | }
76 |
77 | strong {
78 | font-weight: bold;
79 | }
80 |
81 | .hide {
82 | display: none !important;
83 | }
84 |
85 | .sr-only {
86 | border: 0;
87 | clip: rect(0, 0, 0, 0);
88 | clip-path: inset(50%);
89 | height: 1px;
90 | overflow: hidden;
91 | padding: 0;
92 | position: absolute;
93 | white-space: nowrap;
94 | width: 1px;
95 | }
96 |
97 | .deprecation_base {
98 | padding-left: variables.$button-distance;
99 | padding-right: variables.$button-distance;
100 |
101 | div {
102 | border-radius: 5px;
103 | color: variables.$color-white;
104 | font-size: 110%;
105 | font-weight: bold;
106 | padding: 10px;
107 | text-align: center;
108 |
109 | a {
110 | color: variables.$color-white;
111 | text-decoration: underline;
112 |
113 | &:hover {
114 | // sass-lint:disable-line nesting-depth
115 | text-decoration: none;
116 | }
117 | }
118 | }
119 | }
120 |
121 | .deprecated {
122 | @extend .deprecation_base;
123 | div {
124 | background: variables.$color-warning;
125 | }
126 | }
127 |
128 | .eol {
129 | @extend .deprecation_base;
130 | div {
131 | background: variables.$color-error;
132 | }
133 | }
134 |
135 | .hw-img-container {
136 | display: flex;
137 | justify-content: center;
138 | margin-bottom: 0.83em;
139 | width: 100%;
140 | }
141 |
142 | .hw-img {
143 | height: 150px;
144 | padding: 0 !important;
145 | }
146 |
--------------------------------------------------------------------------------
/scss/modules/_button.scss:
--------------------------------------------------------------------------------
1 | @use "sass:color";
2 | @use "variables";
3 |
4 | button {
5 | background: variables.$color-white;
6 | border: 0;
7 | border-radius: 0.9em;
8 | color: variables.$color-black;
9 | cursor: pointer;
10 | font-family: variables.$font-family-icons;
11 | font-size: variables.$button-font-size;
12 | height: 1.8em;
13 | line-height: 1.95;
14 | opacity: 0.7;
15 | outline: none;
16 | padding: 0;
17 | transition:
18 | box-shadow 0.5s,
19 | background-color 0.5s,
20 | color 0.5s;
21 | width: 1.8em;
22 |
23 | &.text {
24 | background: variables.$color-primary;
25 | border: 1px solid variables.$color-primary;
26 | border-radius: 0;
27 | color: variables.$color-white;
28 | font: inherit;
29 | line-height: initial;
30 | padding: 0 20px;
31 | width: auto;
32 |
33 | &:hover {
34 | background: variables.$color-white;
35 | }
36 | }
37 |
38 | &.active,
39 | &:focus {
40 | box-shadow: 0 0 0 2px variables.$color-primary;
41 | }
42 |
43 | &:hover {
44 | color: variables.$color-primary;
45 | }
46 |
47 | // Tooltip
48 | &[data-tooltip] {
49 | &::after {
50 | background: variables.$color-black;
51 | border-radius: 3px;
52 | color: variables.$color-white;
53 | content: attr(data-tooltip);
54 | font-family: variables.$font-family;
55 | font-size: variables.$font-size;
56 | padding: 0 12px;
57 | position: absolute;
58 | transform: translate(45px, 52px);
59 | visibility: hidden;
60 | white-space: nowrap;
61 | }
62 |
63 | &:hover {
64 | &::after {
65 | transition: visibility 0s linear 0.3s;
66 | visibility: visible;
67 | }
68 | }
69 | }
70 |
71 | &.close {
72 | background-color: transparent;
73 | border-radius: 0;
74 | color: color.adjust(variables.$color-black, $alpha: -0.5);
75 | float: right;
76 | font-size: variables.$button-font-size;
77 | height: auto;
78 | line-height: 1.2;
79 | margin: variables.$button-distance;
80 | width: auto;
81 | }
82 | }
83 |
84 | // Tooltip
85 | .content,
86 | .sidebar > {
87 | button {
88 | &[aria-label] {
89 | &::after {
90 | background: variables.$color-black;
91 | border-radius: 3px;
92 | color: variables.$color-white;
93 | content: attr(aria-label);
94 | font-family: variables.$font-family;
95 | font-size: variables.$font-size;
96 | padding: 0 12px;
97 | position: absolute;
98 | transform: translate(45px, 52px);
99 | visibility: hidden;
100 | white-space: nowrap;
101 | }
102 |
103 | &:hover {
104 | &::after {
105 | transition: visibility 0s linear 0.3s;
106 | visibility: visible;
107 | }
108 | }
109 | }
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | Meshviewer
4 |
5 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/lib/about.ts:
--------------------------------------------------------------------------------
1 | import { _ } from "./utils/language.js";
2 | import { CanRender } from "./container.js";
3 |
4 | export const About = function (picturesSource: string, picturesLicense: string): CanRender {
5 | function render(d: HTMLElement) {
6 | d.innerHTML =
7 | _.t("sidebar.aboutInfo") +
8 | "" +
9 | _.t("node.nodes") +
10 | " " +
11 | '' +
12 | ' ' +
13 | _.t("sidebar.nodeNew") +
14 | " " +
15 | ' ' +
16 | _.t("sidebar.nodeOnline") +
17 | " " +
18 | ' ' +
19 | _.t("sidebar.nodeOffline") +
20 | " " +
21 | ' ' +
22 | _.t("sidebar.nodeUplink") +
23 | " " +
24 | "
" +
25 | "" +
26 | _.t("node.clients") +
27 | " " +
28 | '' +
29 | ' 2.4 GHz ' +
30 | ' 5 GHz ' +
31 | ' ' +
32 | _.t("others") +
33 | " " +
34 | "
" +
35 | (picturesSource
36 | ? _.t("sidebar.devicePicturesAttribution", {
37 | pictures_source: picturesSource,
38 | pictures_license: picturesLicense,
39 | })
40 | : "") +
41 | "Feel free to contribute! " +
42 | "Please support the meshviewer by opening issues or sending pull requests!
" +
43 | '' +
44 | "https://github.com/freifunk/meshviewer
" +
45 | "Version: " +
46 | __APP_VERSION__ +
47 | "
" +
48 | "AGPL 3 " +
49 | "Copyright (C) Milan Pässler
" +
50 | "Copyright (C) Nils Schneider
" +
51 | "This program is free software: you can redistribute it and/or " +
52 | "modify it under the terms of the GNU Affero General Public " +
53 | "License as published by the Free Software Foundation, either " +
54 | "version 3 of the License, or (at your option) any later version.
" +
55 | "This program is distributed in the hope that it will be useful, " +
56 | "but WITHOUT ANY WARRANTY; without even the implied warranty of " +
57 | "MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the " +
58 | "GNU Affero General Public License for more details.
" +
59 | "You should have received a copy of the GNU Affero General " +
60 | "Public License along with this program. If not, see " +
61 | '' +
62 | "https://www.gnu.org/licenses/ .
" +
63 | "The source code is available at " +
64 | '' +
65 | "https://github.com/freifunk/meshviewer .
";
66 | }
67 |
68 | return {
69 | render,
70 | };
71 | };
72 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Meshviewer
2 |
3 | [](https://github.com/freifunk/meshviewer/actions?query=workflow%3A%22Build+Meshviewer%22)
4 | [](https://github.com/freifunk/meshviewer/releases)
5 | [](https://www.gnu.org/licenses/agpl-3.0)
6 |
7 | Meshviewer is an online visualization app to represent nodes and links on a map for Freifunk open mesh network.
8 |
9 | ## Installation
10 |
11 | It is recommended to use the latest release:
12 |
13 | - Go to the [release page](https://github.com/freifunk/meshviewer/releases) and download the current build
14 | - Let your webserver serve this build
15 | - Add a config.json to the webdir (based on config.example.json)
16 |
17 | ## Docker Deployment
18 |
19 | Using the GitHub Container Registry (GHCR) you can get the latest dockerized release with `docker compose`.
20 |
21 | Put your config.json into the public folder and run the following to deploy a meshviewer:
22 |
23 | ```
24 | docker compose pull
25 | docker compose up -d
26 | ```
27 |
28 | The map is reachable at [localhost:8080](http://localhost:8080).
29 |
30 | Hint: Instead of the latest release `ghcr.io/freifunk/meshviewer:latest` one can also use version tags for a specific version or `main` for the latest unreleased commits.
31 |
32 | ## Configuration
33 |
34 | The configuration documentation is nowhere near finished.
35 |
36 | ### Deprecation and EOL Warning
37 |
38 | Both the deprecation and the EOL warning can be turned off with `"deprecation_enabled": false` - but we wouldn't suggest it.
39 |
40 | You can insert custom HTML into the deprecation and eol warning via `"deprecation_text":""` and `"eol_text":""` respectively.
41 |
42 | ## Development & Building
43 |
44 | To contribute to the project by developing new features, have a look at our [development documentation](DEVELOPMENT.md).
45 | This also includes instructions on building this project.
46 |
47 | ## History
48 |
49 | Meshviewer started as [ffnord/meshviewer](https://github.com/ffnord/meshviewer) for Freifunk Nord
50 | which was extended as [hopglass/hopglass](https://github.com/hopglass/hopglass)
51 | and further expanded by Freifunk Regensburg as [ffrgb/meshviewer](https://github.com/ffrgb/meshviewer).
52 | After maintenance stopped, Freifunk Frankfurt took over expanding the code base as [freifunk-ffm/meshviewer](https://github.com/freifunk-ffm/meshviewer)
53 | and added features like the deprecation warnings.
54 | It is now maintained by the Freifunk Org at [freifunk/meshviewer](https://github.com/freifunk/meshviewer).
55 |
56 | ## Goals
57 |
58 | The goal for this project is to extend Meshviewer, pick off where other forks ended
59 | and integrate those ideas into a code-base that is easily usable by all Freifunk communities.
60 | This also has the benefit that everyone can take advantage of the bundled development resources
61 | for implementing new features and fixing bugs.
62 |
--------------------------------------------------------------------------------
/lib/infobox/location.ts:
--------------------------------------------------------------------------------
1 | import { _ } from "../utils/language.js";
2 | import * as helper from "../utils/helper.js";
3 | import { TargetLocation } from "../utils/router.js";
4 |
5 | export const location = function (el: HTMLElement, position: TargetLocation) {
6 | let config = window.config;
7 | let sidebarTitle = document.createElement("h2");
8 | sidebarTitle.textContent = _.t("location.location");
9 | el.appendChild(sidebarTitle);
10 |
11 | helper
12 | .getJSON(
13 | config.reverseGeocodingApi +
14 | "?format=json&lat=" +
15 | position.lat +
16 | "&lon=" +
17 | position.lng +
18 | "&zoom=18&addressdetails=0&accept-language=" +
19 | _.locale(),
20 | )
21 | .then(function (result: { display_name: string }) {
22 | if (result.display_name) {
23 | sidebarTitle.outerHTML += "" + result.display_name + "
";
24 | }
25 | });
26 |
27 | let editLat = document.createElement("input");
28 | editLat.setAttribute("aria-label", _.t("location.latitude"));
29 | editLat.type = "text";
30 | editLat.value = position.lat.toFixed(9);
31 | el.appendChild(createBox("lat", _.t("location.latitude"), editLat));
32 |
33 | let editLng = document.createElement("input");
34 | editLng.setAttribute("aria-label", _.t("location.longitude"));
35 | editLng.type = "text";
36 | editLng.value = position.lng.toFixed(9);
37 | el.appendChild(createBox("lng", _.t("location.longitude"), editLng));
38 |
39 | let editUci = document.createElement("textarea");
40 | editUci.setAttribute("aria-label", "Uci");
41 | editUci.value =
42 | "uci set gluon-node-info.@location[0]='location'; " +
43 | "uci set gluon-node-info.@location[0].share_location='1';" +
44 | "uci set gluon-node-info.@location[0].latitude='" +
45 | position.lat.toFixed(9) +
46 | "';" +
47 | "uci set gluon-node-info.@location[0].longitude='" +
48 | position.lng.toFixed(9) +
49 | "';" +
50 | "uci commit gluon-node-info";
51 |
52 | el.appendChild(createBox("uci", "Uci", editUci));
53 |
54 | function createBox(name: string, title: string, inputElem: HTMLInputElement | HTMLTextAreaElement) {
55 | let box = document.createElement("div");
56 | let heading = document.createElement("h3");
57 | heading.textContent = title;
58 | box.appendChild(heading);
59 | let btn = document.createElement("button");
60 | btn.classList.add("ion-clipboard");
61 | btn.title = _.t("location.copy");
62 | btn.setAttribute("aria-label", _.t("location.copy"));
63 | btn.onclick = function onclick() {
64 | copy2clip(inputElem.id);
65 | };
66 | inputElem.id = "location-" + name;
67 | inputElem.readOnly = true;
68 | let line = document.createElement("p");
69 | line.appendChild(inputElem);
70 | line.appendChild(btn);
71 | box.appendChild(line);
72 | box.id = "box-" + name;
73 | return box;
74 | }
75 |
76 | function copy2clip(id: string) {
77 | let copyField: HTMLTextAreaElement = document.querySelector("#" + id);
78 | copyField.select();
79 | try {
80 | document.execCommand("copy");
81 | } catch (err) {
82 | console.warn(err);
83 | }
84 | }
85 | };
86 |
--------------------------------------------------------------------------------
/lib/linklist.ts:
--------------------------------------------------------------------------------
1 | import { h } from "snabbdom";
2 | import { _ } from "./utils/language.js";
3 | import { Heading, SortTable } from "./sorttable.js";
4 | import * as helper from "./utils/helper.js";
5 | import { Link } from "./utils/node.js";
6 | import { CanSetData, ObjectsLinksAndNodes } from "./datadistributor.js";
7 | import { CanRender } from "./container.js";
8 |
9 | function linkName(link: Link) {
10 | return (link.source ? link.source.hostname : link.id) + " – " + link.target.hostname;
11 | }
12 |
13 | let headings: Heading[] = [
14 | {
15 | name: "",
16 | sort: function (a, b) {
17 | return a.type.localeCompare(b.type);
18 | },
19 | },
20 | {
21 | name: "node.nodes",
22 | sort: function (a, b) {
23 | return linkName(a).localeCompare(linkName(b));
24 | },
25 | reverse: false,
26 | },
27 | {
28 | name: "node.tq",
29 | class: "ion-connection-bars",
30 | sort: function (a, b) {
31 | return (a.source_tq + a.target_tq) / 2 - (b.source_tq + b.target_tq) / 2;
32 | },
33 | reverse: true,
34 | },
35 | {
36 | name: "node.distance",
37 | class: "ion-arrow-resize",
38 | sort: function (a, b) {
39 | return (a.distance === undefined ? -1 : a.distance) - (b.distance === undefined ? -1 : b.distance);
40 | },
41 | reverse: true,
42 | },
43 | ];
44 |
45 | export const Linklist = function (linkScale: (t: any) => any): CanRender & CanSetData {
46 | let router = window.router;
47 | let table = SortTable(headings, 3, renderRow);
48 | const self = {
49 | render: undefined,
50 | setData: undefined,
51 | };
52 |
53 | function renderRow(link: Link) {
54 | let td1Content = [
55 | h(
56 | "a",
57 | {
58 | props: {
59 | href: router.generateLink({ link: link.id }),
60 | },
61 | on: {
62 | click: function (e: Event) {
63 | router.fullUrl({ link: link.id }, e);
64 | },
65 | },
66 | },
67 | linkName(link),
68 | ),
69 | ];
70 |
71 | return h("tr", [
72 | h(
73 | "td",
74 | h("span", {
75 | props: {
76 | className: "icon ion-" + (link.type.indexOf("wifi") === 0 ? "wifi" : "share-alt"),
77 | title: _.t(link.type),
78 | },
79 | }),
80 | ),
81 | h("td", td1Content),
82 | h(
83 | "td",
84 | { style: { color: linkScale((link.source_tq + link.target_tq) / 2) } },
85 | helper.showTq(link.source_tq) + " - " + helper.showTq(link.target_tq),
86 | ),
87 | h("td", helper.showDistance(link)),
88 | ]);
89 | }
90 |
91 | self.render = function render(d: HTMLElement) {
92 | let h2 = document.createElement("h2");
93 | h2.textContent = _.t("node.links");
94 | d.appendChild(h2);
95 | table.el.classList.add("link-list");
96 | d.appendChild(table.el);
97 | };
98 |
99 | self.setData = function setData(d: ObjectsLinksAndNodes) {
100 | table.setData(d.links);
101 | };
102 |
103 | return {
104 | setData: self.setData,
105 | render: self.render,
106 | };
107 | };
108 |
--------------------------------------------------------------------------------
/public/locale/cz.json:
--------------------------------------------------------------------------------
1 | {
2 | "node": {
3 | "all": "Všechny uzly",
4 | "nodes": "Uzly",
5 | "uptime": "Celková doba provozu",
6 | "links": "Odkazy",
7 | "clients": "Klienti",
8 | "localClients": "Clients in the local cloud",
9 | "distance": "Vzdálenost",
10 | "connectionType": "typ připojení",
11 | "tq": "tq",
12 | "lastOnline": "poslední on-line %{time} (%{date})",
13 | "lastOffline": "lastOffline %{time} (%{date})",
14 | "activated": "aktivováno (%{branch})",
15 | "deactivated": "deaktivováno",
16 | "status": "Stav",
17 | "firmware": "Verze firmwaru",
18 | "baseversion": "Base version",
19 | "deprecationStatus": "Deprecation Status",
20 | "hardware": "Model hardwaru",
21 | "visible": "Visible on the map",
22 | "update": "Automatický update",
23 | "domain": "Domain",
24 | "gateway": "Brána",
25 | "coordinates": "Souřadnice",
26 | "contact": "Kontakt",
27 | "primaryMac": "Hlavní MAC",
28 | "id": "Identifikace uzlu",
29 | "firstSeen": "firstSeen",
30 | "systemLoad": "Průměrné zatížení",
31 | "ram": "Využití paměti",
32 | "ipAddresses": "IP adresa",
33 | "nexthop": "Další skok",
34 | "selectedGatewayIPv4": "vybranýGatewayIPv4",
35 | "selectedGatewayIPv6": "vybranýGatewayIPv6",
36 | "link": "Odkaz ||| Odkazy",
37 | "node": "Uzel ||| Uzly",
38 | "new": "Nové uzly",
39 | "missing": "Zmizelé uzly"
40 | },
41 | "location": {
42 | "location": "Poloha",
43 | "latitude": "Zeměpisná šířka",
44 | "longitude": "Zeměpisná délka",
45 | "copy": "Kopírovat"
46 | },
47 | "sidebar": {
48 | "nodeFilter": "nodeFilter",
49 | "nodes": "%{total} uzly, %{online} uzly on-line",
50 | "clients": "%{smart_count} klienti |||| %{smart_count} klienti",
51 | "gateway": " %{smart_count} gateway |||| %{smart_count} gateways",
52 | "lastUpdate": "Poslední update",
53 | "nodeNew": "nodeNew",
54 | "nodeOnline": "Uzel je online",
55 | "nodeOffline": "Uzel je offline",
56 | "nodeUplink": "Uplink",
57 | "aboutInfo": "aboutInfo",
58 | "actual": "aktuální",
59 | "stats": "Statistika",
60 | "about": "O produktu",
61 | "toggle": "přepínat"
62 | },
63 | "button": {
64 | "switchView": "Přepnout zobrazení",
65 | "location": "Vybrat souřadnice",
66 | "tracking": "Lokalizace"
67 | },
68 | "momentjs": {
69 | "calendar": {
70 | "sameDay": "[Today at] LT",
71 | "nextDay": "[Tomorrow at] LT",
72 | "nextWeek": "dddd [at] LT",
73 | "lastDay": "[Yesterday at] LT",
74 | "lastWeek": "[Last] dddd [at] LT",
75 | "sameElse": "L"
76 | },
77 | "relativeTime": {
78 | "future": "in %s",
79 | "past": "%s ago",
80 | "s": "Několik sekund",
81 | "m": "minuta",
82 | "mm": "%d minut",
83 | "h": "an hour",
84 | "hh": "%d hodin",
85 | "d": "den",
86 | "dd": "%d dnů",
87 | "M": "měsíc",
88 | "MM": "%d měsíců",
89 | "y": "rok",
90 | "yy": "%d let"
91 | }
92 | },
93 | "yes": "ano",
94 | "no": "ne",
95 | "unknown": "neznámý",
96 | "others": "ostatní",
97 | "none": "žádný",
98 | "remove": "odstranit",
99 | "close": "zavřít"
100 | }
101 |
--------------------------------------------------------------------------------
/lib/utils/version.ts:
--------------------------------------------------------------------------------
1 | type Version = { epoch: number; upstream: string; debian: string };
2 |
3 | const Version = function (v: string) {
4 | // remove leading "v" or "V" if present
5 | if (v.startsWith("v") || v.startsWith("V")) {
6 | v = v.slice(1);
7 | }
8 | let versionResult = /^[a-zA-Z]?([0-9]*(?=:))?:(.*)/.exec(v);
9 | let version = versionResult && versionResult[2] ? versionResult[2] : v;
10 | let versionParts = version.split("-");
11 |
12 | this.epoch = versionResult ? Number(versionResult[1]) : 0;
13 | this.debian = versionParts.length > 1 ? versionParts.pop() : "";
14 | this.upstream = versionParts.join("-");
15 | };
16 |
17 | Version.prototype.compare = function (b: Version) {
18 | if ((this.epoch > 0 || b.epoch > 0) && Math.sign(this.epoch - b.epoch) !== 0) {
19 | return Math.sign(this.epoch - b.epoch);
20 | }
21 | if (this.compareStrings(this.upstream, b.upstream) !== 0) {
22 | return this.compareStrings(this.upstream, b.upstream);
23 | }
24 | return this.compareStrings(this.debian, b.debian);
25 | };
26 |
27 | Version.prototype.charCode = function (c: string) {
28 | if (/[a-zA-Z]/.test(c)) {
29 | return c.charCodeAt(0) - "A".charCodeAt(0) + 1;
30 | } else if (/[.:+-:]/.test(c)) {
31 | return c.charCodeAt(0) + "z".charCodeAt(0) + 1;
32 | } // char codes are 46..58
33 | return 0;
34 | };
35 |
36 | // find index in "array" by "fn" callback.
37 | Version.prototype.findIndex = function (array: string[], fn: (c: string, i: number) => boolean) {
38 | for (let i = 0; i < array.length; i++) {
39 | if (fn(array[i], i)) {
40 | return i;
41 | }
42 | }
43 | return -1;
44 | };
45 |
46 | Version.prototype.compareChunk = function (a: string, b: string) {
47 | let ca = a.split("");
48 | let cb = b.split("");
49 | let diff = this.findIndex(ca, function (c: string, index: number) {
50 | return !(cb[index] && c === cb[index]);
51 | });
52 | if (diff === -1) {
53 | if (cb.length > ca.length) {
54 | if (cb[ca.length] === "~") {
55 | return 1;
56 | }
57 | return -1;
58 | }
59 | return 0; // no diff found and same length
60 | } else if (!cb[diff]) {
61 | return ca[diff] === "~" ? -1 : 1;
62 | }
63 | return this.charCode(ca[diff]) > this.charCode(cb[diff]) ? 1 : -1;
64 | };
65 |
66 | Version.prototype.compareStrings = function (a: string, b: string) {
67 | if (a === b) {
68 | return 0;
69 | }
70 | let parseA = /([^0-9]+|[0-9]+)/g;
71 | let parseB = /([^0-9]+|[0-9]+)/g;
72 | let ra = parseA.exec(a);
73 | let rb = parseB.exec(b);
74 | while (ra !== null && rb !== null) {
75 | if ((isNaN(Number(ra[1])) || isNaN(Number(rb[1]))) && ra[1] !== rb[1]) {
76 | // a or b is not a number and they're not equal. Note : "" IS a number so both null is impossible
77 | return this.compareChunk(ra[1], rb[1]);
78 | } // both are numbers
79 | if (ra[1] !== rb[1]) {
80 | return parseInt(ra[1], 10) > parseInt(rb[1], 10) ? 1 : -1;
81 | }
82 | ra = parseA.exec(a);
83 | rb = parseB.exec(b);
84 | }
85 | if (!ra && rb) {
86 | // rb doesn't get exec-ed when ra == null
87 | return rb.length > 0 && rb[1].split("")[0] === "~" ? 1 : -1;
88 | } else if (ra && !rb) {
89 | return ra[1].split("")[0] === "~" ? -1 : 1;
90 | }
91 | return 0;
92 | };
93 |
94 | export const compare = (a: string, b: string) => {
95 | let va = new Version(a);
96 | let vb = new Version(b);
97 | return vb.compare(va);
98 | };
99 |
--------------------------------------------------------------------------------
/public/locale/ru.json:
--------------------------------------------------------------------------------
1 | {
2 | "node": {
3 | "all": "Все узлы",
4 | "nodes": "Узлы",
5 | "uptime": "Время работы",
6 | "links": "Ссылки",
7 | "clients": "Клиенты",
8 | "localClients": "Clients in the local cloud",
9 | "distance": "Расстояние",
10 | "connectionType": "Тип подключения",
11 | "tq": "Качество связи",
12 | "lastOnline": "в сети, последнее сообщение %{time} (%{date})",
13 | "lastOffline": "не в сети, последнее сообщение %{time} (%{date})",
14 | "activated": "активировано (%{branch})",
15 | "deactivated": "деактивировано",
16 | "status": "Статус",
17 | "firmware": "Версия прошивки",
18 | "baseversion": "Base version",
19 | "deprecationStatus": "Deprecation Status",
20 | "hardware": "Тип оборудования",
21 | "visible": "Видно на карте",
22 | "update": "Автообновление",
23 | "domain": "Сайт",
24 | "gateway": "Шлюз",
25 | "coordinates": "Координаты",
26 | "contact": "Контакты",
27 | "primaryMac": "Основной MAC",
28 | "id": "Идентификатор узла",
29 | "firstSeen": "Впервые замечен",
30 | "systemLoad": "Средняя загрузка",
31 | "ram": "Используемая память",
32 | "ipAddresses": "IP адреса",
33 | "nexthop": "Следующий скачок",
34 | "selectedGatewayIPv4": "Выбранный шлюз ipv4",
35 | "selectedGatewayIPv6": "Выбранный шлюз ipv6",
36 | "link": "Ссылка |||| Ссылки",
37 | "node": "Узел |||| Узлы",
38 | "new": "Новые узлы",
39 | "missing": "Исчезнувшие узлы"
40 | },
41 | "location": {
42 | "location": "Расположение",
43 | "latitude": "Широта",
44 | "longitude": "Долгота",
45 | "copy": "Копировать"
46 | },
47 | "sidebar": {
48 | "nodeFilter": "Фильтр узлов",
49 | "nodes": "%{total} узлов, включая %{online} узлов онлайн",
50 | "clients": "с %{smart_count} клиентом |||| с %{smart_count} клиентами",
51 | "gateway": "на %{smart_count} шлюзе |||| на %{smart_count} шлюзах",
52 | "lastUpdate": "Последнее обновление",
53 | "nodeNew": "Узел новый",
54 | "nodeOnline": "Узел в сети",
55 | "nodeOffline": "Узел не в сети",
56 | "nodeUplink": "Uplink",
57 | "aboutInfo": "О Meshviewer Вы можете увеличить масштаб двойным щелчком мыши и уменьшить с shift + двойной щелчок
",
58 | "actual": "Текущее",
59 | "stats": "Статистика",
60 | "about": "О продукте",
61 | "toggle": "Включить панель"
62 | },
63 | "button": {
64 | "switchView": "Переключить вид",
65 | "location": "Взять координаты",
66 | "tracking": "Локализация"
67 | },
68 | "momentjs": {
69 | "calendar": {
70 | "sameDay": "[Сегодня в] LT",
71 | "nextDay": "[Завтра в] LT",
72 | "nextWeek": "dddd [в] LT",
73 | "lastDay": "[Вчера в] LT",
74 | "lastWeek": "[Последний] dddd [в] LT",
75 | "sameElse": "L"
76 | },
77 | "relativeTime": {
78 | "future": "в %s",
79 | "past": "%s назад",
80 | "s": "несколько секунд",
81 | "m": "минута",
82 | "mm": "%d минут",
83 | "h": "час",
84 | "hh": "%d часов",
85 | "d": "день",
86 | "dd": "%d дней",
87 | "M": "месяц",
88 | "MM": "%d месяцев",
89 | "y": "год",
90 | "yy": "%d лет"
91 | }
92 | },
93 | "yes": "да",
94 | "no": "нет",
95 | "unknown": "неизвестно",
96 | "others": "другие",
97 | "none": "нет",
98 | "remove": "убрать",
99 | "close": "закрыть"
100 | }
101 |
--------------------------------------------------------------------------------
/scss/modules/_sidebar.scss:
--------------------------------------------------------------------------------
1 | @use "sass:color";
2 | @use "sass:map";
3 | @use "variables";
4 |
5 | .sidebar {
6 | box-sizing: border-box;
7 | left: 0;
8 | position: absolute;
9 | transition: left 0.5s;
10 | width: variables.$sidebar-width;
11 | z-index: 1005;
12 |
13 | &.hidden {
14 | left: -(variables.$sidebar-width) - variables.$button-distance;
15 |
16 | .sidebarhandle {
17 | left: variables.$button-distance;
18 | transform: scale(-1, 1);
19 |
20 | &[aria-label] {
21 | &::after {
22 | transform: scale(-1, 1) translate(105px, 52px) !important;
23 | }
24 | }
25 | }
26 |
27 | @media screen and (max-width: map.get(variables.$grid-breakpoints, lg) - 1) {
28 | width: auto;
29 | }
30 | }
31 |
32 | .tab {
33 | padding-bottom: 15px;
34 | }
35 |
36 | img {
37 | padding: 0 variables.$button-distance 1em;
38 | box-sizing: border-box;
39 | }
40 |
41 | .node-list,
42 | .node-links,
43 | .link-list {
44 | th,
45 | td {
46 | &:first-child {
47 | width: 25px;
48 | }
49 |
50 | &:nth-child(2) {
51 | overflow: hidden;
52 | text-align: left;
53 | text-overflow: ellipsis;
54 | white-space: nowrap;
55 | width: 50%;
56 | }
57 | }
58 | }
59 |
60 | .link-list {
61 | th,
62 | td {
63 | &:nth-child(2) {
64 | width: 60%;
65 | }
66 | }
67 | }
68 |
69 | .node-links {
70 | padding-bottom: 15px;
71 |
72 | th,
73 | td {
74 | &:first-child {
75 | width: 35px;
76 | }
77 | }
78 | }
79 |
80 | .container {
81 | background: color.adjust(variables.$color-white, $alpha: -0.03);
82 | border-right: 1px solid color.adjust(variables.$color-white, $lightness: -10%);
83 | min-height: 100vh;
84 | overflow-y: visible;
85 |
86 | &.hidden {
87 | display: none;
88 | }
89 | }
90 |
91 | @media screen and (max-width: map.get(variables.$grid-breakpoints, xl) - 1) {
92 | background: variables.$color-white;
93 | font-size: 0.8em;
94 | margin: 0;
95 | width: variables.$sidebar-width-small;
96 |
97 | .sidebarhandle {
98 | left: variables.$sidebar-width-small + variables.$button-distance;
99 | }
100 |
101 | .container,
102 | .infobox {
103 | border-radius: 0;
104 | margin: 0;
105 | }
106 | }
107 |
108 | @media screen and (max-width: map.get(variables.$grid-breakpoints, lg) - 1) {
109 | height: auto;
110 | min-height: 0;
111 | position: static;
112 | width: auto;
113 |
114 | .sidebarhandle {
115 | display: none;
116 | }
117 |
118 | .content {
119 | height: 60vh;
120 | position: relative;
121 | width: auto;
122 | }
123 | }
124 | }
125 |
126 | .sidebarhandle {
127 | left: variables.$sidebar-width + 2 * variables.$button-distance;
128 | position: fixed;
129 | top: variables.$button-distance;
130 | transition:
131 | left 0.5s,
132 | color 0.5s,
133 | transform 0.5s;
134 | z-index: 1010;
135 |
136 | &::before {
137 | content: "\f124";
138 | padding-right: 0.125em;
139 | }
140 |
141 | &[aria-label] {
142 | &::after {
143 | transform: translate(-45px, 52px) !important;
144 | }
145 | }
146 | }
147 |
148 | .online {
149 | color: variables.$color-new;
150 | }
151 |
152 | .offline {
153 | color: variables.$color-offline;
154 | }
155 |
--------------------------------------------------------------------------------
/public/locale/tr.json:
--------------------------------------------------------------------------------
1 | {
2 | "node": {
3 | "all": "Bütün düğümler",
4 | "nodes": "Düğümler",
5 | "uptime": "Çalışma süresi",
6 | "links": "Bağlantılar",
7 | "clients": "Müşteriler",
8 | "localClients": "Clients in the local cloud",
9 | "distance": "Mesafe",
10 | "connectionType": "Bağlantı türü",
11 | "tq": "İletim kalitesi",
12 | "lastOnline": "çevrimiçi, son mesaj %{time} (%{date})",
13 | "lastOffline": "çevrimdışı, son mesaj %{time} (%{date})",
14 | "activated": "aktif (%{branch})",
15 | "deactivated": "devredışı bırakıldı",
16 | "status": "Durum",
17 | "firmware": "Yazılım versiyonu",
18 | "baseversion": "Base version",
19 | "deprecationStatus": "Deprecation Status",
20 | "hardware": "Donanım modeli",
21 | "visible": "Harita üzerinde görünür",
22 | "update": "Otomatik güncelleme",
23 | "domain": "Domain",
24 | "gateway": "Geçit",
25 | "coordinates": "Koordinatlar",
26 | "contact": "İlişki",
27 | "primaryMac": "Birincil MAC",
28 | "id": "Düğüm kimliği",
29 | "firstSeen": "İlk görülme",
30 | "systemLoad": "Ortalama yük",
31 | "ram": "Bellek kullanımı",
32 | "ipAddresses": "IP adresleri",
33 | "nexthop": "Bir sonraki atlama",
34 | "selectedGatewayIPv4": "Seçili Ipv4-ağ geçidi",
35 | "selectedGatewayIPv6": "Seçili Ipv6-ağ geçidi",
36 | "link": "Bağlantı ||| Bağlantılar",
37 | "node": "Düğüm ||| Düğümler",
38 | "new": "Yeni düğümler",
39 | "missing": "Kaybolan düğümler"
40 | },
41 | "location": {
42 | "location": "Konum",
43 | "latitude": "Enlem",
44 | "longitude": "Boylam",
45 | "copy": "Kopya"
46 | },
47 | "sidebar": {
48 | "nodeFilter": "Düğüm Filtresi",
49 | "nodes": "%{total} düğümler, %{online} çevrimiçi düğümler dahil",
50 | "clients": "%{smart_count} müşteri ile |||| %{smart_count} müşteriler ile",
51 | "gateway": "%{smart_count} geçit üzerinde |||| %{smart_count} geçitler üzerinde",
52 | "lastUpdate": "Son güncelleme",
53 | "nodeNew": "yeni",
54 | "nodeOnline": "çevrimiçi",
55 | "nodeOffline": "çevrimdışı",
56 | "nodeUplink": "uplink",
57 | "aboutInfo": "Meshviewer Hakkında Çift tıklayarak yakınlaştırabilir ve Shift tuşuna basıp+çift tıklayarak uzaklaştırabilirsiniz
",
58 | "actual": "Mevcut",
59 | "stats": "İstatistikler",
60 | "about": "Hakkında",
61 | "toggle": "Kenar çubuğunu değiştir"
62 | },
63 | "button": {
64 | "switchView": "Görünümü Değiştir",
65 | "location": "Koordinatları seç",
66 | "tracking": "Yerelleştirme"
67 | },
68 | "momentjs": {
69 | "calendar": {
70 | "sameDay": "[Bugün] LT",
71 | "nextDay": "[Yarın] LT",
72 | "nextWeek": "dddd [at] LT",
73 | "lastDay": "[Dün] LT",
74 | "lastWeek": "[Last] dddd [at] LT",
75 | "sameElse": "L"
76 | },
77 | "relativeTime": {
78 | "future": "%s içinde",
79 | "past": "%s önce",
80 | "s": "birkaç saniye",
81 | "m": "bir dakika",
82 | "mm": "%d dakikalar",
83 | "h": "bir saat",
84 | "hh": "%d saatler",
85 | "d": "bir gün",
86 | "dd": "%d günler",
87 | "M": "bir ay",
88 | "MM": "%d aylar",
89 | "y": "bir yıl",
90 | "yy": "%d yıllar"
91 | }
92 | },
93 | "yes": "evet",
94 | "no": "hayır",
95 | "unknown": "bilinmeyen",
96 | "others": "diğer",
97 | "none": "hiçbiri",
98 | "remove": "kaldır",
99 | "close": "kapat"
100 | }
101 |
--------------------------------------------------------------------------------
/public/locale/fr.json:
--------------------------------------------------------------------------------
1 | {
2 | "node": {
3 | "all": "Tous les nœuds",
4 | "nodes": "Nœuds",
5 | "uptime": "Temps de fonctionnement",
6 | "links": "Connexion",
7 | "clients": "Clients",
8 | "localClients": "Clients in the local cloud",
9 | "distance": "Distance",
10 | "connectionType": "Type de connexion",
11 | "tq": "Qualité de transmission",
12 | "lastOnline": "en ligne, dernier message %{time} (%{date})",
13 | "lastOffline": "hors ligne, dernier message %{time} (%{date})",
14 | "activated": "activé (%{branch})",
15 | "deactivated": "désactivé",
16 | "status": "Statut",
17 | "firmware": "Version firmware",
18 | "baseversion": "Base version",
19 | "deprecationStatus": "Deprecation Status",
20 | "hardware": "Modèle matériel",
21 | "visible": "Visible sur la carte",
22 | "update": "Mise à jour automatique",
23 | "domain": "Domain",
24 | "gateway": "Passerelle",
25 | "coordinates": "Coordonnées",
26 | "contact": "Contact",
27 | "primaryMac": "MAC primaire",
28 | "id": "ID de nœud",
29 | "firstSeen": "Vu pour la première fois",
30 | "systemLoad": "Charge moyenne",
31 | "ram": "Utilisation de la mémoire",
32 | "ipAddresses": "Adresse IP",
33 | "nexthop": "Nexthop",
34 | "selectedGatewayIPv4": "Selected ipv4-gateway",
35 | "selectedGatewayIPv6": "Selected ipv6-gateway",
36 | "link": "Connexion |||| Connexions",
37 | "node": "Nœud |||| Nœuds",
38 | "new": "Nouveaux nœuds",
39 | "missing": "Nœuds disparus"
40 | },
41 | "location": {
42 | "location": "Lieu",
43 | "latitude": "Latitude",
44 | "longitude": "Longitude",
45 | "copy": "Copier"
46 | },
47 | "sidebar": {
48 | "nodeFilter": "Filtre de nœud",
49 | "nodes": "%{total} nœud, dont %{online} nœuds en ligne",
50 | "clients": "avec %{smart_count} client |||| avec %{smart_count} clients",
51 | "gateway": "sur %{smart_count} passerelle |||| sur %{smart_count} passerelles",
52 | "lastUpdate": "Dernière actualisation",
53 | "nodeNew": "Nœud est nouveau",
54 | "nodeOnline": "Nœud est en ligne",
55 | "nodeOffline": "Nœud hors ligne",
56 | "nodeUplink": "Uplink",
57 | "aboutInfo": "Sur Meshviewer Vous pouvez zoomer avec double-clic et effectuer un zoom arrière avec shift + double-clic
",
58 | "actual": "Actuel",
59 | "stats": "Statistiques",
60 | "about": "À propros",
61 | "toggle": "Toggle Sidebar"
62 | },
63 | "button": {
64 | "switchView": "Basculer l’affichage",
65 | "location": "Choisir les coordonnées",
66 | "tracking": "Localisation"
67 | },
68 | "momentjs": {
69 | "calendar": {
70 | "sameDay": "[Aujourd'hui à] LT [heures]",
71 | "nextDay": "[Demain à] LT [heures]",
72 | "nextWeek": "dddd [à] LT [heures]",
73 | "lastDay": "[Hier à] LT [heures]",
74 | "lastWeek": "[Dernier] dddd [à] LT [heures]",
75 | "sameElse": "L"
76 | },
77 | "relativeTime": {
78 | "future": "dans %s",
79 | "past": "il y a %s",
80 | "s": "quelques secondes",
81 | "m": "une minute",
82 | "mm": "%d minute",
83 | "h": "une heure",
84 | "hh": "%d heures",
85 | "d": "un jour",
86 | "dd": "%d jours",
87 | "M": "un mois",
88 | "MM": "%d mois",
89 | "y": "un an",
90 | "yy": "%d ans"
91 | }
92 | },
93 | "yes": "oui",
94 | "no": "non",
95 | "unknown": "inconnu",
96 | "others": "autres",
97 | "none": "aucun",
98 | "remove": "supprimer",
99 | "close": "fermer"
100 | }
101 |
--------------------------------------------------------------------------------
/config.example.json:
--------------------------------------------------------------------------------
1 | {
2 | "dataPath": ["https://yanic.batman15.ffffm.net/"],
3 | "siteName": "Freifunk Frankfurt",
4 | "maxAge": 21,
5 | "nodeZoom": 19,
6 | "mapLayers": [
7 | {
8 | "name": "OpenStreetMap",
9 | "url": "https://tiles.ffm.freifunk.net/{z}/{x}/{y}.png",
10 | "config": {
11 | "type": "osm",
12 | "maxZoom": 19,
13 | "attribution": "Report Bug | Map data © OpenStreetMap contributor"
14 | }
15 | }
16 | ],
17 | "fixedCenter": [
18 | [50.5099, 8.1393],
19 | [49.9282, 9.3164]
20 | ],
21 | "domainNames": [
22 | { "domain": "ffffm_60431", "name": "60431 Frankfurt am Main" },
23 | { "domain": "ffffm_default", "name": "Default" }
24 | ],
25 | "nodeInfos": [
26 | {
27 | "name": "Clientstatistik",
28 | "href": "https://freifunk.fail/dashboard/db/nodes?var-name={NODE_CUSTOM}&from=now-7d&to=now",
29 | "image": "https://freifunk.fail/render/dashboard-solo/db/node-details-for-map?panelId=7&var-id={NODE_ID}&from=now-7d&to=now&width=600&height=300&theme=light&_t={TIME}",
30 | "title": "Knoten {NODE_ID} ({NODE_NAME})"
31 | },
32 | {
33 | "name": "Traffic",
34 | "href": "https://freifunk.fail/dashboard/db/nodes?var-name={NODE_CUSTOM}&from=now-7d&to=now",
35 | "image": "https://freifunk.fail/render/dashboard-solo/db/node-details-for-map?panelId=1&var-id={NODE_ID}&from=now-7d&to=now&width=600&height=300&theme=light&_t={TIME}",
36 | "title": "Knoten {NODE_ID} ({NODE_NAME})"
37 | },
38 | {
39 | "name": "Uptime",
40 | "href": "https://freifunk.fail/dashboard/db/nodes?var-name={NODE_CUSTOM}&from=now-7d&to=now",
41 | "image": "https://freifunk.fail/render/dashboard-solo/db/node-details-for-map?panelId=5&var-id={NODE_ID}&from=now-7d&to=now&width=600&height=300&theme=light&_t={TIME}",
42 | "title": "Knoten {NODE_ID} ({NODE_NAME})"
43 | },
44 | {
45 | "name": "CPU Auslastung",
46 | "href": "https://freifunk.fail/dashboard/db/nodes?var-name={NODE_CUSTOM}&from=now-7d&to=now",
47 | "image": "https://freifunk.fail/render/dashboard-solo/db/node-details-for-map?panelId=2&var-id={NODE_ID}&from=now-7d&to=now&width=600&height=300&theme=light&_t={TIME}",
48 | "title": "Knoten {NODE_ID} ({NODE_NAME})"
49 | },
50 | {
51 | "name": "Neighbours",
52 | "href": "https://freifunk.fail/dashboard/db/nodes?var-name={NODE_CUSTOM}&from=now-7d&to=now",
53 | "image": "https://freifunk.fail/render/dashboard-solo/db/node-details-for-map?panelId=4&var-id={NODE_ID}&from=now-7d&to=now&width=600&height=300&theme=light&_t={TIME}",
54 | "title": "Knoten {NODE_ID} ({NODE_NAME})"
55 | }
56 | ],
57 | "globalInfos": [
58 | {
59 | "name": "Wochenstatistik",
60 | "href": "https://freifunk.fail/dashboard/db/deck?from=now-7d&to=now",
61 | "image": "https://freifunk.fail/render/dashboard-solo/db/deck?&panelId=22&from=now-7d&to=now&width=600&height=300&theme=light&_t={TIME}",
62 | "title": "Bild der Wochenstatistik"
63 | }
64 | ],
65 | "devicePictures": "https://map.aachen.freifunk.net/pictures-svg/{MODEL_NORMALIZED}.svg",
66 | "devicePicturesSource": "https://github.com/freifunk/device-pictures ",
67 | "devicePicturesLicense": "CC-BY-NC-SA 4.0",
68 | "node_custom": "/[^a-z0-9\\-\\.]/ig",
69 | "deprecation_text": "Hier kann ein eigener Text für die Deprecation Warning (inkl. HTML) stehen!",
70 | "eol_text": "Hier kann ein eigener Text für die End-of-Life Warnung (inkl. HTML) stehen!",
71 | "deprecation_enabled": true
72 | }
73 |
--------------------------------------------------------------------------------
/lib/datadistributor.ts:
--------------------------------------------------------------------------------
1 | import { NodeFilter } from "./filters/nodefilter.js";
2 | import { Link, Node, NodeId } from "./utils/node.js";
3 | import { Moment } from "moment";
4 |
5 | export interface CanSetData {
6 | setData: (data: any) => any;
7 | }
8 |
9 | export interface CanFiltersChanged {
10 | filtersChanged: (filters: Filter[]) => any;
11 | }
12 |
13 | export interface NodesByState {
14 | all: Node[];
15 | lost: Node[];
16 | new: Node[];
17 | online: Node[];
18 | offline: Node[];
19 | }
20 |
21 | export interface ObjectsLinksAndNodes {
22 | links: Link[];
23 | nodes: NodesByState;
24 | nodeDict?: { [k: NodeId]: Node };
25 | now?: Moment;
26 | timestamp?: Moment;
27 | }
28 |
29 | export interface Filter {
30 | getKey?: () => string;
31 | setRefresh(refresh: () => any): any;
32 | run(data: any): Boolean;
33 | }
34 |
35 | export type FilterMethod = (node: Node) => boolean;
36 |
37 | export const DataDistributor = function () {
38 | let targets = [];
39 | let filterObservers: CanFiltersChanged[] = [];
40 | let filters: Filter[] = [];
41 | let filteredData: ObjectsLinksAndNodes;
42 | let data: ObjectsLinksAndNodes;
43 |
44 | function remove(target: CanSetData) {
45 | targets = targets.filter(function (currentElement) {
46 | return target !== currentElement;
47 | });
48 | }
49 |
50 | function add(target: CanSetData) {
51 | targets.push(target);
52 |
53 | if (filteredData !== undefined) {
54 | target.setData(filteredData);
55 | }
56 | }
57 |
58 | function setData(dataValue: ObjectsLinksAndNodes) {
59 | data = dataValue;
60 | refresh();
61 | }
62 |
63 | function refresh() {
64 | if (data === undefined) {
65 | return;
66 | }
67 |
68 | let filter: FilterMethod = filters.reduce(
69 | function (a: FilterMethod, filter) {
70 | return function (d: Node): boolean {
71 | return (a(d) && filter.run(d)).valueOf();
72 | };
73 | },
74 | function () {
75 | return true;
76 | },
77 | );
78 |
79 | filteredData = NodeFilter(filter)(data);
80 |
81 | targets.forEach(function (target) {
82 | target.setData(filteredData);
83 | });
84 | }
85 |
86 | function notifyObservers() {
87 | filterObservers.forEach(function (fileObserver) {
88 | fileObserver.filtersChanged(filters);
89 | });
90 | }
91 |
92 | function addFilter(filter: Filter) {
93 | let newItem = true;
94 |
95 | filters.forEach(function (oldFilter: Filter) {
96 | if (oldFilter.getKey && oldFilter.getKey() === filter.getKey()) {
97 | removeFilter(oldFilter);
98 | newItem = false;
99 | }
100 | });
101 |
102 | if (newItem) {
103 | filters.push(filter);
104 | notifyObservers();
105 | filter.setRefresh(refresh);
106 | refresh();
107 | }
108 | }
109 |
110 | function removeFilter(filter: Filter) {
111 | filters = filters.filter(function (currentElement) {
112 | return filter !== currentElement;
113 | });
114 | notifyObservers();
115 | refresh();
116 | }
117 |
118 | function watchFilters(filterObserver: CanFiltersChanged) {
119 | filterObservers.push(filterObserver);
120 |
121 | filterObserver.filtersChanged(filters);
122 |
123 | return function () {
124 | filterObservers = filterObservers.filter(function (currentFilterObserver) {
125 | return filterObserver !== currentFilterObserver;
126 | });
127 | };
128 | }
129 |
130 | return {
131 | add,
132 | remove,
133 | setData,
134 | addFilter,
135 | removeFilter,
136 | watchFilters,
137 | };
138 | };
139 |
--------------------------------------------------------------------------------
/lib/nodelist.ts:
--------------------------------------------------------------------------------
1 | import { h, VNode } from "snabbdom";
2 | import { _ } from "./utils/language.js";
3 | import { Heading, SortTable } from "./sorttable.js";
4 | import * as helper from "./utils/helper.js";
5 | import { Node } from "./utils/node.js";
6 | import { CanSetData, ObjectsLinksAndNodes } from "./datadistributor.js";
7 | import { CanRender } from "./container.js";
8 |
9 | function showUptime(uptime: number) {
10 | // 1000ms are 1 second and 60 second are 1min: 60 * 1000 = 60000
11 | let seconds = uptime / 60000;
12 | if (Math.abs(seconds) < 60) {
13 | return Math.round(seconds) + " m";
14 | }
15 | seconds /= 60;
16 | if (Math.abs(seconds) < 24) {
17 | return Math.round(seconds) + " h";
18 | }
19 | seconds /= 24;
20 | return Math.round(seconds) + " d";
21 | }
22 |
23 | let headings: Heading[] = [
24 | {
25 | name: "",
26 | },
27 | {
28 | name: "node.nodes",
29 | sort: function (a, b) {
30 | return a.hostname.localeCompare(b.hostname);
31 | },
32 | reverse: false,
33 | },
34 | {
35 | name: "node.uptime",
36 | class: "ion-time",
37 | sort: function (a, b) {
38 | return a.uptime - b.uptime;
39 | },
40 | reverse: true,
41 | },
42 | {
43 | name: "node.links",
44 | class: "ion-share-alt",
45 | sort: function (a, b) {
46 | return a.neighbours.length - b.neighbours.length;
47 | },
48 | reverse: true,
49 | },
50 | {
51 | name: "node.clients",
52 | class: "ion-people",
53 | sort: function (a, b) {
54 | return a.clients - b.clients;
55 | },
56 | reverse: true,
57 | },
58 | ];
59 |
60 | export const Nodelist = function (): CanSetData & CanRender {
61 | let router = window.router;
62 | const self = {
63 | render: undefined,
64 | setData: undefined,
65 | };
66 |
67 | function renderRow(node: Node) {
68 | let td0Content: string | VNode = "";
69 | if (helper.hasLocation(node)) {
70 | td0Content = h("span", { props: { className: "icon ion-location", title: _.t("location.location") } });
71 | }
72 |
73 | let td1Content = h(
74 | "a",
75 | {
76 | props: {
77 | className: ["hostname", node.is_online ? "online" : "offline"].join(" "),
78 | href: router.generateLink({ node: node.node_id }),
79 | },
80 | on: {
81 | click: function (e: Event) {
82 | router.fullUrl({ node: node.node_id }, e);
83 | },
84 | },
85 | },
86 | node.hostname,
87 | );
88 |
89 | return h("tr", [
90 | h("td", td0Content),
91 | h("td", td1Content),
92 | h("td", showUptime(node.uptime)),
93 | h("td", node.neighbours.length),
94 | h("td", node.clients),
95 | ]);
96 | }
97 |
98 | let table = SortTable(headings, 1, renderRow);
99 |
100 | self.render = function render(d: HTMLElement) {
101 | let h2 = document.createElement("h2");
102 | h2.textContent = _.t("node.all");
103 | d.appendChild(h2);
104 | table.el.classList.add("node-list");
105 | d.appendChild(table.el);
106 | };
107 |
108 | self.setData = function setData(nodesData: ObjectsLinksAndNodes) {
109 | let nodesList = nodesData.nodes.all.map(function (node) {
110 | let nodeData = Object.create(node);
111 | if (node.is_online) {
112 | nodeData.uptime = nodesData.now.valueOf() - new Date(node.uptime).getTime();
113 | } else {
114 | nodeData.uptime = node.lastseen.valueOf() - nodesData.now.valueOf();
115 | }
116 | return nodeData;
117 | });
118 |
119 | table.setData(nodesList);
120 | };
121 |
122 | return {
123 | setData: self.setData,
124 | render: self.render,
125 | };
126 | };
127 |
--------------------------------------------------------------------------------
/scss/night.scss:
--------------------------------------------------------------------------------
1 | // Overwrite normal style (colors)
2 | @use "sass:color";
3 | @use "sass:map";
4 | @use "modules/variables";
5 | @use "custom/variables" as variables2;
6 |
7 | variables.$color-white: #1c1c13;
8 | variables.$color-black: #fefefe;
9 | variables.$color-map-background: #0d151c;
10 |
11 | variables.$color-online: color.adjust(variables.$color-online, $lightness: 25%);
12 |
13 | .theme_night {
14 | //@import 'modules/base';
15 | body,
16 | textarea,
17 | input {
18 | background: variables.$color-white;
19 | color: color.adjust(variables.$color-black, $lightness: 100%);
20 | }
21 |
22 | header {
23 | background: color.adjust(variables.$color-black, $alpha: -0.98);
24 | border-bottom-color: color.adjust(variables.$color-white, $lightness: 10%);
25 | }
26 |
27 | a {
28 | color: variables.$color-online;
29 | text-decoration: none;
30 |
31 | &:focus {
32 | color: color.adjust(variables.$color-online, $lightness: -15%);
33 | }
34 | }
35 |
36 | //@import 'modules/leaflet';
37 | .leaflet-container {
38 | background: variables.$color-map-background;
39 | }
40 |
41 | .leaflet-label {
42 | &.leaflet-label-right {
43 | background-color: variables.$color-white;
44 | }
45 | }
46 |
47 | .leaflet-control-container {
48 | .leaflet-control-layers-toggle {
49 | background: color.adjust(variables.$color-white, $lightness: 10%);
50 | color: variables.$color-black;
51 | }
52 | }
53 |
54 | .leaflet-control-zoom {
55 | a {
56 | background: color.adjust(variables.$color-white, $lightness: 10%);
57 | color: variables.$color-black;
58 |
59 | &:hover {
60 | background: variables.$color-white;
61 | }
62 | }
63 | }
64 |
65 | .leaflet-control-layers {
66 | &.leaflet-control {
67 | opacity: 0.9;
68 | }
69 | }
70 |
71 | .language-switch {
72 | color: variables.$color-black;
73 |
74 | option {
75 | background: variables.$color-white;
76 | }
77 | }
78 |
79 | //@import 'modules/filter';
80 | .filter-node {
81 | input {
82 | color: variables.$color-black;
83 | }
84 | }
85 |
86 | //@import 'modules/sidebar';
87 | .sidebar {
88 | .infobox,
89 | .container {
90 | background: color.adjust(variables.$color-white, $alpha: -0.03);
91 | border-right: 1px solid color.adjust(variables.$color-white, $lightness: -10%);
92 | }
93 |
94 | @media screen and (max-width: map.get(variables.$grid-breakpoints, xl) - 1) {
95 | background: variables.$color-white;
96 | }
97 | }
98 |
99 | //@import 'modules/tabs';
100 | .tabs {
101 | background: color.adjust(variables.$color-black, $alpha: -0.98);
102 | border-bottom-color: color.adjust(variables.$color-white, $lightness: 10%);
103 |
104 | li {
105 | color: color.adjust(variables.$color-black, $alpha: -0.5);
106 |
107 | &:hover {
108 | color: variables.$color-black;
109 | }
110 | }
111 | }
112 |
113 | //@import 'modules/node';
114 | .bar {
115 | background: color.mix(variables.$color-new, variables.$color-white, 60%);
116 |
117 | &.warning {
118 | background: color.mix(variables.$color-offline, variables.$color-white, 60%);
119 | }
120 |
121 | label {
122 | color: variables.$color-white;
123 | }
124 | }
125 |
126 | //@import 'modules/button';
127 | button {
128 | background: color.adjust(variables.$color-white, $lightness: 10%);
129 | color: variables.$color-black;
130 |
131 | &:hover {
132 | background: variables.$color-white;
133 | }
134 |
135 | &.close {
136 | background: transparent;
137 | color: color.adjust(variables.$color-black, $alpha: -0.5);
138 | }
139 | }
140 | }
141 |
--------------------------------------------------------------------------------
/public/locale/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "node": {
3 | "all": "All nodes",
4 | "nodes": "Nodes",
5 | "uptime": "Uptime",
6 | "links": "Links",
7 | "clients": "Clients",
8 | "localClients": "Clients in the local cloud",
9 | "distance": "Distance",
10 | "connectionType": "Connection type",
11 | "tq": "Transmit quality",
12 | "lastOnline": "online, last message %{time} (%{date})",
13 | "lastOffline": "offline, last message %{time} (%{date})",
14 | "activated": "activated (%{branch})",
15 | "deactivated": "deactivated",
16 | "status": "Status",
17 | "firmware": "Firmware version",
18 | "baseversion": "Base version",
19 | "deprecationStatus": "Deprecation Status",
20 | "hardware": "Hardware model",
21 | "visible": "Visible on the map",
22 | "update": "Auto update",
23 | "domain": "Domain",
24 | "gateway": "Gateway",
25 | "coordinates": "Coordinates",
26 | "contact": "Contact",
27 | "primaryMac": "Primary MAC",
28 | "id": "Node ID",
29 | "firstSeen": "First seen",
30 | "systemLoad": "Load average",
31 | "ram": "Memory usage",
32 | "ipAddresses": "IP addresses",
33 | "nexthop": "Nexthop",
34 | "selectedGatewayIPv4": "Selected ipv4-gateway",
35 | "selectedGatewayIPv6": "Selected ipv6-gateway",
36 | "link": "Link |||| Links",
37 | "node": "Node |||| Nodes",
38 | "new": "New nodes",
39 | "missing": "Disappeared nodes"
40 | },
41 | "location": {
42 | "location": "Location",
43 | "latitude": "Latitude",
44 | "longitude": "Longitude",
45 | "copy": "Copy"
46 | },
47 | "sidebar": {
48 | "nodeFilter": "Node filter",
49 | "nodes": "%{total} nodes, including %{online} nodes online",
50 | "clients": "with %{smart_count} client |||| with %{smart_count} clients",
51 | "gateway": "on %{smart_count} gateway |||| on %{smart_count} gateways",
52 | "lastUpdate": "Last update",
53 | "nodeNew": "new",
54 | "nodeOnline": "online",
55 | "nodeOffline": "offline",
56 | "nodeUplink": "uplink",
57 | "aboutInfo": "About Meshviewer You can zoom in with double-click and zoom out with shift+double-click
",
58 | "devicePicturesAttribution": "Device Pictures Attribution The hardware pictures are available at %{pictures_source} licensed under %{pictures_license}
",
59 | "actual": "Current",
60 | "stats": "Statistics",
61 | "about": "About",
62 | "toggle": "Toggle Sidebar"
63 | },
64 | "button": {
65 | "switchView": "Switch view",
66 | "location": "Pick coordinates",
67 | "tracking": "Localisation",
68 | "fullscreen": "Toggle fullscreen"
69 | },
70 | "momentjs": {
71 | "calendar": {
72 | "sameDay": "[Today at] LT",
73 | "nextDay": "[Tomorrow at] LT",
74 | "nextWeek": "dddd [at] LT",
75 | "lastDay": "[Yesterday at] LT",
76 | "lastWeek": "[Last] dddd [at] LT",
77 | "sameElse": "L"
78 | },
79 | "relativeTime": {
80 | "future": "in %s",
81 | "past": "%s ago",
82 | "s": "a few seconds",
83 | "m": "a minute",
84 | "mm": "%d minutes",
85 | "h": "an hour",
86 | "hh": "%d hours",
87 | "d": "a day",
88 | "dd": "%d days",
89 | "M": "a month",
90 | "MM": "%d months",
91 | "y": "a year",
92 | "yy": "%d years"
93 | }
94 | },
95 | "yes": "yes",
96 | "no": "no",
97 | "unknown": "unknown",
98 | "others": "other",
99 | "none": "none",
100 | "remove": "remove",
101 | "close": "close",
102 | "deprecation": "deprecated",
103 | "deprecation-text": "This node is deprecated, and will be out of support soon. More information under 8/64 warning . If you're the owner, please replace it with an modern device!",
104 | "eol": "end-of-life",
105 | "eol-text": "This node has reached end-of-life, and is not supported anymore. More information under 4/32 warning . If you're the owner, please replace it with an modern device!",
106 | "loading": "%{name} graph (is generated)"
107 | }
108 |
--------------------------------------------------------------------------------
/lib/infobox/link.ts:
--------------------------------------------------------------------------------
1 | import { classModule, eventListenersModule, h, init, propsModule, styleModule, VNode } from "snabbdom";
2 | import { _ } from "../utils/language.js";
3 | import * as helper from "../utils/helper.js";
4 | import { LinkInfo } from "../config_default.js";
5 | import { Link as LinkData } from "../utils/node.js";
6 |
7 | const patch = init([classModule, propsModule, styleModule, eventListenersModule]);
8 |
9 | function showStatImg(images: HTMLElement[], linkInfo: LinkInfo, link: LinkData, time: string) {
10 | let subst: ReplaceMapping = {
11 | "{SOURCE_ID}": link.source.node_id,
12 | "{SOURCE_NAME}": link.source.hostname.replace(/[^a-z0-9\-]/gi, "_"),
13 | "{SOURCE_ADDR}": link.source_addr,
14 | "{SOURCE_MAC}": link.source_mac ? link.source_mac : link.source_addr,
15 | "{TARGET_ID}": link.target.node_id,
16 | "{TARGET_NAME}": link.target.hostname.replace(/[^a-z0-9\-]/gi, "_"),
17 | "{TARGET_ADDR}": link.target_addr,
18 | "{TARGET_MAC}": link.target_mac ? link.target_mac : link.target_addr,
19 | "{TYPE}": link.type,
20 | "{TIME}": time, // numeric datetime
21 | "{LOCALE}": _.locale(),
22 | };
23 |
24 | images.push(h("h4", helper.listReplace(linkInfo.name, subst)) as unknown as HTMLElement);
25 | images.push(helper.showStat(linkInfo, subst));
26 | }
27 |
28 | export const Link = function (el: HTMLElement, linkData: LinkData[], linkScale: (t: any) => any) {
29 | const self = {
30 | render: undefined,
31 | setData: undefined,
32 | };
33 |
34 | let container = document.createElement("div");
35 | el.appendChild(container);
36 | let containerVnode: VNode | undefined;
37 |
38 | self.render = function render() {
39 | let config = window.config;
40 | let router = window.router;
41 | let children = [];
42 | let img = [];
43 | let time = linkData[0].target.lastseen.format("DDMMYYYYHmmss");
44 |
45 | let newContainer = h("div", [
46 | h(
47 | "div",
48 | h("h2", [
49 | h(
50 | "a",
51 | {
52 | props: { href: router.generateLink({ node: linkData[0].source.node_id }) },
53 | },
54 | linkData[0].source.hostname,
55 | ),
56 | h("span", " - "),
57 | h(
58 | "a",
59 | {
60 | props: { href: router.generateLink({ node: linkData[0].target.node_id }) },
61 | },
62 | linkData[0].target.hostname,
63 | ),
64 | ]),
65 | ),
66 | ]);
67 |
68 | helper.attributeEntry(
69 | children,
70 | "node.hardware",
71 | (linkData[0].source.model ? linkData[0].source.model + " – " : "") +
72 | (linkData[0].target.model ? linkData[0].target.model : ""),
73 | );
74 | helper.attributeEntry(children, "node.distance", helper.showDistance(linkData[0]));
75 |
76 | linkData.forEach(function (link) {
77 | children.push(
78 | h("tr", { props: { className: "header" } }, [h("th", _.t("node.connectionType")), h("th", link.type)]),
79 | );
80 | helper.attributeEntry(
81 | children,
82 | "node.tq",
83 | h(
84 | "span",
85 | { style: { color: linkScale((link.source_tq + link.target_tq) / 2) } },
86 | helper.showTq(link.source_tq) + " - " + helper.showTq(link.target_tq),
87 | ),
88 | );
89 |
90 | if (config.linkTypeInfos) {
91 | config.linkTypeInfos.forEach(function (linkTypeInfo) {
92 | showStatImg(img, linkTypeInfo, link, time);
93 | });
94 | }
95 | });
96 |
97 | if (config.linkInfos) {
98 | config.linkInfos.forEach(function (linkInfo) {
99 | showStatImg(img, linkInfo, linkData[0], time);
100 | });
101 | }
102 |
103 | newContainer.children.push(h("table", { props: { className: "attributes" } }, children));
104 | newContainer.children.push(h("div", img));
105 |
106 | containerVnode = patch(containerVnode ?? container, newContainer);
107 | };
108 |
109 | self.setData = function setData(data: { links: LinkData[] }) {
110 | linkData = data.links.filter(function (link) {
111 | return link.id === linkData[0].id;
112 | });
113 | self.render();
114 | };
115 | return self;
116 | };
117 |
--------------------------------------------------------------------------------
/lib/map/button.js:
--------------------------------------------------------------------------------
1 | import * as L from "leaflet";
2 | import { _ } from "../utils/language";
3 |
4 | import { LocationMarker } from "./locationmarker";
5 |
6 | let ButtonBase = L.Control.extend({
7 | options: {
8 | position: "bottomright",
9 | },
10 |
11 | active: false,
12 | button: undefined,
13 |
14 | initialize: function (f, options) {
15 | L.Util.setOptions(this, options);
16 | this.f = f;
17 | },
18 |
19 | update: function () {
20 | this.button.classList.toggle("active", this.active);
21 | },
22 |
23 | set: function (activeValue) {
24 | this.active = activeValue;
25 | this.update();
26 | },
27 | });
28 |
29 | let LocateButton = ButtonBase.extend({
30 | onAdd: function () {
31 | let button = L.DomUtil.create("button", "ion-locate");
32 | button.setAttribute("aria-label", _.t("button.tracking"));
33 | L.DomEvent.disableClickPropagation(button);
34 | L.DomEvent.addListener(button, "click", this.onClick, this);
35 |
36 | this.button = button;
37 |
38 | return button;
39 | },
40 |
41 | onClick: function () {
42 | this.f(!this.active);
43 | },
44 | });
45 |
46 | let CoordsPickerButton = ButtonBase.extend({
47 | onAdd: function () {
48 | let button = L.DomUtil.create("button", "ion-pin");
49 | button.setAttribute("aria-label", _.t("button.location"));
50 |
51 | // Click propagation isn't disabled as this causes problems with the
52 | // location picking mode; instead propagation is stopped in onClick().
53 | L.DomEvent.addListener(button, "click", this.onClick, this);
54 |
55 | this.button = button;
56 |
57 | return button;
58 | },
59 |
60 | onClick: function (e) {
61 | L.DomEvent.stopPropagation(e);
62 | this.f(!this.active);
63 | },
64 | });
65 |
66 | export const Button = function (map, buttons) {
67 | let userLocation;
68 | const self = {
69 | clearButtons: undefined,
70 | disableTracking: undefined,
71 | locationFound: undefined,
72 | locationError: undefined,
73 | init: undefined,
74 | };
75 |
76 | let locateUserButton = new LocateButton(function (activate) {
77 | if (activate) {
78 | enableTracking();
79 | } else {
80 | self.disableTracking();
81 | }
82 | });
83 |
84 | let mybuttons = [];
85 |
86 | function addButton(button) {
87 | let el = button.onAdd();
88 | mybuttons.push(el);
89 | buttons.appendChild(el);
90 | }
91 |
92 | self.clearButtons = function clearButtons() {
93 | mybuttons.forEach(function (button) {
94 | buttons.removeChild(button);
95 | });
96 | };
97 |
98 | let showCoordsPickerButton = new CoordsPickerButton(function (activate) {
99 | if (activate) {
100 | enableCoords();
101 | } else {
102 | disableCoords();
103 | }
104 | });
105 |
106 | function enableTracking() {
107 | map.locate({
108 | watch: true,
109 | enableHighAccuracy: true,
110 | setView: "untilPan",
111 | });
112 | locateUserButton.set(true);
113 | }
114 |
115 | self.disableTracking = function disableTracking() {
116 | map.stopLocate();
117 | self.locationError();
118 | locateUserButton.set(false);
119 | };
120 |
121 | function enableCoords() {
122 | map.getContainer().classList.add("pick-coordinates");
123 | map.on("click", showCoordinates);
124 | showCoordsPickerButton.set(true);
125 | }
126 |
127 | function disableCoords() {
128 | map.getContainer().classList.remove("pick-coordinates");
129 | map.off("click", showCoordinates);
130 | showCoordsPickerButton.set(false);
131 | }
132 |
133 | function showCoordinates(clicked) {
134 | router.fullUrl({ zoom: map.getZoom(), lat: clicked.latlng.lat, lng: clicked.latlng.lng });
135 | disableCoords();
136 | }
137 |
138 | self.locationFound = function locationFound(location) {
139 | if (!userLocation) {
140 | userLocation = new LocationMarker(location.latlng).addTo(map);
141 | }
142 |
143 | userLocation.setLatLng(location.latlng);
144 | userLocation.setAccuracy(location.accuracy);
145 | };
146 |
147 | self.locationError = function locationError() {
148 | if (userLocation) {
149 | map.removeLayer(userLocation);
150 | userLocation = null;
151 | }
152 | };
153 |
154 | self.init = function init() {
155 | addButton(locateUserButton);
156 | addButton(showCoordsPickerButton);
157 | };
158 |
159 | return self;
160 | };
161 |
--------------------------------------------------------------------------------
/lib/main.ts:
--------------------------------------------------------------------------------
1 | import moment from "moment";
2 | import * as L from "leaflet";
3 |
4 | import { _ } from "./utils/language.js";
5 | import { Router } from "./utils/router.js";
6 | import { Gui } from "./gui.js";
7 | import { Language } from "./utils/language.js";
8 | import * as helper from "./utils/helper.js";
9 | import { Link } from "./utils/node.js";
10 |
11 | export const main = () => {
12 | function handleData(data: { links: Link[]; nodes: Node[]; timestamp: string }[]) {
13 | let config = window.config;
14 | let timestamp: string;
15 | let nodes = [];
16 | let links = [];
17 | let nodeDict = {};
18 |
19 | for (let i = 0; i < data.length; ++i) {
20 | nodes = nodes.concat(data[i].nodes);
21 | timestamp = data[i].timestamp;
22 | links = links.concat(data[i].links);
23 | }
24 |
25 | nodes.forEach(function (node) {
26 | node.firstseen = moment.utc(node.firstseen).local();
27 | node.lastseen = moment.utc(node.lastseen).local();
28 | });
29 |
30 | let age = moment().subtract(config.maxAge, "days");
31 |
32 | let online = nodes.filter(function (node) {
33 | return node.is_online;
34 | });
35 | let offline = nodes.filter(function (node) {
36 | return !node.is_online;
37 | });
38 |
39 | let newnodes = helper.limit("firstseen", age, helper.sortByKey("firstseen", online));
40 | let lostnodes = helper.limit("lastseen", age, helper.sortByKey("lastseen", offline));
41 |
42 | nodes.forEach(function (node) {
43 | node.neighbours = [];
44 | nodeDict[node.node_id] = node;
45 | });
46 |
47 | links.forEach(function (link) {
48 | link.source = nodeDict[link.source];
49 | link.target = nodeDict[link.target];
50 |
51 | link.id = [link.source.node_id, link.target.node_id].join("-");
52 | link.source.neighbours.push({ node: link.target, link: link });
53 | link.target.neighbours.push({ node: link.source, link: link });
54 |
55 | try {
56 | link.latlngs = [];
57 | link.latlngs.push(L.latLng(link.source.location.latitude, link.source.location.longitude));
58 | link.latlngs.push(L.latLng(link.target.location.latitude, link.target.location.longitude));
59 |
60 | link.distance = link.latlngs[0].distanceTo(link.latlngs[1]);
61 | } catch (e) {
62 | // ignore exception
63 | }
64 | });
65 |
66 | return {
67 | now: moment(),
68 | timestamp: moment.utc(timestamp).local(),
69 | nodes: {
70 | all: nodes,
71 | online: online,
72 | offline: offline,
73 | new: newnodes,
74 | lost: lostnodes,
75 | },
76 | links: links,
77 | nodeDict: nodeDict,
78 | };
79 | }
80 |
81 | let config = window.config;
82 | let language = Language();
83 | let router = (window.router = new Router(language));
84 |
85 | config.dataPath.forEach(function (_element, i) {
86 | config.dataPath[i] += "meshviewer.json";
87 | });
88 |
89 | language.init(router);
90 |
91 | function update() {
92 | return Promise.all(config.dataPath.map(helper.getJSON)).then(handleData);
93 | }
94 |
95 | update()
96 | .then(function (nodesData) {
97 | return new Promise(function (resolve, reject) {
98 | let count = 0;
99 | (function waitForLanguage() {
100 | if (Object.keys(_.phrases).length > 0) {
101 | resolve(nodesData);
102 | } else if (count > 500) {
103 | reject(new Error("translation not loaded after 10 seconds"));
104 | } else {
105 | setTimeout(waitForLanguage.bind(this), 20);
106 | }
107 | count++;
108 | })();
109 | });
110 | })
111 | .then(function (nodesData: any) {
112 | let gui = Gui(language);
113 | gui.setData(nodesData);
114 | router.setData(nodesData);
115 | router.resolve();
116 |
117 | window.setInterval(function () {
118 | update().then(function (nodesData: any) {
119 | gui.setData(nodesData);
120 | router.setData(nodesData);
121 | });
122 | }, 60000);
123 | })
124 | .catch(function (e) {
125 | document.querySelector(".loader").innerHTML +=
126 | e.message +
127 | 'Try to reload or report to your community';
128 | console.warn(e);
129 | });
130 | };
131 |
--------------------------------------------------------------------------------
/public/locale/de.json:
--------------------------------------------------------------------------------
1 | {
2 | "node": {
3 | "all": "Alle Knoten",
4 | "nodes": "Knoten",
5 | "uptime": "Laufzeit",
6 | "links": "Verbindungen",
7 | "clients": "Nutzer",
8 | "localClients": "Nutzer in der lokalen Wolke",
9 | "distance": "Entfernung",
10 | "connectionType": "Verbindungsart",
11 | "tq": "Übertragungsqualität",
12 | "lastOnline": "online, letzte Nachricht %{time} (%{date})",
13 | "lastOffline": "offline, letzte Nachricht %{time} (%{date})",
14 | "activated": "aktiviert (%{branch})",
15 | "deactivated": "deaktiviert",
16 | "status": "Status",
17 | "firmware": "Firmware-Version",
18 | "baseversion": "Base Version",
19 | "deprecationStatus": "Veralterungsstatus",
20 | "hardware": "Geräte-Modell",
21 | "visible": "Auf der Karte sichtbar",
22 | "update": "Auto-Update",
23 | "domain": "Domain",
24 | "gateway": "Gateway",
25 | "coordinates": "Koordinaten",
26 | "contact": "Kontakt",
27 | "primaryMac": "Primäre MAC",
28 | "id": "Knoten ID",
29 | "firstSeen": "Erstmals gesehen",
30 | "systemLoad": "Systemlast",
31 | "ram": "Speicherauslastung",
32 | "ipAddresses": "IP Adressen",
33 | "nexthop": "Nächster Sprung",
34 | "selectedGatewayIPv4": "Gewähltes ipv4 Gateway",
35 | "selectedGatewayIPv6": "Gewähltes ipv6 Gateway",
36 | "link": "Verbindung |||| Verbindungen",
37 | "node": "Knoten",
38 | "new": "Neue Knoten",
39 | "missing": "Verschwundene Knoten"
40 | },
41 | "location": {
42 | "location": "Standort",
43 | "latitude": "Breitengrad",
44 | "longitude": "Längengrad",
45 | "copy": "Kopieren"
46 | },
47 | "sidebar": {
48 | "nodeFilter": "Knotenfilter",
49 | "nodes": "%{total} Knoten, davon %{online} Knoten online",
50 | "clients": "mit %{smart_count} Nutzer |||| mit %{smart_count} Nutzern",
51 | "gateway": "auf %{smart_count} Gateway |||| auf %{smart_count} Gateways",
52 | "lastUpdate": "Letzte Aktualisierung",
53 | "nodeNew": "neu",
54 | "nodeOnline": "online",
55 | "nodeOffline": "offline",
56 | "nodeUplink": "uplink",
57 | "aboutInfo": "Über Meshviewer Mit Doppelklick kann man in die Karte hinein zoomen und Shift+Doppelklick heraus zoomen.
",
58 | "devicePicturesAttribution": "Hardware-Bilder Attribution Die Hardware Bilder sind unter %{pictures_source} lizensiert unter %{pictures_license} verfügbar
",
59 | "actual": "Aktuell",
60 | "stats": "Statistiken",
61 | "about": "Über",
62 | "toggle": "Seitenleiste anzeigen/ausblenden"
63 | },
64 | "button": {
65 | "switchView": "Ansicht wechseln",
66 | "location": "Koordinaten wählen",
67 | "tracking": "Lokalisierung",
68 | "fullscreen": "Vollbildmodus wechseln"
69 | },
70 | "momentjs": {
71 | "calendar": {
72 | "sameDay": "[heute um] LT [Uhr]",
73 | "nextDay": "[morgen um] LT [Uhr]",
74 | "nextWeek": "dddd [um] LT [Uhr]",
75 | "lastDay": "[gestern um] LT [Uhr]",
76 | "lastWeek": "[letzten] dddd [um] LT [Uhr]",
77 | "sameElse": "L"
78 | },
79 | "relativeTime": {
80 | "future": "in %s",
81 | "past": "vor %s",
82 | "s": "ein paar Sekunden",
83 | "m": "einer Minute",
84 | "mm": "%d Minuten",
85 | "h": "einer Stunde",
86 | "hh": "%d Stunden",
87 | "d": "einem Tag",
88 | "dd": "%d Tagen",
89 | "M": "einem Monat",
90 | "MM": "%d Monaten",
91 | "y": "einem Jahr",
92 | "yy": "%d Jahren"
93 | }
94 | },
95 | "yes": "ja",
96 | "no": "nein",
97 | "unknown": "unbekannt",
98 | "others": "andere",
99 | "none": "keine",
100 | "remove": "entfernen",
101 | "close": "schließen",
102 | "deprecation": "deprecated",
103 | "deprecation-text": "Warnung: Dieser Knoten ist veraltet, und wird in Zukunft nicht mehr unterstützt. Mehr Infos unter 8/64 warning . Wenn du der Eigentümer des Gerätes bist, bitten wir dich, das Gerät zu ersetzen, um weiterhin am Netz teilnehmen zu können.",
104 | "eol": "end-of-life",
105 | "eol-text": "Warnung: Dieser Knoten ist veraltet, und nicht mehr unterstützt. Mehr Infos unter 4/32 warning . Wenn du der Eigentümer des Gerätes bist, bitten wir dich, das Gerät zu ersetzen, um weiterhin am Netz teilnehmen zu können.",
106 | "loading": "%{name} graph (wird generiert)"
107 | }
108 |
--------------------------------------------------------------------------------
/lib/forcegraph/draw.ts:
--------------------------------------------------------------------------------
1 | import * as helper from "../utils/helper.js";
2 | import { ZoomTransform } from "d3-zoom";
3 | import { Link, Node } from "../utils/node.js";
4 |
5 | type Highlight = { type: string; id: string } | null;
6 |
7 | export interface MapNode extends Point {
8 | o: Node;
9 | }
10 |
11 | export interface MapLink extends Point {
12 | o: Link;
13 | source: MapNode;
14 | target: MapNode;
15 | color: string;
16 | color_to: string;
17 | }
18 |
19 | const self = {
20 | drawNode: undefined,
21 | drawLink: undefined,
22 | setCTX: undefined,
23 | setHighlight: undefined,
24 | setTransform: undefined,
25 | setMaxArea: undefined,
26 | };
27 |
28 | let ctx: CanvasRenderingContext2D; // Canvas context
29 | let width: number;
30 | let height: number;
31 | let transform: ZoomTransform;
32 | let highlight: Highlight;
33 |
34 | let NODE_RADIUS = 15;
35 | let LINE_RADIUS = 12;
36 |
37 | function drawDetailNode(node: MapNode) {
38 | if (transform.k > 1 && node.o.is_online) {
39 | let config = window.config;
40 | helper.positionClients(ctx, node, Math.PI, node.o, 15);
41 | ctx.beginPath();
42 | let name = node.o.node_id;
43 | if (node.o) {
44 | name = node.o.hostname;
45 | }
46 | ctx.textAlign = "center";
47 | ctx.fillStyle = config.forceGraph.labelColor;
48 | ctx.fillText(name, node.x, node.y + 20);
49 | }
50 | }
51 |
52 | function drawHighlightNode(node: MapNode) {
53 | if (highlight && highlight.type === "node" && node.o.node_id === highlight.id) {
54 | let config = window.config;
55 | ctx.arc(node.x, node.y, NODE_RADIUS * 1.5, 0, 2 * Math.PI);
56 | ctx.fillStyle = config.forceGraph.highlightColor;
57 | ctx.fill();
58 | ctx.beginPath();
59 | }
60 | }
61 |
62 | function drawHighlightLink(link: MapLink, to: number[]) {
63 | if (highlight && highlight.type === "link" && link.o.id === highlight.id) {
64 | let config = window.config;
65 | ctx.lineTo(to[0], to[1]);
66 | ctx.strokeStyle = config.forceGraph.highlightColor;
67 | ctx.lineWidth = LINE_RADIUS * 2;
68 | ctx.lineCap = "round";
69 | ctx.stroke();
70 | to = [link.source.x, link.source.y];
71 | }
72 | return to;
73 | }
74 |
75 | self.drawNode = function drawNode(node: MapNode) {
76 | if (
77 | node.x < transform.invertX(0) ||
78 | node.y < transform.invertY(0) ||
79 | transform.invertX(width) < node.x ||
80 | transform.invertY(height) < node.y
81 | ) {
82 | return;
83 | }
84 | ctx.beginPath();
85 |
86 | drawHighlightNode(node);
87 |
88 | let config = window.config;
89 | if (node.o.is_online) {
90 | ctx.arc(node.x, node.y, 8, 0, 2 * Math.PI);
91 | if (node.o.is_gateway) {
92 | ctx.rect(node.x - 9, node.y - 9, 18, 18);
93 | }
94 | ctx.fillStyle = config.forceGraph.nodeColor;
95 | } else {
96 | ctx.arc(node.x, node.y, 6, 0, 2 * Math.PI);
97 | ctx.fillStyle = config.forceGraph.nodeOfflineColor;
98 | }
99 |
100 | ctx.fill();
101 |
102 | drawDetailNode(node);
103 | };
104 |
105 | self.drawLink = function drawLink(link: MapLink) {
106 | let zero = transform.invert([0, 0]);
107 | let area = transform.invert([width, height]);
108 | if (
109 | (link.source.x < zero[0] && link.target.x < zero[0]) ||
110 | (link.source.y < zero[1] && link.target.y < zero[1]) ||
111 | (link.source.x > area[0] && link.target.x > area[0]) ||
112 | (link.source.y > area[1] && link.target.y > area[1])
113 | ) {
114 | return;
115 | }
116 | ctx.beginPath();
117 | ctx.moveTo(link.source.x, link.source.y);
118 | let to = [link.target.x, link.target.y];
119 |
120 | to = drawHighlightLink(link, to);
121 |
122 | let grd = ctx.createLinearGradient(link.source.x, link.source.y, link.target.x, link.target.y);
123 | grd.addColorStop(0.45, link.color);
124 | grd.addColorStop(0.55, link.color_to);
125 |
126 | ctx.lineTo(to[0], to[1]);
127 | ctx.strokeStyle = grd;
128 | if (link.o.type.indexOf("vpn") === 0) {
129 | ctx.globalAlpha = 0.2;
130 | ctx.lineWidth = 1.5;
131 | } else {
132 | ctx.globalAlpha = 0.8;
133 | ctx.lineWidth = 2.5;
134 | }
135 | ctx.stroke();
136 | ctx.globalAlpha = 1;
137 | };
138 |
139 | self.setCTX = function setCTX(newValue: CanvasRenderingContext2D) {
140 | ctx = newValue;
141 | };
142 |
143 | self.setHighlight = function setHighlight(newValue: Highlight) {
144 | highlight = newValue;
145 | };
146 |
147 | self.setTransform = function setTransform(newValue: ZoomTransform) {
148 | transform = newValue;
149 | };
150 |
151 | self.setMaxArea = function setMaxArea(newWidth: number, newHeight: number) {
152 | width = newWidth;
153 | height = newHeight;
154 | };
155 |
156 | export default self;
157 |
--------------------------------------------------------------------------------
/lib/gui.ts:
--------------------------------------------------------------------------------
1 | import { interpolate } from "d3-interpolate";
2 | import { _ } from "./utils/language.js";
3 | import { About } from "./about.js";
4 | import { Container } from "./container.js";
5 | import { DataDistributor } from "./datadistributor.js";
6 | import { ForceGraph } from "./forcegraph.js";
7 | import { Legend } from "./legend.js";
8 | import { Linklist } from "./linklist.js";
9 | import { Nodelist } from "./nodelist.js";
10 | import { Map } from "./map.js";
11 | import { Proportions } from "./proportions.js";
12 | import { SimpleNodelist } from "./simplenodelist.js";
13 | import { Sidebar } from "./sidebar.js";
14 | import { Tabs } from "./tabs.js";
15 | import { Title } from "./title.js";
16 | import { Main as Infobox } from "./infobox/main.js";
17 | import { FilterGui } from "./filters/filtergui.js";
18 | import { HostnameFilter } from "./filters/hostname.js";
19 | import * as helper from "./utils/helper.js";
20 | import { Language } from "./utils/language.js";
21 |
22 | export const Gui = function (language: ReturnType) {
23 | const self = {
24 | setData: undefined,
25 | };
26 | let content: ReturnType;
27 | let contentDiv: HTMLDivElement;
28 | let router = window.router;
29 | let config = window.config;
30 |
31 | let linkScale = interpolate(config.map.tqFrom, config.map.tqTo);
32 | let sidebar: ReturnType;
33 |
34 | let buttons = document.createElement("div");
35 | buttons.classList.add("buttons");
36 |
37 | let fanout = DataDistributor();
38 | let fanoutUnfiltered = DataDistributor();
39 | fanoutUnfiltered.add(fanout);
40 |
41 | function removeContent() {
42 | if (!content) {
43 | return;
44 | }
45 |
46 | router.removeTarget(content);
47 | fanout.remove(content);
48 |
49 | content.destroy();
50 |
51 | content = null;
52 | }
53 |
54 | function addContent(mapViewComponent: typeof Map | typeof ForceGraph) {
55 | removeContent();
56 |
57 | content = mapViewComponent(linkScale, sidebar, buttons);
58 | content.render(contentDiv);
59 |
60 | fanout.add(content);
61 | router.addTarget(content);
62 | }
63 |
64 | function mkView(mapViewComponent: typeof Map | typeof ForceGraph) {
65 | return function () {
66 | addContent(mapViewComponent);
67 | };
68 | }
69 |
70 | let loader = document.getElementsByClassName("loader")[0];
71 | loader.classList.add("hide");
72 |
73 | contentDiv = document.createElement("div");
74 | contentDiv.classList.add("content");
75 | document.body.appendChild(contentDiv);
76 |
77 | sidebar = Sidebar(document.body);
78 |
79 | contentDiv.appendChild(buttons);
80 |
81 | let buttonToggle = document.createElement("button");
82 | buttonToggle.classList.add("ion-eye");
83 | buttonToggle.setAttribute("aria-label", _.t("button.switchView"));
84 | buttonToggle.onclick = function onclick() {
85 | let data: {};
86 | if (router.currentView() === "map") {
87 | data = { view: "graph", lat: undefined, lng: undefined, zoom: undefined };
88 | } else {
89 | data = { view: "map" };
90 | }
91 | router.fullUrl(data, false, true);
92 | };
93 |
94 | buttons.appendChild(buttonToggle);
95 |
96 | if (config.fullscreen || (config.fullscreenFrame && window.frameElement)) {
97 | let buttonFullscreen = document.createElement("button");
98 | buttonFullscreen.classList.add("ion-full-enter");
99 | buttonFullscreen.setAttribute("aria-label", _.t("button.fullscreen"));
100 | buttonFullscreen.onclick = function onclick() {
101 | helper.fullscreen(buttonFullscreen);
102 | };
103 |
104 | buttons.appendChild(buttonFullscreen);
105 | }
106 |
107 | let title = Title();
108 |
109 | let header = Container("header");
110 | let infobox = Infobox(sidebar, linkScale);
111 | let tabs = Tabs();
112 | let overview = Container();
113 | let legend = Legend(language);
114 | let newnodeslist = SimpleNodelist("new", "firstseen", _.t("node.new"));
115 | let lostnodeslist = SimpleNodelist("lost", "lastseen", _.t("node.missing"));
116 | let nodelist = Nodelist();
117 | let linklist = Linklist(linkScale);
118 | let statistics = Proportions(fanout);
119 | let about = About(config.devicePicturesSource, config.devicePicturesLicense);
120 |
121 | fanoutUnfiltered.add(legend);
122 | fanoutUnfiltered.add(newnodeslist);
123 | fanoutUnfiltered.add(lostnodeslist);
124 | fanoutUnfiltered.add(infobox);
125 | fanout.add(nodelist);
126 | fanout.add(linklist);
127 | fanout.add(statistics);
128 |
129 | sidebar.add(header);
130 | header.add(legend);
131 |
132 | overview.add(newnodeslist);
133 | overview.add(lostnodeslist);
134 |
135 | let filterGui = FilterGui(fanout);
136 | fanout.watchFilters(filterGui);
137 | header.add(filterGui);
138 |
139 | let hostnameFilter = HostnameFilter();
140 | fanout.addFilter(hostnameFilter);
141 |
142 | sidebar.add(tabs);
143 | tabs.add("sidebar.actual", overview);
144 | tabs.add("node.nodes", nodelist);
145 | tabs.add("node.links", linklist);
146 | tabs.add("sidebar.stats", statistics);
147 | tabs.add("sidebar.about", about);
148 |
149 | router.addTarget(title);
150 | router.addTarget(infobox);
151 |
152 | router.addView("map", mkView(Map));
153 | router.addView("graph", mkView(ForceGraph));
154 |
155 | self.setData = fanoutUnfiltered.setData;
156 |
157 | return self;
158 | };
159 |
--------------------------------------------------------------------------------
/lib/utils/router.ts:
--------------------------------------------------------------------------------
1 | import Navigo, { Match } from "navigo";
2 | import { Language } from "./language.js";
3 | import { Link, NodeId } from "./node.js";
4 | import { Moment } from "moment";
5 |
6 | export interface Objects {
7 | nodeDict: NodeId[];
8 | links: Link[];
9 | nodes?: Node[];
10 | now?: Moment;
11 | timestamp?: Moment;
12 | }
13 |
14 | export interface TargetLocation {
15 | lng: number;
16 | zoom: number;
17 | lat: number;
18 | }
19 |
20 | export interface Target {
21 | resetView(): void;
22 | gotoNode(nodeId: NodeId, nodeIdList: NodeId[]): any;
23 | gotoLink(link: Link[]): any;
24 | gotoLocation(locationData: TargetLocation): any;
25 | }
26 |
27 | interface Views {
28 | [k: string]: () => any;
29 | }
30 |
31 | export class Router extends Navigo {
32 | init = false;
33 | objects: Objects = { nodeDict: [], links: [] };
34 | targets: Target[] = [];
35 | views: Views = {};
36 | currentState = {
37 | lang: undefined, // like de or en
38 | view: undefined, // map or graph
39 | node: undefined, // Node ID
40 | link: undefined, // Two node IDs concatenated by -
41 | zoom: undefined,
42 | lat: undefined,
43 | lng: undefined,
44 | };
45 | state = { lang: null, view: "map" };
46 | language = undefined;
47 |
48 | constructor(language: ReturnType) {
49 | super("/", { hash: true });
50 | this.language = language;
51 | this.state.lang = language.getLocale();
52 | this.initRoutes();
53 | }
54 |
55 | resetView() {
56 | this.targets.forEach(function (target) {
57 | target.resetView();
58 | });
59 | }
60 |
61 | gotoNode(node: { nodeId: NodeId }) {
62 | if (this.objects.nodeDict[node.nodeId]) {
63 | this.targets.forEach((target) => {
64 | target.gotoNode(this.objects.nodeDict[node.nodeId], this.objects.nodeDict);
65 | });
66 | }
67 | }
68 |
69 | gotoLink(linkData: { linkId: string }) {
70 | let link = this.objects.links.filter(function (value) {
71 | return value.id === linkData.linkId;
72 | });
73 | if (link) {
74 | this.targets.forEach(function (target) {
75 | target.gotoLink(link);
76 | });
77 | }
78 | }
79 |
80 | view(data: { view: string }) {
81 | if (data.view in this.views) {
82 | this.views[data.view]();
83 | this.state.view = data.view;
84 | this.resetView();
85 | }
86 | }
87 |
88 | customRoute(match?: Match) {
89 | let lang: string | undefined = match.data[0];
90 | let viewValue: "map" | "graph" | string | undefined = match.data[1];
91 | let node: string | undefined = match.data[2];
92 | let link: string | undefined = match.data[3];
93 | let zoom: number | string | undefined = match.data[4];
94 | let lat: number | string | undefined = match.data[5];
95 | let lng: number | string | undefined = match.data[6];
96 |
97 | this.currentState = {
98 | lang: lang,
99 | view: viewValue,
100 | node: node,
101 | link: link,
102 | zoom: zoom,
103 | lat: lat,
104 | lng: lng,
105 | };
106 |
107 | if (lang && lang !== this.state.lang && lang === this.language.getLocale(lang)) {
108 | console.debug("Language change reload");
109 | location.hash = "/" + match.url;
110 | location.reload();
111 | }
112 |
113 | if (!this.init || (viewValue && viewValue !== this.state.view)) {
114 | if (!viewValue) {
115 | viewValue = this.state.view;
116 | }
117 | this.view({ view: viewValue });
118 | this.init = true;
119 | }
120 |
121 | if (node) {
122 | this.gotoNode({ nodeId: node });
123 | } else if (link) {
124 | this.gotoLink({ linkId: link });
125 | } else if (lat) {
126 | this.targets.forEach((target) => {
127 | target.gotoLocation({
128 | zoom: parseInt(this.currentState.zoom, 10),
129 | lat: parseFloat(this.currentState.lat),
130 | lng: parseFloat(this.currentState.lng),
131 | });
132 | });
133 | } else {
134 | this.resetView();
135 | }
136 | }
137 |
138 | initRoutes() {
139 | this.on(
140 | // Redirect legacy URL format
141 | /^\/?!(.*)?$/,
142 | (match?: Match) => {
143 | console.debug("fixing legacy url");
144 | this.navigate(match.data[0]);
145 | },
146 | )
147 | .on(
148 | // lang, viewValue, node, link, zoom, lat, lon
149 | /^\/?(\w{2})?\/?(map|graph)?\/?([a-f\d]{12})?([a-f\d\-]{25})?\/?(?:(\d+)\/(-?[\d.]+)\/(-?[\d.]+))?$/,
150 | (match?: Match) => {
151 | this.customRoute(match);
152 | },
153 | )
154 | // Default response
155 | .on(() => {
156 | console.debug("default route redirect");
157 | this.fullUrl();
158 | })
159 | // 404 response
160 | .notFound(() => {
161 | console.debug("notFound redirect");
162 | this.fullUrl();
163 | });
164 | }
165 |
166 | generateLink(data?: {}, full?: boolean, deep?: boolean) {
167 | let result = "";
168 |
169 | if (full) {
170 | data = Object.assign({}, this.state, data);
171 | } else if (deep) {
172 | result = "#";
173 | data = Object.assign({}, this.currentState, data);
174 | }
175 |
176 | for (let key in data) {
177 | if (!data.hasOwnProperty(key) || data[key] === undefined || data[key] === "") {
178 | continue;
179 | }
180 | result += "/" + data[key];
181 | }
182 |
183 | return result;
184 | }
185 |
186 | fullUrl(data?: {}, e?: Event | false, deep?: boolean) {
187 | if (e) {
188 | e.preventDefault();
189 | }
190 | this.navigate(this.generateLink(data, !deep, deep));
191 | }
192 |
193 | getLang() {
194 | let lang = location.hash.match(/^\/?#?\/(\w{2})\//);
195 | if (lang) {
196 | this.state.lang = this.language.getLocale(lang[1]);
197 | return lang[1];
198 | }
199 | return null;
200 | }
201 |
202 | addTarget(target: Target) {
203 | this.targets.push(target);
204 | }
205 | removeTarget(target: Target) {
206 | this.targets = this.targets.filter(function (t) {
207 | return target !== t;
208 | });
209 | }
210 |
211 | addView(key: string, view: () => any) {
212 | this.views[key] = view;
213 | }
214 |
215 | currentView(): string | undefined {
216 | return this.currentState.view;
217 | }
218 |
219 | setData(data: Objects) {
220 | this.objects = data;
221 | }
222 | }
223 |
--------------------------------------------------------------------------------
/lib/utils/node.ts:
--------------------------------------------------------------------------------
1 | import { h } from "snabbdom";
2 | import moment, { Moment } from "moment";
3 | import { _ } from "./language.js";
4 | import * as helper from "./helper.js";
5 |
6 | export type LinkId = string;
7 |
8 | export interface Link {
9 | type: string; // wifi, vpn etc
10 | id: LinkId;
11 | distance: number;
12 | source: Node;
13 | target: Node;
14 | source_addr: string;
15 | target_addr: string;
16 | source_mac?: string; // Same as _addr
17 | target_mac?: string; // Same as _addr
18 | source_tq: number;
19 | target_tq: number;
20 | }
21 |
22 | export interface Neighbour {
23 | node: Node;
24 | link: Link;
25 | }
26 |
27 | export interface Firmware {
28 | release: string;
29 | base: string;
30 | }
31 |
32 | export type IPAddress = string;
33 |
34 | export interface Autoupdater {
35 | enabled: boolean;
36 | branch: string;
37 | }
38 |
39 | export type NodeId = string;
40 |
41 | export interface Node {
42 | node_id: NodeId;
43 | hostname: string;
44 | domain?: string;
45 | firstseen: Moment;
46 | lastseen: Moment;
47 | is_online: boolean;
48 | location: LatLon;
49 | neighbours: Neighbour[];
50 | firmware: Firmware;
51 | uptime: number;
52 | nproc: number;
53 | loadavg: number;
54 | memory_usage: number;
55 | model?: string;
56 | clients: number;
57 | clients_wifi24: number;
58 | clients_wifi5: number;
59 | clients_other: number;
60 | is_gateway: boolean;
61 | addresses: IPAddress[];
62 | gateway_nexthop: NodeId;
63 | gateway: NodeId;
64 | gateway6: string; // mac address for some reason
65 | autoupdater: Autoupdater;
66 | }
67 |
68 | const self = {
69 | showStatus: undefined,
70 | showGeoURI: undefined,
71 | showGateway: undefined,
72 | showFirmware: undefined,
73 | showUptime: undefined,
74 | showFirstSeen: undefined,
75 | showLoad: undefined,
76 | showRAM: undefined,
77 | showDomain: undefined,
78 | countLocalClients: undefined,
79 | showClients: undefined,
80 | showIPs: undefined,
81 | showAutoupdate: undefined,
82 | };
83 |
84 | function showBar(value: string, width: number, warning: boolean) {
85 | return h("span", { props: { className: "bar" + (warning ? " warning" : "") } }, [
86 | h("span", {
87 | style: { width: width * 100 + "%" },
88 | }),
89 | h("label", value),
90 | ]);
91 | }
92 |
93 | self.showStatus = function showStatus(node: Node) {
94 | return h(
95 | "td",
96 | { props: { className: node.is_online ? "online" : "offline" } },
97 | _.t(node.is_online ? "node.lastOnline" : "node.lastOffline", {
98 | time: node.lastseen.fromNow(),
99 | date: node.lastseen.format("DD.MM.YYYY, H:mm:ss"),
100 | }),
101 | );
102 | };
103 |
104 | self.showGeoURI = function showGeoURI(data: Node) {
105 | if (!helper.hasLocation(data)) {
106 | return undefined;
107 | }
108 |
109 | return h(
110 | "td",
111 | h(
112 | "a",
113 | { props: { href: "geo:" + data.location.latitude + "," + data.location.longitude } },
114 | Number(data.location.latitude.toFixed(6)) + ", " + Number(data.location.longitude.toFixed(6)),
115 | ),
116 | );
117 | };
118 |
119 | self.showGateway = function showGateway(node: Node) {
120 | return node.is_gateway ? _.t("yes") : undefined;
121 | };
122 |
123 | self.showFirmware = function showFirmware(node: Node) {
124 | return (
125 | [helper.dictGet(node, ["firmware", "release"]), helper.dictGet(node, ["firmware", "base"])]
126 | .filter(function (value) {
127 | return value !== null;
128 | })
129 | .join(" / ") || undefined
130 | );
131 | };
132 |
133 | self.showUptime = function showUptime(node: Node) {
134 | return moment.utc(node.uptime).local().fromNow(true);
135 | };
136 |
137 | self.showFirstSeen = function showFirstSeen(node: Node) {
138 | return moment.utc(node.firstseen).local().fromNow();
139 | };
140 |
141 | self.showLoad = function showLoad(node: Node) {
142 | return showBar(node.loadavg.toFixed(2), node.loadavg / (node.nproc || 1), node.loadavg >= node.nproc);
143 | };
144 |
145 | self.showRAM = function showRAM(node: Node) {
146 | return showBar(Math.round(node.memory_usage * 100) + " %", node.memory_usage, node.memory_usage >= 0.8);
147 | };
148 |
149 | self.showDomain = function showDomain(node: Node) {
150 | let domainTitle = node.domain;
151 | let config = window.config;
152 | if (config.domainNames) {
153 | config.domainNames.some(function (domain) {
154 | if (domainTitle === domain.domain) {
155 | domainTitle = domain.name;
156 | return true;
157 | }
158 | });
159 | }
160 | return domainTitle;
161 | };
162 |
163 | self.countLocalClients = function countLocalClients(node: Node, visited = {}) {
164 | if (node.node_id in visited) return 0;
165 | visited[node.node_id] = 1;
166 | let count = node.clients || 0;
167 | node.neighbours.forEach(function (neighbour) {
168 | if (neighbour.link.type === "vpn") return;
169 | count += self.countLocalClients(neighbour.node, visited);
170 | });
171 | return count;
172 | };
173 |
174 | self.showClients = function showClients(node: Node) {
175 | if (!node.is_online) {
176 | return undefined;
177 | }
178 | let localClients = self.countLocalClients(node);
179 |
180 | let clients = [
181 | h("span", [
182 | node.clients > 0 ? node.clients : _.t("none"),
183 | h("br"),
184 | h("i", { props: { className: "ion-people", title: _.t("node.clients") } }),
185 | ]),
186 | h("span", { props: { className: "legend-24ghz" } }, [
187 | node.clients_wifi24,
188 | h("br"),
189 | h("span", { props: { className: "symbol", title: "2,4 GHz" } }),
190 | ]),
191 | h("span", { props: { className: "legend-5ghz" } }, [
192 | node.clients_wifi5,
193 | h("br"),
194 | h("span", { props: { className: "symbol", title: "5 GHz" } }),
195 | ]),
196 | h("span", { props: { className: "legend-others" } }, [
197 | node.clients_other,
198 | h("br"),
199 | h("span", { props: { className: "symbol", title: _.t("others") } }),
200 | ]),
201 | h("span", [
202 | localClients > 0 ? localClients : _.t("none"),
203 | h("br"),
204 | h("i", { props: { className: "ion-share-alt", title: _.t("node.localClients") } }),
205 | ]),
206 | ];
207 | return h("td", { props: { className: "clients" } }, clients);
208 | };
209 |
210 | self.showIPs = function showIPs(node: Node) {
211 | let string = [];
212 | let ips = node.addresses;
213 | ips.sort();
214 | ips.forEach(function (ip, i) {
215 | if (i > 0) {
216 | string.push(h("br"));
217 | }
218 |
219 | if (ip.indexOf("fe80:") !== 0) {
220 | string.push(h("a", { props: { href: "http://[" + ip + "]/", target: "_blank" } }, ip));
221 | } else {
222 | string.push(ip);
223 | }
224 | });
225 | return h("td", string);
226 | };
227 |
228 | self.showAutoupdate = function showAutoupdate(node: Node) {
229 | return node.autoupdater.enabled
230 | ? _.t("node.activated", { branch: node.autoupdater.branch })
231 | : _.t("node.deactivated");
232 | };
233 |
234 | export default self;
235 |
--------------------------------------------------------------------------------
/lib/utils/helper.ts:
--------------------------------------------------------------------------------
1 | import { Moment } from "moment";
2 | import { h, Props, VNode, VNodeChildren, VNodeData } from "snabbdom";
3 | import { Map } from "leaflet";
4 | import { _ } from "./language.js";
5 | import { Node } from "./node.js";
6 | import { LinkInfo, NodeInfo } from "../config_default.js";
7 |
8 | export const get = function get(url: string) {
9 | return new Promise(function (resolve, reject) {
10 | let req = new XMLHttpRequest();
11 | req.open("GET", url);
12 |
13 | req.onload = function onload() {
14 | if (req.status === 200) {
15 | resolve(req.response);
16 | } else {
17 | reject(Error(req.statusText));
18 | }
19 | };
20 |
21 | req.onerror = function onerror() {
22 | reject(Error("Network Error"));
23 | };
24 |
25 | req.send();
26 | });
27 | };
28 |
29 | export const getJSON = function getJSON(url: string) {
30 | return get(url).then(JSON.parse);
31 | };
32 |
33 | export const sortByKey = function sortByKey(key: string, data: { [k: string]: Moment }[]) {
34 | return data.sort(function (a, b) {
35 | return b[key].unix() - a[key].unix();
36 | });
37 | };
38 |
39 | export const limit = function limit(
40 | key: string,
41 | moment: Moment,
42 | data: { [k: string]: { isAfter: (p: Moment) => boolean } }[],
43 | ) {
44 | return data.filter(function (entry) {
45 | return entry[key].isAfter(moment);
46 | });
47 | };
48 |
49 | export const sum = function sum(items: number[]) {
50 | return items.reduce(function (a, b) {
51 | return a + b;
52 | }, 0);
53 | };
54 |
55 | export const one = function one() {
56 | return 1;
57 | };
58 |
59 | export const dictGet = function dictGet(dict: { [x: string]: any }, keys: string[]) {
60 | let key = keys.shift();
61 |
62 | if (!(key in dict)) {
63 | return null;
64 | }
65 |
66 | if (keys.length === 0) {
67 | return dict[key];
68 | }
69 |
70 | return dictGet(dict[key], keys);
71 | };
72 |
73 | export const listReplace = function listReplace(template: string, subst: ReplaceMapping) {
74 | for (let key in subst) {
75 | if (subst.hasOwnProperty(key)) {
76 | let re = new RegExp(key, "g");
77 | template = template.replace(re, subst[key]);
78 | }
79 | }
80 | return template;
81 | };
82 |
83 | export const hasLocation = function hasLocation(data: Node | {}) {
84 | return "location" in data && Math.abs(data.location.latitude) < 90 && Math.abs(data.location.longitude) < 180;
85 | };
86 |
87 | export const hasUplink = function hasUplink(data: Node | {}) {
88 | if (!("neighbours" in data)) {
89 | return false;
90 | }
91 | let uplink = false;
92 | data.neighbours.forEach(function (l) {
93 | if (l.link.type === "vpn") {
94 | uplink = true;
95 | }
96 | });
97 | return uplink;
98 | };
99 |
100 | export const subtract = function subtract(a: Node[], b: Node[]) {
101 | let ids = {};
102 |
103 | b.forEach(function (d) {
104 | ids[d.node_id] = true;
105 | });
106 |
107 | return a.filter(function (d) {
108 | return !ids[d.node_id];
109 | });
110 | };
111 |
112 | /* Helpers working with links */
113 |
114 | export const showDistance = function showDistance(data: { distance: number }) {
115 | if (isNaN(data.distance)) {
116 | return "";
117 | }
118 |
119 | return data.distance.toFixed(0) + " m";
120 | };
121 |
122 | export const showTq = function showTq(tq: number) {
123 | return (tq * 100).toFixed(0) + "%";
124 | };
125 |
126 | export function attributeEntry(children: VNode[], label: string, value: string | VNode) {
127 | if (value !== undefined) {
128 | if (typeof value !== "object") {
129 | value = h("td", value);
130 | }
131 |
132 | children.push(h("tr", [h("th", _.t(label)), value]));
133 | }
134 | }
135 |
136 | export function showStat(linkInfo: LinkInfo, subst: ReplaceMapping): HTMLDivElement {
137 | let content: VNode;
138 | if (linkInfo.image) {
139 | content = h("img", {
140 | props: {
141 | src: listReplace(linkInfo.image, subst),
142 | width: linkInfo.width,
143 | height: linkInfo.height,
144 | alt: _.t("loading", { name: linkInfo.name }),
145 | },
146 | });
147 | } else {
148 | content = h("p", listReplace(linkInfo.title, subst));
149 | }
150 |
151 | if (linkInfo.href) {
152 | return h(
153 | "div",
154 | h(
155 | "a",
156 | {
157 | props: {
158 | href: listReplace(linkInfo.href, subst),
159 | target: "_blank",
160 | title: listReplace(linkInfo.title, subst),
161 | },
162 | },
163 | content,
164 | ),
165 | ) as unknown as HTMLDivElement;
166 | }
167 | return h("div", content) as unknown as HTMLDivElement;
168 | }
169 |
170 | export const showDevicePicture = function showDevicePicture(pictures: string, subst: ReplaceMapping) {
171 | if (!pictures) {
172 | return null;
173 | }
174 |
175 | return h("img", {
176 | props: { src: listReplace(pictures, subst) },
177 | class: { "hw-img": true },
178 | on: {
179 | // hide non-existent images
180 | error: function (e: any) {
181 | e.target.style.display = "none";
182 | },
183 | },
184 | });
185 | };
186 |
187 | export const getTileBBox = function getTileBBox(size: Point, map: Map, tileSize: number, margin: number) {
188 | let tl = map.unproject([size.x - margin, size.y - margin]);
189 | let br = map.unproject([size.x + margin + tileSize, size.y + margin + tileSize]);
190 |
191 | return { minX: br.lat, minY: tl.lng, maxX: tl.lat, maxY: br.lng };
192 | };
193 |
194 | export const positionClients = function positionClients(
195 | ctx: CanvasRenderingContext2D,
196 | point: Point,
197 | startAngle: number,
198 | node: Node,
199 | startDistance: number,
200 | ) {
201 | if (node.clients === 0) {
202 | return;
203 | }
204 |
205 | let radius = 3;
206 | let a = 1.2;
207 | let mode = 0;
208 | let config = window.config;
209 |
210 | ctx.beginPath();
211 | ctx.fillStyle = config.client.wifi24;
212 |
213 | for (let orbit = 0, i = 0; i < node.clients; orbit++) {
214 | let distance = startDistance + orbit * 2 * radius * a;
215 | let n = Math.floor((Math.PI * distance) / (a * radius));
216 | let delta = node.clients - i;
217 |
218 | for (let j = 0; j < Math.min(delta, n); i++, j++) {
219 | if (mode !== 1 && i >= node.clients_wifi24 + node.clients_wifi5) {
220 | mode = 1;
221 | ctx.fill();
222 | ctx.beginPath();
223 | ctx.fillStyle = config.client.wifi5;
224 | } else if (mode === 0 && i >= node.clients_wifi24) {
225 | mode = 2;
226 | ctx.fill();
227 | ctx.beginPath();
228 | ctx.fillStyle = config.client.other;
229 | }
230 | let angle = ((2 * Math.PI) / n) * j;
231 | let x = point.x + distance * Math.cos(angle + startAngle);
232 | let y = point.y + distance * Math.sin(angle + startAngle);
233 |
234 | ctx.moveTo(x, y);
235 | ctx.arc(x, y, radius, 0, 2 * Math.PI);
236 | }
237 | }
238 | ctx.fill();
239 | };
240 |
241 | export const fullscreen = function fullscreen(btn: HTMLButtonElement) {
242 | if (!document.fullscreenElement && !document["webkitFullscreenElement"] && !document["mozFullScreenElement"]) {
243 | let fel = document.firstElementChild;
244 | let func = fel.requestFullscreen || fel["webkitRequestFullScreen"] || fel["mozRequestFullScreen"];
245 | func.call(fel);
246 | btn.classList.remove("ion-full-enter");
247 | btn.classList.add("ion-full-exit");
248 | } else {
249 | let func = document.exitFullscreen || document["webkitExitFullscreen"] || document["mozCancelFullScreen"];
250 | if (func) {
251 | func.call(document);
252 | btn.classList.remove("ion-full-exit");
253 | btn.classList.add("ion-full-enter");
254 | }
255 | }
256 | };
257 |
258 | export const escape = function escape(string: string) {
259 | return string.replace(//g, ">").replace(/"/g, """).replace(/'/g, "'");
260 | };
261 |
--------------------------------------------------------------------------------
/lib/proportions.ts:
--------------------------------------------------------------------------------
1 | import * as d3Interpolate from "d3-interpolate";
2 | import { Moment } from "moment";
3 | import { classModule, eventListenersModule, h, init, propsModule, styleModule, VNode } from "snabbdom";
4 | import { DataDistributor, Filter, ObjectsLinksAndNodes } from "./datadistributor.js";
5 | import { GenericNodeFilter } from "./filters/genericnode.js";
6 | import * as helper from "./utils/helper.js";
7 | import { _ } from "./utils/language.js";
8 | import { Node } from "./utils/node.js";
9 | import { compare } from "./utils/version.js";
10 |
11 | type TableNode = {
12 | element: HTMLTableElement;
13 | vnode?: VNode;
14 | };
15 |
16 | const patch = init([classModule, propsModule, styleModule, eventListenersModule]);
17 |
18 | export const Proportions = function (filterManager: ReturnType) {
19 | const self = {
20 | setData: undefined,
21 | render: undefined,
22 | renderSingle: undefined,
23 | };
24 | let config = window.config;
25 | let scale = d3Interpolate.interpolate(config.forceGraph.tqFrom, config.forceGraph.tqTo);
26 | let time: Moment;
27 |
28 | let tables: Record = {};
29 |
30 | function count(nodes: Node[], key: string[], f?: (k: any) => any) {
31 | let dict = {};
32 |
33 | nodes.forEach(function (node) {
34 | let dictKey = helper.dictGet(node, key.slice(0));
35 |
36 | if (f !== undefined) {
37 | dictKey = f(dictKey);
38 | }
39 |
40 | if (dictKey === null) {
41 | return;
42 | }
43 |
44 | dict[dictKey] = 1 + (dictKey in dict ? dict[dictKey] : 0);
45 | });
46 |
47 | return Object.keys(dict).map(function (dictKey) {
48 | return [dictKey, dict[dictKey], key, f];
49 | });
50 | }
51 |
52 | function addFilter(filter: Filter) {
53 | return function () {
54 | filterManager.addFilter(filter);
55 | return false;
56 | };
57 | }
58 |
59 | function fillTable(name: string, table: TableNode | undefined, data: any[][]): TableNode {
60 | let tableNode: TableNode = table ?? {
61 | element: document.createElement("table"),
62 | vnode: undefined,
63 | };
64 |
65 | let max = Math.max.apply(
66 | Math,
67 | data.map(function (data) {
68 | return data[1];
69 | }),
70 | );
71 |
72 | let items = data.map(function (data) {
73 | let v = data[1] / max;
74 |
75 | let filter = GenericNodeFilter(_.t(name), data[2], data[0], data[3]);
76 |
77 | let a = h("a", { on: { click: addFilter(filter) } }, data[0]);
78 |
79 | let th = h("th", a);
80 | let td = h(
81 | "td",
82 | h(
83 | "span",
84 | {
85 | style: {
86 | width: "calc(25px + " + Math.round(v * 90) + "%)",
87 | backgroundColor: scale(v),
88 | },
89 | },
90 | data[1].toFixed(0),
91 | ),
92 | );
93 |
94 | return h("tr", [th, td]);
95 | });
96 | let tableNew = h("table", { props: { className: "proportion" } }, items);
97 | tableNode.vnode = patch(tableNode.vnode ?? tableNode.element, tableNew);
98 | return tableNode;
99 | }
100 |
101 | self.setData = function setData(data: ObjectsLinksAndNodes) {
102 | let nodes = data.nodes.all;
103 | time = data.timestamp;
104 |
105 | function hostnameOfNodeID(nodeid: string | null) {
106 | // nodeid is a mac address here
107 | let gateway = data.nodeDict[nodeid];
108 | if (gateway) {
109 | return gateway.hostname;
110 | }
111 | return null;
112 | }
113 |
114 | function sortVersionCountAndName(a, b) {
115 | // descending by count
116 | if (b[1] !== a[1]) {
117 | return b[1] - a[1];
118 | }
119 | return compare(a[0], b[0]);
120 | }
121 |
122 | let gatewayDict = count(nodes, ["gateway"], hostnameOfNodeID);
123 | let gateway6Dict = count(nodes, ["gateway6"], hostnameOfNodeID);
124 |
125 | let statusDict = count(nodes, ["is_online"], function (d) {
126 | return d ? "online" : "offline";
127 | });
128 | let fwDict = count(nodes, ["firmware", "release"]);
129 | let baseDict = count(nodes, ["firmware", "base"]);
130 | let deprecationDict = count(nodes, ["model"], function (d) {
131 | if (config.deprecated && d && config.deprecated.includes(d)) return _.t("deprecation");
132 | if (config.eol && d && config.eol.includes(d)) return _.t("eol");
133 | return _.t("no");
134 | });
135 | let hwDict = count(nodes, ["model"]);
136 | let geoDict = count(nodes, ["location"], function (d) {
137 | return d && d.longitude && d.latitude ? _.t("yes") : _.t("no");
138 | });
139 |
140 | let autoDict = count(nodes, ["autoupdater"], function (d) {
141 | if (d.enabled) {
142 | return d.branch;
143 | }
144 | return _.t("node.deactivated");
145 | });
146 |
147 | let domainDict = count(nodes, ["domain"], function (d) {
148 | if (config.domainNames) {
149 | config.domainNames.some(function (t) {
150 | if (d === t.domain) {
151 | d = t.name;
152 | return true;
153 | }
154 | });
155 | }
156 | return d;
157 | });
158 |
159 | tables.status = fillTable(
160 | "node.status",
161 | tables.status,
162 | statusDict.sort(function (a, b) {
163 | return b[1] - a[1];
164 | }),
165 | );
166 |
167 | tables.firmware = fillTable("node.firmware", tables.firmware, fwDict.sort(sortVersionCountAndName));
168 |
169 | tables.baseversion = fillTable("node.baseversion", tables.baseversion, baseDict.sort(sortVersionCountAndName));
170 |
171 | tables.deprecationStatus = fillTable(
172 | "node.deprecationStatus",
173 | tables.deprecationStatus,
174 | deprecationDict.sort(function (a, b) {
175 | return b[1] - a[1];
176 | }),
177 | );
178 |
179 | tables.hardware = fillTable(
180 | "node.hardware",
181 | tables.hardware,
182 | hwDict.sort(function (a, b) {
183 | return b[1] - a[1];
184 | }),
185 | );
186 |
187 | tables.visible = fillTable(
188 | "node.visible",
189 | tables.visible,
190 | geoDict.sort(function (a, b) {
191 | return b[1] - a[1];
192 | }),
193 | );
194 |
195 | tables.update = fillTable(
196 | "node.update",
197 | tables.update,
198 | autoDict.sort(function (a, b) {
199 | return b[1] - a[1];
200 | }),
201 | );
202 | tables.gateway = fillTable(
203 | "node.selectedGatewayIPv4",
204 | tables.gateway,
205 | gatewayDict.sort(function (a, b) {
206 | return b[1] - a[1];
207 | }),
208 | );
209 | tables.gateway6 = fillTable(
210 | "node.selectedGatewayIPv6",
211 | tables.gateway6,
212 | gateway6Dict.sort(function (a, b) {
213 | return b[1] - a[1];
214 | }),
215 | );
216 | tables.domain = fillTable(
217 | "node.domain",
218 | tables.domain,
219 | domainDict.sort(function (a, b) {
220 | return b[1] - a[1];
221 | }),
222 | );
223 | };
224 |
225 | self.render = function render(el: HTMLElement) {
226 | self.renderSingle(el, "node.status", tables.status.element);
227 | self.renderSingle(el, "node.firmware", tables.firmware.element);
228 | self.renderSingle(el, "node.baseversion", tables.baseversion.element);
229 | self.renderSingle(el, "node.deprecationStatus", tables.deprecationStatus.element);
230 | self.renderSingle(el, "node.hardware", tables.hardware.element);
231 | self.renderSingle(el, "node.visible", tables.visible.element);
232 | self.renderSingle(el, "node.update", tables.update.element);
233 | self.renderSingle(el, "node.selectedGatewayIPv4", tables.gateway.element);
234 | self.renderSingle(el, "node.selectedGatewayIPv6", tables.gateway6.element);
235 | self.renderSingle(el, "node.domain", tables.domain.element);
236 |
237 | if (config.globalInfos) {
238 | let images = document.createElement("div");
239 | el.appendChild(images);
240 | let img = [];
241 | let subst = {
242 | "{TIME}": String(time.unix()),
243 | "{LOCALE}": _.locale(),
244 | };
245 | config.globalInfos.forEach(function (globalInfo) {
246 | img.push(h("h2", globalInfo.name));
247 | img.push(helper.showStat(globalInfo, subst));
248 | });
249 | patch(images, h("div", img));
250 | }
251 | };
252 |
253 | self.renderSingle = function renderSingle(el: HTMLElement, heading: string, table: HTMLTableElement) {
254 | if (table.children.length > 0) {
255 | let h2 = document.createElement("h2");
256 | h2.classList.add("proportion-header");
257 | h2.textContent = _.t(heading);
258 | h2.onclick = function onclick() {
259 | table.classList.toggle("hide");
260 | };
261 | el.appendChild(h2);
262 | el.appendChild(table);
263 | }
264 | };
265 | return self;
266 | };
267 |
--------------------------------------------------------------------------------
/lib/map.ts:
--------------------------------------------------------------------------------
1 | import * as L from "leaflet";
2 |
3 | import { ClientLayer } from "./map/clientlayer.js";
4 | import { LabelLayer } from "./map/labellayer.js";
5 | import { Button } from "./map/button.js";
6 | import "./map/activearea.js";
7 | import { Sidebar } from "./sidebar.js";
8 | import { LatLng } from "leaflet";
9 | import { Geo } from "./config_default.js";
10 | import { Link, LinkId, Node, NodeId } from "./utils/node.js";
11 | import { ObjectsLinksAndNodes } from "./datadistributor.js";
12 |
13 | let options = {
14 | worldCopyJump: true,
15 | zoomControl: true,
16 | minZoom: 0,
17 | };
18 |
19 | export const Map = function (linkScale: (t: any) => any, sidebar: ReturnType, buttons: HTMLElement) {
20 | const self = {
21 | setData: undefined,
22 | resetView: undefined,
23 | gotoNode: undefined,
24 | gotoLink: undefined,
25 | gotoLocation: undefined,
26 | destroy: undefined,
27 | render: undefined,
28 | };
29 | let savedView: { center: LatLng; zoom: number } | undefined;
30 | let config = window.config;
31 |
32 | let map: L.Map & { setActiveArea?: any };
33 | let layerControl: L.Control.Layers;
34 | let baseLayers = {};
35 |
36 | function saveView() {
37 | savedView = {
38 | center: map.getCenter(),
39 | zoom: map.getZoom(),
40 | };
41 | }
42 |
43 | function contextMenuOpenLayerMenu() {
44 | document.querySelector(".leaflet-control-layers").classList.add("leaflet-control-layers-expanded");
45 | }
46 |
47 | function mapActiveArea() {
48 | map.setActiveArea({
49 | position: "absolute",
50 | left: sidebar.getWidth() + "px",
51 | right: 0,
52 | top: 0,
53 | bottom: 0,
54 | });
55 | }
56 |
57 | function setActiveArea() {
58 | setTimeout(mapActiveArea, 300);
59 | }
60 |
61 | let el = document.createElement("div");
62 | el.classList.add("map");
63 |
64 | map = L.map(el, options);
65 | mapActiveArea();
66 |
67 | let now = new Date();
68 | config.mapLayers.forEach(function (item, i) {
69 | if (
70 | (typeof item.config.start === "number" && item.config.start <= now.getHours()) ||
71 | (typeof item.config.end === "number" && item.config.end > now.getHours())
72 | ) {
73 | item.config.order = item.config.start * -1;
74 | } else {
75 | item.config.order = i;
76 | }
77 | });
78 |
79 | config.mapLayers = config.mapLayers.sort(function (a, b) {
80 | return a.config.order - b.config.order;
81 | });
82 |
83 | let layers = config.mapLayers.map(function (layer) {
84 | return {
85 | name: layer.name,
86 | layer: L.tileLayer(
87 | layer.url.replace(
88 | "{format}",
89 | document.createElement("canvas").toDataURL("image/webp").indexOf("data:image/webp") === 0 ? "webp" : "png",
90 | ),
91 | layer.config,
92 | ),
93 | };
94 | });
95 |
96 | map.addLayer(layers[0].layer);
97 |
98 | layers.forEach(function (layer) {
99 | baseLayers[layer.name] = layer.layer;
100 | });
101 |
102 | let button = Button(map, buttons);
103 |
104 | map.on("locationfound", button.locationFound);
105 | map.on("locationerror", button.locationError);
106 | map.on("dragend", saveView);
107 | map.on("contextmenu", contextMenuOpenLayerMenu);
108 |
109 | if (config.geo) {
110 | [].forEach.call(config.geo, function (geo?: Geo) {
111 | if (geo) {
112 | L.geoJSON(geo.json, geo.option).addTo(map);
113 | }
114 | });
115 | }
116 |
117 | button.init();
118 |
119 | layerControl = L.control.layers(baseLayers, undefined, { position: "bottomright" });
120 | layerControl.addTo(map);
121 |
122 | map.zoomControl.setPosition("topright");
123 |
124 | // @ts-ignore
125 | let clientLayer = new ClientLayer({ minZoom: config.clientZoom });
126 | clientLayer.addTo(map);
127 | clientLayer.setZIndex(5);
128 |
129 | // @ts-ignore
130 | let labelLayer = new LabelLayer({ minZoom: config.labelZoom });
131 | labelLayer.addTo(map);
132 | labelLayer.setZIndex(6);
133 |
134 | sidebar.button.addEventListener("visibility", setActiveArea);
135 |
136 | map.on("zoom", function () {
137 | clientLayer.redraw();
138 | labelLayer.redraw();
139 | });
140 |
141 | map.on("baselayerchange", function (e: any & { name: string }) {
142 | const selectedLayer = baseLayers[e.name];
143 | if (selectedLayer && selectedLayer.options.maxZoom !== undefined) {
144 | const maxZoom = selectedLayer.options.maxZoom;
145 | map.options.maxZoom = maxZoom;
146 | clientLayer.options.maxZoom = maxZoom;
147 | labelLayer.options.maxZoom = maxZoom;
148 |
149 | if (map.getZoom() > maxZoom) {
150 | map.setZoom(maxZoom);
151 | }
152 | }
153 |
154 | let html_tag: Element = document.querySelector("html");
155 | let class_list = html_tag.classList;
156 | const mode = selectedLayer?.options?.mode;
157 | class_list.forEach(function (item) {
158 | if (item.startsWith("theme_")) {
159 | class_list.remove(item);
160 | }
161 | });
162 | if (html_tag && mode && mode !== "" && !html_tag.classList.contains(mode)) {
163 | class_list.add("theme_" + mode);
164 | labelLayer.updateLayer();
165 | }
166 | });
167 |
168 | map.on("load", function () {
169 | let inputs = document.querySelectorAll(".leaflet-control-layers-selector");
170 | [].forEach.call(inputs, function (input: HTMLInputElement) {
171 | input.setAttribute("role", "radiogroup");
172 | // @ts-ignore
173 | input.setAttribute("aria-label", input.nextSibling.innerHTML.trim());
174 | });
175 | });
176 |
177 | let nodeDict = {};
178 | let linkDict = {};
179 | let highlight: { type: "node"; o: Node } | { type: "link"; o: Link } | undefined;
180 |
181 | function resetMarkerStyles(
182 | nodes: { [k: NodeId]: { resetStyle: () => any } },
183 | links: { [k: LinkId]: { resetStyle: () => any } },
184 | ) {
185 | Object.keys(nodes).forEach(function (id) {
186 | nodes[id].resetStyle();
187 | });
188 |
189 | Object.keys(links).forEach(function (id) {
190 | links[id].resetStyle();
191 | });
192 | }
193 |
194 | function setView(bounds: L.LatLngBoundsExpression, zoom?: number) {
195 | map.fitBounds(bounds, { maxZoom: zoom ? zoom : config.nodeZoom });
196 | }
197 |
198 | function goto(element: { getLatLng: () => L.LatLngExpression; getBounds?: () => L.LatLngBoundsExpression }) {
199 | let bounds: L.LatLngBoundsExpression;
200 |
201 | if ("getBounds" in element) {
202 | bounds = element.getBounds();
203 | } else {
204 | bounds = L.latLngBounds([element.getLatLng()]);
205 | }
206 |
207 | setView(bounds);
208 |
209 | return element;
210 | }
211 |
212 | function updateView(nopanzoom?: boolean) {
213 | resetMarkerStyles(nodeDict, linkDict);
214 | let target: { setStyle: any; getLatLng: () => L.LatLngExpression; getBounds?: () => L.LatLngBoundsExpression };
215 |
216 | if (highlight !== undefined) {
217 | if (highlight.type === "node" && nodeDict[highlight.o.node_id]) {
218 | target = nodeDict[highlight.o.node_id];
219 | target.setStyle(config.map.highlightNode);
220 | } else if (highlight.type === "link" && linkDict[highlight.o.id]) {
221 | target = linkDict[highlight.o.id];
222 | target.setStyle(config.map.highlightLink);
223 | }
224 | }
225 |
226 | if (!nopanzoom) {
227 | if (target) {
228 | goto(target);
229 | } else if (savedView) {
230 | map.setView(savedView.center, savedView.zoom);
231 | } else {
232 | setView(config.fixedCenter);
233 | }
234 | }
235 | }
236 |
237 | self.setData = function setData(data: ObjectsLinksAndNodes) {
238 | nodeDict = {};
239 | linkDict = {};
240 |
241 | clientLayer.setData(data);
242 | labelLayer.setData(data, map, nodeDict, linkDict, linkScale);
243 |
244 | updateView(true);
245 | };
246 |
247 | self.resetView = function resetView() {
248 | button.disableTracking();
249 | highlight = undefined;
250 | updateView();
251 | };
252 |
253 | self.gotoNode = function gotoNode(node: Node) {
254 | button.disableTracking();
255 | highlight = { type: "node", o: node };
256 | updateView();
257 | };
258 |
259 | self.gotoLink = function gotoLink(link: Link[]) {
260 | button.disableTracking();
261 | highlight = { type: "link", o: link[0] };
262 | updateView();
263 | };
264 |
265 | self.gotoLocation = function gotoLocation(destination: L.LatLngLiteral & { zoom: number }) {
266 | button.disableTracking();
267 | map.setView([destination.lat, destination.lng], destination.zoom);
268 | };
269 |
270 | self.destroy = function destroy() {
271 | button.clearButtons();
272 | sidebar.button.removeEventListener("visibility", setActiveArea);
273 | map.remove();
274 |
275 | if (el.parentNode) {
276 | el.parentNode.removeChild(el);
277 | }
278 | };
279 |
280 | self.render = function render(d: HTMLElement) {
281 | d.appendChild(el);
282 | map.invalidateSize();
283 | };
284 |
285 | return self;
286 | };
287 |
--------------------------------------------------------------------------------
/lib/infobox/node.ts:
--------------------------------------------------------------------------------
1 | import { h, classModule, eventListenersModule, init, propsModule, styleModule, VNode } from "snabbdom";
2 | import { _ } from "../utils/language.js";
3 |
4 | import { SortTable } from "../sorttable.js";
5 | import * as helper from "../utils/helper.js";
6 | import nodef, { Neighbour, Node as NodeData, NodeId } from "../utils/node.js";
7 | import { NodeInfo } from "../config_default.js";
8 |
9 | const patch = init([classModule, propsModule, styleModule, eventListenersModule]);
10 |
11 | function showStatImg(nodeInfo: NodeInfo, node: NodeData): HTMLDivElement {
12 | let config = window.config;
13 | let subst = {
14 | "{NODE_ID}": node.node_id,
15 | "{NODE_NAME}": node.hostname.replace(/[^a-z0-9\-]/gi, "_"),
16 | "{NODE_CUSTOM}": node.hostname.replace(config.node_custom, "_"),
17 | "{TIME}": node.lastseen.format("DDMMYYYYHmmss"),
18 | "{LOCALE}": _.locale(),
19 | };
20 | return helper.showStat(nodeInfo, subst);
21 | }
22 |
23 | function showDevicePictures(pictures: string, device: NodeData) {
24 | if (!device.model) {
25 | return null;
26 | }
27 | let subst = {
28 | "{MODEL}": device.model,
29 | "{NODE_NAME}": device.hostname,
30 | "{MODEL_HASH}": device.model
31 | .split("")
32 | .reduce(function (a, b) {
33 | a = (a << 5) - a + b.charCodeAt(0);
34 | return a & a;
35 | }, 0)
36 | .toString(),
37 | "{MODEL_NORMALIZED}": device.model
38 | .toLowerCase()
39 | .replace(/[^a-z0-9.\-]+/gi, "-")
40 | .replace(/^-+/, "")
41 | .replace(/-+$/, ""),
42 | };
43 | return helper.showDevicePicture(pictures, subst);
44 | }
45 |
46 | export function Node(el: HTMLElement, node: NodeData, linkScale: (t: any) => any, nodeDict: { [k: NodeId]: NodeData }) {
47 | let config = window.config;
48 | let router = window.router;
49 |
50 | function nodeLink(node: NodeData) {
51 | return h(
52 | "a",
53 | {
54 | props: {
55 | className: node.is_online ? "online" : "offline",
56 | href: router.generateLink({ node: node.node_id }),
57 | },
58 | on: {
59 | click: function (e: Event) {
60 | router.fullUrl({ node: node.node_id }, e);
61 | },
62 | },
63 | },
64 | node.hostname,
65 | );
66 | }
67 |
68 | function nodeIdLink(nodeId: NodeId) {
69 | if (nodeDict[nodeId]) {
70 | return nodeLink(nodeDict[nodeId]);
71 | }
72 | return nodeId;
73 | }
74 |
75 | function showGateway(node: NodeData) {
76 | let gatewayCols = [
77 | h("span", [nodeIdLink(node.gateway_nexthop), h("br"), _.t("node.nexthop")]),
78 | h("span", { props: { className: "ion-arrow-right-c" } }),
79 | h("span", [nodeIdLink(node.gateway), h("br"), "IPv4"]),
80 | ];
81 |
82 | if (node.gateway6 !== undefined) {
83 | gatewayCols.push(h("span", [nodeIdLink(node.gateway6), h("br"), "IPv6"]));
84 | }
85 |
86 | return h("td", { props: { className: "gateway" } }, gatewayCols);
87 | }
88 |
89 | function renderNeighbourRow(connecting: Neighbour) {
90 | let icons = [
91 | h("span", {
92 | props: {
93 | className: "icon ion-" + (connecting.link.type.indexOf("wifi") === 0 ? "wifi" : "share-alt"),
94 | title: _.t(connecting.link.type),
95 | },
96 | }),
97 | ];
98 | if (helper.hasLocation(connecting.node)) {
99 | icons.push(h("span", { props: { className: "ion-location", title: _.t("location.location") } }));
100 | }
101 |
102 | return h("tr", [
103 | h("td", icons),
104 | h("td", nodeLink(connecting.node)),
105 | h("td", connecting.node.clients),
106 | h("td", [
107 | h(
108 | "a",
109 | {
110 | style: {
111 | color: linkScale((connecting.link.source_tq + connecting.link.target_tq) / 2),
112 | },
113 | props: {
114 | title: connecting.link.source.hostname + " - " + connecting.link.target.hostname,
115 | href: router.generateLink({ link: connecting.link.id }),
116 | },
117 | on: {
118 | click: function (e: Event) {
119 | router.fullUrl({ link: connecting.link.id }, e);
120 | },
121 | },
122 | },
123 | helper.showTq(connecting.link.source_tq) + " - " + helper.showTq(connecting.link.target_tq),
124 | ),
125 | ]),
126 | h("td", helper.showDistance(connecting.link)),
127 | ]);
128 | }
129 |
130 | const self = {
131 | render: undefined,
132 | setData: undefined,
133 | };
134 |
135 | let headings = [
136 | {
137 | name: "",
138 | sort: function (a: Neighbour, b: Neighbour) {
139 | return a.link.type.localeCompare(b.link.type);
140 | },
141 | },
142 | {
143 | name: "node.nodes",
144 | sort: function (a: Neighbour, b: Neighbour) {
145 | return a.node.hostname.localeCompare(b.node.hostname);
146 | },
147 | reverse: false,
148 | },
149 | {
150 | name: "node.clients",
151 | class: "ion-people",
152 | sort: function (a: Neighbour, b: Neighbour) {
153 | return a.node.clients - b.node.clients;
154 | },
155 | reverse: true,
156 | },
157 | {
158 | name: "node.tq",
159 | class: "ion-connection-bars",
160 | sort: function (a: Neighbour, b: Neighbour) {
161 | return a.link.source_tq - b.link.source_tq;
162 | },
163 | reverse: true,
164 | },
165 | {
166 | name: "node.distance",
167 | class: "ion-arrow-resize",
168 | sort: function (a: Neighbour, b: Neighbour) {
169 | return (
170 | (a.link.distance === undefined ? -1 : a.link.distance) -
171 | (b.link.distance === undefined ? -1 : b.link.distance)
172 | );
173 | },
174 | reverse: true,
175 | },
176 | ];
177 |
178 | let container = document.createElement("div");
179 | el.appendChild(container);
180 | let containerVnode: VNode | undefined;
181 |
182 | let tableNeighbour = SortTable(headings, 1, renderNeighbourRow, ["node-links"]);
183 |
184 | self.render = function render() {
185 | let newContainer = h("div", [h("h2", node.hostname)]);
186 |
187 | // Device picture
188 | let devicePictures: VNode = showDevicePictures(config.devicePictures, node);
189 | let devicePicturesContainerData = {
190 | props: {
191 | className: "hw-img-container",
192 | },
193 | };
194 | newContainer.children.push(devicePictures ? h("div", devicePicturesContainerData, devicePictures) : h("div"));
195 |
196 | let attributeTable = h("table", { props: { className: "attributes" } }, []);
197 |
198 | let showDeprecation = false;
199 | let showEol = false;
200 |
201 | config.nodeAttr.forEach(function (row) {
202 | let field = node[String(row.value)];
203 | if (typeof row.value === "function") {
204 | field = row.value(node, nodeDict);
205 | } else if (nodef["show" + row.value] !== undefined) {
206 | field = nodef["show" + row.value](node);
207 | }
208 | // Check if device is in list of deprecated devices. If so, display the deprecation warning
209 | if (config.deprecation_enabled) {
210 | if (row.name === "node.hardware") {
211 | if (config.eol && field && config.eol.includes(field)) {
212 | showEol = true;
213 | } else if (config.deprecated && field && config.deprecated.includes(field)) {
214 | showDeprecation = true;
215 | }
216 | }
217 | }
218 |
219 | if (field) {
220 | if (typeof field !== "object") {
221 | field = h("td", field);
222 | }
223 | attributeTable.children.push(h("tr", [row.name !== undefined ? h("th", _.t(row.name)) : null, field]));
224 | }
225 | });
226 | attributeTable.children.push(h("tr", [h("th", _.t("node.gateway")), showGateway(node)]));
227 |
228 | // Deprecation warning
229 | if (showEol) {
230 | // Add eol warning to the container
231 | newContainer.children.push(
232 | h("div", { props: { className: "eol" } }, [
233 | h("div", {
234 | props: {
235 | innerHTML: config.eol_text || _.t("eol"),
236 | },
237 | }),
238 | ]),
239 | );
240 | } else if (showDeprecation) {
241 | // Add deprecation warning to the container
242 | newContainer.children.push(
243 | h("div", { props: { className: "deprecated" } }, [
244 | h("div", {
245 | props: {
246 | innerHTML: config.deprecation_text || _.t("deprecation"),
247 | },
248 | }),
249 | ]),
250 | );
251 | }
252 |
253 | // Attributes
254 | newContainer.children.push(attributeTable);
255 |
256 | // // Neighbors
257 | newContainer.children.push(h("h3", _.t("node.link", node.neighbours.length) + " (" + node.neighbours.length + ")"));
258 | if (node.neighbours.length > 0) {
259 | tableNeighbour.setData(node.neighbours);
260 | newContainer.children.push(tableNeighbour.vnode);
261 | }
262 |
263 | // // Images
264 | if (config.nodeInfos) {
265 | let img = [];
266 | config.nodeInfos.forEach(function (nodeInfo) {
267 | img.push(h("h4", nodeInfo.name) as unknown as HTMLElement);
268 | img.push(showStatImg(nodeInfo, node));
269 | });
270 | newContainer.children.push(h("div", img));
271 | }
272 |
273 | containerVnode = patch(containerVnode ?? container, newContainer);
274 | };
275 |
276 | self.setData = function setData(data: { nodeDict: { [x: NodeId]: NodeData } }) {
277 | if (data.nodeDict[node.node_id]) {
278 | node = data.nodeDict[node.node_id];
279 | }
280 | self.render();
281 | };
282 | return self;
283 | }
284 |
--------------------------------------------------------------------------------
/lib/map/activearea.js:
--------------------------------------------------------------------------------
1 | /**
2 | * https://github.com/Mappy/Leaflet-active-area
3 | * Apache 2.0 license https://www.apache.org/licenses/LICENSE-2.0
4 | */
5 | import * as L from "leaflet";
6 |
7 | let previousMethods = {
8 | getCenter: L.Map.prototype.getCenter,
9 | setView: L.Map.prototype.setView,
10 | setZoomAround: L.Map.prototype.setZoomAround,
11 | getBoundsZoom: L.Map.prototype.getBoundsZoom,
12 | RendererUpdate: L.Renderer.prototype._update,
13 | };
14 |
15 | L.Map.include({
16 | getBounds: function () {
17 | if (this._viewport) {
18 | return this.getViewportLatLngBounds();
19 | }
20 | let bounds = this.getPixelBounds();
21 | let sw = this.unproject(bounds.getBottomLeft());
22 | let ne = this.unproject(bounds.getTopRight());
23 |
24 | return new L.LatLngBounds(sw, ne);
25 | },
26 |
27 | getViewport: function () {
28 | return this._viewport;
29 | },
30 |
31 | getViewportBounds: function () {
32 | let viewport = this._viewport;
33 | let topleft = L.point(viewport.offsetLeft, viewport.offsetTop);
34 | let vpsize = L.point(viewport.clientWidth, viewport.clientHeight);
35 |
36 | if (vpsize.x === 0 || vpsize.y === 0) {
37 | // Our own viewport has no good size - so we fall back to the container size:
38 | viewport = this.getContainer();
39 | if (viewport) {
40 | topleft = L.point(0, 0);
41 | vpsize = L.point(viewport.clientWidth, viewport.clientHeight);
42 | }
43 | }
44 |
45 | return L.bounds(topleft, topleft.add(vpsize));
46 | },
47 |
48 | getViewportLatLngBounds: function () {
49 | let bounds = this.getViewportBounds();
50 | return L.latLngBounds(this.containerPointToLatLng(bounds.min), this.containerPointToLatLng(bounds.max));
51 | },
52 |
53 | getOffset: function () {
54 | let mCenter = this.getSize().divideBy(2);
55 | let vCenter = this.getViewportBounds().getCenter();
56 |
57 | return mCenter.subtract(vCenter);
58 | },
59 |
60 | getCenter: function (withoutViewport) {
61 | let center = previousMethods.getCenter.call(this);
62 |
63 | if (this.getViewport() && !withoutViewport) {
64 | let zoom = this.getZoom();
65 | let point = this.project(center, zoom);
66 | point = point.subtract(this.getOffset());
67 |
68 | center = this.unproject(point, zoom);
69 | }
70 |
71 | return center;
72 | },
73 |
74 | setView: function (center, zoom, options) {
75 | center = L.latLng(center);
76 | zoom = zoom === undefined ? this._zoom : this._limitZoom(zoom);
77 |
78 | if (this.getViewport()) {
79 | let point = this.project(center, this._limitZoom(zoom));
80 | point = point.add(this.getOffset());
81 | center = this.unproject(point, this._limitZoom(zoom));
82 | }
83 |
84 | return previousMethods.setView.call(this, center, zoom, options);
85 | },
86 |
87 | setZoomAround: function (latlng, zoom, options) {
88 | let viewport = this.getViewport();
89 |
90 | if (viewport) {
91 | let scale = this.getZoomScale(zoom);
92 | let viewHalf = this.getViewportBounds().getCenter();
93 | let containerPoint = latlng instanceof L.Point ? latlng : this.latLngToContainerPoint(latlng);
94 |
95 | let centerOffset = containerPoint.subtract(viewHalf).multiplyBy(1 - 1 / scale);
96 | let newCenter = this.containerPointToLatLng(viewHalf.add(centerOffset));
97 |
98 | return this.setView(newCenter, zoom, { zoom: options });
99 | }
100 | return previousMethods.setZoomAround.call(this, latlng, zoom, options);
101 | },
102 |
103 | getBoundsZoom: function (bounds, inside, padding) {
104 | // (LatLngBounds[, Boolean, Point]) -> Number
105 | bounds = L.latLngBounds(bounds);
106 | padding = L.point(padding || [0, 0]);
107 |
108 | let zoom = this.getZoom() || 0;
109 | let min = this.getMinZoom();
110 | let max = this.getMaxZoom();
111 | let nw = bounds.getNorthWest();
112 | let se = bounds.getSouthEast();
113 | let viewport = this.getViewport();
114 | let size = (viewport ? L.point(viewport.clientWidth, viewport.clientHeight) : this.getSize()).subtract(padding);
115 | let boundsSize = this.project(se, zoom).subtract(this.project(nw, zoom));
116 | let snap = L.Browser.any3d ? this.options.zoomSnap : 1;
117 |
118 | let scale = Math.min(size.x / boundsSize.x, size.y / boundsSize.y);
119 |
120 | zoom = this.getScaleZoom(scale, zoom);
121 |
122 | if (snap) {
123 | zoom = Math.round(zoom / (snap / 100)) * (snap / 100); // don't jump if within 1% of a snap level
124 | zoom = inside ? Math.ceil(zoom / snap) * snap : Math.floor(zoom / snap) * snap;
125 | }
126 |
127 | return Math.max(min, Math.min(max, zoom));
128 | },
129 | });
130 |
131 | L.Map.include({
132 | setActiveArea: function (css, keepCenter, animate) {
133 | let center;
134 | if (keepCenter && this._zoom) {
135 | // save center if map is already initialized
136 | // and keepCenter is passed
137 | center = this.getCenter();
138 | }
139 |
140 | if (!this._viewport) {
141 | // Make viewport if not already made
142 | let container = this.getContainer();
143 | this._viewport = L.DomUtil.create("div", "");
144 | container.insertBefore(this._viewport, container.firstChild);
145 | }
146 |
147 | if (typeof css === "string") {
148 | this._viewport.className = css;
149 | } else {
150 | L.extend(this._viewport.style, css);
151 | }
152 |
153 | if (center) {
154 | this.setView(center, this.getZoom(), { animate: !!animate });
155 | }
156 | return this;
157 | },
158 | });
159 |
160 | L.Renderer.include({
161 | _onZoom: function () {
162 | this._updateTransform(this._map.getCenter(true), this._map.getZoom());
163 | },
164 |
165 | _update: function () {
166 | previousMethods.RendererUpdate.call(this);
167 | this._center = this._map.getCenter(true);
168 | },
169 | });
170 |
171 | L.GridLayer.include({
172 | _updateLevels: function () {
173 | let zoom = this._tileZoom;
174 | let maxZoom = this.options.maxZoom;
175 |
176 | if (zoom === undefined) {
177 | return undefined;
178 | }
179 |
180 | for (let zoomLevel in this._levels) {
181 | if (this._levels[zoomLevel].el.children.length || zoomLevel === zoom) {
182 | this._levels[zoomLevel].el.style.zIndex = maxZoom - Math.abs(zoom - zoomLevel);
183 | } else {
184 | L.DomUtil.remove(this._levels[zoomLevel].el);
185 | this._removeTilesAtZoom(zoomLevel);
186 | delete this._levels[zoomLevel];
187 | }
188 | }
189 |
190 | let level = this._levels[zoom];
191 | let map = this._map;
192 |
193 | if (!level) {
194 | level = this._levels[zoom] = {};
195 |
196 | level.el = L.DomUtil.create("div", "leaflet-tile-container leaflet-zoom-animated", this._container);
197 | level.el.style.zIndex = maxZoom;
198 |
199 | level.origin = map.project(map.unproject(map.getPixelOrigin()), zoom).round();
200 | level.zoom = zoom;
201 |
202 | this._setZoomTransform(level, map.getCenter(true), map.getZoom());
203 |
204 | // force the browser to consider the newly added element for transition
205 | L.Util.falseFn(level.el.offsetWidth);
206 | }
207 |
208 | this._level = level;
209 |
210 | return level;
211 | },
212 |
213 | _resetView: function (e) {
214 | let animating = e && (e.pinch || e.flyTo);
215 | this._setView(this._map.getCenter(true), this._map.getZoom(), animating, animating);
216 | },
217 |
218 | _update: function (center) {
219 | let map = this._map;
220 | if (!map) {
221 | return;
222 | }
223 | let zoom = map.getZoom();
224 |
225 | if (center === undefined) {
226 | center = map.getCenter(this);
227 | }
228 | if (this._tileZoom === undefined) {
229 | return;
230 | } // if out of minzoom/maxzoom
231 |
232 | let pixelBounds = this._getTiledPixelBounds(center);
233 | let tileRange = this._pxBoundsToTileRange(pixelBounds);
234 | let tileCenter = tileRange.getCenter();
235 | let queue = [];
236 |
237 | for (let key in this._tiles) {
238 | this._tiles[key].current = false;
239 | }
240 |
241 | // _update just loads more tiles. If the tile zoom level differs too much
242 | // from the map's, let _setView reset levels and prune old tiles.
243 | if (Math.abs(zoom - this._tileZoom) > 1) {
244 | this._setView(center, zoom);
245 | return;
246 | }
247 |
248 | // create a queue of coordinates to load tiles from
249 | for (let j = tileRange.min.y; j <= tileRange.max.y; j++) {
250 | for (let i = tileRange.min.x; i <= tileRange.max.x; i++) {
251 | let coords = new L.Point(i, j);
252 | coords.z = this._tileZoom;
253 |
254 | if (!this._isValidTile(coords)) {
255 | continue;
256 | }
257 |
258 | let tile = this._tiles[this._tileCoordsToKey(coords)];
259 | if (tile) {
260 | tile.current = true;
261 | } else {
262 | queue.push(coords);
263 | }
264 | }
265 | }
266 |
267 | // sort tile queue to load tiles in order of their distance to center
268 | queue.sort(function (a, b) {
269 | return a.distanceTo(tileCenter) - b.distanceTo(tileCenter);
270 | });
271 |
272 | if (queue.length !== 0) {
273 | // if it's the first batch of tiles to load
274 | if (!this._loading) {
275 | this._loading = true;
276 | // @event loading: Event
277 | // Fired when the grid layer starts loading tiles
278 | this.fire("loading");
279 | }
280 |
281 | // create DOM fragment to append tiles in one batch
282 | let fragment = document.createDocumentFragment();
283 |
284 | for (let i = 0; i < queue.length; i++) {
285 | this._addTile(queue[i], fragment);
286 | }
287 |
288 | this._level.el.appendChild(fragment);
289 | }
290 | },
291 | });
292 |
--------------------------------------------------------------------------------