├── .editorconfig
├── .eslintignore
├── .eslintrc.json
├── .github
├── CONTRIBUTING.md
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── PULL_REQUEST_TEMPLATE.md
├── dependabot.yml
└── workflows
│ ├── build-docker.yml
│ ├── build-meshviewer.yml
│ ├── publish-docker.yml
│ └── release-meshviewer.yml
├── .gitignore
├── .prettierignore
├── .prettierrc.json
├── .stylelintrc
├── DEVELOPMENT.md
├── Dockerfile
├── LICENSE.md
├── README.md
├── assets
├── fonts
│ ├── Assistant-Bold.ttf
│ ├── Assistant-Bold.woff
│ ├── Assistant-Bold.woff2
│ ├── Assistant-Light.ttf
│ ├── Assistant-Light.woff
│ ├── Assistant-Light.woff2
│ ├── meshviewer.ttf
│ ├── meshviewer.woff
│ └── meshviewer.woff2
├── icons
│ ├── _icon-mixin.scss
│ └── icon.scss
└── logo.svg
├── config.example.json
├── index.html
├── lib
├── about.ts
├── config_default.ts
├── container.ts
├── datadistributor.ts
├── filters
│ ├── filtergui.ts
│ ├── genericnode.ts
│ ├── hostname.ts
│ └── nodefilter.ts
├── forcegraph.ts
├── forcegraph
│ └── draw.ts
├── global.d.ts
├── gui.ts
├── index.ts
├── infobox
│ ├── link.ts
│ ├── location.ts
│ ├── main.ts
│ └── node.ts
├── legend.ts
├── linklist.ts
├── load.ts
├── main.ts
├── map.ts
├── map
│ ├── activearea.js
│ ├── button.js
│ ├── clientlayer.ts
│ ├── labellayer.js
│ └── locationmarker.js
├── nodelist.ts
├── offline.ts
├── proportions.ts
├── sidebar.ts
├── simplenodelist.ts
├── sorttable.ts
├── tabs.ts
├── title.ts
├── types.d.ts
└── utils
│ ├── helper.ts
│ ├── language.ts
│ ├── math.ts
│ ├── node.ts
│ ├── router.ts
│ └── version.ts
├── offline.html
├── package-lock.json
├── package.json
├── public
├── apple-touch-icon-180x180.png
├── favicon.ico
├── locale
│ ├── cz.json
│ ├── de.json
│ ├── en.json
│ ├── fr.json
│ ├── ru.json
│ └── tr.json
├── maskable-icon-512x512.png
├── pwa-192x192.png
├── pwa-512x512.png
└── pwa-64x64.png
├── scss
├── custom
│ ├── _custom.scss
│ └── _variables.scss
├── main.scss
├── mixins
│ ├── _font.scss
│ └── _icon.scss
├── modules
│ ├── _base.scss
│ ├── _button.scss
│ ├── _filter.scss
│ ├── _forcegraph.scss
│ ├── _infobox.scss
│ ├── _leaflet.scss
│ ├── _legend.scss
│ ├── _loader.scss
│ ├── _map.scss
│ ├── _node.scss
│ ├── _proportion.scss
│ ├── _reset.scss
│ ├── _sidebar.scss
│ ├── _table.scss
│ ├── _tabs.scss
│ ├── _variables.scss
│ └── font
│ │ ├── _font.scss
│ │ └── _icon.scss
└── night.scss
├── tsconfig.json
└── vite.config.js
/.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 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | /build
2 | /dev-dist
3 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "parserOptions": {
4 | "sourceType": "module"
5 | },
6 | "env": {
7 | "browser": true,
8 | "es2020": true,
9 | "node": true
10 | },
11 | "extends": ["eslint:recommended", "prettier"],
12 | "rules": {
13 | "no-undef": "off",
14 | "no-prototype-builtins": "off",
15 | "no-useless-escape": "off"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.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@v4
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 |
--------------------------------------------------------------------------------
/.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: [20.x]
14 | steps:
15 | - uses: actions/checkout@v4
16 | - name: Use Node.js ${{ matrix.node-version }}
17 | uses: actions/setup-node@v4
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 |
--------------------------------------------------------------------------------
/.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@v4
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 |
--------------------------------------------------------------------------------
/.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: [20.x]
13 | steps:
14 | - uses: actions/checkout@v4
15 | - name: Use Node.js ${{ matrix.node-version }}
16 | uses: actions/setup-node@v4
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@v2
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 |
--------------------------------------------------------------------------------
/.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 | /config.json
19 | *.zip
20 | /dev-dist
21 | /public/config.json
22 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | /build
2 | /dev-dist
3 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120
3 | }
4 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/DEVELOPMENT.md:
--------------------------------------------------------------------------------
1 | # Meshviewer Development
2 |
3 | Following you can find some wording and used functionality for this project.
4 |
5 | Normally you should use meaningful and self explaining names for variables and functions
6 | but sometimes using common conventions might help as well, for example `i` / `j` for index, `e` for exceptions or events etc.
7 | but also names based on elements like `p`, `a`, `div`..
8 |
9 | ## Functions
10 |
11 | `_.t("[translation.selector]")`
12 | : Lookup translation based on dotted path from `public/locale/[language].json`
13 |
14 | ## Variables
15 |
16 | `a` / `b`
17 | : Used when sorting data
18 |
19 | `d`
20 | : Is normally used to represent a selected dom node but can be (sadly) any data object.
21 |
22 | `el`
23 | : An element or dom node
24 |
25 | `f`
26 | : Functions / callbacks
27 |
28 | `L`
29 | : [Leaflet.js](https://github.com/Leaflet/Leaflet)
30 |
31 | `V`
32 | : [Snabbdom](https://github.com/snabbdom/snabbdom) virtual dom
33 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | ### Build stage for the website frontend
2 | FROM --platform=$BUILDPLATFORM node:24-bookworm-slim AS build
3 | RUN apt-get update && \
4 | apt-get install -y python3
5 | WORKDIR /code
6 | COPY . ./
7 | RUN npm install
8 | RUN npm audit
9 | RUN npm run lint
10 | RUN npm run build
11 |
12 | FROM nginx:1.29.0-alpine
13 | COPY --from=build /code/build/ /usr/share/nginx/html
14 | COPY --from=build /code/config.example.json /usr/share/nginx/html/
15 | EXPOSE 80
16 |
--------------------------------------------------------------------------------
/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 | ### Build yourself
18 |
19 | - Clone this repository
20 | - Run `npm install`
21 | - Place your config file in `public/config.json`.
22 | You can copy the example config for testing/development: `cp config.example.json public/config.json`.
23 | - Run `npm run build`
24 | - A production build can then be found in [`/build`](./build)
25 |
26 | Hint: You can start a development server with `npm run dev`
27 |
28 | ### Build and run using Docker
29 |
30 | Static local test instance:
31 |
32 | ```bash
33 | docker run -it --rm -u $(id -u):$(id -g) -v "$PWD":/app -w /app node npm install
34 | docker run -it --rm -u $(id -u):$(id -g) -v "$PWD":/app -w /app node npm run build
35 | docker run -it --rm -v "$PWD/build":/usr/share/nginx/html -p 8080:80 --name nginx nginx
36 | ```
37 |
38 | The map is reachable at [localhost:8080](http://localhost:8080).
39 | You have to copy `config.example.json` to `public/config.json`:
40 |
41 | Start a development environment:
42 |
43 | ```bash
44 | docker run -it --rm --name meshviewer-dev \
45 | -u $(id -u):$(id -g) \
46 | -v "$PWD":/app -w /app \
47 | -e NODE_ENV=development \
48 | -p 5173:5173 \
49 | node npm run dev -- --host 0.0.0.0
50 | ```
51 |
52 | ## Configuration
53 |
54 | The configuration documentation is nowhere near finished.
55 |
56 | ### Deprecation Warning
57 |
58 | The deprecation warning can be turned of with `"deprecation_enabled": false` - but we wouldn't suggest it.
59 |
60 | You can insert your own HTML into the deprecation warning via `"deprecation_text":""`.
61 |
62 | ## Development
63 |
64 | To contribute to the project by developing new features, have a look at our [development documentation](DEVELOPMENT.md).
65 |
66 | ## History
67 |
68 | Meshviewer started as [ffnord/meshviewer](https://github.com/ffnord/meshviewer) for Freifunk Nord
69 | which was extended as [hopglass/hopglass](https://github.com/hopglass/hopglass)
70 | and further expanded by Freifunk Regensburg as [ffrgb/meshviewer](https://github.com/ffrgb/meshviewer).
71 | After maintenance stopped, Freifunk Frankfurt took over expanding the code base as [freifunk-ffm/meshviewer](https://github.com/freifunk-ffm/meshviewer)
72 | and added features like the deprecation warnings.
73 | It is now maintained by the Freifunk Org at [freifunk/meshviewer](https://github.com/freifunk/meshviewer).
74 |
75 | ## Goals
76 |
77 | The goal for this project is to extend Meshviewer, pick off where other forks ended
78 | and integrate those ideas into a code-base that is easily usable by all Freifunk communities.
79 | This also has the benefit that everyone can take advantage of the bundled development resources
80 | for implementing new features and fixing bugs.
81 |
--------------------------------------------------------------------------------
/assets/fonts/Assistant-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/f9e9f8c4d00a30a98f7b21422e784a32066a2a70/assets/fonts/Assistant-Bold.ttf
--------------------------------------------------------------------------------
/assets/fonts/Assistant-Bold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/f9e9f8c4d00a30a98f7b21422e784a32066a2a70/assets/fonts/Assistant-Bold.woff
--------------------------------------------------------------------------------
/assets/fonts/Assistant-Bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/f9e9f8c4d00a30a98f7b21422e784a32066a2a70/assets/fonts/Assistant-Bold.woff2
--------------------------------------------------------------------------------
/assets/fonts/Assistant-Light.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/f9e9f8c4d00a30a98f7b21422e784a32066a2a70/assets/fonts/Assistant-Light.ttf
--------------------------------------------------------------------------------
/assets/fonts/Assistant-Light.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/f9e9f8c4d00a30a98f7b21422e784a32066a2a70/assets/fonts/Assistant-Light.woff
--------------------------------------------------------------------------------
/assets/fonts/Assistant-Light.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/f9e9f8c4d00a30a98f7b21422e784a32066a2a70/assets/fonts/Assistant-Light.woff2
--------------------------------------------------------------------------------
/assets/fonts/meshviewer.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/f9e9f8c4d00a30a98f7b21422e784a32066a2a70/assets/fonts/meshviewer.ttf
--------------------------------------------------------------------------------
/assets/fonts/meshviewer.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/f9e9f8c4d00a30a98f7b21422e784a32066a2a70/assets/fonts/meshviewer.woff
--------------------------------------------------------------------------------
/assets/fonts/meshviewer.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/f9e9f8c4d00a30a98f7b21422e784a32066a2a70/assets/fonts/meshviewer.woff2
--------------------------------------------------------------------------------
/assets/icons/_icon-mixin.scss:
--------------------------------------------------------------------------------
1 | @mixin icon($name, $code, $prefix: "ion-") {
2 | .#{$prefix}#{$name} {
3 | &::before {
4 | content: "#{$code}";
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/assets/icons/icon.scss:
--------------------------------------------------------------------------------
1 | // Needed for standalone scss
2 | // @import 'icon-mixin';
3 |
4 | $cache-breaker: unique-id();
5 |
6 | @font-face {
7 | font-family: "ionicons";
8 | font-style: normal;
9 | font-weight: normal;
10 | src:
11 | url("@fonts/meshviewer.woff2?rel=#{$cache-breaker}") format("woff2"),
12 | url("@fonts/meshviewer.woff?rel=#{$cache-breaker}") format("woff"),
13 | url("@fonts/meshviewer.ttf?rel=#{$cache-breaker}") format("truetype");
14 | }
15 |
16 | [class^="ion-"],
17 | [class*=" ion-"] {
18 | &::before {
19 | display: inline-block;
20 | font-family: $font-family-icons;
21 | font-style: normal;
22 | font-variant: normal;
23 | font-weight: normal;
24 | line-height: 1;
25 | speak: none;
26 | text-rendering: auto;
27 | text-transform: none;
28 | vertical-align: 0;
29 | }
30 | }
31 |
32 | @include icon("chevron-left", "\f124");
33 | @include icon("chevron-right", "\f125");
34 | @include icon("pin", "\f3a3");
35 | @include icon("wifi", "\f25c");
36 | @include icon("eye", "\f133");
37 | @include icon("up-b", "\f10d");
38 | @include icon("down-b", "\f104");
39 | @include icon("locate", "\f2e9");
40 | @include icon("close", "\f2d7");
41 | @include icon("location", "\f456");
42 | @include icon("layer", "\f229");
43 | @include icon("filter", "\f38B");
44 | @include icon("connection-bars", "\f274");
45 | @include icon("share-alt", "\f3ac");
46 | @include icon("clipboard", "\f376");
47 | @include icon("people", "\f39e");
48 | @include icon("person", "\f3a0");
49 | @include icon("time", "\f3b3");
50 | @include icon("arrow-resize", "\f264");
51 | @include icon("arrow-left-c", "\f108");
52 | @include icon("arrow-right-c", "\f10b");
53 | @include icon("full-enter", "\e901");
54 | @include icon("full-exit", "\e900");
55 |
--------------------------------------------------------------------------------
/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 | Meshviewer
4 |
5 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/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 | "deprecation_enabled": true
71 | }
72 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
24 |
25 |
26 |
27 | Meshviewer - loading...
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | Lade
40 |
41 |
42 | Karten & Knoten...
43 |
44 |
45 | JavaScript required
46 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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: ReturnType;
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/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/index.ts:
--------------------------------------------------------------------------------
1 | import "../scss/main.scss";
2 | import { load } from "./load.js";
3 |
4 | load();
5 |
--------------------------------------------------------------------------------
/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/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/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/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 |
200 | config.nodeAttr.forEach(function (row) {
201 | let field = node[String(row.value)];
202 | if (typeof row.value === "function") {
203 | field = row.value(node, nodeDict);
204 | } else if (nodef["show" + row.value] !== undefined) {
205 | field = nodef["show" + row.value](node);
206 | }
207 | // Check if device is in list of deprecated devices. If so, display the deprecation warning
208 | if (config.deprecation_enabled) {
209 | if (row.name === "node.hardware") {
210 | if (config.deprecated && field && config.deprecated.includes(field)) {
211 | showDeprecation = true;
212 | }
213 | }
214 | }
215 |
216 | if (field) {
217 | if (typeof field !== "object") {
218 | field = h("td", field);
219 | }
220 | attributeTable.children.push(h("tr", [row.name !== undefined ? h("th", _.t(row.name)) : null, field]));
221 | }
222 | });
223 | attributeTable.children.push(h("tr", [h("th", _.t("node.gateway")), showGateway(node)]));
224 |
225 | // Deprecation warning
226 | if (showDeprecation) {
227 | // Add deprecation warning to the container
228 | newContainer.children.push(
229 | h("div", { props: { className: "deprecated" } }, [
230 | h("div", {
231 | props: {
232 | innerHTML: config.deprecation_text || _.t("deprecation"),
233 | },
234 | }),
235 | ]),
236 | );
237 | }
238 |
239 | // Attributes
240 | newContainer.children.push(attributeTable);
241 |
242 | // // Neighbors
243 | newContainer.children.push(h("h3", _.t("node.link", node.neighbours.length) + " (" + node.neighbours.length + ")"));
244 | if (node.neighbours.length > 0) {
245 | tableNeighbour.setData(node.neighbours);
246 | newContainer.children.push(tableNeighbour.vnode);
247 | }
248 |
249 | // // Images
250 | if (config.nodeInfos) {
251 | let img = [];
252 | config.nodeInfos.forEach(function (nodeInfo) {
253 | img.push(h("h4", nodeInfo.name) as unknown as HTMLElement);
254 | img.push(showStatImg(nodeInfo, node));
255 | });
256 | newContainer.children.push(h("div", img));
257 | }
258 |
259 | containerVnode = patch(containerVnode ?? container, newContainer);
260 | };
261 |
262 | self.setData = function setData(data: { nodeDict: { [x: NodeId]: NodeData } }) {
263 | if (data.nodeDict[node.node_id]) {
264 | node = data.nodeDict[node.node_id];
265 | }
266 | self.render();
267 | };
268 | return self;
269 | }
270 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 = 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) {
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) {
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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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: true,
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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/lib/offline.ts:
--------------------------------------------------------------------------------
1 | import "../scss/main.scss";
2 |
--------------------------------------------------------------------------------
/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 onlineNodes = data.nodes.online;
103 | let nodes = onlineNodes.concat(data.nodes.lost);
104 | time = data.timestamp;
105 |
106 | function hostnameOfNodeID(nodeid: string | null) {
107 | // nodeid is a mac address here
108 | let gateway = data.nodeDict[nodeid];
109 | if (gateway) {
110 | return gateway.hostname;
111 | }
112 | return null;
113 | }
114 |
115 | function sortVersionCountAndName(a, b) {
116 | // descending by count
117 | if (b[1] !== a[1]) {
118 | return b[1] - a[1];
119 | }
120 | return compare(a[0], b[0]);
121 | }
122 |
123 | let gatewayDict = count(nodes, ["gateway"], hostnameOfNodeID);
124 | let gateway6Dict = count(nodes, ["gateway6"], hostnameOfNodeID);
125 |
126 | let statusDict = count(nodes, ["is_online"], function (d) {
127 | return d ? "online" : "offline";
128 | });
129 | let fwDict = count(nodes, ["firmware", "release"]);
130 | let baseDict = count(nodes, ["firmware", "base"]);
131 | let deprecationDict = count(nodes, ["model"], function (d) {
132 | return config.deprecated && d && config.deprecated.includes(d) ? _.t("yes") : _.t("no");
133 | });
134 | let hwDict = count(nodes, ["model"]);
135 | let geoDict = count(nodes, ["location"], function (d) {
136 | return d && d.longitude && d.latitude ? _.t("yes") : _.t("no");
137 | });
138 |
139 | let autoDict = count(nodes, ["autoupdater"], function (d) {
140 | if (d.enabled) {
141 | return d.branch;
142 | }
143 | return _.t("node.deactivated");
144 | });
145 |
146 | let domainDict = count(nodes, ["domain"], function (d) {
147 | if (config.domainNames) {
148 | config.domainNames.some(function (t) {
149 | if (d === t.domain) {
150 | d = t.name;
151 | return true;
152 | }
153 | });
154 | }
155 | return d;
156 | });
157 |
158 | tables.status = fillTable(
159 | "node.status",
160 | tables.status,
161 | statusDict.sort(function (a, b) {
162 | return b[1] - a[1];
163 | }),
164 | );
165 |
166 | tables.firmware = fillTable("node.firmware", tables.firmware, fwDict.sort(sortVersionCountAndName));
167 |
168 | tables.baseversion = fillTable("node.baseversion", tables.baseversion, baseDict.sort(sortVersionCountAndName));
169 |
170 | tables.deprecationStatus = fillTable(
171 | "node.deprecationStatus",
172 | tables.deprecationStatus,
173 | deprecationDict.sort(function (a, b) {
174 | return b[1] - a[1];
175 | }),
176 | );
177 |
178 | tables.hardware = fillTable(
179 | "node.hardware",
180 | tables.hardware,
181 | hwDict.sort(function (a, b) {
182 | return b[1] - a[1];
183 | }),
184 | );
185 |
186 | tables.visible = fillTable(
187 | "node.visible",
188 | tables.visible,
189 | geoDict.sort(function (a, b) {
190 | return b[1] - a[1];
191 | }),
192 | );
193 |
194 | tables.update = fillTable(
195 | "node.update",
196 | tables.update,
197 | autoDict.sort(function (a, b) {
198 | return b[1] - a[1];
199 | }),
200 | );
201 | tables.gateway = fillTable(
202 | "node.selectedGatewayIPv4",
203 | tables.gateway,
204 | gatewayDict.sort(function (a, b) {
205 | return b[1] - a[1];
206 | }),
207 | );
208 | tables.gateway6 = fillTable(
209 | "node.selectedGatewayIPv6",
210 | tables.gateway6,
211 | gateway6Dict.sort(function (a, b) {
212 | return b[1] - a[1];
213 | }),
214 | );
215 | tables.domain = fillTable(
216 | "node.domain",
217 | tables.domain,
218 | domainDict.sort(function (a, b) {
219 | return b[1] - a[1];
220 | }),
221 | );
222 | };
223 |
224 | self.render = function render(el: HTMLElement) {
225 | self.renderSingle(el, "node.status", tables.status.element);
226 | self.renderSingle(el, "node.firmware", tables.firmware.element);
227 | self.renderSingle(el, "node.baseversion", tables.baseversion.element);
228 | self.renderSingle(el, "node.deprecationStatus", tables.deprecationStatus.element);
229 | self.renderSingle(el, "node.hardware", tables.hardware.element);
230 | self.renderSingle(el, "node.visible", tables.visible.element);
231 | self.renderSingle(el, "node.update", tables.update.element);
232 | self.renderSingle(el, "node.selectedGatewayIPv4", tables.gateway.element);
233 | self.renderSingle(el, "node.selectedGatewayIPv6", tables.gateway6.element);
234 | self.renderSingle(el, "node.domain", tables.domain.element);
235 |
236 | if (config.globalInfos) {
237 | let images = document.createElement("div");
238 | el.appendChild(images);
239 | let img = [];
240 | let subst = {
241 | "{TIME}": String(time.unix()),
242 | "{LOCALE}": _.locale(),
243 | };
244 | config.globalInfos.forEach(function (globalInfo) {
245 | img.push(h("h2", globalInfo.name));
246 | img.push(helper.showStat(globalInfo, subst));
247 | });
248 | patch(images, h("div", img));
249 | }
250 | };
251 |
252 | self.renderSingle = function renderSingle(el: HTMLElement, heading: string, table: HTMLTableElement) {
253 | if (table.children.length > 0) {
254 | let h2 = document.createElement("h2");
255 | h2.classList.add("proportion-header");
256 | h2.textContent = _.t(heading);
257 | h2.onclick = function onclick() {
258 | table.classList.toggle("hide");
259 | };
260 | el.appendChild(h2);
261 | el.appendChild(table);
262 | }
263 | };
264 | return self;
265 | };
266 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/lib/utils/helper.ts:
--------------------------------------------------------------------------------
1 | import { Moment } from "moment";
2 | import { h, VNode } 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 = h("img", {
138 | props: {
139 | src: listReplace(linkInfo.image, subst),
140 | width: linkInfo.width,
141 | height: linkInfo.height,
142 | alt: _.t("loading", { name: linkInfo.name }),
143 | },
144 | });
145 |
146 | if (linkInfo.href) {
147 | return h(
148 | "div",
149 | h(
150 | "a",
151 | {
152 | props: {
153 | href: listReplace(linkInfo.href, subst),
154 | target: "_blank",
155 | title: listReplace(linkInfo.title, subst),
156 | },
157 | },
158 | content,
159 | ),
160 | ) as unknown as HTMLDivElement;
161 | }
162 | return h("div", content) as unknown as HTMLDivElement;
163 | }
164 |
165 | export const showDevicePicture = function showDevicePicture(pictures: string, subst: ReplaceMapping) {
166 | if (!pictures) {
167 | return null;
168 | }
169 |
170 | return h("img", {
171 | props: { src: listReplace(pictures, subst) },
172 | class: { "hw-img": true },
173 | on: {
174 | // hide non-existent images
175 | error: function (e: any) {
176 | e.target.style.display = "none";
177 | },
178 | },
179 | });
180 | };
181 |
182 | export const getTileBBox = function getTileBBox(size: Point, map: Map, tileSize: number, margin: number) {
183 | let tl = map.unproject([size.x - margin, size.y - margin]);
184 | let br = map.unproject([size.x + margin + tileSize, size.y + margin + tileSize]);
185 |
186 | return { minX: br.lat, minY: tl.lng, maxX: tl.lat, maxY: br.lng };
187 | };
188 |
189 | export const positionClients = function positionClients(
190 | ctx: CanvasRenderingContext2D,
191 | point: Point,
192 | startAngle: number,
193 | node: Node,
194 | startDistance: number,
195 | ) {
196 | if (node.clients === 0) {
197 | return;
198 | }
199 |
200 | let radius = 3;
201 | let a = 1.2;
202 | let mode = 0;
203 | let config = window.config;
204 |
205 | ctx.beginPath();
206 | ctx.fillStyle = config.client.wifi24;
207 |
208 | for (let orbit = 0, i = 0; i < node.clients; orbit++) {
209 | let distance = startDistance + orbit * 2 * radius * a;
210 | let n = Math.floor((Math.PI * distance) / (a * radius));
211 | let delta = node.clients - i;
212 |
213 | for (let j = 0; j < Math.min(delta, n); i++, j++) {
214 | if (mode !== 1 && i >= node.clients_wifi24 + node.clients_wifi5) {
215 | mode = 1;
216 | ctx.fill();
217 | ctx.beginPath();
218 | ctx.fillStyle = config.client.wifi5;
219 | } else if (mode === 0 && i >= node.clients_wifi24) {
220 | mode = 2;
221 | ctx.fill();
222 | ctx.beginPath();
223 | ctx.fillStyle = config.client.other;
224 | }
225 | let angle = ((2 * Math.PI) / n) * j;
226 | let x = point.x + distance * Math.cos(angle + startAngle);
227 | let y = point.y + distance * Math.sin(angle + startAngle);
228 |
229 | ctx.moveTo(x, y);
230 | ctx.arc(x, y, radius, 0, 2 * Math.PI);
231 | }
232 | }
233 | ctx.fill();
234 | };
235 |
236 | export const fullscreen = function fullscreen(btn: HTMLButtonElement) {
237 | if (!document.fullscreenElement && !document["webkitFullscreenElement"] && !document["mozFullScreenElement"]) {
238 | let fel = document.firstElementChild;
239 | let func = fel.requestFullscreen || fel["webkitRequestFullScreen"] || fel["mozRequestFullScreen"];
240 | func.call(fel);
241 | btn.classList.remove("ion-full-enter");
242 | btn.classList.add("ion-full-exit");
243 | } else {
244 | let func = document.exitFullscreen || document["webkitExitFullscreen"] || document["mozCancelFullScreen"];
245 | if (func) {
246 | func.call(document);
247 | btn.classList.remove("ion-full-exit");
248 | btn.classList.add("ion-full-enter");
249 | }
250 | }
251 | };
252 |
253 | export const escape = function escape(string: string) {
254 | return string.replace(//g, ">").replace(/"/g, """).replace(/'/g, "'");
255 | };
256 |
--------------------------------------------------------------------------------
/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: ReturnType;
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: ReturnType) {
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 |
--------------------------------------------------------------------------------
/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/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 node.firstseen.fromNow(true);
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/router.ts:
--------------------------------------------------------------------------------
1 | import Navigo 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 const Router = function (language: ReturnType) {
32 | let init = false;
33 | let objects: Objects = { nodeDict: [], links: [] };
34 | let targets: Target[] = [];
35 | let views: Views = {};
36 | let current = {
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 | let state = { lang: language.getLocale(), view: "map" };
46 |
47 | function resetView() {
48 | targets.forEach(function (target) {
49 | target.resetView();
50 | });
51 | }
52 |
53 | function gotoNode(node: { nodeId: NodeId }) {
54 | if (objects.nodeDict[node.nodeId]) {
55 | targets.forEach(function (target) {
56 | target.gotoNode(objects.nodeDict[node.nodeId], objects.nodeDict);
57 | });
58 | }
59 | }
60 |
61 | function gotoLink(linkData: { linkId: string }) {
62 | let link = objects.links.filter(function (value) {
63 | return value.id === linkData.linkId;
64 | });
65 | if (link) {
66 | targets.forEach(function (target) {
67 | target.gotoLink(link);
68 | });
69 | }
70 | }
71 |
72 | function view(data: { view: string }) {
73 | if (data.view in views) {
74 | views[data.view]();
75 | state.view = data.view;
76 | resetView();
77 | }
78 | }
79 |
80 | function customRoute(
81 | lang?: string,
82 | viewValue?: "map" | "graph" | string,
83 | node?: string,
84 | link?: string,
85 | zoom?: number | string,
86 | lat?: number | string,
87 | lng?: number | string,
88 | ) {
89 | current = {
90 | lang: lang,
91 | view: viewValue,
92 | node: node,
93 | link: link,
94 | zoom: zoom,
95 | lat: lat,
96 | lng: lng,
97 | };
98 |
99 | if (lang && lang !== state.lang && lang === language.getLocale(lang)) {
100 | location.reload();
101 | }
102 |
103 | if (!init || (viewValue && viewValue !== state.view)) {
104 | if (!viewValue) {
105 | viewValue = state.view;
106 | }
107 | view({ view: viewValue });
108 | init = true;
109 | }
110 |
111 | if (node) {
112 | gotoNode({ nodeId: node });
113 | } else if (link) {
114 | gotoLink({ linkId: link });
115 | } else if (lat) {
116 | targets.forEach(function (target) {
117 | target.gotoLocation({
118 | zoom: parseInt(current.zoom, 10),
119 | lat: parseFloat(current.lat),
120 | lng: parseFloat(current.lng),
121 | });
122 | });
123 | } else {
124 | resetView();
125 | }
126 | }
127 |
128 | let router = new Navigo(null, true, "#!");
129 |
130 | router
131 | .on(
132 | /^\/?#?!?\/(\w{2})?\/?(map|graph)?\/?([a-f\d]{12})?([a-f\d\-]{25})?\/?(?:(\d+)\/(-?[\d.]+)\/(-?[\d.]+))?$/,
133 | customRoute,
134 | )
135 | .on({
136 | "*": function () {
137 | router.fullUrl();
138 | },
139 | });
140 |
141 | router.generateLink = function generateLink(data?: {}, full?: boolean, deep?: boolean) {
142 | let result = "#!";
143 |
144 | if (full) {
145 | data = Object.assign({}, state, data);
146 | } else if (deep) {
147 | data = Object.assign({}, current, data);
148 | }
149 |
150 | for (let key in data) {
151 | if (!data.hasOwnProperty(key) || data[key] === undefined || data[key] === "") {
152 | continue;
153 | }
154 | result += "/" + data[key];
155 | }
156 |
157 | return result;
158 | };
159 |
160 | router.fullUrl = function fullUrl(data?: {}, e?: Event | false, deep?: boolean) {
161 | if (e) {
162 | e.preventDefault();
163 | }
164 | router.navigate(router.generateLink(data, !deep, deep), false);
165 | };
166 |
167 | router.getLang = function getLang() {
168 | let lang = location.hash.match(/^\/?#!?\/(\w{2})\//);
169 | if (lang) {
170 | state.lang = language.getLocale(lang[1]);
171 | return lang[1];
172 | }
173 | return null;
174 | };
175 |
176 | router.addTarget = function addTarget(target: Target) {
177 | targets.push(target);
178 | };
179 |
180 | router.removeTarget = function removeTarget(target: Target) {
181 | targets = targets.filter(function (t) {
182 | return target !== t;
183 | });
184 | };
185 |
186 | router.addView = function addView(key: string, view: () => any) {
187 | views[key] = view;
188 | };
189 |
190 | router.currentView = function currentView(): string | undefined {
191 | return current.view;
192 | };
193 |
194 | router.setData = function setData(data: Objects) {
195 | objects = data;
196 | };
197 |
198 | return router;
199 | };
200 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "meshviewer",
3 | "version": "12.5.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 | "@typescript-eslint/parser": "^6.17.0",
15 | "eslint": "^8.51.0",
16 | "eslint-config-prettier": "^9.1.0",
17 | "prettier": "3.5.2",
18 | "sass": "^1.77.8",
19 | "typescript": "^5.7.2",
20 | "vite": "^6.3.4",
21 | "vite-plugin-checker": "^0.6.4",
22 | "vite-plugin-pwa": "^0.21.2"
23 | },
24 | "dependencies": {
25 | "@types/d3-collection": "^1.0.13",
26 | "@types/d3-drag": "^3.0.7",
27 | "@types/d3-ease": "^3.0.2",
28 | "@types/d3-force": "^3.0.10",
29 | "@types/d3-interpolate": "^3.0.4",
30 | "@types/d3-selection": "^3.0.11",
31 | "@types/d3-timer": "^3.0.2",
32 | "@types/d3-zoom": "^3.0.8",
33 | "@types/leaflet": "^1.9.4",
34 | "@types/node-polyglot": "^2.4.4",
35 | "@types/rbush": "^4.0.0",
36 | "d3-collection": "^1.0.7",
37 | "d3-drag": "^3.0.0",
38 | "d3-ease": "^3.0.1",
39 | "d3-force": "^3.0.0",
40 | "d3-interpolate": "^3.0.1",
41 | "d3-selection": "^3.0.0",
42 | "d3-timer": "^3.0.1",
43 | "d3-zoom": "^3.0.0",
44 | "leaflet": "^1.9.4",
45 | "moment": "^2.30.1",
46 | "navigo": "^7.1.3",
47 | "node-polyglot": "2.5.0",
48 | "promise-polyfill": "^8.2.0",
49 | "rbush": "^4.0.1",
50 | "snabbdom": "^3.6.2"
51 | },
52 | "scripts": {
53 | "dev": "vite",
54 | "build": "vite build",
55 | "preview": "vite preview",
56 | "lint": "npm run lint:prettier && npm run lint:eslint",
57 | "lint:eslint": "./node_modules/.bin/eslint .",
58 | "lint:prettier": "./node_modules/.bin/prettier --check .",
59 | "lint:fix": "npm run lint:fix:prettier && npm run lint:fix:eslint",
60 | "lint:fix:eslint": "./node_modules/.bin/eslint --fix .",
61 | "lint:fix:prettier": "./node_modules/.bin/prettier --log-level warn --write .",
62 | "generate-pwa-assets": "pwa-assets-generator --preset minimal assets/logo.svg"
63 | },
64 | "browserslist": [
65 | "> 1% in DE"
66 | ]
67 | }
68 |
--------------------------------------------------------------------------------
/public/apple-touch-icon-180x180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/f9e9f8c4d00a30a98f7b21422e784a32066a2a70/public/apple-touch-icon-180x180.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/f9e9f8c4d00a30a98f7b21422e784a32066a2a70/public/favicon.ico
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 Monate",
91 | "y": "einem Jahr",
92 | "yy": "%d Jahre"
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": "Warnung: Dieser Knoten ist veraltet, und wird demnächst 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.",
103 | "loading": "%{name} graph (wird generiert)"
104 | }
105 |
--------------------------------------------------------------------------------
/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": "This node is deprecated, and will be out of support soon. More information under 4/32 warning . If you're the owner, please replace it with an modern device!",
103 | "loading": "%{name} graph (is generated)"
104 | }
105 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/maskable-icon-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/f9e9f8c4d00a30a98f7b21422e784a32066a2a70/public/maskable-icon-512x512.png
--------------------------------------------------------------------------------
/public/pwa-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/f9e9f8c4d00a30a98f7b21422e784a32066a2a70/public/pwa-192x192.png
--------------------------------------------------------------------------------
/public/pwa-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/f9e9f8c4d00a30a98f7b21422e784a32066a2a70/public/pwa-512x512.png
--------------------------------------------------------------------------------
/public/pwa-64x64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/freifunk/meshviewer/f9e9f8c4d00a30a98f7b21422e784a32066a2a70/public/pwa-64x64.png
--------------------------------------------------------------------------------
/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/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/main.scss:
--------------------------------------------------------------------------------
1 | // Set variables
2 | @import "modules/variables";
3 | @import "custom/variables";
4 |
5 | // Mixins
6 | @import "mixins/icon";
7 | @import "mixins/font";
8 |
9 | // Add modules
10 | @import "modules/reset";
11 | @import "modules/font/font";
12 | @import "modules/base";
13 | @import "modules/font/icon";
14 | @import "modules/loader";
15 | @import "modules/leaflet";
16 | @import "modules/table";
17 | @import "modules/filter";
18 | @import "modules/sidebar";
19 | @import "modules/map";
20 | @import "modules/forcegraph";
21 | @import "modules/legend";
22 | @import "modules/proportion";
23 | @import "modules/tabs";
24 | @import "modules/node";
25 | @import "modules/infobox";
26 | @import "modules/button";
27 |
28 | // Make adjustments in custom scss
29 | @import "custom/custom";
30 | @import "night";
31 |
--------------------------------------------------------------------------------
/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/mixins/_icon.scss:
--------------------------------------------------------------------------------
1 | ../../assets/icons/_icon-mixin.scss
--------------------------------------------------------------------------------
/scss/modules/_base.scss:
--------------------------------------------------------------------------------
1 | body {
2 | -webkit-tap-highlight-color: transparent;
3 | background: $color-white;
4 | color: $color-black;
5 | font-family: $font-family;
6 | font-size: $font-size;
7 | overflow: hidden;
8 | overflow-y: scroll;
9 | }
10 |
11 | header {
12 | background: transparentize($color-black, 0.98);
13 | border-bottom: 1px solid darken($color-white, 10%);
14 | }
15 |
16 | textarea,
17 | input {
18 | background: transparent;
19 | color: $color-black, 100;
20 | }
21 |
22 | h1,
23 | h2,
24 | h3,
25 | h4,
26 | h5,
27 | h6 {
28 | font-weight: bold;
29 | }
30 |
31 | h1,
32 | h2 {
33 | font-size: 1.5em;
34 | padding: 0.83em 0;
35 | }
36 |
37 | h3 {
38 | font-size: 1.17em;
39 | padding: 1em 0;
40 | }
41 |
42 | h1,
43 | h2,
44 | h3 {
45 | padding-left: $button-distance;
46 | padding-right: $button-distance;
47 | }
48 |
49 | p,
50 | pre,
51 | ul,
52 | h4 {
53 | padding: 0 $button-distance 1em;
54 | }
55 |
56 | img {
57 | max-width: 100%;
58 | height: auto;
59 | }
60 |
61 | a {
62 | color: $color-online;
63 | text-decoration: none;
64 |
65 | &:focus {
66 | color: darken($color-online, 15%);
67 | }
68 | }
69 |
70 | p {
71 | line-height: 1.67em;
72 | }
73 |
74 | strong {
75 | font-weight: bold;
76 | }
77 |
78 | .hide {
79 | display: none !important;
80 | }
81 |
82 | .sr-only {
83 | border: 0;
84 | clip: rect(0, 0, 0, 0);
85 | clip-path: inset(50%);
86 | height: 1px;
87 | overflow: hidden;
88 | padding: 0;
89 | position: absolute;
90 | white-space: nowrap;
91 | width: 1px;
92 | }
93 |
94 | .deprecated {
95 | padding-left: $button-distance;
96 | padding-right: $button-distance;
97 |
98 | div {
99 | background: $color-warning;
100 | border-radius: 5px;
101 | color: $color-white;
102 | font-size: 110%;
103 | font-weight: bold;
104 | padding: 10px;
105 | text-align: center;
106 |
107 | a {
108 | color: $color-white;
109 | text-decoration: underline;
110 |
111 | &:hover {
112 | // sass-lint:disable-line nesting-depth
113 | text-decoration: none;
114 | }
115 | }
116 | }
117 | }
118 |
119 | .hw-img-container {
120 | display: flex;
121 | justify-content: center;
122 | margin-bottom: 0.83em;
123 | width: 100%;
124 | }
125 |
126 | .hw-img {
127 | height: 150px;
128 | padding: 0 !important;
129 | }
130 |
--------------------------------------------------------------------------------
/scss/modules/_button.scss:
--------------------------------------------------------------------------------
1 | button {
2 | background: $color-white;
3 | border: 0;
4 | border-radius: 0.9em;
5 | color: $color-black;
6 | cursor: pointer;
7 | font-family: $font-family-icons;
8 | font-size: $button-font-size;
9 | height: 1.8em;
10 | line-height: 1.95;
11 | opacity: 0.7;
12 | outline: none;
13 | padding: 0;
14 | transition:
15 | box-shadow 0.5s,
16 | background-color 0.5s,
17 | color 0.5s;
18 | width: 1.8em;
19 |
20 | &.text {
21 | background: $color-primary;
22 | border: 1px solid $color-primary;
23 | border-radius: 0;
24 | color: $color-white;
25 | font: inherit;
26 | line-height: initial;
27 | padding: 0 20px;
28 | width: auto;
29 |
30 | &:hover {
31 | background: $color-white;
32 | }
33 | }
34 |
35 | &.active,
36 | &:focus {
37 | box-shadow: 0 0 0 2px $color-primary;
38 | }
39 |
40 | &:hover {
41 | color: $color-primary;
42 | }
43 |
44 | // Tooltip
45 | &[data-tooltip] {
46 | &::after {
47 | background: $color-black;
48 | border-radius: 3px;
49 | color: $color-white;
50 | content: attr(data-tooltip);
51 | font-family: $font-family;
52 | font-size: $font-size;
53 | padding: 0 12px;
54 | position: absolute;
55 | transform: translate(45px, 52px);
56 | visibility: hidden;
57 | white-space: nowrap;
58 | }
59 |
60 | &:hover {
61 | &::after {
62 | transition: visibility 0s linear 0.3s;
63 | visibility: visible;
64 | }
65 | }
66 | }
67 |
68 | &.close {
69 | background-color: transparent;
70 | border-radius: 0;
71 | color: transparentize($color-black, 0.5);
72 | float: right;
73 | font-size: $button-font-size;
74 | height: auto;
75 | line-height: 1.2;
76 | margin: $button-distance;
77 | width: auto;
78 | }
79 | }
80 |
81 | // Tooltip
82 | .content,
83 | .sidebar > {
84 | button {
85 | &[aria-label] {
86 | &::after {
87 | background: $color-black;
88 | border-radius: 3px;
89 | color: $color-white;
90 | content: attr(aria-label);
91 | font-family: $font-family;
92 | font-size: $font-size;
93 | padding: 0 12px;
94 | position: absolute;
95 | transform: translate(45px, 52px);
96 | visibility: hidden;
97 | white-space: nowrap;
98 | }
99 |
100 | &:hover {
101 | &::after {
102 | transition: visibility 0s linear 0.3s;
103 | visibility: visible;
104 | }
105 | }
106 | }
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/scss/modules/_filter.scss:
--------------------------------------------------------------------------------
1 | .filters {
2 | display: flex;
3 | flex-wrap: wrap;
4 | font-size: 0.83em;
5 | padding: 0 8px 8px;
6 |
7 | li {
8 | align-items: center;
9 | background: transparent;
10 | border: 1px solid $color-primary;
11 | color: $color-primary;
12 | display: flex;
13 | margin: 4px;
14 | padding: 0 0 0 10px;
15 |
16 | label {
17 | cursor: pointer;
18 | }
19 |
20 | button {
21 | background: none;
22 | color: $color-gray-light;
23 | font-size: $font-size-small;
24 | height: 24px;
25 | margin: 3px;
26 | width: 24px;
27 |
28 | &:hover {
29 | color: $color-primary;
30 | }
31 | }
32 |
33 | &.not {
34 | label {
35 | color: $color-primary;
36 | text-decoration: line-through;
37 | }
38 | }
39 | }
40 |
41 | .filter-node {
42 | border: 0;
43 | padding: 0 5px;
44 | width: 100%;
45 |
46 | &::before {
47 | font-size: 1.8em;
48 | }
49 |
50 | input {
51 | background: transparent;
52 | border: 0;
53 | border-bottom: 1px solid $color-primary;
54 | font-family: $font-family;
55 | font-size: $font-size;
56 | margin: 0 15px 0 3px;
57 | outline: none;
58 | padding: 0 2px;
59 | width: 100%;
60 |
61 | &:focus {
62 | background: transparentize($color-primary, 0.95);
63 | }
64 | }
65 |
66 | button {
67 | display: none;
68 | }
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/scss/modules/_forcegraph.scss:
--------------------------------------------------------------------------------
1 | .graph {
2 | background: $color-gray-dark;
3 | font: $font-size-small $font-family;
4 | height: 100%;
5 | width: 100%;
6 |
7 | canvas {
8 | display: block;
9 | position: absolute;
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/scss/modules/_infobox.scss:
--------------------------------------------------------------------------------
1 | .infobox {
2 | .clients,
3 | .gateway {
4 | display: flex;
5 | flex-flow: wrap;
6 |
7 | span {
8 | flex-grow: 1;
9 | text-align: center;
10 | }
11 |
12 | .ion-people,
13 | .ion-arrow-right-c {
14 | font-size: 1.5em;
15 | }
16 | }
17 |
18 | .node-links {
19 | table-layout: fixed;
20 |
21 | th,
22 | td {
23 | &:nth-child(3),
24 | &:nth-child(5) {
25 | width: 12%;
26 | }
27 | }
28 | }
29 |
30 | input,
31 | textarea {
32 | border: 1px solid $color-gray-light;
33 | font-family: $font-family-monospace;
34 | font-size: 1.15em;
35 | line-height: 1.67em;
36 | margin-right: 0.7em;
37 | max-width: 500px;
38 | min-height: 42px;
39 | padding: 3px 6px;
40 | vertical-align: bottom;
41 | width: calc(100% - 80px);
42 | }
43 |
44 | textarea {
45 | font-size: 0.8em;
46 | height: 100px;
47 | max-height: 300px;
48 | overflow: auto;
49 | resize: vertical;
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/scss/modules/_legend.scss:
--------------------------------------------------------------------------------
1 | header {
2 | h1 {
3 | display: inline-block;
4 | }
5 | }
6 |
7 | .language-switch {
8 | background: transparent;
9 | border: 0;
10 | color: $color-black;
11 | float: right;
12 | margin: 20px 16px 0 0;
13 |
14 | option {
15 | background: $color-white;
16 | }
17 | }
18 |
19 | .legend {
20 | a {
21 | margin-right: 10px;
22 | }
23 |
24 | span {
25 | &:not(:first-child) {
26 | margin-left: 1em;
27 | }
28 | }
29 | }
30 |
31 | .symbol {
32 | border-radius: 50%;
33 | display: inline-block;
34 | height: 1em;
35 | vertical-align: -5%;
36 | width: 1em;
37 | }
38 |
39 | // Dot looks compared to thin font a bit darker - lighten it 10%
40 | .legend-new {
41 | .symbol {
42 | background-color: lighten($color-new, 10%);
43 | }
44 | }
45 |
46 | .legend-online {
47 | .symbol {
48 | background-color: lighten($color-online, 10%);
49 | }
50 | }
51 |
52 | .legend-offline {
53 | .symbol {
54 | background-color: lighten($color-offline, 10%);
55 | }
56 | }
57 |
58 | .legend-24ghz {
59 | .symbol {
60 | background-color: $color-24ghz;
61 | }
62 | }
63 |
64 | .legend-5ghz {
65 | .symbol {
66 | background-color: $color-5ghz;
67 | }
68 | }
69 |
70 | .legend-uplink {
71 | .symbol {
72 | background-color: lighten($color-online, 50%);
73 | border-color: lighten($color-online, 10%);
74 | border-style: solid;
75 | border-width: 0.27em;
76 | height: 0.47em;
77 | width: 0.47em;
78 | }
79 | }
80 |
81 | .legend-others {
82 | .symbol {
83 | background-color: $color-others;
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/scss/modules/_loader.scss:
--------------------------------------------------------------------------------
1 | .loader {
2 | color: $color-primary;
3 | font-size: 1.8em;
4 | line-height: 2;
5 | margin: 30vh auto;
6 | text-align: center;
7 | }
8 |
--------------------------------------------------------------------------------
/scss/modules/_map.scss:
--------------------------------------------------------------------------------
1 | .content {
2 | height: 100vh;
3 | position: fixed;
4 | width: 100%;
5 |
6 | .buttons {
7 | direction: rtl;
8 | position: absolute;
9 | right: $button-distance;
10 | top: $button-distance;
11 | unicode-bidi: bidi-override;
12 | z-index: 1001;
13 |
14 | button {
15 | margin-left: $button-distance;
16 | }
17 |
18 | @media screen and (max-width: map-get($grid-breakpoints, lg) - 1) {
19 | right: 0.1rem;
20 | top: 0;
21 | transform: scale(0.8);
22 | transform-origin: right;
23 | }
24 | }
25 |
26 | @media screen and (max-width: map-get($grid-breakpoints, lg) - 1) {
27 | height: calc(100vh - 150px);
28 | min-height: 240px;
29 | position: relative;
30 | width: auto;
31 | }
32 |
33 | @media all and (device-height: 1024px) and (orientation: portrait) {
34 | height: 800px;
35 | }
36 |
37 | @media all and (device-width: 768px) and (orientation: landscape) {
38 | height: 768px;
39 | }
40 |
41 | @media only screen and (device-height: 568px) and (orientation: portrait) {
42 | height: 320px;
43 | }
44 |
45 | @media only screen and (device-width: 320px) and (orientation: landscape) {
46 | height: 240px;
47 | }
48 | }
49 |
50 | .stroke-first {
51 | paint-order: stroke;
52 | }
53 |
54 | .pick-coordinates {
55 | cursor: crosshair;
56 | }
57 |
58 | .map {
59 | height: 100%;
60 | width: 100%;
61 | }
62 |
--------------------------------------------------------------------------------
/scss/modules/_node.scss:
--------------------------------------------------------------------------------
1 | .bar {
2 | background: mix($color-new, $color-white, 60%);
3 | display: block;
4 | height: 1.4em;
5 | position: relative;
6 |
7 | &.warning {
8 | background: mix($color-offline, $color-white, 60%);
9 |
10 | span {
11 | background: $color-offline;
12 | }
13 | }
14 |
15 | span {
16 | background: $color-new;
17 | display: inline-block;
18 | height: 1.4em;
19 | max-width: 100%;
20 | }
21 |
22 | label {
23 | color: $color-white;
24 | font-weight: bold;
25 | position: absolute;
26 | right: 0.5em;
27 | top: 0.1em;
28 | white-space: nowrap;
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/scss/modules/_proportion.scss:
--------------------------------------------------------------------------------
1 | .proportion-header {
2 | cursor: pointer;
3 | }
4 |
5 | .proportion {
6 | th {
7 | font-size: 0.95em;
8 | font-weight: normal;
9 | padding-right: 0.71em;
10 | text-align: right;
11 | }
12 |
13 | td {
14 | text-align: left;
15 | }
16 |
17 | span {
18 | box-sizing: border-box;
19 | color: $color-white;
20 | display: inline-block;
21 | font-weight: bold;
22 | min-width: 1.5em;
23 | padding: 0.25em 0.5em;
24 | }
25 |
26 | a {
27 | cursor: pointer;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/scss/modules/_sidebar.scss:
--------------------------------------------------------------------------------
1 | .sidebar {
2 | box-sizing: border-box;
3 | left: 0;
4 | position: absolute;
5 | transition: left 0.5s;
6 | width: $sidebar-width;
7 | z-index: 1005;
8 |
9 | &.hidden {
10 | left: -$sidebar-width - $button-distance;
11 |
12 | .sidebarhandle {
13 | left: $button-distance;
14 | transform: scale(-1, 1);
15 |
16 | &[aria-label] {
17 | &::after {
18 | transform: scale(-1, 1) translate(105px, 52px) !important;
19 | }
20 | }
21 | }
22 |
23 | @media screen and (max-width: map-get($grid-breakpoints, lg) - 1) {
24 | width: auto;
25 | }
26 | }
27 |
28 | .tab {
29 | padding-bottom: 15px;
30 | }
31 |
32 | img {
33 | padding: 0 $button-distance 1em;
34 | box-sizing: border-box;
35 | }
36 |
37 | .node-list,
38 | .node-links,
39 | .link-list {
40 | th,
41 | td {
42 | &:first-child {
43 | width: 25px;
44 | }
45 |
46 | &:nth-child(2) {
47 | overflow: hidden;
48 | text-align: left;
49 | text-overflow: ellipsis;
50 | white-space: nowrap;
51 | width: 50%;
52 | }
53 | }
54 | }
55 |
56 | .link-list {
57 | th,
58 | td {
59 | &:nth-child(2) {
60 | width: 60%;
61 | }
62 | }
63 | }
64 |
65 | .node-links {
66 | padding-bottom: 15px;
67 |
68 | th,
69 | td {
70 | &:first-child {
71 | width: 35px;
72 | }
73 | }
74 | }
75 |
76 | .container {
77 | background: transparentize($color-white, 0.03);
78 | border-right: 1px solid darken($color-white, 10%);
79 | min-height: 100vh;
80 | overflow-y: visible;
81 |
82 | &.hidden {
83 | display: none;
84 | }
85 | }
86 |
87 | @media screen and (max-width: map-get($grid-breakpoints, xl) - 1) {
88 | background: $color-white;
89 | font-size: 0.8em;
90 | margin: 0;
91 | width: $sidebar-width-small;
92 |
93 | .sidebarhandle {
94 | left: $sidebar-width-small + $button-distance;
95 | }
96 |
97 | .container,
98 | .infobox {
99 | border-radius: 0;
100 | margin: 0;
101 | }
102 | }
103 |
104 | @media screen and (max-width: map-get($grid-breakpoints, lg) - 1) {
105 | height: auto;
106 | min-height: 0;
107 | position: static;
108 | width: auto;
109 |
110 | .sidebarhandle {
111 | display: none;
112 | }
113 |
114 | .content {
115 | height: 60vh;
116 | position: relative;
117 | width: auto;
118 | }
119 | }
120 | }
121 |
122 | .sidebarhandle {
123 | left: $sidebar-width + 2 * $button-distance;
124 | position: fixed;
125 | top: $button-distance;
126 | transition:
127 | left 0.5s,
128 | color 0.5s,
129 | transform 0.5s;
130 | z-index: 1010;
131 |
132 | &::before {
133 | content: "\f124";
134 | padding-right: 0.125em;
135 | }
136 |
137 | &[aria-label] {
138 | &::after {
139 | transform: translate(-45px, 52px) !important;
140 | }
141 | }
142 | }
143 |
144 | .online {
145 | color: $color-new;
146 | }
147 |
148 | .offline {
149 | color: $color-offline;
150 | }
151 |
--------------------------------------------------------------------------------
/scss/modules/_table.scss:
--------------------------------------------------------------------------------
1 | table {
2 | border-collapse: separate;
3 | border-spacing: 0 0.5em;
4 | padding: 0 $button-distance;
5 | width: 100%;
6 |
7 | &.attributes {
8 | line-height: 1.41em;
9 |
10 | th {
11 | font-weight: bold;
12 | padding-right: 1em;
13 | text-align: left;
14 | vertical-align: top;
15 | white-space: nowrap;
16 | }
17 |
18 | td {
19 | text-align: left;
20 | width: 100%;
21 | }
22 | }
23 | }
24 |
25 | tr {
26 | &.header {
27 | font-size: 1.2em;
28 |
29 | th {
30 | padding-top: 1em;
31 | }
32 | }
33 | }
34 |
35 | td,
36 | th {
37 | line-height: 1.41em;
38 | text-align: right;
39 |
40 | &:first-child {
41 | text-align: left;
42 | }
43 | }
44 |
45 | th {
46 | font-weight: bold;
47 |
48 | &[class*=" ion-"] {
49 | &::before {
50 | font-size: 1.3em;
51 | }
52 | }
53 |
54 | &.sort-header {
55 | cursor: pointer;
56 |
57 | &::selection {
58 | background: transparent;
59 | }
60 |
61 | &::after {
62 | content: "\f10d";
63 | font-family: $font-family-icons;
64 | padding-left: 0.25em;
65 | visibility: hidden;
66 | }
67 |
68 | &:hover {
69 | &::after {
70 | visibility: visible;
71 | }
72 | }
73 | }
74 |
75 | &.sort-up {
76 | &::after {
77 | content: "\f104";
78 | }
79 | }
80 |
81 | &.sort-up,
82 | &.sort-down {
83 | &::after {
84 | opacity: 0.4;
85 | visibility: visible;
86 | }
87 | }
88 | }
89 |
90 | .tab {
91 | table {
92 | table-layout: fixed;
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/scss/modules/_tabs.scss:
--------------------------------------------------------------------------------
1 | .tabs {
2 | background: transparentize($color-black, 0.98);
3 | border: 0 solid darken($color-white, 10%);
4 | border-bottom-width: 1px;
5 | display: flex;
6 | display: -webkit-flex;
7 | list-style: none;
8 | margin: 0;
9 | padding: 0;
10 |
11 | li {
12 | -webkit-flex: 1 1 auto;
13 | color: transparentize($color-black, 0.5);
14 | cursor: pointer;
15 | flex: 1 1 auto;
16 | padding: 1.3em 0.5em 1em;
17 | text-align: center;
18 | text-transform: uppercase;
19 |
20 | &:hover {
21 | color: $color-black;
22 | }
23 | }
24 |
25 | .visible {
26 | border-bottom: 2px solid $color-primary;
27 | color: $color-primary;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/scss/modules/_variables.scss:
--------------------------------------------------------------------------------
1 | $color-white: #fff !default;
2 | $color-black: #000 !default;
3 |
4 | $color-gray-light: darken($color-white, 30%) !default;
5 | $color-gray-dark: lighten($color-black, 20%) !default;
6 |
7 | $color-primary: #dc0067 !default;
8 |
9 | $color-new: #459c18 !default;
10 | $color-online: #1566a9 !default;
11 | $color-offline: #cf3e2a !default;
12 |
13 | $color-24ghz: $color-primary !default;
14 | $color-5ghz: #e3a619 !default;
15 | $color-others: #0a9c92 !default;
16 |
17 | $color-warning: #c20000 !default;
18 |
19 | $color-map-background: #f8f4f0 !default;
20 |
21 | $font-family: "Assistant", sans-serif !default;
22 | $font-family-icons: ionicons !default;
23 | $font-family-monospace: monospace !default;
24 | $font-size: 15px !default;
25 | $font-size-small: 11px !default;
26 | $font-size-map-control: 12px !default;
27 |
28 | $button-font-size: 1.6rem !default;
29 | $button-distance: 16px !default;
30 |
31 | // Bootstrap breakpoints
32 | // In lib/sidebar to avoid render blocking onload
33 | $grid-breakpoints: (
34 | // Extra small screen / phone
35 | xs: 0,
36 | // Small screen / phone
37 | sm: 544px,
38 | // Medium screen / tablet
39 | md: 768px,
40 | // Large screen / desktop
41 | lg: 992px,
42 | // Extra large screen / wide desktop
43 | xl: 1200px
44 | ) !default;
45 |
46 | // 45% sidebar - based on viewport
47 | // In lib/sidebar to avoid render blocking onload
48 | $sidebar-width: map-get($grid-breakpoints, xl) * 0.45 !default;
49 | $sidebar-width-small: map-get($grid-breakpoints, lg) * 0.45 !default;
50 |
51 | // En/disable included font
52 | $use-included-font: 1 !default;
53 |
--------------------------------------------------------------------------------
/scss/modules/font/_font.scss:
--------------------------------------------------------------------------------
1 | @if $use-included-font == 1 {
2 | @include load-font("Assistant", "Light", 300, normal);
3 | @include load-font("Assistant", "Bold", 700, normal);
4 | }
5 |
--------------------------------------------------------------------------------
/scss/modules/font/_icon.scss:
--------------------------------------------------------------------------------
1 | ../../../assets/icons/icon.scss
--------------------------------------------------------------------------------
/scss/night.scss:
--------------------------------------------------------------------------------
1 | // Overwrite normal style (colors)
2 | @import "modules/variables";
3 | @import "custom/variables";
4 |
5 | $color-white: #1c1c13;
6 | $color-black: #fefefe;
7 | $color-map-background: #0d151c;
8 |
9 | $color-online: lighten($color-online, 25%);
10 |
11 | .theme_night {
12 | //@import 'modules/base';
13 | body,
14 | textarea,
15 | input {
16 | background: $color-white;
17 | color: lighten($color-black, 100);
18 | }
19 |
20 | header {
21 | background: transparentize($color-black, 0.98);
22 | border-bottom-color: lighten($color-white, 10%);
23 | }
24 |
25 | a {
26 | color: $color-online;
27 | text-decoration: none;
28 |
29 | &:focus {
30 | color: darken($color-online, 15%);
31 | }
32 | }
33 |
34 | //@import 'modules/leaflet';
35 | .leaflet-container {
36 | background: $color-map-background;
37 | }
38 |
39 | .leaflet-label {
40 | &.leaflet-label-right {
41 | background-color: $color-white;
42 | }
43 | }
44 |
45 | .leaflet-control-container {
46 | .leaflet-control-layers-toggle {
47 | background: lighten($color-white, 10);
48 | color: $color-black;
49 | }
50 | }
51 |
52 | .leaflet-control-zoom {
53 | a {
54 | background: lighten($color-white, 10);
55 | color: $color-black;
56 |
57 | &:hover {
58 | background: $color-white;
59 | }
60 | }
61 | }
62 |
63 | .leaflet-control-layers {
64 | &.leaflet-control {
65 | opacity: 0.9;
66 | }
67 | }
68 |
69 | .language-switch {
70 | color: $color-black;
71 |
72 | option {
73 | background: $color-white;
74 | }
75 | }
76 |
77 | //@import 'modules/filter';
78 | .filter-node {
79 | input {
80 | color: $color-black;
81 | }
82 | }
83 |
84 | //@import 'modules/sidebar';
85 | .sidebar {
86 | .infobox,
87 | .container {
88 | background: transparentize($color-white, 0.03);
89 | border-right: 1px solid darken($color-white, 10%);
90 | }
91 |
92 | @media screen and (max-width: map-get($grid-breakpoints, xl) - 1) {
93 | background: $color-white;
94 | }
95 | }
96 |
97 | //@import 'modules/tabs';
98 | .tabs {
99 | background: transparentize($color-black, 0.98);
100 | border-bottom-color: lighten($color-white, 10%);
101 |
102 | li {
103 | color: transparentize($color-black, 0.5);
104 |
105 | &:hover {
106 | color: $color-black;
107 | }
108 | }
109 | }
110 |
111 | //@import 'modules/node';
112 | .bar {
113 | background: mix($color-new, $color-white, 60%);
114 |
115 | &.warning {
116 | background: mix($color-offline, $color-white, 60%);
117 | }
118 |
119 | label {
120 | color: $color-white;
121 | }
122 | }
123 |
124 | //@import 'modules/button';
125 | button {
126 | background: lighten($color-white, 10);
127 | color: $color-black;
128 |
129 | &:hover {
130 | background: $color-white;
131 | }
132 |
133 | &.close {
134 | background: transparent;
135 | color: transparentize($color-black, 0.5);
136 | }
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES6",
4 | "allowJs": true,
5 | "module": "NodeNext",
6 | "moduleResolution": "NodeNext"
7 | },
8 | "include": ["lib"]
9 | }
10 |
--------------------------------------------------------------------------------
/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 | },
32 | manifest: {
33 | name: "Meshviewer",
34 | short_name: "Meshviewer",
35 | description:
36 | "Meshviewer is an online visualization app to represent nodes and links on a map for Freifunk open mesh network.",
37 | theme_color: "#ffffff",
38 | icons: [
39 | {
40 | src: "pwa-64x64.png",
41 | sizes: "64x64",
42 | type: "image/png",
43 | },
44 | {
45 | src: "pwa-192x192.png",
46 | sizes: "192x192",
47 | type: "image/png",
48 | },
49 | {
50 | src: "pwa-512x512.png",
51 | sizes: "512x512",
52 | type: "image/png",
53 | },
54 | ],
55 | },
56 | devOptions: {
57 | enabled: true,
58 | },
59 | }),
60 | checker({
61 | // Run TypeScript checks
62 | typescript: true,
63 | }),
64 | ],
65 | });
66 |
--------------------------------------------------------------------------------