From 13837cdf322337af8c784ca43c13244123fc3e7b Mon Sep 17 00:00:00 2001 From: pralav-25 <174412353+pralav-25@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:17:20 +0530 Subject: [PATCH] Complete private workspaces and maintenance reporting workflows --- .github/workflows/ci.yml | 27 +- .gitignore | 5 + .python-version | 1 + README.md | 180 ++-- app.py | 5 + auth.html | 331 +------ database.py | 161 ++- home.html | 1298 +----------------------- index.html | 1375 +------------------------- main.py | 886 +++++++++++++---- public/static/app.js | 305 ++++++ public/static/favicon.svg | 1 + public/static/styles.css | 11 + public/static/utils.js | 18 + public/static/vendor/LEAFLET-LICENSE | 26 + public/static/vendor/leaflet.css | 661 +++++++++++++ public/static/vendor/leaflet.js | 6 + requirements-dev.txt | 3 +- requirements.txt | 12 +- ruff.toml | 13 + schemas.py | 62 +- scoring.py | 5 +- security.py | 29 + seed.py | 35 + tests-web/utils.test.mjs | 8 + tests/test_api.py | 361 ++++++- tests/test_schemas.py | 1 - vercel.json | 5 + 28 files changed, 2492 insertions(+), 3339 deletions(-) create mode 100644 .python-version create mode 100644 app.py create mode 100644 public/static/app.js create mode 100644 public/static/favicon.svg create mode 100644 public/static/styles.css create mode 100644 public/static/utils.js create mode 100644 public/static/vendor/LEAFLET-LICENSE create mode 100644 public/static/vendor/leaflet.css create mode 100644 public/static/vendor/leaflet.js create mode 100644 ruff.toml create mode 100644 security.py create mode 100644 seed.py create mode 100644 tests-web/utils.test.mjs create mode 100644 vercel.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b928a9a..28d35f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,22 @@ permissions: jobs: api: runs-on: ubuntu-latest + strategy: + matrix: + database: [sqlite, postgres] + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: structiq + POSTGRES_PASSWORD: test-only-password + POSTGRES_DB: structiq_test + ports: ['5432:5432'] + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -15,4 +31,13 @@ jobs: python-version: '3.12' cache: pip - run: pip install -r requirements-dev.txt - - run: python -m unittest discover -s tests -v + - run: ruff check . + - name: Run integration and scoring tests + run: python -m unittest discover -s tests -v + env: + TEST_DATABASE_URL: ${{ matrix.database == 'postgres' && 'postgresql+psycopg://structiq:test-only-password@localhost:5432/structiq_test' || '' }} + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: node --test tests-web/*.test.mjs + - run: node --check public/static/app.js diff --git a/.gitignore b/.gitignore index 0b2fb07..07c7921 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,8 @@ __pycache__/ .env .venv/ venv/ +.data/ +.vercel/ +.pytest_cache/ +.ruff_cache/ +node_modules/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/README.md b/README.md index 80de098..e4befa6 100644 --- a/README.md +++ b/README.md @@ -2,84 +2,134 @@ [![Checks](https://github.com/pralav-25/StructIQ/actions/workflows/ci.yml/badge.svg)](https://github.com/pralav-25/StructIQ/actions/workflows/ci.yml) -A predictive infrastructure-monitoring prototype for exploring asset health, -maintenance priority, citizen reports, and simulated environmental impact. The -project combines a FastAPI service with a SQLite data layer and browser-based -monitoring interfaces. +An infrastructure maintenance workflow application with private workspaces, an +asset map, community reports, verified photo attachments, and a persistent audit +trail. The Chennai examples and scoring model make it an interactive portfolio +demonstration, not a structural-safety assessment service. -## Features +## What works -- Asset registry for bridges, roads, and flyovers -- API validation for asset names, types, construction years, and coordinates -- Age-based health scoring and maintenance prioritization -- Citizen report workflow with prototype image-triage simulation -- Flood-impact simulation for road assets -- Maintenance and report-resolution actions -- Demo data for representative Chennai infrastructure +- Create an account and sign in to a private workspace, or launch an isolated demo. +- Register, edit, search, archive, and restore bridges, roads, and flyovers. +- Explore assets on a map and export the register as CSV. +- Submit an incident with a manually selected priority and optional photo. +- Share a public reporting link without granting dashboard access. +- Track a submitted report through an unguessable status link. +- Record maintenance and resolve incidents with notes; keep the complete history. +- Enable and clear a reversible flood scenario for road assets. +- Use the same application on desktop and mobile. -## Stack +Demo workspaces last seven days and are cleaned up when a new demo is created. +Registered accounts persist. Authentication uses usernames and passwords; email +recovery is not provided, so keep your password in a password manager. Signing out +of an anonymous demo ends access to that demo in the current browser. -- Python and FastAPI -- SQLAlchemy and SQLite -- Pydantic -- HTML, CSS, and JavaScript -- Bootstrap, Chart.js, and Leaflet +## Run locally -## Run the API +Use Python 3.12 or newer: -Requirements: Python 3.10 or newer. - -```bash +```sh python -m venv .venv source .venv/bin/activate -pip install -r requirements.txt +pip install -r requirements-dev.txt uvicorn main:app --reload ``` -The API is available at `http://127.0.0.1:8000`. Interactive API documentation -is available at `http://127.0.0.1:8000/docs`. - -To load the demonstration assets (repeat calls do not duplicate them), run: - -```bash -curl -X POST http://127.0.0.1:8000/setup-demo -``` - -Serve the HTML files with a local static server when testing the browser -interfaces: - -```bash -python -m http.server 8080 +Open **http://127.0.0.1:8000**. The app, API, and static assets are served from the +same origin. No separate frontend server or localhost URL edits are necessary. +Click **Launch private demo** to create a workspace with nine example assets. + +The default local database is `.data/structiq.db`, which is ignored by Git. The +legacy `structiq.db` file in the repository is retained as an original example; +the current app does not load or modify it. To use PostgreSQL instead, set +`DATABASE_URL` to a connection string. Both databases use the same application. + +## Deploy to Vercel + +1. Import this GitHub repository and choose the **FastAPI** framework preset. +2. Connect a persistent PostgreSQL database through Vercel Storage. A Neon Free + database is sufficient for a small demo. Choose Singapore to match `sin1` in + `vercel.json`, or change the function region to match your database. +3. Make the database's `DATABASE_URL` available to Production. The native + integration injects this variable automatically when you connect the project. +4. Deploy `main`, then check `/api/health`. It must return `status: ok` and + `database: postgresql`. +5. Open the app, create a workspace, submit a report, and reload to verify that + data persists. + +`app.py` exports the ASGI application. `public/static/` is served by Vercel's CDN +and mounted at `/static` by the local server. Database tables are created on +application startup. A deployment without a configured database fails explicitly +instead of pretending that temporary server storage is persistent. Use a separate +database for previews; do not connect untrusted preview code to production data. + +No application secret or shared administrator password is required. Session +tokens are random, stored as hashes, and sent in HttpOnly cookies. Production +cookies are Secure. Database credentials belong only in environment variables. + +## API and data behavior + +The API reference is available at `/docs`. Application endpoints use `/api/`. +Write requests require the `X-Requested-With: StructIQ` header, and private routes +also require a valid session cookie. Cross-origin access is not enabled. + +| Route | Purpose | +| --- | --- | +| `POST /api/auth/register`, `/login`, `/demo`, `/logout` | Account and session lifecycle | +| `GET /api/auth/me` | Current workspace | +| `GET, POST /api/assets` | List or register assets | +| `PUT /api/assets/{id}` | Edit an asset | +| `PATCH /api/assets/{id}/archive` | Archive or restore | +| `POST /api/assets/{id}/maintenance` | Record maintenance with a note | +| `GET, POST /api/reports` | Report history and new submissions | +| `POST /api/reports/{id}/resolve` | Retain a report with its resolution | +| `GET /api/reports/{id}/image` | Authenticated access to photo evidence | +| `GET /api/activity` | Latest 100 workspace events | +| `POST /api/scenarios/flood` | Set the flood scenario on or off | +| `GET /api/export` | Spreadsheet-safe CSV export | +| `GET, POST /api/public/{token}/assets or /reports` | Shared reporting flow | +| `GET /api/track/{code}` | Minimal public report status | +| `GET /api/health` | Database connectivity and application version | + +Uploads accept actual JPEG, PNG, or WebP images up to 3 MB. Images are decoded, +validated, resized, and re-encoded as JPEG without original metadata. A submitted +photo is retained for human review; the application does **not** diagnose cracks +or infer structural condition from images. + +The demo score starts from an age heuristic (3 points/year for roads, 0.5 for other +types); the included examples have explicit illustrative baseline scores. Open +reports subtract 1.5 times the selected severity (5, 10, or 15). The flood scenario +subtracts 15 points from roads while active. Maintenance adds up to 20 baseline +points, capped at 100. Resolution removes that report's penalty exactly once. +Scores are bounded at zero, and priority bands are `<40`, `40–69.9`, and `>=70`. + +Every private query is scoped to the authenticated workspace. Anonymous reporting +is limited to the assets exposed by the supplied share token. Tracking links +expose only asset name and timestamps/status, not descriptions, images, or account +details. Limits apply to sign-in, account/demo creation, submissions, and tracking. +Workspaces support 100 assets and 200 reports (50 reports for demos). + +## Verification + +```sh +ruff check . +python -m unittest discover -s tests -v +node --test tests-web/*.test.mjs +node --check public/static/app.js ``` -## Main endpoints - -| Method | Path | Purpose | -|---|---|---| -| `GET` | `/assets` | List monitored assets | -| `POST` | `/assets` | Add an asset and calculate its initial health | -| `GET` | `/reports` | List citizen reports | -| `POST` | `/reports/upload-ai` | Run the prototype report-triage flow | -| `POST` | `/weather/trigger-flood` | Simulate flood impact on road assets | -| `POST` | `/assets/{asset_id}/maintenance` | Record a maintenance improvement | - -## Scope - -StructIQ is a demonstration prototype, not a production structural-safety -system. Its health scores and image-triage results are simulated heuristics and -must not be used for engineering, maintenance, or emergency decisions. +CI runs the API suite against both SQLite and PostgreSQL 17, plus frontend logic +checks. Tests cover account isolation, invalid uploads, session revocation, +retained/idempotent resolution, public-link scope, reversible scenarios, CSV +formula escaping, and database-backed persistence. -## Development checks +## Scope and dependencies -```bash -pip install -r requirements-dev.txt -python -m unittest discover -s tests -v -``` +This project is a working portfolio demo. It has no validated structural model, +live sensors, real weather alerts, municipal integration, or emergency dispatch. +Its scores must not be used for real-world engineering or safety decisions. -`DATABASE_URL` selects the database (default: `sqlite:///./structiq.db`). Tests use -an isolated temporary database and never modify the bundled demonstration data. -`GET /reports?status=Open` returns the incident queue; omit the filter for the full -history. Resolving a report keeps that history and repeated resolution does not -raise the asset's score again. Uploads accept JPEG, PNG, or WebP content types up -to 5 MB. Content type checks are preliminary validation, not image verification. -The response marks triage as simulated; filenames cannot establish authenticity. +Built with FastAPI, SQLAlchemy, PostgreSQL/SQLite, Pillow, and browser JavaScript. +Leaflet 1.9.4 is bundled with its BSD license in `public/static/vendor/`. +Map tiles use OpenStreetMap with visible attribution. Fonts are loaded from Google +Fonts; the interface falls back to system sans-serif if they are unavailable. diff --git a/app.py b/app.py new file mode 100644 index 0000000..527df83 --- /dev/null +++ b/app.py @@ -0,0 +1,5 @@ +"""ASGI entrypoint for Vercel and standard Python hosts.""" + +from main import app + +__all__ = ["app"] diff --git a/auth.html b/auth.html index d37da9f..8930b89 100644 --- a/auth.html +++ b/auth.html @@ -1,330 +1 @@ - - - - - StructIQ | Authority Command Center - - - - - - - - - - - -
-
-

- STRUCTIQ | COMMAND CENTER -

-

- LOCATION: CHENNAI_GRID_ALPHA_2 -

-
-
- -
00:00:00
-
-
- -
-
-
-
-
-
- -
-
-
-
- NEW ASSET REGISTRY -
-
-
-
- -
-
-
-
-
-
- -
-
-
- MAINTENANCE CLOSURE -
-
- - -
-

Enter Report ID to restore health.

-
-
-
-
- -
-
-
INFRASTRUCTURE FLEET
- 0 -
-
- -
-
LIVE INCIDENT FEED (CITIZEN REPORTS)
-
-
Awaiting uplink...
-
-
-
-
-
- - - - - - - \ No newline at end of file +StructIQ workspaceOpen your StructIQ workspace diff --git a/database.py b/database.py index d29e141..b0def63 100644 --- a/database.py +++ b/database.py @@ -1,37 +1,124 @@ -from sqlalchemy import create_engine, Column, Integer, String, Float -import os -from sqlalchemy.orm import declarative_base -from sqlalchemy.orm import sessionmaker - -# 1. Setup the Database File -SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./structiq.db") -engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} if SQLALCHEMY_DATABASE_URL.startswith("sqlite") else {}) -SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) -Base = declarative_base() - -# 2. Define the Asset Table -class Asset(Base): - __tablename__ = "assets" - - id = Column(Integer, primary_key=True, index=True) - name = Column(String) - asset_type = Column(String) - age = Column(Integer) - construction_year = Column(Integer) - latitude = Column(Float) - longitude = Column(Float) - health_score = Column(Float, default=100.0) # Changed to Float for precision math - # PPT Features - vibration_level = Column(Float, default=0.05) - last_service_date = Column(String, default="2023-10-10") - maintenance_priority = Column(String, default="Low") - -# 3. Define the Report Table -class Report(Base): - __tablename__ = "reports" - - id = Column(Integer, primary_key=True, index=True) - asset_id = Column(Integer) - description = Column(String) - severity = Column(Integer) - status = Column(String, default="Open") \ No newline at end of file +"""Persistent, isolated workspaces for the demonstration application.""" + +import os +from datetime import UTC, datetime +from pathlib import Path + +from sqlalchemy import ( + Boolean, + Column, + DateTime, + Float, + ForeignKey, + Integer, + LargeBinary, + String, + create_engine, + event, +) +from sqlalchemy.orm import declarative_base, sessionmaker +from sqlalchemy.pool import NullPool + + +def utcnow(): + return datetime.now(UTC).replace(tzinfo=None) + + +DATABASE_URL = os.getenv("DATABASE_URL") or os.getenv("POSTGRES_URL") +if not DATABASE_URL: + if os.getenv("VERCEL"): + raise RuntimeError("Connect a Postgres database before deploying this application.") + Path(".data").mkdir(exist_ok=True) + DATABASE_URL = "sqlite:///./.data/structiq.db" +if DATABASE_URL.startswith(("postgres://", "postgresql://")): + DATABASE_URL = "postgresql+psycopg://" + DATABASE_URL.split("://", 1)[1] +options = {"pool_pre_ping": True} +if DATABASE_URL.startswith("sqlite"): + options["connect_args"] = {"check_same_thread": False, "timeout": 15} +else: + options["poolclass"] = NullPool +engine = create_engine(DATABASE_URL, **options) +if DATABASE_URL.startswith("sqlite"): + + @event.listens_for(engine, "connect") + def enable_foreign_keys(connection, _record): + connection.execute("PRAGMA foreign_keys=ON") + + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + +class Workspace(Base): + __tablename__ = "workspaces" + id = Column(Integer, primary_key=True) + username = Column(String(32), unique=True, nullable=False) + password_hash = Column(String(256)) + name = Column(String(80), nullable=False) + share_token = Column(String(64), unique=True, nullable=False) + is_demo = Column(Boolean, default=False, nullable=False) + flood_active = Column(Boolean, default=False, nullable=False) + created_at = Column(DateTime, default=utcnow, nullable=False) + + +class LoginSession(Base): + __tablename__ = "login_sessions" + token_hash = Column(String(64), primary_key=True) + workspace_id = Column(ForeignKey("workspaces.id", ondelete="CASCADE"), index=True) + expires_at = Column(DateTime, nullable=False, index=True) + + +class Asset(Base): + __tablename__ = "workspace_assets" + id = Column(Integer, primary_key=True) + workspace_id = Column( + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True + ) + name = Column(String(120), nullable=False) + asset_type = Column(String(16), nullable=False) + construction_year = Column(Integer, nullable=False) + latitude = Column(Float, nullable=False) + longitude = Column(Float, nullable=False) + condition_score = Column(Float, nullable=False) + archived = Column(Boolean, default=False, nullable=False) + last_service_at = Column(DateTime) + created_at = Column(DateTime, default=utcnow, nullable=False) + + +class Report(Base): + __tablename__ = "incident_reports" + id = Column(Integer, primary_key=True) + workspace_id = Column( + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True + ) + asset_id = Column( + ForeignKey("workspace_assets.id", ondelete="CASCADE"), nullable=False, index=True + ) + tracking_code = Column(String(48), nullable=False, unique=True) + description = Column(String(2000), nullable=False) + severity = Column(Integer, nullable=False) + status = Column(String(16), default="Open", nullable=False) + resolution_note = Column(String(1000)) + image = Column(LargeBinary) + created_at = Column(DateTime, default=utcnow, nullable=False) + resolved_at = Column(DateTime) + + +class Activity(Base): + __tablename__ = "activity_events" + id = Column(Integer, primary_key=True) + workspace_id = Column( + ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True + ) + asset_id = Column(ForeignKey("workspace_assets.id", ondelete="SET NULL")) + kind = Column(String(24), nullable=False) + message = Column(String(1500), nullable=False) + health_score = Column(Float) + created_at = Column(DateTime, default=utcnow, nullable=False) + + +class RequestLimit(Base): + __tablename__ = "request_limits" + key = Column(String(100), primary_key=True) + count = Column(Integer, nullable=False) + expires_at = Column(DateTime, nullable=False, index=True) diff --git a/home.html b/home.html index 66edca6..c162778 100644 --- a/home.html +++ b/home.html @@ -1,1297 +1 @@ - - - - - - - - - StructIQ | Predictive Infrastructure Health Monitoring - - - - - - - - - - - - - - - -
-
-
-
-
-
- - -
- -
- - - - -
- - -
- - - - - - - -
- - -
- - - - - - -
-
- -
- -
-
-
System v1.0.4 Online
-

Structural
Intelligence.

-

- Predictive analysis for civil infrastructure. Converting concrete data into maintenance timelines. -

-
- Initialize Pilot - View Schematics -
-
- -
-
-
-
-
ASSET: BR-402
-
-
LIVE FEED
-
-
-
- - - - -
- 80 - SCORE -
-
-
-
- LOAD
450 Tons
-
-
- VIBE
0.04 g
-
-
- TEMP
28°C
-
-
-
-
-
-
- - -
- - -
-
- Deep Dive -

Asset Diagnostics

-

- Detailed breakdown of structural health based on multi-sensor fusion. -

-
- -
- -
-
ASSET: NORTH BRIDGE (ID-402)
- - - - - - - - - - - - - - - - - - -
-
⚠ STRESS FRACTURE DETECTED
-
-
- - -
-
-
- 72 - /100 -
-
-
STATUS: MONITOR
-
Last Scan: 12 mins ago
-
-
- -
-
-
- Structural Integrity - 65% -
-
-
-
-
- Load Capacity - 88% -
-
-
-
-
- Material Degradation - 42% -
-
-
-
-
- Seismic Resistance - 95% -
-
-
-
- -
- AI PREDICTION: - High probability of micro-fracture expansion due to seasonal thermal contraction. Recommended inspection within 14 days. -
-
-
- - -
-
-

Live Network Activity

-
SIMULATION DATA
-
- -
-
- ID - TYPE - LOCATION - STATUS - SCORE -
- - -
- HWY-904 - Highway - East District, Sector 4 - ● STABLE - 98/100 -
- - -
- TUN-101 - Tunnel - Metro Underground - ● MONITOR - 65/100 -
- - -
- DAM-007 - Utility Dam - Reservoir Zone A - ⚠ CRITICAL - 32/100 -
- - -
- BRG-221 - Overpass - Industrial Belt - ● STABLE - 92/100 -
-
- -

- * Note: All data presented above is simulated for demonstration purposes only and does not reflect real-world infrastructure status. -

-
-
-
- - -
-
-
-
-
-
-
- Workflow -

Data Pipeline

-
-
-
-
01
-

Asset Tagging

-

Metadata Registration: Age, Material, Coordinates.

-
-
-
02
-

Ingestion

-

Sensors, Reports, Satellite Imagery.

-
-
-
03
-

Analysis

-

AI Model computes unified Health Score (0-100).

-
-
-
04
-

Alerts

-

Automated dispatch for Critical status assets.

-
-
-
-
- - -
-
-
- Field Operations -

Crowdsourced Verification.

-

- Mobile integration allows authorized field agents and citizens to upload visual data. Computer vision models validate damage and adjust risk scores instantly. -

-
-
- -

- * Geo-location required for accurate tagging. -

-
-
-
-
-
STRUCTIQ FIELD● REC
-
-
-
SCANNING_SURFACE...
-
-
-
DAMAGE TYPECONCRETE FISSURE
-
SEVERITYCRITICAL
-
-
-
-
-
- - -
-
-
-
-
- - -
- -
- - - - -
- - -
- - - - - - - -
- - -
- - - - - - -
-
- -
-
-

Deployment Access

-
-
-
-

PILOT

-
Proof of Concept
-

Single district deployment for evaluation.

-
    -
  • > 50 Assets
  • -
  • > 3 Admin Keys
  • -
  • > Basic Logs
  • -
- Request Key -
- -
-

STATE

-
Custom Build
-

On-premise solutions for government bodies.

-
    -
  • > Dedicated Server
  • -
  • > Custom AI Models
  • -
  • > SLA Support
  • -
- Contact Sales -
-
-
-
- - -
-
-
- - -
- Our Mission -

Immunizing Cities Against Decay.

-
-

- Infrastructure is the silent backbone of civilization. We believe that concrete shouldn't just stand; it should speak. -

-

- StructIQ was founded by a coalition of civil engineers and machine learning specialists with a singular directive: Move from reactive repairs to predictive prevention. -

-

- By fusing satellite imagery, IoT sensor data, and historical blueprints, we are building the nervous system for the modern metropolis. -

-
- -
-
-
2026
-
ESTABLISHED
-
-
-
-
-
-
- - -
-
-
- Support -

Common Queries

-
- -
-
- How does the prediction engine work? -
- Our AI analyzes historical data (age, material), real-time sensor inputs (load, vibration), and environmental factors to calculate a dynamic probability of failure. -
-
- -
- Is it compatible with existing municipal systems? -
- Yes. StructIQ is built with a RESTful API architecture that allows seamless data export to existing GIS and asset management software used by city councils. -
-
- -
- What is included in the Pilot Program? -
- The Pilot allows you to monitor up to 50 assets in a single district for 3 months. It includes full access to the dashboard and basic reporting features. -
-
- -
- How secure is the infrastructure data? -
- We use enterprise-grade encryption for all data at rest and in transit. Access is strictly controlled via role-based authentication suitable for government standards. -
-
-
-
-
- - - - - - - - - - - - - \ No newline at end of file +StructIQOpen StructIQ diff --git a/index.html b/index.html index 50ac3d2..6d92536 100644 --- a/index.html +++ b/index.html @@ -1,1353 +1,22 @@ - - - - - - - - - StructIQ | Predictive Infrastructure Health Monitoring - - - - - - - - - - - - - - - -
-
-
-
-
-
- - -
- -
- - - - -
- - -
- - - - - - - -
- - -
- - - - - - -
-
- -
- -
-
-
System v1.0.4 Online
-

Structural
Intelligence.

-

- Predictive analysis for civil infrastructure. Converting concrete data into maintenance timelines. -

-
- Initialize Pilot - View Schematics -
-
- -
-
-
-
-
ASSET: BR-402
-
-
LIVE FEED
-
-
-
- - - - -
- 80 - SCORE -
-
-
-
- LOAD
450 Tons
-
-
- VIBE
0.04 g
-
-
- TEMP
28°C
-
-
-
-
-
-
- - -
- - -
-
- Deep Dive -

Asset Diagnostics

-

- Detailed breakdown of structural health based on multi-sensor fusion. -

-
- -
- -
-
ASSET: NORTH BRIDGE (ID-402)
- - - - - - - - - - - - - - - - - - -
-
⚠ STRESS FRACTURE DETECTED
-
-
- - -
-
-
- 72 - /100 -
-
-
STATUS: MONITOR
-
Last Scan: 12 mins ago
-
-
- -
-
-
- Structural Integrity - 65% -
-
-
-
-
- Load Capacity - 88% -
-
-
-
-
- Material Degradation - 42% -
-
-
-
-
- Seismic Resistance - 95% -
-
-
-
- -
- AI PREDICTION: - High probability of micro-fracture expansion due to seasonal thermal contraction. Recommended inspection within 14 days. -
-
-
- - -
-
-

Live Network Activity

-
SIMULATION DATA
-
- -
-
- ID - TYPE - LOCATION - STATUS - SCORE -
- - -
- HWY-904 - Highway - East District, Sector 4 - ● STABLE - 98/100 -
- - -
- TUN-101 - Tunnel - Metro Underground - ● MONITOR - 65/100 -
- - -
- DAM-007 - Utility Dam - Reservoir Zone A - ⚠ CRITICAL - 32/100 -
- - -
- BRG-221 - Overpass - Industrial Belt - ● STABLE - 92/100 -
-
- -

- * Note: All data presented above is simulated for demonstration purposes only and does not reflect real-world infrastructure status. -

-
-
-
- - -
-
-
-
-
-
-
- Workflow -

Data Pipeline

-
-
-
-
01
-

Asset Tagging

-

Metadata Registration: Age, Material, Coordinates.

-
-
-
02
-

Ingestion

-

Sensors, Reports, Satellite Imagery.

-
-
-
03
-

Analysis

-

AI Model computes unified Health Score (0-100).

-
-
-
04
-

Alerts

-

Automated dispatch for Critical status assets.

-
-
-
-
- - -
-
-
- Field Operations -

Crowdsourced Verification.

-

- Mobile integration allows authorized field agents and citizens to upload visual data. Computer vision models validate damage and adjust risk scores instantly. -

-
-
- -

- * Geo-location required for accurate tagging. -

-
-
-
-
-
STRUCTIQ FIELD● REC
-
-
-
SCANNING_SURFACE...
-
-
-
DAMAGE TYPECONCRETE FISSURE
-
SEVERITYCRITICAL
-
-
-
-
-
- - -
-
-
-
-
- - -
- -
- - - - -
- - -
- - - - - - - -
- - -
- - - - - - -
-
- -
-
-

Deployment Access

-
-
-
-

PILOT

-
Proof of Concept
-

Single district deployment for evaluation.

-
    -
  • > 50 Assets
  • -
  • > 3 Admin Keys
  • -
  • > Basic Logs
  • -
- Request Key -
- -
-

STATE

-
Custom Build
-

On-premise solutions for government bodies.

-
    -
  • > Dedicated Server
  • -
  • > Custom AI Models
  • -
  • > SLA Support
  • -
- Contact Sales -
-
-
-
- - -
-
-
- - -
- Our Mission -

Immunizing Cities Against Decay.

-
-

- Infrastructure is the silent backbone of civilization. We believe that concrete shouldn't just stand; it should speak. -

-

- StructIQ was founded by a coalition of civil engineers and machine learning specialists with a singular directive: Move from reactive repairs to predictive prevention. -

-

- By fusing satellite imagery, IoT sensor data, and historical blueprints, we are building the nervous system for the modern metropolis. -

-
- -
-
-
2026
-
ESTABLISHED
-
-
-
-
-
-
- - -
-
-
- Support -

Common Queries

-
- -
-
- How does the prediction engine work? -
- Our AI analyzes historical data (age, material), real-time sensor inputs (load, vibration), and environmental factors to calculate a dynamic probability of failure. -
-
- -
- Is it compatible with existing municipal systems? -
- Yes. StructIQ is built with a RESTful API architecture that allows seamless data export to existing GIS and asset management software used by city councils. -
-
- -
- What is included in the Pilot Program? -
- The Pilot allows you to monitor up to 50 assets in a single district for 3 months. It includes full access to the dashboard and basic reporting features. -
-
- -
- How secure is the infrastructure data? -
- We use enterprise-grade encryption for all data at rest and in transit. Access is strictly controlled via role-based authentication suitable for government standards. -
-
-
-
-
- - - - - - - - - - - - \ No newline at end of file + + + + + + + + StructIQ — Infrastructure, understood. + + + + + + + + +
StructIQ

Opening your workspace…

+ +
+ + + diff --git a/main.py b/main.py index 827345a..e11f55c 100644 --- a/main.py +++ b/main.py @@ -1,202 +1,684 @@ -import random -from fastapi import FastAPI, Depends, HTTPException, File, UploadFile, Form, Query -from sqlalchemy.orm import Session -from fastapi.middleware.cors import CORSMiddleware -import database # Assumes database.py contains Asset and Report models -from schemas import AssetCreate -from scoring import calculate_health, maintenance_priority - -# 1. INITIALIZE APP -app = FastAPI(title="StructIQ AI Engine : Chennai") - -# 2. CORS CONFIGURATION -# Allows your HTML files to talk to this Python server -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=False, - allow_methods=["*"], - allow_headers=["*"], -) - -# 3. DATABASE INITIALIZATION -database.Base.metadata.create_all(bind=database.engine) - -def get_db(): - db = database.SessionLocal() - try: - yield db - finally: - db.close() - -# 5. API ENDPOINTS - -@app.get("/assets") -def get_assets(db: Session = Depends(get_db)): - """Fetches all assets for the Map and Sidebar.""" - return db.query(database.Asset).all() - -@app.get("/reports") -def get_reports(status: str | None = Query(None, pattern="^(Open|Resolved)$"), db: Session = Depends(get_db)): - """Fetches all citizen reports for the Incident Feed.""" - query = db.query(database.Report) - if status is not None: - query = query.filter(database.Report.status == status) - return query.order_by(database.Report.id.desc()).all() - -@app.post("/assets") -def create_asset(asset: AssetCreate, db: Session = Depends(get_db)): - """Manually adds a new asset with age-calculated health.""" - try: - calculated_age, final_score, prio = calculate_health( - asset.asset_type, - asset.construction_year, - ) - except ValueError as error: - raise HTTPException(status_code=422, detail=str(error)) from error - - new_asset = database.Asset( - name=asset.name, - asset_type=asset.asset_type, - age=calculated_age, - construction_year=asset.construction_year, - latitude=asset.latitude, - longitude=asset.longitude, - health_score=float(final_score), - maintenance_priority=prio, - vibration_level=round(random.uniform(0.01, 0.08), 3) - ) - db.add(new_asset) - db.commit() - db.refresh(new_asset) - return new_asset - -@app.post("/reports/upload-ai") -async def upload_ai_report( - asset_id: int = Form(...), - description: str = Form(..., min_length=1, max_length=2000), - file: UploadFile = File(...), - db: Session = Depends(get_db) -): - """Simulates image-based crack triage for the prototype workflow.""" - description = description.strip() - if not description: - raise HTTPException(status_code=422, detail="Description must not be blank.") - if file.content_type not in {"image/jpeg", "image/png", "image/webp"}: - raise HTTPException(status_code=415, detail="Use a JPEG, PNG, or WebP image.") - content = await file.read(5 * 1024 * 1024 + 1) - await file.close() - if not content: - raise HTTPException(status_code=422, detail="Image must not be empty.") - if len(content) > 5 * 1024 * 1024: - raise HTTPException(status_code=413, detail="Image exceeds the 5 MB limit.") - - asset = db.query(database.Asset).filter(database.Asset.id == asset_id).first() - if not asset: raise HTTPException(status_code=404, detail="Asset ID not found.") - - # Prototype-only simulation. Replace with a validated model before real use. - ai_severity = random.choice([5, 10, 15]) - labels = {5: "Minor Hairline", 10: "Significant Surface", 15: "Critical Structural"} - ai_label = labels[ai_severity] - - new_report = database.Report( - asset_id=asset_id, - description=f"SIMULATED TRIAGE [{ai_label}]: {description}", - severity=ai_severity, - status="Open" - ) - - # AI Impact: Reduce health based on severity - asset.health_score = max(0.0, asset.health_score - (ai_severity * 1.5)) - asset.maintenance_priority = maintenance_priority(asset.health_score) - - db.add(new_report) - db.commit() - db.refresh(new_report) - return {"report_id": new_report.id, "analysis": ai_label, "severity": ai_severity, "simulated": True} - -@app.post("/weather/trigger-flood") -def trigger_flood_alert(db: Session = Depends(get_db)): - """Simulates monsoon impact on Chennai Roads.""" - roads = db.query(database.Asset).filter(database.Asset.asset_type == "Road").all() - for road in roads: - road.health_score = max(0.0, road.health_score - 15.0) - road.maintenance_priority = maintenance_priority(road.health_score) - db.commit() - return {"status": "FLOOD ALERT ACTIVE", "affected_count": len(roads)} - -@app.post("/reports/{report_id}/resolve") -def resolve_report(report_id: int, db: Session = Depends(get_db)): - """Retain the report and restore health only on its first resolution.""" - report = db.query(database.Report).filter(database.Report.id == report_id).first() - if not report: raise HTTPException(status_code=404) - - if report.status == "Resolved": - return {"status": "success", "report_id": report.id, "already_resolved": True} - - asset = db.query(database.Asset).filter(database.Asset.id == report.asset_id).first() - if asset: - asset.health_score = min(100, asset.health_score + (report.severity * 1.5)) - asset.maintenance_priority = maintenance_priority(asset.health_score) - - report.status = "Resolved" - db.commit() - return {"status": "success", "report_id": report.id, "already_resolved": False} - -@app.post("/assets/{asset_id}/maintenance") -def perform_maintenance(asset_id: int, db: Session = Depends(get_db)): - """FEATURE: Admin manually boosts health after repair.""" - asset = db.query(database.Asset).filter(database.Asset.id == asset_id).first() - if not asset: raise HTTPException(status_code=404) - - asset.health_score = min(100.0, asset.health_score + 20.0) - asset.maintenance_priority = maintenance_priority(asset.health_score) - - db.commit() - return {"new_health": asset.health_score, "status": "Maintenance logged"} - -# --- JUDGES DEMO SETUP ROUTE --- -@app.post("/setup-demo") -def setup_demo(db: Session = Depends(get_db)): - """Seeds the database with 9 realistic Chennai landmarks.""" - demo_assets = [ - {"name": "Adyar Bridge", "asset_type": "Bridge", "latitude": 13.0067, "longitude": 80.2595, "construction_year": 1970}, - {"name": "Napier Bridge", "asset_type": "Bridge", "latitude": 13.0694, "longitude": 80.2824, "construction_year": 1869}, - {"name": "Ennore Creek Bridge", "asset_type": "Bridge", "latitude": 13.2217, "longitude": 80.3222, "construction_year": 2005}, - {"name": "Anna Flyover", "asset_type": "Flyover", "latitude": 13.0500, "longitude": 80.2500, "construction_year": 1973}, - {"name": "Kathipara Cloverleaf", "asset_type": "Flyover", "latitude": 13.0067, "longitude": 80.2050, "construction_year": 2008}, - {"name": "T.Nagar Skywalk", "asset_type": "Flyover", "latitude": 13.0333, "longitude": 80.2333, "construction_year": 2022}, - {"name": "OMR IT Expressway", "asset_type": "Road", "latitude": 12.9228, "longitude": 80.2316, "construction_year": 2006}, - {"name": "ECR Highway", "asset_type": "Road", "latitude": 12.8491, "longitude": 80.2433, "construction_year": 1998}, - {"name": "Mount Road", "asset_type": "Road", "latitude": 13.0600, "longitude": 80.2500, "construction_year": 1950} - ] - - for data in demo_assets: - existing = db.query(database.Asset).filter(database.Asset.name == data["name"]).first() - if not existing: - age, h_score, prio = calculate_health( - data["asset_type"], - data["construction_year"], - ) - - demo_floor = 15.0 if data["asset_type"] == "Road" else 20.0 - h_score = max(demo_floor, h_score) - prio = maintenance_priority(h_score) - - # Demo exception: Napier is old but represented as well maintained. - if data["name"] == "Napier Bridge": - h_score = 92.5 - prio = maintenance_priority(h_score) - - new_asset = database.Asset( - **data, age=age, health_score=round(h_score, 1), - maintenance_priority=prio, vibration_level=0.02 - ) - db.add(new_asset) - - db.commit() - return {"status": "Demo assets loaded successfully", "count": 9} - -if __name__ == "__main__": - import uvicorn - uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True) +"""StructIQ: an infrastructure maintenance workflow demonstration.""" + +import csv +import io +import os +import secrets +import warnings +from contextlib import asynccontextmanager +from datetime import timedelta +from pathlib import Path +from typing import Literal + +from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, Request, UploadFile +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response +from fastapi.staticfiles import StaticFiles +from PIL import Image, ImageOps, UnidentifiedImageError +from sqlalchemy import func, text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +import database as dbm +from schemas import ArchiveState, AssetCreate, Credentials, Note, Registration, Scenario +from scoring import calculate_health, maintenance_priority +from security import check_password, hash_password, token_hash +from seed import seed_workspace + +ROOT = Path(__file__).parent +COOKIE = "structiq_session" +MAX_UPLOAD = 3 * 1024 * 1024 +Image.MAX_IMAGE_PIXELS = 16_000_000 +DUMMY_PASSWORD = hash_password(secrets.token_urlsafe(20)) + + +@asynccontextmanager +async def lifespan(_app): + with dbm.engine.begin() as connection: + if connection.dialect.name == "postgresql": + connection.execute(text("SELECT pg_advisory_xact_lock(729601341)")) + dbm.Base.metadata.create_all(connection) + yield + + +app = FastAPI( + title="StructIQ", + version="1.0.0", + lifespan=lifespan, + description="Private workspaces for a demonstration maintenance workflow. Scores are illustrative, not engineering assessments.", +) + + +@app.middleware("http") +async def protect_requests(request: Request, call_next): + if request.method not in {"GET", "HEAD", "OPTIONS"}: + if request.headers.get("x-requested-with") != "StructIQ": + return JSONResponse( + {"detail": "Use the StructIQ application to submit this request."}, status_code=403 + ) + length = request.headers.get("content-length") + if length and (not length.isdigit() or int(length) > MAX_UPLOAD + 65536): + return JSONResponse( + {"detail": "Request exceeds the 3 MB upload limit."}, status_code=413 + ) + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "same-origin" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)" + if request.url.path.startswith("/api/"): + response.headers["Cache-Control"] = "no-store" + return response + + +def get_db(): + with dbm.SessionLocal() as db: + yield db + + +def workspace(request: Request, db: Session = Depends(get_db)): + token = request.cookies.get(COOKIE, "") + session = db.get(dbm.LoginSession, token_hash(token)) if token else None + if not session or session.expires_at <= dbm.utcnow(): + raise HTTPException(401, "Your session has expired. Sign in or start a new demo.") + account = db.get(dbm.Workspace, session.workspace_id) + if not account or (account.is_demo and account.created_at < dbm.utcnow() - timedelta(days=7)): + raise HTTPException(401, "This demo has expired. Start a new workspace.") + return account + + +def public_workspace(share_token, db): + account = db.query(dbm.Workspace).filter_by(share_token=share_token).first() + if not account or (account.is_demo and account.created_at < dbm.utcnow() - timedelta(days=7)): + raise HTTPException(404, "This reporting link is unavailable.") + return account + + +def limited(request, db, action, maximum, seconds=3600): + """Atomic persistent limits; production headers are set by the hosting proxy.""" + address = request.client.host if request.client else "unknown" + if os.getenv("VERCEL"): + address = request.headers.get("x-vercel-forwarded-for", address).split(",")[0].strip() + now = dbm.utcnow() + bucket = int(now.timestamp()) // seconds + key = f"{action}:{token_hash(address)[:32]}:{bucket}" + insert_module = __import__(f"sqlalchemy.dialects.{db.bind.dialect.name}", fromlist=["insert"]) + statement = insert_module.insert(dbm.RequestLimit).values( + key=key, count=1, expires_at=now + timedelta(seconds=seconds * 2) + ) + statement = statement.on_conflict_do_update( + index_elements=["key"], set_={"count": dbm.RequestLimit.count + 1} + ).returning(dbm.RequestLimit.count) + count = db.execute(statement).scalar_one() + db.query(dbm.RequestLimit).filter(dbm.RequestLimit.expires_at < now).delete() + db.commit() + if count > maximum: + raise HTTPException( + 429, "Too many requests. Please try again later.", headers={"Retry-After": str(seconds)} + ) + + +def account_data(account): + return { + "name": account.name, + "username": None if account.is_demo else account.username, + "is_demo": account.is_demo, + "share_token": account.share_token, + "flood_active": account.flood_active, + } + + +def start_session(db, account): + token = secrets.token_urlsafe(32) + days = 7 if account.is_demo else 30 + db.add( + dbm.LoginSession( + token_hash=token_hash(token), + workspace_id=account.id, + expires_at=dbm.utcnow() + timedelta(days=days), + ) + ) + db.commit() + response = JSONResponse(account_data(account)) + response.set_cookie( + COOKIE, + token, + max_age=days * 86400, + httponly=True, + secure=bool(os.getenv("VERCEL")), + samesite="lax", + path="/", + ) + return response + + +def activity(db, account, kind, message, asset=None): + db.flush() + db.add( + dbm.Activity( + workspace_id=account.id, + kind=kind, + message=message, + asset_id=asset.id if asset else None, + health_score=asset_score(db, account, asset) if asset else None, + ) + ) + + +def asset_score(db, account, asset): + severity = ( + db.query(func.coalesce(func.sum(dbm.Report.severity), 0)) + .filter_by(workspace_id=account.id, asset_id=asset.id, status="Open") + .scalar() + ) + flood = 15 if account.flood_active and asset.asset_type == "Road" else 0 + return round(max(0.0, asset.condition_score - severity * 1.5 - flood), 1) + + +def owned_asset(db, account, asset_id, include_archived=False): + asset = ( + db.query(dbm.Asset) + .filter_by(id=asset_id, workspace_id=account.id) + .with_for_update() + .first() + ) + if not asset or (asset.archived and not include_archived): + raise HTTPException(404, "Asset not found.") + return asset + + +def asset_data(db, account, asset): + score = asset_score(db, account, asset) + return { + "id": asset.id, + "name": asset.name, + "asset_type": asset.asset_type, + "construction_year": asset.construction_year, + "age": dbm.utcnow().year - asset.construction_year, + "latitude": asset.latitude, + "longitude": asset.longitude, + "health_score": score, + "condition_score": round(asset.condition_score, 1), + "maintenance_priority": maintenance_priority(score), + "archived": asset.archived, + "last_service_at": iso(asset.last_service_at), + "created_at": iso(asset.created_at), + } + + +def iso(value): + return value.isoformat() + "Z" if value else None + + +def report_data(db, report): + asset = db.get(dbm.Asset, report.asset_id) + return { + "id": report.id, + "asset_id": report.asset_id, + "asset_name": asset.name if asset else "Archived asset", + "description": report.description, + "severity": report.severity, + "status": report.status, + "tracking_code": report.tracking_code, + "has_image": bool(report.image), + "resolution_note": report.resolution_note, + "created_at": iso(report.created_at), + "resolved_at": iso(report.resolved_at), + } + + +@app.get("/api/health") +def health(db: Session = Depends(get_db)): + db.execute(text("SELECT 1")) + return { + "status": "ok", + "database": "postgresql" if db.bind.dialect.name == "postgresql" else "sqlite", + "version": "1.0.0", + } + + +@app.post("/api/auth/register") +def register(payload: Registration, request: Request, db: Session = Depends(get_db)): + limited(request, db, "register", 5) + account = dbm.Workspace( + username=payload.username, + name=payload.name, + password_hash=hash_password(payload.password), + share_token=secrets.token_urlsafe(24), + ) + db.add(account) + try: + db.flush() + except IntegrityError: + db.rollback() + raise HTTPException( + 409, "That username is already in use. Choose another or sign in." + ) from None + seed_workspace(db, account) + activity(db, account, "workspace", "Workspace created with nine illustrative Chennai assets.") + return start_session(db, account) + + +@app.post("/api/auth/demo") +def demo(request: Request, db: Session = Depends(get_db)): + limited(request, db, "demo", 8) + # Demo retention is shown before creation. Registered workspaces are never removed here. + db.query(dbm.Workspace).filter( + dbm.Workspace.is_demo.is_(True), dbm.Workspace.created_at < dbm.utcnow() - timedelta(days=7) + ).delete() + account = dbm.Workspace( + username="demo_" + secrets.token_hex(10), + name="Chennai demo workspace", + is_demo=True, + share_token=secrets.token_urlsafe(24), + ) + db.add(account) + db.flush() + seed_workspace(db, account) + activity(db, account, "workspace", "Private demo created. All scores are illustrative.") + return start_session(db, account) + + +@app.post("/api/auth/login") +def login(payload: Credentials, request: Request, db: Session = Depends(get_db)): + limited(request, db, "login", 15, 900) + account = db.query(dbm.Workspace).filter_by(username=payload.username, is_demo=False).first() + valid = check_password(payload.password, account.password_hash if account else DUMMY_PASSWORD) + if not account or not valid: + raise HTTPException(401, "Username or password is incorrect.") + return start_session(db, account) + + +@app.get("/api/auth/me") +def current_account(account=Depends(workspace)): + return account_data(account) + + +@app.post("/api/auth/logout") +def logout(request: Request, db: Session = Depends(get_db)): + token = request.cookies.get(COOKIE, "") + db.query(dbm.LoginSession).filter_by(token_hash=token_hash(token)).delete() + db.commit() + response = JSONResponse({"status": "signed_out"}) + response.delete_cookie(COOKIE, path="/") + return response + + +@app.get("/assets", include_in_schema=False) +@app.get("/api/assets") +def assets(archived: bool = False, account=Depends(workspace), db: Session = Depends(get_db)): + return [ + asset_data(db, account, a) + for a in db.query(dbm.Asset) + .filter_by(workspace_id=account.id, archived=archived) + .order_by(dbm.Asset.id) + ] + + +@app.post("/assets", include_in_schema=False) +@app.post("/api/assets", status_code=201) +def create_asset(payload: AssetCreate, account=Depends(workspace), db: Session = Depends(get_db)): + if db.query(dbm.Asset).filter_by(workspace_id=account.id).count() >= 100: + raise HTTPException(409, "This workspace has reached its 100-asset limit.") + _, score, _ = calculate_health(payload.asset_type, payload.construction_year) + asset = dbm.Asset(**payload.model_dump(), workspace_id=account.id, condition_score=score) + db.add(asset) + activity(db, account, "asset", f"Registered {asset.name}.", asset) + db.commit() + return asset_data(db, account, asset) + + +@app.put("/api/assets/{asset_id}") +def update_asset( + asset_id: int, payload: AssetCreate, account=Depends(workspace), db: Session = Depends(get_db) +): + asset = owned_asset(db, account, asset_id) + if (payload.asset_type, payload.construction_year) != ( + asset.asset_type, + asset.construction_year, + ): + _, asset.condition_score, _ = calculate_health( + payload.asset_type, payload.construction_year + ) + for key, value in payload.model_dump().items(): + setattr(asset, key, value) + activity(db, account, "asset", f"Updated registration details for {asset.name}.", asset) + db.commit() + return asset_data(db, account, asset) + + +@app.patch("/api/assets/{asset_id}/archive") +def archive_asset( + asset_id: int, payload: ArchiveState, account=Depends(workspace), db: Session = Depends(get_db) +): + asset = owned_asset(db, account, asset_id, include_archived=True) + if ( + payload.archived + and db.query(dbm.Report).filter_by(asset_id=asset.id, status="Open").count() + ): + raise HTTPException(409, "Resolve this asset’s open reports before archiving it.") + if asset.archived != payload.archived: + asset.archived = payload.archived + activity( + db, + account, + "asset", + f"{'Archived' if payload.archived else 'Restored'} {asset.name}.", + asset, + ) + db.commit() + return {"archived": asset.archived} + + +@app.post("/assets/{asset_id}/maintenance", include_in_schema=False) +@app.post("/api/assets/{asset_id}/maintenance") +def maintain_asset( + asset_id: int, payload: Note, account=Depends(workspace), db: Session = Depends(get_db) +): + asset = owned_asset(db, account, asset_id) + asset.condition_score = min(100.0, asset.condition_score + 20.0) + asset.last_service_at = dbm.utcnow() + activity(db, account, "maintenance", f"Maintenance on {asset.name}: {payload.note}", asset) + db.commit() + return asset_data(db, account, asset) + + +@app.get("/api/activity") +def events(account=Depends(workspace), db: Session = Depends(get_db)): + rows = ( + db.query(dbm.Activity) + .filter_by(workspace_id=account.id) + .order_by(dbm.Activity.id.desc()) + .limit(100) + ) + return [ + { + "id": a.id, + "kind": a.kind, + "message": a.message, + "asset_id": a.asset_id, + "health_score": a.health_score, + "created_at": iso(a.created_at), + } + for a in rows + ] + + +@app.get("/reports", include_in_schema=False) +@app.get("/api/reports") +def reports( + status: str | None = Query(None, pattern="^(Open|Resolved)$"), + account=Depends(workspace), + db: Session = Depends(get_db), +): + query = db.query(dbm.Report).filter_by(workspace_id=account.id) + if status: + query = query.filter_by(status=status) + return [report_data(db, r) for r in query.order_by(dbm.Report.id.desc())] + + +async def verified_image(file): + if not file or not file.filename: + return None + try: + if file.content_type not in {"image/jpeg", "image/png", "image/webp"}: + raise HTTPException(415, "Attach a JPEG, PNG, or WebP image.") + content = await file.read(MAX_UPLOAD + 1) + if not content: + raise HTTPException(422, "The image is empty.") + if len(content) > MAX_UPLOAD: + raise HTTPException(413, "Image exceeds the 3 MB limit.") + with warnings.catch_warnings(): + warnings.simplefilter("error", Image.DecompressionBombWarning) + with Image.open(io.BytesIO(content)) as source: + if source.format not in {"JPEG", "PNG", "WEBP"}: + raise HTTPException(415, "The file is not a supported image.") + source.verify() + with Image.open(io.BytesIO(content)) as source: + image = ImageOps.exif_transpose(source).convert("RGB") + image.thumbnail((1400, 1400)) + output = io.BytesIO() + image.save(output, "JPEG", quality=75, optimize=True) + result = output.getvalue() + if len(result) > 600_000: + raise HTTPException(413, "Please choose a smaller image.") + return result + except ( + UnidentifiedImageError, + OSError, + ValueError, + Image.DecompressionBombError, + Image.DecompressionBombWarning, + ): + raise HTTPException(422, "The attachment could not be verified as a valid image.") from None + finally: + await file.close() + + +async def save_report(account, db, request, asset_id, description, severity, file): + limited(request, db, "report", 20) + asset = owned_asset(db, account, asset_id) + description = description.strip() + if not description: + raise HTTPException(422, "Describe the issue before submitting.") + cap = 50 if account.is_demo else 200 + if db.query(dbm.Report).filter_by(workspace_id=account.id).count() >= cap: + raise HTTPException(409, f"This workspace has reached its {cap}-report limit.") + image = await verified_image(file) + report = dbm.Report( + workspace_id=account.id, + asset_id=asset.id, + description=description, + severity=int(severity), + tracking_code=secrets.token_urlsafe(16), + image=image, + ) + db.add(report) + activity( + db, + account, + "report", + f"New { {5: 'low', 10: 'medium', 15: 'high'}[int(severity)] }-priority report for {asset.name}.", + asset, + ) + db.commit() + return report_data(db, report) + + +@app.post("/api/reports", status_code=201) +async def submit_report( + request: Request, + asset_id: int = Form(...), + description: str = Form(..., min_length=1, max_length=2000), + severity: Literal["5", "10", "15"] = Form(...), + file: UploadFile | None = File(None), + account=Depends(workspace), + db: Session = Depends(get_db), +): + return await save_report(account, db, request, asset_id, description, severity, file) + + +@app.post("/reports/{report_id}/resolve", include_in_schema=False) +@app.post("/api/reports/{report_id}/resolve") +def resolve_report( + report_id: int, payload: Note, account=Depends(workspace), db: Session = Depends(get_db) +): + report = ( + db.query(dbm.Report) + .filter_by(id=report_id, workspace_id=account.id) + .with_for_update() + .first() + ) + if not report: + raise HTTPException(404, "Report not found.") + if report.status == "Resolved": + return {**report_data(db, report), "already_resolved": True} + report.status, report.resolution_note, report.resolved_at = ( + "Resolved", + payload.note, + dbm.utcnow(), + ) + asset = owned_asset(db, account, report.asset_id, include_archived=True) + activity( + db, + account, + "resolution", + f"Resolved report #{report.id} for {asset.name}: {payload.note}", + asset, + ) + db.commit() + return {**report_data(db, report), "already_resolved": False} + + +@app.get("/api/reports/{report_id}/image") +def report_image(report_id: int, account=Depends(workspace), db: Session = Depends(get_db)): + report = db.query(dbm.Report).filter_by(id=report_id, workspace_id=account.id).first() + if not report or not report.image: + raise HTTPException(404, "Attachment not found.") + return Response( + report.image, + media_type="image/jpeg", + headers={"Content-Disposition": "inline; filename=evidence.jpg"}, + ) + + +@app.post("/api/scenarios/flood") +def scenario(payload: Scenario, account=Depends(workspace), db: Session = Depends(get_db)): + account = db.query(dbm.Workspace).filter_by(id=account.id).with_for_update().one() + changed = account.flood_active != payload.active + account.flood_active = payload.active + if changed: + activity( + db, + account, + "scenario", + "Flood scenario enabled: road scores carry a 15-point demonstration penalty." + if payload.active + else "Flood scenario cleared: road penalties removed.", + ) + db.commit() + return {"active": account.flood_active, "changed": changed} + + +@app.post("/setup-demo", include_in_schema=False) +@app.post("/api/setup-demo") +def seed(account=Depends(workspace), db: Session = Depends(get_db)): + # Serialize repeated seed requests within this workspace. + account = db.query(dbm.Workspace).filter_by(id=account.id).with_for_update().one() + count = seed_workspace(db, account) + if count: + activity(db, account, "workspace", f"Added {count} missing example assets.") + db.commit() + return {"added": count} + + +@app.get("/api/public/{share_token}/assets") +def public_assets(share_token: str, db: Session = Depends(get_db)): + account = public_workspace(share_token, db) + return { + "workspace": account.name, + "assets": [ + {"id": a.id, "name": a.name, "asset_type": a.asset_type} + for a in db.query(dbm.Asset).filter_by(workspace_id=account.id, archived=False) + ], + } + + +@app.post("/api/public/{share_token}/reports", status_code=201) +async def public_report( + share_token: str, + request: Request, + asset_id: int = Form(...), + description: str = Form(..., min_length=1, max_length=2000), + severity: Literal["5", "10", "15"] = Form(...), + file: UploadFile | None = File(None), + db: Session = Depends(get_db), +): + account = public_workspace(share_token, db) + report = await save_report(account, db, request, asset_id, description, severity, file) + return {key: report[key] for key in ["id", "tracking_code", "status", "created_at"]} + + +@app.get("/api/track/{tracking_code}") +def track(tracking_code: str, request: Request, db: Session = Depends(get_db)): + limited(request, db, "tracking", 60, 60) + report = db.query(dbm.Report).filter_by(tracking_code=tracking_code).first() + if not report: + raise HTTPException(404, "Report not found. Check the tracking link.") + owner = db.get(dbm.Workspace, report.workspace_id) + if not owner or (owner.is_demo and owner.created_at < dbm.utcnow() - timedelta(days=7)): + raise HTTPException(404, "This report has expired.") + asset = db.get(dbm.Asset, report.asset_id) + return { + "id": report.id, + "asset_name": asset.name, + "status": report.status, + "created_at": iso(report.created_at), + "resolved_at": iso(report.resolved_at), + } + + +@app.get("/api/export") +def export(account=Depends(workspace), db: Session = Depends(get_db)): + stream = io.StringIO() + writer = csv.writer(stream) + writer.writerow( + [ + "ID", + "Asset", + "Type", + "Construction year", + "Latitude", + "Longitude", + "Demo health score", + "Priority", + "Last maintenance", + ] + ) + for asset in db.query(dbm.Asset).filter_by(workspace_id=account.id, archived=False): + data = asset_data(db, account, asset) + name = asset.name + if name.lstrip().startswith(("=", "+", "-", "@")) or name.startswith(("\t", "\r", "\n")): + name = "'" + name + writer.writerow( + [ + asset.id, + name, + asset.asset_type, + asset.construction_year, + asset.latitude, + asset.longitude, + data["health_score"], + data["maintenance_priority"], + data["last_service_at"] or "", + ] + ) + return Response( + stream.getvalue(), + media_type="text/csv", + headers={"Content-Disposition": 'attachment; filename="structiq-assets.csv"'}, + ) + + +@app.get("/", include_in_schema=False) +@app.get("/app", include_in_schema=False) +@app.get("/report/{share_token}", include_in_schema=False) +@app.get("/track/{tracking_code}", include_in_schema=False) +def page(): + return FileResponse(ROOT / "index.html", headers={"Cache-Control": "no-cache"}) + + +@app.get("/auth.html", include_in_schema=False) +def old_dashboard(): + return RedirectResponse("/app") + + +@app.get("/home.html", include_in_schema=False) +@app.get("/index.html", include_in_schema=False) +def old_home(): + return RedirectResponse("/") + + +# Vercel serves public files directly; this mount provides the same paths locally. +app.mount( + "/static", StaticFiles(directory=ROOT / "public" / "static", check_dir=False), name="static" +) diff --git a/public/static/app.js b/public/static/app.js new file mode 100644 index 0000000..6ea3576 --- /dev/null +++ b/public/static/app.js @@ -0,0 +1,305 @@ +import { priority, color, summary, filterAssets, date, initials, severityName } from './utils.js'; + +const $ = (selector, parent = document) => parent.querySelector(selector); +const root = $('#root'), modal = $('#modal'), modalBody = $('#modal-body'); +const state = { account: null, assets: [], reports: [], activity: [], view: 'overview', archived: false, reportFilter: 'Open' }; +let map, toastTimer, refreshing = false; +const paths = { + grid: '', + bridge: '', + road: '', + reports: '', + clock: '', + plus: '', + arrow: '', + search: '', + download: '', + check: '', + heart: '', + warning: '', + logout: '', + share: '', + tool: '', +}; +function icon(name) { return ``; } +function el(tag, attrs = {}, children = []) { + const element = document.createElement(tag); + for (const [key, value] of Object.entries(attrs)) { + if (value == null) continue; + if (key === 'class') element.className = value; + else if (key.startsWith('on')) element.addEventListener(key.slice(2), value); + else element.setAttribute(key, value); + } + for (const child of [children].flat(Infinity)) if (child != null) element.append(child instanceof Node ? child : document.createTextNode(String(child))); + return element; +} +function symbol(name) { const span = el('span', { class: 'icon' }); span.innerHTML = icon(name); return span; } +function button(label, action, cls = 'button', name) { return el('button', { class: cls, type: 'button', onclick: action }, [name ? symbol(name) : null, label]); } +function badge(label, tone = label.toLowerCase()) { return el('span', { class: `badge ${tone}` }, [el('span', { class: 'dot' }), label]); } +function brand() { const a = el('a', { class: 'brand', href: '/', 'aria-label': 'StructIQ home' }); a.innerHTML = 'StructIQ'; return a; } +function toast(message) { const t = $('#toast'); t.textContent = message; t.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { t.hidden = true; }, 5500); } +async function api(path, options = {}) { + const headers = { 'X-Requested-With': 'StructIQ', ...options.headers }; + if (options.body && !(options.body instanceof FormData)) { headers['Content-Type'] = 'application/json'; options.body = JSON.stringify(options.body); } + let response; + try { response = await fetch(`/api${path}`, { credentials: 'same-origin', ...options, headers, signal: AbortSignal.timeout(25000) }); } + catch { throw new Error('Connection interrupted. Check your connection and try again.'); } + const body = await response.json().catch(() => ({})); + if (!response.ok) { + const message = Array.isArray(body.detail) ? body.detail.map(e => `${e.loc.at(-1)}: ${e.msg}`).join(' · ') : body.detail; + const error = new Error(message || 'Something went wrong. Please try again.'); error.status = response.status; throw error; + } + return body; +} +function openDialog(title, content) { $('#modal-title').textContent = title; modalBody.replaceChildren(content); if (!modal.open) modal.showModal(); } +$('#close-modal').onclick = () => modal.close(); +modal.addEventListener('click', event => { if (event.target === modal) { const r = modal.getBoundingClientRect(); if (event.clientX < r.left || event.clientX > r.right || event.clientY < r.top || event.clientY > r.bottom) modal.close(); } }); +function formError(form, message) { let error = $('.error-message', form); if (!error) { error = el('p', { class: 'error-message', role: 'alert' }); form.prepend(error); } error.textContent = message; error.focus?.(); } +async function submit(form, task) { + const controls = [...form.querySelectorAll('button[type="submit"]')]; controls.forEach(b => b.disabled = true); + $('.error-message', form)?.remove(); + try { await task(); } catch (error) { formError(form, error.message); } + finally { controls.forEach(b => b.disabled = false); } +} +async function startDemo(event) { + const target = event?.currentTarget; if (target) target.disabled = true; + try { state.account = await api('/auth/demo', { method: 'POST' }); history.pushState({}, '', '/app'); await loadWorkspace(); } + catch (error) { toast(error.message); } finally { if (target) target.disabled = false; } +} +function authDialog(mode = 'login') { + const register = mode === 'register'; + const form = el('form', { class: 'form' }); + form.innerHTML = `${register ? '' : ''} + + +

${register ? 'Use letters, numbers, hyphens, or underscores for your username. Your workspace starts with nine illustrative assets. Keep your password safe; email recovery is not available.' : 'Sign in to your private workspace. Demo workspaces are accessible from the browser where you created them.'}

+ `; + form.onsubmit = event => { event.preventDefault(); submit(form, async () => { + const data = Object.fromEntries(new FormData(form)); + state.account = await api(`/auth/${register ? 'register' : 'login'}`, { method: 'POST', body: data }); + modal.close(); history.pushState({}, '', '/app'); await loadWorkspace(); + }); }; + const content = el('div', {}, [el('p', { class: 'auth-intro' }, register ? 'A dedicated place to organize assets and follow every report through to resolution.' : 'Welcome back. Your assets and reports are right where you left them.'), form, + el('div', { class: 'auth-foot' }, [register ? 'Already have a workspace? ' : 'New to StructIQ? ', button(register ? 'Sign in' : 'Create an account', () => authDialog(register ? 'login' : 'register'), 'text-button')])]); + openDialog(register ? 'Create your workspace' : 'Sign in to StructIQ', content); +} +function landing() { + destroyMap(); + root.innerHTML = `
+
Infrastructure, understood.

Every asset.
Every report.
A clearer picture.

Bring asset health, community reports, and maintenance work into one organized workspace. See what needs attention. Keep track of what happens next.

Explore the workflow

No account required for a demo · Your own data · Kept for 7 days

+
CHENNAI / EXAMPLE NETWORKDemonstration
+ Anna FlyoverNapier BridgeAdyar Bridge +
09Example assets
03Asset categories
01Connected workflow
From first report to verified closureFull history
+
01 / UNDERSTAND

Your network, in context.

Explore bridges, roads, and flyovers on a map. Search the asset register and compare clearly explained demonstration scores.

02 / RESPOND

Give every report a path.

Capture an issue, attach a photo, and follow its status. Share a reporting link with your community without sharing account access.

03 / REMEMBER

Keep the whole story.

Record maintenance, resolve incidents with notes, and export your register. A persistent timeline keeps every action in context.

+

A working portfolio demonstration. Example assets, health scores, and flood effects are illustrative. StructIQ is not a validated engineering or emergency system and must not be used for real-world safety decisions.

`; + $('#landing-brand').append(brand()); $('#sign-in').onclick = () => authDialog(); $('#create-account').onclick = () => authDialog('register'); $('#launch-demo').onclick = startDemo; + if (state.account) { $('#sign-in').textContent = 'Open workspace'; $('#sign-in').onclick = () => { history.pushState({}, '', '/app'); loadWorkspace(); }; } +} +function destroyMap() { if (map) { map.remove(); map = null; } } +async function loadWorkspace() { + if (refreshing) return; + refreshing = true; + try { + const [account, assets, reports, activity] = await Promise.all([api('/auth/me'), api('/assets'), api('/reports'), api('/activity')]); + Object.assign(state, { account, assets, reports, activity, archived: false }); renderShell(); + } catch (error) { + if (error.status === 401) { state.account = null; landing(); authDialog(); } + else { toast(error.message); if (!$('#content')) root.replaceChildren(el('main', { id: 'main', class: 'public-page' }, [brand(), el('h1', {}, 'Connection unavailable'), el('p', {}, error.message), button('Try again', loadWorkspace)])); } + } finally { refreshing = false; } +} +function renderShell() { + destroyMap(); const open = state.reports.filter(r => r.status === 'Open').length; + root.innerHTML = `
+
Connected
Portfolio demo. Scores and flood effects are illustrative, not structural assessments.API reference ↗
`; + $('#app-brand').append(brand()); $('.avatar').textContent = initials(state.account.name); $('.account-name').textContent = state.account.name; + $('.side-account .small').textContent = state.account.is_demo ? 'Private demo · 7-day retention' : `@${state.account.username}`; + $('.top-date').textContent = date(new Date().toISOString()); $('#refresh-data').onclick = loadWorkspace; + for (const [key, label, name] of [['overview', 'Overview', 'grid'], ['assets', 'Asset register', 'bridge'], ['reports', 'Incident reports', 'reports'], ['activity', 'Activity log', 'clock']]) { + const b = button(label, () => { state.view = key; state.archived = false; renderShell(); }, `nav-item ${state.view === key ? 'active' : ''}`, name); + if (state.view === key) b.setAttribute('aria-current', 'page'); if (key === 'reports' && open) b.append(el('span', { class: 'count' }, open)); $('.nav-items').append(b); + } + $('#logout').onclick = async () => { try { await api('/auth/logout', { method: 'POST' }); state.account = null; history.pushState({}, '', '/'); landing(); } catch (e) { toast(e.message); } }; + const names = { overview: 'Overview', assets: 'Asset register', reports: 'Incident reports', activity: 'Activity log' }; $('#breadcrumb-view').textContent = names[state.view]; + ({ overview, assetsView, reportsView, activityView })[{ overview: 'overview', assets: 'assetsView', reports: 'reportsView', activity: 'activityView' }[state.view]](); +} +function heading(title, description, actions = []) { return el('div', { class: 'page-head' }, [el('div', {}, [el('h1', {}, title), el('p', {}, description)]), el('div', { class: 'row wrap' }, actions)]); } +function exportButton() { return el('a', { class: 'button secondary', href: '/api/export', download: 'structiq-assets.csv' }, [symbol('download'), 'Export register']); } +function panel(title, subtitle, action) { const section = el('section', { class: 'panel' }); section.append(el('div', { class: 'panel-head' }, [el('div', {}, [el('h2', {}, title), subtitle ? el('p', {}, subtitle) : null]), action])); return section; } +function stats() { + const s = summary(state.assets, state.reports), box = el('div', { class: 'stats' }); + for (const [label, value, foot, name] of [['Monitored assets', String(s.total).padStart(2, '0'), 'Bridges, roads & flyovers', 'bridge'], ['Average demo health', `${s.average.toFixed(1)}%`, 'Illustrative condition estimate', 'heart'], ['Assets needing attention', String(s.attention).padStart(2, '0'), 'Below the 70-point threshold', 'warning'], ['Open reports', String(s.open).padStart(2, '0'), `${state.reports.length - s.open} resolved and retained`, 'reports']]) { + box.append(el('article', { class: 'stat' }, [el('div', { class: 'stat-label' }, [label, symbol(name)]), el('div', { class: 'stat-value' }, value), el('div', { class: 'stat-foot' }, foot)])); + } return box; +} +function overview() { + const content = $('#content'); content.append(heading('Network overview', 'A clear view of your assets, reports, and maintenance priorities.', [exportButton(), button('Add asset', () => assetForm(), 'button', 'plus')]), stats()); + const grid = el('div', { class: 'dashboard-grid' }), mapPanel = panel('Asset geography', 'Chennai example network', badge(`${state.assets.length} assets`, 'neutral')); + const wrap = el('div', { class: 'map-wrap' }, [el('div', { class: 'map-fallback' }, 'Map unavailable. All assets remain accessible in the register below.'), el('div', { class: 'map', id: 'asset-map', 'aria-label': 'Map of registered infrastructure assets' })]); + mapPanel.append(wrap); const legend = el('div', { class: 'map-legend' }); + for (const [label, tone] of [['Low priority · 70–100', ''], ['High · 40–69', 'amber'], ['Emergency · under 40', 'red']]) legend.append(el('span', {}, [el('i', { class: `legend-dot ${tone}` }), label])); mapPanel.append(legend); + const queue = panel('Incident queue', 'Latest open reports', badge(String(state.reports.filter(r => r.status === 'Open').length), 'neutral')); + const reports = state.reports.filter(r => r.status === 'Open').slice(0, 3), list = el('div', { class: 'queue-list' }); + for (const report of reports) { + const item = el('div', { class: 'queue-item' }, [el('div', { class: 'queue-title' }, [button(report.asset_name, () => reportDetail(report), 'text-button'), badge(severityName(report.severity), report.severity === 15 ? 'emergency' : report.severity === 10 ? 'high' : '')]), el('p', {}, report.description), el('div', { class: 'queue-meta' }, [`REPORT #${report.id}`, date(report.created_at, true)])]); list.append(item); + } + if (!reports.length) list.append(el('div', { class: 'empty' }, [symbol('check'), el('strong', {}, 'All caught up'), 'New reports will appear here. Create one to try the full workflow.', el('div', { style: 'margin-top:15px' }, button('Submit a report', () => reportForm(), 'button secondary slim', 'plus'))])); + queue.append(list, el('div', { class: 'queue-footer' }, button('View all reports', () => { state.view = 'reports'; renderShell(); }, 'text-button', 'arrow'))); grid.append(mapPanel, queue); content.append(grid); + const tablePanel = panel('Asset register', 'Prioritized by demonstration health score', button('View register', () => { state.view = 'assets'; renderShell(); }, 'text-button', 'arrow')); + tablePanel.append(assetTable([...state.assets].sort((a, b) => a.health_score - b.health_score).slice(0, 5))); content.append(tablePanel, scenarioBar()); mountMap(); +} +function mountMap() { + if (!window.L || !$('#asset-map')) return; + try { + map = L.map('asset-map', { scrollWheelZoom: false, zoomControl: true }).setView([13.025, 80.245], 11); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© OpenStreetMap contributors' }).addTo(map); + const points = []; + for (const asset of state.assets) { + points.push([asset.latitude, asset.longitude]); + const popup = el('div', { class: 'map-popup' }, [el('strong', {}, asset.name), el('p', {}, `${asset.asset_type} · ${asset.health_score.toFixed(1)} demo health`), button('Asset details', () => assetDetail(asset), 'text-button')]); + L.circleMarker([asset.latitude, asset.longitude], { radius: 7, fillColor: color(asset.health_score), color: '#fff', weight: 2, fillOpacity: 1 }).addTo(map).bindPopup(popup); + } + if (points.length) map.fitBounds(points, { padding: [38, 38], maxZoom: 13 }); + $('.map-fallback').hidden = true; requestAnimationFrame(() => map?.invalidateSize()); + } catch { $('#asset-map').hidden = true; } +} +function scoreCell(asset) { + const fill = el('span'); fill.style.width = `${Math.max(0, Math.min(100, asset.health_score))}%`; fill.style.background = color(asset.health_score); + return el('div', { class: 'score' }, [el('strong', {}, asset.health_score.toFixed(1)), el('div', { class: 'score-bar', 'aria-hidden': 'true' }, fill)]); +} +function assetTable(assets) { + if (!assets.length) return el('div', { class: 'empty' }, [el('strong', {}, 'No matching assets'), 'Try a different filter or register a new asset.']); + const table = el('table'); table.innerHTML = 'Asset nameTypeAgeDemo healthPriorityDetails'; + const tbody = el('tbody'); + for (const a of assets) tbody.append(el('tr', {}, [el('td', {}, el('div', { class: 'asset-name' }, [el('span', { class: 'asset-symbol' }, symbol(a.asset_type === 'Road' ? 'road' : 'bridge')), el('div', {}, [button(a.name, () => assetDetail(a), 'text-button'), el('small', {}, `AST-${String(a.id).padStart(4, '0')}`)])])), el('td', {}, a.asset_type), el('td', { class: 'muted' }, `${a.age} years`), el('td', {}, scoreCell(a)), el('td', {}, badge(priority(a.health_score))), el('td', {}, button(a.archived ? 'Restore' : 'View →', () => a.archived ? toggleArchive(a, false) : assetDetail(a), 'text-button'))])); + table.append(tbody); return el('div', { class: 'table-wrap' }, table); +} +function scenarioBar() { + return el('div', { class: `scenario-bar ${state.account.flood_active ? 'active' : ''}` }, [el('div', {}, [el('strong', {}, state.account.flood_active ? 'Flood scenario is active' : 'Explore a flood scenario'), el('p', {}, 'Applies a reversible 15-point penalty to road scores. This is a demonstration, not live weather.')]), button(state.account.flood_active ? 'Clear scenario' : 'Simulate flood', async event => { + event.currentTarget.disabled = true; + try { await api('/scenarios/flood', { method: 'POST', body: { active: !state.account.flood_active } }); await loadWorkspace(); toast('Scenario updated.'); } catch (e) { toast(e.message); event.currentTarget.disabled = false; } + }, 'button secondary slim')]); +} +function assetsView() { + const content = $('#content'); content.append(heading('Asset register', 'Search, register, and maintain your infrastructure inventory.', [exportButton(), button('Add asset', () => assetForm(), 'button', 'plus')])); + const toolbar = el('div', { class: 'toolbar' }); toolbar.innerHTML = `
`; + const area = el('div', { class: 'panel' }), input = $('input', toolbar), [type, risk] = toolbar.querySelectorAll('select'); + let currentAssets = state.assets; + const update = () => area.replaceChildren(assetTable(filterAssets(currentAssets, input.value, type.value, risk.value))); + [input, type, risk].forEach(control => control.addEventListener(control === input ? 'input' : 'change', update)); + $('#archive-filter', toolbar).onclick = async () => { + try { state.archived = !state.archived; currentAssets = state.archived ? await api('/assets?archived=true') : state.assets; $('#archive-filter').textContent = state.archived ? 'Active assets' : 'View archived'; update(); } + catch (e) { toast(e.message); } + }; + content.append(toolbar, area, scenarioBar()); update(); +} +function assetForm(asset) { + const form = el('form', { class: 'form' }); + form.innerHTML = `

Coordinates determine the map position. Changing construction year or asset type recalculates the base demonstration score.

`; + if (asset) for (const key of ['name', 'asset_type', 'construction_year', 'latitude', 'longitude']) form.elements[key].value = asset[key]; + form.onsubmit = event => { event.preventDefault(); submit(form, async () => { + const data = Object.fromEntries(new FormData(form)); for (const key of ['construction_year', 'latitude', 'longitude']) data[key] = Number(data[key]); + await api(asset ? `/assets/${asset.id}` : '/assets', { method: asset ? 'PUT' : 'POST', body: data }); modal.close(); await loadWorkspace(); toast(asset ? 'Asset updated.' : 'Asset registered.'); + }); }; openDialog(asset ? 'Edit asset' : 'Register an asset', form); +} +function assetDetail(asset) { + const incidents = state.reports.filter(r => r.asset_id === asset.id && r.status === 'Open'); + const body = el('div', {}, [el('div', { class: 'row between' }, [badge(asset.asset_type, 'neutral'), badge(priority(asset.health_score))]), + el('div', { class: 'details-grid' }, [el('div', { class: 'detail-stat' }, ['Demo health', el('strong', {}, `${asset.health_score.toFixed(1)} / 100`)]), el('div', { class: 'detail-stat' }, ['Open reports', el('strong', {}, incidents.length)])]), + el('div', { class: 'detail-list' }, [el('div', {}, [el('span', {}, 'Construction year'), `${asset.construction_year} (${asset.age} years)`]), el('div', {}, [el('span', {}, 'Coordinates'), `${asset.latitude.toFixed(4)}, ${asset.longitude.toFixed(4)}`]), el('div', {}, [el('span', {}, 'Last maintenance'), date(asset.last_service_at)]), el('div', {}, [el('span', {}, 'Base condition score'), asset.condition_score.toFixed(1)])]), + el('p', { class: 'notice' }, 'Score = base condition − open report penalties − an active flood penalty. Reports use the submitter’s priority; photos are evidence for human review, not automated diagnoses.')]); + const scores = state.activity.filter(a => a.asset_id === asset.id && a.health_score != null).reverse(); + if (scores.length > 1) { + body.append(el('p', { class: 'progress-heading' }, 'Recorded score history')); + const chart = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); chart.setAttribute('viewBox', '0 0 420 105'); chart.setAttribute('class', 'health-chart'); chart.setAttribute('role', 'img'); chart.setAttribute('aria-label', `Recorded demo scores: ${scores.map(s => s.health_score).join(', ')}`); + const line = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); line.setAttribute('points', scores.map((s, i) => `${10 + i * 400 / (scores.length - 1)},${95 - s.health_score * .85}`).join(' ')); line.setAttribute('fill', 'none'); line.setAttribute('stroke', 'currentColor'); line.setAttribute('stroke-width', '2'); chart.append(line); body.append(chart); + } + body.append(el('div', { class: 'row wrap' }, [button('Log maintenance', () => noteDialog('Log maintenance', `Describe the completed work on ${asset.name}. This raises its base demonstration score by up to 20 points; open report penalties remain until resolved.`, `/assets/${asset.id}/maintenance`), 'button', 'tool'), button('Edit details', () => assetForm(asset), 'button secondary'), button('Report an issue', () => reportForm(asset.id), 'text-button')])); + body.append(el('div', { style: 'margin-top:20px' }, button('Archive asset', () => { + openDialog('Archive this asset?', el('div', { class: 'stack' }, [el('p', { class: 'auth-intro' }, `${asset.name} will be hidden from the active register. Its history stays available, and you can restore it from the archived view. Open reports must be resolved first.`), button('Archive asset', () => toggleArchive(asset, true), 'button secondary')])); + }, 'text-button'))); openDialog(asset.name, body); +} +async function toggleArchive(asset, archived) { try { await api(`/assets/${asset.id}/archive`, { method: 'PATCH', body: { archived } }); modal.close(); state.archived = false; await loadWorkspace(); toast(archived ? 'Asset archived. You can restore it from the register.' : 'Asset restored.'); } catch (e) { toast(e.message); } } +function noteDialog(title, description, endpoint) { + const form = el('form', { class: 'form' }); form.append(el('p', { class: 'hint' }, description)); + const label = el('label', {}, ['Work completed', el('textarea', { name: 'note', required: '', minlength: '5', maxlength: '1000', placeholder: 'Record what was inspected, repaired, or verified.' })]); form.append(label, el('button', { type: 'submit', class: 'button' }, title)); + form.onsubmit = e => { e.preventDefault(); submit(form, async () => { await api(endpoint, { method: 'POST', body: { note: form.elements.note.value } }); modal.close(); await loadWorkspace(); toast('Work recorded in the activity history.'); }); }; openDialog(title, form); +} +function reportForm(assetId, publicContext) { + const available = publicContext?.assets || state.assets; + const form = el('form', { class: 'form' }); + form.innerHTML = `

Photo metadata is removed before storage. Do not include personal details. This demo does not notify emergency or municipal services.

`; + for (const asset of available) form.elements.asset_id.append(el('option', { value: asset.id }, asset.name)); + if (assetId) form.elements.asset_id.value = assetId; + form.onsubmit = event => { event.preventDefault(); submit(form, async () => { + const data = new FormData(form), file = form.elements.file.files[0]; + if (!file) data.delete('file'); else if (file.size > 3 * 1024 * 1024) throw new Error('Choose an image smaller than 3 MB.'); + const report = await api(publicContext ? `/public/${publicContext.token}/reports` : '/reports', { method: 'POST', body: data }); + if (!publicContext) { modal.close(); await loadWorkspace(); } + const result = el('div', { class: 'stack' }, [badge('Report received'), el('p', { class: 'auth-intro' }, 'Your report is saved and awaiting review. Keep this private tracking link to check its status.'), linkBox(`${location.origin}/track/${report.tracking_code}`), el('a', { href: `/track/${report.tracking_code}`, class: 'button secondary', target: '_blank', rel: 'noopener' }, 'View report status')]); + if (publicContext) { $('#public-form').replaceChildren(result); } else openDialog('Report submitted', result); + }); }; + if (publicContext) return form; + if (!available.length) { toast('Register an asset before submitting a report.'); return; } + openDialog('Report an issue', form); +} +function reportsView() { + const content = $('#content'); content.append(heading('Incident reports', 'Track observations from submission through to documented resolution.', [button('Share reporting link', shareReporting, 'button secondary', 'share'), button('Submit report', () => reportForm(), 'button', 'plus')])); + const tabs = el('div', { class: 'tabs', 'aria-label': 'Report status filters' }); + for (const [value, label] of [['Open', 'Open reports'], ['Resolved', 'Resolved'], ['', 'All history']]) tabs.append(button(label, () => { state.reportFilter = value; renderShell(); }, state.reportFilter === value ? 'active' : '')); + content.append(el('div', { class: 'toolbar' }, tabs)); + const items = state.reports.filter(r => !state.reportFilter || r.status === state.reportFilter), grid = el('div', { class: 'report-grid' }); + for (const r of items) { + const card = el('article', { class: 'report-card' }, [el('div', { class: 'row between' }, [el('span', { class: 'small muted' }, `REPORT #${r.id} · ${date(r.created_at)}`), badge(r.status === 'Resolved' ? 'Resolved' : severityName(r.severity), r.status === 'Resolved' ? '' : r.severity === 15 ? 'emergency' : r.severity === 10 ? 'high' : '')]), el('h3', {}, r.asset_name), el('p', {}, r.description), r.resolution_note ? el('div', { class: 'resolution' }, [el('strong', {}, 'Resolution: '), r.resolution_note]) : null, + el('div', { class: 'report-bottom' }, [button(r.has_image ? 'View details & photo' : 'View details', () => reportDetail(r), 'text-button'), r.status === 'Open' ? button('Resolve report', () => resolveDialog(r), 'button secondary slim', 'check') : el('span', { class: 'small muted' }, date(r.resolved_at))])]); grid.append(card); + } + content.append(items.length ? grid : el('div', { class: 'panel empty' }, [el('strong', {}, 'No reports in this view'), 'Submit an observation to begin the workflow, or choose a different status filter.'])); +} +function resolveDialog(report) { noteDialog('Resolve report', `Record the resolution of report #${report.id} for ${report.asset_name}. The report and its attachment stay in your history.`, `/reports/${report.id}/resolve`); } +function reportDetail(report) { + const content = el('div', { class: 'stack' }, [el('div', { class: 'row between' }, [badge(report.status), el('span', { class: 'small muted' }, date(report.created_at, true))]), el('strong', {}, report.asset_name), el('p', { class: 'auth-intro', style: 'white-space:pre-wrap;overflow-wrap:anywhere' }, report.description)]); + if (report.has_image) content.append(el('img', { class: 'evidence', src: `/api/reports/${report.id}/image`, alt: `Submitted evidence for report ${report.id}` })); + if (report.resolution_note) content.append(el('div', { class: 'resolution' }, [el('strong', {}, 'Resolution: '), report.resolution_note])); + content.append(el('p', { class: 'small muted' }, `Submitter priority: ${severityName(report.severity)}. Priority is manually selected, not an image diagnosis.`), linkBox(`${location.origin}/track/${report.tracking_code}`)); + if (report.status === 'Open') content.append(button('Resolve report', () => resolveDialog(report), 'button', 'check')); openDialog(`Report #${report.id}`, content); +} +function linkBox(url) { + const input = el('input', { value: url, readonly: '', 'aria-label': 'Shareable link' }); + return el('div', { class: 'link-field' }, [input, button('Copy link', async () => { + try { await navigator.clipboard.writeText(url); toast('Link copied.'); } catch { input.select(); toast('Select and copy the link from this field.'); } + }, 'button secondary slim')]); +} +function shareReporting() { + openDialog('Community reporting link', el('div', { class: 'stack' }, [el('p', { class: 'auth-intro' }, 'Anyone with this link can see your active asset names and submit a report. They cannot see your dashboard, report history, or account details.'), linkBox(`${location.origin}/report/${state.account.share_token}`), el('a', { class: 'button secondary', href: `/report/${state.account.share_token}`, target: '_blank', rel: 'noopener' }, 'Open reporting page ↗')])); +} +function activityView() { + const content = $('#content'); content.append(heading('Activity log', 'A persistent record of registrations, reports, maintenance, and scenarios.', [button('Refresh', loadWorkspace, 'button secondary', 'clock')])); + const section = panel('Workspace timeline', 'The 100 most recent events'), list = el('div', { class: 'activity-list' }); + for (const a of state.activity) list.append(el('article', { class: 'activity-item' }, [el('div', { class: 'activity-icon' }, symbol({ report: 'reports', resolution: 'check', maintenance: 'tool', scenario: 'warning', asset: 'bridge' }[a.kind] || 'grid')), el('div', {}, [el('p', {}, a.message), el('time', { datetime: a.created_at }, date(a.created_at, true))]), a.health_score != null ? badge(`${a.health_score.toFixed(1)} health`, 'neutral') : null])); + section.append(state.activity.length ? list : el('div', { class: 'empty' }, 'Workspace events will appear here.')); content.append(section); +} +async function publicReportPage(token) { + root.replaceChildren(el('main', { class: 'public-page', id: 'main' }, [brand(), el('div', { class: 'panel', id: 'public-form' }, el('p', {}, 'Loading reporting form…'))])); + try { + const data = await api(`/public/${encodeURIComponent(token)}/assets`), panel = $('#public-form'); + panel.replaceChildren(el('span', { class: 'eyebrow' }, data.workspace), el('h1', { style: 'margin-top:15px' }, 'Report an observation'), el('p', { class: 'auth-intro' }, 'Share an issue with this workspace. You’ll receive a private tracking link after submission.'), el('div', { class: 'notice' }, 'Portfolio demonstration only. This form does not contact authorities or emergency services.')); + panel.append(data.assets.length ? reportForm(null, { token, assets: data.assets }) : el('div', { class: 'empty' }, 'There are no active assets available for reporting.')); + } catch (e) { $('#public-form').replaceChildren(el('h1', {}, 'Reporting link unavailable'), el('p', { class: 'auth-intro' }, e.message), el('a', { href: '/', class: 'button secondary' }, 'Back to StructIQ')); } +} +async function trackingPage(code) { + root.replaceChildren(el('main', { class: 'public-page', id: 'main' }, [brand(), el('section', { class: 'panel', id: 'tracking' }, el('p', {}, 'Loading report status…'))])); + try { + const r = await api(`/track/${encodeURIComponent(code)}`); + $('#tracking').replaceChildren(el('span', { class: 'eyebrow' }, 'Report status'), el('h1', { style: 'margin-top:14px' }, `Report #${r.id}`), el('p', { class: 'auth-intro' }, r.asset_name), badge(r.status), + el('div', { class: 'tracking-result' }, [el('p', { class: 'small' }, `Submitted ${date(r.created_at, true)}`), el('p', { class: 'small' }, r.resolved_at ? `Resolved ${date(r.resolved_at, true)}` : 'Your report is saved and awaiting review by the workspace owner.')]), el('p', { class: 'notice' }, 'This is a portfolio demonstration. Report status does not confirm real-world inspection, repair, or safety.'), button('Refresh status', () => trackingPage(code), 'button secondary', 'clock')); + } catch (e) { $('#tracking').replaceChildren(el('h1', {}, 'Report unavailable'), el('p', { class: 'auth-intro' }, e.message), el('a', { href: '/', class: 'button secondary' }, 'Back to StructIQ')); } +} +async function route() { + const path = location.pathname; + if (path.startsWith('/report/')) return publicReportPage(path.split('/')[2]); + if (path.startsWith('/track/')) return trackingPage(path.split('/')[2]); + try { state.account = await api('/auth/me'); } catch { state.account = null; } + if (path === '/app' && state.account) return loadWorkspace(); + landing(); if (path === '/app') authDialog(); +} +window.addEventListener('popstate', route); +route(); diff --git a/public/static/favicon.svg b/public/static/favicon.svg new file mode 100644 index 0000000..dd3d2e3 --- /dev/null +++ b/public/static/favicon.svg @@ -0,0 +1 @@ + diff --git a/public/static/styles.css b/public/static/styles.css new file mode 100644 index 0000000..010f7a7 --- /dev/null +++ b/public/static/styles.css @@ -0,0 +1,11 @@ +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@400;500;600;700;800&display=swap'); +:root{--ink:#173136;--muted:#697e81;--paper:#f3f6f4;--card:#fff;--line:#dfe7e2;--green:#227e5b;--mint:#b6efce;--nav:#102c31;--orange:#e6a54c;--red:#b94040;--amber:#a56b16;--shadow:0 12px 40px #102c3108;font-family:'DM Sans',sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none} +*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline-offset:4px}button:focus-visible,a:focus-visible{outline:3px solid #3b9e79}button{cursor:pointer}button:disabled{cursor:wait;opacity:.6}a{color:inherit;text-decoration:none}a:hover{text-decoration:underline}h1,h2,h3,h4,p{margin-top:0}h1,h2,h3,.brand{font-family:Manrope,sans-serif}button svg, .icon svg{width:20px;height:20px;flex-shrink:0}svg{vertical-align:middle}button{border:0}input,select,textarea{max-width:100%}[hidden]{display:none!important}.skip-link{position:fixed;left:16px;top:-70px;padding:12px;background:white;z-index:2000}.skip-link:focus{top:10px}.brand{font-size:27px;font-weight:800;letter-spacing:-1.1px;display:inline-flex;align-items:center;gap:9px}.brand>span{color:#65c79b}.brand img{width:35px}.eyebrow{font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:var(--green)}.muted{color:var(--muted)}.small{font-size:12px}.initial{padding:12vh 10vw}.button{padding:12px 19px;display:inline-flex;gap:9px;align-items:center;justify-content:center;border-radius:9px;background:var(--green);color:white;font-size:13px;font-weight:700;min-height:44px;transition:background .15s,transform .15s}.button:hover{background:#176643;text-decoration:none}.button.secondary{background:white;color:var(--ink);border:1px solid var(--line)}.button.secondary:hover{background:#edf4ef}.button.dark{background:var(--nav)}.button.danger{background:var(--red)}.button.slim{padding:8px 13px;min-height:36px;font-size:12px}.button.ghost{background:transparent;color:var(--muted);border:1px solid var(--line)}.icon-button{width:35px;height:35px;display:inline-grid;place-items:center;background:transparent;color:var(--muted);border-radius:7px;font-size:25px}.icon-button:hover{background:#e9f1ed}.row{display:flex;gap:12px;align-items:center}.between{justify-content:space-between}.wrap{flex-wrap:wrap}.stack{display:grid;gap:16px}.notice{padding:12px 16px;border:1px solid #dedfc8;border-radius:9px;background:#f5f5e9;color:#6c7051;font-size:12px;line-height:1.6}.error{background:#fff0ee;color:#9b3831;border-color:#f1d0c9}.error-message{font-size:13px;color:var(--red);line-height:1.5}.empty{padding:38px 22px;text-align:center;color:var(--muted);font-size:13px;line-height:1.7}.empty strong{color:var(--ink);display:block;font-size:16px;margin-bottom:6px}.badge{display:inline-flex;align-items:center;gap:5px;border-radius:5px;padding:5px 8px;background:#edf5ef;color:var(--green);font-size:10px;font-weight:700;white-space:nowrap}.badge.high{background:#fbf1df;color:var(--amber)}.badge.emergency{background:#fbeae7;color:var(--red)}.badge.neutral{color:var(--muted);background:#eff2f0}.dot{width:6px;height:6px;background:currentColor;border-radius:50%;display:inline-block}.demo-label{font-size:10px;letter-spacing:1px;text-transform:uppercase;color:#b8c4b9;border:1px solid #45605c;padding:5px 8px;border-radius:4px}.land-nav{max-width:1240px;margin:auto;padding:25px 35px;display:flex;align-items:center;justify-content:space-between;gap:20px}.land-nav .brand{font-size:24px}.land-nav nav{display:flex;gap:24px;align-items:center;font-size:13px}.landing{background:#f7faf6;min-height:100vh}.hero{max-width:1240px;margin:auto;display:grid;grid-template-columns:1.03fr 1fr;align-items:center;gap:55px;padding:80px 35px 90px}.hero h1{font-size:clamp(43px,4.9vw,67px);letter-spacing:-3.2px;line-height:1.06;font-weight:800;margin:22px 0}.hero h1 em{font-style:normal;color:var(--green)}.hero>div>p{line-height:1.75;color:var(--muted);font-size:16px;max-width:460px}.hero .row{margin:28px 0 16px}.hero-fine{font-size:11px!important;color:#86988c!important}.hero-board{background:var(--nav);border-radius:18px;padding:25px;box-shadow:0 25px 80px #133b3424;color:#effaf3;transform:rotate(1deg);position:relative;overflow:hidden}.hero-board:before{content:'';position:absolute;inset:0;background-image:linear-gradient(#91bdbd0b 1px,transparent 1px),linear-gradient(90deg,#91bdbd0b 1px,transparent 1px);background-size:26px 26px;pointer-events:none}.hero-board>*{position:relative}.hero-board .eyebrow{color:var(--mint)}.hero-map{width:100%;height:235px;margin:15px 0}.preview-metrics{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid #46615d;padding-top:20px;gap:15px}.preview-metrics strong{font-size:25px;display:block;font-family:Manrope}.preview-metrics span{font-size:10px;letter-spacing:.7px;text-transform:uppercase;color:#a8c3b9}.mini-incident{margin-top:22px;padding:14px;background:#ffffff0c;border:1px solid #ffffff0d;border-radius:9px;font-size:12px}.mini-incident .badge{background:#fbd29a;color:#735523}.landing-features{max-width:1170px;margin:0 auto;padding:0 0 65px;display:grid;grid-template-columns:repeat(3,1fr);gap:38px}.feature{border-top:1px solid #d8e3d9;padding-top:22px}.feature h3{font-size:16px;margin:15px 0 8px}.feature p{font-size:13px;color:var(--muted);line-height:1.7}.feature-number{font-size:11px;color:var(--green);font-weight:700}.land-bottom{background:#eaf0e8;padding:25px 35px;font-size:12px;color:#5e7265}.land-bottom>div{max-width:1170px;margin:auto;display:flex;gap:25px;justify-content:space-between}.land-bottom p{max-width:670px;margin:0;line-height:1.7}.land-bottom nav{display:flex;gap:20px;align-items:center} +/* Application */ +.shell{display:grid;grid-template-columns:226px minmax(0,1fr);min-height:100vh}.sidebar{background:var(--nav);color:#bdd0cb;padding:28px 18px 22px;display:flex;flex-direction:column;position:fixed;inset:0 auto 0 0;width:226px;z-index:500}.sidebar .brand{color:#fff;font-size:26px;margin:0 13px 41px}.sidebar .brand img{width:31px}.nav-label{font-size:9px;color:#6f928a;letter-spacing:1.6px;text-transform:uppercase;margin:0 15px 14px}.nav-items{display:grid;gap:7px}.nav-item{padding:13px 14px;border-radius:8px;display:flex;align-items:center;gap:13px;background:transparent;color:#97b4ac;text-align:left;font-size:12px;font-weight:500}.nav-item:hover{background:#ffffff08;color:#fff}.nav-item.active{background:#284941;color:#d1f2dd}.nav-item .count{margin-left:auto;background:#ffffff12;padding:2px 6px;border-radius:4px;font-size:10px}.side-note{margin-top:auto;padding:20px 13px}.side-note .line-art{border-top:1px solid #36534c;padding-top:20px;color:#52766a}.side-note p{font-size:10px;line-height:1.7;color:#7f9d92;margin:12px 0}.side-account{display:flex;gap:10px;align-items:center;padding:16px 10px 0;border-top:1px solid #294d42}.avatar{width:34px;height:34px;border-radius:50%;display:grid;place-items:center;background:#426b56;color:#cef0d7;font-size:11px;font-weight:700;flex-shrink:0}.side-account .account-name{font-size:11px;color:#d8e9df;max-width:125px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.side-account .small{font-size:10px;color:#83a693;margin-top:4px}.side-account button{margin-left:auto;color:#90ac9f}.workspace-main{grid-column:2;min-width:0}.topbar{height:79px;border-bottom:1px solid var(--line);background:#ffffff90;display:flex;align-items:center;justify-content:space-between;padding:0 34px;gap:12px}.breadcrumb{font-size:11px;color:var(--muted);display:flex;gap:11px;align-items:center}.breadcrumb strong{color:var(--ink);font-weight:500}.live-pill{display:inline-flex;gap:6px;align-items:center;font-size:10px;color:var(--green);background:#e7f3eb;padding:7px 11px;border-radius:20px}.top-date{font-size:11px;color:var(--muted)}.content{padding:31px 34px;max-width:1640px;margin:auto}.page-head{display:flex;gap:20px;justify-content:space-between;align-items:center;margin-bottom:25px}.page-head h1{font-size:26px;letter-spacing:-1px;font-weight:800;margin:0 0 8px}.page-head p{font-size:12px;color:var(--muted);margin:0}.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:23px}.stat{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:20px 21px;box-shadow:var(--shadow)}.stat-label{display:flex;justify-content:space-between;gap:12px;font-size:11px;color:var(--muted)}.stat-label .icon{color:#6d9488}.stat-value{font-family:Manrope;font-size:33px;font-weight:800;letter-spacing:-1px;margin:10px 0 8px;line-height:1}.stat-foot{font-size:10px;color:var(--muted)}.stat-foot .green{color:var(--green)}.panel{background:white;border:1px solid var(--line);border-radius:11px;overflow:hidden;box-shadow:var(--shadow);min-width:0}.panel-head{padding:18px 20px;display:flex;align-items:center;justify-content:space-between;gap:15px;border-bottom:1px solid #edf0ed}.panel-head h2{font-size:13px;font-weight:800;margin:0}.panel-head p{font-size:10px;color:var(--muted);margin:5px 0 0}.dashboard-grid{display:grid;grid-template-columns:minmax(0,1fr) 303px;gap:20px;margin-bottom:22px}.map-wrap{height:356px;position:relative;background:#dfe8df}.map{height:100%;width:100%;z-index:1}.map-fallback{position:absolute;inset:0;display:grid;place-items:center;padding:25px;font-size:13px;color:var(--muted);text-align:center}.map-legend{padding:12px 20px;display:flex;gap:20px;align-items:center;font-size:10px;color:var(--muted);border-top:1px solid var(--line)}.map-legend span{display:flex;align-items:center;gap:6px}.legend-dot{height:7px;width:7px;border-radius:50%;background:var(--green)}.legend-dot.amber{background:#ce9848}.legend-dot.red{background:#bc5751}.map-label{background:white;border:1px solid var(--line);font-family:'DM Sans';font-size:11px;color:var(--ink);border-radius:5px;padding:5px 8px}.map-popup strong{display:block;margin-bottom:6px;font-size:13px}.map-popup p{margin:0 0 9px;color:var(--muted);font-size:11px}.leaflet-control-attribution{font-size:9px!important}.leaflet-control-zoom a{color:var(--ink)!important}.queue-list{padding:0 18px}.queue-item{padding:18px 0;border-bottom:1px solid #edf0ed;cursor:pointer}.queue-item:last-child{border:0}.queue-title{font-size:12px;font-weight:700;display:flex;gap:8px;justify-content:space-between;align-items:center;margin-bottom:8px}.queue-item p{font-size:11px;color:var(--muted);margin:0 0 9px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.queue-meta{font-size:9px;color:#8a9c94;display:flex;justify-content:space-between}.queue-footer{padding:14px 18px;border-top:1px solid var(--line)}.text-button{background:transparent;color:var(--green);font-size:11px;font-weight:700;padding:4px 0;display:inline-flex;gap:7px;align-items:center}.text-button:hover{text-decoration:underline}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;text-align:left;white-space:nowrap;font-size:11px}th{background:#f8faf7;color:#82938b;font-size:9px;letter-spacing:.8px;text-transform:uppercase;font-weight:500;padding:12px 20px}td{padding:15px 20px;border-bottom:1px solid #edf0ed}tbody tr:last-child td{border:0}tbody tr:hover{background:#fbfcfa}.asset-name{display:flex;align-items:center;gap:11px}.asset-symbol{display:grid;place-items:center;width:31px;height:31px;background:#f0f5ef;color:#689883;border-radius:7px}.asset-symbol svg{width:17px;height:17px}.asset-name strong{display:block;font-size:11px;font-weight:600}.asset-name small{display:block;font-size:9px;color:#90a195;margin-top:4px}.score{display:flex;gap:9px;align-items:center}.score strong{font-size:11px;min-width:28px}.score-bar{width:63px;height:4px;background:#eaf0e9;overflow:hidden;border-radius:2px}.score-bar span{display:block;height:100%;border-radius:2px}.toolbar{display:flex;justify-content:space-between;gap:14px;align-items:center;margin-bottom:19px}.search-wrap{display:flex;align-items:center;gap:9px;background:white;border:1px solid var(--line);padding:9px 12px;border-radius:8px;min-width:240px;color:#8c9f94}.search-wrap input{border:0;outline:none;width:100%;min-width:0;font-size:12px;color:var(--ink);background:transparent}.filter{background:white;color:var(--muted);border:1px solid var(--line);border-radius:8px;padding:9px 12px;min-height:39px;font-size:11px}.tabs{display:flex;gap:4px;padding:4px;border:1px solid var(--line);background:white;border-radius:9px;width:max-content}.tabs button{padding:8px 16px;border-radius:6px;background:transparent;color:var(--muted);font-size:12px}.tabs button.active{background:#e9f3eb;color:var(--green);font-weight:700}.scenario-bar{margin-top:22px;border:1px dashed #cddccf;border-radius:9px;display:flex;gap:16px;align-items:center;justify-content:space-between;padding:15px 18px;background:#eef3ec}.scenario-bar strong{font-size:11px;display:block;margin-bottom:5px}.scenario-bar p{font-size:10px;color:var(--muted);margin:0;line-height:1.6}.scenario-bar.active{background:#fbf3e5;border-color:#e3cfa9}.app-foot{margin-top:25px;font-size:10px;line-height:1.7;color:#91a196;display:flex;gap:20px;justify-content:space-between}.report-card{padding:22px;background:white;border:1px solid var(--line);border-radius:10px}.report-card h3{font-size:14px;margin:12px 0 8px}.report-card p{font-size:12px;line-height:1.7;color:var(--muted);white-space:pre-wrap;overflow-wrap:anywhere}.report-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.report-card .report-bottom{border-top:1px solid var(--line);padding-top:13px;margin-top:15px;display:flex;align-items:center;justify-content:space-between;gap:10px}.activity-list{padding:5px 22px}.activity-item{display:grid;grid-template-columns:34px 1fr auto;gap:16px;padding:20px 0;border-bottom:1px solid var(--line)}.activity-item:last-child{border:0}.activity-icon{height:31px;width:31px;border-radius:50%;background:#eaf4eb;color:var(--green);display:grid;place-items:center}.activity-icon svg{width:15px;height:15px}.activity-item p{font-size:12px;line-height:1.65;margin:0;white-space:pre-wrap;overflow-wrap:anywhere}.activity-item time{font-size:10px;color:var(--muted);display:block;margin-top:5px}.activity-item .badge{height:max-content}.details-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:12px;margin:16px 0}.detail-stat{background:#f4f7f2;border-radius:8px;padding:14px;font-size:11px;color:var(--muted)}.detail-stat strong{font-size:21px;color:var(--ink);display:block;margin-top:7px}.detail-list{font-size:12px;display:grid;gap:11px;line-height:1.5;margin:20px 0}.detail-list>div{display:flex;justify-content:space-between;gap:15px}.detail-list span{color:var(--muted)}.health-chart{width:100%;height:100px;color:var(--green);margin:10px 0}.progress-heading{font-size:11px;color:var(--muted);margin-top:20px}.resolution{background:#f0f7ef;border-radius:7px;padding:12px;font-size:12px;margin-top:14px}.public-page{max-width:670px;padding:50px 22px;margin:auto}.public-page>.brand{margin-bottom:32px}.public-page h1{font-size:31px;letter-spacing:-1px}.public-page>.panel{padding:30px}.public-page .notice{margin:20px 0}.public-page .tracking-code{font-family:monospace;font-size:14px;overflow-wrap:anywhere}.link-field{display:flex;gap:8px}.link-field input{flex:1;min-width:0}.tracking-result{padding:20px;background:#f0f6ef;border-radius:10px;margin-top:20px}dialog{border:1px solid var(--line);border-radius:14px;background:#fff;padding:0;width:min(530px,calc(100% - 30px));color:var(--ink);box-shadow:0 30px 120px #102c3155;max-height:90dvh}dialog::backdrop{background:#102c3185;backdrop-filter:blur(3px)}.dialog-head{padding:20px 24px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line);position:sticky;top:0;background:#fff;z-index:2}.dialog-head h2{font-size:18px;margin:0;letter-spacing:-.4px}#modal-body{padding:24px}.form{display:grid;gap:17px}.form label{font-size:12px;font-weight:600;display:grid;gap:8px}.form input,.form select,.form textarea,.link-field input{border:1px solid #cddacf;border-radius:7px;padding:11px 12px;width:100%;background:white;color:var(--ink);font-size:13px;min-height:43px}.form input:focus,.form select:focus,.form textarea:focus{outline:2px solid #7bb491;border-color:transparent}.form textarea{resize:vertical;min-height:100px}.form .hint{font-size:11px;color:var(--muted);line-height:1.6;margin:0}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.form hr{width:100%;border:0;border-top:1px solid var(--line);margin:3px 0}.form .button{width:100%}.form .form-check{display:flex;align-items:flex-start;gap:9px;font-size:11px;font-weight:400;line-height:1.6}.form-check input{width:16px;min-height:16px;margin-top:2px}.evidence{width:100%;max-height:330px;object-fit:contain;border-radius:8px;background:#f2f5ee}.auth-intro{font-size:13px;color:var(--muted);line-height:1.7}.auth-foot{text-align:center;font-size:12px;margin-top:20px;color:var(--muted)}#toast{position:fixed;bottom:24px;right:24px;max-width:min(460px,calc(100% - 32px));background:var(--nav);color:white;border-radius:10px;padding:16px 21px;box-shadow:0 10px 45px #0002;z-index:3000;font-size:13px;line-height:1.5}.loading-state{padding:60px;text-align:center;font-size:13px;color:var(--muted)} +@media(min-width:1450px){.dashboard-grid{grid-template-columns:minmax(0,1fr) 360px}.map-wrap{height:395px}.queue-item{padding:22px 0}.content{padding:37px 45px}.topbar{padding:0 45px}} +@media(max-width:1100px){.shell{grid-template-columns:190px minmax(0,1fr)}.sidebar{width:190px;padding:25px 12px}.sidebar .brand{font-size:24px;margin-left:9px}.content{padding:25px 22px}.topbar{padding:0 22px}.dashboard-grid{grid-template-columns:minmax(0,1fr) 270px;gap:15px}.stat{padding:17px 15px}.stats{gap:12px}.hero{gap:35px}.landing-features{padding-left:35px;padding-right:35px}.hero-board{padding:21px}.page-head{align-items:flex-start}.page-head .row{gap:8px}.page-head .button{font-size:11px;padding:10px 12px}td,th{padding-left:16px;padding-right:16px}} +@media(max-width:900px){.dashboard-grid{grid-template-columns:1fr}.queue-list{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}.queue-item{border:0}.queue-title{flex-wrap:wrap}.queue-footer{display:none}.hero{padding-top:40px;padding-bottom:55px;grid-template-columns:1fr;max-width:710px;gap:35px}.hero>div>p{max-width:560px}.hero h1{font-size:58px}.hero-board{transform:none}.hero-map{height:200px}.landing-features{gap:25px}.report-grid{grid-template-columns:1fr}.top-date{display:none}.stats{grid-template-columns:repeat(2,1fr)}} +@media(max-width:700px){.shell{display:block}.sidebar{position:relative;inset:auto;width:100%;padding:16px 16px 0;display:grid;grid-template-columns:1fr auto;gap:15px}.sidebar .brand{margin:0;font-size:23px}.nav-label,.side-note{display:none}.nav-items{grid-column:1/-1;grid-row:2;display:flex;gap:0;justify-content:space-between}.nav-item{font-size:10px;padding:12px 9px;border-radius:6px 6px 0 0;gap:7px}.nav-item svg{width:16px;height:16px}.nav-item .count{display:none}.side-account{padding:0;border:0;gap:7px;grid-column:2;grid-row:1}.side-account .avatar{display:none}.side-account .account-name{max-width:145px;font-size:10px}.side-account .small{font-size:9px}.side-account .icon-button{width:28px}.workspace-main{width:100%}.topbar{height:52px;padding:0 18px}.content{padding:24px 16px}.breadcrumb{font-size:10px}.live-pill{font-size:9px}.page-head{gap:15px;flex-direction:column;margin-bottom:21px}.page-head h1{font-size:25px}.page-head p{font-size:11px;line-height:1.6}.page-head>.row{width:100%}.page-head>.row .button{flex:1}.stats{gap:10px;margin-bottom:17px}.stat{padding:17px}.stat-label{font-size:10px}.stat-value{font-size:31px}.stat-foot{font-size:9px}.dashboard-grid{gap:17px;margin-bottom:17px}.panel-head{padding:16px}.map-wrap{height:310px}.map-legend{padding:12px 14px;gap:12px;font-size:9px}.queue-list{display:block;padding:0 16px}.queue-item{padding:15px 0;border-bottom:1px solid var(--line)}.queue-title{flex-wrap:nowrap}.queue-footer{display:block}.toolbar{align-items:stretch;flex-wrap:wrap;gap:9px}.search-wrap{width:100%;min-width:0}.toolbar>.row{width:100%;gap:8px}.filter{flex:1;min-width:0;padding:9px;font-size:10px}.scenario-bar{align-items:flex-start;gap:13px}.scenario-bar .button{padding:9px 12px;flex-shrink:0}.scenario-bar p{font-size:9px}.app-foot{display:block;font-size:9px}.app-foot a{display:inline-block;margin-top:8px}.activity-item{grid-template-columns:30px 1fr;gap:10px}.activity-item>.badge{grid-column:2;width:max-content}.activity-list{padding:0 16px}.activity-item p{font-size:11px}.report-card{padding:18px}.land-nav{padding:20px}.land-nav nav{gap:15px}.land-nav nav>a:first-child{display:none}.land-nav .button{font-size:11px;padding:10px 13px}.hero{padding:38px 22px 46px;gap:30px}.hero h1{font-size:47px;letter-spacing:-2px}.hero>div>p{font-size:14px}.hero .row{flex-wrap:wrap}.hero .button{flex:1;white-space:nowrap;font-size:12px}.hero-map{height:210px}.hero-board{padding:20px;border-radius:13px}.preview-metrics strong{font-size:23px}.preview-metrics span{font-size:8px}.landing-features{grid-template-columns:1fr;padding:0 25px 35px;gap:18px}.feature p{margin-bottom:0}.feature h3{margin-top:10px}.land-bottom{padding:22px}.land-bottom>div{display:block}.land-bottom nav{margin-top:20px}.public-page{padding:28px 16px}.public-page>.panel{padding:23px}.public-page h1{font-size:27px}.link-field{flex-wrap:wrap}.link-field input{flex-basis:100%}.form-grid{gap:10px}.form input,.form select{font-size:16px}.form textarea{font-size:16px}.dialog-head{padding:17px 19px}#modal-body{padding:20px}#toast{bottom:16px;right:16px;font-size:12px}} +@media(prefers-reduced-motion:reduce){*,*:before,*:after{scroll-behavior:auto!important;animation:none!important;transition:none!important}} +.side-account>div:nth-child(2){min-width:0;flex:1}.side-account .account-name{max-width:100%}.side-account .icon-button{flex-shrink:0}.side-note .line-art>svg{width:100%;height:85px;opacity:.7} diff --git a/public/static/utils.js b/public/static/utils.js new file mode 100644 index 0000000..1d69c0c --- /dev/null +++ b/public/static/utils.js @@ -0,0 +1,18 @@ +export function priority(score) { return score < 40 ? 'Emergency' : score < 70 ? 'High' : 'Low'; } +export function color(score) { return score < 40 ? '#ba5550' : score < 70 ? '#ca9847' : '#3f9571'; } +export function summary(assets, reports) { + return { total: assets.length, average: assets.length ? assets.reduce((sum, a) => sum + a.health_score, 0) / assets.length : 0, + attention: assets.filter(a => a.health_score < 70).length, open: reports.filter(r => r.status === 'Open').length }; +} +export function filterAssets(assets, query = '', type = '', risk = '') { + const term = query.trim().toLocaleLowerCase(); + return assets.filter(a => (!term || `${a.name} ${a.id}`.toLocaleLowerCase().includes(term)) && (!type || a.asset_type === type) && (!risk || priority(a.health_score) === risk)); +} +export function date(value, time = false) { + if (!value) return 'Not recorded'; + const parsed = new Date(value); + if (Number.isNaN(parsed.valueOf())) return 'Not recorded'; + return new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short', ...(time ? { hour: '2-digit', minute: '2-digit' } : { year: 'numeric' }) }).format(parsed); +} +export function initials(name) { return name.split(/\s+/).filter(Boolean).slice(0, 2).map(s => s[0]).join('').toUpperCase(); } +export function severityName(value) { return ({ 5: 'Low', 10: 'Medium', 15: 'High' })[value] || 'Unrated'; } diff --git a/public/static/vendor/LEAFLET-LICENSE b/public/static/vendor/LEAFLET-LICENSE new file mode 100644 index 0000000..bcb3ba1 --- /dev/null +++ b/public/static/vendor/LEAFLET-LICENSE @@ -0,0 +1,26 @@ +BSD 2-Clause License + +Copyright (c) 2010-2023, Volodymyr Agafonkin +Copyright (c) 2010-2011, CloudMade +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/public/static/vendor/leaflet.css b/public/static/vendor/leaflet.css new file mode 100644 index 0000000..2961b76 --- /dev/null +++ b/public/static/vendor/leaflet.css @@ -0,0 +1,661 @@ +/* required styles */ + +.leaflet-pane, +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-tile-container, +.leaflet-pane > svg, +.leaflet-pane > canvas, +.leaflet-zoom-box, +.leaflet-image-layer, +.leaflet-layer { + position: absolute; + left: 0; + top: 0; + } +.leaflet-container { + overflow: hidden; + } +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + -webkit-user-drag: none; + } +/* Prevents IE11 from highlighting tiles in blue */ +.leaflet-tile::selection { + background: transparent; +} +/* Safari renders non-retina tile on retina better with this, but Chrome is worse */ +.leaflet-safari .leaflet-tile { + image-rendering: -webkit-optimize-contrast; + } +/* hack that prevents hw layers "stretching" when loading new tiles */ +.leaflet-safari .leaflet-tile-container { + width: 1600px; + height: 1600px; + -webkit-transform-origin: 0 0; + } +.leaflet-marker-icon, +.leaflet-marker-shadow { + display: block; + } +/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ +/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ +.leaflet-container .leaflet-overlay-pane svg { + max-width: none !important; + max-height: none !important; + } +.leaflet-container .leaflet-marker-pane img, +.leaflet-container .leaflet-shadow-pane img, +.leaflet-container .leaflet-tile-pane img, +.leaflet-container img.leaflet-image-layer, +.leaflet-container .leaflet-tile { + max-width: none !important; + max-height: none !important; + width: auto; + padding: 0; + } + +.leaflet-container img.leaflet-tile { + /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */ + mix-blend-mode: plus-lighter; +} + +.leaflet-container.leaflet-touch-zoom { + -ms-touch-action: pan-x pan-y; + touch-action: pan-x pan-y; + } +.leaflet-container.leaflet-touch-drag { + -ms-touch-action: pinch-zoom; + /* Fallback for FF which doesn't support pinch-zoom */ + touch-action: none; + touch-action: pinch-zoom; +} +.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { + -ms-touch-action: none; + touch-action: none; +} +.leaflet-container { + -webkit-tap-highlight-color: transparent; +} +.leaflet-container a { + -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); +} +.leaflet-tile { + filter: inherit; + visibility: hidden; + } +.leaflet-tile-loaded { + visibility: inherit; + } +.leaflet-zoom-box { + width: 0; + height: 0; + -moz-box-sizing: border-box; + box-sizing: border-box; + z-index: 800; + } +/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ +.leaflet-overlay-pane svg { + -moz-user-select: none; + } + +.leaflet-pane { z-index: 400; } + +.leaflet-tile-pane { z-index: 200; } +.leaflet-overlay-pane { z-index: 400; } +.leaflet-shadow-pane { z-index: 500; } +.leaflet-marker-pane { z-index: 600; } +.leaflet-tooltip-pane { z-index: 650; } +.leaflet-popup-pane { z-index: 700; } + +.leaflet-map-pane canvas { z-index: 100; } +.leaflet-map-pane svg { z-index: 200; } + +.leaflet-vml-shape { + width: 1px; + height: 1px; + } +.lvml { + behavior: url(#default#VML); + display: inline-block; + position: absolute; + } + + +/* control positioning */ + +.leaflet-control { + position: relative; + z-index: 800; + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } +.leaflet-top, +.leaflet-bottom { + position: absolute; + z-index: 1000; + pointer-events: none; + } +.leaflet-top { + top: 0; + } +.leaflet-right { + right: 0; + } +.leaflet-bottom { + bottom: 0; + } +.leaflet-left { + left: 0; + } +.leaflet-control { + float: left; + clear: both; + } +.leaflet-right .leaflet-control { + float: right; + } +.leaflet-top .leaflet-control { + margin-top: 10px; + } +.leaflet-bottom .leaflet-control { + margin-bottom: 10px; + } +.leaflet-left .leaflet-control { + margin-left: 10px; + } +.leaflet-right .leaflet-control { + margin-right: 10px; + } + + +/* zoom and fade animations */ + +.leaflet-fade-anim .leaflet-popup { + opacity: 0; + -webkit-transition: opacity 0.2s linear; + -moz-transition: opacity 0.2s linear; + transition: opacity 0.2s linear; + } +.leaflet-fade-anim .leaflet-map-pane .leaflet-popup { + opacity: 1; + } +.leaflet-zoom-animated { + -webkit-transform-origin: 0 0; + -ms-transform-origin: 0 0; + transform-origin: 0 0; + } +svg.leaflet-zoom-animated { + will-change: transform; +} + +.leaflet-zoom-anim .leaflet-zoom-animated { + -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); + -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); + transition: transform 0.25s cubic-bezier(0,0,0.25,1); + } +.leaflet-zoom-anim .leaflet-tile, +.leaflet-pan-anim .leaflet-tile { + -webkit-transition: none; + -moz-transition: none; + transition: none; + } + +.leaflet-zoom-anim .leaflet-zoom-hide { + visibility: hidden; + } + + +/* cursors */ + +.leaflet-interactive { + cursor: pointer; + } +.leaflet-grab { + cursor: -webkit-grab; + cursor: -moz-grab; + cursor: grab; + } +.leaflet-crosshair, +.leaflet-crosshair .leaflet-interactive { + cursor: crosshair; + } +.leaflet-popup-pane, +.leaflet-control { + cursor: auto; + } +.leaflet-dragging .leaflet-grab, +.leaflet-dragging .leaflet-grab .leaflet-interactive, +.leaflet-dragging .leaflet-marker-draggable { + cursor: move; + cursor: -webkit-grabbing; + cursor: -moz-grabbing; + cursor: grabbing; + } + +/* marker & overlays interactivity */ +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-image-layer, +.leaflet-pane > svg path, +.leaflet-tile-container { + pointer-events: none; + } + +.leaflet-marker-icon.leaflet-interactive, +.leaflet-image-layer.leaflet-interactive, +.leaflet-pane > svg path.leaflet-interactive, +svg.leaflet-image-layer.leaflet-interactive path { + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } + +/* visual tweaks */ + +.leaflet-container { + background: #ddd; + outline-offset: 1px; + } +.leaflet-container a { + color: #0078A8; + } +.leaflet-zoom-box { + border: 2px dotted #38f; + background: rgba(255,255,255,0.5); + } + + +/* general typography */ +.leaflet-container { + font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; + font-size: 12px; + font-size: 0.75rem; + line-height: 1.5; + } + + +/* general toolbar styles */ + +.leaflet-bar { + box-shadow: 0 1px 5px rgba(0,0,0,0.65); + border-radius: 4px; + } +.leaflet-bar a { + background-color: #fff; + border-bottom: 1px solid #ccc; + width: 26px; + height: 26px; + line-height: 26px; + display: block; + text-align: center; + text-decoration: none; + color: black; + } +.leaflet-bar a, +.leaflet-control-layers-toggle { + background-position: 50% 50%; + background-repeat: no-repeat; + display: block; + } +.leaflet-bar a:hover, +.leaflet-bar a:focus { + background-color: #f4f4f4; + } +.leaflet-bar a:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } +.leaflet-bar a:last-child { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-bottom: none; + } +.leaflet-bar a.leaflet-disabled { + cursor: default; + background-color: #f4f4f4; + color: #bbb; + } + +.leaflet-touch .leaflet-bar a { + width: 30px; + height: 30px; + line-height: 30px; + } +.leaflet-touch .leaflet-bar a:first-child { + border-top-left-radius: 2px; + border-top-right-radius: 2px; + } +.leaflet-touch .leaflet-bar a:last-child { + border-bottom-left-radius: 2px; + border-bottom-right-radius: 2px; + } + +/* zoom control */ + +.leaflet-control-zoom-in, +.leaflet-control-zoom-out { + font: bold 18px 'Lucida Console', Monaco, monospace; + text-indent: 1px; + } + +.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { + font-size: 22px; + } + + +/* layers control */ + +.leaflet-control-layers { + box-shadow: 0 1px 5px rgba(0,0,0,0.4); + background: #fff; + border-radius: 5px; + } +.leaflet-control-layers-toggle { + background-image: url(images/layers.png); + width: 36px; + height: 36px; + } +.leaflet-retina .leaflet-control-layers-toggle { + background-image: url(images/layers-2x.png); + background-size: 26px 26px; + } +.leaflet-touch .leaflet-control-layers-toggle { + width: 44px; + height: 44px; + } +.leaflet-control-layers .leaflet-control-layers-list, +.leaflet-control-layers-expanded .leaflet-control-layers-toggle { + display: none; + } +.leaflet-control-layers-expanded .leaflet-control-layers-list { + display: block; + position: relative; + } +.leaflet-control-layers-expanded { + padding: 6px 10px 6px 6px; + color: #333; + background: #fff; + } +.leaflet-control-layers-scrollbar { + overflow-y: scroll; + overflow-x: hidden; + padding-right: 5px; + } +.leaflet-control-layers-selector { + margin-top: 2px; + position: relative; + top: 1px; + } +.leaflet-control-layers label { + display: block; + font-size: 13px; + font-size: 1.08333em; + } +.leaflet-control-layers-separator { + height: 0; + border-top: 1px solid #ddd; + margin: 5px -10px 5px -6px; + } + +/* Default icon URLs */ +.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */ + background-image: url(images/marker-icon.png); + } + + +/* attribution and scale controls */ + +.leaflet-container .leaflet-control-attribution { + background: #fff; + background: rgba(255, 255, 255, 0.8); + margin: 0; + } +.leaflet-control-attribution, +.leaflet-control-scale-line { + padding: 0 5px; + color: #333; + line-height: 1.4; + } +.leaflet-control-attribution a { + text-decoration: none; + } +.leaflet-control-attribution a:hover, +.leaflet-control-attribution a:focus { + text-decoration: underline; + } +.leaflet-attribution-flag { + display: inline !important; + vertical-align: baseline !important; + width: 1em; + height: 0.6669em; + } +.leaflet-left .leaflet-control-scale { + margin-left: 5px; + } +.leaflet-bottom .leaflet-control-scale { + margin-bottom: 5px; + } +.leaflet-control-scale-line { + border: 2px solid #777; + border-top: none; + line-height: 1.1; + padding: 2px 5px 1px; + white-space: nowrap; + -moz-box-sizing: border-box; + box-sizing: border-box; + background: rgba(255, 255, 255, 0.8); + text-shadow: 1px 1px #fff; + } +.leaflet-control-scale-line:not(:first-child) { + border-top: 2px solid #777; + border-bottom: none; + margin-top: -2px; + } +.leaflet-control-scale-line:not(:first-child):not(:last-child) { + border-bottom: 2px solid #777; + } + +.leaflet-touch .leaflet-control-attribution, +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + box-shadow: none; + } +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + border: 2px solid rgba(0,0,0,0.2); + background-clip: padding-box; + } + + +/* popup */ + +.leaflet-popup { + position: absolute; + text-align: center; + margin-bottom: 20px; + } +.leaflet-popup-content-wrapper { + padding: 1px; + text-align: left; + border-radius: 12px; + } +.leaflet-popup-content { + margin: 13px 24px 13px 20px; + line-height: 1.3; + font-size: 13px; + font-size: 1.08333em; + min-height: 1px; + } +.leaflet-popup-content p { + margin: 17px 0; + margin: 1.3em 0; + } +.leaflet-popup-tip-container { + width: 40px; + height: 20px; + position: absolute; + left: 50%; + margin-top: -1px; + margin-left: -20px; + overflow: hidden; + pointer-events: none; + } +.leaflet-popup-tip { + width: 17px; + height: 17px; + padding: 1px; + + margin: -10px auto 0; + pointer-events: auto; + + -webkit-transform: rotate(45deg); + -moz-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + } +.leaflet-popup-content-wrapper, +.leaflet-popup-tip { + background: white; + color: #333; + box-shadow: 0 3px 14px rgba(0,0,0,0.4); + } +.leaflet-container a.leaflet-popup-close-button { + position: absolute; + top: 0; + right: 0; + border: none; + text-align: center; + width: 24px; + height: 24px; + font: 16px/24px Tahoma, Verdana, sans-serif; + color: #757575; + text-decoration: none; + background: transparent; + } +.leaflet-container a.leaflet-popup-close-button:hover, +.leaflet-container a.leaflet-popup-close-button:focus { + color: #585858; + } +.leaflet-popup-scrolled { + overflow: auto; + } + +.leaflet-oldie .leaflet-popup-content-wrapper { + -ms-zoom: 1; + } +.leaflet-oldie .leaflet-popup-tip { + width: 24px; + margin: 0 auto; + + -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; + filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); + } + +.leaflet-oldie .leaflet-control-zoom, +.leaflet-oldie .leaflet-control-layers, +.leaflet-oldie .leaflet-popup-content-wrapper, +.leaflet-oldie .leaflet-popup-tip { + border: 1px solid #999; + } + + +/* div icon */ + +.leaflet-div-icon { + background: #fff; + border: 1px solid #666; + } + + +/* Tooltip */ +/* Base styles for the element that has a tooltip */ +.leaflet-tooltip { + position: absolute; + padding: 6px; + background-color: #fff; + border: 1px solid #fff; + border-radius: 3px; + color: #222; + white-space: nowrap; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + box-shadow: 0 1px 3px rgba(0,0,0,0.4); + } +.leaflet-tooltip.leaflet-interactive { + cursor: pointer; + pointer-events: auto; + } +.leaflet-tooltip-top:before, +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + position: absolute; + pointer-events: none; + border: 6px solid transparent; + background: transparent; + content: ""; + } + +/* Directions */ + +.leaflet-tooltip-bottom { + margin-top: 6px; +} +.leaflet-tooltip-top { + margin-top: -6px; +} +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-top:before { + left: 50%; + margin-left: -6px; + } +.leaflet-tooltip-top:before { + bottom: 0; + margin-bottom: -12px; + border-top-color: #fff; + } +.leaflet-tooltip-bottom:before { + top: 0; + margin-top: -12px; + margin-left: -6px; + border-bottom-color: #fff; + } +.leaflet-tooltip-left { + margin-left: -6px; +} +.leaflet-tooltip-right { + margin-left: 6px; +} +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + top: 50%; + margin-top: -6px; + } +.leaflet-tooltip-left:before { + right: 0; + margin-right: -12px; + border-left-color: #fff; + } +.leaflet-tooltip-right:before { + left: 0; + margin-left: -12px; + border-right-color: #fff; + } + +/* Printing */ + +@media print { + /* Prevent printers from removing background-images of controls. */ + .leaflet-control { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + } diff --git a/public/static/vendor/leaflet.js b/public/static/vendor/leaflet.js new file mode 100644 index 0000000..a3bf693 --- /dev/null +++ b/public/static/vendor/leaflet.js @@ -0,0 +1,6 @@ +/* @preserve + * Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com + * (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n=this.min.x&&i.x<=this.max.x&&e.y>=this.min.y&&i.y<=this.max.y},intersects:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>=e.x&&n.x<=i.x,t=t.y>=e.y&&n.y<=i.y;return o&&t},overlaps:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>e.x&&n.xe.y&&n.y=n.lat&&i.lat<=o.lat&&e.lng>=n.lng&&i.lng<=o.lng},intersects:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>=e.lat&&n.lat<=i.lat,t=t.lng>=e.lng&&n.lng<=i.lng;return o&&t},overlaps:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>e.lat&&n.late.lng&&n.lng","http://www.w3.org/2000/svg"===(Wt.firstChild&&Wt.firstChild.namespaceURI));function y(t){return 0<=navigator.userAgent.toLowerCase().indexOf(t)}var b={ie:pt,ielt9:mt,edge:n,webkit:ft,android:gt,android23:vt,androidStock:yt,opera:xt,chrome:wt,gecko:bt,safari:Pt,phantom:Lt,opera12:o,win:Tt,ie3d:Mt,webkit3d:zt,gecko3d:_t,any3d:Ct,mobile:Zt,mobileWebkit:St,mobileWebkit3d:Et,msPointer:kt,pointer:Ot,touch:Bt,touchNative:At,mobileOpera:It,mobileGecko:Rt,retina:Nt,passiveEvents:Dt,canvas:jt,svg:Ht,vml:!Ht&&function(){try{var t=document.createElement("div"),e=(t.innerHTML='',t.firstChild);return e.style.behavior="url(#default#VML)",e&&"object"==typeof e.adj}catch(t){return!1}}(),inlineSvg:Wt,mac:0===navigator.platform.indexOf("Mac"),linux:0===navigator.platform.indexOf("Linux")},Ft=b.msPointer?"MSPointerDown":"pointerdown",Ut=b.msPointer?"MSPointerMove":"pointermove",Vt=b.msPointer?"MSPointerUp":"pointerup",qt=b.msPointer?"MSPointerCancel":"pointercancel",Gt={touchstart:Ft,touchmove:Ut,touchend:Vt,touchcancel:qt},Kt={touchstart:function(t,e){e.MSPOINTER_TYPE_TOUCH&&e.pointerType===e.MSPOINTER_TYPE_TOUCH&&O(e);ee(t,e)},touchmove:ee,touchend:ee,touchcancel:ee},Yt={},Xt=!1;function Jt(t,e,i){return"touchstart"!==e||Xt||(document.addEventListener(Ft,$t,!0),document.addEventListener(Ut,Qt,!0),document.addEventListener(Vt,te,!0),document.addEventListener(qt,te,!0),Xt=!0),Kt[e]?(i=Kt[e].bind(this,i),t.addEventListener(Gt[e],i,!1),i):(console.warn("wrong event specified:",e),u)}function $t(t){Yt[t.pointerId]=t}function Qt(t){Yt[t.pointerId]&&(Yt[t.pointerId]=t)}function te(t){delete Yt[t.pointerId]}function ee(t,e){if(e.pointerType!==(e.MSPOINTER_TYPE_MOUSE||"mouse")){for(var i in e.touches=[],Yt)e.touches.push(Yt[i]);e.changedTouches=[e],t(e)}}var ie=200;function ne(t,i){t.addEventListener("dblclick",i);var n,o=0;function e(t){var e;1!==t.detail?n=t.detail:"mouse"===t.pointerType||t.sourceCapabilities&&!t.sourceCapabilities.firesTouchEvents||((e=Ne(t)).some(function(t){return t instanceof HTMLLabelElement&&t.attributes.for})&&!e.some(function(t){return t instanceof HTMLInputElement||t instanceof HTMLSelectElement})||((e=Date.now())-o<=ie?2===++n&&i(function(t){var e,i,n={};for(i in t)e=t[i],n[i]=e&&e.bind?e.bind(t):e;return(t=n).type="dblclick",n.detail=2,n.isTrusted=!1,n._simulated=!0,n}(t)):n=1,o=e))}return t.addEventListener("click",e),{dblclick:i,simDblclick:e}}var oe,se,re,ae,he,le,ue=we(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),ce=we(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),de="webkitTransition"===ce||"OTransition"===ce?ce+"End":"transitionend";function _e(t){return"string"==typeof t?document.getElementById(t):t}function pe(t,e){var i=t.style[e]||t.currentStyle&&t.currentStyle[e];return"auto"===(i=i&&"auto"!==i||!document.defaultView?i:(t=document.defaultView.getComputedStyle(t,null))?t[e]:null)?null:i}function P(t,e,i){t=document.createElement(t);return t.className=e||"",i&&i.appendChild(t),t}function T(t){var e=t.parentNode;e&&e.removeChild(t)}function me(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function fe(t){var e=t.parentNode;e&&e.lastChild!==t&&e.appendChild(t)}function ge(t){var e=t.parentNode;e&&e.firstChild!==t&&e.insertBefore(t,e.firstChild)}function ve(t,e){return void 0!==t.classList?t.classList.contains(e):0<(t=xe(t)).length&&new RegExp("(^|\\s)"+e+"(\\s|$)").test(t)}function M(t,e){var i;if(void 0!==t.classList)for(var n=F(e),o=0,s=n.length;othis.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,e){this._enforcingBounds=!0;var i=this.getCenter(),t=this._limitCenter(i,this._zoom,g(t));return i.equals(t)||this.panTo(t,e),this._enforcingBounds=!1,this},panInside:function(t,e){var i=m((e=e||{}).paddingTopLeft||e.padding||[0,0]),n=m(e.paddingBottomRight||e.padding||[0,0]),o=this.project(this.getCenter()),t=this.project(t),s=this.getPixelBounds(),i=_([s.min.add(i),s.max.subtract(n)]),s=i.getSize();return i.contains(t)||(this._enforcingBounds=!0,n=t.subtract(i.getCenter()),i=i.extend(t).getSize().subtract(s),o.x+=n.x<0?-i.x:i.x,o.y+=n.y<0?-i.y:i.y,this.panTo(this.unproject(o),e),this._enforcingBounds=!1),this},invalidateSize:function(t){if(!this._loaded)return this;t=l({animate:!1,pan:!0},!0===t?{animate:!0}:t);var e=this.getSize(),i=(this._sizeChanged=!0,this._lastCenter=null,this.getSize()),n=e.divideBy(2).round(),o=i.divideBy(2).round(),n=n.subtract(o);return n.x||n.y?(t.animate&&t.pan?this.panBy(n):(t.pan&&this._rawPanBy(n),this.fire("move"),t.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(a(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:e,newSize:i})):this},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(t){var e,i;return t=this._locateOptions=l({timeout:1e4,watch:!1},t),"geolocation"in navigator?(e=a(this._handleGeolocationResponse,this),i=a(this._handleGeolocationError,this),t.watch?this._locationWatchId=navigator.geolocation.watchPosition(e,i,t):navigator.geolocation.getCurrentPosition(e,i,t)):this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){var e;this._container._leaflet_id&&(e=t.code,t=t.message||(1===e?"permission denied":2===e?"position unavailable":"timeout"),this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:e,message:"Geolocation error: "+t+"."}))},_handleGeolocationResponse:function(t){if(this._container._leaflet_id){var e,i,n=new v(t.coords.latitude,t.coords.longitude),o=n.toBounds(2*t.coords.accuracy),s=this._locateOptions,r=(s.setView&&(e=this.getBoundsZoom(o),this.setView(n,s.maxZoom?Math.min(e,s.maxZoom):e)),{latlng:n,bounds:o,timestamp:t.timestamp});for(i in t.coords)"number"==typeof t.coords[i]&&(r[i]=t.coords[i]);this.fire("locationfound",r)}},addHandler:function(t,e){return e&&(e=this[t]=new e(this),this._handlers.push(e),this.options[t]&&e.enable()),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch(t){this._container._leaflet_id=void 0,this._containerId=void 0}for(var t in void 0!==this._locationWatchId&&this.stopLocate(),this._stop(),T(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(r(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload"),this._layers)this._layers[t].remove();for(t in this._panes)T(this._panes[t]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(t,e){e=P("div","leaflet-pane"+(t?" leaflet-"+t.replace("Pane","")+"-pane":""),e||this._mapPane);return t&&(this._panes[t]=e),e},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var t=this.getPixelBounds();return new s(this.unproject(t.getBottomLeft()),this.unproject(t.getTopRight()))},getMinZoom:function(){return void 0===this.options.minZoom?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return void 0===this.options.maxZoom?void 0===this._layersMaxZoom?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(t,e,i){t=g(t),i=m(i||[0,0]);var n=this.getZoom()||0,o=this.getMinZoom(),s=this.getMaxZoom(),r=t.getNorthWest(),t=t.getSouthEast(),i=this.getSize().subtract(i),t=_(this.project(t,n),this.project(r,n)).getSize(),r=b.any3d?this.options.zoomSnap:1,a=i.x/t.x,i=i.y/t.y,t=e?Math.max(a,i):Math.min(a,i),n=this.getScaleZoom(t,n);return r&&(n=Math.round(n/(r/100))*(r/100),n=e?Math.ceil(n/r)*r:Math.floor(n/r)*r),Math.max(o,Math.min(s,n))},getSize:function(){return this._size&&!this._sizeChanged||(this._size=new p(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(t,e){t=this._getTopLeftPoint(t,e);return new f(t,t.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(t){return this.options.crs.getProjectedBounds(void 0===t?this.getZoom():t)},getPane:function(t){return"string"==typeof t?this._panes[t]:t},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(t,e){var i=this.options.crs;return e=void 0===e?this._zoom:e,i.scale(t)/i.scale(e)},getScaleZoom:function(t,e){var i=this.options.crs,t=(e=void 0===e?this._zoom:e,i.zoom(t*i.scale(e)));return isNaN(t)?1/0:t},project:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.latLngToPoint(w(t),e)},unproject:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.pointToLatLng(m(t),e)},layerPointToLatLng:function(t){t=m(t).add(this.getPixelOrigin());return this.unproject(t)},latLngToLayerPoint:function(t){return this.project(w(t))._round()._subtract(this.getPixelOrigin())},wrapLatLng:function(t){return this.options.crs.wrapLatLng(w(t))},wrapLatLngBounds:function(t){return this.options.crs.wrapLatLngBounds(g(t))},distance:function(t,e){return this.options.crs.distance(w(t),w(e))},containerPointToLayerPoint:function(t){return m(t).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(t){return m(t).add(this._getMapPanePos())},containerPointToLatLng:function(t){t=this.containerPointToLayerPoint(m(t));return this.layerPointToLatLng(t)},latLngToContainerPoint:function(t){return this.layerPointToContainerPoint(this.latLngToLayerPoint(w(t)))},mouseEventToContainerPoint:function(t){return De(t,this._container)},mouseEventToLayerPoint:function(t){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(t))},mouseEventToLatLng:function(t){return this.layerPointToLatLng(this.mouseEventToLayerPoint(t))},_initContainer:function(t){t=this._container=_e(t);if(!t)throw new Error("Map container not found.");if(t._leaflet_id)throw new Error("Map container is already initialized.");S(t,"scroll",this._onScroll,this),this._containerId=h(t)},_initLayout:function(){var t=this._container,e=(this._fadeAnimated=this.options.fadeAnimation&&b.any3d,M(t,"leaflet-container"+(b.touch?" leaflet-touch":"")+(b.retina?" leaflet-retina":"")+(b.ielt9?" leaflet-oldie":"")+(b.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":"")),pe(t,"position"));"absolute"!==e&&"relative"!==e&&"fixed"!==e&&"sticky"!==e&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),Z(this._mapPane,new p(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(M(t.markerPane,"leaflet-zoom-hide"),M(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,e,i){Z(this._mapPane,new p(0,0));var n=!this._loaded,o=(this._loaded=!0,e=this._limitZoom(e),this.fire("viewprereset"),this._zoom!==e);this._moveStart(o,i)._move(t,e)._moveEnd(o),this.fire("viewreset"),n&&this.fire("load")},_moveStart:function(t,e){return t&&this.fire("zoomstart"),e||this.fire("movestart"),this},_move:function(t,e,i,n){void 0===e&&(e=this._zoom);var o=this._zoom!==e;return this._zoom=e,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),n?i&&i.pinch&&this.fire("zoom",i):((o||i&&i.pinch)&&this.fire("zoom",i),this.fire("move",i)),this},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return r(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){Z(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(t){this._targets={};var e=t?k:S;e((this._targets[h(this._container)]=this)._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&e(window,"resize",this._onResize,this),b.any3d&&this.options.transform3DLimit&&(t?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){r(this._resizeRequest),this._resizeRequest=x(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,e){for(var i,n=[],o="mouseout"===e||"mouseover"===e,s=t.target||t.srcElement,r=!1;s;){if((i=this._targets[h(s)])&&("click"===e||"preclick"===e)&&this._draggableMoved(i)){r=!0;break}if(i&&i.listens(e,!0)){if(o&&!We(s,t))break;if(n.push(i),o)break}if(s===this._container)break;s=s.parentNode}return n=n.length||r||o||!this.listens(e,!0)?n:[this]},_isClickDisabled:function(t){for(;t&&t!==this._container;){if(t._leaflet_disable_click)return!0;t=t.parentNode}},_handleDOMEvent:function(t){var e,i=t.target||t.srcElement;!this._loaded||i._leaflet_disable_events||"click"===t.type&&this._isClickDisabled(i)||("mousedown"===(e=t.type)&&Me(i),this._fireDOMEvent(t,e))},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,e,i){"click"===t.type&&((a=l({},t)).type="preclick",this._fireDOMEvent(a,a.type,i));var n=this._findEventTargets(t,e);if(i){for(var o=[],s=0;sthis.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(e),n=this._getCenterOffset(t)._divideBy(1-1/n);if(!0!==i.animate&&!this.getSize().contains(n))return!1;x(function(){this._moveStart(!0,i.noMoveStart||!1)._animateZoom(t,e,!0)},this)}return!0},_animateZoom:function(t,e,i,n){this._mapPane&&(i&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=e,M(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:e,noUpdate:n}),this._tempFireZoomEvent||(this._tempFireZoomEvent=this._zoom!==this._animateToZoom),this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(a(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&z(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire("zoom"),delete this._tempFireZoomEvent,this.fire("move"),this._moveEnd(!0))}});function Ue(t){return new B(t)}var B=et.extend({options:{position:"topright"},initialize:function(t){c(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var e=this._map;return e&&e.removeControl(this),this.options.position=t,e&&e.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var e=this._container=this.onAdd(t),i=this.getPosition(),t=t._controlCorners[i];return M(e,"leaflet-control"),-1!==i.indexOf("bottom")?t.insertBefore(e,t.firstChild):t.appendChild(e),this._map.on("unload",this.remove,this),this},remove:function(){return this._map&&(T(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null),this},_refocusOnMap:function(t){this._map&&t&&0",e=document.createElement("div");return e.innerHTML=t,e.firstChild},_addItem:function(t){var e,i=document.createElement("label"),n=this._map.hasLayer(t.layer),n=(t.overlay?((e=document.createElement("input")).type="checkbox",e.className="leaflet-control-layers-selector",e.defaultChecked=n):e=this._createRadioElement("leaflet-base-layers_"+h(this),n),this._layerControlInputs.push(e),e.layerId=h(t.layer),S(e,"click",this._onInputClick,this),document.createElement("span")),o=(n.innerHTML=" "+t.name,document.createElement("span"));return i.appendChild(o),o.appendChild(e),o.appendChild(n),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(i),this._checkDisabledLayers(),i},_onInputClick:function(){if(!this._preventClick){var t,e,i=this._layerControlInputs,n=[],o=[];this._handlingClick=!0;for(var s=i.length-1;0<=s;s--)t=i[s],e=this._getLayer(t.layerId).layer,t.checked?n.push(e):t.checked||o.push(e);for(s=0;se.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expandSafely:function(){var t=this._section,e=(this._preventClick=!0,S(t,"click",O),this.expand(),this);setTimeout(function(){k(t,"click",O),e._preventClick=!1})}})),qe=B.extend({options:{position:"topleft",zoomInText:'',zoomInTitle:"Zoom in",zoomOutText:'',zoomOutTitle:"Zoom out"},onAdd:function(t){var e="leaflet-control-zoom",i=P("div",e+" leaflet-bar"),n=this.options;return this._zoomInButton=this._createButton(n.zoomInText,n.zoomInTitle,e+"-in",i,this._zoomIn),this._zoomOutButton=this._createButton(n.zoomOutText,n.zoomOutTitle,e+"-out",i,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),i},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,e,i,n,o){i=P("a",i,n);return i.innerHTML=t,i.href="#",i.title=e,i.setAttribute("role","button"),i.setAttribute("aria-label",e),Ie(i),S(i,"click",Re),S(i,"click",o,this),S(i,"click",this._refocusOnMap,this),i},_updateDisabled:function(){var t=this._map,e="leaflet-disabled";z(this._zoomInButton,e),z(this._zoomOutButton,e),this._zoomInButton.setAttribute("aria-disabled","false"),this._zoomOutButton.setAttribute("aria-disabled","false"),!this._disabled&&t._zoom!==t.getMinZoom()||(M(this._zoomOutButton,e),this._zoomOutButton.setAttribute("aria-disabled","true")),!this._disabled&&t._zoom!==t.getMaxZoom()||(M(this._zoomInButton,e),this._zoomInButton.setAttribute("aria-disabled","true"))}}),Ge=(A.mergeOptions({zoomControl:!0}),A.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new qe,this.addControl(this.zoomControl))}),B.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var e="leaflet-control-scale",i=P("div",e),n=this.options;return this._addScales(n,e+"-line",i),t.on(n.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),i},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,e,i){t.metric&&(this._mScale=P("div",e,i)),t.imperial&&(this._iScale=P("div",e,i))},_update:function(){var t=this._map,e=t.getSize().y/2,t=t.distance(t.containerPointToLatLng([0,e]),t.containerPointToLatLng([this.options.maxWidth,e]));this._updateScales(t)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var e=this._getRoundNum(t);this._updateScale(this._mScale,e<1e3?e+" m":e/1e3+" km",e/t)},_updateImperial:function(t){var e,i,t=3.2808399*t;5280'+(b.inlineSvg?' ':"")+"Leaflet"},initialize:function(t){c(this,t),this._attributions={}},onAdd:function(t){for(var e in(t.attributionControl=this)._container=P("div","leaflet-control-attribution"),Ie(this._container),t._layers)t._layers[e].getAttribution&&this.addAttribution(t._layers[e].getAttribution());return this._update(),t.on("layeradd",this._addAttribution,this),this._container},onRemove:function(t){t.off("layeradd",this._addAttribution,this)},_addAttribution:function(t){t.layer.getAttribution&&(this.addAttribution(t.layer.getAttribution()),t.layer.once("remove",function(){this.removeAttribution(t.layer.getAttribution())},this))},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t&&(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update()),this},removeAttribution:function(t){return t&&this._attributions[t]&&(this._attributions[t]--,this._update()),this},_update:function(){if(this._map){var t,e=[];for(t in this._attributions)this._attributions[t]&&e.push(t);var i=[];this.options.prefix&&i.push(this.options.prefix),e.length&&i.push(e.join(", ")),this._container.innerHTML=i.join(' ')}}}),n=(A.mergeOptions({attributionControl:!0}),A.addInitHook(function(){this.options.attributionControl&&(new Ke).addTo(this)}),B.Layers=Ve,B.Zoom=qe,B.Scale=Ge,B.Attribution=Ke,Ue.layers=function(t,e,i){return new Ve(t,e,i)},Ue.zoom=function(t){return new qe(t)},Ue.scale=function(t){return new Ge(t)},Ue.attribution=function(t){return new Ke(t)},et.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled||(this._enabled=!0,this.addHooks()),this},disable:function(){return this._enabled&&(this._enabled=!1,this.removeHooks()),this},enabled:function(){return!!this._enabled}})),ft=(n.addTo=function(t,e){return t.addHandler(e,this),this},{Events:e}),Ye=b.touch?"touchstart mousedown":"mousedown",Xe=it.extend({options:{clickTolerance:3},initialize:function(t,e,i,n){c(this,n),this._element=t,this._dragStartTarget=e||t,this._preventOutline=i},enable:function(){this._enabled||(S(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(Xe._dragging===this&&this.finishDrag(!0),k(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){var e,i;this._enabled&&(this._moved=!1,ve(this._element,"leaflet-zoom-anim")||(t.touches&&1!==t.touches.length?Xe._dragging===this&&this.finishDrag():Xe._dragging||t.shiftKey||1!==t.which&&1!==t.button&&!t.touches||((Xe._dragging=this)._preventOutline&&Me(this._element),Le(),re(),this._moving||(this.fire("down"),i=t.touches?t.touches[0]:t,e=Ce(this._element),this._startPoint=new p(i.clientX,i.clientY),this._startPos=Pe(this._element),this._parentScale=Ze(e),i="mousedown"===t.type,S(document,i?"mousemove":"touchmove",this._onMove,this),S(document,i?"mouseup":"touchend touchcancel",this._onUp,this)))))},_onMove:function(t){var e;this._enabled&&(t.touches&&1e&&(i.push(t[n]),o=n);oe.max.x&&(i|=2),t.ye.max.y&&(i|=8),i}function ri(t,e,i,n){var o=e.x,e=e.y,s=i.x-o,r=i.y-e,a=s*s+r*r;return 0this._layersMaxZoom&&this.setZoom(this._layersMaxZoom),void 0===this.options.minZoom&&this._layersMinZoom&&this.getZoom()t.y!=n.y>t.y&&t.x<(n.x-i.x)*(t.y-i.y)/(n.y-i.y)+i.x&&(l=!l);return l||yi.prototype._containsPoint.call(this,t,!0)}});var wi=ci.extend({initialize:function(t,e){c(this,e),this._layers={},t&&this.addData(t)},addData:function(t){var e,i,n,o=d(t)?t:t.features;if(o){for(e=0,i=o.length;es.x&&(r=i.x+a-s.x+o.x),i.x-r-n.x<(a=0)&&(r=i.x-n.x),i.y+e+o.y>s.y&&(a=i.y+e-s.y+o.y),i.y-a-n.y<0&&(a=i.y-n.y),(r||a)&&(this.options.keepInView&&(this._autopanning=!0),t.fire("autopanstart").panBy([r,a]))))},_getAnchor:function(){return m(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}})),Ii=(A.mergeOptions({closePopupOnClick:!0}),A.include({openPopup:function(t,e,i){return this._initOverlay(Bi,t,e,i).openOn(this),this},closePopup:function(t){return(t=arguments.length?t:this._popup)&&t.close(),this}}),o.include({bindPopup:function(t,e){return this._popup=this._initOverlay(Bi,this._popup,t,e),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t){return this._popup&&(this instanceof ci||(this._popup._source=this),this._popup._prepareOpen(t||this._latlng)&&this._popup.openOn(this._map)),this},closePopup:function(){return this._popup&&this._popup.close(),this},togglePopup:function(){return this._popup&&this._popup.toggle(this),this},isPopupOpen:function(){return!!this._popup&&this._popup.isOpen()},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){var e;this._popup&&this._map&&(Re(t),e=t.layer||t.target,this._popup._source!==e||e instanceof fi?(this._popup._source=e,this.openPopup(t.latlng)):this._map.hasLayer(this._popup)?this.closePopup():this.openPopup(t.latlng))},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){13===t.originalEvent.keyCode&&this._openPopup(t)}}),Ai.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,opacity:.9},onAdd:function(t){Ai.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&(this.addEventParent(this._source),this._source.fire("tooltipopen",{tooltip:this},!0))},onRemove:function(t){Ai.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&(this.removeEventParent(this._source),this._source.fire("tooltipclose",{tooltip:this},!0))},getEvents:function(){var t=Ai.prototype.getEvents.call(this);return this.options.permanent||(t.preclick=this.close),t},_initLayout:function(){var t="leaflet-tooltip "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=P("div",t),this._container.setAttribute("role","tooltip"),this._container.setAttribute("id","leaflet-tooltip-"+h(this))},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var e,i=this._map,n=this._container,o=i.latLngToContainerPoint(i.getCenter()),i=i.layerPointToContainerPoint(t),s=this.options.direction,r=n.offsetWidth,a=n.offsetHeight,h=m(this.options.offset),l=this._getAnchor(),i="top"===s?(e=r/2,a):"bottom"===s?(e=r/2,0):(e="center"===s?r/2:"right"===s?0:"left"===s?r:i.xthis.options.maxZoom||nthis.options.maxZoom||void 0!==this.options.minZoom&&oi.max.x)||!e.wrapLat&&(t.yi.max.y))return!1}return!this.options.bounds||(e=this._tileCoordsToBounds(t),g(this.options.bounds).overlaps(e))},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToNwSe:function(t){var e=this._map,i=this.getTileSize(),n=t.scaleBy(i),i=n.add(i);return[e.unproject(n,t.z),e.unproject(i,t.z)]},_tileCoordsToBounds:function(t){t=this._tileCoordsToNwSe(t),t=new s(t[0],t[1]);return t=this.options.noWrap?t:this._map.wrapLatLngBounds(t)},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var t=t.split(":"),e=new p(+t[0],+t[1]);return e.z=+t[2],e},_removeTile:function(t){var e=this._tiles[t];e&&(T(e.el),delete this._tiles[t],this.fire("tileunload",{tile:e.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){M(t,"leaflet-tile");var e=this.getTileSize();t.style.width=e.x+"px",t.style.height=e.y+"px",t.onselectstart=u,t.onmousemove=u,b.ielt9&&this.options.opacity<1&&C(t,this.options.opacity)},_addTile:function(t,e){var i=this._getTilePos(t),n=this._tileCoordsToKey(t),o=this.createTile(this._wrapCoords(t),a(this._tileReady,this,t));this._initTile(o),this.createTile.length<2&&x(a(this._tileReady,this,t,null,o)),Z(o,i),this._tiles[n]={el:o,coords:t,current:!0},e.appendChild(o),this.fire("tileloadstart",{tile:o,coords:t})},_tileReady:function(t,e,i){e&&this.fire("tileerror",{error:e,tile:i,coords:t});var n=this._tileCoordsToKey(t);(i=this._tiles[n])&&(i.loaded=+new Date,this._map._fadeAnimated?(C(i.el,0),r(this._fadeFrame),this._fadeFrame=x(this._updateOpacity,this)):(i.active=!0,this._pruneTiles()),e||(M(i.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:i.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),b.ielt9||!this._map._fadeAnimated?x(this._pruneTiles,this):setTimeout(a(this._pruneTiles,this),250)))},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var e=new p(this._wrapX?H(t.x,this._wrapX):t.x,this._wrapY?H(t.y,this._wrapY):t.y);return e.z=t.z,e},_pxBoundsToTileRange:function(t){var e=this.getTileSize();return new f(t.min.unscaleBy(e).floor(),t.max.unscaleBy(e).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}});var Di=Ni.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1,referrerPolicy:!1},initialize:function(t,e){this._url=t,(e=c(this,e)).detectRetina&&b.retina&&0')}}catch(t){}return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}(),zt={_initContainer:function(){this._container=P("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(Wi.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var e=t._container=Vi("shape");M(e,"leaflet-vml-shape "+(this.options.className||"")),e.coordsize="1 1",t._path=Vi("path"),e.appendChild(t._path),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){var e=t._container;this._container.appendChild(e),t.options.interactive&&t.addInteractiveTarget(e)},_removePath:function(t){var e=t._container;T(e),t.removeInteractiveTarget(e),delete this._layers[h(t)]},_updateStyle:function(t){var e=t._stroke,i=t._fill,n=t.options,o=t._container;o.stroked=!!n.stroke,o.filled=!!n.fill,n.stroke?(e=e||(t._stroke=Vi("stroke")),o.appendChild(e),e.weight=n.weight+"px",e.color=n.color,e.opacity=n.opacity,n.dashArray?e.dashStyle=d(n.dashArray)?n.dashArray.join(" "):n.dashArray.replace(/( *, *)/g," "):e.dashStyle="",e.endcap=n.lineCap.replace("butt","flat"),e.joinstyle=n.lineJoin):e&&(o.removeChild(e),t._stroke=null),n.fill?(i=i||(t._fill=Vi("fill")),o.appendChild(i),i.color=n.fillColor||n.color,i.opacity=n.fillOpacity):i&&(o.removeChild(i),t._fill=null)},_updateCircle:function(t){var e=t._point.round(),i=Math.round(t._radius),n=Math.round(t._radiusY||i);this._setPath(t,t._empty()?"M0 0":"AL "+e.x+","+e.y+" "+i+","+n+" 0,23592600")},_setPath:function(t,e){t._path.v=e},_bringToFront:function(t){fe(t._container)},_bringToBack:function(t){ge(t._container)}},qi=b.vml?Vi:ct,Gi=Wi.extend({_initContainer:function(){this._container=qi("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=qi("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){T(this._container),k(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_update:function(){var t,e,i;this._map._animatingZoom&&this._bounds||(Wi.prototype._update.call(this),e=(t=this._bounds).getSize(),i=this._container,this._svgSize&&this._svgSize.equals(e)||(this._svgSize=e,i.setAttribute("width",e.x),i.setAttribute("height",e.y)),Z(i,t.min),i.setAttribute("viewBox",[t.min.x,t.min.y,e.x,e.y].join(" ")),this.fire("update"))},_initPath:function(t){var e=t._path=qi("path");t.options.className&&M(e,t.options.className),t.options.interactive&&M(e,"leaflet-interactive"),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){T(t._path),t.removeInteractiveTarget(t._path),delete this._layers[h(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var e=t._path,t=t.options;e&&(t.stroke?(e.setAttribute("stroke",t.color),e.setAttribute("stroke-opacity",t.opacity),e.setAttribute("stroke-width",t.weight),e.setAttribute("stroke-linecap",t.lineCap),e.setAttribute("stroke-linejoin",t.lineJoin),t.dashArray?e.setAttribute("stroke-dasharray",t.dashArray):e.removeAttribute("stroke-dasharray"),t.dashOffset?e.setAttribute("stroke-dashoffset",t.dashOffset):e.removeAttribute("stroke-dashoffset")):e.setAttribute("stroke","none"),t.fill?(e.setAttribute("fill",t.fillColor||t.color),e.setAttribute("fill-opacity",t.fillOpacity),e.setAttribute("fill-rule",t.fillRule||"evenodd")):e.setAttribute("fill","none"))},_updatePoly:function(t,e){this._setPath(t,dt(t._parts,e))},_updateCircle:function(t){var e=t._point,i=Math.max(Math.round(t._radius),1),n="a"+i+","+(Math.max(Math.round(t._radiusY),1)||i)+" 0 1,0 ",e=t._empty()?"M0 0":"M"+(e.x-i)+","+e.y+n+2*i+",0 "+n+2*-i+",0 ";this._setPath(t,e)},_setPath:function(t,e){t._path.setAttribute("d",e)},_bringToFront:function(t){fe(t._path)},_bringToBack:function(t){ge(t._path)}});function Ki(t){return b.svg||b.vml?new Gi(t):null}b.vml&&Gi.include(zt),A.include({getRenderer:function(t){t=(t=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer)||(this._renderer=this._createRenderer());return this.hasLayer(t)||this.addLayer(t),t},_getPaneRenderer:function(t){var e;return"overlayPane"!==t&&void 0!==t&&(void 0===(e=this._paneRenderers[t])&&(e=this._createRenderer({pane:t}),this._paneRenderers[t]=e),e)},_createRenderer:function(t){return this.options.preferCanvas&&Ui(t)||Ki(t)}});var Yi=xi.extend({initialize:function(t,e){xi.prototype.initialize.call(this,this._boundsToLatLngs(t),e)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return[(t=g(t)).getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});Gi.create=qi,Gi.pointsToPath=dt,wi.geometryToLayer=bi,wi.coordsToLatLng=Li,wi.coordsToLatLngs=Ti,wi.latLngToCoords=Mi,wi.latLngsToCoords=zi,wi.getFeature=Ci,wi.asFeature=Zi,A.mergeOptions({boxZoom:!0});var _t=n.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){S(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){k(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){T(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){0!==this._resetStateTimeout&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){if(!t.shiftKey||1!==t.which&&1!==t.button)return!1;this._clearDeferredResetState(),this._resetState(),re(),Le(),this._startPoint=this._map.mouseEventToContainerPoint(t),S(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=P("div","leaflet-zoom-box",this._container),M(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var t=new f(this._point,this._startPoint),e=t.getSize();Z(this._box,t.min),this._box.style.width=e.x+"px",this._box.style.height=e.y+"px"},_finish:function(){this._moved&&(T(this._box),z(this._container,"leaflet-crosshair")),ae(),Te(),k(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){1!==t.which&&1!==t.button||(this._finish(),this._moved&&(this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(a(this._resetState,this),0),t=new s(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point)),this._map.fitBounds(t).fire("boxzoomend",{boxZoomBounds:t})))},_onKeyDown:function(t){27===t.keyCode&&(this._finish(),this._clearDeferredResetState(),this._resetState())}}),Ct=(A.addInitHook("addHandler","boxZoom",_t),A.mergeOptions({doubleClickZoom:!0}),n.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var e=this._map,i=e.getZoom(),n=e.options.zoomDelta,i=t.originalEvent.shiftKey?i-n:i+n;"center"===e.options.doubleClickZoom?e.setZoom(i):e.setZoomAround(t.containerPoint,i)}})),Zt=(A.addInitHook("addHandler","doubleClickZoom",Ct),A.mergeOptions({dragging:!0,inertia:!0,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0}),n.extend({addHooks:function(){var t;this._draggable||(t=this._map,this._draggable=new Xe(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))),M(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){z(this._map._container,"leaflet-grab"),z(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t,e=this._map;e._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity?(t=g(this._map.options.maxBounds),this._offsetLimit=_(this._map.latLngToContainerPoint(t.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(t.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))):this._offsetLimit=null,e.fire("movestart").fire("dragstart"),e.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){var e,i;this._map.options.inertia&&(e=this._lastTime=+new Date,i=this._lastPos=this._draggable._absPos||this._draggable._newPos,this._positions.push(i),this._times.push(e),this._prunePositions(e)),this._map.fire("move",t).fire("drag",t)},_prunePositions:function(t){for(;1e.max.x&&(t.x=this._viscousLimit(t.x,e.max.x)),t.y>e.max.y&&(t.y=this._viscousLimit(t.y,e.max.y)),this._draggable._newPos=this._draggable._startPos.add(t))},_onPreDragWrap:function(){var t=this._worldWidth,e=Math.round(t/2),i=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-e+i)%t+e-i,n=(n+e+i)%t-e-i,t=Math.abs(o+i)e.getMaxZoom()&&1=0.27,<1 +httpx==0.28.1 +ruff==0.16.6 diff --git a/requirements.txt b/requirements.txt index 1e4aa82..008b94e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,7 @@ -fastapi -pydantic -python-multipart -sqlalchemy -uvicorn[standard] +fastapi==0.141.1 +pydantic==2.13.5 +python-multipart==0.0.32 +sqlalchemy==2.0.52 +uvicorn[standard]==0.52.4 +Pillow==12.3.0 +psycopg[binary]==3.3.3 diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..ab33902 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,13 @@ +target-version = "py312" +line-length = 100 +exclude = ["public/static/vendor"] + +[lint] +select = ["E4", "E7", "E9", "F", "I", "B", "UP"] + +[lint.flake8-bugbear] +extend-immutable-calls = ["fastapi.Depends", "fastapi.File", "fastapi.Form", "fastapi.Query"] + +# Test database selection must happen before the application imports its engine. +[lint.per-file-ignores] +"tests/test_api.py" = ["E402"] diff --git a/schemas.py b/schemas.py index 416fae5..69defb3 100644 --- a/schemas.py +++ b/schemas.py @@ -1,11 +1,11 @@ +from datetime import datetime from typing import Literal -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator class AssetCreate(BaseModel): - """Validated payload for registering an infrastructure asset.""" - + model_config = ConfigDict(extra="forbid", allow_inf_nan=False) name: str = Field(min_length=1, max_length=120) asset_type: Literal["Bridge", "Road", "Flyover"] construction_year: int = Field(gt=0) @@ -14,8 +14,54 @@ class AssetCreate(BaseModel): @field_validator("name") @classmethod - def normalize_name(cls, value: str) -> str: - normalized = value.strip() - if not normalized: - raise ValueError("name must contain visible characters") - return normalized + def normalize_name(cls, value): + if not value.strip(): + raise ValueError("Enter a visible name.") + return value.strip() + + @field_validator("construction_year") + @classmethod + def validate_year(cls, value): + if value > datetime.now().year: + raise ValueError("Construction year cannot be in the future.") + return value + + +class Credentials(BaseModel): + username: str = Field(min_length=3, max_length=32, pattern=r"^[a-zA-Z0-9_-]+$") + password: str = Field(min_length=10, max_length=128) + + @field_validator("username") + @classmethod + def normalize_username(cls, value): + return value.lower() + + +class Registration(Credentials): + name: str = Field(min_length=1, max_length=80) + + @field_validator("name") + @classmethod + def normalize_name(cls, value): + if not value.strip(): + raise ValueError("Enter a workspace name.") + return value.strip() + + +class Note(BaseModel): + note: str = Field(min_length=5, max_length=1000) + + @field_validator("note") + @classmethod + def normalize_note(cls, value): + if len(value.strip()) < 5: + raise ValueError("Describe the work in at least five characters.") + return value.strip() + + +class Scenario(BaseModel): + active: bool + + +class ArchiveState(BaseModel): + archived: bool diff --git a/scoring.py b/scoring.py index a8b27ce..ac61307 100644 --- a/scoring.py +++ b/scoring.py @@ -1,5 +1,4 @@ from datetime import datetime -from typing import Optional, Tuple def maintenance_priority(health_score: float) -> str: @@ -15,8 +14,8 @@ def calculate_health( asset_type: str, construction_year: int, *, - current_year: Optional[int] = None, -) -> Tuple[int, float, str]: + current_year: int | None = None, +) -> tuple[int, float, str]: """Calculate age, health score, and priority for a new asset.""" year = current_year or datetime.now().year if construction_year <= 0 or construction_year > year: diff --git a/security.py b/security.py new file mode 100644 index 0000000..81fe2a9 --- /dev/null +++ b/security.py @@ -0,0 +1,29 @@ +"""Password hashing and opaque server-side sessions.""" + +import hashlib +import secrets + + +def hash_password(password: str) -> str: + salt = secrets.token_hex(16) + digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), 600_000) + return f"pbkdf2_sha256$600000${salt}${digest.hex()}" + + +def check_password(password: str, encoded: str | None) -> bool: + if not encoded: + return False + try: + algorithm, iterations, salt, expected = encoded.split("$") + if algorithm != "pbkdf2_sha256": + return False + actual = hashlib.pbkdf2_hmac( + "sha256", password.encode(), bytes.fromhex(salt), int(iterations) + ) + return secrets.compare_digest(actual.hex(), expected) + except (ValueError, TypeError): + return False + + +def token_hash(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() diff --git a/seed.py b/seed.py new file mode 100644 index 0000000..434bc0f --- /dev/null +++ b/seed.py @@ -0,0 +1,35 @@ +"""Illustrative Chennai assets, never live infrastructure assessments.""" + +import database + +EXAMPLES = [ + ("Adyar Bridge", "Bridge", 1970, 13.0067, 80.2595, 72.0), + ("Napier Bridge", "Bridge", 1869, 13.0694, 80.2824, 92.5), + ("Ennore Creek Bridge", "Bridge", 2005, 13.2217, 80.3222, 89.5), + ("Anna Flyover", "Flyover", 1973, 13.0500, 80.2500, 73.5), + ("Kathipara Junction", "Flyover", 2008, 13.0067, 80.2050, 91.0), + ("T. Nagar Skywalk", "Flyover", 2022, 13.0333, 80.2333, 98.0), + ("OMR IT Expressway", "Road", 2006, 12.9228, 80.2316, 40.0), + ("East Coast Road", "Road", 1998, 12.8491, 80.2433, 35.0), + ("Mount Road", "Road", 1950, 13.0600, 80.2500, 25.0), +] + + +def seed_workspace(db, workspace): + added = 0 + for name, kind, year, lat, lng, score in EXAMPLES: + exists = db.query(database.Asset).filter_by(workspace_id=workspace.id, name=name).first() + if not exists: + db.add( + database.Asset( + workspace_id=workspace.id, + name=name, + asset_type=kind, + construction_year=year, + latitude=lat, + longitude=lng, + condition_score=score, + ) + ) + added += 1 + return added diff --git a/tests-web/utils.test.mjs b/tests-web/utils.test.mjs new file mode 100644 index 0000000..4710c44 --- /dev/null +++ b/tests-web/utils.test.mjs @@ -0,0 +1,8 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { priority, summary, filterAssets, date } from '../public/static/utils.js'; +const assets = [{id:1,name:'Adyar Bridge',asset_type:'Bridge',health_score:70},{id:2,name:'Mount Road',asset_type:'Road',health_score:39.9},{id:3,name:'East Coast Road',asset_type:'Road',health_score:40}]; +test('dashboard bands match documented boundary values', () => { assert.equal(priority(39.9),'Emergency'); assert.equal(priority(40),'High'); assert.equal(priority(69.9),'High'); assert.equal(priority(70),'Low'); }); +test('summaries use all assets and only open reports', () => { assert.deepEqual(summary([],[]),{total:0,average:0,attention:0,open:0}); const s=summary(assets,[{status:'Open'},{status:'Resolved'}]); assert.equal(s.total,3); assert.equal(s.attention,2); assert.equal(s.open,1); assert.ok(Math.abs(s.average-49.96666666)<1e-6); }); +test('combined search, asset type, and priority filters select matching rows', () => { assert.deepEqual(filterAssets(assets,' ROAD ','Road','High').map(a=>a.id),[3]); assert.deepEqual(filterAssets(assets,'1').map(a=>a.id),[1]); assert.equal(filterAssets(assets,'missing').length,0); }); +test('dates do not manufacture missing maintenance records', () => { assert.equal(date(null),'Not recorded'); assert.equal(date('invalid'),'Not recorded'); }); diff --git a/tests/test_api.py b/tests/test_api.py index 321ce7b..cf983c9 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,61 +1,350 @@ +import csv +import io import os import tempfile import unittest +from unittest.mock import patch -# Set before importing the application: startup must not open the demo database. _test_database = tempfile.TemporaryDirectory() -os.environ["DATABASE_URL"] = "sqlite:///" + _test_database.name + "/test.db" +os.environ["DATABASE_URL"] = ( + os.getenv("TEST_DATABASE_URL") or "sqlite:///" + _test_database.name + "/test.db" +) from fastapi.testclient import TestClient +from PIL import Image + import database -from main import app +from main import COOKIE, app +from security import check_password + +HEADERS = {"X-Requested-With": "StructIQ"} +ASSET = { + "name": "Test bridge", + "asset_type": "Bridge", + "construction_year": 2000, + "latitude": 13, + "longitude": 80, +} + + +def photo(): + buffer = io.BytesIO() + Image.new("RGB", (40, 40), "gray").save(buffer, "PNG") + return buffer.getvalue() class ApiTests(unittest.TestCase): def setUp(self): database.Base.metadata.drop_all(database.engine) database.Base.metadata.create_all(database.engine) - self.client = TestClient(app) - self.asset = self.client.post("/assets", json={ - "name": "Test bridge", "asset_type": "Bridge", "construction_year": 2000, - "latitude": 13, "longitude": 80, - }).json() + self.client = TestClient(app, headers=HEADERS) + self.other = TestClient(app, headers=HEADERS) + self.account = self.client.post("/api/auth/demo").json() + self.other_account = self.other.post("/api/auth/demo").json() + result = self.client.post("/api/assets", json=ASSET) + self.assertEqual(result.status_code, 201, result.text) + self.asset = result.json() + + def tearDown(self): + self.client.close() + self.other.close() def upload(self, **kwargs): - return self.client.post("/reports/upload-ai", data={ - "asset_id": self.asset["id"], "description": kwargs.pop("description", "Surface crack"), - }, files={"file": kwargs.pop("file", ("download.png", b"demo-image", "image/png"))}) + return self.client.post( + "/api/reports", + data={ + "asset_id": kwargs.pop("asset_id", self.asset["id"]), + "description": kwargs.pop("description", "Surface crack requiring inspection"), + "severity": kwargs.pop("severity", "10"), + }, + files={"file": kwargs.pop("file", ("evidence.png", photo(), "image/png"))}, + ) + + def health(self): + return next( + a for a in self.client.get("/api/assets").json() if a["id"] == self.asset["id"] + )["health_score"] - def test_resolution_is_retained_and_idempotent(self): + def test_workspace_isolation_and_anonymous_access(self): + self.assertEqual(len(self.client.get("/api/assets").json()), 10) + self.assertEqual(len(self.other.get("/api/assets").json()), 9) + stranger = TestClient(app, headers=HEADERS) + self.assertEqual(stranger.get("/api/assets").status_code, 401) + self.assertEqual(stranger.post("/api/assets", json=ASSET).status_code, 401) + self.assertEqual( + self.other.put(f"/api/assets/{self.asset['id']}", json=ASSET).status_code, 404 + ) + self.assertEqual( + self.other.post( + f"/api/assets/{self.asset['id']}/maintenance", json={"note": "Repair completed"} + ).status_code, + 404, + ) + self.assertEqual( + self.other.patch( + f"/api/assets/{self.asset['id']}/archive", json={"archived": True} + ).status_code, + 404, + ) + + def test_registration_password_hash_login_and_logout(self): + credentials = {"username": "TestOperator", "password": "a long unique passphrase"} + response = self.client.post( + "/api/auth/register", json={**credentials, "name": "New workspace"} + ) + self.assertEqual(response.status_code, 200, response.text) + with database.SessionLocal() as db: + account = db.query(database.Workspace).filter_by(username="testoperator").one() + self.assertNotIn(credentials["password"], account.password_hash) + self.assertTrue(check_password(credentials["password"], account.password_hash)) + self.assertEqual(self.client.post("/api/auth/logout").status_code, 200) + self.assertEqual(self.client.get("/api/auth/me").status_code, 401) + self.assertEqual( + self.client.post( + "/api/auth/login", json={**credentials, "password": "wrong password"} + ).status_code, + 401, + ) + self.assertEqual(self.client.post("/api/auth/login", json=credentials).status_code, 200) + self.assertEqual(self.client.get("/api/auth/me").json()["name"], "New workspace") + duplicate = self.other.post("/api/auth/register", json={**credentials, "name": "Duplicate"}) + self.assertEqual(duplicate.status_code, 409) + + def test_cookie_security_and_csrf_header(self): + cookie = self.client.cookies[COOKIE] + session_header = self.client.post("/api/auth/demo").headers["set-cookie"] + self.assertIn("HttpOnly", session_header) + self.assertIn("SameSite=lax", session_header) + plain = TestClient(app) + plain.cookies.set(COOKIE, cookie) + self.assertEqual(plain.post("/api/assets", json=ASSET).status_code, 403) + with patch.dict(os.environ, {"VERCEL": "1"}): + response = self.other.post("/api/auth/demo") + self.assertIn("Secure", response.headers["set-cookie"]) + + def test_resolution_preserves_history_and_never_inflates_score(self): response = self.upload() - self.assertEqual(response.status_code, 200) - self.assertTrue(response.json()["simulated"]) - report_id = response.json()["report_id"] - route = f"/reports/{report_id}/resolve" - self.assertFalse(self.client.post(route).json()["already_resolved"]) - health = self.client.get("/assets").json()[0]["health_score"] - self.assertTrue(self.client.post(route).json()["already_resolved"]) - self.assertEqual(self.client.get("/assets").json()[0]["health_score"], health) - self.assertEqual(self.client.get("/reports?status=Open").json(), []) - self.assertEqual(self.client.get("/reports").json()[0]["status"], "Resolved") - - def test_rejects_invalid_uploads_without_changing_health(self): - for kwargs, status in [ + self.assertEqual(response.status_code, 201, response.text) + report_id = response.json()["id"] + self.assertLess(self.health(), self.asset["health_score"]) + route = f"/api/reports/{report_id}/resolve" + self.assertFalse( + self.client.post(route, json={"note": "Repair inspected and confirmed"}).json()[ + "already_resolved" + ] + ) + restored = self.health() + self.assertEqual(restored, self.asset["health_score"]) + self.assertTrue( + self.client.post(route, json={"note": "Repeat resolution attempt"}).json()[ + "already_resolved" + ] + ) + self.assertEqual(self.health(), restored) + self.assertEqual(self.client.get("/api/reports?status=Open").json(), []) + history = self.client.get("/api/reports").json()[0] + self.assertEqual(history["status"], "Resolved") + self.assertEqual(history["resolution_note"], "Repair inspected and confirmed") + self.assertIsNotNone(history["resolved_at"]) + self.assertEqual( + len([e for e in self.client.get("/api/activity").json() if e["kind"] == "resolution"]), + 1, + ) + + def test_multiple_report_penalties_remain_until_each_resolution(self): + a, b = self.upload().json(), self.upload().json() + self.assertEqual(self.health(), self.asset["health_score"] - 30) + self.client.post(f"/api/reports/{a['id']}/resolve", json={"note": "First repair completed"}) + self.assertEqual(self.health(), self.asset["health_score"] - 15) + self.assertEqual( + self.other.post( + f"/api/reports/{b['id']}/resolve", json={"note": "Unauthorized resolution"} + ).status_code, + 404, + ) + self.assertEqual(self.other.get(f"/api/reports/{b['id']}/image").status_code, 404) + + def test_report_archive_and_restore(self): + report = self.upload().json() + endpoint = f"/api/assets/{self.asset['id']}/archive" + self.assertEqual(self.client.patch(endpoint, json={"archived": True}).status_code, 409) + self.client.post( + f"/api/reports/{report['id']}/resolve", json={"note": "Issue addressed and inspected"} + ) + self.assertEqual(self.client.patch(endpoint, json={"archived": True}).status_code, 200) + self.assertNotIn(self.asset["id"], [a["id"] for a in self.client.get("/api/assets").json()]) + self.assertEqual( + self.client.get("/api/assets?archived=true").json()[0]["id"], self.asset["id"] + ) + self.assertEqual(self.upload().status_code, 404) + self.client.patch(endpoint, json={"archived": False}) + self.assertIn(self.asset["id"], [a["id"] for a in self.client.get("/api/assets").json()]) + + def test_verified_image_and_rejected_invalid_uploads(self): + for kwargs, code in [ ({"description": " "}, 422), ({"file": ("test.txt", b"text", "text/plain")}, 415), ({"file": ("empty.png", b"", "image/png")}, 422), - ({"file": ("large.png", b"x" * (5 * 1024 * 1024 + 1), "image/png")}, 413), + ({"file": ("fake.png", b"not an image", "image/png")}, 422), + ({"file": ("large.png", b"x" * (3 * 1024 * 1024 + 1), "image/png")}, 413), + ({"severity": "30"}, 422), ]: - self.assertEqual(self.upload(**kwargs).status_code, status) - self.assertEqual(self.client.get("/reports").json(), []) - self.assertEqual(self.client.get("/assets").json()[0]["health_score"], self.asset["health_score"]) + response = self.upload(**kwargs) + self.assertEqual(response.status_code, code, response.text) + self.assertEqual(self.client.get("/api/reports").json(), []) + self.assertEqual(self.health(), self.asset["health_score"]) + report = self.upload().json() + image = self.client.get(f"/api/reports/{report['id']}/image") + self.assertEqual(image.status_code, 200) + self.assertEqual(image.headers["content-type"], "image/jpeg") + with Image.open(io.BytesIO(image.content)) as decoded: + self.assertEqual(decoded.size, (40, 40)) + self.assertFalse(decoded.getexif()) + + def test_public_report_is_scoped_and_tracking_does_not_leak_details(self): + public = TestClient(app, headers=HEADERS) + token = self.account["share_token"] + public_assets = public.get(f"/api/public/{token}/assets").json() + self.assertEqual(set(public_assets["assets"][0]), {"id", "name", "asset_type"}) + response = public.post( + f"/api/public/{token}/reports", + data={ + "asset_id": self.asset["id"], + "description": "Private observation", + "severity": "5", + }, + ) + self.assertEqual(response.status_code, 201, response.text) + report = response.json() + tracking = public.get(f"/api/track/{report['tracking_code']}").json() + self.assertEqual(tracking["status"], "Open") + self.assertNotIn("description", tracking) + self.assertNotIn("image", tracking) + self.assertNotIn("username", tracking) + foreign = self.other.get("/api/assets").json()[0]["id"] + self.assertEqual( + public.post( + f"/api/public/{token}/reports", + data={"asset_id": foreign, "description": "Wrong workspace", "severity": "5"}, + ).status_code, + 404, + ) + self.assertEqual(public.get("/api/public/not-a-token/assets").status_code, 404) + + def test_flood_is_reversible_idempotent_and_workspace_scoped(self): + before = {a["id"]: a["health_score"] for a in self.client.get("/api/assets").json()} + second_before = self.other.get("/api/assets").json() + endpoint = "/api/scenarios/flood" + self.assertTrue(self.client.post(endpoint, json={"active": True}).json()["changed"]) + first = self.client.get("/api/assets").json() + self.assertFalse(self.client.post(endpoint, json={"active": True}).json()["changed"]) + self.assertEqual(first, self.client.get("/api/assets").json()) + for asset in first: + self.assertEqual( + asset["health_score"], + max(0, before[asset["id"]] - (15 if asset["asset_type"] == "Road" else 0)), + ) + self.client.post(endpoint, json={"active": False}) + self.assertEqual( + before, {a["id"]: a["health_score"] for a in self.client.get("/api/assets").json()} + ) + self.assertEqual(second_before, self.other.get("/api/assets").json()) - def test_seeding_is_post_only_and_repeatable(self): + def test_maintenance_records_note_and_keeps_open_report_penalty(self): + self.upload() + endpoint = f"/api/assets/{self.asset['id']}/maintenance" + response = self.client.post(endpoint, json={"note": "Repaired deck expansion joints"}) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["condition_score"], 100) + self.assertEqual(response.json()["health_score"], 85) + self.assertTrue(response.json()["last_service_at"]) + self.assertIn( + "Repaired deck expansion joints", self.client.get("/api/activity").json()[0]["message"] + ) + self.assertEqual(self.client.post(endpoint, json={"note": " "}).status_code, 422) + + def test_edit_asset_validation_seed_and_filters(self): + endpoint = f"/api/assets/{self.asset['id']}" + self.assertEqual( + self.client.put(endpoint, json={**ASSET, "name": "New name", "latitude": 22}).json()[ + "latitude" + ], + 22, + ) + self.assertEqual( + self.client.put(endpoint, json={**ASSET, "construction_year": 9999}).status_code, 422 + ) + self.assertEqual(self.client.post("/api/setup-demo").json()["added"], 0) + self.assertEqual(self.client.post("/api/setup-demo").json()["added"], 0) + self.assertEqual(self.client.get("/api/reports?status=bad").status_code, 422) self.assertEqual(self.client.get("/setup-demo").status_code, 405) - self.assertEqual(self.client.post("/setup-demo").status_code, 200) - self.client.post("/setup-demo") - self.assertEqual(len(self.client.get("/assets").json()), 10) - def test_invalid_report_filter_and_missing_report(self): - self.assertEqual(self.client.get("/reports?status=invalid").status_code, 422) - self.assertEqual(self.client.post("/reports/999/resolve").status_code, 404) + def test_export_is_scoped_and_protects_spreadsheet_formulas(self): + self.client.put( + f"/api/assets/{self.asset['id']}", + json={**ASSET, "name": '=HYPERLINK("https://example.com")'}, + ) + response = self.client.get("/api/export") + self.assertEqual(response.status_code, 200) + rows = list(csv.reader(io.StringIO(response.text))) + self.assertEqual(len(rows), 11) + self.assertTrue(rows[-1][1].startswith("'=")) + self.assertNotIn(self.other_account["share_token"], response.text) + + def test_session_revocation_expiry_and_persistence_across_clients(self): + resumed = TestClient(app, headers=HEADERS) + resumed.cookies.update(self.client.cookies) + self.assertEqual(len(resumed.get("/api/assets").json()), 10) + resumed.post("/api/auth/logout") + self.assertEqual(self.client.get("/api/assets").status_code, 401) + with database.SessionLocal() as db: + db.query(database.LoginSession).update({"expires_at": database.utcnow()}) + db.commit() + self.assertEqual(self.other.get("/api/auth/me").status_code, 401) + + def test_auth_request_limit(self): + for _ in range(15): + self.assertEqual( + self.client.post( + "/api/auth/login", + json={"username": "missing", "password": "incorrect password"}, + ).status_code, + 401, + ) + response = self.client.post( + "/api/auth/login", json={"username": "missing", "password": "incorrect password"} + ) + self.assertEqual(response.status_code, 429) + self.assertIn("retry-after", response.headers) + + def test_expired_demo_is_inaccessible_and_cleaned_up(self): + from datetime import timedelta + + with database.SessionLocal() as db: + db.query(database.Workspace).filter_by(share_token=self.account["share_token"]).update( + {"created_at": database.utcnow() - timedelta(days=8)} + ) + db.commit() + self.assertEqual(self.client.get("/api/auth/me").status_code, 401) + self.assertEqual( + self.client.get(f"/api/public/{self.account['share_token']}/assets").status_code, 404 + ) + self.client.post("/api/auth/demo") + with database.SessionLocal() as db: + self.assertIsNone( + db.query(database.Workspace) + .filter_by(share_token=self.account["share_token"]) + .first() + ) + + def test_pages_health_and_secret_files_are_not_served(self): + self.assertEqual(self.client.get("/api/health").json()["status"], "ok") + for path in ["/", "/app", "/report/example", "/track/example"]: + self.assertEqual(self.client.get(path).status_code, 200) + self.assertEqual( + self.client.get("/auth.html", follow_redirects=False).headers["location"], "/app" + ) + self.assertEqual(self.client.get("/database.py").status_code, 404) + self.assertEqual(self.client.get("/structiq.db").status_code, 404) + self.assertEqual(self.client.get("/.env").status_code, 404) + self.assertEqual(self.client.get("/api/assets").headers["cache-control"], "no-store") diff --git a/tests/test_schemas.py b/tests/test_schemas.py index fc38d65..76a8a5b 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -4,7 +4,6 @@ from schemas import AssetCreate - VALID_ASSET = { "name": "Adyar Bridge", "asset_type": "Bridge", diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..f539b60 --- /dev/null +++ b/vercel.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "fastapi", + "regions": ["sin1"] +}