├── .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 | [![Build Status](https://img.shields.io/github/actions/workflow/status/freifunk/meshviewer/build-meshviewer.yml?branch=main&style=flat-square)](https://github.com/freifunk/meshviewer/actions?query=workflow%3A%22Build+Meshviewer%22) 4 | [![Release](https://img.shields.io/github/v/release/freifunk/meshviewer?style=flat-square)](https://github.com/freifunk/meshviewer/releases) 5 | [![License: AGPL v3](https://img.shields.io/github/license/freifunk/meshviewer.svg?style=flat-square)](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 | Loading ... 41 |
42 | Karten & Knoten... 43 |

44 | 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 | '
" + 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 | '


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 = ""; 23 | for (let i = 0; i < config.supportedLocale.length; i++) { 24 | select.innerHTML += 25 | '"; 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 | Loading ... 20 |
21 | No connection available. 22 |
23 |
24 |
25 |

26 | 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 | --------------------------------------------------------------------------------