├── public ├── .htaccess ├── favicon.ico ├── loading.gif ├── logo192.png ├── logo512.png ├── robots.txt └── manifest.json ├── .nvmrc ├── _data ├── .gitignore ├── avg_site.py ├── sites.csv └── generate_measurment.py ├── .node-version ├── src ├── react-app-env.d.ts ├── utils │ ├── fonts.css │ ├── round-2.ts │ ├── fetch.ts │ ├── measurementMapUtils.ts │ ├── get-data-range.ts │ ├── config.ts │ └── get-bounds.ts ├── admin │ ├── login.css │ ├── AdminBody.tsx │ ├── ViewIdentity.tsx │ ├── ViewQRCode.tsx │ ├── NewUserDialog.tsx │ ├── Login.tsx │ ├── AdminPortal.tsx │ ├── UserPage.tsx │ ├── EditSite.tsx │ └── EditData.tsx ├── Theme.tsx ├── index.css ├── Footer.tsx ├── Loading.tsx ├── vis │ ├── SiteSelect.tsx │ ├── DeviceSelect.tsx │ ├── DateSelect.tsx │ ├── MapSelectionRadio.tsx │ ├── DisplaySelection.tsx │ ├── MapLegend.tsx │ ├── LineChart.tsx │ ├── Vis.tsx │ └── MeasurementMap.tsx ├── index.tsx ├── types.d.ts ├── ListItems.tsx ├── leaflet-component │ └── site-marker.ts ├── Navbar.tsx └── types │ └── api.d.ts ├── .env ├── .gitmodules ├── prettierrc.json ├── .prettierrc.json ├── cert.dockerfile ├── .gitignore ├── docker-compose.yaml ├── scripts ├── setup.sh ├── publish-docker.js ├── generate-certs.sh └── publish-version.js ├── .github └── workflows │ ├── check.yml │ ├── publish.yml │ └── release.yaml ├── tsconfig.json ├── CHANGELOG.md ├── vis.dockerfile ├── configs ├── nginx.conf └── local-nginx.conf ├── .dockerignore ├── Makefile ├── .releaserc ├── vite.config.ts ├── package.json ├── index.html ├── README.md └── LICENSE /public/.htaccess: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | .node-version -------------------------------------------------------------------------------- /_data/.gitignore: -------------------------------------------------------------------------------- 1 | venv -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 22.14.0 2 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | VITE_PORT=3000 2 | VITE_ENV_API_URL=localhost 3 | EXPOSED_PORT=3002 -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patterns/ccn-coverage-vis/main/public/favicon.ico -------------------------------------------------------------------------------- /public/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patterns/ccn-coverage-vis/main/public/loading.gif -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patterns/ccn-coverage-vis/main/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/patterns/ccn-coverage-vis/main/public/logo512.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/utils/fonts.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400&display=swap"); 2 | -------------------------------------------------------------------------------- /src/utils/round-2.ts: -------------------------------------------------------------------------------- 1 | export default function round2(num: number) { 2 | return Math.round(num * 100) / 100; 3 | } 4 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "models/ccn-coverage-models"] 2 | path = models/ccn-coverage-models 3 | url = https://github.com/Local-Connectivity-Lab/ccn-coverage-models.git 4 | -------------------------------------------------------------------------------- /src/admin/login.css: -------------------------------------------------------------------------------- 1 | .form { 2 | max-width: 330px; 3 | margin: 0 auto; 4 | display: flex; 5 | flex-direction: column; 6 | padding: 20px; 7 | margin-top: 30px; 8 | } 9 | -------------------------------------------------------------------------------- /_data/avg_site.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | 3 | 4 | data = pd.read_json("./ccn-measurements-small.json") 5 | # print(type(data["timestamp"])) 6 | 7 | 8 | agg = data.groupby("timestamp", "site").mean() 9 | 10 | -------------------------------------------------------------------------------- /prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "printWidth": 80, 4 | "singleQuote": true, 5 | "jsxSingleQuote": true, 6 | "bracketSpacing": true, 7 | "arrowParens": "avoid", 8 | "trailingComma": "all" 9 | } 10 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "printWidth": 80, 4 | "singleQuote": true, 5 | "jsxSingleQuote": true, 6 | "bracketSpacing": true, 7 | "arrowParens": "avoid", 8 | "trailingComma": "all" 9 | } -------------------------------------------------------------------------------- /cert.dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:latest 2 | 3 | WORKDIR / 4 | 5 | RUN apt-get update && apt-get install -y openssl 6 | 7 | COPY scripts/generate-certs.sh . 8 | 9 | RUN chmod +x generate-certs.sh 10 | CMD ["./generate-certs.sh"] -------------------------------------------------------------------------------- /src/utils/fetch.ts: -------------------------------------------------------------------------------- 1 | import createClient from 'openapi-fetch'; 2 | import type { paths } from '../types/api'; 3 | import { API_URL } from './config'; 4 | 5 | export const apiClient = createClient({ 6 | baseUrl: API_URL, 7 | credentials: 'include', 8 | }); 9 | -------------------------------------------------------------------------------- /src/Theme.tsx: -------------------------------------------------------------------------------- 1 | import { createTheme } from '@mui/material/styles'; 2 | import { red } from '@mui/material/colors'; 3 | 4 | export const theme = createTheme({ 5 | palette: { 6 | primary: { 7 | main: red[500], 8 | }, 9 | }, 10 | }); 11 | 12 | /* 13 | --jet: #353535ff; 14 | --ming: #3c6e71ff; 15 | --white: #ffffffff; 16 | --gainsboro: #d9d9d9ff; 17 | --indigo-dye: #284b63ff; 18 | */ 19 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .vscode/launch.json 25 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | cert-generator: 3 | build: 4 | context: . 5 | dockerfile: cert.dockerfile 6 | volumes: 7 | - certs:/certs 8 | 9 | web: 10 | build: 11 | context: . 12 | dockerfile: vis.dockerfile 13 | volumes: 14 | - certs:/etc/nginx/ssl/certs 15 | ports: 16 | - 443:443 17 | depends_on: 18 | cert-generator: 19 | condition: service_completed_successfully 20 | 21 | volumes: 22 | certs: -------------------------------------------------------------------------------- /src/utils/measurementMapUtils.ts: -------------------------------------------------------------------------------- 1 | export const UNITS = { 2 | dbm: 'dBm', 3 | ping: 'ms', 4 | download_speed: 'Mbps', 5 | upload_speed: 'Mbps', 6 | } as const; 7 | 8 | export const MULTIPLIERS = { 9 | dbm: 1, 10 | ping: 1, 11 | download_speed: 1, 12 | upload_speed: 1, 13 | } as const; 14 | 15 | export const MAP_TYPE_CONVERT = { 16 | dbm: 'Signal Strength', 17 | ping: 'Ping', 18 | download_speed: 'Download Speed', 19 | upload_speed: 'Upload Speed', 20 | } as const; 21 | -------------------------------------------------------------------------------- /src/admin/AdminBody.tsx: -------------------------------------------------------------------------------- 1 | import UserPage from './UserPage'; 2 | import EditSite from './EditSite'; 3 | import EditData from './EditData'; 4 | 5 | interface AdminBodyProps { 6 | page: AdminPage; 7 | } 8 | 9 | export default function AdminBody(props: AdminBodyProps) { 10 | switch (props.page) { 11 | case 'users': 12 | return ; 13 | case 'edit-site': 14 | return ; 15 | case 'edit-data': 16 | return ; 17 | default: 18 | return

Error

; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /scripts/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ -f /etc/os-release ]; then 4 | . /etc/os-release 5 | if [[ "$ID" == "ubuntu" || "$ID" == "debian" ]]; then 6 | set -e 7 | 8 | apt update 9 | apt install -y python3 pkg-config build-essential libcairo2-dev libpango1.0-dev 10 | 11 | echo "Script execution completed successfully." 12 | else 13 | echo "This script is designed to run only on Ubuntu." 14 | echo "Current OS: $PRETTY_NAME" 15 | exit 1 16 | fi 17 | else 18 | echo "Cannot determine the operating system." 19 | exit 1 20 | fi 21 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/Footer.tsx: -------------------------------------------------------------------------------- 1 | import Typography from '@mui/material/Typography'; 2 | import Link from '@mui/material/Link'; 3 | function Copyright(props: any) { 4 | return ( 5 | 11 | {'Copyright © '} 12 | 13 | ICTD 14 | {' '} 15 | {new Date().getFullYear()} 16 | {'.'} 17 | 18 | ); 19 | } 20 | export default function Footer() { 21 | return ; 22 | } 23 | -------------------------------------------------------------------------------- /scripts/publish-docker.js: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | 3 | export default async (pluginConfig, context) => { 4 | const { nextRelease } = context; 5 | const version = nextRelease.version; 6 | 7 | const imageName = 'ghcr.io/Local-Connectivity-Lab/ccn-coverage-vis'; 8 | 9 | console.log(`Building Docker image: ${imageName}:${version}`); 10 | 11 | execSync('make build', { stdio: 'inherit' }); 12 | execSync(`docker tag ${imageName}:${version} ${imageName}:latest`, { stdio: 'inherit' }); 13 | execSync(`docker push ${imageName}:${version}`, { stdio: 'inherit' }); 14 | execSync(`docker push ${imageName}:latest`, { stdio: 'inherit' }); 15 | }; -------------------------------------------------------------------------------- /src/Loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface LoadingProps { 4 | loading: boolean; 5 | left: number; 6 | top: number; 7 | size: number; 8 | } 9 | 10 | const Loading = (props: LoadingProps) => { 11 | return ( 12 | source: https://tenor.com/view/loading-buffering-spinning-waiting-gif-17313179 24 | ); 25 | }; 26 | 27 | export default Loading; 28 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Check 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | - "!main" 8 | 9 | jobs: 10 | check: 11 | name: Check format 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version-file: ".node-version" 20 | cache: "npm" 21 | 22 | - name: Set up dependencies 23 | run: sudo ./scripts/setup.sh 24 | 25 | - name: Install Node dependencies 26 | run: npm ci 27 | 28 | - name: Check format 29 | run: npm run format-check 30 | 31 | - name: Check build 32 | run: npm run build 33 | -------------------------------------------------------------------------------- /src/utils/get-data-range.ts: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3'; 2 | 3 | type Datum = { 4 | latitude: number; 5 | longitude: number; 6 | }; 7 | 8 | export default function dataRange(data: Datum[]) { 9 | const [minLat, maxLat] = d3 10 | .extent(data, d => d.latitude) 11 | .map(d => d ?? NaN) as [number, number]; 12 | const [minLon, maxLon] = d3 13 | .extent(data, d => d.longitude) 14 | .map(d => d ?? NaN) as [number, number]; 15 | 16 | const center: [number, number] = [ 17 | Number.isNaN(minLat) || Number.isNaN(maxLat) ? 0 : (minLat + maxLat) / 2, 18 | Number.isNaN(minLon) || Number.isNaN(maxLon) ? 0 : (minLon + maxLon) / 2, 19 | ]; 20 | 21 | return { center, minLat, minLon, maxLat, maxLon }; 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": ["vite/client"], 4 | "target": "ESNext", 5 | "useDefineForClassFields": true, 6 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 7 | "allowJs": false, 8 | "skipLibCheck": true, 9 | "esModuleInterop": true, 10 | "allowSyntheticDefaultImports": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noUncheckedIndexedAccess": true, 14 | "module": "ESNext", 15 | "moduleResolution": "Node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "noEmit": true, 19 | "jsx": "react-jsx", 20 | "baseUrl": ".", 21 | "paths": { 22 | "@/*": ["src/*"] 23 | } 24 | }, 25 | "include": ["src"] 26 | } -------------------------------------------------------------------------------- /src/utils/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The value should be: 3 | * - When developing frontend only: 'https://coverage.seattlecommunitynetwork.org'. 4 | * - When developing with local backend: 'http://localhost:3000'. 5 | * - On production: ''. 6 | */ 7 | export const API_URL: string = import.meta.env.DEV 8 | ? String( 9 | `http://${import.meta.env.VITE_ENV_API_URL}:${import.meta.env.VITE_PORT}`, 10 | ) 11 | : ''; 12 | 13 | export const DEVICE_OPTIONS: DeviceOption[] = [ 14 | { 15 | label: 'Low Gain CPE', 16 | value: 'CPEL', 17 | }, 18 | { 19 | label: 'High Gain CPE', 20 | value: 'CPEH', 21 | }, 22 | { 23 | label: 'LG-G8', 24 | value: 'LGG8', 25 | }, 26 | { 27 | label: 'Pixel 4', 28 | value: 'Pixel4', 29 | }, 30 | { 31 | label: 'Android', 32 | value: 'Android', 33 | }, 34 | ]; 35 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.0.1](https://github.com/Local-Connectivity-Lab/ccn-coverage-vis/compare/v1.0.0...v1.0.1) (2025-05-23) 2 | 3 | # 1.0.0 (2025-05-23) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * fix artifact name ([#45](https://github.com/Local-Connectivity-Lab/ccn-coverage-vis/issues/45)) ([9c59aad](https://github.com/Local-Connectivity-Lab/ccn-coverage-vis/commit/9c59aad6ed5405777669e9bee2eca1e00c3c3bf0)) 9 | 10 | 11 | ### Features 12 | 13 | * add sidebar support for the map ([#3](https://github.com/Local-Connectivity-Lab/ccn-coverage-vis/issues/3)) ([004345f](https://github.com/Local-Connectivity-Lab/ccn-coverage-vis/commit/004345f4241a66f8a753063afd34f6ad44299344)) 14 | * Stabilize coverage web app and add auto sem versioning ([#44](https://github.com/Local-Connectivity-Lab/ccn-coverage-vis/issues/44)) ([de0acf4](https://github.com/Local-Connectivity-Lab/ccn-coverage-vis/commit/de0acf4050016da3260a17312ac8e63f95ba6fc1)) 15 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Publish Site 5 | 6 | on: 7 | workflow_dispatch: 8 | 9 | jobs: 10 | build: 11 | name: Publish Site 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - uses: actions/setup-node@v3 18 | with: 19 | node-version-file: ".node-version" 20 | 21 | - name: Install Node dependencies 22 | run: npm ci 23 | 24 | - name: Build 25 | run: npm run build 26 | 27 | - name: Publish website 28 | uses: alex-page/blazing-fast-gh-pages-deploy@v1.1.0 29 | with: 30 | repo-token: ${{ secrets.GH_PAT }} 31 | site-directory: "build" 32 | -------------------------------------------------------------------------------- /_data/sites.csv: -------------------------------------------------------------------------------- 1 | name,latitude,longitude,status,address 2 | "David-TCN",47.23911,-122.44989,"active","2309 S L St, Tacoma, WA 98405" 3 | "SURGEtacoma",47.23854,-122.44094,"confirmed","2367 Tacoma Ave S, Tacoma, WA 98402" 4 | "Filipino Community Center",47.5501,-122.2872,"confirmed","5740 Martin Luther King Jr Way S, Seattle, WA 98118" 5 | "Oareao OCC Masjid",47.52391,-122.27636,"in-conversation","8812 Renton Ave S, Seattle, WA 98118" 6 | "ALTSpace",47.60816,-122.30192,"in-conversation","2318 E Cherry St, Seattle, WA 98122" 7 | "Franklin High School",47.576,-122.29307,"in-conversation","3013 S Mt Baker Blvd, Seattle, WA 98144" 8 | "Garfield High School",47.60533,-122.3018,"in-conversation","400 23rd Ave, Seattle, WA 98122" 9 | "Skyway Library",47.49049,-122.23853,"in-conversation","12601 76th Ave S, Seattle, WA 98178" 10 | "University Heights Center",47.66613,-122.31374,"in-conversation","5031 University Way NE, Seattle, WA 98105" 11 | "Hillside Church Kent",47.38639, -122.22205,"in-conversation","930 E James St, Kent, WA 98031" -------------------------------------------------------------------------------- /src/vis/SiteSelect.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Typography from '@mui/material/Typography'; 3 | import Box from '@mui/material/Box'; 4 | import Select from 'react-select'; 5 | import '@fontsource/roboto'; 6 | 7 | interface SidebarProps { 8 | selectedSites: SiteOption[]; 9 | setSelectedSites: React.Dispatch>; 10 | loading: boolean; 11 | allSites: Site[]; 12 | } 13 | 14 | const SiteSelect = (props: SidebarProps) => { 15 | const siteOptions = props.allSites.map(({ name, status }) => ({ 16 | label: name, 17 | value: name, 18 | status: status, 19 | })); 20 | 21 | return ( 22 | 23 | Select Sites 24 | 28 | props.setSelectedDevices(selected as DeviceOption[]) 29 | } 30 | isDisabled={props.loading} 31 | placeholder='Select...' 32 | /> 33 | 34 | ); 35 | }; 36 | 37 | export default DeviceSelect; 38 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import { BrowserRouter, Routes, Route } from 'react-router-dom'; 3 | import Vis from './vis/Vis'; 4 | import Login from './admin/Login'; 5 | import AdminPortal from './admin/AdminPortal'; 6 | import './index.css'; 7 | 8 | // Get the root element 9 | const rootElement = document.getElementById('root'); 10 | 11 | // Make sure the element exists 12 | if (!rootElement) throw new Error('Failed to find the root element'); 13 | 14 | // Create a root 15 | const root = createRoot(rootElement); 16 | 17 | // Render your app 18 | root.render( 19 | 20 | 21 | } /> 22 | } /> 23 | } /> 24 | } /> 25 | } /> 26 | } 29 | /> 30 | } 33 | /> 34 | 35 | , 36 | ); 37 | -------------------------------------------------------------------------------- /configs/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name localhost; 4 | 5 | # Compression settings for better performance 6 | gzip on; 7 | gzip_vary on; 8 | gzip_min_length 10240; 9 | gzip_proxied expired no-cache no-store private auth; 10 | gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript; 11 | gzip_disable "MSIE [1-6]\."; 12 | 13 | # Root directory for the site 14 | root /usr/share/nginx/html; 15 | 16 | location ^~ /admin/assets/ { 17 | # Rewrite requests from /admin/assets/ to /assets/ 18 | rewrite ^/admin/assets/(.*) /assets/$1 last; 19 | } 20 | 21 | # Static assets caching 22 | location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { 23 | expires 1y; 24 | add_header Cache-Control "public, max-age=31536000, immutable"; 25 | try_files $uri =404; 26 | } 27 | 28 | # Handle all routes for the SPA 29 | location / { 30 | index index.html; 31 | try_files $uri $uri/ /index.html; 32 | } 33 | 34 | # Error pages 35 | error_page 404 /index.html; 36 | error_page 500 502 503 504 /50x.html; 37 | location = /50x.html { 38 | try_files $uri =404; 39 | } 40 | } -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Python bytecode and cache 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | .pytest_cache/ 8 | .coverage 9 | htmlcov/ 10 | .tox/ 11 | .nox/ 12 | .hypothesis/ 13 | .coverage.* 14 | coverage.xml 15 | *.cover 16 | 17 | # Virtual environments 18 | venv/ 19 | env/ 20 | ENV/ 21 | virtualenv/ 22 | .env 23 | .venv 24 | env.bak/ 25 | venv.bak/ 26 | 27 | # Distribution / packaging 28 | dist/ 29 | build/ 30 | *.egg-info/ 31 | *.egg 32 | MANIFEST 33 | .installed.cfg 34 | eggs/ 35 | develop-eggs/ 36 | downloads/ 37 | lib/ 38 | lib64/ 39 | parts/ 40 | sdist/ 41 | var/ 42 | wheels/ 43 | 44 | # Version control 45 | .git 46 | .gitignore 47 | .gitattributes 48 | .github/ 49 | 50 | # IDE and editors 51 | .idea/ 52 | .vscode/ 53 | *.swp 54 | *.swo 55 | *~ 56 | .DS_Store 57 | *.sublime-* 58 | .project 59 | .pydevproject 60 | .settings 61 | .spyderproject 62 | .spyproject 63 | .ropeproject 64 | 65 | # Logs 66 | logs/ 67 | *.log 68 | pip-log.txt 69 | pip-delete-this-directory.txt 70 | 71 | # Docker specific 72 | Dockerfile* 73 | docker-compose* 74 | .dockerignore 75 | 76 | # Documentation 77 | docs/ 78 | *.md 79 | README* 80 | LICENSE 81 | AUTHORS 82 | 83 | # Project specific 84 | .pytest_cache/ 85 | .mypy_cache/ 86 | .ruff_cache/ 87 | .ipynb_checkpoints/ 88 | notebooks/ 89 | tests/ 90 | .flake8 91 | setup.cfg 92 | pyproject.toml 93 | requirements-dev.txt 94 | tox.ini 95 | 96 | # Keep essential files 97 | !requirements.txt 98 | !setup.py 99 | !pyproject.toml -------------------------------------------------------------------------------- /src/utils/get-bounds.ts: -------------------------------------------------------------------------------- 1 | import * as L from 'leaflet'; 2 | 3 | type GetBoundsParams = { 4 | map: L.Map; 5 | center: [number, number]; 6 | minLat: number; 7 | minLon: number; 8 | maxLat: number; 9 | maxLon: number; 10 | width: number; 11 | height: number; 12 | }; 13 | 14 | export default function getBounds({ 15 | map, 16 | center, 17 | minLat, 18 | minLon, 19 | maxLat, 20 | maxLon, 21 | width, 22 | height, 23 | }: GetBoundsParams) { 24 | const { x, y } = map.project(center); 25 | 26 | const _bottomleft = map.project([minLat, minLon]); 27 | const bottomleft: [number, number] = [ 28 | Math.floor(Math.min(x - width / 2, _bottomleft.x - width / 10)), 29 | Math.ceil(Math.max(y + height / 2, _bottomleft.y + height / 10)), 30 | ]; 31 | 32 | const _topright = map.project([maxLat, maxLon]); 33 | const topright: [number, number] = [ 34 | Math.ceil(Math.max(x + width / 2, _topright.x + width / 10)), 35 | Math.floor(Math.min(y - height / 2, _topright.y - height / 10)), 36 | ]; 37 | 38 | const sw = map.unproject(bottomleft); 39 | const ne = map.unproject(topright); 40 | 41 | const bounds = L.latLngBounds(sw, ne); 42 | map 43 | .setMaxBounds(bounds) 44 | .on('drag', () => map.panInsideBounds(bounds, { animate: false })); 45 | 46 | return { 47 | width: topright[0] - bottomleft[0], 48 | height: bottomleft[1] - topright[1], 49 | left: bottomleft[0], 50 | top: topright[1], 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Name of the Docker container 2 | DOCKER_IMAGE=node:22-slim 3 | VIS_DOCKER_IMAGE_NAME=ccn-coverage-vis 4 | include .env 5 | 6 | # The current directory (mapped to the container) 7 | CURRENT_DIR=$(shell pwd) 8 | 9 | .PHONY: clean 10 | clean: 11 | @echo "Clean" 12 | rm -rf build 13 | docker volume rm ccn-coverage-vis_certs 14 | docker rmi $(docker images --filter=reference='ccn-coverage-vis*' -q) 15 | 16 | .PHONY: build-test 17 | build-test: 18 | @echo "Create test docker container for $(VIS_DOCKER_IMAGE_NAME)" 19 | 20 | docker build --build-arg NGINX_CONFIG="local-nginx.conf" -t $(VIS_DOCKER_IMAGE_NAME) -f vis.dockerfile . 21 | 22 | # Validate semantic version format 23 | validate-semver-%: 24 | @echo "Validating version format: $*" 25 | @if ! echo "$*" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$$' > /dev/null; then \ 26 | echo "Error: Version must be in semantic version format (e.g., 1.2.3)"; \ 27 | exit 1; \ 28 | fi 29 | 30 | .PHONY: build 31 | build: 32 | @echo "Create docker container for $(VIS_DOCKER_IMAGE_NAME)" 33 | docker build -t $(VIS_DOCKER_IMAGE_NAME) -f vis.dockerfile . 34 | 35 | # Build with specific version (e.g., make build-1.2.3) 36 | build-%: validate-semver-% 37 | @echo "Create docker container for $(VIS_DOCKER_IMAGE_NAME) with version $*" 38 | docker build -t $(VIS_DOCKER_IMAGE_NAME):$* -f vis.dockerfile . 39 | 40 | 41 | # The target for development 42 | .PHONY: dev 43 | dev: 44 | docker run --rm -it \ 45 | -v $(CURRENT_DIR):/app \ 46 | -w /app \ 47 | -p $(EXPOSED_PORT):$(EXPOSED_PORT) \ 48 | $(DOCKER_IMAGE) /bin/bash 49 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | type SiteOption = { 2 | label: string; 3 | value: string; 4 | status: SiteStatus; 5 | }; 6 | 7 | type DeviceOption = { 8 | label: string; 9 | value: string; 10 | }; 11 | 12 | type Cell = { 13 | x: int; 14 | y: int; 15 | }; 16 | 17 | type DisplayOption = { 18 | label: string; 19 | name: string; 20 | checked: boolean; 21 | }; 22 | 23 | type SiteStatus = 'active' | 'confirmed' | 'in-conversation' | 'unknown'; 24 | 25 | type AdminPage = 'users' | 'edit-site' | 'edit-data'; 26 | 27 | type UserRow = { 28 | identity: string; 29 | email: string; 30 | firstName: string; 31 | lastName: string; 32 | isEnabled: boolean; 33 | issueDate: Date; 34 | registered: boolean; 35 | qrCode: string; 36 | }; 37 | 38 | type Site = { 39 | name: string; 40 | latitude: number; 41 | longitude: number; 42 | status: SiteStatus; 43 | address: string; 44 | cell_id: string[]; 45 | color?: string; 46 | boundary?: LatLng[]; 47 | }; 48 | 49 | type SiteData = { 50 | [id: string]: { 51 | ping: number; 52 | download_speed: number; 53 | upload_speed: number; 54 | dbm: number; 55 | }; 56 | }; 57 | 58 | type Marker = { 59 | latitude: number; 60 | longitude: number; 61 | device_id: string; 62 | site: string; 63 | dbm?: number; 64 | upload_speed: number; 65 | download_speed: number; 66 | ping: number; 67 | mid: string; 68 | }; 69 | 70 | type Measurement = { 71 | latitude: number; 72 | longitude: number; 73 | timestamp: string; 74 | upload_speed: number; 75 | download_speed: number; 76 | data_since_last_report: number; 77 | ping: number; 78 | site: string; 79 | device_id: number; 80 | }; 81 | -------------------------------------------------------------------------------- /src/admin/ViewIdentity.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Button from '@mui/material/Button'; 3 | import TextField from '@mui/material/TextField'; 4 | import Dialog from '@mui/material/Dialog'; 5 | import DialogActions from '@mui/material/DialogActions'; 6 | import DialogContent from '@mui/material/DialogContent'; 7 | import DialogContentText from '@mui/material/DialogContentText'; 8 | import DialogTitle from '@mui/material/DialogTitle'; 9 | import Link from '@mui/material/Link'; 10 | 11 | interface NewUserDialogProp { 12 | identity: string; 13 | } 14 | export default function NewUserDialog(props: NewUserDialogProp) { 15 | const [open, setOpen] = React.useState(false); 16 | 17 | const handleClickOpen = () => { 18 | setOpen(true); 19 | }; 20 | 21 | const handleClose = () => { 22 | setOpen(false); 23 | }; 24 | 25 | return ( 26 |
27 | 28 | {'...' + props.identity.substring(56)} 29 | 30 | 31 | View Identity 32 | 33 | 34 | 44 | 45 | 46 | 47 | 48 | 49 | 50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "main" 4 | ], 5 | "plugins": [ 6 | [ 7 | "@semantic-release/commit-analyzer", 8 | { 9 | "preset": "angular", 10 | "releaseRules": [ 11 | { 12 | "type": "feat", 13 | "release": "minor" 14 | }, 15 | { 16 | "type": "fix", 17 | "release": "patch" 18 | }, 19 | { 20 | "type": "refactor", 21 | "release": "patch" 22 | }, 23 | { 24 | "type": "perf", 25 | "release": "patch" 26 | }, 27 | { 28 | "type": "build", 29 | "release": "patch" 30 | }, 31 | { 32 | "type": "ci", 33 | "release": "patch" 34 | }, 35 | { 36 | "type": "chore", 37 | "release": "patch" 38 | }, 39 | { 40 | "type": "docs", 41 | "release": "patch" 42 | }, 43 | { 44 | "type": "style", 45 | "release": "patch" 46 | } 47 | ] 48 | } 49 | ], 50 | "@semantic-release/release-notes-generator", 51 | "@semantic-release/git", 52 | "@semantic-release/github" 53 | ], 54 | "prepare": [ 55 | "@semantic-release/changelog", 56 | "./scripts/publish-version.js", 57 | "@semantic-release/git" 58 | ] 59 | } -------------------------------------------------------------------------------- /src/vis/DateSelect.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Typography from '@mui/material/Typography'; 3 | import TextField from '@mui/material/TextField'; 4 | import Box from '@mui/material/Box'; 5 | import Stack from '@mui/material/Stack'; 6 | import '@fontsource/roboto'; 7 | import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; 8 | import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; 9 | import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker'; 10 | import dayjs from 'dayjs'; 11 | 12 | interface SidebarProps { 13 | timeFrom: Date; 14 | timeTo: Date; 15 | setTimeFrom: React.Dispatch>; 16 | setTimeTo: React.Dispatch>; 17 | loading: boolean; 18 | } 19 | 20 | const DateSelect = (props: SidebarProps) => { 21 | const handleChangeTimeFrom = (newValue: dayjs.Dayjs | null) => { 22 | if (newValue) { 23 | props.setTimeFrom(newValue.toDate()); 24 | } 25 | }; 26 | 27 | const handleChangeTimeTo = (newValue: dayjs.Dayjs | null) => { 28 | if (newValue) { 29 | props.setTimeTo(newValue.toDate()); 30 | } 31 | }; 32 | 33 | return ( 34 | 35 | Filter Date Time 36 | 37 | 38 | 44 | 50 | 51 | 52 | 53 | ); 54 | }; 55 | 56 | export default DateSelect; 57 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import path from 'path' 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | base: "./", 8 | resolve: { 9 | alias: { 10 | '@': path.resolve(__dirname, './src'), 11 | }, 12 | }, 13 | server: { 14 | host: true, 15 | port: 3002, 16 | cors: true, 17 | proxy: { 18 | '/api': { 19 | target: 'http://localhost:3000', 20 | changeOrigin: true, 21 | secure: false, 22 | rewrite: (path) => path.replace(/^\/api/, ''), 23 | configure: (proxy, options) => { 24 | proxy.on('error', (err, req, res) => { 25 | console.log('proxy error', err); 26 | }); 27 | proxy.on('proxyReq', (proxyReq, req, res) => { 28 | console.log('Sending Request to the Target:', req.method, req.url); 29 | }); 30 | proxy.on('proxyRes', (proxyRes, req, res) => { 31 | console.log('Received Response from the Target:', proxyRes.statusCode); 32 | }); 33 | }, 34 | }, 35 | '/secure': { 36 | target: 'http://localhost:3000', 37 | changeOrigin: true, 38 | secure: false, 39 | rewrite: (path) => path.replace(/^\/secure/, ''), 40 | configure: (proxy, options) => { 41 | proxy.on('error', (err, req, res) => { 42 | console.log('proxy error', err); 43 | }); 44 | proxy.on('proxyReq', (proxyReq, req, res) => { 45 | console.log('Sending Request to the Target:', req.method, req.url); 46 | }); 47 | proxy.on('proxyRes', (proxyRes, req, res) => { 48 | console.log('Received Response from the Target:', proxyRes.statusCode); 49 | }); 50 | }, 51 | } 52 | } 53 | }, 54 | publicDir: 'public', 55 | build: { 56 | outDir: "build" 57 | } 58 | }) -------------------------------------------------------------------------------- /src/ListItems.tsx: -------------------------------------------------------------------------------- 1 | import ListItem from '@mui/material/ListItem'; 2 | import ListItemIcon from '@mui/material/ListItemIcon'; 3 | import ListItemText from '@mui/material/ListItemText'; 4 | import ListSubheader from '@mui/material/ListSubheader'; 5 | import EditLocationAltIcon from '@mui/icons-material/EditLocationAlt'; 6 | import ManageAccountsIcon from '@mui/icons-material/ManageAccounts'; 7 | import SyncIcon from '@mui/icons-material/Sync'; 8 | import HomeIcon from '@mui/icons-material/Home'; 9 | import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; 10 | import { ListItemButton } from '@mui/material'; 11 | 12 | export const mainListItems = ( 13 |
14 | Admin Panel 15 | window.open('/admin/users', '_self')}> 16 | 17 | 18 | 19 | 20 | 21 | window.open('/admin/edit-site', '_self')}> 22 | 23 | 24 | 25 | 26 | 27 | window.open('/admin/edit-data', '_self')}> 28 | 29 | 30 | 31 | 32 | 33 |
34 | ); 35 | 36 | export const secondaryListItems = ( 37 |
38 | User Panel 39 | window.open('/')}> 40 | 41 | 42 | 43 | 44 | 45 |
46 | ); 47 | 48 | export const homeListItems = ( 49 |
50 | window.open('login')}> 51 | 52 | 53 | 54 | 55 | 56 |
57 | ); 58 | -------------------------------------------------------------------------------- /src/vis/MapSelectionRadio.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Radio from '@mui/material/Radio'; 3 | import Typography from '@mui/material/Typography'; 4 | import RadioGroup from '@mui/material/RadioGroup'; 5 | import Box from '@mui/material/Box'; 6 | import FormControlLabel from '@mui/material/FormControlLabel'; 7 | import FormControl from '@mui/material/FormControl'; 8 | import '@fontsource/roboto'; 9 | 10 | const MAP_TYPE_INDEX = { 11 | dbm: 1, 12 | ping: 1, 13 | upload_speed: 1, 14 | download_speed: 1, 15 | } as const; 16 | export type MapType = keyof typeof MAP_TYPE_INDEX; 17 | 18 | function isMapType(m: any): m is MapType { 19 | return m in MAP_TYPE_INDEX; 20 | } 21 | 22 | interface MapSelectionRadioProps { 23 | mapType: MapType; 24 | setMapType: React.Dispatch>; 25 | loading: boolean; 26 | } 27 | 28 | export default function MapSelectionRadio(props: MapSelectionRadioProps) { 29 | type InputEvent = React.ChangeEvent; 30 | const handleChange = (event: InputEvent) => { 31 | const _mapType = event.target.value; 32 | if (!isMapType(_mapType)) { 33 | throw new Error('Invalid map type selection: ' + _mapType); 34 | } 35 | props.setMapType(_mapType); 36 | }; 37 | 38 | return ( 39 | 40 | 41 | Map Type 42 | 48 | } 51 | label='Signal Strength' 52 | /> 53 | } 56 | label='Upload Speed' 57 | /> 58 | } 61 | label='Download Speed' 62 | /> 63 | } label='Ping' /> 64 | 65 | 66 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cnn-coverage-vis", 3 | "homepage": "https://seattlecommunitynetwork.org/ccn-coverage-vis/", 4 | "version": "2.1.0", 5 | "private": true, 6 | "type": "module", 7 | "dependencies": { 8 | "@emotion/react": "^11.14.0", 9 | "@emotion/styled": "^11.14.0", 10 | "@fontsource/roboto": "^5.2.5", 11 | "@mui/icons-material": "^6.4.7", 12 | "@mui/material": "^6.4.7", 13 | "@mui/x-date-pickers": "^7.27.3", 14 | "add": "^2.0.6", 15 | "arquero": "^8.0.1", 16 | "cors": "^2.8.5", 17 | "d3": "^7.8.5", 18 | "dayjs": "^1.11.13", 19 | "jsonwebtoken": "^9.0.2", 20 | "leaflet": "^1.9.4", 21 | "leaflet-geosearch": "^4.2.0", 22 | "openapi-fetch": "^0.13.5", 23 | "qrcode.react": "^4.2.0", 24 | "react": "^19.0.0", 25 | "react-dom": "^19.0.0", 26 | "react-router": "^7.3.0", 27 | "react-router-dom": "^7.3.0", 28 | "react-select": "^5.10.1", 29 | "web-vitals": "^4.2.4" 30 | }, 31 | "scripts": { 32 | "start": "vite", 33 | "dev": "vite --host", 34 | "build": "tsc && vite build", 35 | "preview": "vite preview", 36 | "format": "prettier --write --config ./prettierrc.json src/**/*.{ts,tsx}", 37 | "format-check": "prettier --check --config ./prettierrc.json src/**/*.{ts,tsx}", 38 | "release-dry-run": "semantic-release --dry-run" 39 | }, 40 | "browserslist": { 41 | "production": [ 42 | ">0.2%", 43 | "not dead", 44 | "not op_mini all" 45 | ], 46 | "development": [ 47 | "last 1 chrome version", 48 | "last 1 firefox version", 49 | "last 1 safari version" 50 | ] 51 | }, 52 | "devDependencies": { 53 | "@octokit/rest": "^21.1.1", 54 | "@semantic-release/changelog": "^6.0.3", 55 | "@semantic-release/git": "^10.0.1", 56 | "@semantic-release/github": "^11.0.2", 57 | "@types/cors": "^2.8.17", 58 | "@types/d3": "^7.4.3", 59 | "@types/jsonwebtoken": "^9.0.5", 60 | "@types/leaflet": "^1.9.8", 61 | "@types/node": "^22.13.10", 62 | "@types/qrcode.react": "^1.0.5", 63 | "@types/react": "^19.0.10", 64 | "@types/react-dom": "^19.0.4", 65 | "@vitejs/plugin-react": "^4.3.4", 66 | "canvas": "^3.1.0", 67 | "js-yaml": "^4.1.0", 68 | "prettier": "^3.2.5", 69 | "semantic-release": "^24.2.4", 70 | "typescript": "^5.8.2", 71 | "vite": "^6.2.2" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/vis/DisplaySelection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Typography from '@mui/material/Typography'; 3 | import Box from '@mui/material/Box'; 4 | import FormControlLabel from '@mui/material/FormControlLabel'; 5 | import FormControl from '@mui/material/FormControl'; 6 | import FormGroup from '@mui/material/FormGroup'; 7 | import Checkbox from '@mui/material/Checkbox'; 8 | import '@fontsource/roboto'; 9 | 10 | interface DisplayOptionsProps { 11 | displayOptions: DisplayOption[]; 12 | setDisplayOptions: React.Dispatch>; 13 | loading: boolean; 14 | } 15 | 16 | export const solveDisplayOptions = ( 17 | displayOptions: DisplayOption[], 18 | name: string, 19 | value: boolean, 20 | ) => { 21 | const newOptions: DisplayOption[] = []; 22 | for (let option of displayOptions) { 23 | if (option.name === name) { 24 | option.checked = value; 25 | } 26 | newOptions.push(option); 27 | } 28 | return newOptions; 29 | }; 30 | 31 | export default function DisplaySelection(props: DisplayOptionsProps) { 32 | type InputEvent = React.ChangeEvent; 33 | const handleChange = (event: InputEvent) => { 34 | const checked = event.target.checked; 35 | const name = event.target.name; 36 | const _displayOptions = [...props.displayOptions]; 37 | 38 | props.setDisplayOptions( 39 | _displayOptions.map(option => { 40 | if (option.name === name) { 41 | option.checked = checked; 42 | } 43 | return option; 44 | }), 45 | ); 46 | }; 47 | 48 | return ( 49 | 50 | 51 | Display Options 52 | 53 | {props.displayOptions.map((option: DisplayOption) => ( 54 | 63 | } 64 | label={option.label} 65 | /> 66 | ))} 67 | 68 | 69 | 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /scripts/generate-certs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Set variables 4 | PRIMARY_DOMAIN="ccn-coverage-vis" 5 | CERT_DIR="/certs" # Use absolute path 6 | DAYS_VALID=365 7 | 8 | # Add all domains that might be used to access your service 9 | DOMAINS=( 10 | "$PRIMARY_DOMAIN" 11 | "localhost" 12 | "127.0.0.1" 13 | ) 14 | 15 | # Create directory for certificates if it doesn't exist 16 | mkdir -p $CERT_DIR 17 | 18 | echo "Generating self-signed certificates..." 19 | 20 | # Generate private key 21 | openssl genrsa -out $CERT_DIR/private-key.pem 2048 22 | 23 | # Create config file for SAN support 24 | cat > $CERT_DIR/openssl.cnf << EOF 25 | [req] 26 | distinguished_name = req_distinguished_name 27 | req_extensions = v3_req 28 | prompt = no 29 | 30 | [req_distinguished_name] 31 | CN = $PRIMARY_DOMAIN 32 | 33 | [v3_req] 34 | basicConstraints = CA:FALSE 35 | keyUsage = nonRepudiation, digitalSignature, keyEncipherment 36 | subjectAltName = @alt_names 37 | 38 | [alt_names] 39 | EOF 40 | 41 | # Add all domains to the config file 42 | for i in "${!DOMAINS[@]}"; do 43 | echo "DNS.$((i+1)) = ${DOMAINS[$i]}" >> $CERT_DIR/openssl.cnf 44 | done 45 | 46 | # Generate a CSR with the config 47 | openssl req -new -key $CERT_DIR/private-key.pem -out $CERT_DIR/csr.pem -config $CERT_DIR/openssl.cnf 48 | 49 | # Generate the self-signed certificate 50 | openssl x509 -req -days $DAYS_VALID -in $CERT_DIR/csr.pem -signkey $CERT_DIR/private-key.pem -out $CERT_DIR/certificate.pem -extensions v3_req -extfile $CERT_DIR/openssl.cnf 51 | 52 | # Create a full chain file 53 | cat $CERT_DIR/certificate.pem > $CERT_DIR/fullchain.pem 54 | 55 | # Set proper permissions (readable by all) 56 | chmod 644 $CERT_DIR/private-key.pem 57 | chmod 644 $CERT_DIR/certificate.pem 58 | chmod 644 $CERT_DIR/fullchain.pem 59 | 60 | # Try to generate PKCS12 file but don't fail if it doesn't work 61 | openssl pkcs12 -export -out $CERT_DIR/certificate.pfx -inkey $CERT_DIR/private-key.pem -in $CERT_DIR/certificate.pem -passout pass: || echo "PKCS12 export failed, but continuing" 62 | 63 | # Verify file creation and permissions 64 | echo "Certificates generated successfully in $CERT_DIR directory!" 65 | echo "Files generated with permissions:" 66 | ls -la $CERT_DIR/ 67 | 68 | # Verify certificate content 69 | echo "Verifying certificate:" 70 | openssl x509 -in $CERT_DIR/certificate.pem -text -noout | head -n 15 71 | 72 | # Verify private key 73 | echo "Verifying private key:" 74 | openssl rsa -in $CERT_DIR/private-key.pem -check -noout || echo "Private key verification failed" -------------------------------------------------------------------------------- /src/admin/ViewQRCode.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Button from '@mui/material/Button'; 3 | import Dialog from '@mui/material/Dialog'; 4 | import DialogActions from '@mui/material/DialogActions'; 5 | import DialogContent from '@mui/material/DialogContent'; 6 | import DialogContentText from '@mui/material/DialogContentText'; 7 | import DialogTitle from '@mui/material/DialogTitle'; 8 | import TextField from '@mui/material/TextField'; 9 | import Slider from '@mui/material/Slider'; 10 | import QrCodeIcon from '@mui/icons-material/QrCode'; 11 | import { QRCodeCanvas } from 'qrcode.react'; 12 | 13 | interface ViewQRCodeProp { 14 | identity: string; 15 | qrCode: string; 16 | } 17 | 18 | export default function ViewQRCode(props: ViewQRCodeProp) { 19 | const [open, setOpen] = React.useState(false); 20 | const [size, setSize] = React.useState(512); 21 | 22 | const handleClickOpen = () => { 23 | setOpen(true); 24 | }; 25 | 26 | const handleClose = () => { 27 | setOpen(false); 28 | }; 29 | 30 | const handleChange = (event: Event, newSize: number | number[]) => { 31 | setSize(newSize as number); 32 | }; 33 | 34 | return ( 35 |
36 | 44 | 45 | Registration code 46 | 47 | 48 | The code will be valid for 30 minuites 49 | 50 | 1500 ? '' : props.qrCode} 53 | > 54 | 62 | 72 | 73 | 74 | 75 | 76 | 77 |
78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 18 | 19 | 23 | 24 | Seattle Community Network Coverage Map 25 | 60 | 61 | 62 | 63 | 64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /configs/local-nginx.conf: -------------------------------------------------------------------------------- 1 | # HTTP server - redirects to HTTPS 2 | server { 3 | listen 80; 4 | server_name localhost; 5 | 6 | # Redirect all HTTP requests to HTTPS 7 | return 301 https://$host$request_uri; 8 | } 9 | 10 | # HTTPS server 11 | server { 12 | listen 443 ssl; 13 | server_name localhost; 14 | 15 | # SSL certificate configuration 16 | ssl_certificate /etc/nginx/ssl/certs/certificate.pem; 17 | ssl_certificate_key /etc/nginx/ssl/certs/private-key.pem; 18 | 19 | # SSL protocols and ciphers for improved security 20 | ssl_protocols TLSv1.2 TLSv1.3; 21 | ssl_prefer_server_ciphers on; 22 | ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384'; 23 | ssl_session_timeout 1d; 24 | ssl_session_cache shared:SSL:50m; 25 | 26 | # HSTS (HTTP Strict Transport Security) 27 | add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"; 28 | 29 | # Compression settings for better performance 30 | gzip on; 31 | gzip_vary on; 32 | gzip_min_length 10240; 33 | gzip_proxied expired no-cache no-store private auth; 34 | gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript; 35 | gzip_disable "MSIE [1-6]\."; 36 | 37 | # Root directory for the site 38 | root /usr/share/nginx/html; 39 | 40 | # API and secure endpoints 41 | location ~ ^/(api|secure)/ { 42 | proxy_pass http://api:3000; 43 | proxy_http_version 1.1; 44 | proxy_set_header Upgrade $http_upgrade; 45 | proxy_set_header Connection 'upgrade'; 46 | proxy_set_header Host $host; 47 | proxy_set_header X-Real-IP $remote_addr; 48 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 49 | proxy_set_header X-Forwarded-Proto $scheme; 50 | proxy_cache_bypass $http_upgrade; 51 | 52 | # Timeout settings 53 | proxy_connect_timeout 60s; 54 | proxy_send_timeout 60s; 55 | proxy_read_timeout 60s; 56 | } 57 | 58 | # Handle requests to /admin/assets/ - KEY FIX HERE 59 | location ^~ /admin/assets/ { 60 | # Rewrite requests from /admin/assets/ to /assets/ 61 | rewrite ^/admin/assets/(.*) /assets/$1 last; 62 | } 63 | 64 | # Static assets caching 65 | location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { 66 | expires 1y; 67 | add_header Cache-Control "public, max-age=31536000, immutable"; 68 | try_files $uri =404; 69 | } 70 | 71 | # Handle all routes for the SPA 72 | location / { 73 | index index.html; 74 | try_files $uri $uri/ /index.html; 75 | } 76 | 77 | # Error pages 78 | error_page 404 /index.html; 79 | error_page 500 502 503 504 /50x.html; 80 | location = /50x.html { 81 | try_files $uri =404; 82 | } 83 | } -------------------------------------------------------------------------------- /src/leaflet-component/site-marker.ts: -------------------------------------------------------------------------------- 1 | import * as L from 'leaflet'; 2 | import { UNITS, MULTIPLIERS } from '../utils/measurementMapUtils'; 3 | import round2 from '../utils/round-2'; 4 | 5 | const statusColor: Map = new Map([ 6 | ['active', 'green'], 7 | ['confirmed', 'yellow'], 8 | ['in-conversation', 'red'], 9 | ]); 10 | 11 | export function isSiteArray(sites: any[]): sites is Site[] { 12 | return sites.every(isSite); 13 | } 14 | 15 | export function isMarkerArray(marker: any[]): marker is Marker[] { 16 | return marker.every(isMarker); 17 | } 18 | 19 | export function isSite(prop: any): prop is Site { 20 | return ( 21 | typeof prop?.name === 'string' || 22 | typeof prop?.latitude === 'number' || 23 | typeof prop?.longitude === 'number' || 24 | typeof prop?.address === 'string' || 25 | prop?.status in statusColor 26 | ); 27 | } 28 | 29 | export function isMarker(prop: any): prop is Marker { 30 | return ( 31 | typeof prop?.latitude === 'number' || 32 | typeof prop?.longitude === 'number' || 33 | typeof prop?.device_id === 'string' || 34 | typeof prop?.cell_id === 'string' || 35 | typeof prop?.dbm === 'number' || 36 | typeof prop?.upload_speed === 'number' || 37 | typeof prop?.download_speed === 'number' || 38 | typeof prop?.ping === 'number' || 39 | typeof prop?.mid === 'string' 40 | ); 41 | } 42 | 43 | export function siteMarker( 44 | site: Site, 45 | summary: { 46 | dbm: number; 47 | ping: number; 48 | upload_speed: number; 49 | download_speed: number; 50 | }, 51 | map: L.Map, 52 | ) { 53 | return L.marker([site.latitude, site.longitude]) 54 | .bindTooltip( 55 | `${site.name} [${site.status}]
${site.address}
58 | signal strength: ${round2(summary?.dbm * MULTIPLIERS.dbm)} ${UNITS.dbm}
59 | ping: ${round2(summary?.ping * MULTIPLIERS.ping)} ${UNITS.ping}
60 | upload speed: ${round2(summary?.upload_speed * MULTIPLIERS.upload_speed)} ${ 61 | UNITS.upload_speed 62 | }
63 | download speed: ${round2( 64 | summary?.download_speed * MULTIPLIERS.download_speed, 65 | )} ${UNITS.download_speed}`, 66 | ) 67 | .on('click', function (e: any) { 68 | map.setView(e.latlng, 13); 69 | }); 70 | } 71 | 72 | export function siteSmallMarker(m: Marker) { 73 | return L.marker([m.latitude, m.longitude]).bindTooltip( 74 | `${m.site} 75 |
76 | ${m.device_id} 77 |
${m.latitude}, ${m.longitude}
78 | signal strength: ${ 79 | m.dbm === undefined ? 'N/A' : round2(m.dbm * MULTIPLIERS.dbm) 80 | } ${UNITS.dbm}
81 | ping: ${round2(m.ping * MULTIPLIERS.ping)} ${UNITS.ping}
82 | upload speed: ${round2(m.upload_speed * MULTIPLIERS.upload_speed)} ${ 83 | UNITS.upload_speed 84 | }
85 | download speed: ${round2(m.download_speed * MULTIPLIERS.download_speed)} ${ 86 | UNITS.download_speed 87 | }`, 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /src/vis/MapLegend.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import * as d3 from 'd3'; 3 | import { createCanvas } from 'canvas'; 4 | 5 | const tickSize = 6; 6 | const height = 150; 7 | const marginTop = 40; 8 | const marginRight = 15; 9 | const marginBottom = 0; 10 | const marginLeft = 0; 11 | const ticks = height / 64; 12 | 13 | function ramp(color: (t: number) => string, n = 256) { 14 | const canvas = createCanvas(1, n); 15 | const context = canvas.getContext('2d'); 16 | 17 | for (let i = 0; i < n; ++i) { 18 | context.fillStyle = color(i / (n - 1)); 19 | context.fillRect(0, i, 1, 1); 20 | } 21 | 22 | return canvas; 23 | } 24 | 25 | interface MapProps { 26 | colorDomain: number[] | undefined; 27 | title: string; 28 | width: number; 29 | } 30 | 31 | const MapLegend = ({ colorDomain, title, width }: MapProps) => { 32 | const _svg = useRef(null); 33 | 34 | if (colorDomain && _svg.current) { 35 | const color = d3.scaleSequential(colorDomain, d3.interpolateViridis); 36 | const tickFormat = d3.format('.2f'); 37 | 38 | const svg = d3 39 | .select(_svg.current) 40 | .attr('width', width) 41 | .attr('height', height) 42 | .attr('viewBox', [0, 0, width, height].join(' ')) 43 | .style('overflow', 'visible') 44 | .style('display', 'block'); 45 | 46 | svg.selectAll('*').remove(); 47 | 48 | let tickAdjust = (g: d3.Selection) => 49 | g.selectAll('.tick line').attr('x1', width - marginRight - marginLeft); 50 | let x = Object.assign( 51 | color 52 | .copy() 53 | .interpolator(d3.interpolateRound(marginTop, height - marginBottom)), 54 | { 55 | range() { 56 | return [height - marginBottom, marginTop]; 57 | }, 58 | }, 59 | ); 60 | 61 | svg 62 | .append('image') 63 | .attr('x', marginLeft) 64 | .attr('y', marginTop) 65 | .attr('width', width - marginLeft - marginRight) 66 | .attr('height', height - marginTop - marginBottom) 67 | .attr('preserveAspectRatio', 'none') 68 | .attr( 69 | 'xlink:href', 70 | ramp( 71 | color.interpolator(), 72 | height - marginTop - marginBottom, 73 | ).toDataURL(), 74 | ); 75 | 76 | const n = Math.round(ticks + 1); 77 | const tickValues = d3 78 | .range(n) 79 | .map(i => d3.quantile(color.domain(), i / (n - 1)) ?? NaN); 80 | 81 | svg 82 | .append('g') 83 | .attr('transform', `translate(${marginLeft},${0})`) 84 | .call( 85 | d3 86 | .axisLeft(x) 87 | .ticks(ticks, typeof tickFormat === 'string' ? tickFormat : undefined) 88 | // .tickFormat(typeof tickFormat === 'function' ? tickFormat : undefined) 89 | .tickSize(tickSize) 90 | .tickValues(tickValues), 91 | ) 92 | .call(tickAdjust) 93 | .call(g => g.select('.domain').remove()) 94 | .call(g => 95 | g 96 | .append('text') 97 | .attr('y', marginTop - 12) 98 | .attr('x', width - marginRight - marginLeft - 2) 99 | .attr('fill', 'currentColor') 100 | .attr('text-anchor', 'begin') 101 | .attr('font-weight', 'bold') 102 | .attr('class', 'title') 103 | .text(title), 104 | ); 105 | } 106 | 107 | return ; 108 | }; 109 | 110 | export default MapLegend; 111 | -------------------------------------------------------------------------------- /src/admin/NewUserDialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Button from '@mui/material/Button'; 3 | import TextField from '@mui/material/TextField'; 4 | import Dialog from '@mui/material/Dialog'; 5 | import DialogActions from '@mui/material/DialogActions'; 6 | import DialogContent from '@mui/material/DialogContent'; 7 | import DialogContentText from '@mui/material/DialogContentText'; 8 | import DialogTitle from '@mui/material/DialogTitle'; 9 | import Stack from '@mui/material/Stack'; 10 | import AddIcon from '@mui/icons-material/Add'; 11 | import { apiClient } from '@/utils/fetch'; 12 | 13 | interface NewUserDialogProp { 14 | setCalled: React.Dispatch>; 15 | } 16 | export default function NewUserDialog(props: NewUserDialogProp) { 17 | const [open, setOpen] = React.useState(false); 18 | const [email, setEmail] = React.useState(''); 19 | const [firstName, setFirstName] = React.useState(''); 20 | const [lastName, setLastName] = React.useState(''); 21 | 22 | const handleClickOpen = () => { 23 | setOpen(true); 24 | }; 25 | 26 | const handleClose = () => { 27 | setOpen(false); 28 | }; 29 | 30 | const handleSubmit = () => { 31 | apiClient 32 | .POST('/secure/new-user', { 33 | body: { 34 | firstName: firstName, 35 | lastName: lastName, 36 | email: email, 37 | }, 38 | }) 39 | .then(res => { 40 | const { error } = res; 41 | if (error) { 42 | console.log(`Unable to create user: ${error}`); 43 | setOpen(true); 44 | return; 45 | } 46 | 47 | props.setCalled(false); 48 | setOpen(false); 49 | }) 50 | .catch(err => { 51 | console.log(`Unable to create user: ${err}`); 52 | setOpen(true); 53 | }); 54 | }; 55 | 56 | return ( 57 |
58 | 59 | 67 | 68 | 69 | Generate registration code 70 | 71 | 72 | The code will be valid for 30 minuites 73 | 74 |
75 | setEmail(e.target.value)} 84 | /> 85 | setFirstName(e.target.value)} 93 | /> 94 | setLastName(e.target.value)} 102 | /> 103 | 104 |
105 | 106 | 107 | 110 | 111 |
112 |
113 | ); 114 | } 115 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ccn-coverage-vis 2 | 3 | Visualizations of coverage and performance analysis for Community Cellular Networks. 4 | 5 | Now hosted on https://coverage.seattlecommunitynetwork.org/ 6 | 7 | 8 | ## Initial Setup 9 | To install this service, the fist time, you will need to: 10 | 11 | 1. Required tools and versions: 12 | 1. Install `node` and `npm` according to the directions at https://nodejs.org/en/download/package-manager 13 | 2. `make` and `docker` are used for local development 14 | 2. Clone the service: `https://github.com/Local-Connectivity-Lab/ccn-coverage-vis` 15 | 3. Configure: 16 | 1. `cd cd ccn-coverage-vis` 17 | 1. Edit `src/utils/config.ts` and set the correct URL for your API host (if you're testing or you're deploying to a new URL). 18 | 19 | ## Development 20 | Avoid committing your change directly to the `main` branch. Check out your own private branch for your development and submit a pull request when the feature/bug fix/cleanup is ready for review. Follow the [Angular Commit Message Conventions](https://github.com/angular/angular/blob/main/contributing-docs/commit-message-guidelines.md) for your commit message. Versions will be managed based on those commit messages. 21 | 22 | ## Troubleshooting & Recovery 23 | When a problem occurs, there are several checks to determine where the failure is: 24 | 1. Check HTTP errors in the browser 25 | 1. Login to the coverage-host 26 | 2. Confirm ccn-coverage-vis is operating as expected 27 | 3. Confirm nginx is operating as expected 28 | 29 | ### Checking HTTP errors in the browser 30 | First, open your browser and go to: https://coverage.seattlecommunitynetwork.org/ 31 | 32 | Is it working? 33 | 34 | If not, open up the browser **Web Developer Tools**, usually under the menu Tools > Developer Tools > Web Developer Tools. 35 | 36 | With this panel open at the bottom of the screen select the **Network** tab and refresh the browser page. 37 | 38 | Look in the first column, Status: 39 | * `200`: OK, everything is good. 40 | * `502`: Error with the backend services (behind nginx) 41 | * `500` errors: problem with nxginx. Look in `/var/log/nginx/error.log` for details. 42 | * `400` errors: problem with the service. Check the service logs and nginx logs. 43 | * Timeout or unreachable error: Something is broken in the network between your web browser and the coverage-vis host. 44 | 45 | 46 | ### Checking nginx 47 | If there appear problems with nginx, then check that the 48 | 49 | Check service operation: 50 | ``` 51 | systemctl status nginx 52 | ``` 53 | 54 | Check nginx logs: 55 | ``` 56 | sudo tail /var/log/nginx/error.log 57 | ``` 58 | 59 | Sources of errors might include nginx configuration in `/etc/nginx/conf.d/01-ccn-coverage.conf` 60 | 61 | If you need to restart nginx, use: 62 | ``` 63 | sudo systemctl restart nginx 64 | ``` 65 | 66 | ### Clean Recovery 67 | If nothing else works, the last option is a clean reinstall of the service. The process is: 68 | * Remove the `ccn-coverage-vis` directory. 69 | * Re-install as per **Initial Setup**. 70 | 71 | 72 | ## Testing 73 | 74 | We provide a docker compose environment for local testing. Run `docker compose up -d`, and the web server will be running on your local host at port `443`. 75 | 76 | 77 | # Issues 78 | 79 | - Chart doesn't show tooltips. 80 | 81 | # TODOs 82 | 83 | - Toggle graph view off results in no toggle-on button 84 | - Make the chart more informative 85 | - Hover on a line should show the exact data and which sites are they from 86 | - Admin Panel 87 | - Edit Button 88 | - Toggle Active 89 | - Better compatibility with local development 90 | 91 | ## Contributing 92 | Any contribution and pull requests are welcome! However, before you plan to implement some features or try to fix an uncertain issue, it is recommended to open a discussion first. You can also join our [Discord channel](https://discord.com/invite/gn4DKF83bP), or visit our [website](https://seattlecommunitynetwork.org/). 93 | 94 | ## License 95 | ccn-coverage-vis is released under Apache License. See [LICENSE](/LICENSE) for more details. -------------------------------------------------------------------------------- /src/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { styled, createTheme, ThemeProvider } from '@mui/material/styles'; 3 | import CssBaseline from '@mui/material/CssBaseline'; 4 | import MuiDrawer from '@mui/material/Drawer'; 5 | import Box from '@mui/material/Box'; 6 | import MuiAppBar, { AppBarProps as MuiAppBarProps } from '@mui/material/AppBar'; 7 | import Toolbar from '@mui/material/Toolbar'; 8 | import List from '@mui/material/List'; 9 | import Typography from '@mui/material/Typography'; 10 | import Divider from '@mui/material/Divider'; 11 | import IconButton from '@mui/material/IconButton'; 12 | import Badge from '@mui/material/Badge'; 13 | import Container from '@mui/material/Container'; 14 | import MenuIcon from '@mui/icons-material/Menu'; 15 | import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; 16 | import NotificationsIcon from '@mui/icons-material/Notifications'; 17 | import { mainListItems, secondaryListItems } from './ListItems'; 18 | import Footer from './Footer'; 19 | 20 | const drawerWidth: number = 240; 21 | 22 | interface AppBarProps extends MuiAppBarProps { 23 | open?: boolean; 24 | } 25 | 26 | const AppBar = styled(MuiAppBar, { 27 | shouldForwardProp: prop => prop !== 'open', 28 | })(({ theme, open }) => ({ 29 | zIndex: theme.zIndex.drawer + 1, 30 | transition: theme.transitions.create(['width', 'margin'], { 31 | easing: theme.transitions.easing.sharp, 32 | duration: theme.transitions.duration.leavingScreen, 33 | }), 34 | ...(open && { 35 | marginLeft: drawerWidth, 36 | width: `calc(100% - ${drawerWidth}px)`, 37 | transition: theme.transitions.create(['width', 'margin'], { 38 | easing: theme.transitions.easing.sharp, 39 | duration: theme.transitions.duration.enteringScreen, 40 | }), 41 | }), 42 | })); 43 | 44 | const Drawer = styled(MuiDrawer, { 45 | shouldForwardProp: prop => prop !== 'open', 46 | })(({ theme, open }) => ({ 47 | '& .MuiDrawer-paper': { 48 | position: 'relative', 49 | whiteSpace: 'nowrap', 50 | width: drawerWidth, 51 | transition: theme.transitions.create('width', { 52 | easing: theme.transitions.easing.sharp, 53 | duration: theme.transitions.duration.enteringScreen, 54 | }), 55 | boxSizing: 'border-box', 56 | ...(!open && { 57 | overflowX: 'hidden', 58 | transition: theme.transitions.create('width', { 59 | easing: theme.transitions.easing.sharp, 60 | duration: theme.transitions.duration.leavingScreen, 61 | }), 62 | width: theme.spacing(7), 63 | [theme.breakpoints.up('sm')]: { 64 | width: theme.spacing(9), 65 | }, 66 | }), 67 | }, 68 | })); 69 | 70 | const mdTheme = createTheme(); 71 | 72 | export default function Navbar() { 73 | const [open, setOpen] = React.useState(true); 74 | const toggleDrawer = () => { 75 | setOpen(!open); 76 | }; 77 | 78 | return ( 79 | 80 | 81 | 82 | 83 | 88 | 98 | 99 | 100 | 107 | Dashboard 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 125 | 126 | 127 | 128 | 129 | 130 | {mainListItems} 131 | 132 | {secondaryListItems} 133 | 134 | 135 | 136 | ); 137 | } 138 | -------------------------------------------------------------------------------- /src/admin/Login.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Button from '@mui/material/Button'; 3 | import CssBaseline from '@mui/material/CssBaseline'; 4 | import TextField from '@mui/material/TextField'; 5 | import FormControlLabel from '@mui/material/FormControlLabel'; 6 | import Checkbox from '@mui/material/Checkbox'; 7 | import Box from '@mui/material/Box'; 8 | import Snackbar from '@mui/material/Snackbar'; 9 | import Alert from '@mui/material/Alert'; 10 | import Typography from '@mui/material/Typography'; 11 | import Container from '@mui/material/Container'; 12 | import Footer from '../Footer'; 13 | import { createTheme, ThemeProvider } from '@mui/material/styles'; 14 | import { apiClient } from '@/utils/fetch'; 15 | const theme = createTheme(); 16 | 17 | export default function Login() { 18 | const [open, setOpen] = React.useState(false); 19 | const [errorMessage, setErrorMessage] = React.useState(''); 20 | const handleClose = ( 21 | event?: React.SyntheticEvent | Event, 22 | reason?: string, 23 | ) => { 24 | if (reason === 'clickaway') { 25 | return; 26 | } 27 | 28 | setOpen(false); 29 | }; 30 | const handleSubmit = (event: React.FormEvent) => { 31 | event.preventDefault(); 32 | const data = new FormData(event.currentTarget); 33 | // eslint-disable-next-line no-console 34 | 35 | if (!data.has('username')) { 36 | return; 37 | } 38 | 39 | if (!data.has('password')) { 40 | return; 41 | } 42 | 43 | const username = data.get('username')?.toString() as string; 44 | const password = data.get('password')?.toString() as string; 45 | 46 | apiClient 47 | .POST('/secure/login', { 48 | body: { 49 | username: username, 50 | password: password, 51 | }, 52 | }) 53 | .then(res => { 54 | const { data, error } = res; 55 | if (!data || error) { 56 | console.log(`Unable to login: ${error.error}`); 57 | setErrorMessage(error.error); 58 | setOpen(true); 59 | return; 60 | } 61 | 62 | if (data.result === 'success') { 63 | console.log('Login successful'); 64 | window.open('/admin/users', '_self'); 65 | } else { 66 | setOpen(true); 67 | } 68 | }) 69 | .catch(err => { 70 | console.error(`Error occurred while logging in: ${err}`); 71 | setOpen(true); 72 | }); 73 | }; 74 | 75 | return ( 76 | 77 | 78 | 79 | 87 | 88 | Sign in 89 | 90 | 96 | 106 | 116 | } 118 | label='Remember me' 119 | /> 120 | 128 | 134 | 139 | Incorrect username or password: {errorMessage} 140 | 141 | 142 | 143 | 144 |