Compare commits
54 Commits
14856e61ef
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f582a8a17 | |||
| 184f0ddd13 | |||
| e7bd09418b | |||
| 27eeb0fae6 | |||
| 192e15f238 | |||
| 49bc625c1a | |||
| 95fedca8c9 | |||
| e8e155556a | |||
| 33e962e73d | |||
| ac48fb2977 | |||
| 5e9cc32fdc | |||
| 3c4b81cf78 | |||
| d135727ebd | |||
| 64d4423308 | |||
| 4f71d528ce | |||
| 4f56dea4f3 | |||
| 40359db066 | |||
| 57a85f565b | |||
|
|
e6555ba924 | ||
| 3d5b2fddef | |||
| 8694282dd0 | |||
| bc02dc9564 | |||
| 0d01715f81 | |||
| b3ec249c5e | |||
| b6e74258f1 | |||
| 5ea64c3561 | |||
| 1a87ff13c9 | |||
| 22c62c0729 | |||
| 0f47b69c92 | |||
| 76667454b3 | |||
| 0e3f512203 | |||
|
|
15d962ba42 | ||
| e4d1f0d684 | |||
|
|
b571dc29bc | ||
|
|
e2c841d5d7 | ||
| cc94493331 | |||
|
|
5a5426cceb | ||
|
|
66eddd6fe2 | ||
|
|
c77794787c | ||
| 61c84bc71d | |||
| fbf7f2a65d | |||
|
|
202fcaf91c | ||
|
|
3a411d0a89 | ||
| 0c2186f5d8 | |||
| c138e8c6a0 | |||
| 1dd396acd8 | |||
| e89a04f58c | |||
| e4ef065db8 | |||
| 86010de60c | |||
| f89f04cd6f | |||
| 67a2faa2d3 | |||
| b15d434fce | |||
| 7b4e12c127 | |||
| 742a98a8ed |
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
# Terra-View Specifics
|
# Terra-View Specifics
|
||||||
# Dev build counter (local only, never commit)
|
# Dev build counter (local only, never commit)
|
||||||
build_number.txt
|
build_number.txt
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
# SQLite database files
|
# SQLite database files
|
||||||
*.db
|
*.db
|
||||||
@@ -9,7 +10,7 @@ data/
|
|||||||
data-dev/
|
data-dev/
|
||||||
.aider*
|
.aider*
|
||||||
.aider*
|
.aider*
|
||||||
docker-compose.override.yml
|
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
@@ -219,3 +220,14 @@ marimo/_static/
|
|||||||
marimo/_lsp/
|
marimo/_lsp/
|
||||||
__marimo__/
|
__marimo__/
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
# Seismo Fleet Manager
|
||||||
|
# SQLite database files
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
/data/
|
||||||
|
/data-dev/
|
||||||
|
.aider*
|
||||||
|
.aider*
|
||||||
|
=======
|
||||||
|
>>>>>>> 0c2186f5d89d948b0357d674c0773a67a67d8027
|
||||||
|
|||||||
197
CHANGELOG.md
197
CHANGELOG.md
@@ -5,6 +5,202 @@ All notable changes to Terra-View will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.9.3] - 2026-03-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Monitoring Session Detail Page**: New dedicated page for each session showing session info, data files (with View/Report/Download actions), an editable session panel, and report actions.
|
||||||
|
- **Session Calendar with Gantt Bars**: Monthly calendar view below the session list, showing each session as a Gantt-style bar. The dim bar represents the full device on/off window; the bright bar highlights the effective recording window. Bars extend edge-to-edge across day cells for sessions spanning midnight.
|
||||||
|
- **Configurable Period Windows**: Sessions now store `period_start_hour` and `period_end_hour` to define the exact hours that count toward reports, replacing hardcoded day/night defaults. The session edit panel shows a "Required Recording Window" section with a live preview (e.g. "7:00 AM → 7:00 PM") and a Defaults button that auto-fills based on period type.
|
||||||
|
- **Report Date Field**: Sessions can now store an explicit `report_date` to override the automatic target-date heuristic — useful when a device ran across multiple days but only one specific day's data is needed for the report.
|
||||||
|
- **Effective Window on Session Info**: Session detail and session cards now show an "Effective" row displaying the computed recording window dates and times in local time.
|
||||||
|
- **Vibration Project Redesign**: Vibration project detail page is stripped back to project details and monitoring locations only. Each location supports assigning a seismograph and optional modem. Sound-specific tabs (Schedules, Sessions, Data Files, Assigned Units) are hidden for vibration projects.
|
||||||
|
- **Modem Assignment on Locations**: Vibration monitoring locations now support an optional paired modem alongside the seismograph. The swap endpoint handles both assignments atomically, updating bidirectional pairing fields on both units.
|
||||||
|
- **Available Modems Endpoint**: New `GET /api/projects/{project_id}/available-modems` endpoint returning all deployed, non-retired modems for use in assignment dropdowns.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Active Assignment Checks**: Unified all `UnitAssignment` "active" checks from `status == "active"` to `assigned_until IS NULL` throughout `project_locations.py` and `projects.py` for consistency with the canonical active definition.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Sound-Only Endpoint Guards**: FTP browser, RND viewer, Excel report generation, combined report wizard, and data upload endpoints now return HTTP 400 if called on a non-sound-monitoring project.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
Run on each database before deploying:
|
||||||
|
```bash
|
||||||
|
docker compose exec terra-view python3 backend/migrate_add_session_period_hours.py
|
||||||
|
docker compose exec terra-view python3 backend/migrate_add_session_report_date.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.9.2] - 2026-03-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Deployment Records**: Seismographs now track a full deployment history (location, project, dates). Each deployment is logged on the unit detail page with start/end dates, and the fleet calendar service uses this history for availability calculations.
|
||||||
|
- **Allocated Unit Status**: New `allocated` status for units reserved for an upcoming job but not yet deployed. Allocated units appear in the dashboard summary, roster filters, and devices table with visual indicators.
|
||||||
|
- **Project Allocation**: Units can be linked to a project via `allocated_to_project_id`. Allocation is shown on the unit detail page and in a new quick-info modal accessible from the fleet calendar and roster.
|
||||||
|
- **Quick-Info Unit Modal**: Click any unit in the fleet calendar or roster to open a modal showing cal status, project allocation, upcoming jobs, and deployment state — without leaving the page.
|
||||||
|
- **Cal Date in Planner**: When a unit is selected for a monitoring location slot in the Job Planner, its calibration expiry date is now shown inline so you can spot near-expiry units before committing.
|
||||||
|
- **Inline Seismograph Editing**: Unit rows in the seismograph dashboard now support inline editing of cal date, notes, and deployment status without navigating to the full detail page.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
Run on each database before deploying:
|
||||||
|
```bash
|
||||||
|
docker compose exec terra-view python3 backend/migrate_add_allocated.py
|
||||||
|
docker compose exec terra-view python3 backend/migrate_add_deployment_records.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.9.1] - 2026-03-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Location slots not persisting**: Empty monitoring location slots (no unit assigned yet) were lost on save/reload. Added `location_slots` JSON column to `job_reservations` to store the full slot list including empty slots.
|
||||||
|
- **Modems in Recent Alerts**: Modems no longer appear in the dashboard Recent Alerts panel — alerts are for seismographs and SLMs only. Modem status is still tracked internally via paired device inheritance.
|
||||||
|
- **Series 4 heartbeat `source_id`**: Updated heartbeat endpoint to accept the new `source_id` field from Series 4 units with fallback to the legacy field for backwards compatibility.
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
Run on each database before deploying:
|
||||||
|
```bash
|
||||||
|
docker compose exec terra-view python3 backend/migrate_add_location_slots.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.9.0] - 2026-03-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Job Planner**: Full redesign of the Fleet Calendar into a two-tab Job Planner / Calendar interface
|
||||||
|
- **Planner tab**: Create and manage job reservations with name, device type, dates, color, estimated units, and monitoring locations
|
||||||
|
- **Calendar tab**: 12-month rolling heatmap with colored job bars per day; confirmed jobs solid, planned jobs dashed
|
||||||
|
- **Monitoring Locations**: Each job has named location slots (filled = unit assigned, empty = needs a unit); progress shown as `2/5` with colored squares that fill as units are assigned
|
||||||
|
- **Estimated Units**: Separate planning number independent of actual location count; shown prominently on job cards
|
||||||
|
- **Fleet Summary panel**: Unit counts as clickable filter buttons; unit list shows reservation badges with job name, dates, and color
|
||||||
|
- **Available Units panel**: Shows units available for the job's date range when assigning
|
||||||
|
- **Smart color picker**: 18-swatch palette + custom color wheel; new jobs auto-pick a color maximally distant in hue from existing jobs
|
||||||
|
- **Job card progress**: `est. N · X/Y (Z more)` with filled/empty squares; amber → green when fully assigned
|
||||||
|
- **Promote to Project**: Promote a planned job to a tracked project directly from the planner form
|
||||||
|
- **Collapsible job details**: Name, dates, device type, color, project link, and estimated units collapse into a summary header
|
||||||
|
- **Calendar bar tooltips**: Hover any job bar to see job name and date range
|
||||||
|
- **Hash-based tab persistence**: `#cal` in URL restores Calendar tab on refresh; device type toggle preserves active tab
|
||||||
|
- **Auto-scroll to today**: Switching to Calendar tab smooth-scrolls to the current month
|
||||||
|
- **Upcoming project status**: New `upcoming` status for projects promoted from reservations
|
||||||
|
- **Job device type**: Reservations carry a device type so they only appear on the correct calendar
|
||||||
|
- **Project filtering by device type**: Projects only appear on the calendar matching their type (vibration → seismograph, sound → SLM, combined → both)
|
||||||
|
- **Confirmed/Planned toggles**: Independent show/hide toggles for job bar layers on the calendar
|
||||||
|
- **Cal expire dots toggle**: Calibration expiry dots off by default, togglable
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Renamed**: "Fleet Calendar" / "Reservation Planner" → **"Job Planner"** throughout UI and sidebar
|
||||||
|
- **Project status dropdown**: Inline `<select>` in project header for quick status changes
|
||||||
|
- **"All Projects" tab**: Shows everything except deleted; default view excludes archived/completed
|
||||||
|
- **Toast notifications**: All `alert()` dialogs replaced with non-blocking toasts (green = success, red = error)
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
Run on each database before deploying:
|
||||||
|
```bash
|
||||||
|
docker compose exec terra-view python3 -c "
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect('/app/data/seismo_fleet.db')
|
||||||
|
conn.execute('ALTER TABLE job_reservations ADD COLUMN estimated_units INTEGER')
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.8.0] - 2026-03-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Watcher Manager**: New admin page (`/admin/watchers`) for monitoring field watcher agents
|
||||||
|
- Live status cards per agent showing connectivity, version, IP, last-seen age, and log tail
|
||||||
|
- Trigger Update button to queue a self-update on the agent's next heartbeat
|
||||||
|
- Expand/collapse log tail with full-log expand mode
|
||||||
|
- Live surgical refresh every 30 seconds via `/api/admin/watchers` — no full page reload, open logs stay open
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Watcher status logic**: Agent status now reflects whether Terra-View is hearing from the watcher (ok if seen within 60 minutes, missing otherwise) — previously reflected the worst unit status from the last heartbeat payload, which caused false alarms when units went missing
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Watcher Manager meta row**: Dark mode background was white due to invalid `dark:bg-slate-850` Tailwind class; corrected to `dark:bg-slate-800`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.7.1] - 2026-03-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **"Out for Calibration" Unit Status**: New `out_for_cal` status for units currently away for calibration, with visual indicators in the roster, unit list, and seismograph stats panel
|
||||||
|
- **Reservation Modal**: Fleet calendar reservation modal is now fully functional for creating and managing device reservations
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Retire Unit Button**: Redesigned to be more visually prominent/destructive to reduce accidental clicks
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Migration Scripts**: Fixed database path references in several migration scripts
|
||||||
|
- **Docker Compose**: Removed dev override file from the repository; dev environment config kept separate
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
Run the following migration script once per database before deploying:
|
||||||
|
```bash
|
||||||
|
python backend/migrate_add_out_for_calibration.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.7.0] - 2026-03-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Project Status Management**: Projects can now be placed `on_hold` or `archived`, with automatic cancellation of pending scheduled actions
|
||||||
|
- **Hard Delete Projects**: Support for permanently deleting projects, in addition to soft-delete with auto-pruning
|
||||||
|
- **Vibration Location Detail**: New dedicated template for vibration project location detail views
|
||||||
|
- **Vibration Project Isolation**: Vibration projects no longer show SLM-specific project tabs
|
||||||
|
- **Manual SD Card Data Upload**: Upload offline NRL data directly from SD card via ZIP or multi-file select
|
||||||
|
- Accepts `.rnd`/`.rnh` files; parses `.rnh` metadata for session start/stop times, serial number, and store name
|
||||||
|
- Creates `MonitoringSession` and `DataFile` records automatically; no unit assignment required
|
||||||
|
- Upload panel on NRL detail Data Files tab with inline feedback and auto-refresh via HTMX
|
||||||
|
- **Standalone SLM Type**: New SLM device mode that operates without a modem (direct IP connection)
|
||||||
|
- **NL32 Data Support**: Report generator and web viewer now support NL32 measurement data format
|
||||||
|
- **Combined Report Wizard**: Multi-session combined Excel report generation tool
|
||||||
|
- Wizard UI grouped by location with period type badges (day/night)
|
||||||
|
- Each selected session produces one `.xlsx` in a ZIP archive
|
||||||
|
- Period type filtering: day sessions keep last calendar date (7AM–6:59PM); night sessions span both days (7PM–6:59AM)
|
||||||
|
- **Combined Report Preview**: Interactive spreadsheet-style preview before generating combined reports
|
||||||
|
- **Chart Preview**: Live chart preview in the report generator matching final report styling
|
||||||
|
- **SLM Model Schemas**: Per-model configuration schemas for NL32, NL43, NL53 devices
|
||||||
|
- **Data Collection Mode**: Projects now store a data collection mode field with UI controls and migration
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **MonitoringSession rename**: `RecordingSession` renamed to `MonitoringSession` throughout codebase; DB table renamed from `recording_sessions` to `monitoring_sessions`
|
||||||
|
- Migration: `backend/migrate_rename_recording_to_monitoring_sessions.py`
|
||||||
|
- **Combined Report Split Logic**: Separate days now generate separate `.xlsx` files; NRLs remain one per sheet
|
||||||
|
- **Mass Upload Parsing**: Smarter file filtering — no longer imports unneeded Lp files or `.xlsx` files
|
||||||
|
- **SLM Start Time Grace Period**: 15-minute grace window added so data starting at session start time is included
|
||||||
|
- **NL32 Date Parsing**: Date now read from `start_time` field instead of file metadata
|
||||||
|
- **Project Data Labels**: Improved Jinja filters and UI label clarity for project data views
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Dev/Prod Separation**: Dev server now uses Docker Compose override; production deployment no longer affected by dev config
|
||||||
|
- **SLM Modal**: Bench/deploy toggle now correctly shown in SLM unit modal
|
||||||
|
- **Auto-Downloaded Files**: Files downloaded by scheduler now appear in project file listings
|
||||||
|
- **Duplicate Download**: Removed duplicate file download that occurred following a scheduled stop
|
||||||
|
- **SLMM Environment Variables**: `TCP_IDLE_TTL` and `TCP_MAX_AGE` now correctly passed to SLMM service via docker-compose
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
- `session_label` and `period_type` stored on `monitoring_sessions` table (migration: `migrate_add_session_period_type.py`)
|
||||||
|
- `device_model` stored on `monitoring_sessions` table (migration: `migrate_add_session_device_model.py`)
|
||||||
|
- Upload endpoint: `POST /api/projects/{project_id}/nrl/{location_id}/upload-data`
|
||||||
|
- ZIP filename format: `{session_label}_{project_name}_report.xlsx` (label first)
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
Run the following migration scripts once per database before deploying:
|
||||||
|
```bash
|
||||||
|
python backend/migrate_rename_recording_to_monitoring_sessions.py
|
||||||
|
python backend/migrate_add_session_period_type.py
|
||||||
|
python backend/migrate_add_session_device_model.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.6.1] - 2026-02-16
|
## [0.6.1] - 2026-02-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -445,6 +641,7 @@ No database migration required for v0.4.0. All new features use existing databas
|
|||||||
- Photo management per unit
|
- Photo management per unit
|
||||||
- Automated status categorization (OK/Pending/Missing)
|
- Automated status categorization (OK/Pending/Missing)
|
||||||
|
|
||||||
|
[0.7.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.6.1...v0.7.0
|
||||||
[0.6.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.1...v0.6.0
|
[0.6.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.1...v0.6.0
|
||||||
[0.5.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.0...v0.5.1
|
[0.5.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.0...v0.5.1
|
||||||
[0.5.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.4...v0.5.0
|
[0.5.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.4...v0.5.0
|
||||||
|
|||||||
29
README.md
29
README.md
@@ -1,4 +1,4 @@
|
|||||||
# Terra-View v0.6.1
|
# Terra-View v0.9.3
|
||||||
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
@@ -496,6 +496,21 @@ docker compose down -v
|
|||||||
|
|
||||||
## Release Highlights
|
## Release Highlights
|
||||||
|
|
||||||
|
### v0.8.0 — 2026-03-18
|
||||||
|
- **Watcher Manager**: Admin page for monitoring field watcher agents with live status cards, log tails, and one-click update triggering
|
||||||
|
- **Watcher Status Fix**: Agent status now reflects heartbeat connectivity (missing if not heard from in >60 min) rather than unit-level data staleness
|
||||||
|
- **Live Refresh**: Watcher Manager surgically patches status, last-seen, and pending indicators every 30s without a full page reload
|
||||||
|
|
||||||
|
### v0.7.0 — 2026-03-07
|
||||||
|
- **Project Status Management**: On-hold and archived project states with automatic cancellation of pending actions
|
||||||
|
- **Manual SD Card Upload**: Upload offline NRL/SLM data directly from SD card (ZIP or multi-file); auto-creates monitoring sessions from `.rnh` metadata
|
||||||
|
- **Combined Report Wizard**: Multi-session Excel report generation with location grouping, period type filtering, and ZIP download
|
||||||
|
- **NL32 Support**: Report generator and web viewer now handle NL32 measurement data
|
||||||
|
- **Chart Preview**: Live chart preview in the report generator matching final output styling
|
||||||
|
- **Standalone SLM Mode**: SLMs can now be configured without a paired modem (direct IP)
|
||||||
|
- **Vibration Project Isolation**: Vibration project views no longer show SLM-specific tabs
|
||||||
|
- **MonitoringSession Rename**: `RecordingSession` renamed to `MonitoringSession` throughout; run migration before deploying
|
||||||
|
|
||||||
### v0.6.1 — 2026-02-16
|
### v0.6.1 — 2026-02-16
|
||||||
- **One-Off Recording Schedules**: Schedule single recordings with specific start/end datetimes
|
- **One-Off Recording Schedules**: Schedule single recordings with specific start/end datetimes
|
||||||
- **Bidirectional Pairing Sync**: Device-modem pairing now updates both sides automatically
|
- **Bidirectional Pairing Sync**: Device-modem pairing now updates both sides automatically
|
||||||
@@ -584,11 +599,17 @@ MIT
|
|||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
**Current: 0.6.1** — One-off recording schedules, bidirectional pairing sync, scheduler timezone fix (2026-02-16)
|
**Current: 0.8.0** — Watcher Manager admin page, live agent status refresh, watcher connectivity-based status (2026-03-18)
|
||||||
|
|
||||||
Previous: 0.6.0 — Calendar & reservation mode, device pairing interface, calibration UX overhaul, modem dashboard enhancements (2026-02-06)
|
Previous: 0.7.1 — Out-for-calibration status, reservation modal, migration fixes (2026-03-12)
|
||||||
|
|
||||||
Previous: 0.5.1 — Dashboard schedule view with today's actions panel, new Terra-View branding and logo rework (2026-01-27)
|
0.7.0 — Project status management, manual SD card upload, combined report wizard, NL32 support, MonitoringSession rename (2026-03-07)
|
||||||
|
|
||||||
|
0.6.1 — One-off recording schedules, bidirectional pairing sync, scheduler timezone fix (2026-02-16)
|
||||||
|
|
||||||
|
0.6.0 — Calendar & reservation mode, device pairing interface, calibration UX overhaul, modem dashboard enhancements (2026-02-06)
|
||||||
|
|
||||||
|
0.5.1 — Dashboard schedule view with today's actions panel, new Terra-View branding and logo rework (2026-01-27)
|
||||||
|
|
||||||
0.4.4 — Recurring schedules, alerting UI, report templates + RND viewer, and SLM workflow polish (2026-01-23)
|
0.4.4 — Recurring schedules, alerting UI, report templates + RND viewer, and SLM workflow polish (2026-01-23)
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
|
|||||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
VERSION = "0.6.1"
|
VERSION = "0.9.3"
|
||||||
if ENVIRONMENT == "development":
|
if ENVIRONMENT == "development":
|
||||||
_build = os.getenv("BUILD_NUMBER", "0")
|
_build = os.getenv("BUILD_NUMBER", "0")
|
||||||
if _build and _build != "0":
|
if _build and _build != "0":
|
||||||
@@ -102,6 +102,9 @@ app.include_router(modem_dashboard.router)
|
|||||||
from backend.routers import settings
|
from backend.routers import settings
|
||||||
app.include_router(settings.router)
|
app.include_router(settings.router)
|
||||||
|
|
||||||
|
from backend.routers import watcher_manager
|
||||||
|
app.include_router(watcher_manager.router)
|
||||||
|
|
||||||
# Projects system routers
|
# Projects system routers
|
||||||
app.include_router(projects.router)
|
app.include_router(projects.router)
|
||||||
app.include_router(project_locations.router)
|
app.include_router(project_locations.router)
|
||||||
@@ -123,6 +126,10 @@ app.include_router(recurring_schedules.router)
|
|||||||
from backend.routers import fleet_calendar
|
from backend.routers import fleet_calendar
|
||||||
app.include_router(fleet_calendar.router)
|
app.include_router(fleet_calendar.router)
|
||||||
|
|
||||||
|
# Deployment Records router
|
||||||
|
from backend.routers import deployments
|
||||||
|
app.include_router(deployments.router)
|
||||||
|
|
||||||
# Start scheduler service and device status monitor on application startup
|
# Start scheduler service and device status monitor on application startup
|
||||||
from backend.services.scheduler import start_scheduler, stop_scheduler
|
from backend.services.scheduler import start_scheduler, stop_scheduler
|
||||||
from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor
|
from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor
|
||||||
@@ -348,8 +355,11 @@ async def nrl_detail_page(
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
assigned_unit = None
|
assigned_unit = None
|
||||||
|
assigned_modem = None
|
||||||
if assignment:
|
if assignment:
|
||||||
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||||
|
if assigned_unit and assigned_unit.deployed_with_modem_id:
|
||||||
|
assigned_modem = db.query(RosterUnit).filter_by(id=assigned_unit.deployed_with_modem_id).first()
|
||||||
|
|
||||||
# Get session count
|
# Get session count
|
||||||
session_count = db.query(MonitoringSession).filter_by(location_id=location_id).count()
|
session_count = db.query(MonitoringSession).filter_by(location_id=location_id).count()
|
||||||
@@ -386,6 +396,7 @@ async def nrl_detail_page(
|
|||||||
"location": location,
|
"location": location,
|
||||||
"assignment": assignment,
|
"assignment": assignment,
|
||||||
"assigned_unit": assigned_unit,
|
"assigned_unit": assigned_unit,
|
||||||
|
"assigned_modem": assigned_modem,
|
||||||
"session_count": session_count,
|
"session_count": session_count,
|
||||||
"file_count": file_count,
|
"file_count": file_count,
|
||||||
"active_session": active_session,
|
"active_session": active_session,
|
||||||
@@ -659,6 +670,7 @@ async def devices_all_partial(request: Request):
|
|||||||
"last_seen": unit_data.get("last", "Never"),
|
"last_seen": unit_data.get("last", "Never"),
|
||||||
"deployed": True,
|
"deployed": True,
|
||||||
"retired": False,
|
"retired": False,
|
||||||
|
"out_for_calibration": False,
|
||||||
"ignored": False,
|
"ignored": False,
|
||||||
"note": unit_data.get("note", ""),
|
"note": unit_data.get("note", ""),
|
||||||
"device_type": unit_data.get("device_type", "seismograph"),
|
"device_type": unit_data.get("device_type", "seismograph"),
|
||||||
@@ -683,6 +695,59 @@ async def devices_all_partial(request: Request):
|
|||||||
"last_seen": unit_data.get("last", "Never"),
|
"last_seen": unit_data.get("last", "Never"),
|
||||||
"deployed": False,
|
"deployed": False,
|
||||||
"retired": False,
|
"retired": False,
|
||||||
|
"out_for_calibration": False,
|
||||||
|
"ignored": False,
|
||||||
|
"note": unit_data.get("note", ""),
|
||||||
|
"device_type": unit_data.get("device_type", "seismograph"),
|
||||||
|
"address": unit_data.get("address", ""),
|
||||||
|
"coordinates": unit_data.get("coordinates", ""),
|
||||||
|
"project_id": unit_data.get("project_id", ""),
|
||||||
|
"last_calibrated": unit_data.get("last_calibrated"),
|
||||||
|
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||||
|
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||||
|
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
|
||||||
|
"ip_address": unit_data.get("ip_address"),
|
||||||
|
"phone_number": unit_data.get("phone_number"),
|
||||||
|
"hardware_model": unit_data.get("hardware_model"),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add allocated units
|
||||||
|
for unit_id, unit_data in snapshot.get("allocated", {}).items():
|
||||||
|
units_list.append({
|
||||||
|
"id": unit_id,
|
||||||
|
"status": "Allocated",
|
||||||
|
"age": "N/A",
|
||||||
|
"last_seen": "N/A",
|
||||||
|
"deployed": False,
|
||||||
|
"retired": False,
|
||||||
|
"out_for_calibration": False,
|
||||||
|
"allocated": True,
|
||||||
|
"allocated_to_project_id": unit_data.get("allocated_to_project_id", ""),
|
||||||
|
"ignored": False,
|
||||||
|
"note": unit_data.get("note", ""),
|
||||||
|
"device_type": unit_data.get("device_type", "seismograph"),
|
||||||
|
"address": unit_data.get("address", ""),
|
||||||
|
"coordinates": unit_data.get("coordinates", ""),
|
||||||
|
"project_id": unit_data.get("project_id", ""),
|
||||||
|
"last_calibrated": unit_data.get("last_calibrated"),
|
||||||
|
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||||
|
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||||
|
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
|
||||||
|
"ip_address": unit_data.get("ip_address"),
|
||||||
|
"phone_number": unit_data.get("phone_number"),
|
||||||
|
"hardware_model": unit_data.get("hardware_model"),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add out-for-calibration units
|
||||||
|
for unit_id, unit_data in snapshot["out_for_calibration"].items():
|
||||||
|
units_list.append({
|
||||||
|
"id": unit_id,
|
||||||
|
"status": "Out for Calibration",
|
||||||
|
"age": "N/A",
|
||||||
|
"last_seen": "N/A",
|
||||||
|
"deployed": False,
|
||||||
|
"retired": False,
|
||||||
|
"out_for_calibration": True,
|
||||||
"ignored": False,
|
"ignored": False,
|
||||||
"note": unit_data.get("note", ""),
|
"note": unit_data.get("note", ""),
|
||||||
"device_type": unit_data.get("device_type", "seismograph"),
|
"device_type": unit_data.get("device_type", "seismograph"),
|
||||||
@@ -707,6 +772,7 @@ async def devices_all_partial(request: Request):
|
|||||||
"last_seen": "N/A",
|
"last_seen": "N/A",
|
||||||
"deployed": False,
|
"deployed": False,
|
||||||
"retired": True,
|
"retired": True,
|
||||||
|
"out_for_calibration": False,
|
||||||
"ignored": False,
|
"ignored": False,
|
||||||
"note": unit_data.get("note", ""),
|
"note": unit_data.get("note", ""),
|
||||||
"device_type": unit_data.get("device_type", "seismograph"),
|
"device_type": unit_data.get("device_type", "seismograph"),
|
||||||
@@ -731,6 +797,7 @@ async def devices_all_partial(request: Request):
|
|||||||
"last_seen": "N/A",
|
"last_seen": "N/A",
|
||||||
"deployed": False,
|
"deployed": False,
|
||||||
"retired": False,
|
"retired": False,
|
||||||
|
"out_for_calibration": False,
|
||||||
"ignored": True,
|
"ignored": True,
|
||||||
"note": unit_data.get("note", unit_data.get("reason", "")),
|
"note": unit_data.get("note", unit_data.get("reason", "")),
|
||||||
"device_type": unit_data.get("device_type", "unknown"),
|
"device_type": unit_data.get("device_type", "unknown"),
|
||||||
@@ -748,15 +815,19 @@ async def devices_all_partial(request: Request):
|
|||||||
|
|
||||||
# Sort by status category, then by ID
|
# Sort by status category, then by ID
|
||||||
def sort_key(unit):
|
def sort_key(unit):
|
||||||
# Priority: deployed (active) -> benched -> retired -> ignored
|
# Priority: deployed (active) -> allocated -> benched -> out_for_calibration -> retired -> ignored
|
||||||
if unit["deployed"]:
|
if unit["deployed"]:
|
||||||
return (0, unit["id"])
|
return (0, unit["id"])
|
||||||
elif not unit["retired"] and not unit["ignored"]:
|
elif unit.get("allocated"):
|
||||||
return (1, unit["id"])
|
return (1, unit["id"])
|
||||||
elif unit["retired"]:
|
elif not unit["retired"] and not unit["out_for_calibration"] and not unit["ignored"]:
|
||||||
return (2, unit["id"])
|
return (2, unit["id"])
|
||||||
else:
|
elif unit["out_for_calibration"]:
|
||||||
return (3, unit["id"])
|
return (3, unit["id"])
|
||||||
|
elif unit["retired"]:
|
||||||
|
return (4, unit["id"])
|
||||||
|
else:
|
||||||
|
return (5, unit["id"])
|
||||||
|
|
||||||
units_list.sort(key=sort_key)
|
units_list.sort(key=sort_key)
|
||||||
|
|
||||||
|
|||||||
35
backend/migrate_add_allocated.py
Normal file
35
backend/migrate_add_allocated.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""
|
||||||
|
Migration: Add allocated and allocated_to_project_id columns to roster table.
|
||||||
|
Run once: python backend/migrate_add_allocated.py
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_PATH = os.path.join(os.path.dirname(__file__), '..', 'data', 'seismo_fleet.db')
|
||||||
|
|
||||||
|
def run():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Check existing columns
|
||||||
|
cur.execute("PRAGMA table_info(roster)")
|
||||||
|
cols = {row[1] for row in cur.fetchall()}
|
||||||
|
|
||||||
|
if 'allocated' not in cols:
|
||||||
|
cur.execute("ALTER TABLE roster ADD COLUMN allocated BOOLEAN DEFAULT 0 NOT NULL")
|
||||||
|
print("Added column: allocated")
|
||||||
|
else:
|
||||||
|
print("Column already exists: allocated")
|
||||||
|
|
||||||
|
if 'allocated_to_project_id' not in cols:
|
||||||
|
cur.execute("ALTER TABLE roster ADD COLUMN allocated_to_project_id VARCHAR")
|
||||||
|
print("Added column: allocated_to_project_id")
|
||||||
|
else:
|
||||||
|
print("Column already exists: allocated_to_project_id")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print("Migration complete.")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run()
|
||||||
79
backend/migrate_add_deployment_records.py
Normal file
79
backend/migrate_add_deployment_records.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""
|
||||||
|
Migration: Add deployment_records table.
|
||||||
|
|
||||||
|
Tracks each time a unit is sent to the field and returned.
|
||||||
|
The active deployment is the row with actual_removal_date IS NULL.
|
||||||
|
|
||||||
|
Run once per database:
|
||||||
|
python backend/migrate_add_deployment_records.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_PATH = "./data/seismo_fleet.db"
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_database():
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
print(f"Database not found at {DB_PATH}")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if table already exists
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT name FROM sqlite_master
|
||||||
|
WHERE type='table' AND name='deployment_records'
|
||||||
|
""")
|
||||||
|
if cursor.fetchone():
|
||||||
|
print("✓ deployment_records table already exists, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Creating deployment_records table...")
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE deployment_records (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
unit_id TEXT NOT NULL,
|
||||||
|
deployed_date DATE,
|
||||||
|
estimated_removal_date DATE,
|
||||||
|
actual_removal_date DATE,
|
||||||
|
project_ref TEXT,
|
||||||
|
project_id TEXT,
|
||||||
|
location_name TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE INDEX idx_deployment_records_unit_id
|
||||||
|
ON deployment_records(unit_id)
|
||||||
|
""")
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE INDEX idx_deployment_records_project_id
|
||||||
|
ON deployment_records(project_id)
|
||||||
|
""")
|
||||||
|
# Index for finding active deployments quickly
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE INDEX idx_deployment_records_active
|
||||||
|
ON deployment_records(unit_id, actual_removal_date)
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("✓ deployment_records table created successfully")
|
||||||
|
print("✓ Indexes created")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
print(f"✗ Migration failed: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate_database()
|
||||||
62
backend/migrate_add_estimated_units.py
Normal file
62
backend/migrate_add_estimated_units.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""
|
||||||
|
Migration: Add estimated_units to job_reservations
|
||||||
|
|
||||||
|
Adds column:
|
||||||
|
- job_reservations.estimated_units: Estimated number of units for the reservation (nullable integer)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Default database path (matches production pattern)
|
||||||
|
DB_PATH = "./data/seismo_fleet.db"
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(db_path: str):
|
||||||
|
"""Run the migration."""
|
||||||
|
print(f"Migrating database: {db_path}")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if job_reservations table exists
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='job_reservations'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
print("job_reservations table does not exist. Skipping migration.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get existing columns in job_reservations
|
||||||
|
cursor.execute("PRAGMA table_info(job_reservations)")
|
||||||
|
existing_cols = {row[1] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
# Add estimated_units column if it doesn't exist
|
||||||
|
if 'estimated_units' not in existing_cols:
|
||||||
|
print("Adding estimated_units column to job_reservations...")
|
||||||
|
cursor.execute("ALTER TABLE job_reservations ADD COLUMN estimated_units INTEGER")
|
||||||
|
else:
|
||||||
|
print("estimated_units column already exists. Skipping.")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("Migration completed successfully!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Migration failed: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
db_path = DB_PATH
|
||||||
|
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
db_path = sys.argv[1]
|
||||||
|
|
||||||
|
if not Path(db_path).exists():
|
||||||
|
print(f"Database not found: {db_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
migrate(db_path)
|
||||||
24
backend/migrate_add_location_slots.py
Normal file
24
backend/migrate_add_location_slots.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""
|
||||||
|
Migration: Add location_slots column to job_reservations table.
|
||||||
|
Stores the full ordered slot list (including empty/unassigned slots) as JSON.
|
||||||
|
Run once per database.
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get("DB_PATH", "/app/data/seismo_fleet.db")
|
||||||
|
|
||||||
|
def run():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
existing = [r[1] for r in cursor.execute("PRAGMA table_info(job_reservations)").fetchall()]
|
||||||
|
if "location_slots" not in existing:
|
||||||
|
cursor.execute("ALTER TABLE job_reservations ADD COLUMN location_slots TEXT")
|
||||||
|
conn.commit()
|
||||||
|
print("Added location_slots column to job_reservations.")
|
||||||
|
else:
|
||||||
|
print("location_slots column already exists, skipping.")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
54
backend/migrate_add_out_for_calibration.py
Normal file
54
backend/migrate_add_out_for_calibration.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""
|
||||||
|
Database Migration: Add out_for_calibration field to roster table
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- Adds out_for_calibration BOOLEAN column (default FALSE) to roster table
|
||||||
|
- Safe to run multiple times (idempotent)
|
||||||
|
- No data loss
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python backend/migrate_add_out_for_calibration.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
SQLALCHEMY_DATABASE_URL = "sqlite:///./data/seismo_fleet.db"
|
||||||
|
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
print("=" * 60)
|
||||||
|
print("Migration: Add out_for_calibration to roster")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Check if column already exists
|
||||||
|
result = db.execute(text("PRAGMA table_info(roster)")).fetchall()
|
||||||
|
columns = [row[1] for row in result]
|
||||||
|
|
||||||
|
if "out_for_calibration" in columns:
|
||||||
|
print("Column out_for_calibration already exists. Skipping.")
|
||||||
|
else:
|
||||||
|
db.execute(text("ALTER TABLE roster ADD COLUMN out_for_calibration BOOLEAN DEFAULT FALSE"))
|
||||||
|
db.commit()
|
||||||
|
print("Added out_for_calibration column to roster table.")
|
||||||
|
|
||||||
|
print("Migration complete.")
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
print(f"Error: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
@@ -44,7 +44,7 @@ def migrate(db_path: str):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
db_path = "./data/terra-view.db"
|
db_path = "./data/seismo_fleet.db"
|
||||||
|
|
||||||
if len(sys.argv) > 1:
|
if len(sys.argv) > 1:
|
||||||
db_path = sys.argv[1]
|
db_path = sys.argv[1]
|
||||||
|
|||||||
42
backend/migrate_add_session_period_hours.py
Normal file
42
backend/migrate_add_session_period_hours.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
Migration: add period_start_hour and period_end_hour to monitoring_sessions.
|
||||||
|
|
||||||
|
Run once:
|
||||||
|
python backend/migrate_add_session_period_hours.py
|
||||||
|
|
||||||
|
Or inside the container:
|
||||||
|
docker exec terra-view python3 backend/migrate_add_session_period_hours.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from backend.database import engine
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with engine.connect() as conn:
|
||||||
|
# Check which columns already exist
|
||||||
|
result = conn.execute(text("PRAGMA table_info(monitoring_sessions)"))
|
||||||
|
existing = {row[1] for row in result}
|
||||||
|
|
||||||
|
added = []
|
||||||
|
for col, definition in [
|
||||||
|
("period_start_hour", "INTEGER"),
|
||||||
|
("period_end_hour", "INTEGER"),
|
||||||
|
]:
|
||||||
|
if col not in existing:
|
||||||
|
conn.execute(text(f"ALTER TABLE monitoring_sessions ADD COLUMN {col} {definition}"))
|
||||||
|
added.append(col)
|
||||||
|
else:
|
||||||
|
print(f" Column '{col}' already exists — skipping.")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if added:
|
||||||
|
print(f" Added columns: {', '.join(added)}")
|
||||||
|
print("Migration complete.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
131
backend/migrate_add_session_period_type.py
Normal file
131
backend/migrate_add_session_period_type.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration: Add session_label and period_type columns to monitoring_sessions.
|
||||||
|
|
||||||
|
session_label - user-editable display name, e.g. "NRL-1 Sun 2/23 Night"
|
||||||
|
period_type - one of: weekday_day | weekday_night | weekend_day | weekend_night
|
||||||
|
Auto-derived from started_at when NULL.
|
||||||
|
|
||||||
|
Period definitions (used in report stats table):
|
||||||
|
weekday_day Mon-Fri 07:00-22:00 -> Daytime (7AM-10PM)
|
||||||
|
weekday_night Mon-Fri 22:00-07:00 -> Nighttime (10PM-7AM)
|
||||||
|
weekend_day Sat-Sun 07:00-22:00 -> Daytime (7AM-10PM)
|
||||||
|
weekend_night Sat-Sun 22:00-07:00 -> Nighttime (10PM-7AM)
|
||||||
|
|
||||||
|
Run once inside the Docker container:
|
||||||
|
docker exec terra-view python3 backend/migrate_add_session_period_type.py
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
DB_PATH = Path("data/seismo_fleet.db")
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_period_type(started_at_str: str) -> str | None:
|
||||||
|
"""Derive period_type from a started_at ISO datetime string."""
|
||||||
|
if not started_at_str:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(started_at_str)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
is_weekend = dt.weekday() >= 5 # 5=Sat, 6=Sun
|
||||||
|
is_night = dt.hour >= 22 or dt.hour < 7
|
||||||
|
if is_weekend:
|
||||||
|
return "weekend_night" if is_night else "weekend_day"
|
||||||
|
else:
|
||||||
|
return "weekday_night" if is_night else "weekday_day"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_label(started_at_str: str, location_name: str | None, period_type: str | None) -> str | None:
|
||||||
|
"""Build a human-readable session label."""
|
||||||
|
if not started_at_str:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(started_at_str)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
day_abbr = dt.strftime("%a") # Mon, Tue, Sun, etc.
|
||||||
|
date_str = dt.strftime("%-m/%-d") # 2/23
|
||||||
|
|
||||||
|
period_labels = {
|
||||||
|
"weekday_day": "Day",
|
||||||
|
"weekday_night": "Night",
|
||||||
|
"weekend_day": "Day",
|
||||||
|
"weekend_night": "Night",
|
||||||
|
}
|
||||||
|
period_str = period_labels.get(period_type or "", "")
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if location_name:
|
||||||
|
parts.append(location_name)
|
||||||
|
parts.append(f"{day_abbr} {date_str}")
|
||||||
|
if period_str:
|
||||||
|
parts.append(period_str)
|
||||||
|
return " — ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
if not DB_PATH.exists():
|
||||||
|
print(f"Database not found at {DB_PATH}. Are you running from /home/serversdown/terra-view?")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# 1. Add columns (idempotent)
|
||||||
|
cur.execute("PRAGMA table_info(monitoring_sessions)")
|
||||||
|
existing_cols = {row["name"] for row in cur.fetchall()}
|
||||||
|
|
||||||
|
for col, typedef in [("session_label", "TEXT"), ("period_type", "TEXT")]:
|
||||||
|
if col not in existing_cols:
|
||||||
|
cur.execute(f"ALTER TABLE monitoring_sessions ADD COLUMN {col} {typedef}")
|
||||||
|
conn.commit()
|
||||||
|
print(f"✓ Added column {col} to monitoring_sessions")
|
||||||
|
else:
|
||||||
|
print(f"○ Column {col} already exists — skipping ALTER TABLE")
|
||||||
|
|
||||||
|
# 2. Backfill existing rows
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT ms.id, ms.started_at, ms.location_id
|
||||||
|
FROM monitoring_sessions ms
|
||||||
|
WHERE ms.period_type IS NULL OR ms.session_label IS NULL"""
|
||||||
|
)
|
||||||
|
sessions = cur.fetchall()
|
||||||
|
print(f"Backfilling {len(sessions)} session(s)...")
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
for row in sessions:
|
||||||
|
session_id = row["id"]
|
||||||
|
started_at = row["started_at"]
|
||||||
|
location_id = row["location_id"]
|
||||||
|
|
||||||
|
# Look up location name
|
||||||
|
location_name = None
|
||||||
|
if location_id:
|
||||||
|
cur.execute("SELECT name FROM monitoring_locations WHERE id = ?", (location_id,))
|
||||||
|
loc_row = cur.fetchone()
|
||||||
|
if loc_row:
|
||||||
|
location_name = loc_row["name"]
|
||||||
|
|
||||||
|
period_type = _derive_period_type(started_at)
|
||||||
|
label = _build_label(started_at, location_name, period_type)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE monitoring_sessions SET period_type = ?, session_label = ? WHERE id = ?",
|
||||||
|
(period_type, label, session_id),
|
||||||
|
)
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"✓ Backfilled {updated} session(s).")
|
||||||
|
print("Migration complete.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
41
backend/migrate_add_session_report_date.py
Normal file
41
backend/migrate_add_session_report_date.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""
|
||||||
|
Migration: add report_date to monitoring_sessions.
|
||||||
|
|
||||||
|
Run once:
|
||||||
|
python backend/migrate_add_session_report_date.py
|
||||||
|
|
||||||
|
Or inside the container:
|
||||||
|
docker exec terra-view-terra-view-1 python3 backend/migrate_add_session_report_date.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from backend.database import engine
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
def run():
|
||||||
|
with engine.connect() as conn:
|
||||||
|
# Check which columns already exist
|
||||||
|
result = conn.execute(text("PRAGMA table_info(monitoring_sessions)"))
|
||||||
|
existing = {row[1] for row in result}
|
||||||
|
|
||||||
|
added = []
|
||||||
|
for col, definition in [
|
||||||
|
("report_date", "DATE"),
|
||||||
|
]:
|
||||||
|
if col not in existing:
|
||||||
|
conn.execute(text(f"ALTER TABLE monitoring_sessions ADD COLUMN {col} {definition}"))
|
||||||
|
added.append(col)
|
||||||
|
else:
|
||||||
|
print(f" Column '{col}' already exists — skipping.")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if added:
|
||||||
|
print(f" Added columns: {', '.join(added)}")
|
||||||
|
print("Migration complete.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -42,7 +42,7 @@ def migrate(db_path: str):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
db_path = "./data/terra-view.db"
|
db_path = "./data/seismo_fleet.db"
|
||||||
|
|
||||||
if len(sys.argv) > 1:
|
if len(sys.argv) > 1:
|
||||||
db_path = sys.argv[1]
|
db_path = sys.argv[1]
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ class RosterUnit(Base):
|
|||||||
device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "slm"
|
device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "slm"
|
||||||
deployed = Column(Boolean, default=True)
|
deployed = Column(Boolean, default=True)
|
||||||
retired = Column(Boolean, default=False)
|
retired = Column(Boolean, default=False)
|
||||||
|
out_for_calibration = Column(Boolean, default=False)
|
||||||
|
allocated = Column(Boolean, default=False) # Staged for an upcoming job, not yet deployed
|
||||||
|
allocated_to_project_id = Column(String, nullable=True) # Which project it's allocated to
|
||||||
note = Column(String, nullable=True)
|
note = Column(String, nullable=True)
|
||||||
project_id = Column(String, nullable=True)
|
project_id = Column(String, nullable=True)
|
||||||
location = Column(String, nullable=True) # Legacy field - use address/coordinates instead
|
location = Column(String, nullable=True) # Legacy field - use address/coordinates instead
|
||||||
@@ -65,6 +68,26 @@ class RosterUnit(Base):
|
|||||||
slm_last_check = Column(DateTime, nullable=True) # Last communication check
|
slm_last_check = Column(DateTime, nullable=True) # Last communication check
|
||||||
|
|
||||||
|
|
||||||
|
class WatcherAgent(Base):
|
||||||
|
"""
|
||||||
|
Watcher agents: tracks the watcher processes (series3-watcher, thor-watcher)
|
||||||
|
that run on field machines and report unit heartbeats.
|
||||||
|
|
||||||
|
Updated on every heartbeat received from each source_id.
|
||||||
|
"""
|
||||||
|
__tablename__ = "watcher_agents"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, index=True) # source_id (hostname)
|
||||||
|
source_type = Column(String, nullable=False) # series3_watcher | series4_watcher
|
||||||
|
version = Column(String, nullable=True) # e.g. "1.4.0"
|
||||||
|
last_seen = Column(DateTime, default=datetime.utcnow)
|
||||||
|
status = Column(String, nullable=False, default="unknown") # ok | pending | missing | error | unknown
|
||||||
|
ip_address = Column(String, nullable=True)
|
||||||
|
log_tail = Column(Text, nullable=True) # last N log lines (JSON array of strings)
|
||||||
|
update_pending = Column(Boolean, default=False) # set True to trigger remote update
|
||||||
|
update_version = Column(String, nullable=True) # target version to update to
|
||||||
|
|
||||||
|
|
||||||
class IgnoredUnit(Base):
|
class IgnoredUnit(Base):
|
||||||
"""
|
"""
|
||||||
Ignored units: units that report but should be filtered out from unknown emitters.
|
Ignored units: units that report but should be filtered out from unknown emitters.
|
||||||
@@ -272,6 +295,25 @@ class MonitoringSession(Base):
|
|||||||
duration_seconds = Column(Integer, nullable=True)
|
duration_seconds = Column(Integer, nullable=True)
|
||||||
status = Column(String, default="recording") # recording, completed, failed
|
status = Column(String, default="recording") # recording, completed, failed
|
||||||
|
|
||||||
|
# Human-readable label auto-derived from date/location, editable by user.
|
||||||
|
# e.g. "NRL-1 — Sun 2/23 — Night"
|
||||||
|
session_label = Column(String, nullable=True)
|
||||||
|
|
||||||
|
# Period classification for report stats columns.
|
||||||
|
# weekday_day | weekday_night | weekend_day | weekend_night
|
||||||
|
period_type = Column(String, nullable=True)
|
||||||
|
|
||||||
|
# Effective monitoring window (hours 0–23). Night sessions cross midnight
|
||||||
|
# (period_end_hour < period_start_hour). NULL = no filtering applied.
|
||||||
|
# e.g. Day: start=7, end=19 Night: start=19, end=7
|
||||||
|
period_start_hour = Column(Integer, nullable=True)
|
||||||
|
period_end_hour = Column(Integer, nullable=True)
|
||||||
|
|
||||||
|
# For day sessions: the specific calendar date to use for report filtering.
|
||||||
|
# Overrides the automatic "last date with daytime rows" heuristic.
|
||||||
|
# Null = use heuristic.
|
||||||
|
report_date = Column(Date, nullable=True)
|
||||||
|
|
||||||
# Snapshot of device configuration at recording time
|
# Snapshot of device configuration at recording time
|
||||||
session_metadata = Column(Text, nullable=True) # JSON
|
session_metadata = Column(Text, nullable=True) # JSON
|
||||||
|
|
||||||
@@ -419,6 +461,41 @@ class Alert(Base):
|
|||||||
expires_at = Column(DateTime, nullable=True) # Auto-dismiss after this time
|
expires_at = Column(DateTime, nullable=True) # Auto-dismiss after this time
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Deployment Records
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class DeploymentRecord(Base):
|
||||||
|
"""
|
||||||
|
Deployment records: tracks each time a unit is sent to the field and returned.
|
||||||
|
|
||||||
|
Each row represents one deployment. The active deployment is the record
|
||||||
|
with actual_removal_date IS NULL. The fleet calendar uses this to show
|
||||||
|
units as "In Field" and surface their expected return date.
|
||||||
|
|
||||||
|
project_ref is a freeform string for legacy/vibration jobs like "Fay I-80".
|
||||||
|
project_id will be populated once those jobs are migrated to proper Project records.
|
||||||
|
"""
|
||||||
|
__tablename__ = "deployment_records"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, index=True) # UUID
|
||||||
|
unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit.id
|
||||||
|
|
||||||
|
deployed_date = Column(Date, nullable=True) # When unit left the yard
|
||||||
|
estimated_removal_date = Column(Date, nullable=True) # Expected return date
|
||||||
|
actual_removal_date = Column(Date, nullable=True) # Filled in when returned; NULL = still out
|
||||||
|
|
||||||
|
# Project linkage: freeform for legacy jobs, FK for proper project records
|
||||||
|
project_ref = Column(String, nullable=True) # e.g. "Fay I-80" (vibration jobs)
|
||||||
|
project_id = Column(String, nullable=True, index=True) # FK to Project.id (when available)
|
||||||
|
|
||||||
|
location_name = Column(String, nullable=True) # e.g. "North Gate", "VP-001"
|
||||||
|
notes = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Fleet Calendar & Job Reservations
|
# Fleet Calendar & Job Reservations
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -451,6 +528,11 @@ class JobReservation(Base):
|
|||||||
# For quantity reservations
|
# For quantity reservations
|
||||||
device_type = Column(String, default="seismograph") # seismograph | slm
|
device_type = Column(String, default="seismograph") # seismograph | slm
|
||||||
quantity_needed = Column(Integer, nullable=True) # e.g., 8 units
|
quantity_needed = Column(Integer, nullable=True) # e.g., 8 units
|
||||||
|
estimated_units = Column(Integer, nullable=True)
|
||||||
|
|
||||||
|
# Full slot list as JSON: [{"location_name": "North Gate", "unit_id": null}, ...]
|
||||||
|
# Includes empty slots (no unit assigned yet). Filled slots are authoritative in JobReservationUnit.
|
||||||
|
location_slots = Column(Text, nullable=True)
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
@@ -486,3 +568,10 @@ class JobReservationUnit(Base):
|
|||||||
assignment_source = Column(String, default="specific") # "specific" | "filled" | "swap"
|
assignment_source = Column(String, default="specific") # "specific" | "filled" | "swap"
|
||||||
assigned_at = Column(DateTime, default=datetime.utcnow)
|
assigned_at = Column(DateTime, default=datetime.utcnow)
|
||||||
notes = Column(Text, nullable=True) # "Replacing BE17353" etc.
|
notes = Column(Text, nullable=True) # "Replacing BE17353" etc.
|
||||||
|
|
||||||
|
# Power requirements for this deployment slot
|
||||||
|
power_type = Column(String, nullable=True) # "ac" | "solar" | None
|
||||||
|
|
||||||
|
# Location identity
|
||||||
|
location_name = Column(String, nullable=True) # e.g. "North Gate", "Main Entrance"
|
||||||
|
slot_index = Column(Integer, nullable=True) # Order within reservation (0-based)
|
||||||
|
|||||||
154
backend/routers/deployments.py
Normal file
154
backend/routers/deployments.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime, date
|
||||||
|
from typing import Optional
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import DeploymentRecord, RosterUnit
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["deployments"])
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(record: DeploymentRecord) -> dict:
|
||||||
|
return {
|
||||||
|
"id": record.id,
|
||||||
|
"unit_id": record.unit_id,
|
||||||
|
"deployed_date": record.deployed_date.isoformat() if record.deployed_date else None,
|
||||||
|
"estimated_removal_date": record.estimated_removal_date.isoformat() if record.estimated_removal_date else None,
|
||||||
|
"actual_removal_date": record.actual_removal_date.isoformat() if record.actual_removal_date else None,
|
||||||
|
"project_ref": record.project_ref,
|
||||||
|
"project_id": record.project_id,
|
||||||
|
"location_name": record.location_name,
|
||||||
|
"notes": record.notes,
|
||||||
|
"created_at": record.created_at.isoformat() if record.created_at else None,
|
||||||
|
"updated_at": record.updated_at.isoformat() if record.updated_at else None,
|
||||||
|
"is_active": record.actual_removal_date is None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/deployments/{unit_id}")
|
||||||
|
def get_deployments(unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Get all deployment records for a unit, newest first."""
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
||||||
|
|
||||||
|
records = (
|
||||||
|
db.query(DeploymentRecord)
|
||||||
|
.filter_by(unit_id=unit_id)
|
||||||
|
.order_by(DeploymentRecord.deployed_date.desc(), DeploymentRecord.created_at.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return {"deployments": [_serialize(r) for r in records]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/deployments/{unit_id}/active")
|
||||||
|
def get_active_deployment(unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Get the current active deployment (actual_removal_date is NULL), or null."""
|
||||||
|
record = (
|
||||||
|
db.query(DeploymentRecord)
|
||||||
|
.filter(
|
||||||
|
DeploymentRecord.unit_id == unit_id,
|
||||||
|
DeploymentRecord.actual_removal_date == None
|
||||||
|
)
|
||||||
|
.order_by(DeploymentRecord.created_at.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return {"deployment": _serialize(record) if record else None}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/deployments/{unit_id}")
|
||||||
|
def create_deployment(unit_id: str, payload: dict, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Create a new deployment record for a unit.
|
||||||
|
|
||||||
|
Body fields (all optional):
|
||||||
|
deployed_date (YYYY-MM-DD)
|
||||||
|
estimated_removal_date (YYYY-MM-DD)
|
||||||
|
project_ref (freeform string)
|
||||||
|
project_id (UUID if linked to Project)
|
||||||
|
location_name
|
||||||
|
notes
|
||||||
|
"""
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
||||||
|
|
||||||
|
def parse_date(val) -> Optional[date]:
|
||||||
|
if not val:
|
||||||
|
return None
|
||||||
|
if isinstance(val, date):
|
||||||
|
return val
|
||||||
|
return date.fromisoformat(str(val))
|
||||||
|
|
||||||
|
record = DeploymentRecord(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
unit_id=unit_id,
|
||||||
|
deployed_date=parse_date(payload.get("deployed_date")),
|
||||||
|
estimated_removal_date=parse_date(payload.get("estimated_removal_date")),
|
||||||
|
actual_removal_date=None,
|
||||||
|
project_ref=payload.get("project_ref"),
|
||||||
|
project_id=payload.get("project_id"),
|
||||||
|
location_name=payload.get("location_name"),
|
||||||
|
notes=payload.get("notes"),
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(record)
|
||||||
|
return _serialize(record)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/deployments/{unit_id}/{deployment_id}")
|
||||||
|
def update_deployment(unit_id: str, deployment_id: str, payload: dict, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Update a deployment record. Used for:
|
||||||
|
- Setting/changing estimated_removal_date
|
||||||
|
- Closing a deployment (set actual_removal_date to mark unit returned)
|
||||||
|
- Editing project_ref, location_name, notes
|
||||||
|
"""
|
||||||
|
record = db.query(DeploymentRecord).filter_by(id=deployment_id, unit_id=unit_id).first()
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(status_code=404, detail="Deployment record not found")
|
||||||
|
|
||||||
|
def parse_date(val) -> Optional[date]:
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
if val == "":
|
||||||
|
return None
|
||||||
|
if isinstance(val, date):
|
||||||
|
return val
|
||||||
|
return date.fromisoformat(str(val))
|
||||||
|
|
||||||
|
if "deployed_date" in payload:
|
||||||
|
record.deployed_date = parse_date(payload["deployed_date"])
|
||||||
|
if "estimated_removal_date" in payload:
|
||||||
|
record.estimated_removal_date = parse_date(payload["estimated_removal_date"])
|
||||||
|
if "actual_removal_date" in payload:
|
||||||
|
record.actual_removal_date = parse_date(payload["actual_removal_date"])
|
||||||
|
if "project_ref" in payload:
|
||||||
|
record.project_ref = payload["project_ref"]
|
||||||
|
if "project_id" in payload:
|
||||||
|
record.project_id = payload["project_id"]
|
||||||
|
if "location_name" in payload:
|
||||||
|
record.location_name = payload["location_name"]
|
||||||
|
if "notes" in payload:
|
||||||
|
record.notes = payload["notes"]
|
||||||
|
|
||||||
|
record.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(record)
|
||||||
|
return _serialize(record)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/deployments/{unit_id}/{deployment_id}")
|
||||||
|
def delete_deployment(unit_id: str, deployment_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Delete a deployment record."""
|
||||||
|
record = db.query(DeploymentRecord).filter_by(id=deployment_id, unit_id=unit_id).first()
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(status_code=404, detail="Deployment record not found")
|
||||||
|
db.delete(record)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
@@ -19,7 +19,7 @@ import logging
|
|||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import (
|
from backend.models import (
|
||||||
RosterUnit, JobReservation, JobReservationUnit,
|
RosterUnit, JobReservation, JobReservationUnit,
|
||||||
UserPreferences, Project
|
UserPreferences, Project, MonitoringLocation, UnitAssignment
|
||||||
)
|
)
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
from backend.services.fleet_calendar_service import (
|
from backend.services.fleet_calendar_service import (
|
||||||
@@ -61,9 +61,53 @@ async def fleet_calendar_page(
|
|||||||
|
|
||||||
# Get projects for the reservation form dropdown
|
# Get projects for the reservation form dropdown
|
||||||
projects = db.query(Project).filter(
|
projects = db.query(Project).filter(
|
||||||
Project.status == "active"
|
Project.status.in_(["active", "upcoming", "on_hold"])
|
||||||
).order_by(Project.name).all()
|
).order_by(Project.name).all()
|
||||||
|
|
||||||
|
# Build a serializable list of items with dates for calendar bars
|
||||||
|
# Includes both tracked Projects (with dates) and Job Reservations (matching device_type)
|
||||||
|
project_colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4', '#F97316']
|
||||||
|
# Map calendar device_type to project_type_ids
|
||||||
|
device_type_to_project_types = {
|
||||||
|
"seismograph": ["vibration_monitoring", "combined"],
|
||||||
|
"slm": ["sound_monitoring", "combined"],
|
||||||
|
}
|
||||||
|
relevant_project_types = device_type_to_project_types.get(device_type, [])
|
||||||
|
|
||||||
|
calendar_projects = []
|
||||||
|
for i, p in enumerate(projects):
|
||||||
|
if p.start_date and p.project_type_id in relevant_project_types:
|
||||||
|
calendar_projects.append({
|
||||||
|
"id": p.id,
|
||||||
|
"name": p.name,
|
||||||
|
"start_date": p.start_date.isoformat(),
|
||||||
|
"end_date": p.end_date.isoformat() if p.end_date else None,
|
||||||
|
"color": project_colors[i % len(project_colors)],
|
||||||
|
"confirmed": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add job reservations for this device_type as bars
|
||||||
|
from sqlalchemy import or_ as _or
|
||||||
|
cal_window_end = date(year + ((month + 10) // 12), ((month + 10) % 12) + 1, 1)
|
||||||
|
reservations_for_cal = db.query(JobReservation).filter(
|
||||||
|
JobReservation.device_type == device_type,
|
||||||
|
JobReservation.start_date <= cal_window_end,
|
||||||
|
_or(
|
||||||
|
JobReservation.end_date >= date(year, month, 1),
|
||||||
|
JobReservation.end_date == None,
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for res in reservations_for_cal:
|
||||||
|
end = res.end_date or res.estimated_end_date
|
||||||
|
calendar_projects.append({
|
||||||
|
"id": res.id,
|
||||||
|
"name": res.name,
|
||||||
|
"start_date": res.start_date.isoformat(),
|
||||||
|
"end_date": end.isoformat() if end else None,
|
||||||
|
"color": res.color,
|
||||||
|
"confirmed": bool(res.project_id),
|
||||||
|
})
|
||||||
|
|
||||||
# Calculate prev/next month navigation
|
# Calculate prev/next month navigation
|
||||||
prev_year, prev_month = (year - 1, 12) if month == 1 else (year, month - 1)
|
prev_year, prev_month = (year - 1, 12) if month == 1 else (year, month - 1)
|
||||||
next_year, next_month = (year + 1, 1) if month == 12 else (year, month + 1)
|
next_year, next_month = (year + 1, 1) if month == 12 else (year, month + 1)
|
||||||
@@ -81,6 +125,7 @@ async def fleet_calendar_page(
|
|||||||
"device_type": device_type,
|
"device_type": device_type,
|
||||||
"calendar_data": calendar_data,
|
"calendar_data": calendar_data,
|
||||||
"projects": projects,
|
"projects": projects,
|
||||||
|
"calendar_projects": calendar_projects,
|
||||||
"today": today.isoformat()
|
"today": today.isoformat()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -167,6 +212,7 @@ async def create_reservation(
|
|||||||
if estimated_end_date and estimated_end_date < start_date:
|
if estimated_end_date and estimated_end_date < start_date:
|
||||||
raise HTTPException(status_code=400, detail="Estimated end date must be after start date")
|
raise HTTPException(status_code=400, detail="Estimated end date must be after start date")
|
||||||
|
|
||||||
|
import json as _json
|
||||||
reservation = JobReservation(
|
reservation = JobReservation(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
@@ -178,6 +224,8 @@ async def create_reservation(
|
|||||||
assignment_type=data["assignment_type"],
|
assignment_type=data["assignment_type"],
|
||||||
device_type=data.get("device_type", "seismograph"),
|
device_type=data.get("device_type", "seismograph"),
|
||||||
quantity_needed=data.get("quantity_needed"),
|
quantity_needed=data.get("quantity_needed"),
|
||||||
|
estimated_units=data.get("estimated_units"),
|
||||||
|
location_slots=_json.dumps(data["location_slots"]) if data.get("location_slots") is not None else None,
|
||||||
notes=data.get("notes"),
|
notes=data.get("notes"),
|
||||||
color=data.get("color", "#3B82F6")
|
color=data.get("color", "#3B82F6")
|
||||||
)
|
)
|
||||||
@@ -221,8 +269,16 @@ async def get_reservation(
|
|||||||
reservation_id=reservation_id
|
reservation_id=reservation_id
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
unit_ids = [a.unit_id for a in assignments]
|
# Sort assignments by slot_index so order is preserved
|
||||||
|
assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999))
|
||||||
|
unit_ids = [a.unit_id for a in assignments_sorted]
|
||||||
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else []
|
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else []
|
||||||
|
units_by_id = {u.id: u for u in units}
|
||||||
|
# Build per-unit lookups from assignments
|
||||||
|
assignment_map = {a.unit_id: a for a in assignments_sorted}
|
||||||
|
|
||||||
|
import json as _json
|
||||||
|
stored_slots = _json.loads(reservation.location_slots) if reservation.location_slots else None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": reservation.id,
|
"id": reservation.id,
|
||||||
@@ -235,15 +291,21 @@ async def get_reservation(
|
|||||||
"assignment_type": reservation.assignment_type,
|
"assignment_type": reservation.assignment_type,
|
||||||
"device_type": reservation.device_type,
|
"device_type": reservation.device_type,
|
||||||
"quantity_needed": reservation.quantity_needed,
|
"quantity_needed": reservation.quantity_needed,
|
||||||
|
"estimated_units": reservation.estimated_units,
|
||||||
|
"location_slots": stored_slots,
|
||||||
"notes": reservation.notes,
|
"notes": reservation.notes,
|
||||||
"color": reservation.color,
|
"color": reservation.color,
|
||||||
"assigned_units": [
|
"assigned_units": [
|
||||||
{
|
{
|
||||||
"id": u.id,
|
"id": uid,
|
||||||
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
|
"last_calibrated": units_by_id[uid].last_calibrated.isoformat() if uid in units_by_id and units_by_id[uid].last_calibrated else None,
|
||||||
"deployed": u.deployed
|
"deployed": units_by_id[uid].deployed if uid in units_by_id else False,
|
||||||
|
"power_type": assignment_map[uid].power_type,
|
||||||
|
"notes": assignment_map[uid].notes,
|
||||||
|
"location_name": assignment_map[uid].location_name,
|
||||||
|
"slot_index": assignment_map[uid].slot_index,
|
||||||
}
|
}
|
||||||
for u in units
|
for uid in unit_ids
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +340,11 @@ async def update_reservation(
|
|||||||
reservation.assignment_type = data["assignment_type"]
|
reservation.assignment_type = data["assignment_type"]
|
||||||
if "quantity_needed" in data:
|
if "quantity_needed" in data:
|
||||||
reservation.quantity_needed = data["quantity_needed"]
|
reservation.quantity_needed = data["quantity_needed"]
|
||||||
|
if "estimated_units" in data:
|
||||||
|
reservation.estimated_units = data["estimated_units"]
|
||||||
|
if "location_slots" in data:
|
||||||
|
import json as _json
|
||||||
|
reservation.location_slots = _json.dumps(data["location_slots"]) if data["location_slots"] is not None else None
|
||||||
if "notes" in data:
|
if "notes" in data:
|
||||||
reservation.notes = data["notes"]
|
reservation.notes = data["notes"]
|
||||||
if "color" in data:
|
if "color" in data:
|
||||||
@@ -337,52 +404,57 @@ async def assign_units_to_reservation(
|
|||||||
|
|
||||||
data = await request.json()
|
data = await request.json()
|
||||||
unit_ids = data.get("unit_ids", [])
|
unit_ids = data.get("unit_ids", [])
|
||||||
|
# Optional per-unit dicts keyed by unit_id
|
||||||
|
power_types = data.get("power_types", {})
|
||||||
|
location_notes = data.get("location_notes", {})
|
||||||
|
location_names = data.get("location_names", {})
|
||||||
|
# slot_indices: {"BE17354": 0, "BE9441": 1, ...}
|
||||||
|
slot_indices = data.get("slot_indices", {})
|
||||||
|
|
||||||
if not unit_ids:
|
# Verify units exist (allow empty list to clear all assignments)
|
||||||
raise HTTPException(status_code=400, detail="No units specified")
|
if unit_ids:
|
||||||
|
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all()
|
||||||
|
found_ids = {u.id for u in units}
|
||||||
|
missing = set(unit_ids) - found_ids
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Units not found: {', '.join(missing)}")
|
||||||
|
|
||||||
# Verify units exist
|
# Full replace: delete all existing assignments for this reservation first
|
||||||
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all()
|
db.query(JobReservationUnit).filter_by(reservation_id=reservation_id).delete()
|
||||||
found_ids = {u.id for u in units}
|
db.flush()
|
||||||
missing = set(unit_ids) - found_ids
|
|
||||||
if missing:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Units not found: {', '.join(missing)}")
|
|
||||||
|
|
||||||
# Check for conflicts (already assigned to overlapping reservations)
|
# Check for conflicts with other reservations and insert new assignments
|
||||||
conflicts = []
|
conflicts = []
|
||||||
for unit_id in unit_ids:
|
for unit_id in unit_ids:
|
||||||
# Check if unit is already assigned to this reservation
|
|
||||||
existing = db.query(JobReservationUnit).filter_by(
|
|
||||||
reservation_id=reservation_id,
|
|
||||||
unit_id=unit_id
|
|
||||||
).first()
|
|
||||||
if existing:
|
|
||||||
continue # Already assigned, skip
|
|
||||||
|
|
||||||
# Check overlapping reservations
|
# Check overlapping reservations
|
||||||
overlapping = db.query(JobReservation).join(
|
if reservation.end_date:
|
||||||
JobReservationUnit, JobReservation.id == JobReservationUnit.reservation_id
|
overlapping = db.query(JobReservation).join(
|
||||||
).filter(
|
JobReservationUnit, JobReservation.id == JobReservationUnit.reservation_id
|
||||||
JobReservationUnit.unit_id == unit_id,
|
).filter(
|
||||||
JobReservation.id != reservation_id,
|
JobReservationUnit.unit_id == unit_id,
|
||||||
JobReservation.start_date <= reservation.end_date,
|
JobReservation.id != reservation_id,
|
||||||
JobReservation.end_date >= reservation.start_date
|
JobReservation.start_date <= reservation.end_date,
|
||||||
).first()
|
JobReservation.end_date >= reservation.start_date
|
||||||
|
).first()
|
||||||
|
|
||||||
if overlapping:
|
if overlapping:
|
||||||
conflicts.append({
|
conflicts.append({
|
||||||
"unit_id": unit_id,
|
"unit_id": unit_id,
|
||||||
"conflict_reservation": overlapping.name,
|
"conflict_reservation": overlapping.name,
|
||||||
"conflict_dates": f"{overlapping.start_date} - {overlapping.end_date}"
|
"conflict_dates": f"{overlapping.start_date} - {overlapping.end_date}"
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Add assignment
|
# Add assignment
|
||||||
assignment = JobReservationUnit(
|
assignment = JobReservationUnit(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
reservation_id=reservation_id,
|
reservation_id=reservation_id,
|
||||||
unit_id=unit_id,
|
unit_id=unit_id,
|
||||||
assignment_source="filled" if reservation.assignment_type == "quantity" else "specific"
|
assignment_source="filled" if reservation.assignment_type == "quantity" else "specific",
|
||||||
|
power_type=power_types.get(unit_id),
|
||||||
|
notes=location_notes.get(unit_id),
|
||||||
|
location_name=location_names.get(unit_id),
|
||||||
|
slot_index=slot_indices.get(unit_id),
|
||||||
)
|
)
|
||||||
db.add(assignment)
|
db.add(assignment)
|
||||||
|
|
||||||
@@ -511,7 +583,7 @@ async def get_reservations_list(
|
|||||||
else:
|
else:
|
||||||
end_date = date(end_year, end_month + 1, 1) - timedelta(days=1)
|
end_date = date(end_year, end_month + 1, 1) - timedelta(days=1)
|
||||||
|
|
||||||
# Include TBD reservations that started before window end
|
# Filter by device_type and date window
|
||||||
reservations = db.query(JobReservation).filter(
|
reservations = db.query(JobReservation).filter(
|
||||||
JobReservation.device_type == device_type,
|
JobReservation.device_type == device_type,
|
||||||
JobReservation.start_date <= end_date,
|
JobReservation.start_date <= end_date,
|
||||||
@@ -524,16 +596,38 @@ async def get_reservations_list(
|
|||||||
# Get assignment counts
|
# Get assignment counts
|
||||||
reservation_data = []
|
reservation_data = []
|
||||||
for res in reservations:
|
for res in reservations:
|
||||||
assigned_count = db.query(JobReservationUnit).filter_by(
|
assignments = db.query(JobReservationUnit).filter_by(
|
||||||
reservation_id=res.id
|
reservation_id=res.id
|
||||||
).count()
|
).all()
|
||||||
|
assigned_count = len(assignments)
|
||||||
|
|
||||||
|
# Enrich assignments with unit details, sorted by slot_index
|
||||||
|
assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999))
|
||||||
|
unit_ids = [a.unit_id for a in assignments_sorted]
|
||||||
|
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else []
|
||||||
|
units_by_id = {u.id: u for u in units}
|
||||||
|
assigned_units = [
|
||||||
|
{
|
||||||
|
"id": a.unit_id,
|
||||||
|
"power_type": a.power_type,
|
||||||
|
"notes": a.notes,
|
||||||
|
"location_name": a.location_name,
|
||||||
|
"slot_index": a.slot_index,
|
||||||
|
"deployed": units_by_id[a.unit_id].deployed if a.unit_id in units_by_id else False,
|
||||||
|
"last_calibrated": units_by_id[a.unit_id].last_calibrated if a.unit_id in units_by_id else None,
|
||||||
|
}
|
||||||
|
for a in assignments_sorted
|
||||||
|
]
|
||||||
|
|
||||||
# Check for calibration conflicts
|
# Check for calibration conflicts
|
||||||
conflicts = check_calibration_conflicts(db, res.id)
|
conflicts = check_calibration_conflicts(db, res.id)
|
||||||
|
|
||||||
|
location_count = res.quantity_needed or assigned_count
|
||||||
reservation_data.append({
|
reservation_data.append({
|
||||||
"reservation": res,
|
"reservation": res,
|
||||||
"assigned_count": assigned_count,
|
"assigned_count": assigned_count,
|
||||||
|
"location_count": location_count,
|
||||||
|
"assigned_units": assigned_units,
|
||||||
"has_conflicts": len(conflicts) > 0,
|
"has_conflicts": len(conflicts) > 0,
|
||||||
"conflict_count": len(conflicts)
|
"conflict_count": len(conflicts)
|
||||||
})
|
})
|
||||||
@@ -549,6 +643,131 @@ async def get_reservations_list(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/fleet-calendar/planner-availability", response_class=JSONResponse)
|
||||||
|
async def get_planner_availability(
|
||||||
|
device_type: str = "seismograph",
|
||||||
|
start_date: Optional[str] = None,
|
||||||
|
end_date: Optional[str] = None,
|
||||||
|
exclude_reservation_id: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get available units for the reservation planner split-panel UI.
|
||||||
|
Dates are optional — if omitted, returns all non-retired units regardless of reservations.
|
||||||
|
"""
|
||||||
|
if start_date and end_date:
|
||||||
|
try:
|
||||||
|
start = date.fromisoformat(start_date)
|
||||||
|
end = date.fromisoformat(end_date)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||||
|
units = get_available_units_for_period(db, start, end, device_type, exclude_reservation_id)
|
||||||
|
else:
|
||||||
|
# No dates: return all non-retired units of this type, with current reservation info
|
||||||
|
from backend.models import RosterUnit as RU
|
||||||
|
from datetime import timedelta
|
||||||
|
today = date.today()
|
||||||
|
all_units = db.query(RU).filter(
|
||||||
|
RU.device_type == device_type,
|
||||||
|
RU.retired == False
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Build a map: unit_id -> list of active/upcoming reservations
|
||||||
|
active_assignments = db.query(JobReservationUnit).join(
|
||||||
|
JobReservation, JobReservationUnit.reservation_id == JobReservation.id
|
||||||
|
).filter(
|
||||||
|
JobReservation.device_type == device_type,
|
||||||
|
JobReservation.end_date >= today
|
||||||
|
).all()
|
||||||
|
unit_reservations = {}
|
||||||
|
for assignment in active_assignments:
|
||||||
|
res = db.query(JobReservation).filter(JobReservation.id == assignment.reservation_id).first()
|
||||||
|
if not res:
|
||||||
|
continue
|
||||||
|
unit_reservations.setdefault(assignment.unit_id, []).append({
|
||||||
|
"reservation_id": res.id,
|
||||||
|
"reservation_name": res.name,
|
||||||
|
"start_date": res.start_date.isoformat() if res.start_date else None,
|
||||||
|
"end_date": res.end_date.isoformat() if res.end_date else None,
|
||||||
|
"color": res.color or "#3B82F6"
|
||||||
|
})
|
||||||
|
|
||||||
|
units = []
|
||||||
|
for u in all_units:
|
||||||
|
expiry = (u.last_calibrated + timedelta(days=365)) if u.last_calibrated else None
|
||||||
|
units.append({
|
||||||
|
"id": u.id,
|
||||||
|
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
|
||||||
|
"expiry_date": expiry.isoformat() if expiry else None,
|
||||||
|
"calibration_status": "needs_calibration" if not u.last_calibrated else "valid",
|
||||||
|
"deployed": u.deployed,
|
||||||
|
"out_for_calibration": u.out_for_calibration or False,
|
||||||
|
"allocated": getattr(u, 'allocated', False) or False,
|
||||||
|
"allocated_to_project_id": getattr(u, 'allocated_to_project_id', None) or "",
|
||||||
|
"note": u.note or "",
|
||||||
|
"reservations": unit_reservations.get(u.id, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort: benched first (easier to assign), then deployed, then by ID
|
||||||
|
units.sort(key=lambda u: (1 if u["deployed"] else 0, u["id"]))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"units": units,
|
||||||
|
"start_date": start_date,
|
||||||
|
"end_date": end_date,
|
||||||
|
"count": len(units)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/fleet-calendar/unit-quick-info/{unit_id}", response_class=JSONResponse)
|
||||||
|
async def get_unit_quick_info(unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Return at-a-glance info for the planner quick-view modal."""
|
||||||
|
from backend.models import Emitter
|
||||||
|
u = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
if not u:
|
||||||
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
expiry = (u.last_calibrated + timedelta(days=365)) if u.last_calibrated else None
|
||||||
|
|
||||||
|
# Active/upcoming reservations
|
||||||
|
assignments = db.query(JobReservationUnit).filter(JobReservationUnit.unit_id == unit_id).all()
|
||||||
|
reservations = []
|
||||||
|
for a in assignments:
|
||||||
|
res = db.query(JobReservation).filter(
|
||||||
|
JobReservation.id == a.reservation_id,
|
||||||
|
JobReservation.end_date >= today
|
||||||
|
).first()
|
||||||
|
if res:
|
||||||
|
reservations.append({
|
||||||
|
"name": res.name,
|
||||||
|
"start_date": res.start_date.isoformat() if res.start_date else None,
|
||||||
|
"end_date": res.end_date.isoformat() if res.end_date else None,
|
||||||
|
"end_date_tbd": res.end_date_tbd,
|
||||||
|
"color": res.color or "#3B82F6",
|
||||||
|
"location_name": a.location_name,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Last seen from emitter
|
||||||
|
emitter = db.query(Emitter).filter(Emitter.unit_type == unit_id).first()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": u.id,
|
||||||
|
"unit_type": u.unit_type,
|
||||||
|
"deployed": u.deployed,
|
||||||
|
"out_for_calibration": u.out_for_calibration or False,
|
||||||
|
"note": u.note or "",
|
||||||
|
"project_id": u.project_id or "",
|
||||||
|
"address": u.address or u.location or "",
|
||||||
|
"coordinates": u.coordinates or "",
|
||||||
|
"deployed_with_modem_id": u.deployed_with_modem_id or "",
|
||||||
|
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
|
||||||
|
"next_calibration_due": u.next_calibration_due.isoformat() if u.next_calibration_due else (expiry.isoformat() if expiry else None),
|
||||||
|
"cal_expired": not u.last_calibrated or (expiry and expiry < today),
|
||||||
|
"last_seen": emitter.last_seen.isoformat() if emitter and emitter.last_seen else None,
|
||||||
|
"reservations": reservations,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/fleet-calendar/available-units", response_class=HTMLResponse)
|
@router.get("/api/fleet-calendar/available-units", response_class=HTMLResponse)
|
||||||
async def get_available_units_partial(
|
async def get_available_units_partial(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -608,3 +827,102 @@ async def get_month_partial(
|
|||||||
"today": date.today().isoformat()
|
"today": date.today().isoformat()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Promote Reservation to Project
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.post("/api/fleet-calendar/reservations/{reservation_id}/promote-to-project", response_class=JSONResponse)
|
||||||
|
async def promote_reservation_to_project(
|
||||||
|
reservation_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Promote a job reservation to a full project in the projects DB.
|
||||||
|
Creates: Project + MonitoringLocations + UnitAssignments.
|
||||||
|
"""
|
||||||
|
reservation = db.query(JobReservation).filter_by(id=reservation_id).first()
|
||||||
|
if not reservation:
|
||||||
|
raise HTTPException(status_code=404, detail="Reservation not found")
|
||||||
|
|
||||||
|
data = await request.json()
|
||||||
|
project_number = data.get("project_number") or None
|
||||||
|
client_name = data.get("client_name") or None
|
||||||
|
|
||||||
|
# Map device_type to project_type_id
|
||||||
|
if reservation.device_type == "slm":
|
||||||
|
project_type_id = "sound_monitoring"
|
||||||
|
location_type = "sound"
|
||||||
|
else:
|
||||||
|
project_type_id = "vibration_monitoring"
|
||||||
|
location_type = "vibration"
|
||||||
|
|
||||||
|
# Check for duplicate project name
|
||||||
|
existing = db.query(Project).filter_by(name=reservation.name).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=409, detail=f"A project named '{reservation.name}' already exists.")
|
||||||
|
|
||||||
|
# Create the project
|
||||||
|
project_id = str(uuid.uuid4())
|
||||||
|
project = Project(
|
||||||
|
id=project_id,
|
||||||
|
name=reservation.name,
|
||||||
|
project_number=project_number,
|
||||||
|
client_name=client_name,
|
||||||
|
project_type_id=project_type_id,
|
||||||
|
status="upcoming",
|
||||||
|
start_date=reservation.start_date,
|
||||||
|
end_date=reservation.end_date,
|
||||||
|
description=reservation.notes,
|
||||||
|
)
|
||||||
|
db.add(project)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# Load assignments sorted by slot_index
|
||||||
|
assignments = db.query(JobReservationUnit).filter_by(reservation_id=reservation_id).all()
|
||||||
|
assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999))
|
||||||
|
|
||||||
|
locations_created = 0
|
||||||
|
units_assigned = 0
|
||||||
|
|
||||||
|
for i, assignment in enumerate(assignments_sorted):
|
||||||
|
loc_num = str(i + 1).zfill(3)
|
||||||
|
loc_name = assignment.location_name or f"Location {i + 1}"
|
||||||
|
|
||||||
|
location = MonitoringLocation(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
project_id=project_id,
|
||||||
|
location_type=location_type,
|
||||||
|
name=loc_name,
|
||||||
|
description=assignment.notes,
|
||||||
|
)
|
||||||
|
db.add(location)
|
||||||
|
db.flush()
|
||||||
|
locations_created += 1
|
||||||
|
|
||||||
|
if assignment.unit_id:
|
||||||
|
unit_assignment = UnitAssignment(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
unit_id=assignment.unit_id,
|
||||||
|
location_id=location.id,
|
||||||
|
project_id=project_id,
|
||||||
|
device_type=reservation.device_type or "seismograph",
|
||||||
|
status="active",
|
||||||
|
notes=f"Power: {assignment.power_type}" if assignment.power_type else None,
|
||||||
|
)
|
||||||
|
db.add(unit_assignment)
|
||||||
|
units_assigned += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Promoted reservation '{reservation.name}' to project {project_id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"project_id": project_id,
|
||||||
|
"project_name": reservation.name,
|
||||||
|
"locations_created": locations_created,
|
||||||
|
"units_assigned": units_assigned,
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,10 +31,57 @@ from backend.models import (
|
|||||||
DataFile,
|
DataFile,
|
||||||
)
|
)
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
|
from backend.utils.timezone import local_to_utc
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
|
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Shared helpers
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def _require_sound_project(project) -> None:
|
||||||
|
"""Raise 400 if the project is not a sound_monitoring project."""
|
||||||
|
if not project or project.project_type_id != "sound_monitoring":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="This feature is only available for Sound Monitoring projects.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Session period helpers
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def _derive_period_type(dt: datetime) -> str:
|
||||||
|
"""
|
||||||
|
Classify a session start time into one of four period types.
|
||||||
|
Night = 22:00–07:00, Day = 07:00–22:00.
|
||||||
|
Weekend = Saturday (5) or Sunday (6).
|
||||||
|
"""
|
||||||
|
is_weekend = dt.weekday() >= 5
|
||||||
|
is_night = dt.hour >= 22 or dt.hour < 7
|
||||||
|
if is_weekend:
|
||||||
|
return "weekend_night" if is_night else "weekend_day"
|
||||||
|
return "weekday_night" if is_night else "weekday_day"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_session_label(dt: datetime, location_name: str, period_type: str) -> str:
|
||||||
|
"""Build a human-readable session label, e.g. 'NRL-1 — Sun 2/23 — Night'.
|
||||||
|
Uses started_at date as-is; user can correct period_type in the wizard.
|
||||||
|
"""
|
||||||
|
day_abbr = dt.strftime("%a")
|
||||||
|
date_str = f"{dt.month}/{dt.day}"
|
||||||
|
period_str = {
|
||||||
|
"weekday_day": "Day",
|
||||||
|
"weekday_night": "Night",
|
||||||
|
"weekend_day": "Day",
|
||||||
|
"weekend_night": "Night",
|
||||||
|
}.get(period_type, "")
|
||||||
|
parts = [p for p in [location_name, f"{day_abbr} {date_str}", period_str] if p]
|
||||||
|
return " — ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Monitoring Locations CRUD
|
# Monitoring Locations CRUD
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -65,11 +112,11 @@ async def get_project_locations(
|
|||||||
# Enrich with assignment info
|
# Enrich with assignment info
|
||||||
locations_data = []
|
locations_data = []
|
||||||
for location in locations:
|
for location in locations:
|
||||||
# Get active assignment
|
# Get active assignment (active = assigned_until IS NULL)
|
||||||
assignment = db.query(UnitAssignment).filter(
|
assignment = db.query(UnitAssignment).filter(
|
||||||
and_(
|
and_(
|
||||||
UnitAssignment.location_id == location.id,
|
UnitAssignment.location_id == location.id,
|
||||||
UnitAssignment.status == "active",
|
UnitAssignment.assigned_until == None,
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -225,11 +272,11 @@ async def delete_location(
|
|||||||
if not location:
|
if not location:
|
||||||
raise HTTPException(status_code=404, detail="Location not found")
|
raise HTTPException(status_code=404, detail="Location not found")
|
||||||
|
|
||||||
# Check if location has active assignments
|
# Check if location has active assignments (active = assigned_until IS NULL)
|
||||||
active_assignments = db.query(UnitAssignment).filter(
|
active_assignments = db.query(UnitAssignment).filter(
|
||||||
and_(
|
and_(
|
||||||
UnitAssignment.location_id == location_id,
|
UnitAssignment.location_id == location_id,
|
||||||
UnitAssignment.status == "active",
|
UnitAssignment.assigned_until == None,
|
||||||
)
|
)
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
@@ -320,18 +367,18 @@ async def assign_unit_to_location(
|
|||||||
detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
|
detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if location already has an active assignment
|
# Check if location already has an active assignment (active = assigned_until IS NULL)
|
||||||
existing_assignment = db.query(UnitAssignment).filter(
|
existing_assignment = db.query(UnitAssignment).filter(
|
||||||
and_(
|
and_(
|
||||||
UnitAssignment.location_id == location_id,
|
UnitAssignment.location_id == location_id,
|
||||||
UnitAssignment.status == "active",
|
UnitAssignment.assigned_until == None,
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
if existing_assignment:
|
if existing_assignment:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Location already has an active unit assignment ({existing_assignment.unit_id}). Unassign first.",
|
detail=f"Location already has an active unit assignment ({existing_assignment.unit_id}). Use swap to replace it.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create new assignment
|
# Create new assignment
|
||||||
@@ -400,10 +447,120 @@ async def unassign_unit(
|
|||||||
return {"success": True, "message": "Unit unassigned successfully"}
|
return {"success": True, "message": "Unit unassigned successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/locations/{location_id}/swap")
|
||||||
|
async def swap_unit_on_location(
|
||||||
|
project_id: str,
|
||||||
|
location_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Swap the unit assigned to a vibration monitoring location.
|
||||||
|
Ends the current active assignment (if any), creates a new one,
|
||||||
|
and optionally updates modem pairing on the seismograph.
|
||||||
|
Works for first-time assignments too (no current assignment = just create).
|
||||||
|
"""
|
||||||
|
location = db.query(MonitoringLocation).filter_by(
|
||||||
|
id=location_id,
|
||||||
|
project_id=project_id,
|
||||||
|
).first()
|
||||||
|
if not location:
|
||||||
|
raise HTTPException(status_code=404, detail="Location not found")
|
||||||
|
|
||||||
|
form_data = await request.form()
|
||||||
|
unit_id = form_data.get("unit_id")
|
||||||
|
modem_id = form_data.get("modem_id") or None
|
||||||
|
notes = form_data.get("notes") or None
|
||||||
|
|
||||||
|
if not unit_id:
|
||||||
|
raise HTTPException(status_code=400, detail="unit_id is required")
|
||||||
|
|
||||||
|
# Validate new unit
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
|
||||||
|
expected_device_type = "slm" if location.location_type == "sound" else "seismograph"
|
||||||
|
if unit.device_type != expected_device_type:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
|
||||||
|
)
|
||||||
|
|
||||||
|
# End current active assignment if one exists (active = assigned_until IS NULL)
|
||||||
|
current = db.query(UnitAssignment).filter(
|
||||||
|
and_(
|
||||||
|
UnitAssignment.location_id == location_id,
|
||||||
|
UnitAssignment.assigned_until == None,
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if current:
|
||||||
|
current.assigned_until = datetime.utcnow()
|
||||||
|
current.status = "completed"
|
||||||
|
|
||||||
|
# Create new assignment
|
||||||
|
new_assignment = UnitAssignment(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
unit_id=unit_id,
|
||||||
|
location_id=location_id,
|
||||||
|
project_id=project_id,
|
||||||
|
device_type=unit.device_type,
|
||||||
|
assigned_until=None,
|
||||||
|
status="active",
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
db.add(new_assignment)
|
||||||
|
|
||||||
|
# Update modem pairing on the seismograph if modem provided
|
||||||
|
if modem_id:
|
||||||
|
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
|
||||||
|
if not modem:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Modem '{modem_id}' not found")
|
||||||
|
unit.deployed_with_modem_id = modem_id
|
||||||
|
modem.deployed_with_unit_id = unit_id
|
||||||
|
else:
|
||||||
|
# Clear modem pairing if not provided
|
||||||
|
unit.deployed_with_modem_id = None
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"success": True,
|
||||||
|
"assignment_id": new_assignment.id,
|
||||||
|
"message": f"Unit '{unit_id}' assigned to '{location.name}'" + (f" with modem '{modem_id}'" if modem_id else ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Available Units for Assignment
|
# Available Units for Assignment
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/available-modems", response_class=JSONResponse)
|
||||||
|
async def get_available_modems(
|
||||||
|
project_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get all deployed, non-retired modems for the modem assignment dropdown.
|
||||||
|
"""
|
||||||
|
modems = db.query(RosterUnit).filter(
|
||||||
|
and_(
|
||||||
|
RosterUnit.device_type == "modem",
|
||||||
|
RosterUnit.deployed == True,
|
||||||
|
RosterUnit.retired == False,
|
||||||
|
)
|
||||||
|
).order_by(RosterUnit.id).all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": m.id,
|
||||||
|
"hardware_model": m.hardware_model,
|
||||||
|
"ip_address": m.ip_address,
|
||||||
|
}
|
||||||
|
for m in modems
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/available-units", response_class=JSONResponse)
|
@router.get("/available-units", response_class=JSONResponse)
|
||||||
async def get_available_units(
|
async def get_available_units(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
@@ -426,9 +583,9 @@ async def get_available_units(
|
|||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
# Filter out units that already have active assignments
|
# Filter out units that already have active assignments (active = assigned_until IS NULL)
|
||||||
assigned_unit_ids = db.query(UnitAssignment.unit_id).filter(
|
assigned_unit_ids = db.query(UnitAssignment.unit_id).filter(
|
||||||
UnitAssignment.status == "active"
|
UnitAssignment.assigned_until == None
|
||||||
).distinct().all()
|
).distinct().all()
|
||||||
assigned_unit_ids = [uid[0] for uid in assigned_unit_ids]
|
assigned_unit_ids = [uid[0] for uid in assigned_unit_ids]
|
||||||
|
|
||||||
@@ -604,6 +761,9 @@ async def upload_nrl_data(
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
# Verify project and location exist
|
# Verify project and location exist
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
_require_sound_project(project)
|
||||||
|
|
||||||
location = db.query(MonitoringLocation).filter_by(
|
location = db.query(MonitoringLocation).filter_by(
|
||||||
id=location_id, project_id=project_id
|
id=location_id, project_id=project_id
|
||||||
).first()
|
).first()
|
||||||
@@ -634,6 +794,30 @@ async def upload_nrl_data(
|
|||||||
if not file_entries:
|
if not file_entries:
|
||||||
raise HTTPException(status_code=400, detail="No usable files found in upload.")
|
raise HTTPException(status_code=400, detail="No usable files found in upload.")
|
||||||
|
|
||||||
|
# --- Step 1b: Filter to only relevant files ---
|
||||||
|
# Keep: .rnh (metadata) and measurement .rnd files
|
||||||
|
# NL-43 generates two .rnd types: _Leq_ (15-min averages, wanted) and _Lp_ (1-sec granular, skip)
|
||||||
|
# AU2 (NL-23/older Rion) generates a single Au2_####.rnd per session — always keep those
|
||||||
|
# Drop: _Lp_ .rnd, .xlsx, .mp3, and anything else
|
||||||
|
def _is_wanted(fname: str) -> bool:
|
||||||
|
n = fname.lower()
|
||||||
|
if n.endswith(".rnh"):
|
||||||
|
return True
|
||||||
|
if n.endswith(".rnd"):
|
||||||
|
if "_leq_" in n: # NL-43 Leq file
|
||||||
|
return True
|
||||||
|
if n.startswith("au2_"): # AU2 format (NL-23) — always Leq equivalent
|
||||||
|
return True
|
||||||
|
if "_lp" not in n and "_leq_" not in n:
|
||||||
|
# Unknown .rnd format — include it so we don't silently drop data
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
file_entries = [(fname, fbytes) for fname, fbytes in file_entries if _is_wanted(fname)]
|
||||||
|
|
||||||
|
if not file_entries:
|
||||||
|
raise HTTPException(status_code=400, detail="No usable .rnd or .rnh files found. Expected NL-43 _Leq_ files or AU2 format .rnd files.")
|
||||||
|
|
||||||
# --- Step 2: Find and parse .rnh metadata ---
|
# --- Step 2: Find and parse .rnh metadata ---
|
||||||
rnh_meta = {}
|
rnh_meta = {}
|
||||||
for fname, fbytes in file_entries:
|
for fname, fbytes in file_entries:
|
||||||
@@ -641,8 +825,15 @@ async def upload_nrl_data(
|
|||||||
rnh_meta = _parse_rnh(fbytes)
|
rnh_meta = _parse_rnh(fbytes)
|
||||||
break
|
break
|
||||||
|
|
||||||
started_at = _parse_rnh_datetime(rnh_meta.get("start_time_str")) or datetime.utcnow()
|
# RNH files store local time (no UTC offset). Use local values for period
|
||||||
stopped_at = _parse_rnh_datetime(rnh_meta.get("stop_time_str"))
|
# classification / label generation, then convert to UTC for DB storage so
|
||||||
|
# the local_datetime Jinja filter displays the correct time.
|
||||||
|
started_at_local = _parse_rnh_datetime(rnh_meta.get("start_time_str")) or datetime.utcnow()
|
||||||
|
stopped_at_local = _parse_rnh_datetime(rnh_meta.get("stop_time_str"))
|
||||||
|
|
||||||
|
started_at = local_to_utc(started_at_local)
|
||||||
|
stopped_at = local_to_utc(stopped_at_local) if stopped_at_local else None
|
||||||
|
|
||||||
duration_seconds = None
|
duration_seconds = None
|
||||||
if started_at and stopped_at:
|
if started_at and stopped_at:
|
||||||
duration_seconds = int((stopped_at - started_at).total_seconds())
|
duration_seconds = int((stopped_at - started_at).total_seconds())
|
||||||
@@ -652,6 +843,10 @@ async def upload_nrl_data(
|
|||||||
index_number = rnh_meta.get("index_number", "")
|
index_number = rnh_meta.get("index_number", "")
|
||||||
|
|
||||||
# --- Step 3: Create MonitoringSession ---
|
# --- Step 3: Create MonitoringSession ---
|
||||||
|
# Use local times for period/label so classification reflects the clock at the site.
|
||||||
|
period_type = _derive_period_type(started_at_local) if started_at_local else None
|
||||||
|
session_label = _build_session_label(started_at_local, location.name, period_type) if started_at_local else None
|
||||||
|
|
||||||
session_id = str(uuid.uuid4())
|
session_id = str(uuid.uuid4())
|
||||||
monitoring_session = MonitoringSession(
|
monitoring_session = MonitoringSession(
|
||||||
id=session_id,
|
id=session_id,
|
||||||
@@ -663,6 +858,8 @@ async def upload_nrl_data(
|
|||||||
stopped_at=stopped_at,
|
stopped_at=stopped_at,
|
||||||
duration_seconds=duration_seconds,
|
duration_seconds=duration_seconds,
|
||||||
status="completed",
|
status="completed",
|
||||||
|
session_label=session_label,
|
||||||
|
period_type=period_type,
|
||||||
session_metadata=json.dumps({
|
session_metadata=json.dumps({
|
||||||
"source": "manual_upload",
|
"source": "manual_upload",
|
||||||
"store_name": store_name,
|
"store_name": store_name,
|
||||||
@@ -753,15 +950,18 @@ async def get_nrl_live_status(
|
|||||||
Fetch cached status from SLMM for the unit assigned to this NRL and
|
Fetch cached status from SLMM for the unit assigned to this NRL and
|
||||||
return a compact HTML status card. Used in the NRL overview tab for
|
return a compact HTML status card. Used in the NRL overview tab for
|
||||||
connected NRLs. Gracefully shows an offline message if SLMM is unreachable.
|
connected NRLs. Gracefully shows an offline message if SLMM is unreachable.
|
||||||
|
Sound Monitoring projects only.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
# Find the assigned unit
|
_require_sound_project(db.query(Project).filter_by(id=project_id).first())
|
||||||
|
|
||||||
|
# Find the assigned unit (active = assigned_until IS NULL)
|
||||||
assignment = db.query(UnitAssignment).filter(
|
assignment = db.query(UnitAssignment).filter(
|
||||||
and_(
|
and_(
|
||||||
UnitAssignment.location_id == location_id,
|
UnitAssignment.location_id == location_id,
|
||||||
UnitAssignment.status == "active",
|
UnitAssignment.assigned_until == None,
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,8 @@ import httpx
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences
|
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory, UserPreferences, DeploymentRecord
|
||||||
|
import uuid
|
||||||
from backend.services.slmm_sync import sync_slm_to_slmm
|
from backend.services.slmm_sync import sync_slm_to_slmm
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
||||||
@@ -27,6 +28,38 @@ def get_calibration_interval(db: Session) -> int:
|
|||||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||||
|
|
||||||
|
|
||||||
|
def sync_deployment_record(db: Session, unit: RosterUnit, new_deployed: bool):
|
||||||
|
"""
|
||||||
|
Keep DeploymentRecord in sync with the deployed flag.
|
||||||
|
|
||||||
|
deployed True → open a new DeploymentRecord if none is already open.
|
||||||
|
deployed False → close the active DeploymentRecord by setting actual_removal_date = today.
|
||||||
|
"""
|
||||||
|
if new_deployed:
|
||||||
|
existing = db.query(DeploymentRecord).filter(
|
||||||
|
DeploymentRecord.unit_id == unit.id,
|
||||||
|
DeploymentRecord.actual_removal_date == None
|
||||||
|
).first()
|
||||||
|
if not existing:
|
||||||
|
record = DeploymentRecord(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
unit_id=unit.id,
|
||||||
|
project_ref=unit.project_id or None,
|
||||||
|
deployed_date=date.today(),
|
||||||
|
created_at=datetime.utcnow(),
|
||||||
|
updated_at=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
else:
|
||||||
|
active = db.query(DeploymentRecord).filter(
|
||||||
|
DeploymentRecord.unit_id == unit.id,
|
||||||
|
DeploymentRecord.actual_removal_date == None
|
||||||
|
).first()
|
||||||
|
if active:
|
||||||
|
active.actual_removal_date = date.today()
|
||||||
|
active.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
def record_history(db: Session, unit_id: str, change_type: str, field_name: str = None,
|
def record_history(db: Session, unit_id: str, change_type: str, field_name: str = None,
|
||||||
old_value: str = None, new_value: str = None, source: str = "manual", notes: str = None):
|
old_value: str = None, new_value: str = None, source: str = "manual", notes: str = None):
|
||||||
"""Helper function to record a change in unit history"""
|
"""Helper function to record a change in unit history"""
|
||||||
@@ -146,6 +179,7 @@ async def add_roster_unit(
|
|||||||
unit_type: str = Form("series3"),
|
unit_type: str = Form("series3"),
|
||||||
deployed: str = Form(None),
|
deployed: str = Form(None),
|
||||||
retired: str = Form(None),
|
retired: str = Form(None),
|
||||||
|
out_for_calibration: str = Form(None),
|
||||||
note: str = Form(""),
|
note: str = Form(""),
|
||||||
project_id: str = Form(None),
|
project_id: str = Form(None),
|
||||||
location: str = Form(None),
|
location: str = Form(None),
|
||||||
@@ -177,6 +211,7 @@ async def add_roster_unit(
|
|||||||
# Convert boolean strings to actual booleans
|
# Convert boolean strings to actual booleans
|
||||||
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
||||||
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
||||||
|
out_for_calibration_bool = out_for_calibration in ['true', 'True', '1', 'yes'] if out_for_calibration else False
|
||||||
|
|
||||||
# Convert port strings to integers
|
# Convert port strings to integers
|
||||||
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
||||||
@@ -211,6 +246,7 @@ async def add_roster_unit(
|
|||||||
unit_type=unit_type,
|
unit_type=unit_type,
|
||||||
deployed=deployed_bool,
|
deployed=deployed_bool,
|
||||||
retired=retired_bool,
|
retired=retired_bool,
|
||||||
|
out_for_calibration=out_for_calibration_bool,
|
||||||
note=note,
|
note=note,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
location=location,
|
location=location,
|
||||||
@@ -463,6 +499,9 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
"unit_type": unit.unit_type,
|
"unit_type": unit.unit_type,
|
||||||
"deployed": unit.deployed,
|
"deployed": unit.deployed,
|
||||||
"retired": unit.retired,
|
"retired": unit.retired,
|
||||||
|
"out_for_calibration": unit.out_for_calibration or False,
|
||||||
|
"allocated": getattr(unit, 'allocated', False) or False,
|
||||||
|
"allocated_to_project_id": getattr(unit, 'allocated_to_project_id', None) or "",
|
||||||
"note": unit.note or "",
|
"note": unit.note or "",
|
||||||
"project_id": unit.project_id or "",
|
"project_id": unit.project_id or "",
|
||||||
"location": unit.location or "",
|
"location": unit.location or "",
|
||||||
@@ -494,6 +533,9 @@ async def edit_roster_unit(
|
|||||||
unit_type: str = Form("series3"),
|
unit_type: str = Form("series3"),
|
||||||
deployed: str = Form(None),
|
deployed: str = Form(None),
|
||||||
retired: str = Form(None),
|
retired: str = Form(None),
|
||||||
|
out_for_calibration: str = Form(None),
|
||||||
|
allocated: str = Form(None),
|
||||||
|
allocated_to_project_id: str = Form(None),
|
||||||
note: str = Form(""),
|
note: str = Form(""),
|
||||||
project_id: str = Form(None),
|
project_id: str = Form(None),
|
||||||
location: str = Form(None),
|
location: str = Form(None),
|
||||||
@@ -535,6 +577,8 @@ async def edit_roster_unit(
|
|||||||
# Convert boolean strings to actual booleans
|
# Convert boolean strings to actual booleans
|
||||||
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
||||||
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
||||||
|
out_for_calibration_bool = out_for_calibration in ['true', 'True', '1', 'yes'] if out_for_calibration else False
|
||||||
|
allocated_bool = allocated in ['true', 'True', '1', 'yes'] if allocated else False
|
||||||
|
|
||||||
# Convert port strings to integers
|
# Convert port strings to integers
|
||||||
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
||||||
@@ -564,12 +608,16 @@ async def edit_roster_unit(
|
|||||||
old_note = unit.note
|
old_note = unit.note
|
||||||
old_deployed = unit.deployed
|
old_deployed = unit.deployed
|
||||||
old_retired = unit.retired
|
old_retired = unit.retired
|
||||||
|
old_out_for_calibration = unit.out_for_calibration
|
||||||
|
|
||||||
# Update all fields
|
# Update all fields
|
||||||
unit.device_type = device_type
|
unit.device_type = device_type
|
||||||
unit.unit_type = unit_type
|
unit.unit_type = unit_type
|
||||||
unit.deployed = deployed_bool
|
unit.deployed = deployed_bool
|
||||||
unit.retired = retired_bool
|
unit.retired = retired_bool
|
||||||
|
unit.out_for_calibration = out_for_calibration_bool
|
||||||
|
unit.allocated = allocated_bool
|
||||||
|
unit.allocated_to_project_id = allocated_to_project_id if allocated_bool else None
|
||||||
unit.note = note
|
unit.note = note
|
||||||
unit.project_id = project_id
|
unit.project_id = project_id
|
||||||
unit.location = location
|
unit.location = location
|
||||||
@@ -671,12 +719,18 @@ async def edit_roster_unit(
|
|||||||
status_text = "deployed" if deployed else "benched"
|
status_text = "deployed" if deployed else "benched"
|
||||||
old_status_text = "deployed" if old_deployed else "benched"
|
old_status_text = "deployed" if old_deployed else "benched"
|
||||||
record_history(db, unit_id, "deployed_change", "deployed", old_status_text, status_text, "manual")
|
record_history(db, unit_id, "deployed_change", "deployed", old_status_text, status_text, "manual")
|
||||||
|
sync_deployment_record(db, unit, deployed_bool)
|
||||||
|
|
||||||
if old_retired != retired:
|
if old_retired != retired:
|
||||||
status_text = "retired" if retired else "active"
|
status_text = "retired" if retired else "active"
|
||||||
old_status_text = "retired" if old_retired else "active"
|
old_status_text = "retired" if old_retired else "active"
|
||||||
record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual")
|
record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual")
|
||||||
|
|
||||||
|
if old_out_for_calibration != out_for_calibration_bool:
|
||||||
|
status_text = "out_for_calibration" if out_for_calibration_bool else "available"
|
||||||
|
old_status_text = "out_for_calibration" if old_out_for_calibration else "available"
|
||||||
|
record_history(db, unit_id, "calibration_status_change", "out_for_calibration", old_status_text, status_text, "manual")
|
||||||
|
|
||||||
# Handle cascade to paired device
|
# Handle cascade to paired device
|
||||||
cascaded_unit_id = None
|
cascaded_unit_id = None
|
||||||
if cascade_to_unit_id and cascade_to_unit_id.strip():
|
if cascade_to_unit_id and cascade_to_unit_id.strip():
|
||||||
@@ -782,6 +836,7 @@ async def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = D
|
|||||||
new_value=status_text,
|
new_value=status_text,
|
||||||
source="manual"
|
source="manual"
|
||||||
)
|
)
|
||||||
|
sync_deployment_record(db, unit, deployed)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ Seismograph Dashboard API Router
|
|||||||
Provides endpoints for the seismograph-specific dashboard
|
Provides endpoints for the seismograph-specific dashboard
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Depends, Query
|
from fastapi import APIRouter, Request, Depends, Query, Form, HTTPException
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import RosterUnit
|
from backend.models import RosterUnit, UnitHistory, UserPreferences
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"])
|
router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"])
|
||||||
@@ -28,7 +28,8 @@ async def get_seismo_stats(request: Request, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
total = len(seismos)
|
total = len(seismos)
|
||||||
deployed = sum(1 for s in seismos if s.deployed)
|
deployed = sum(1 for s in seismos if s.deployed)
|
||||||
benched = sum(1 for s in seismos if not s.deployed)
|
benched = sum(1 for s in seismos if not s.deployed and not s.out_for_calibration)
|
||||||
|
out_for_calibration = sum(1 for s in seismos if s.out_for_calibration)
|
||||||
|
|
||||||
# Count modems assigned to deployed seismographs
|
# Count modems assigned to deployed seismographs
|
||||||
with_modem = sum(1 for s in seismos if s.deployed and s.deployed_with_modem_id)
|
with_modem = sum(1 for s in seismos if s.deployed and s.deployed_with_modem_id)
|
||||||
@@ -41,6 +42,7 @@ async def get_seismo_stats(request: Request, db: Session = Depends(get_db)):
|
|||||||
"total": total,
|
"total": total,
|
||||||
"deployed": deployed,
|
"deployed": deployed,
|
||||||
"benched": benched,
|
"benched": benched,
|
||||||
|
"out_for_calibration": out_for_calibration,
|
||||||
"with_modem": with_modem,
|
"with_modem": with_modem,
|
||||||
"without_modem": without_modem
|
"without_modem": without_modem
|
||||||
}
|
}
|
||||||
@@ -77,7 +79,9 @@ async def get_seismo_units(
|
|||||||
if status == "deployed":
|
if status == "deployed":
|
||||||
query = query.filter(RosterUnit.deployed == True)
|
query = query.filter(RosterUnit.deployed == True)
|
||||||
elif status == "benched":
|
elif status == "benched":
|
||||||
query = query.filter(RosterUnit.deployed == False)
|
query = query.filter(RosterUnit.deployed == False, RosterUnit.out_for_calibration == False)
|
||||||
|
elif status == "out_for_calibration":
|
||||||
|
query = query.filter(RosterUnit.out_for_calibration == True)
|
||||||
|
|
||||||
# Apply modem filter
|
# Apply modem filter
|
||||||
if modem == "with":
|
if modem == "with":
|
||||||
@@ -116,3 +120,109 @@ async def get_seismo_units(
|
|||||||
"today": date.today()
|
"today": date.today()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_calibration_interval(db: Session) -> int:
|
||||||
|
prefs = db.query(UserPreferences).first()
|
||||||
|
if prefs and prefs.calibration_interval_days:
|
||||||
|
return prefs.calibration_interval_days
|
||||||
|
return 365
|
||||||
|
|
||||||
|
|
||||||
|
def _row_context(request: Request, unit: RosterUnit) -> dict:
|
||||||
|
return {"request": request, "unit": unit, "today": date.today()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unit/{unit_id}/view-row", response_class=HTMLResponse)
|
||||||
|
async def get_seismo_view_row(unit_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
return templates.TemplateResponse("partials/seismo_row_view.html", _row_context(request, unit))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unit/{unit_id}/edit-row", response_class=HTMLResponse)
|
||||||
|
async def get_seismo_edit_row(unit_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
return templates.TemplateResponse("partials/seismo_row_edit.html", _row_context(request, unit))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/unit/{unit_id}/quick-update", response_class=HTMLResponse)
|
||||||
|
async def quick_update_seismo_unit(
|
||||||
|
unit_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
status: str = Form(...),
|
||||||
|
last_calibrated: str = Form(""),
|
||||||
|
note: str = Form(""),
|
||||||
|
):
|
||||||
|
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
|
||||||
|
# --- Status ---
|
||||||
|
old_deployed = unit.deployed
|
||||||
|
old_out_for_cal = unit.out_for_calibration
|
||||||
|
if status == "deployed":
|
||||||
|
unit.deployed = True
|
||||||
|
unit.out_for_calibration = False
|
||||||
|
elif status == "out_for_calibration":
|
||||||
|
unit.deployed = False
|
||||||
|
unit.out_for_calibration = True
|
||||||
|
else:
|
||||||
|
unit.deployed = False
|
||||||
|
unit.out_for_calibration = False
|
||||||
|
|
||||||
|
if unit.deployed != old_deployed or unit.out_for_calibration != old_out_for_cal:
|
||||||
|
old_status = "deployed" if old_deployed else ("out_for_calibration" if old_out_for_cal else "benched")
|
||||||
|
db.add(UnitHistory(
|
||||||
|
unit_id=unit_id,
|
||||||
|
change_type="deployed_change",
|
||||||
|
field_name="status",
|
||||||
|
old_value=old_status,
|
||||||
|
new_value=status,
|
||||||
|
source="manual",
|
||||||
|
))
|
||||||
|
|
||||||
|
# --- Last calibrated ---
|
||||||
|
old_cal = unit.last_calibrated
|
||||||
|
if last_calibrated:
|
||||||
|
try:
|
||||||
|
new_cal = datetime.strptime(last_calibrated, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
|
||||||
|
unit.last_calibrated = new_cal
|
||||||
|
unit.next_calibration_due = new_cal + timedelta(days=_get_calibration_interval(db))
|
||||||
|
else:
|
||||||
|
unit.last_calibrated = None
|
||||||
|
unit.next_calibration_due = None
|
||||||
|
|
||||||
|
if unit.last_calibrated != old_cal:
|
||||||
|
db.add(UnitHistory(
|
||||||
|
unit_id=unit_id,
|
||||||
|
change_type="calibration_status_change",
|
||||||
|
field_name="last_calibrated",
|
||||||
|
old_value=old_cal.strftime("%Y-%m-%d") if old_cal else None,
|
||||||
|
new_value=last_calibrated or None,
|
||||||
|
source="manual",
|
||||||
|
))
|
||||||
|
|
||||||
|
# --- Note ---
|
||||||
|
old_note = unit.note
|
||||||
|
unit.note = note or None
|
||||||
|
if unit.note != old_note:
|
||||||
|
db.add(UnitHistory(
|
||||||
|
unit_id=unit_id,
|
||||||
|
change_type="note_change",
|
||||||
|
field_name="note",
|
||||||
|
old_value=old_note,
|
||||||
|
new_value=unit.note,
|
||||||
|
source="manual",
|
||||||
|
))
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(unit)
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/seismo_row_view.html", _row_context(request, unit))
|
||||||
|
|||||||
133
backend/routers/watcher_manager.py
Normal file
133
backend/routers/watcher_manager.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"""
|
||||||
|
Watcher Manager — admin API for series3-watcher and thor-watcher agents.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
GET /api/admin/watchers — list all watcher agents
|
||||||
|
GET /api/admin/watchers/{agent_id} — get single agent detail
|
||||||
|
POST /api/admin/watchers/{agent_id}/trigger-update — flag agent for update
|
||||||
|
POST /api/admin/watchers/{agent_id}/clear-update — clear update flag
|
||||||
|
GET /api/admin/watchers/{agent_id}/update-check — polled by watcher on heartbeat
|
||||||
|
|
||||||
|
Page:
|
||||||
|
GET /admin/watchers — HTML admin page
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import WatcherAgent
|
||||||
|
from backend.templates_config import templates
|
||||||
|
|
||||||
|
router = APIRouter(tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _agent_to_dict(agent: WatcherAgent) -> dict:
|
||||||
|
last_seen = agent.last_seen
|
||||||
|
if last_seen:
|
||||||
|
now_utc = datetime.utcnow()
|
||||||
|
age_minutes = int((now_utc - last_seen).total_seconds() // 60)
|
||||||
|
if age_minutes > 60:
|
||||||
|
status = "missing"
|
||||||
|
else:
|
||||||
|
status = "ok"
|
||||||
|
else:
|
||||||
|
age_minutes = None
|
||||||
|
status = "missing"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": agent.id,
|
||||||
|
"source_type": agent.source_type,
|
||||||
|
"version": agent.version,
|
||||||
|
"last_seen": last_seen.isoformat() if last_seen else None,
|
||||||
|
"age_minutes": age_minutes,
|
||||||
|
"status": status,
|
||||||
|
"ip_address": agent.ip_address,
|
||||||
|
"log_tail": agent.log_tail,
|
||||||
|
"update_pending": bool(agent.update_pending),
|
||||||
|
"update_version": agent.update_version,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── API routes ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/api/admin/watchers")
|
||||||
|
def list_watchers(db: Session = Depends(get_db)):
|
||||||
|
agents = db.query(WatcherAgent).order_by(WatcherAgent.last_seen.desc()).all()
|
||||||
|
return [_agent_to_dict(a) for a in agents]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/admin/watchers/{agent_id}")
|
||||||
|
def get_watcher(agent_id: str, db: Session = Depends(get_db)):
|
||||||
|
agent = db.query(WatcherAgent).filter(WatcherAgent.id == agent_id).first()
|
||||||
|
if not agent:
|
||||||
|
raise HTTPException(status_code=404, detail="Watcher agent not found")
|
||||||
|
return _agent_to_dict(agent)
|
||||||
|
|
||||||
|
|
||||||
|
class TriggerUpdateRequest(BaseModel):
|
||||||
|
version: Optional[str] = None # target version label (informational)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/admin/watchers/{agent_id}/trigger-update")
|
||||||
|
def trigger_update(agent_id: str, body: TriggerUpdateRequest, db: Session = Depends(get_db)):
|
||||||
|
agent = db.query(WatcherAgent).filter(WatcherAgent.id == agent_id).first()
|
||||||
|
if not agent:
|
||||||
|
raise HTTPException(status_code=404, detail="Watcher agent not found")
|
||||||
|
agent.update_pending = True
|
||||||
|
agent.update_version = body.version
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "agent_id": agent_id, "update_pending": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/admin/watchers/{agent_id}/clear-update")
|
||||||
|
def clear_update(agent_id: str, db: Session = Depends(get_db)):
|
||||||
|
agent = db.query(WatcherAgent).filter(WatcherAgent.id == agent_id).first()
|
||||||
|
if not agent:
|
||||||
|
raise HTTPException(status_code=404, detail="Watcher agent not found")
|
||||||
|
agent.update_pending = False
|
||||||
|
agent.update_version = None
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "agent_id": agent_id, "update_pending": False}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/admin/watchers/{agent_id}/update-check")
|
||||||
|
def update_check(agent_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Polled by watcher agents on each heartbeat cycle.
|
||||||
|
Returns update_available=True when an update has been triggered via the UI.
|
||||||
|
Automatically clears the flag after the watcher acknowledges it.
|
||||||
|
"""
|
||||||
|
agent = db.query(WatcherAgent).filter(WatcherAgent.id == agent_id).first()
|
||||||
|
if not agent:
|
||||||
|
return {"update_available": False}
|
||||||
|
|
||||||
|
pending = bool(agent.update_pending)
|
||||||
|
|
||||||
|
if pending:
|
||||||
|
# Clear the flag — the watcher will now self-update
|
||||||
|
agent.update_pending = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"update_available": pending,
|
||||||
|
"version": agent.update_version,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTML page ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/admin/watchers", response_class=HTMLResponse)
|
||||||
|
def admin_watchers_page(request: Request, db: Session = Depends(get_db)):
|
||||||
|
agents = db.query(WatcherAgent).order_by(WatcherAgent.last_seen.desc()).all()
|
||||||
|
agents_data = [_agent_to_dict(a) for a in agents]
|
||||||
|
return templates.TemplateResponse("admin_watchers.html", {
|
||||||
|
"request": request,
|
||||||
|
"agents": agents_data,
|
||||||
|
})
|
||||||
@@ -5,7 +5,7 @@ from datetime import datetime
|
|||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import Emitter
|
from backend.models import Emitter, WatcherAgent
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -107,6 +107,35 @@ def get_fleet_status(db: Session = Depends(get_db)):
|
|||||||
emitters = db.query(Emitter).all()
|
emitters = db.query(Emitter).all()
|
||||||
return emitters
|
return emitters
|
||||||
|
|
||||||
|
# ── Watcher agent upsert helper ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _upsert_watcher_agent(db: Session, source_id: str, source_type: str,
|
||||||
|
version: str, ip_address: str, log_tail: str,
|
||||||
|
status: str) -> None:
|
||||||
|
"""Create or update the WatcherAgent row for a given source_id."""
|
||||||
|
agent = db.query(WatcherAgent).filter(WatcherAgent.id == source_id).first()
|
||||||
|
if agent:
|
||||||
|
agent.source_type = source_type
|
||||||
|
agent.version = version
|
||||||
|
agent.last_seen = datetime.utcnow()
|
||||||
|
agent.status = status
|
||||||
|
if ip_address:
|
||||||
|
agent.ip_address = ip_address
|
||||||
|
if log_tail is not None:
|
||||||
|
agent.log_tail = log_tail
|
||||||
|
else:
|
||||||
|
agent = WatcherAgent(
|
||||||
|
id=source_id,
|
||||||
|
source_type=source_type,
|
||||||
|
version=version,
|
||||||
|
last_seen=datetime.utcnow(),
|
||||||
|
status=status,
|
||||||
|
ip_address=ip_address,
|
||||||
|
log_tail=log_tail,
|
||||||
|
)
|
||||||
|
db.add(agent)
|
||||||
|
|
||||||
|
|
||||||
# series3v1.1 Standardized Heartbeat Schema (multi-unit)
|
# series3v1.1 Standardized Heartbeat Schema (multi-unit)
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
@@ -120,6 +149,11 @@ async def series3_heartbeat(request: Request, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
source = payload.get("source_id")
|
source = payload.get("source_id")
|
||||||
units = payload.get("units", [])
|
units = payload.get("units", [])
|
||||||
|
version = payload.get("version")
|
||||||
|
log_tail = payload.get("log_tail") # list of strings or None
|
||||||
|
import json as _json
|
||||||
|
log_tail_str = _json.dumps(log_tail) if log_tail is not None else None
|
||||||
|
client_ip = request.client.host if request.client else None
|
||||||
|
|
||||||
print("\n=== Series 3 Heartbeat ===")
|
print("\n=== Series 3 Heartbeat ===")
|
||||||
print("Source:", source)
|
print("Source:", source)
|
||||||
@@ -182,13 +216,27 @@ async def series3_heartbeat(request: Request, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
results.append({"unit": uid, "status": status})
|
results.append({"unit": uid, "status": status})
|
||||||
|
|
||||||
|
if source:
|
||||||
|
_upsert_watcher_agent(db, source, "series3_watcher", version,
|
||||||
|
client_ip, log_tail_str, "ok")
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
# Check if an update has been triggered for this agent
|
||||||
|
update_available = False
|
||||||
|
if source:
|
||||||
|
agent = db.query(WatcherAgent).filter(WatcherAgent.id == source).first()
|
||||||
|
if agent and agent.update_pending:
|
||||||
|
update_available = True
|
||||||
|
agent.update_pending = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"message": "Heartbeat processed",
|
"message": "Heartbeat processed",
|
||||||
"source": source,
|
"source": source,
|
||||||
"units_processed": len(results),
|
"units_processed": len(results),
|
||||||
"results": results
|
"results": results,
|
||||||
|
"update_available": update_available,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -219,8 +267,14 @@ async def series4_heartbeat(request: Request, db: Session = Depends(get_db)):
|
|||||||
"""
|
"""
|
||||||
payload = await request.json()
|
payload = await request.json()
|
||||||
|
|
||||||
source = payload.get("source", "series4_emitter")
|
# Accept source_id (new standard field) with fallback to legacy "source" key
|
||||||
|
source = payload.get("source_id") or payload.get("source", "series4_emitter")
|
||||||
units = payload.get("units", [])
|
units = payload.get("units", [])
|
||||||
|
version = payload.get("version")
|
||||||
|
log_tail = payload.get("log_tail")
|
||||||
|
import json as _json
|
||||||
|
log_tail_str = _json.dumps(log_tail) if log_tail is not None else None
|
||||||
|
client_ip = request.client.host if request.client else None
|
||||||
|
|
||||||
print("\n=== Series 4 Heartbeat ===")
|
print("\n=== Series 4 Heartbeat ===")
|
||||||
print("Source:", source)
|
print("Source:", source)
|
||||||
@@ -276,11 +330,25 @@ async def series4_heartbeat(request: Request, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
results.append({"unit": uid, "status": status})
|
results.append({"unit": uid, "status": status})
|
||||||
|
|
||||||
|
if source:
|
||||||
|
_upsert_watcher_agent(db, source, "series4_watcher", version,
|
||||||
|
client_ip, log_tail_str, "ok")
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
# Check if an update has been triggered for this agent
|
||||||
|
update_available = False
|
||||||
|
if source:
|
||||||
|
agent = db.query(WatcherAgent).filter(WatcherAgent.id == source).first()
|
||||||
|
if agent and agent.update_pending:
|
||||||
|
update_available = True
|
||||||
|
agent.update_pending = False
|
||||||
|
db.commit()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"message": "Heartbeat processed",
|
"message": "Heartbeat processed",
|
||||||
"source": source,
|
"source": source,
|
||||||
"units_processed": len(results),
|
"units_processed": len(results),
|
||||||
"results": results
|
"results": results,
|
||||||
|
"update_available": update_available,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from sqlalchemy import and_, or_
|
|||||||
|
|
||||||
from backend.models import (
|
from backend.models import (
|
||||||
RosterUnit, JobReservation, JobReservationUnit,
|
RosterUnit, JobReservation, JobReservationUnit,
|
||||||
UserPreferences, Project
|
UserPreferences, Project, DeploymentRecord
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -70,6 +70,19 @@ def get_unit_reservations_on_date(
|
|||||||
return reservations
|
return reservations
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_deployment(db: Session, unit_id: str) -> Optional[DeploymentRecord]:
|
||||||
|
"""Return the active (unreturned) deployment record for a unit, or None."""
|
||||||
|
return (
|
||||||
|
db.query(DeploymentRecord)
|
||||||
|
.filter(
|
||||||
|
DeploymentRecord.unit_id == unit_id,
|
||||||
|
DeploymentRecord.actual_removal_date == None
|
||||||
|
)
|
||||||
|
.order_by(DeploymentRecord.created_at.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def is_unit_available_on_date(
|
def is_unit_available_on_date(
|
||||||
db: Session,
|
db: Session,
|
||||||
unit: RosterUnit,
|
unit: RosterUnit,
|
||||||
@@ -82,8 +95,8 @@ def is_unit_available_on_date(
|
|||||||
Returns:
|
Returns:
|
||||||
(is_available, status, reservation_name)
|
(is_available, status, reservation_name)
|
||||||
- is_available: True if unit can be assigned to new work
|
- is_available: True if unit can be assigned to new work
|
||||||
- status: "available", "reserved", "expired", "retired", "needs_calibration"
|
- status: "available", "reserved", "expired", "retired", "needs_calibration", "in_field"
|
||||||
- reservation_name: Name of blocking reservation (if any)
|
- reservation_name: Name of blocking reservation or project ref (if any)
|
||||||
"""
|
"""
|
||||||
# Check if retired
|
# Check if retired
|
||||||
if unit.retired:
|
if unit.retired:
|
||||||
@@ -96,6 +109,12 @@ def is_unit_available_on_date(
|
|||||||
if cal_status == "needs_calibration":
|
if cal_status == "needs_calibration":
|
||||||
return False, "needs_calibration", None
|
return False, "needs_calibration", None
|
||||||
|
|
||||||
|
# Check for an active deployment record (unit is physically in the field)
|
||||||
|
active_deployment = get_active_deployment(db, unit.id)
|
||||||
|
if active_deployment:
|
||||||
|
label = active_deployment.project_ref or "Field deployment"
|
||||||
|
return False, "in_field", label
|
||||||
|
|
||||||
# Check if already reserved
|
# Check if already reserved
|
||||||
reservations = get_unit_reservations_on_date(db, unit.id, check_date)
|
reservations = get_unit_reservations_on_date(db, unit.id, check_date)
|
||||||
if reservations:
|
if reservations:
|
||||||
@@ -136,6 +155,7 @@ def get_day_summary(
|
|||||||
expired_units = []
|
expired_units = []
|
||||||
expiring_soon_units = []
|
expiring_soon_units = []
|
||||||
needs_calibration_units = []
|
needs_calibration_units = []
|
||||||
|
in_field_units = []
|
||||||
cal_expiring_today = [] # Units whose calibration expires ON this day
|
cal_expiring_today = [] # Units whose calibration expires ON this day
|
||||||
|
|
||||||
for unit in units:
|
for unit in units:
|
||||||
@@ -167,6 +187,9 @@ def get_day_summary(
|
|||||||
available_units.append(unit_info)
|
available_units.append(unit_info)
|
||||||
if cal_status == "expiring_soon":
|
if cal_status == "expiring_soon":
|
||||||
expiring_soon_units.append(unit_info)
|
expiring_soon_units.append(unit_info)
|
||||||
|
elif status == "in_field":
|
||||||
|
unit_info["project_ref"] = reservation_name
|
||||||
|
in_field_units.append(unit_info)
|
||||||
elif status == "reserved":
|
elif status == "reserved":
|
||||||
unit_info["reservation_name"] = reservation_name
|
unit_info["reservation_name"] = reservation_name
|
||||||
reserved_units.append(unit_info)
|
reserved_units.append(unit_info)
|
||||||
@@ -207,6 +230,7 @@ def get_day_summary(
|
|||||||
"date": check_date.isoformat(),
|
"date": check_date.isoformat(),
|
||||||
"device_type": device_type,
|
"device_type": device_type,
|
||||||
"available_units": available_units,
|
"available_units": available_units,
|
||||||
|
"in_field_units": in_field_units,
|
||||||
"reserved_units": reserved_units,
|
"reserved_units": reserved_units,
|
||||||
"expired_units": expired_units,
|
"expired_units": expired_units,
|
||||||
"expiring_soon_units": expiring_soon_units,
|
"expiring_soon_units": expiring_soon_units,
|
||||||
@@ -215,6 +239,7 @@ def get_day_summary(
|
|||||||
"reservations": reservation_list,
|
"reservations": reservation_list,
|
||||||
"counts": {
|
"counts": {
|
||||||
"available": len(available_units),
|
"available": len(available_units),
|
||||||
|
"in_field": len(in_field_units),
|
||||||
"reserved": len(reserved_units),
|
"reserved": len(reserved_units),
|
||||||
"expired": len(expired_units),
|
"expired": len(expired_units),
|
||||||
"expiring_soon": len(expiring_soon_units),
|
"expiring_soon": len(expiring_soon_units),
|
||||||
@@ -285,6 +310,14 @@ def get_calendar_year_data(
|
|||||||
unit_reservations[unit_id] = []
|
unit_reservations[unit_id] = []
|
||||||
unit_reservations[unit_id].append((start_d, end_d, res.name))
|
unit_reservations[unit_id].append((start_d, end_d, res.name))
|
||||||
|
|
||||||
|
# Build set of unit IDs that have an active deployment record (still in the field)
|
||||||
|
unit_ids = [u.id for u in units]
|
||||||
|
active_deployments = db.query(DeploymentRecord.unit_id).filter(
|
||||||
|
DeploymentRecord.unit_id.in_(unit_ids),
|
||||||
|
DeploymentRecord.actual_removal_date == None
|
||||||
|
).all()
|
||||||
|
unit_in_field = {row.unit_id for row in active_deployments}
|
||||||
|
|
||||||
# Generate data for each month
|
# Generate data for each month
|
||||||
months_data = {}
|
months_data = {}
|
||||||
|
|
||||||
@@ -301,6 +334,7 @@ def get_calendar_year_data(
|
|||||||
|
|
||||||
while current_day <= last_day:
|
while current_day <= last_day:
|
||||||
available = 0
|
available = 0
|
||||||
|
in_field = 0
|
||||||
reserved = 0
|
reserved = 0
|
||||||
expired = 0
|
expired = 0
|
||||||
expiring_soon = 0
|
expiring_soon = 0
|
||||||
@@ -328,6 +362,11 @@ def get_calendar_year_data(
|
|||||||
needs_cal += 1
|
needs_cal += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Check active deployment record (in field)
|
||||||
|
if unit.id in unit_in_field:
|
||||||
|
in_field += 1
|
||||||
|
continue
|
||||||
|
|
||||||
# Check if reserved
|
# Check if reserved
|
||||||
is_reserved = False
|
is_reserved = False
|
||||||
if unit.id in unit_reservations:
|
if unit.id in unit_reservations:
|
||||||
@@ -346,6 +385,7 @@ def get_calendar_year_data(
|
|||||||
|
|
||||||
days_data[current_day.day] = {
|
days_data[current_day.day] = {
|
||||||
"available": available,
|
"available": available,
|
||||||
|
"in_field": in_field,
|
||||||
"reserved": reserved,
|
"reserved": reserved,
|
||||||
"expired": expired,
|
"expired": expired,
|
||||||
"expiring_soon": expiring_soon,
|
"expiring_soon": expiring_soon,
|
||||||
@@ -462,6 +502,14 @@ def get_rolling_calendar_data(
|
|||||||
unit_reservations[unit_id] = []
|
unit_reservations[unit_id] = []
|
||||||
unit_reservations[unit_id].append((start_d, end_d, res.name))
|
unit_reservations[unit_id].append((start_d, end_d, res.name))
|
||||||
|
|
||||||
|
# Build set of unit IDs that have an active deployment record (still in the field)
|
||||||
|
unit_ids = [u.id for u in units]
|
||||||
|
active_deployments = db.query(DeploymentRecord.unit_id).filter(
|
||||||
|
DeploymentRecord.unit_id.in_(unit_ids),
|
||||||
|
DeploymentRecord.actual_removal_date == None
|
||||||
|
).all()
|
||||||
|
unit_in_field = {row.unit_id for row in active_deployments}
|
||||||
|
|
||||||
# Generate data for each of the 12 months
|
# Generate data for each of the 12 months
|
||||||
months_data = []
|
months_data = []
|
||||||
current_year = start_year
|
current_year = start_year
|
||||||
@@ -640,28 +688,37 @@ def get_available_units_for_period(
|
|||||||
for a in assigned:
|
for a in assigned:
|
||||||
reserved_unit_ids.add(a.unit_id)
|
reserved_unit_ids.add(a.unit_id)
|
||||||
|
|
||||||
|
# Get units with active deployment records (still in the field)
|
||||||
|
unit_ids = [u.id for u in units]
|
||||||
|
active_deps = db.query(DeploymentRecord.unit_id).filter(
|
||||||
|
DeploymentRecord.unit_id.in_(unit_ids),
|
||||||
|
DeploymentRecord.actual_removal_date == None
|
||||||
|
).all()
|
||||||
|
in_field_unit_ids = {row.unit_id for row in active_deps}
|
||||||
|
|
||||||
available_units = []
|
available_units = []
|
||||||
for unit in units:
|
for unit in units:
|
||||||
# Check if already reserved
|
# Check if already reserved
|
||||||
if unit.id in reserved_unit_ids:
|
if unit.id in reserved_unit_ids:
|
||||||
continue
|
continue
|
||||||
|
# Check if currently in the field
|
||||||
|
if unit.id in in_field_unit_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
# Check calibration through end of period
|
if unit.last_calibrated:
|
||||||
if not unit.last_calibrated:
|
expiry_date = unit.last_calibrated + timedelta(days=365)
|
||||||
continue # Needs calibration
|
cal_status = get_calibration_status(unit, end_date, warning_days)
|
||||||
|
else:
|
||||||
expiry_date = unit.last_calibrated + timedelta(days=365)
|
expiry_date = None
|
||||||
if expiry_date <= end_date:
|
cal_status = "needs_calibration"
|
||||||
continue # Calibration expires during period
|
|
||||||
|
|
||||||
cal_status = get_calibration_status(unit, end_date, warning_days)
|
|
||||||
|
|
||||||
available_units.append({
|
available_units.append({
|
||||||
"id": unit.id,
|
"id": unit.id,
|
||||||
"last_calibrated": unit.last_calibrated.isoformat(),
|
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
|
||||||
"expiry_date": expiry_date.isoformat(),
|
"expiry_date": expiry_date.isoformat() if expiry_date else None,
|
||||||
"calibration_status": cal_status,
|
"calibration_status": cal_status,
|
||||||
"deployed": unit.deployed,
|
"deployed": unit.deployed,
|
||||||
|
"out_for_calibration": unit.out_for_calibration or False,
|
||||||
"note": unit.note or ""
|
"note": unit.note or ""
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,18 @@ def emit_status_snapshot():
|
|||||||
age = "N/A"
|
age = "N/A"
|
||||||
last_seen = None
|
last_seen = None
|
||||||
fname = ""
|
fname = ""
|
||||||
|
elif r.out_for_calibration:
|
||||||
|
# Out for calibration units get separated later
|
||||||
|
status = "Out for Calibration"
|
||||||
|
age = "N/A"
|
||||||
|
last_seen = None
|
||||||
|
fname = ""
|
||||||
|
elif getattr(r, 'allocated', False) and not r.deployed:
|
||||||
|
# Allocated: staged for an upcoming job, not yet physically deployed
|
||||||
|
status = "Allocated"
|
||||||
|
age = "N/A"
|
||||||
|
last_seen = None
|
||||||
|
fname = ""
|
||||||
else:
|
else:
|
||||||
if e:
|
if e:
|
||||||
last_seen = ensure_utc(e.last_seen)
|
last_seen = ensure_utc(e.last_seen)
|
||||||
@@ -103,6 +115,9 @@ def emit_status_snapshot():
|
|||||||
"deployed": r.deployed,
|
"deployed": r.deployed,
|
||||||
"note": r.note or "",
|
"note": r.note or "",
|
||||||
"retired": r.retired,
|
"retired": r.retired,
|
||||||
|
"out_for_calibration": r.out_for_calibration or False,
|
||||||
|
"allocated": getattr(r, 'allocated', False) or False,
|
||||||
|
"allocated_to_project_id": getattr(r, 'allocated_to_project_id', None) or "",
|
||||||
# Device type and type-specific fields
|
# Device type and type-specific fields
|
||||||
"device_type": r.device_type or "seismograph",
|
"device_type": r.device_type or "seismograph",
|
||||||
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
|
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
|
||||||
@@ -133,6 +148,9 @@ def emit_status_snapshot():
|
|||||||
"deployed": False, # default
|
"deployed": False, # default
|
||||||
"note": "",
|
"note": "",
|
||||||
"retired": False,
|
"retired": False,
|
||||||
|
"out_for_calibration": False,
|
||||||
|
"allocated": False,
|
||||||
|
"allocated_to_project_id": "",
|
||||||
# Device type and type-specific fields (defaults for unknown units)
|
# Device type and type-specific fields (defaults for unknown units)
|
||||||
"device_type": "seismograph", # default
|
"device_type": "seismograph", # default
|
||||||
"last_calibrated": None,
|
"last_calibrated": None,
|
||||||
@@ -179,12 +197,17 @@ def emit_status_snapshot():
|
|||||||
# Separate buckets for UI
|
# Separate buckets for UI
|
||||||
active_units = {
|
active_units = {
|
||||||
uid: u for uid, u in units.items()
|
uid: u for uid, u in units.items()
|
||||||
if not u["retired"] and u["deployed"] and uid not in ignored
|
if not u["retired"] and not u["out_for_calibration"] and u["deployed"] and uid not in ignored
|
||||||
}
|
}
|
||||||
|
|
||||||
benched_units = {
|
benched_units = {
|
||||||
uid: u for uid, u in units.items()
|
uid: u for uid, u in units.items()
|
||||||
if not u["retired"] and not u["deployed"] and uid not in ignored
|
if not u["retired"] and not u["out_for_calibration"] and not u["allocated"] and not u["deployed"] and uid not in ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
allocated_units = {
|
||||||
|
uid: u for uid, u in units.items()
|
||||||
|
if not u["retired"] and not u["out_for_calibration"] and u["allocated"] and not u["deployed"] and uid not in ignored
|
||||||
}
|
}
|
||||||
|
|
||||||
retired_units = {
|
retired_units = {
|
||||||
@@ -192,6 +215,11 @@ def emit_status_snapshot():
|
|||||||
if u["retired"]
|
if u["retired"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
out_for_calibration_units = {
|
||||||
|
uid: u for uid, u in units.items()
|
||||||
|
if u["out_for_calibration"]
|
||||||
|
}
|
||||||
|
|
||||||
# Unknown units - emitters that aren't in the roster and aren't ignored
|
# Unknown units - emitters that aren't in the roster and aren't ignored
|
||||||
unknown_units = {
|
unknown_units = {
|
||||||
uid: u for uid, u in units.items()
|
uid: u for uid, u in units.items()
|
||||||
@@ -203,13 +231,17 @@ def emit_status_snapshot():
|
|||||||
"units": units,
|
"units": units,
|
||||||
"active": active_units,
|
"active": active_units,
|
||||||
"benched": benched_units,
|
"benched": benched_units,
|
||||||
|
"allocated": allocated_units,
|
||||||
"retired": retired_units,
|
"retired": retired_units,
|
||||||
|
"out_for_calibration": out_for_calibration_units,
|
||||||
"unknown": unknown_units,
|
"unknown": unknown_units,
|
||||||
"summary": {
|
"summary": {
|
||||||
"total": len(active_units) + len(benched_units),
|
"total": len(active_units) + len(benched_units) + len(allocated_units),
|
||||||
"active": len(active_units),
|
"active": len(active_units),
|
||||||
"benched": len(benched_units),
|
"benched": len(benched_units),
|
||||||
|
"allocated": len(allocated_units),
|
||||||
"retired": len(retired_units),
|
"retired": len(retired_units),
|
||||||
|
"out_for_calibration": len(out_for_calibration_units),
|
||||||
"unknown": len(unknown_units),
|
"unknown": len(unknown_units),
|
||||||
# Status counts only for deployed units (active_units)
|
# Status counts only for deployed units (active_units)
|
||||||
"ok": sum(1 for u in active_units.values() if u["status"] == "OK"),
|
"ok": sum(1 for u in active_units.values() if u["status"] == "OK"),
|
||||||
|
|||||||
@@ -60,11 +60,31 @@ def jinja_same_date(dt1, dt2) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def jinja_log_tail_display(s):
|
||||||
|
"""Jinja filter: decode a JSON-encoded log tail array into a plain-text string."""
|
||||||
|
if not s:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
lines = _json.loads(s)
|
||||||
|
if isinstance(lines, list):
|
||||||
|
return "\n".join(str(l) for l in lines)
|
||||||
|
return str(s)
|
||||||
|
except Exception:
|
||||||
|
return str(s)
|
||||||
|
|
||||||
|
|
||||||
|
def jinja_local_datetime_input(dt):
|
||||||
|
"""Jinja filter: format UTC datetime as local YYYY-MM-DDTHH:MM for <input type='datetime-local'>."""
|
||||||
|
return format_local_datetime(dt, "%Y-%m-%dT%H:%M")
|
||||||
|
|
||||||
|
|
||||||
# Register Jinja filters and globals
|
# Register Jinja filters and globals
|
||||||
templates.env.filters["local_datetime"] = jinja_local_datetime
|
templates.env.filters["local_datetime"] = jinja_local_datetime
|
||||||
templates.env.filters["local_time"] = jinja_local_time
|
templates.env.filters["local_time"] = jinja_local_time
|
||||||
templates.env.filters["local_date"] = jinja_local_date
|
templates.env.filters["local_date"] = jinja_local_date
|
||||||
|
templates.env.filters["local_datetime_input"] = jinja_local_datetime_input
|
||||||
templates.env.filters["fromjson"] = jinja_fromjson
|
templates.env.filters["fromjson"] = jinja_fromjson
|
||||||
templates.env.globals["timezone_abbr"] = jinja_timezone_abbr
|
templates.env.globals["timezone_abbr"] = jinja_timezone_abbr
|
||||||
templates.env.globals["get_user_timezone"] = get_user_timezone
|
templates.env.globals["get_user_timezone"] = get_user_timezone
|
||||||
templates.env.globals["same_date"] = jinja_same_date
|
templates.env.globals["same_date"] = jinja_same_date
|
||||||
|
templates.env.filters["log_tail_display"] = jinja_log_tail_display
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
# --- TERRA-VIEW PRODUCTION ---
|
|
||||||
terra-view:
|
terra-view:
|
||||||
build: .
|
build: .
|
||||||
container_name: terra-view
|
|
||||||
ports:
|
ports:
|
||||||
- "8001:8001"
|
- "8001:8001"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -29,7 +27,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ../slmm
|
context: ../slmm
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: slmm
|
|
||||||
network_mode: host
|
network_mode: host
|
||||||
volumes:
|
volumes:
|
||||||
- ../slmm/data:/app/data
|
- ../slmm/data:/app/data
|
||||||
|
|||||||
37
migrate_watcher_agents.py
Normal file
37
migrate_watcher_agents.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""
|
||||||
|
Migration: add watcher_agents table.
|
||||||
|
|
||||||
|
Safe to run multiple times (idempotent).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_PATH = os.path.join(os.path.dirname(__file__), "data", "seismo.db")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
con = sqlite3.connect(DB_PATH)
|
||||||
|
cur = con.cursor()
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS watcher_agents (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
source_type TEXT NOT NULL,
|
||||||
|
version TEXT,
|
||||||
|
last_seen DATETIME,
|
||||||
|
status TEXT NOT NULL DEFAULT 'unknown',
|
||||||
|
ip_address TEXT,
|
||||||
|
log_tail TEXT,
|
||||||
|
update_pending INTEGER NOT NULL DEFAULT 0,
|
||||||
|
update_version TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
print("Migration complete: watcher_agents table ready.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
12
rebuild-prod.sh
Executable file
12
rebuild-prod.sh
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Production rebuild script — rebuilds and restarts terra-view on :8001
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
echo "Building terra-view production..."
|
||||||
|
docker compose -f docker-compose.yml build terra-view
|
||||||
|
docker compose -f docker-compose.yml up -d terra-view
|
||||||
|
|
||||||
|
echo "Done — terra-view production is running on :8001"
|
||||||
@@ -8,7 +8,7 @@ import sys
|
|||||||
from sqlalchemy import create_engine, text
|
from sqlalchemy import create_engine, text
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
DATABASE_URL = "sqlite:///data/sfm.db"
|
DATABASE_URL = "sqlite:///data/seismo_fleet.db"
|
||||||
|
|
||||||
def rename_unit(old_id: str, new_id: str):
|
def rename_unit(old_id: str, new_id: str):
|
||||||
"""
|
"""
|
||||||
|
|||||||
273
templates/admin_watchers.html
Normal file
273
templates/admin_watchers.html
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Watcher Manager — Admin{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Watcher Manager</h1>
|
||||||
|
<span class="px-2 py-0.5 text-xs font-semibold bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300 rounded-full">Admin</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mt-1 text-sm">
|
||||||
|
Monitor and manage field watcher agents. Data updates on each heartbeat received.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Agent cards -->
|
||||||
|
<div id="agent-list" class="space-y-4">
|
||||||
|
|
||||||
|
{% if not agents %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow p-8 text-center">
|
||||||
|
<svg class="w-12 h-12 mx-auto text-gray-300 dark:text-gray-600 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">No watcher agents have reported in yet.</p>
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Once a watcher sends its first heartbeat it will appear here.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for agent in agents %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg overflow-hidden" id="agent-{{ agent.id | replace(' ', '-') }}">
|
||||||
|
|
||||||
|
<!-- Card header -->
|
||||||
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-slate-700">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Status dot -->
|
||||||
|
{% if agent.status == 'ok' %}
|
||||||
|
<span class="status-dot inline-block w-3 h-3 rounded-full bg-green-500 flex-shrink-0"></span>
|
||||||
|
{% elif agent.status == 'pending' %}
|
||||||
|
<span class="status-dot inline-block w-3 h-3 rounded-full bg-yellow-400 flex-shrink-0"></span>
|
||||||
|
{% elif agent.status in ('missing', 'error') %}
|
||||||
|
<span class="status-dot inline-block w-3 h-3 rounded-full bg-red-500 flex-shrink-0"></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-dot inline-block w-3 h-3 rounded-full bg-gray-400 flex-shrink-0"></span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ agent.id }}</h2>
|
||||||
|
<div class="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
<span>{{ agent.source_type }}</span>
|
||||||
|
{% if agent.version %}
|
||||||
|
<span class="bg-gray-100 dark:bg-slate-700 px-1.5 py-0.5 rounded font-mono">v{{ agent.version }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if agent.ip_address %}
|
||||||
|
<span>{{ agent.ip_address }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Status badge -->
|
||||||
|
{% if agent.status == 'ok' %}
|
||||||
|
<span class="status-badge px-2.5 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300">OK</span>
|
||||||
|
{% elif agent.status == 'pending' %}
|
||||||
|
<span class="status-badge px-2.5 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300">Pending</span>
|
||||||
|
{% elif agent.status == 'missing' %}
|
||||||
|
<span class="status-badge px-2.5 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300">Missing</span>
|
||||||
|
{% elif agent.status == 'error' %}
|
||||||
|
<span class="status-badge px-2.5 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300">Error</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-badge px-2.5 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-gray-400">Unknown</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Trigger Update button -->
|
||||||
|
<button
|
||||||
|
onclick="triggerUpdate('{{ agent.id }}')"
|
||||||
|
class="px-3 py-1.5 text-xs font-medium bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors"
|
||||||
|
id="btn-update-{{ agent.id | replace(' ', '-') }}"
|
||||||
|
>
|
||||||
|
Trigger Update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Meta row -->
|
||||||
|
<div class="px-6 py-3 bg-gray-50 dark:bg-slate-800 border-b border-gray-100 dark:border-slate-700 flex flex-wrap gap-6 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Last seen</span>
|
||||||
|
<span class="last-seen-value ml-2 font-medium text-gray-800 dark:text-gray-200">
|
||||||
|
{% if agent.last_seen %}
|
||||||
|
{{ agent.last_seen }}
|
||||||
|
{% if agent.age_minutes is not none %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-500 font-normal">({{ agent.age_minutes }}m ago)</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
Never
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="update-pending-indicator flex items-center gap-1.5 text-yellow-600 dark:text-yellow-400 {% if not agent.update_pending %}hidden{% endif %}">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-xs font-semibold">Update pending — will apply on next heartbeat</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Log tail -->
|
||||||
|
{% if agent.log_tail %}
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">Log Tail</span>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button onclick="expandLog('{{ agent.id | replace(' ', '-') }}')" id="expand-{{ agent.id | replace(' ', '-') }}" class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||||
|
Expand
|
||||||
|
</button>
|
||||||
|
<button onclick="toggleLog('{{ agent.id | replace(' ', '-') }}')" class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
|
||||||
|
Toggle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre id="log-{{ agent.id | replace(' ', '-') }}" class="text-xs font-mono bg-gray-900 text-green-400 rounded-lg p-3 overflow-x-auto max-h-96 overflow-y-auto leading-relaxed hidden">{{ agent.log_tail | log_tail_display }}</pre>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="px-6 py-4 text-xs text-gray-400 dark:text-gray-500 italic">No log data received yet.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto-refresh every 30s -->
|
||||||
|
<div class="mt-6 text-xs text-gray-400 dark:text-gray-600 text-center">
|
||||||
|
Auto-refreshes every 30 seconds — or <a href="/admin/watchers" class="underline hover:text-gray-600 dark:hover:text-gray-400">refresh now</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function triggerUpdate(agentId) {
|
||||||
|
if (!confirm('Trigger update for ' + agentId + '?\n\nThe watcher will self-update on its next heartbeat cycle.')) return;
|
||||||
|
|
||||||
|
const safeId = agentId.replace(/ /g, '-');
|
||||||
|
const btn = document.getElementById('btn-update-' + safeId);
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Sending...';
|
||||||
|
|
||||||
|
fetch('/api/admin/watchers/' + encodeURIComponent(agentId) + '/trigger-update', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({})
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.ok) {
|
||||||
|
btn.textContent = 'Update Queued';
|
||||||
|
btn.classList.remove('bg-seismo-orange', 'hover:bg-orange-600');
|
||||||
|
btn.classList.add('bg-green-600');
|
||||||
|
// Show the pending indicator immediately without a reload
|
||||||
|
const card = document.getElementById('agent-' + safeId);
|
||||||
|
if (card) {
|
||||||
|
const indicator = card.querySelector('.update-pending-indicator');
|
||||||
|
if (indicator) indicator.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
btn.textContent = 'Error';
|
||||||
|
btn.classList.add('bg-red-600');
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
btn.textContent = 'Failed';
|
||||||
|
btn.classList.add('bg-red-600');
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLog(agentId) {
|
||||||
|
const el = document.getElementById('log-' + agentId);
|
||||||
|
if (el) el.classList.toggle('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandLog(agentId) {
|
||||||
|
const el = document.getElementById('log-' + agentId);
|
||||||
|
const btn = document.getElementById('expand-' + agentId);
|
||||||
|
if (!el) return;
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
if (el.classList.contains('max-h-96')) {
|
||||||
|
el.classList.remove('max-h-96');
|
||||||
|
el.style.maxHeight = 'none';
|
||||||
|
if (btn) btn.textContent = 'Collapse';
|
||||||
|
} else {
|
||||||
|
el.classList.add('max-h-96');
|
||||||
|
el.style.maxHeight = '';
|
||||||
|
if (btn) btn.textContent = 'Expand';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status colors for dot and badge by status value
|
||||||
|
const STATUS_DOT = {
|
||||||
|
ok: 'bg-green-500',
|
||||||
|
pending: 'bg-yellow-400',
|
||||||
|
missing: 'bg-red-500',
|
||||||
|
error: 'bg-red-500',
|
||||||
|
};
|
||||||
|
const STATUS_BADGE_CLASSES = {
|
||||||
|
ok: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||||
|
pending: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300',
|
||||||
|
missing: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
|
||||||
|
error: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
|
||||||
|
};
|
||||||
|
const STATUS_BADGE_DEFAULT = 'bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-gray-400';
|
||||||
|
const DOT_COLORS = ['bg-green-500', 'bg-yellow-400', 'bg-red-500', 'bg-gray-400'];
|
||||||
|
const BADGE_COLORS = [
|
||||||
|
'bg-green-100', 'text-green-700', 'dark:bg-green-900', 'dark:text-green-300',
|
||||||
|
'bg-yellow-100', 'text-yellow-700', 'dark:bg-yellow-900', 'dark:text-yellow-300',
|
||||||
|
'bg-red-100', 'text-red-700', 'dark:bg-red-900', 'dark:text-red-300',
|
||||||
|
'bg-gray-100', 'text-gray-600', 'dark:bg-slate-700', 'dark:text-gray-400',
|
||||||
|
];
|
||||||
|
|
||||||
|
function patchAgent(card, agent) {
|
||||||
|
// Status dot
|
||||||
|
const dot = card.querySelector('.status-dot');
|
||||||
|
if (dot) {
|
||||||
|
dot.classList.remove(...DOT_COLORS);
|
||||||
|
dot.classList.add(STATUS_DOT[agent.status] || 'bg-gray-400');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status badge
|
||||||
|
const badge = card.querySelector('.status-badge');
|
||||||
|
if (badge) {
|
||||||
|
badge.classList.remove(...BADGE_COLORS);
|
||||||
|
const label = agent.status ? agent.status.charAt(0).toUpperCase() + agent.status.slice(1) : 'Unknown';
|
||||||
|
badge.textContent = label === 'Ok' ? 'OK' : label;
|
||||||
|
const cls = STATUS_BADGE_CLASSES[agent.status] || STATUS_BADGE_DEFAULT;
|
||||||
|
badge.classList.add(...cls.split(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last seen / age
|
||||||
|
const lastSeen = card.querySelector('.last-seen-value');
|
||||||
|
if (lastSeen) {
|
||||||
|
if (agent.last_seen) {
|
||||||
|
const age = agent.age_minutes != null
|
||||||
|
? ` <span class="text-gray-400 dark:text-gray-500 font-normal">(${agent.age_minutes}m ago)</span>`
|
||||||
|
: '';
|
||||||
|
lastSeen.innerHTML = agent.last_seen + age;
|
||||||
|
} else {
|
||||||
|
lastSeen.textContent = 'Never';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update pending indicator
|
||||||
|
const indicator = card.querySelector('.update-pending-indicator');
|
||||||
|
if (indicator) {
|
||||||
|
indicator.classList.toggle('hidden', !agent.update_pending);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function liveRefresh() {
|
||||||
|
fetch('/api/admin/watchers')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(agents => {
|
||||||
|
agents.forEach(agent => {
|
||||||
|
const safeId = agent.id.replace(/ /g, '-');
|
||||||
|
const card = document.getElementById('agent-' + safeId);
|
||||||
|
if (card) patchAgent(card, agent);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {}); // silently ignore fetch errors
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(liveRefresh, 30000);
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
|
|
||||||
<div class="flex h-screen overflow-hidden">
|
<div class="flex h-screen overflow-hidden">
|
||||||
<!-- Sidebar (Responsive) -->
|
<!-- Sidebar (Responsive) -->
|
||||||
<aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col">
|
<aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col{% if request.query_params.get('embed') == '1' %} hidden{% endif %}">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<a href="/" class="block">
|
<a href="/" class="block">
|
||||||
@@ -155,7 +155,7 @@
|
|||||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Fleet Calendar
|
Job Planner
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="/settings" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/settings' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
<a href="/settings" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/settings' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||||
@@ -193,14 +193,14 @@
|
|||||||
|
|
||||||
<!-- Main content -->
|
<!-- Main content -->
|
||||||
<main class="main-content flex-1 overflow-y-auto">
|
<main class="main-content flex-1 overflow-y-auto">
|
||||||
<div class="p-8">
|
<div class="{% if request.query_params.get('embed') == '1' %}p-4{% else %}p-8{% endif %}">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom Navigation (Mobile Only) -->
|
<!-- Bottom Navigation (Mobile Only) -->
|
||||||
<nav class="bottom-nav">
|
<nav class="bottom-nav{% if request.query_params.get('embed') == '1' %} hidden{% endif %}">
|
||||||
<div class="grid grid-cols-4 h-16">
|
<div class="grid grid-cols-4 h-16">
|
||||||
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
|
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Generate Excel
|
Generate Reports (ZIP)
|
||||||
</button>
|
</button>
|
||||||
<a href="/api/projects/{{ project_id }}/combined-report-wizard"
|
<a href="/api/projects/{{ project_id }}/combined-report-wizard"
|
||||||
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm">
|
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm">
|
||||||
@@ -187,7 +187,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const el = document.getElementById('spreadsheet-' + idx);
|
const el = document.getElementById('spreadsheet-' + idx);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const opts = Object.assign({}, jssOptions, { data: loc.spreadsheet_data });
|
const opts = Object.assign({}, jssOptions, { data: loc.spreadsheet_data });
|
||||||
spreadsheets[loc.location_name] = jspreadsheet(el, opts);
|
spreadsheets[idx] = jspreadsheet(el, opts);
|
||||||
});
|
});
|
||||||
if (allLocationData.length > 0) {
|
if (allLocationData.length > 0) {
|
||||||
switchTab(0);
|
switchTab(0);
|
||||||
@@ -228,9 +228,8 @@ function switchTab(idx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Refresh jspreadsheet rendering after showing panel
|
// Refresh jspreadsheet rendering after showing panel
|
||||||
const loc = allLocationData[idx];
|
if (spreadsheets[idx]) {
|
||||||
if (loc && spreadsheets[loc.location_name]) {
|
try { spreadsheets[idx].updateTable(); } catch(e) {}
|
||||||
try { spreadsheets[loc.location_name].updateTable(); } catch(e) {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,13 +237,17 @@ async function downloadCombinedReport() {
|
|||||||
const btn = document.getElementById('download-btn');
|
const btn = document.getElementById('download-btn');
|
||||||
const originalText = btn.innerHTML;
|
const originalText = btn.innerHTML;
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> Generating...';
|
btn.innerHTML = '<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> Generating ZIP...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const locations = allLocationData.map(function(loc) {
|
const locations = allLocationData.map(function(loc, idx) {
|
||||||
return {
|
return {
|
||||||
location_name: loc.location_name,
|
session_id: loc.session_id || '',
|
||||||
spreadsheet_data: spreadsheets[loc.location_name] ? spreadsheets[loc.location_name].getData() : loc.spreadsheet_data,
|
session_label: loc.session_label || '',
|
||||||
|
period_type: loc.period_type || '',
|
||||||
|
started_at: loc.started_at || '',
|
||||||
|
location_name: loc.location_name,
|
||||||
|
spreadsheet_data: spreadsheets[idx] ? spreadsheets[idx].getData() : loc.spreadsheet_data,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -268,7 +271,7 @@ async function downloadCombinedReport() {
|
|||||||
a.href = url;
|
a.href = url;
|
||||||
|
|
||||||
const contentDisposition = response.headers.get('Content-Disposition');
|
const contentDisposition = response.headers.get('Content-Disposition');
|
||||||
let filename = 'combined_report.xlsx';
|
let filename = 'combined_reports.zip';
|
||||||
if (contentDisposition) {
|
if (contentDisposition) {
|
||||||
const match = contentDisposition.match(/filename="(.+)"/);
|
const match = contentDisposition.match(/filename="(.+)"/);
|
||||||
if (match) filename = match[1];
|
if (match) filename = match[1];
|
||||||
|
|||||||
@@ -74,105 +74,134 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Time Filter Card -->
|
<!-- Sessions Card -->
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 overflow-hidden">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Time Filter</h2>
|
<div class="flex items-center justify-between mb-1">
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Applied to all locations. Leave blank to include all data.</p>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
|
||||||
|
|
||||||
<!-- Preset Buttons -->
|
|
||||||
<div class="flex flex-wrap gap-2 mb-4">
|
|
||||||
<button type="button" onclick="setTimePreset('night')" data-preset="night"
|
|
||||||
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
|
||||||
Night 7PM – 7AM
|
|
||||||
</button>
|
|
||||||
<button type="button" onclick="setTimePreset('day')" data-preset="day"
|
|
||||||
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
|
||||||
Day 7AM – 7PM
|
|
||||||
</button>
|
|
||||||
<button type="button" onclick="setTimePreset('all')" data-preset="all"
|
|
||||||
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-emerald-600 text-white hover:bg-emerald-700 transition-colors">
|
|
||||||
All Day
|
|
||||||
</button>
|
|
||||||
<button type="button" onclick="setTimePreset('custom')" data-preset="custom"
|
|
||||||
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
|
||||||
Custom
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Time Inputs -->
|
|
||||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
|
||||||
<div>
|
|
||||||
<label for="start-time" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Start Time</label>
|
|
||||||
<input type="time" id="start-time" value=""
|
|
||||||
onchange="updatePresetButtons()"
|
|
||||||
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="end-time" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">End Time</label>
|
|
||||||
<input type="time" id="end-time" value=""
|
|
||||||
onchange="updatePresetButtons()"
|
|
||||||
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Date Range -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Date Range <span class="text-gray-400 font-normal">(optional)</span>
|
|
||||||
</label>
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label for="start-date" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">From</label>
|
|
||||||
<input type="date" id="start-date" value=""
|
|
||||||
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="end-date" class="block text-xs text-gray-500 dark:text-gray-400 mb-1">To</label>
|
|
||||||
<input type="date" id="end-date" value=""
|
|
||||||
class="block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Locations Card -->
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Locations to Include</h2>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
|
||||||
<span id="selected-count">{{ locations|length }}</span> of {{ locations|length }} selected
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-3 text-sm">
|
<div class="flex gap-3 text-sm">
|
||||||
<button type="button" onclick="selectAll()" class="text-emerald-600 dark:text-emerald-400 hover:underline">Select All</button>
|
<button type="button" onclick="selectAllSessions()" class="text-emerald-600 dark:text-emerald-400 hover:underline">Select All</button>
|
||||||
<button type="button" onclick="deselectAll()" class="text-gray-500 dark:text-gray-400 hover:underline">Deselect All</button>
|
<button type="button" onclick="deselectAllSessions()" class="text-gray-500 dark:text-gray-400 hover:underline">Deselect All</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
<span id="selected-count">0</span> session(s) selected — each selected session becomes one sheet in the ZIP.
|
||||||
|
Change the period type per session to control how stats are bucketed (Day vs Night).
|
||||||
|
</p>
|
||||||
|
|
||||||
{% if locations %}
|
{% if locations %}
|
||||||
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
|
||||||
{% for loc in locations %}
|
{% for loc in locations %}
|
||||||
<label class="flex items-center gap-3 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-slate-700/50 px-2 rounded-md transition-colors">
|
{% set loc_name = loc.name %}
|
||||||
<input type="checkbox" name="location" value="{{ loc.name }}" checked
|
{% set sessions = loc.sessions %}
|
||||||
onchange="updateSelectedCount()"
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg mb-3 overflow-hidden">
|
||||||
class="h-4 w-4 text-emerald-600 border-gray-300 dark:border-gray-600 rounded focus:ring-emerald-500">
|
<!-- Location header / toggle -->
|
||||||
<span class="flex-1 text-sm text-gray-900 dark:text-white font-medium">{{ loc.name }}</span>
|
<button type="button"
|
||||||
<span class="text-xs text-gray-400 dark:text-gray-500">{{ loc.file_count }} file{{ 's' if loc.file_count != 1 else '' }}</span>
|
onclick="toggleLocation('loc-{{ loop.index }}')"
|
||||||
</label>
|
class="w-full flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-slate-700/50 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors text-left">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<svg id="chevron-loc-{{ loop.index }}" class="w-4 h-4 text-gray-400 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white text-sm">{{ loc_name }}</span>
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500">{{ sessions|length }} session{{ 's' if sessions|length != 1 else '' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 text-xs" onclick="event.stopPropagation()">
|
||||||
|
<button type="button" onclick="selectLocation('loc-{{ loop.index }}')"
|
||||||
|
class="text-emerald-600 dark:text-emerald-400 hover:underline">All</button>
|
||||||
|
<button type="button" onclick="deselectLocation('loc-{{ loop.index }}')"
|
||||||
|
class="text-gray-400 hover:underline">None</button>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Session rows -->
|
||||||
|
<div id="loc-{{ loop.index }}" class="divide-y divide-gray-100 dark:divide-gray-700/50">
|
||||||
|
{% for s in sessions %}
|
||||||
|
{% set pt_colors = {
|
||||||
|
'weekday_day': 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||||
|
'weekday_night': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
|
||||||
|
'weekend_day': 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||||
|
'weekend_night': 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
|
||||||
|
} %}
|
||||||
|
{% set pt_labels = {
|
||||||
|
'weekday_day': 'Weekday Day',
|
||||||
|
'weekday_night': 'Weekday Night',
|
||||||
|
'weekend_day': 'Weekend Day',
|
||||||
|
'weekend_night': 'Weekend Night',
|
||||||
|
} %}
|
||||||
|
<div class="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-slate-700/30 transition-colors">
|
||||||
|
<!-- Checkbox -->
|
||||||
|
<input type="checkbox"
|
||||||
|
class="session-cb loc-{{ loop.index }}-cb h-4 w-4 text-emerald-600 border-gray-300 dark:border-gray-600 rounded focus:ring-emerald-500 shrink-0"
|
||||||
|
value="{{ s.session_id }}"
|
||||||
|
checked
|
||||||
|
onchange="updateSelectionStats()">
|
||||||
|
|
||||||
|
<!-- Date/day info -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{{ s.day_of_week }} {{ s.date_display }}
|
||||||
|
</span>
|
||||||
|
{% if s.session_label %}
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500 truncate">{{ s.session_label }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if s.status == 'recording' %}
|
||||||
|
<span class="px-1.5 py-0.5 text-xs bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 rounded-full flex items-center gap-1">
|
||||||
|
<span class="w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse"></span>Recording
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 mt-0.5 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{% if s.started_at %}
|
||||||
|
<span>{{ s.started_at }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if s.duration_h is not none %}
|
||||||
|
<span>{{ s.duration_h }}h {{ s.duration_m }}m</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Period type dropdown -->
|
||||||
|
<div class="relative shrink-0" id="wiz-period-wrap-{{ s.session_id }}">
|
||||||
|
<button type="button"
|
||||||
|
onclick="toggleWizPeriodMenu('{{ s.session_id }}')"
|
||||||
|
id="wiz-period-badge-{{ s.session_id }}"
|
||||||
|
class="px-2 py-0.5 text-xs font-medium rounded-full flex items-center gap-1 transition-colors {{ pt_colors.get(s.period_type, 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400') }}"
|
||||||
|
title="Click to change period type">
|
||||||
|
<span id="wiz-period-label-{{ s.session_id }}">{{ pt_labels.get(s.period_type, 'Set period') }}</span>
|
||||||
|
<svg class="w-3 h-3 opacity-60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="wiz-period-menu-{{ s.session_id }}"
|
||||||
|
class="hidden absolute right-0 top-full mt-1 z-20 bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg min-w-[160px] py-1">
|
||||||
|
{% for pt, pt_label in [('weekday_day','Weekday Day'),('weekday_night','Weekday Night'),('weekend_day','Weekend Day'),('weekend_night','Weekend Night')] %}
|
||||||
|
<button type="button"
|
||||||
|
onclick="setWizPeriodType('{{ s.session_id }}', '{{ pt }}')"
|
||||||
|
class="w-full text-left px-3 py-1.5 text-xs hover:bg-gray-100 dark:hover:bg-slate-600 text-gray-700 dark:text-gray-300 {% if s.period_type == pt %}font-bold{% endif %}">
|
||||||
|
{{ pt_label }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
<div class="text-center py-10 text-gray-500 dark:text-gray-400">
|
||||||
<p>No Leq measurement files found in this project.</p>
|
<svg class="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<p class="text-sm mt-1">Upload RND files with '_Leq_' in the filename to generate reports.</p>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
|
||||||
|
</svg>
|
||||||
|
<p>No monitoring sessions found.</p>
|
||||||
|
<p class="text-sm mt-1">Upload data files to create sessions first.</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer Buttons -->
|
<!-- Footer Buttons -->
|
||||||
<div class="flex flex-col sm:flex-row items-center justify-between gap-3 pb-6">
|
<div class="flex flex-col sm:flex-row items-center justify-between gap-3 pb-6">
|
||||||
<a href="/api/projects/{{ project_id }}"
|
<a href="/projects/{{ project_id }}"
|
||||||
class="w-full sm:w-auto px-6 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors text-center text-sm font-medium">
|
class="w-full sm:w-auto px-6 py-2.5 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors text-center text-sm font-medium">
|
||||||
Cancel
|
Cancel
|
||||||
</a>
|
</a>
|
||||||
@@ -191,180 +220,173 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let reportTemplates = [];
|
const PROJECT_ID = '{{ project_id }}';
|
||||||
|
|
||||||
// ---- Template management (same as rnd_viewer.html) ----
|
const PERIOD_COLORS = {
|
||||||
|
weekday_day: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||||
|
weekday_night: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
|
||||||
|
weekend_day: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||||
|
weekend_night: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
|
||||||
|
};
|
||||||
|
const PERIOD_LABELS = {
|
||||||
|
weekday_day: 'Weekday Day',
|
||||||
|
weekday_night: 'Weekday Night',
|
||||||
|
weekend_day: 'Weekend Day',
|
||||||
|
weekend_night: 'Weekend Night',
|
||||||
|
};
|
||||||
|
const ALL_PERIOD_BADGE_CLASSES = [
|
||||||
|
'bg-gray-100','text-gray-500','dark:bg-gray-700','dark:text-gray-400',
|
||||||
|
...new Set(Object.values(PERIOD_COLORS).flatMap(s => s.split(' ')))
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Location accordion ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function toggleLocation(locId) {
|
||||||
|
const body = document.getElementById(locId);
|
||||||
|
const chevron = document.getElementById('chevron-' + locId);
|
||||||
|
body.classList.toggle('hidden');
|
||||||
|
chevron.style.transform = body.classList.contains('hidden') ? 'rotate(-90deg)' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectLocation(locId) {
|
||||||
|
document.querySelectorAll('.' + locId + '-cb').forEach(cb => cb.checked = true);
|
||||||
|
updateSelectionStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deselectLocation(locId) {
|
||||||
|
document.querySelectorAll('.' + locId + '-cb').forEach(cb => cb.checked = false);
|
||||||
|
updateSelectionStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllSessions() {
|
||||||
|
document.querySelectorAll('.session-cb').forEach(cb => cb.checked = true);
|
||||||
|
updateSelectionStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deselectAllSessions() {
|
||||||
|
document.querySelectorAll('.session-cb').forEach(cb => cb.checked = false);
|
||||||
|
updateSelectionStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectionStats() {
|
||||||
|
const count = document.querySelectorAll('.session-cb:checked').length;
|
||||||
|
document.getElementById('selected-count').textContent = count;
|
||||||
|
document.getElementById('preview-btn').disabled = count === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Period type dropdown (wizard) ─────────────────────────────────
|
||||||
|
|
||||||
|
function toggleWizPeriodMenu(sessionId) {
|
||||||
|
const menu = document.getElementById('wiz-period-menu-' + sessionId);
|
||||||
|
document.querySelectorAll('[id^="wiz-period-menu-"]').forEach(m => {
|
||||||
|
if (m.id !== 'wiz-period-menu-' + sessionId) m.classList.add('hidden');
|
||||||
|
});
|
||||||
|
menu.classList.toggle('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!e.target.closest('[id^="wiz-period-wrap-"]')) {
|
||||||
|
document.querySelectorAll('[id^="wiz-period-menu-"]').forEach(m => m.classList.add('hidden'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function setWizPeriodType(sessionId, periodType) {
|
||||||
|
document.getElementById('wiz-period-menu-' + sessionId).classList.add('hidden');
|
||||||
|
const badge = document.getElementById('wiz-period-badge-' + sessionId);
|
||||||
|
badge.disabled = true;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${sessionId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({period_type: periodType}),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await resp.text());
|
||||||
|
ALL_PERIOD_BADGE_CLASSES.forEach(c => badge.classList.remove(c));
|
||||||
|
const colorStr = PERIOD_COLORS[periodType] || 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400';
|
||||||
|
badge.classList.add(...colorStr.split(' ').filter(Boolean));
|
||||||
|
document.getElementById('wiz-period-label-' + sessionId).textContent = PERIOD_LABELS[periodType] || periodType;
|
||||||
|
} catch(err) {
|
||||||
|
alert('Failed to update period type: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
badge.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Template management ───────────────────────────────────────────
|
||||||
|
|
||||||
|
let reportTemplates = [];
|
||||||
|
|
||||||
async function loadTemplates() {
|
async function loadTemplates() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/report-templates?project_id={{ project_id }}');
|
const resp = await fetch('/api/report-templates?project_id=' + PROJECT_ID);
|
||||||
if (response.ok) {
|
if (resp.ok) {
|
||||||
reportTemplates = await response.json();
|
reportTemplates = await resp.json();
|
||||||
populateTemplateDropdown();
|
populateTemplateDropdown();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch(e) { console.error('Error loading templates:', e); }
|
||||||
console.error('Error loading templates:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateTemplateDropdown() {
|
function populateTemplateDropdown() {
|
||||||
const select = document.getElementById('template-select');
|
const select = document.getElementById('template-select');
|
||||||
if (!select) return;
|
if (!select) return;
|
||||||
select.innerHTML = '<option value="">-- Select a template --</option>';
|
select.innerHTML = '<option value="">-- Select a template --</option>';
|
||||||
reportTemplates.forEach(template => {
|
reportTemplates.forEach(t => {
|
||||||
const option = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
option.value = template.id;
|
opt.value = t.id;
|
||||||
option.textContent = template.name;
|
opt.textContent = t.name;
|
||||||
option.dataset.config = JSON.stringify(template);
|
opt.dataset.config = JSON.stringify(t);
|
||||||
select.appendChild(option);
|
select.appendChild(opt);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTemplate() {
|
function applyTemplate() {
|
||||||
const select = document.getElementById('template-select');
|
const select = document.getElementById('template-select');
|
||||||
const selectedOption = select.options[select.selectedIndex];
|
const opt = select.options[select.selectedIndex];
|
||||||
if (!selectedOption.value) return;
|
if (!opt.value) return;
|
||||||
const template = JSON.parse(selectedOption.dataset.config);
|
const t = JSON.parse(opt.dataset.config);
|
||||||
if (template.report_title) document.getElementById('report-title').value = template.report_title;
|
if (t.report_title) document.getElementById('report-title').value = t.report_title;
|
||||||
if (template.start_time) document.getElementById('start-time').value = template.start_time;
|
|
||||||
if (template.end_time) document.getElementById('end-time').value = template.end_time;
|
|
||||||
if (template.start_date) document.getElementById('start-date').value = template.start_date;
|
|
||||||
if (template.end_date) document.getElementById('end-date').value = template.end_date;
|
|
||||||
updatePresetButtons();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveAsTemplate() {
|
async function saveAsTemplate() {
|
||||||
const name = prompt('Enter a name for this template:');
|
const name = prompt('Enter a name for this template:');
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
const templateData = {
|
const data = {
|
||||||
name: name,
|
name,
|
||||||
project_id: '{{ project_id }}',
|
project_id: PROJECT_ID,
|
||||||
report_title: document.getElementById('report-title').value || 'Background Noise Study',
|
report_title: document.getElementById('report-title').value || 'Background Noise Study',
|
||||||
start_time: document.getElementById('start-time').value || null,
|
|
||||||
end_time: document.getElementById('end-time').value || null,
|
|
||||||
start_date: document.getElementById('start-date').value || null,
|
|
||||||
end_date: document.getElementById('end-date').value || null
|
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/report-templates', {
|
const resp = await fetch('/api/report-templates', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify(templateData)
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (resp.ok) { alert('Template saved!'); loadTemplates(); }
|
||||||
alert('Template saved successfully!');
|
else alert('Failed to save template');
|
||||||
loadTemplates();
|
} catch(e) { alert('Error: ' + e.message); }
|
||||||
} else {
|
|
||||||
alert('Failed to save template');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error saving template: ' + error.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Time preset buttons ----
|
// ── Navigate to preview ───────────────────────────────────────────
|
||||||
|
|
||||||
function setTimePreset(preset) {
|
|
||||||
const startTimeInput = document.getElementById('start-time');
|
|
||||||
const endTimeInput = document.getElementById('end-time');
|
|
||||||
|
|
||||||
document.querySelectorAll('.preset-btn').forEach(btn => {
|
|
||||||
btn.classList.remove('bg-emerald-600', 'text-white');
|
|
||||||
btn.classList.add('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
|
|
||||||
});
|
|
||||||
|
|
||||||
switch (preset) {
|
|
||||||
case 'night':
|
|
||||||
startTimeInput.value = '19:00';
|
|
||||||
endTimeInput.value = '07:00';
|
|
||||||
break;
|
|
||||||
case 'day':
|
|
||||||
startTimeInput.value = '07:00';
|
|
||||||
endTimeInput.value = '19:00';
|
|
||||||
break;
|
|
||||||
case 'all':
|
|
||||||
startTimeInput.value = '';
|
|
||||||
endTimeInput.value = '';
|
|
||||||
break;
|
|
||||||
case 'custom':
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeBtn = document.querySelector(`[data-preset="${preset}"]`);
|
|
||||||
if (activeBtn) {
|
|
||||||
activeBtn.classList.remove('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
|
|
||||||
activeBtn.classList.add('bg-emerald-600', 'text-white');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePresetButtons() {
|
|
||||||
const startTime = document.getElementById('start-time').value;
|
|
||||||
const endTime = document.getElementById('end-time').value;
|
|
||||||
|
|
||||||
document.querySelectorAll('.preset-btn').forEach(btn => {
|
|
||||||
btn.classList.remove('bg-emerald-600', 'text-white');
|
|
||||||
btn.classList.add('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
|
|
||||||
});
|
|
||||||
|
|
||||||
let preset = 'custom';
|
|
||||||
if (startTime === '19:00' && endTime === '07:00') preset = 'night';
|
|
||||||
else if (startTime === '07:00' && endTime === '19:00') preset = 'day';
|
|
||||||
else if (!startTime && !endTime) preset = 'all';
|
|
||||||
|
|
||||||
const activeBtn = document.querySelector(`[data-preset="${preset}"]`);
|
|
||||||
if (activeBtn) {
|
|
||||||
activeBtn.classList.remove('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
|
|
||||||
activeBtn.classList.add('bg-emerald-600', 'text-white');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Location checkboxes ----
|
|
||||||
|
|
||||||
function updateSelectedCount() {
|
|
||||||
const checked = document.querySelectorAll('input[name="location"]:checked').length;
|
|
||||||
document.getElementById('selected-count').textContent = checked;
|
|
||||||
document.getElementById('preview-btn').disabled = checked === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectAll() {
|
|
||||||
document.querySelectorAll('input[name="location"]').forEach(cb => cb.checked = true);
|
|
||||||
updateSelectedCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
function deselectAll() {
|
|
||||||
document.querySelectorAll('input[name="location"]').forEach(cb => cb.checked = false);
|
|
||||||
updateSelectedCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCheckedLocations() {
|
|
||||||
return Array.from(document.querySelectorAll('input[name="location"]:checked')).map(cb => cb.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Navigate to preview ----
|
|
||||||
|
|
||||||
function gotoPreview() {
|
function gotoPreview() {
|
||||||
const checked = getCheckedLocations();
|
const checked = Array.from(document.querySelectorAll('.session-cb:checked')).map(cb => cb.value);
|
||||||
if (checked.length === 0) {
|
if (checked.length === 0) {
|
||||||
alert('Please select at least one location.');
|
alert('Please select at least one session.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
report_title: document.getElementById('report-title').value || 'Background Noise Study',
|
report_title: document.getElementById('report-title').value || 'Background Noise Study',
|
||||||
project_name: document.getElementById('report-project').value || '',
|
project_name: document.getElementById('report-project').value || '',
|
||||||
client_name: document.getElementById('report-client').value || '',
|
client_name: document.getElementById('report-client').value || '',
|
||||||
start_time: document.getElementById('start-time').value || '',
|
selected_sessions: checked.join(','),
|
||||||
end_time: document.getElementById('end-time').value || '',
|
|
||||||
start_date: document.getElementById('start-date').value || '',
|
|
||||||
end_date: document.getElementById('end-date').value || '',
|
|
||||||
enabled_locations: checked.join(','),
|
|
||||||
});
|
});
|
||||||
|
window.location.href = `/api/projects/${PROJECT_ID}/combined-report-preview?${params.toString()}`;
|
||||||
window.location.href = `/api/projects/{{ project_id }}/combined-report-preview?${params.toString()}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Init ----
|
// ── Init ─────────────────────────────────────────────────────────
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
updateSelectionStats();
|
||||||
loadTemplates();
|
loadTemplates();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -57,6 +57,10 @@
|
|||||||
<span class="text-gray-600 dark:text-gray-400">Benched</span>
|
<span class="text-gray-600 dark:text-gray-400">Benched</span>
|
||||||
<span id="benched-units" class="text-3xl md:text-2xl font-bold text-gray-600 dark:text-gray-400">--</span>
|
<span id="benched-units" class="text-3xl md:text-2xl font-bold text-gray-600 dark:text-gray-400">--</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-orange-600 dark:text-orange-400">Allocated</span>
|
||||||
|
<span id="allocated-units" class="text-3xl md:text-2xl font-bold text-orange-500 dark:text-orange-400">--</span>
|
||||||
|
</div>
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">By Device Type:</p>
|
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">By Device Type:</p>
|
||||||
<div class="flex justify-between items-center mb-1">
|
<div class="flex justify-between items-center mb-1">
|
||||||
@@ -509,7 +513,7 @@ function renderFilteredDashboard(data) {
|
|||||||
// Update the Recent Alerts section with filtering
|
// Update the Recent Alerts section with filtering
|
||||||
function updateAlertsFiltered(filteredActive) {
|
function updateAlertsFiltered(filteredActive) {
|
||||||
const alertsList = document.getElementById('alerts-list');
|
const alertsList = document.getElementById('alerts-list');
|
||||||
const missingUnits = Object.entries(filteredActive).filter(([_, u]) => u.status === 'Missing');
|
const missingUnits = Object.entries(filteredActive).filter(([_, u]) => u.status === 'Missing' && u.device_type !== 'modem');
|
||||||
|
|
||||||
if (!missingUnits.length) {
|
if (!missingUnits.length) {
|
||||||
// Check if this is because of filters or genuinely no alerts
|
// Check if this is because of filters or genuinely no alerts
|
||||||
@@ -703,6 +707,7 @@ function updateDashboard(event) {
|
|||||||
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
|
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
|
||||||
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
|
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
|
||||||
document.getElementById('benched-units').textContent = data.summary?.benched ?? 0;
|
document.getElementById('benched-units').textContent = data.summary?.benched ?? 0;
|
||||||
|
document.getElementById('allocated-units').textContent = data.summary?.allocated ?? 0;
|
||||||
document.getElementById('status-ok').textContent = data.summary?.ok ?? 0;
|
document.getElementById('status-ok').textContent = data.summary?.ok ?? 0;
|
||||||
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
|
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
|
||||||
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
|
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -385,16 +385,27 @@
|
|||||||
file:text-sm file:font-medium file:bg-seismo-orange file:text-white
|
file:text-sm file:font-medium file:bg-seismo-orange file:text-white
|
||||||
hover:file:bg-seismo-navy file:cursor-pointer" />
|
hover:file:bg-seismo-navy file:cursor-pointer" />
|
||||||
<div class="flex items-center gap-3 mt-3">
|
<div class="flex items-center gap-3 mt-3">
|
||||||
<button onclick="submitUpload()"
|
<button id="upload-btn" onclick="submitUpload()"
|
||||||
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
|
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
|
||||||
Import Files
|
Import Files
|
||||||
</button>
|
</button>
|
||||||
<button onclick="toggleUploadPanel()"
|
<button id="upload-cancel-btn" onclick="toggleUploadPanel()"
|
||||||
class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<span id="upload-status" class="text-sm hidden"></span>
|
<span id="upload-status" class="text-sm hidden"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Progress bar (hidden until upload starts) -->
|
||||||
|
<div id="upload-progress-wrap" class="hidden mt-3">
|
||||||
|
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
<span id="upload-progress-label">Uploading…</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
|
<div id="upload-progress-bar"
|
||||||
|
class="bg-green-500 h-2 rounded-full transition-all duration-300"
|
||||||
|
style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="data-files-list"
|
<div id="data-files-list"
|
||||||
@@ -629,57 +640,105 @@ function toggleUploadPanel() {
|
|||||||
const panel = document.getElementById('upload-panel');
|
const panel = document.getElementById('upload-panel');
|
||||||
const status = document.getElementById('upload-status');
|
const status = document.getElementById('upload-status');
|
||||||
panel.classList.toggle('hidden');
|
panel.classList.toggle('hidden');
|
||||||
// Reset status when reopening
|
// Reset state when reopening
|
||||||
if (!panel.classList.contains('hidden')) {
|
if (!panel.classList.contains('hidden')) {
|
||||||
status.textContent = '';
|
status.textContent = '';
|
||||||
status.className = 'text-sm hidden';
|
status.className = 'text-sm hidden';
|
||||||
document.getElementById('upload-input').value = '';
|
document.getElementById('upload-input').value = '';
|
||||||
|
document.getElementById('upload-progress-wrap').classList.add('hidden');
|
||||||
|
document.getElementById('upload-progress-bar').style.width = '0%';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitUpload() {
|
function submitUpload() {
|
||||||
const input = document.getElementById('upload-input');
|
const input = document.getElementById('upload-input');
|
||||||
const status = document.getElementById('upload-status');
|
const status = document.getElementById('upload-status');
|
||||||
|
const btn = document.getElementById('upload-btn');
|
||||||
|
const cancelBtn = document.getElementById('upload-cancel-btn');
|
||||||
|
const progressWrap = document.getElementById('upload-progress-wrap');
|
||||||
|
const progressBar = document.getElementById('upload-progress-bar');
|
||||||
|
const progressLabel = document.getElementById('upload-progress-label');
|
||||||
|
|
||||||
if (!input.files.length) {
|
if (!input.files.length) {
|
||||||
alert('Please select files to upload.');
|
alert('Please select files to upload.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileCount = input.files.length;
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
for (const file of input.files) {
|
for (const file of input.files) {
|
||||||
formData.append('files', file);
|
formData.append('files', file);
|
||||||
}
|
}
|
||||||
|
|
||||||
status.textContent = 'Uploading\u2026';
|
// Disable controls and show progress bar
|
||||||
status.className = 'text-sm text-gray-500';
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Uploading\u2026';
|
||||||
|
btn.classList.add('opacity-60', 'cursor-not-allowed');
|
||||||
|
cancelBtn.disabled = true;
|
||||||
|
cancelBtn.classList.add('opacity-40', 'cursor-not-allowed');
|
||||||
|
status.className = 'text-sm hidden';
|
||||||
|
progressWrap.classList.remove('hidden');
|
||||||
|
progressBar.style.width = '0%';
|
||||||
|
progressLabel.textContent = `Uploading ${fileCount} file${fileCount !== 1 ? 's' : ''}\u2026`;
|
||||||
|
|
||||||
try {
|
const xhr = new XMLHttpRequest();
|
||||||
const response = await fetch(
|
|
||||||
`/api/projects/${projectId}/nrl/${locationId}/upload-data`,
|
|
||||||
{ method: 'POST', body: formData }
|
|
||||||
);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
const parts = [`Imported ${data.files_imported} file${data.files_imported !== 1 ? 's' : ''}`];
|
if (e.lengthComputable) {
|
||||||
if (data.leq_files || data.lp_files) {
|
const pct = Math.round((e.loaded / e.total) * 100);
|
||||||
parts.push(`(${data.leq_files} Leq, ${data.lp_files} Lp)`);
|
progressBar.style.width = pct + '%';
|
||||||
|
progressLabel.textContent = `Uploading ${fileCount} file${fileCount !== 1 ? 's' : ''}\u2026 ${pct}%`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('load', () => {
|
||||||
|
progressBar.style.width = '100%';
|
||||||
|
progressLabel.textContent = 'Processing files on server\u2026';
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('load', () => {
|
||||||
|
progressWrap.classList.add('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Import Files';
|
||||||
|
btn.classList.remove('opacity-60', 'cursor-not-allowed');
|
||||||
|
cancelBtn.disabled = false;
|
||||||
|
cancelBtn.classList.remove('opacity-40', 'cursor-not-allowed');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(xhr.responseText);
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
const parts = [`Imported ${data.files_imported} file${data.files_imported !== 1 ? 's' : ''}`];
|
||||||
|
if (data.leq_files || data.lp_files) {
|
||||||
|
parts.push(`(${data.leq_files} Leq, ${data.lp_files} Lp)`);
|
||||||
|
}
|
||||||
|
if (data.store_name) parts.push(`\u2014 ${data.store_name}`);
|
||||||
|
status.textContent = parts.join(' ');
|
||||||
|
status.className = 'text-sm text-green-600 dark:text-green-400';
|
||||||
|
input.value = '';
|
||||||
|
htmx.trigger(document.getElementById('data-files-list'), 'load');
|
||||||
|
} else {
|
||||||
|
status.textContent = `Error: ${data.detail || 'Upload failed'}`;
|
||||||
|
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
}
|
}
|
||||||
if (data.store_name) parts.push(`\u2014 ${data.store_name}`);
|
} catch {
|
||||||
status.textContent = parts.join(' ');
|
status.textContent = 'Error: Unexpected server response';
|
||||||
status.className = 'text-sm text-green-600 dark:text-green-400';
|
|
||||||
input.value = '';
|
|
||||||
// Refresh the file list
|
|
||||||
htmx.trigger(document.getElementById('data-files-list'), 'load');
|
|
||||||
} else {
|
|
||||||
status.textContent = `Error: ${data.detail || 'Upload failed'}`;
|
|
||||||
status.className = 'text-sm text-red-600 dark:text-red-400';
|
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
}
|
}
|
||||||
} catch (err) {
|
});
|
||||||
status.textContent = `Error: ${err.message}`;
|
|
||||||
|
xhr.addEventListener('error', () => {
|
||||||
|
progressWrap.classList.add('hidden');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Import Files';
|
||||||
|
btn.classList.remove('opacity-60', 'cursor-not-allowed');
|
||||||
|
cancelBtn.disabled = false;
|
||||||
|
cancelBtn.classList.remove('opacity-40', 'cursor-not-allowed');
|
||||||
|
status.textContent = 'Error: Network error during upload';
|
||||||
status.className = 'text-sm text-red-600 dark:text-red-400';
|
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
}
|
});
|
||||||
|
|
||||||
|
xhr.open('POST', `/api/projects/${projectId}/nrl/${locationId}/upload-data`);
|
||||||
|
xhr.send(formData);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
{% for unit in units %}
|
{% for unit in units %}
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
data-device-type="{{ unit.device_type }}"
|
data-device-type="{{ unit.device_type }}"
|
||||||
data-status="{% if unit.deployed %}deployed{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
|
data-status="{% if unit.deployed %}deployed{% elif unit.out_for_calibration %}out_for_calibration{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% elif unit.allocated %}allocated{% else %}benched{% endif %}"
|
||||||
data-health="{{ unit.status }}"
|
data-health="{{ unit.status }}"
|
||||||
data-id="{{ unit.id }}"
|
data-id="{{ unit.id }}"
|
||||||
data-type="{{ unit.device_type }}"
|
data-type="{{ unit.device_type }}"
|
||||||
@@ -60,7 +60,11 @@
|
|||||||
data-note="{{ unit.note if unit.note else '' }}">
|
data-note="{{ unit.note if unit.note else '' }}">
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
{% if not unit.deployed %}
|
{% if unit.out_for_calibration %}
|
||||||
|
<span class="w-3 h-3 rounded-full bg-purple-500" title="Out for Calibration"></span>
|
||||||
|
{% elif unit.allocated %}
|
||||||
|
<span class="w-3 h-3 rounded-full bg-orange-400" title="Allocated"></span>
|
||||||
|
{% elif not unit.deployed %}
|
||||||
<span class="w-3 h-3 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
<span class="w-3 h-3 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
||||||
{% elif unit.status == 'OK' %}
|
{% elif unit.status == 'OK' %}
|
||||||
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
|
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
|
||||||
@@ -72,6 +76,10 @@
|
|||||||
|
|
||||||
{% if unit.deployed %}
|
{% if unit.deployed %}
|
||||||
<span class="w-2 h-2 rounded-full bg-blue-500" title="Deployed"></span>
|
<span class="w-2 h-2 rounded-full bg-blue-500" title="Deployed"></span>
|
||||||
|
{% elif unit.out_for_calibration %}
|
||||||
|
<span class="w-2 h-2 rounded-full bg-purple-400" title="Out for Calibration"></span>
|
||||||
|
{% elif unit.allocated %}
|
||||||
|
<span class="w-2 h-2 rounded-full bg-orange-400" title="Allocated"></span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
|
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -203,14 +211,18 @@
|
|||||||
<div class="unit-card device-card"
|
<div class="unit-card device-card"
|
||||||
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
|
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
|
||||||
data-device-type="{{ unit.device_type }}"
|
data-device-type="{{ unit.device_type }}"
|
||||||
data-status="{% if unit.deployed %}deployed{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
|
data-status="{% if unit.deployed %}deployed{% elif unit.out_for_calibration %}out_for_calibration{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% elif unit.allocated %}allocated{% else %}benched{% endif %}"
|
||||||
data-health="{{ unit.status }}"
|
data-health="{{ unit.status }}"
|
||||||
data-unit-id="{{ unit.id }}"
|
data-unit-id="{{ unit.id }}"
|
||||||
data-age="{{ unit.age }}">
|
data-age="{{ unit.age }}">
|
||||||
<!-- Header: Status Dot + Unit ID + Status Badge -->
|
<!-- Header: Status Dot + Unit ID + Status Badge -->
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{% if not unit.deployed %}
|
{% if unit.out_for_calibration %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-purple-500" title="Out for Calibration"></span>
|
||||||
|
{% elif unit.allocated %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-orange-400" title="Allocated"></span>
|
||||||
|
{% elif not unit.deployed %}
|
||||||
<span class="w-4 h-4 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
<span class="w-4 h-4 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
|
||||||
{% elif unit.status == 'OK' %}
|
{% elif unit.status == 'OK' %}
|
||||||
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
|
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
|
||||||
@@ -224,12 +236,14 @@
|
|||||||
<span class="font-bold text-lg text-seismo-orange dark:text-seismo-orange">{{ unit.id }}</span>
|
<span class="font-bold text-lg text-seismo-orange dark:text-seismo-orange">{{ unit.id }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="px-3 py-1 rounded-full text-xs font-medium
|
<span class="px-3 py-1 rounded-full text-xs font-medium
|
||||||
{% if unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
|
{% if unit.out_for_calibration %}bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300
|
||||||
|
{% elif unit.allocated %}bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300
|
||||||
|
{% elif unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
|
||||||
{% elif unit.status == 'Pending' %}bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300
|
{% elif unit.status == 'Pending' %}bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300
|
||||||
{% elif unit.status == 'Missing' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300
|
{% elif unit.status == 'Missing' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300
|
||||||
{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400
|
{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400
|
||||||
{% endif %}">
|
{% endif %}">
|
||||||
{% if unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
|
{% if unit.out_for_calibration %}Out for Cal{% elif unit.allocated %}Allocated{% elif unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -279,6 +293,10 @@
|
|||||||
<span class="text-xs text-blue-600 dark:text-blue-400">
|
<span class="text-xs text-blue-600 dark:text-blue-400">
|
||||||
⚡ Deployed
|
⚡ Deployed
|
||||||
</span>
|
</span>
|
||||||
|
{% elif unit.out_for_calibration %}
|
||||||
|
<span class="text-xs text-purple-600 dark:text-purple-400">
|
||||||
|
🔧 Out for Calibration
|
||||||
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-500">
|
<span class="text-xs text-gray-500 dark:text-gray-500">
|
||||||
📦 Benched
|
📦 Benched
|
||||||
|
|||||||
@@ -1,103 +1,203 @@
|
|||||||
<!-- Reservations List -->
|
<!-- Reservations List -->
|
||||||
{% if reservations %}
|
{% if reservations %}
|
||||||
<div class="space-y-3">
|
<div class="space-y-2">
|
||||||
{% for item in reservations %}
|
{% for item in reservations %}
|
||||||
{% set res = item.reservation %}
|
{% set res = item.reservation %}
|
||||||
<div class="flex items-center justify-between p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
{% set card_id = "res-card-" ~ res.id %}
|
||||||
|
{% set detail_id = "res-detail-" ~ res.id %}
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-gray-200 dark:border-gray-700"
|
||||||
style="border-left: 4px solid {{ res.color }};">
|
style="border-left: 4px solid {{ res.color }};">
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex items-center gap-2">
|
<!-- Header row (always visible, clickable) -->
|
||||||
<h3 class="font-semibold text-gray-900 dark:text-white">{{ res.name }}</h3>
|
<div class="res-card-header flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors select-none"
|
||||||
{% if item.has_conflicts %}
|
data-res-id="{{ res.id }}"
|
||||||
<span class="px-2 py-0.5 text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-full"
|
onclick="toggleResCard('{{ res.id }}')">
|
||||||
title="{{ item.conflict_count }} unit(s) have calibration expiring during this job">
|
|
||||||
{{ item.conflict_count }} conflict{{ 's' if item.conflict_count != 1 else '' }}
|
<div class="flex-1 min-w-0">
|
||||||
</span>
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white">{{ res.name }}</h3>
|
||||||
|
{% if res.device_type == 'slm' %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 rounded">SLM</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded">Seismograph</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.has_conflicts %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded"
|
||||||
|
title="{{ item.conflict_count }} unit(s) will need a calibration swap during this job">
|
||||||
|
{{ item.conflict_count }} cal swap{{ 's' if item.conflict_count != 1 else '' }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{{ res.start_date.strftime('%b %d, %Y') }} –
|
||||||
|
{% if res.end_date %}
|
||||||
|
{{ res.end_date.strftime('%b %d, %Y') }}
|
||||||
|
{% elif res.end_date_tbd %}
|
||||||
|
<span class="text-yellow-600 dark:text-yellow-400 font-medium">TBD</span>
|
||||||
|
{% if res.estimated_end_date %}
|
||||||
|
<span class="text-gray-400">(est. {{ res.estimated_end_date.strftime('%b %d, %Y') }})</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-yellow-600 dark:text-yellow-400">Ongoing</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Counts -->
|
||||||
|
<div class="flex flex-col items-end gap-1 mx-4 flex-shrink-0">
|
||||||
|
{% set full = item.assigned_count == item.location_count and item.location_count > 0 %}
|
||||||
|
{% set remaining = item.location_count - item.assigned_count %}
|
||||||
|
<!-- Number row -->
|
||||||
|
<div class="flex items-baseline gap-2">
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500">est. {% if res.estimated_units %}{{ res.estimated_units }}{% else %}—{% endif %}</span>
|
||||||
|
<span class="text-gray-300 dark:text-gray-600">·</span>
|
||||||
|
<span class="text-base font-bold {% if full %}text-green-600 dark:text-green-400{% elif item.assigned_count == 0 %}text-gray-400 dark:text-gray-500{% else %}text-amber-500 dark:text-amber-400{% endif %}">
|
||||||
|
{{ item.assigned_count }}/{{ item.location_count }}
|
||||||
|
</span>
|
||||||
|
{% if remaining > 0 %}
|
||||||
|
<span class="text-xs text-amber-500 dark:text-amber-400 whitespace-nowrap">({{ remaining }} more)</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<!-- Progress squares -->
|
||||||
|
{% if item.location_count > 0 %}
|
||||||
|
<div class="flex gap-0.5">
|
||||||
|
{% for i in range(item.location_count) %}
|
||||||
|
<span class="w-3 h-3 rounded-sm {% if i < item.assigned_count %}{% if full %}bg-green-500{% else %}bg-amber-500{% endif %}{% else %}bg-gray-300 dark:bg-gray-600{% endif %}"></span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
||||||
{{ res.start_date.strftime('%b %d, %Y') }} -
|
<!-- Action buttons -->
|
||||||
{% if res.end_date %}
|
<div class="flex items-center gap-1 flex-shrink-0">
|
||||||
{{ res.end_date.strftime('%b %d, %Y') }}
|
<!-- Assign units (always visible) -->
|
||||||
{% elif res.end_date_tbd %}
|
<button onclick="event.stopPropagation(); openPlanner('{{ res.id }}')"
|
||||||
<span class="text-yellow-600 dark:text-yellow-400 font-medium">TBD</span>
|
class="p-2 text-gray-400 hover:text-green-600 dark:hover:text-green-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
{% if res.estimated_end_date %}
|
title="Assign units">
|
||||||
<span class="text-gray-400">(est. {{ res.estimated_end_date.strftime('%b %d, %Y') }})</span>
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
{% endif %}
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
|
||||||
{% else %}
|
</svg>
|
||||||
<span class="text-yellow-600 dark:text-yellow-400">Ongoing</span>
|
</button>
|
||||||
{% endif %}
|
|
||||||
</p>
|
<!-- "..." overflow menu -->
|
||||||
|
<div class="relative" onclick="event.stopPropagation()">
|
||||||
|
<button onclick="toggleResMenu('{{ res.id }}')"
|
||||||
|
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
title="More options">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="res-menu-{{ res.id }}"
|
||||||
|
class="hidden absolute right-0 top-8 z-20 w-44 bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1">
|
||||||
|
<button onclick="openPromoteModal('{{ res.id }}', '{{ res.name }}'); toggleResMenu('{{ res.id }}')"
|
||||||
|
class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-700 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"/>
|
||||||
|
</svg>
|
||||||
|
Promote to Project
|
||||||
|
</button>
|
||||||
|
<button onclick="editReservation('{{ res.id }}'); toggleResMenu('{{ res.id }}')"
|
||||||
|
class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-700 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||||
|
</svg>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<div class="border-t border-gray-100 dark:border-gray-700 my-1"></div>
|
||||||
|
<button onclick="deleteReservation('{{ res.id }}', '{{ res.name }}'); toggleResMenu('{{ res.id }}')"
|
||||||
|
class="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||||
|
</svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chevron -->
|
||||||
|
<svg id="chevron-{{ res.id }}" class="w-4 h-4 text-gray-400 transition-transform duration-200 ml-1 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expandable detail panel -->
|
||||||
|
<div id="{{ detail_id }}" class="hidden border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-slate-800/60 px-4 py-3">
|
||||||
|
|
||||||
{% if res.notes %}
|
{% if res.notes %}
|
||||||
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">{{ res.notes }}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3 italic">{{ res.notes }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-x-6 gap-y-1 text-sm mb-3">
|
||||||
|
<div class="text-gray-500 dark:text-gray-400">Estimated</div>
|
||||||
|
<div class="font-medium {% if res.estimated_units %}text-gray-800 dark:text-gray-200{% else %}text-gray-400 dark:text-gray-500 italic{% endif %}">
|
||||||
|
{% if res.estimated_units %}{{ res.estimated_units }} unit{{ 's' if res.estimated_units != 1 else '' }}{% else %}not specified{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-500 dark:text-gray-400">Locations</div>
|
||||||
|
<div class="font-medium text-gray-800 dark:text-gray-200">{{ item.assigned_count }} of {{ item.location_count }} filled</div>
|
||||||
|
{% if item.assigned_count < item.location_count %}
|
||||||
|
<div class="text-gray-500 dark:text-gray-400">Still needed</div>
|
||||||
|
<div class="font-medium text-amber-600 dark:text-amber-400">{{ item.location_count - item.assigned_count }} location{{ 's' if (item.location_count - item.assigned_count) != 1 else '' }} remaining</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.has_conflicts %}
|
||||||
|
<div class="text-gray-500 dark:text-gray-400">Cal swaps</div>
|
||||||
|
<div class="font-medium text-amber-600 dark:text-amber-400">{{ item.conflict_count }} unit{{ 's' if item.conflict_count != 1 else '' }} will need swapping during job</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if item.assigned_units %}
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500 mb-2">Monitoring Locations</p>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
{% for u in item.assigned_units %}
|
||||||
|
<div class="rounded bg-white dark:bg-slate-700 border border-gray-100 dark:border-gray-600 text-sm">
|
||||||
|
<div class="flex items-center gap-3 px-3 py-1.5">
|
||||||
|
<span class="text-gray-400 dark:text-gray-500 text-xs w-12 flex-shrink-0">Loc. {{ loop.index }}</span>
|
||||||
|
<div class="flex flex-col min-w-0">
|
||||||
|
{% if u.location_name %}
|
||||||
|
<span class="text-xs font-semibold text-gray-700 dark:text-gray-300 truncate">{{ u.location_name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<button onclick="openUnitDetailModal('{{ u.id }}')"
|
||||||
|
class="font-medium text-blue-600 dark:text-blue-400 hover:underline text-left text-sm">{{ u.id }}</button>
|
||||||
|
</div>
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
{% if u.power_type == 'ac' %}
|
||||||
|
<span class="text-xs px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded">A/C</span>
|
||||||
|
{% elif u.power_type == 'solar' %}
|
||||||
|
<span class="text-xs px-1.5 py-0.5 bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400 rounded">Solar</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if u.deployed %}
|
||||||
|
<span class="text-xs px-1.5 py-0.5 bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 rounded">Deployed</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-600 text-gray-500 dark:text-gray-400 rounded">Benched</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if u.last_calibrated %}
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500">Cal: {{ u.last_calibrated.strftime('%b %d, %Y') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if u.notes %}
|
||||||
|
<p class="px-3 pb-1.5 text-xs text-gray-400 dark:text-gray-500 italic">{{ u.notes }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500 italic">No units assigned yet. Click the clipboard icon to plan.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right ml-4">
|
|
||||||
<p class="text-lg font-bold text-gray-900 dark:text-white">
|
|
||||||
{% if res.assignment_type == 'quantity' %}
|
|
||||||
{{ item.assigned_count }}/{{ res.quantity_needed or '?' }}
|
|
||||||
{% else %}
|
|
||||||
{{ item.assigned_count }}
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{{ 'units needed' if res.assignment_type == 'quantity' else 'units assigned' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4 flex items-center gap-2">
|
|
||||||
<button onclick="editReservation('{{ res.id }}')"
|
|
||||||
class="p-2 text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
||||||
title="Edit reservation">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button onclick="deleteReservation('{{ res.id }}', '{{ res.name }}')"
|
|
||||||
class="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
||||||
title="Delete reservation">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<!-- toggleResCard, deleteReservation, editReservation, openUnitDetailModal defined in fleet_calendar.html -->
|
||||||
async function deleteReservation(id, name) {
|
|
||||||
if (!confirm(`Delete reservation "${name}"?\n\nThis will remove all unit assignments.`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/fleet-calendar/reservations/${id}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
window.location.reload();
|
|
||||||
} else {
|
|
||||||
const data = await response.json();
|
|
||||||
alert('Error: ' + (data.detail || 'Failed to delete'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error:', error);
|
|
||||||
alert('Error deleting reservation');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function editReservation(id) {
|
|
||||||
// For now, just show alert - can implement edit modal later
|
|
||||||
alert('Edit functionality coming soon. For now, delete and recreate the reservation.');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-8">
|
<div class="text-center py-8">
|
||||||
<svg class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-gray-500 dark:text-gray-400">No reservations for {{ year }}</p>
|
<p class="text-gray-500 dark:text-gray-400">No jobs yet</p>
|
||||||
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Click "New Reservation" to plan unit assignments</p>
|
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Click "New Job" to start planning a deployment</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% if project.status == 'active' %}
|
{% if project.status == 'upcoming' %}
|
||||||
|
<span class="px-3 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 rounded-full">Upcoming</span>
|
||||||
|
{% elif project.status == 'active' %}
|
||||||
<span class="px-3 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
|
<span class="px-3 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
|
||||||
{% elif project.status == 'on_hold' %}
|
{% elif project.status == 'on_hold' %}
|
||||||
<span class="px-3 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>
|
<span class="px-3 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>
|
||||||
|
|||||||
@@ -3,12 +3,26 @@
|
|||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">{{ project.name }}</h1>
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">{{ project.name }}</h1>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
|
<div class="relative inline-block">
|
||||||
{% if project.status == 'active' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
|
<select onchange="quickUpdateStatus(this.value)"
|
||||||
{% elif project.status == 'completed' %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
|
class="appearance-none cursor-pointer inline-flex items-center pl-3 pr-7 py-1 rounded-full text-sm font-medium border-0 focus:ring-2 focus:ring-offset-1 focus:ring-blue-500
|
||||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
|
{% if project.status == 'upcoming' %}bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200
|
||||||
{{ project.status|title }}
|
{% elif project.status == 'active' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
|
||||||
</span>
|
{% elif project.status == 'on_hold' %}bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200
|
||||||
|
{% elif project.status == 'completed' %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
|
||||||
|
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
|
||||||
|
<option value="upcoming" {% if project.status == 'upcoming' %}selected{% endif %}>Upcoming</option>
|
||||||
|
<option value="active" {% if project.status == 'active' %}selected{% endif %}>Active</option>
|
||||||
|
<option value="on_hold" {% if project.status == 'on_hold' %}selected{% endif %}>On Hold</option>
|
||||||
|
<option value="completed" {% if project.status == 'completed' %}selected{% endif %}>Completed</option>
|
||||||
|
<option value="archived" {% if project.status == 'archived' %}selected{% endif %}>Archived</option>
|
||||||
|
</select>
|
||||||
|
<span class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-current opacity-60">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{% if project_type %}
|
{% if project_type %}
|
||||||
<span class="text-gray-500 dark:text-gray-400">{{ project_type.name }}</span>
|
<span class="text-gray-500 dark:text-gray-400">{{ project_type.name }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if item.project.status == 'active' %}
|
{% if item.project.status == 'upcoming' %}
|
||||||
|
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 rounded-full">Upcoming</span>
|
||||||
|
{% elif item.project.status == 'active' %}
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
|
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
|
||||||
{% elif item.project.status == 'on_hold' %}
|
{% elif item.project.status == 'on_hold' %}
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>
|
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>
|
||||||
|
|||||||
@@ -1,79 +1,208 @@
|
|||||||
<!-- Monitoring Sessions List -->
|
<!-- Monitoring Sessions List -->
|
||||||
{% if sessions %}
|
{% if sessions %}
|
||||||
<div class="space-y-4">
|
<div class="space-y-3">
|
||||||
{% for item in sessions %}
|
{% for item in sessions %}
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
{% set s = item.session %}
|
||||||
<div class="flex items-start justify-between gap-3">
|
{% set loc = item.location %}
|
||||||
|
{% set unit = item.unit %}
|
||||||
|
{% set effective_range = item.effective_range %}
|
||||||
|
|
||||||
|
{# Period display maps #}
|
||||||
|
{% set period_labels = {
|
||||||
|
'weekday_day': 'Weekday Day',
|
||||||
|
'weekday_night': 'Weekday Night',
|
||||||
|
'weekend_day': 'Weekend Day',
|
||||||
|
'weekend_night': 'Weekend Night',
|
||||||
|
} %}
|
||||||
|
{% set period_colors = {
|
||||||
|
'weekday_day': 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||||
|
'weekday_night': 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
|
||||||
|
'weekend_day': 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||||
|
'weekend_night': 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
|
||||||
|
} %}
|
||||||
|
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-slate-800 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
||||||
|
id="session-card-{{ s.id }}">
|
||||||
|
<div class="flex items-start justify-between gap-3 p-4 pb-3">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-3 mb-2">
|
|
||||||
<h4 class="font-semibold text-gray-900 dark:text-white">
|
<!-- Label + badges -->
|
||||||
Session {{ item.session.id[:8] }}...
|
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||||||
</h4>
|
<span id="label-display-{{ s.id }}"
|
||||||
{% if item.session.status == 'recording' %}
|
class="font-semibold text-gray-900 dark:text-white text-sm cursor-pointer hover:text-seismo-orange"
|
||||||
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded-full flex items-center">
|
title="Click to edit label"
|
||||||
<span class="w-2 h-2 bg-red-500 rounded-full mr-1.5 animate-pulse"></span>
|
onclick="startEditLabel('{{ s.id }}')">
|
||||||
Recording
|
{{ s.session_label or ('Session ' + s.id[:8] + '…') }}
|
||||||
</span>
|
</span>
|
||||||
{% elif item.session.status == 'completed' %}
|
<input id="label-input-{{ s.id }}"
|
||||||
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">
|
class="hidden text-sm font-semibold bg-transparent border-b border-seismo-orange text-gray-900 dark:text-white focus:outline-none min-w-[180px]"
|
||||||
Completed
|
value="{{ s.session_label or '' }}"
|
||||||
</span>
|
onblur="saveLabel('{{ s.id }}')"
|
||||||
{% elif item.session.status == 'paused' %}
|
onkeydown="if(event.key==='Enter'){saveLabel('{{ s.id }}');}if(event.key==='Escape'){cancelEditLabel('{{ s.id }}');}">
|
||||||
<span class="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">
|
|
||||||
Paused
|
{% if s.status == 'recording' %}
|
||||||
</span>
|
<span class="px-2 py-0.5 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded-full flex items-center gap-1">
|
||||||
{% elif item.session.status == 'failed' %}
|
<span class="w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse"></span>Recording
|
||||||
<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 rounded-full">
|
|
||||||
Failed
|
|
||||||
</span>
|
</span>
|
||||||
|
{% elif s.status == 'completed' %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Completed</span>
|
||||||
|
{% elif s.status == 'failed' %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400 rounded-full">Failed</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Period type badge (click to open hour editor) -->
|
||||||
|
<div class="relative" id="period-wrap-{{ s.id }}">
|
||||||
|
<button onclick="openPeriodEditor('{{ s.id }}')"
|
||||||
|
id="period-badge-{{ s.id }}"
|
||||||
|
class="px-2 py-0.5 text-xs font-medium rounded-full flex items-center gap-1 transition-colors {{ period_colors.get(s.period_type, 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400') }}"
|
||||||
|
title="Click to edit period type and hours">
|
||||||
|
<span id="period-label-{{ s.id }}">{{ period_labels.get(s.period_type, 'Set period') }}</span>
|
||||||
|
<svg class="w-3 h-3 opacity-60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Period editor panel -->
|
||||||
|
<div id="period-editor-{{ s.id }}"
|
||||||
|
class="hidden absolute left-0 top-full mt-1 z-20 bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg w-64 p-3 space-y-3">
|
||||||
|
|
||||||
|
<!-- Period type selector -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Period Type</label>
|
||||||
|
<div class="grid grid-cols-2 gap-1">
|
||||||
|
{% for pt, pt_label in [('weekday_day','WD Day'),('weekday_night','WD Night'),('weekend_day','WE Day'),('weekend_night','WE Night')] %}
|
||||||
|
<button onclick="selectPeriodType('{{ s.id }}', '{{ pt }}')"
|
||||||
|
id="pt-btn-{{ s.id }}-{{ pt }}"
|
||||||
|
class="period-type-btn text-xs py-1 px-2 rounded border transition-colors
|
||||||
|
{% if s.period_type == pt %}border-seismo-orange bg-orange-50 text-seismo-orange dark:bg-orange-900/20{% else %}border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 hover:border-gray-400{% endif %}">
|
||||||
|
{{ pt_label }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hour inputs -->
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Start Hour (0–23)</label>
|
||||||
|
<input type="number" min="0" max="23"
|
||||||
|
id="period-start-hr-{{ s.id }}"
|
||||||
|
value="{{ s.period_start_hour if s.period_start_hour is not none else '' }}"
|
||||||
|
placeholder="e.g. 19"
|
||||||
|
class="w-full text-xs bg-gray-50 dark:bg-slate-600 border border-gray-200 dark:border-gray-500 rounded px-2 py-1 text-gray-800 dark:text-gray-200 focus:outline-none focus:border-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">End Hour (0–23)</label>
|
||||||
|
<input type="number" min="0" max="23"
|
||||||
|
id="period-end-hr-{{ s.id }}"
|
||||||
|
value="{{ s.period_end_hour if s.period_end_hour is not none else '' }}"
|
||||||
|
placeholder="e.g. 7"
|
||||||
|
class="w-full text-xs bg-gray-50 dark:bg-slate-600 border border-gray-200 dark:border-gray-500 rounded px-2 py-1 text-gray-800 dark:text-gray-200 focus:outline-none focus:border-seismo-orange">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500">Day: 7→19 · Night: 19→7 · Customize as needed</p>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex gap-2 pt-1">
|
||||||
|
<button onclick="savePeriodEditor('{{ s.id }}')"
|
||||||
|
class="flex-1 text-xs py-1 bg-seismo-orange text-white rounded hover:bg-orange-600 transition-colors">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button onclick="closePeriodEditor('{{ s.id }}')"
|
||||||
|
class="text-xs py-1 px-2 border border-gray-200 dark:border-gray-600 rounded text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button onclick="clearPeriodEditor('{{ s.id }}')"
|
||||||
|
class="text-xs py-1 px-2 border border-gray-200 dark:border-gray-600 rounded text-gray-500 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors"
|
||||||
|
title="Clear period type and hours">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-3 text-sm text-gray-600 dark:text-gray-400">
|
<!-- Info grid -->
|
||||||
{% if item.unit %}
|
<div class="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<div>
|
{% if loc %}
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-500">Unit:</span>
|
<div class="flex items-center gap-1">
|
||||||
<a href="/slm/{{ item.unit.id }}?from_project={{ project_id }}" class="text-seismo-orange hover:text-seismo-navy font-medium ml-1">
|
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
{{ item.unit.id }}
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||||
</a>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium text-gray-700 dark:text-gray-300">{{ loc.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div>
|
{% if s.started_at %}
|
||||||
<span class="text-xs text-gray-500">Started:</span>
|
<div class="flex items-center gap-1">
|
||||||
<span class="ml-1">{{ item.session.started_at|local_datetime if item.session.started_at else 'N/A' }}</span>
|
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</div>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
{% if item.session.stopped_at %}
|
<span>{{ s.started_at|local_datetime }}</span>
|
||||||
<div>
|
|
||||||
<span class="text-xs text-gray-500">Ended:</span>
|
|
||||||
<span class="ml-1">{{ item.session.stopped_at|local_datetime }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if item.session.duration_seconds %}
|
{% if s.stopped_at %}
|
||||||
<div>
|
<div class="flex items-center gap-1">
|
||||||
<span class="text-xs text-gray-500">Duration:</span>
|
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<span class="ml-1">{{ (item.session.duration_seconds // 3600) }}h {{ ((item.session.duration_seconds % 3600) // 60) }}m</span>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Ended {{ s.stopped_at|local_datetime }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if s.duration_seconds %}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>{{ (s.duration_seconds // 3600) }}h {{ ((s.duration_seconds % 3600) // 60) }}m</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if unit %}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2v-4M9 21H5a2 2 0 01-2-2v-4m0 0h18"></path>
|
||||||
|
</svg>
|
||||||
|
<a href="/slm/{{ unit.id }}?from_project={{ project_id }}"
|
||||||
|
class="text-seismo-orange hover:underline font-medium">{{ unit.id }}</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if s.device_model %}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>{{ s.device_model }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if item.session.notes %}
|
<!-- Effective window (when period hours are set) -->
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
{% if effective_range %}
|
||||||
{{ item.session.notes }}
|
<div class="flex items-center gap-1 mt-1.5 text-xs text-indigo-600 dark:text-indigo-400">
|
||||||
</p>
|
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span id="effective-range-{{ s.id }}">Effective: {{ effective_range }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="hidden text-xs text-indigo-600 dark:text-indigo-400 mt-1.5"
|
||||||
|
id="effective-range-{{ s.id }}"></div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
{% if item.session.status == 'recording' %}
|
{% if s.status == 'recording' %}
|
||||||
<button onclick="stopRecording('{{ item.session.id }}')"
|
<button onclick="stopRecording('{{ s.id }}')"
|
||||||
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
|
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
|
||||||
Stop
|
Stop
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button onclick="viewSession('{{ item.session.id }}')"
|
<button onclick="viewSession('{{ s.id }}')"
|
||||||
class="px-3 py-1 text-xs bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
class="px-3 py-1 text-xs bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||||
Details
|
Details
|
||||||
</button>
|
</button>
|
||||||
@@ -84,24 +213,210 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-12">
|
<div class="text-center py-12">
|
||||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-gray-500 dark:text-gray-400 mb-2">No monitoring sessions yet</p>
|
<p class="text-gray-500 dark:text-gray-400 mb-1">No monitoring sessions yet</p>
|
||||||
<p class="text-sm text-gray-400 dark:text-gray-500">Schedule a session to get started</p>
|
<p class="text-sm text-gray-400 dark:text-gray-500">Upload data to create sessions</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const PROJECT_ID = '{{ project_id }}';
|
||||||
|
|
||||||
|
const PERIOD_COLORS = {
|
||||||
|
weekday_day: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
|
||||||
|
weekday_night: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
|
||||||
|
weekend_day: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300',
|
||||||
|
weekend_night: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300',
|
||||||
|
};
|
||||||
|
const PERIOD_LABELS = {
|
||||||
|
weekday_day: 'Weekday Day',
|
||||||
|
weekday_night: 'Weekday Night',
|
||||||
|
weekend_day: 'Weekend Day',
|
||||||
|
weekend_night: 'Weekend Night',
|
||||||
|
};
|
||||||
|
// Default hours for each period type
|
||||||
|
const PERIOD_DEFAULT_HOURS = {
|
||||||
|
weekday_day: {start: 7, end: 19},
|
||||||
|
weekday_night: {start: 19, end: 7},
|
||||||
|
weekend_day: {start: 7, end: 19},
|
||||||
|
weekend_night: {start: 19, end: 7},
|
||||||
|
};
|
||||||
|
const FALLBACK_COLORS = ['bg-gray-100','text-gray-500','dark:bg-gray-700','dark:text-gray-400'];
|
||||||
|
const ALL_BADGE_COLORS = [...new Set([
|
||||||
|
...FALLBACK_COLORS,
|
||||||
|
...Object.values(PERIOD_COLORS).flatMap(s => s.split(' '))
|
||||||
|
])];
|
||||||
|
|
||||||
|
// Track which period type is selected in the editor before saving
|
||||||
|
const _editorState = {};
|
||||||
|
|
||||||
|
// ---- Period editor ----
|
||||||
|
|
||||||
|
function openPeriodEditor(sessionId) {
|
||||||
|
// Close all other editors first
|
||||||
|
document.querySelectorAll('[id^="period-editor-"]').forEach(el => {
|
||||||
|
if (el.id !== 'period-editor-' + sessionId) el.classList.add('hidden');
|
||||||
|
});
|
||||||
|
document.getElementById('period-editor-' + sessionId).classList.toggle('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePeriodEditor(sessionId) {
|
||||||
|
document.getElementById('period-editor-' + sessionId).classList.add('hidden');
|
||||||
|
delete _editorState[sessionId];
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPeriodType(sessionId, pt) {
|
||||||
|
_editorState[sessionId] = pt;
|
||||||
|
// Highlight selected button
|
||||||
|
document.querySelectorAll(`[id^="pt-btn-${sessionId}-"]`).forEach(btn => {
|
||||||
|
const isSelected = btn.id === `pt-btn-${sessionId}-${pt}`;
|
||||||
|
btn.classList.toggle('border-seismo-orange', isSelected);
|
||||||
|
btn.classList.toggle('bg-orange-50', isSelected);
|
||||||
|
btn.classList.toggle('text-seismo-orange', isSelected);
|
||||||
|
btn.classList.toggle('dark:bg-orange-900/20', isSelected);
|
||||||
|
btn.classList.toggle('border-gray-200', !isSelected);
|
||||||
|
btn.classList.toggle('dark:border-gray-600', !isSelected);
|
||||||
|
btn.classList.toggle('text-gray-600', !isSelected);
|
||||||
|
btn.classList.toggle('dark:text-gray-400', !isSelected);
|
||||||
|
});
|
||||||
|
// Fill default hours
|
||||||
|
const defaults = PERIOD_DEFAULT_HOURS[pt];
|
||||||
|
if (defaults) {
|
||||||
|
const sh = document.getElementById('period-start-hr-' + sessionId);
|
||||||
|
const eh = document.getElementById('period-end-hr-' + sessionId);
|
||||||
|
if (sh && !sh.value) sh.value = defaults.start;
|
||||||
|
if (eh && !eh.value) eh.value = defaults.end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePeriodEditor(sessionId) {
|
||||||
|
const pt = _editorState[sessionId] || document.getElementById('period-badge-' + sessionId)
|
||||||
|
?.dataset?.currentPeriod || null;
|
||||||
|
const shInput = document.getElementById('period-start-hr-' + sessionId);
|
||||||
|
const ehInput = document.getElementById('period-end-hr-' + sessionId);
|
||||||
|
|
||||||
|
const payload = {};
|
||||||
|
if (pt !== undefined) payload.period_type = pt || null;
|
||||||
|
payload.period_start_hour = shInput?.value !== '' ? parseInt(shInput.value, 10) : null;
|
||||||
|
payload.period_end_hour = ehInput?.value !== '' ? parseInt(ehInput.value, 10) : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${sessionId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await resp.text());
|
||||||
|
const result = await resp.json();
|
||||||
|
|
||||||
|
// Update badge
|
||||||
|
const badge = document.getElementById('period-badge-' + sessionId);
|
||||||
|
const label = document.getElementById('period-label-' + sessionId);
|
||||||
|
const newPt = result.period_type;
|
||||||
|
ALL_BADGE_COLORS.forEach(c => badge.classList.remove(c));
|
||||||
|
if (newPt && PERIOD_COLORS[newPt]) {
|
||||||
|
badge.classList.add(...PERIOD_COLORS[newPt].split(' ').filter(Boolean));
|
||||||
|
if (label) label.textContent = PERIOD_LABELS[newPt];
|
||||||
|
} else {
|
||||||
|
badge.classList.add(...FALLBACK_COLORS);
|
||||||
|
if (label) label.textContent = 'Set period';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update effective range display
|
||||||
|
_updateEffectiveRange(sessionId, result.period_start_hour, result.period_end_hour);
|
||||||
|
|
||||||
|
closePeriodEditor(sessionId);
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to save period: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearPeriodEditor(sessionId) {
|
||||||
|
const shInput = document.getElementById('period-start-hr-' + sessionId);
|
||||||
|
const ehInput = document.getElementById('period-end-hr-' + sessionId);
|
||||||
|
if (shInput) shInput.value = '';
|
||||||
|
if (ehInput) ehInput.value = '';
|
||||||
|
_editorState[sessionId] = null;
|
||||||
|
|
||||||
|
// Reset period type button highlights
|
||||||
|
document.querySelectorAll(`[id^="pt-btn-${sessionId}-"]`).forEach(btn => {
|
||||||
|
btn.classList.remove('border-seismo-orange','bg-orange-50','text-seismo-orange','dark:bg-orange-900/20');
|
||||||
|
btn.classList.add('border-gray-200','dark:border-gray-600','text-gray-600','dark:text-gray-400');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Effective range helper ----
|
||||||
|
|
||||||
|
function _updateEffectiveRange(sessionId, startHour, endHour) {
|
||||||
|
const el = document.getElementById('effective-range-' + sessionId);
|
||||||
|
if (!el) return;
|
||||||
|
if (startHour == null || endHour == null) {
|
||||||
|
el.textContent = '';
|
||||||
|
el.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
function _fmt(h) {
|
||||||
|
const ampm = h < 12 ? 'AM' : 'PM';
|
||||||
|
const h12 = h % 12 || 12;
|
||||||
|
return `${h12}:00 ${ampm}`;
|
||||||
|
}
|
||||||
|
// We don't have the session start date in JS so just show the hours pattern
|
||||||
|
el.textContent = `Effective window: ${_fmt(startHour)} → ${_fmt(endHour)}`;
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Close editors on outside click ----
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!e.target.closest('[id^="period-wrap-"]')) {
|
||||||
|
document.querySelectorAll('[id^="period-editor-"]').forEach(m => m.classList.add('hidden'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Label editing ----
|
||||||
|
|
||||||
|
function startEditLabel(sessionId) {
|
||||||
|
document.getElementById('label-display-' + sessionId).classList.add('hidden');
|
||||||
|
const input = document.getElementById('label-input-' + sessionId);
|
||||||
|
input.classList.remove('hidden');
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditLabel(sessionId) {
|
||||||
|
document.getElementById('label-input-' + sessionId).classList.add('hidden');
|
||||||
|
document.getElementById('label-display-' + sessionId).classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLabel(sessionId) {
|
||||||
|
const display = document.getElementById('label-display-' + sessionId);
|
||||||
|
const input = document.getElementById('label-input-' + sessionId);
|
||||||
|
const newLabel = input.value.trim();
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${sessionId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({session_label: newLabel}),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await resp.text());
|
||||||
|
display.textContent = newLabel || ('Session ' + sessionId.slice(0, 8) + '…');
|
||||||
|
} catch(err) {
|
||||||
|
alert('Failed to save label: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
input.classList.add('hidden');
|
||||||
|
display.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Session details ----
|
||||||
|
|
||||||
function viewSession(sessionId) {
|
function viewSession(sessionId) {
|
||||||
// TODO: Implement session detail modal or page
|
window.location.href = `/api/projects/${PROJECT_ID}/sessions/${sessionId}/detail`;
|
||||||
alert('Session details coming soon: ' + sessionId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopRecording(sessionId) {
|
function stopRecording(sessionId) {
|
||||||
if (!confirm('Stop this monitoring session?')) return;
|
if (!confirm('Stop this monitoring session?')) return;
|
||||||
|
|
||||||
// TODO: Implement stop recording API call
|
|
||||||
alert('Stop recording API coming soon for session: ' + sessionId);
|
alert('Stop recording API coming soon for session: ' + sessionId);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
124
templates/partials/projects/sessions_calendar.html
Normal file
124
templates/partials/projects/sessions_calendar.html
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<!-- Monthly Sessions Calendar — Gantt Style -->
|
||||||
|
<div class="sessions-cal-wrap bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Month navigation -->
|
||||||
|
<div class="px-5 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<button hx-get="/api/projects/{{ project_id }}/sessions-calendar?month={{ prev_month }}&year={{ prev_year }}"
|
||||||
|
hx-target="#sessions-calendar"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors text-gray-500 dark:text-gray-400"
|
||||||
|
title="Previous month">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ month_name }} {{ year }}</h3>
|
||||||
|
<button hx-get="/api/projects/{{ project_id }}/sessions-calendar?month={{ next_month }}&year={{ next_year }}"
|
||||||
|
hx-target="#sessions-calendar"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors text-gray-500 dark:text-gray-400"
|
||||||
|
title="Next month">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legend + key -->
|
||||||
|
<div class="px-5 py-2 border-b border-gray-100 dark:border-gray-700 flex flex-wrap items-center gap-x-5 gap-y-1.5">
|
||||||
|
{% if legend %}
|
||||||
|
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
{% for loc in legend %}
|
||||||
|
<div class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<span class="w-2.5 h-2.5 rounded-full shrink-0" style="background-color: {{ loc.color }}"></span>
|
||||||
|
<span>{{ loc.name }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<!-- Bar key -->
|
||||||
|
<div class="flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500 ml-auto shrink-0">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<div class="w-8 h-2 rounded-sm" style="background:rgba(100,100,100,0.25)"></div>
|
||||||
|
<span>Device on</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<div class="w-8 h-2 rounded-sm bg-blue-500"></div>
|
||||||
|
<span>Effective window</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Day-of-week headers -->
|
||||||
|
<div class="grid grid-cols-7 border-b border-gray-100 dark:border-gray-700">
|
||||||
|
{% for day_name in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] %}
|
||||||
|
<div class="py-2 text-center text-xs font-medium uppercase tracking-wide
|
||||||
|
{% if loop.index == 1 or loop.index == 7 %}text-amber-500 dark:text-amber-400{% else %}text-gray-400 dark:text-gray-500{% endif %}">
|
||||||
|
{{ day_name }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Calendar grid -->
|
||||||
|
{% for week in weeks %}
|
||||||
|
<div class="grid grid-cols-7 {% if not loop.last %}border-b border-gray-100 dark:border-gray-700{% endif %}">
|
||||||
|
{% for day in week %}
|
||||||
|
<div class="min-h-[80px] p-1.5
|
||||||
|
{% if not loop.last %}border-r border-gray-100 dark:border-gray-700{% endif %}
|
||||||
|
{% if not day.in_month %}bg-gray-50 dark:bg-slate-800/50{% else %}bg-white dark:bg-slate-800{% endif %}
|
||||||
|
{% if day.is_today %}ring-1 ring-inset ring-seismo-orange{% endif %}">
|
||||||
|
|
||||||
|
<!-- Date number -->
|
||||||
|
<div class="text-right mb-1.5">
|
||||||
|
<span class="text-xs {% if day.is_today %}font-bold text-seismo-orange{% elif day.in_month %}text-gray-700 dark:text-gray-300{% else %}text-gray-300 dark:text-gray-600{% endif %}">
|
||||||
|
{{ day.date.day }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gantt bars -->
|
||||||
|
{% if day.sessions %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for s in day.sessions %}
|
||||||
|
|
||||||
|
<a href="/api/projects/{{ project_id }}/sessions/{{ s.session_id }}/detail" class="block">
|
||||||
|
|
||||||
|
<!-- 24-hour timeline bar -->
|
||||||
|
<div class="relative overflow-hidden -mx-1.5" style="height:11px; background:rgba(128,128,128,0.08);">
|
||||||
|
<div class="absolute top-0 bottom-0 w-px" style="left:25%; background:rgba(128,128,128,0.18)"></div>
|
||||||
|
<div class="absolute top-0 bottom-0 w-px" style="left:50%; background:rgba(128,128,128,0.28)"></div>
|
||||||
|
<div class="absolute top-0 bottom-0 w-px" style="left:75%; background:rgba(128,128,128,0.18)"></div>
|
||||||
|
<div class="absolute top-0 bottom-0"
|
||||||
|
style="left:{{ s.dev_start_pct }}%; width:{{ s.dev_width_pct }}%; background-color:{{ s.color }}; opacity:0.28;"></div>
|
||||||
|
{% if s.eff_start_pct is not none %}
|
||||||
|
<div class="absolute"
|
||||||
|
style="left:{{ s.eff_start_pct }}%; width:{{ s.eff_width_pct }}%; top:1.5px; bottom:1.5px; background-color:{{ s.color }};"></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Label -->
|
||||||
|
<div class="truncate mt-0.5" style="color:{{ s.color }}; font-size:0.58rem; line-height:1.3;">
|
||||||
|
{{ s.location_name }} · {{ day.date.strftime('%-m/%-d') }} · {% if s.period_type %}{{ 'Night' if 'night' in s.period_type else 'Day' }}{% else %}—{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Time scale footer hint -->
|
||||||
|
<div class="px-4 py-1.5 border-t border-gray-100 dark:border-gray-700 flex justify-between text-gray-300 dark:text-gray-600" style="font-size:0.6rem;">
|
||||||
|
<span>12 AM</span>
|
||||||
|
<span>6 AM</span>
|
||||||
|
<span>12 PM</span>
|
||||||
|
<span>6 PM</span>
|
||||||
|
<span>12 AM</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
@@ -56,6 +56,15 @@
|
|||||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
|
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
|
||||||
{{ session.status or 'unknown' }}
|
{{ session.status or 'unknown' }}
|
||||||
</span>
|
</span>
|
||||||
|
<!-- Edit Session Times -->
|
||||||
|
<button onclick="event.stopPropagation(); openEditSessionModal('{{ session.id }}', '{{ session.started_at|local_datetime if session.started_at else '' }}', '{{ session.stopped_at|local_datetime if session.stopped_at else '' }}')"
|
||||||
|
class="px-3 py-1 text-xs bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-1"
|
||||||
|
title="Edit session times">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||||
|
</svg>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
<!-- Download All Files in Session -->
|
<!-- Download All Files in Session -->
|
||||||
<button onclick="event.stopPropagation(); downloadSessionFiles('{{ session.id }}')"
|
<button onclick="event.stopPropagation(); downloadSessionFiles('{{ session.id }}')"
|
||||||
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1"
|
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1"
|
||||||
@@ -326,4 +335,74 @@ async function deleteSession(sessionId) {
|
|||||||
alert(`Error deleting session: ${error.message}`);
|
alert(`Error deleting session: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openEditSessionModal(sessionId, startedAt, stoppedAt) {
|
||||||
|
document.getElementById('editSessionId').value = sessionId;
|
||||||
|
// local_datetime filter returns "YYYY-MM-DD HH:MM" — convert to "YYYY-MM-DDTHH:MM" for datetime-local input
|
||||||
|
document.getElementById('editStartedAt').value = startedAt ? startedAt.replace(' ', 'T') : '';
|
||||||
|
document.getElementById('editStoppedAt').value = stoppedAt ? stoppedAt.replace(' ', 'T') : '';
|
||||||
|
document.getElementById('editSessionModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditSessionModal() {
|
||||||
|
document.getElementById('editSessionModal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSessionTimes() {
|
||||||
|
const sessionId = document.getElementById('editSessionId').value;
|
||||||
|
const startedAt = document.getElementById('editStartedAt').value;
|
||||||
|
const stoppedAt = document.getElementById('editStoppedAt').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
started_at: startedAt || null,
|
||||||
|
stopped_at: stoppedAt || null,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
closeEditSessionModal();
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
alert(`Failed to update session: ${data.detail || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error updating session: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Edit Session Times Modal -->
|
||||||
|
<div id="editSessionModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-xl p-6 w-full max-w-sm mx-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Edit Session Times</h3>
|
||||||
|
<input type="hidden" id="editSessionId">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start Time</label>
|
||||||
|
<input type="datetime-local" id="editStartedAt"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Stop Time</label>
|
||||||
|
<input type="datetime-local" id="editStoppedAt"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">Times are in your local timezone. The session label and period type will be updated automatically.</p>
|
||||||
|
<div class="flex justify-end gap-3 mt-5">
|
||||||
|
<button onclick="closeEditSessionModal()"
|
||||||
|
class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button onclick="saveSessionTimes()"
|
||||||
|
class="px-4 py-2 text-sm text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
63
templates/partials/seismo_row_edit.html
Normal file
63
templates/partials/seismo_row_edit.html
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<tr id="seismo-row-{{ unit.id }}" class="bg-blue-50 dark:bg-slate-600 transition-colors">
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
{{ unit.id }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 whitespace-nowrap">
|
||||||
|
<select name="status"
|
||||||
|
class="text-xs rounded border border-gray-300 dark:border-gray-500 bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500">
|
||||||
|
<option value="deployed" {% if unit.deployed %}selected{% endif %}>Deployed</option>
|
||||||
|
<option value="out_for_calibration" {% if unit.out_for_calibration %}selected{% endif %}>Out for Cal</option>
|
||||||
|
<option value="benched" {% if not unit.deployed and not unit.out_for_calibration %}selected{% endif %}>Benched</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{% if unit.deployed_with_modem_id %}
|
||||||
|
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
{{ unit.deployed_with_modem_id }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-600">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{% if unit.address %}
|
||||||
|
<span class="truncate max-w-xs inline-block" title="{{ unit.address }}">{{ unit.address }}</span>
|
||||||
|
{% elif unit.coordinates %}
|
||||||
|
<span>{{ unit.coordinates }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 whitespace-nowrap">
|
||||||
|
<input type="date"
|
||||||
|
name="last_calibrated"
|
||||||
|
value="{{ unit.last_calibrated.strftime('%Y-%m-%d') if unit.last_calibrated else '' }}"
|
||||||
|
class="text-xs rounded border border-gray-300 dark:border-gray-500 bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500" />
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2">
|
||||||
|
<input type="text"
|
||||||
|
name="note"
|
||||||
|
value="{{ unit.note or '' }}"
|
||||||
|
placeholder="Add a note..."
|
||||||
|
class="w-full text-sm rounded border border-gray-300 dark:border-gray-500 bg-white dark:bg-slate-700 text-gray-900 dark:text-gray-100 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500" />
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2 whitespace-nowrap text-right text-sm">
|
||||||
|
<div class="flex items-center justify-end gap-2">
|
||||||
|
<button hx-post="/api/seismo-dashboard/unit/{{ unit.id }}/quick-update"
|
||||||
|
hx-include="closest tr"
|
||||||
|
hx-target="#seismo-row-{{ unit.id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="inline-flex items-center px-2.5 py-1 rounded text-xs font-medium bg-blue-600 text-white hover:bg-blue-700 transition-colors">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button hx-get="/api/seismo-dashboard/unit/{{ unit.id }}/view-row"
|
||||||
|
hx-target="#seismo-row-{{ unit.id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="inline-flex items-center px-2.5 py-1 rounded text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-600 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
93
templates/partials/seismo_row_view.html
Normal file
93
templates/partials/seismo_row_view.html
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<tr id="seismo-row-{{ unit.id }}" class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
{{ unit.id }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
{% if unit.deployed %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||||
|
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
Deployed
|
||||||
|
</span>
|
||||||
|
{% elif unit.out_for_calibration %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
|
||||||
|
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
Out for Cal
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
|
||||||
|
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
Benched
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
|
||||||
|
{% if unit.deployed_with_modem_id %}
|
||||||
|
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
{{ unit.deployed_with_modem_id }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-600">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-300">
|
||||||
|
{% if unit.address %}
|
||||||
|
<span class="truncate max-w-xs inline-block" title="{{ unit.address }}">{{ unit.address }}</span>
|
||||||
|
{% elif unit.coordinates %}
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">{{ unit.coordinates }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
|
||||||
|
{% if unit.last_calibrated %}
|
||||||
|
<span class="inline-flex items-center gap-1.5">
|
||||||
|
{% if unit.next_calibration_due and today %}
|
||||||
|
{% set days_until = (unit.next_calibration_due - today).days %}
|
||||||
|
{% if days_until < 0 %}
|
||||||
|
<span class="w-2 h-2 rounded-full bg-red-500" title="Calibration expired {{ -days_until }} days ago"></span>
|
||||||
|
{% elif days_until <= 14 %}
|
||||||
|
<span class="w-2 h-2 rounded-full bg-yellow-500" title="Calibration expires in {{ days_until }} days"></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="w-2 h-2 rounded-full bg-green-500" title="Calibration valid ({{ days_until }} days remaining)"></span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="w-2 h-2 rounded-full bg-gray-400" title="No expiry date set"></span>
|
||||||
|
{% endif %}
|
||||||
|
{{ unit.last_calibrated.strftime('%Y-%m-%d') }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-400">
|
||||||
|
{% if unit.note %}
|
||||||
|
<span class="truncate max-w-xs inline-block" title="{{ unit.note }}">{{ unit.note }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
||||||
|
<div class="flex items-center justify-end gap-3">
|
||||||
|
<button hx-get="/api/seismo-dashboard/unit/{{ unit.id }}/edit-row"
|
||||||
|
hx-target="#seismo-row-{{ unit.id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
||||||
|
title="Edit row">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<a href="/unit/{{ unit.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
View Details →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
@@ -31,6 +31,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="text-gray-600 dark:text-gray-400 text-sm">Benched</p>
|
<p class="text-gray-600 dark:text-gray-400 text-sm">Benched</p>
|
||||||
<p class="text-3xl font-bold text-gray-600 dark:text-gray-400 mt-2">{{ benched }}</p>
|
<p class="text-3xl font-bold text-gray-600 dark:text-gray-400 mt-2">{{ benched }}</p>
|
||||||
|
{% if out_for_calibration > 0 %}
|
||||||
|
<p class="text-xs text-purple-600 dark:text-purple-400 mt-1">{{ out_for_calibration }} out for calibration</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<svg class="w-12 h-12 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-12 h-12 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||||
|
|||||||
@@ -92,81 +92,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{% for unit in units %}
|
{% for unit in units %}
|
||||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
{% include "partials/seismo_row_view.html" %}
|
||||||
<td class="px-4 py-3 whitespace-nowrap">
|
|
||||||
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
|
|
||||||
{{ unit.id }}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 whitespace-nowrap">
|
|
||||||
{% if unit.deployed %}
|
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
|
||||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
|
||||||
</svg>
|
|
||||||
Deployed
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
|
|
||||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd"></path>
|
|
||||||
</svg>
|
|
||||||
Benched
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
|
|
||||||
{% if unit.deployed_with_modem_id %}
|
|
||||||
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
|
||||||
{{ unit.deployed_with_modem_id }}
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-gray-400 dark:text-gray-600">None</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-300">
|
|
||||||
{% if unit.address %}
|
|
||||||
<span class="truncate max-w-xs inline-block" title="{{ unit.address }}">{{ unit.address }}</span>
|
|
||||||
{% elif unit.coordinates %}
|
|
||||||
<span class="text-gray-500 dark:text-gray-400">{{ unit.coordinates }}</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
|
|
||||||
{% if unit.last_calibrated %}
|
|
||||||
<span class="inline-flex items-center gap-1.5">
|
|
||||||
{% if unit.next_calibration_due and today %}
|
|
||||||
{% set days_until = (unit.next_calibration_due - today).days %}
|
|
||||||
{% if days_until < 0 %}
|
|
||||||
<span class="w-2 h-2 rounded-full bg-red-500" title="Calibration expired {{ -days_until }} days ago"></span>
|
|
||||||
{% elif days_until <= 14 %}
|
|
||||||
<span class="w-2 h-2 rounded-full bg-yellow-500" title="Calibration expires in {{ days_until }} days"></span>
|
|
||||||
{% else %}
|
|
||||||
<span class="w-2 h-2 rounded-full bg-green-500" title="Calibration valid ({{ days_until }} days remaining)"></span>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<span class="w-2 h-2 rounded-full bg-gray-400" title="No expiry date set"></span>
|
|
||||||
{% endif %}
|
|
||||||
{{ unit.last_calibrated.strftime('%Y-%m-%d') }}
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-400">
|
|
||||||
{% if unit.note %}
|
|
||||||
<span class="truncate max-w-xs inline-block" title="{{ unit.note }}">{{ unit.note }}</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
|
||||||
<a href="/unit/{{ unit.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
|
||||||
View Details →
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -208,6 +208,19 @@
|
|||||||
<div class="text-center py-8 text-gray-500">Loading sessions...</div>
|
<div class="text-center py-8 text-gray-500">Loading sessions...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Monthly Calendar -->
|
||||||
|
<div class="mt-6 bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Calendar View</h3>
|
||||||
|
</div>
|
||||||
|
<div id="sessions-calendar"
|
||||||
|
hx-get="/api/projects/{{ project_id }}/sessions-calendar"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="text-center py-6 text-gray-400 text-sm">Loading calendar…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Data Files Tab -->
|
<!-- Data Files Tab -->
|
||||||
@@ -264,16 +277,28 @@
|
|||||||
file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0
|
file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0
|
||||||
file:text-sm file:font-medium file:bg-seismo-orange file:text-white
|
file:text-sm file:font-medium file:bg-seismo-orange file:text-white
|
||||||
hover:file:bg-seismo-navy file:cursor-pointer" />
|
hover:file:bg-seismo-navy file:cursor-pointer" />
|
||||||
<button onclick="submitUploadAll()"
|
<span id="upload-all-file-count" class="text-xs text-gray-500 dark:text-gray-400 hidden"></span>
|
||||||
|
<button id="upload-all-btn" onclick="submitUploadAll()"
|
||||||
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
|
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
|
||||||
Import
|
Import
|
||||||
</button>
|
</button>
|
||||||
<button onclick="toggleUploadAll()"
|
<button id="upload-all-cancel-btn" onclick="toggleUploadAll()"
|
||||||
class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<span id="upload-all-status" class="text-sm hidden"></span>
|
<span id="upload-all-status" class="text-sm hidden"></span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div id="upload-all-progress-wrap" class="hidden mt-3">
|
||||||
|
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
<span id="upload-all-progress-label">Uploading…</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
|
<div id="upload-all-progress-bar"
|
||||||
|
class="bg-green-500 h-2 rounded-full transition-all duration-300"
|
||||||
|
style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Result summary -->
|
<!-- Result summary -->
|
||||||
<div id="upload-all-results" class="hidden mt-3 text-sm space-y-1"></div>
|
<div id="upload-all-results" class="hidden mt-3 text-sm space-y-1"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -316,6 +341,7 @@
|
|||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Status</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Status</label>
|
||||||
<select name="status" id="settings-status"
|
<select name="status" id="settings-status"
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="upcoming">Upcoming</option>
|
||||||
<option value="active">Active</option>
|
<option value="active">Active</option>
|
||||||
<option value="on_hold">On Hold</option>
|
<option value="on_hold">On Hold</option>
|
||||||
<option value="completed">Completed</option>
|
<option value="completed">Completed</option>
|
||||||
@@ -746,6 +772,24 @@ const projectId = "{{ project_id }}";
|
|||||||
let editingLocationId = null;
|
let editingLocationId = null;
|
||||||
let projectTypeId = null;
|
let projectTypeId = null;
|
||||||
|
|
||||||
|
async function quickUpdateStatus(newStatus) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ status: newStatus })
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
// Reload the page to reflect new badge color and any side effects
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Failed to update status');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error updating status');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Tab switching
|
// Tab switching
|
||||||
function switchTab(tabName) {
|
function switchTab(tabName) {
|
||||||
// Hide all tab panels
|
// Hide all tab panels
|
||||||
@@ -807,6 +851,7 @@ async function loadProjectDetails() {
|
|||||||
|
|
||||||
// Update tab labels and visibility based on project type
|
// Update tab labels and visibility based on project type
|
||||||
const isSoundProject = projectTypeId === 'sound_monitoring';
|
const isSoundProject = projectTypeId === 'sound_monitoring';
|
||||||
|
const isVibrationProject = projectTypeId === 'vibration_monitoring';
|
||||||
if (isSoundProject) {
|
if (isSoundProject) {
|
||||||
document.getElementById('locations-tab-label').textContent = 'NRLs';
|
document.getElementById('locations-tab-label').textContent = 'NRLs';
|
||||||
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
|
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
|
||||||
@@ -817,9 +862,9 @@ async function loadProjectDetails() {
|
|||||||
const isRemote = mode === 'remote';
|
const isRemote = mode === 'remote';
|
||||||
document.getElementById('sessions-tab-btn').classList.toggle('hidden', !isSoundProject);
|
document.getElementById('sessions-tab-btn').classList.toggle('hidden', !isSoundProject);
|
||||||
document.getElementById('data-tab-btn').classList.toggle('hidden', !isSoundProject);
|
document.getElementById('data-tab-btn').classList.toggle('hidden', !isSoundProject);
|
||||||
// Schedules and Assigned Units are remote-only (manual projects collect data by hand)
|
// Schedules and Assigned Units: hidden for vibration; for sound, only show if remote
|
||||||
document.getElementById('schedules-tab-btn')?.classList.toggle('hidden', isSoundProject && !isRemote);
|
document.getElementById('schedules-tab-btn')?.classList.toggle('hidden', isVibrationProject || (isSoundProject && !isRemote));
|
||||||
document.getElementById('units-tab-btn')?.classList.toggle('hidden', isSoundProject && !isRemote);
|
document.getElementById('units-tab-btn')?.classList.toggle('hidden', isVibrationProject || (isSoundProject && !isRemote));
|
||||||
// FTP browser within Data Files tab
|
// FTP browser within Data Files tab
|
||||||
document.getElementById('ftp-browser')?.classList.toggle('hidden', !isRemote);
|
document.getElementById('ftp-browser')?.classList.toggle('hidden', !isRemote);
|
||||||
|
|
||||||
@@ -1642,75 +1687,148 @@ function toggleUploadAll() {
|
|||||||
document.getElementById('upload-all-results').classList.add('hidden');
|
document.getElementById('upload-all-results').classList.add('hidden');
|
||||||
document.getElementById('upload-all-results').innerHTML = '';
|
document.getElementById('upload-all-results').innerHTML = '';
|
||||||
document.getElementById('upload-all-input').value = '';
|
document.getElementById('upload-all-input').value = '';
|
||||||
|
document.getElementById('upload-all-file-count').classList.add('hidden');
|
||||||
|
document.getElementById('upload-all-progress-wrap').classList.add('hidden');
|
||||||
|
document.getElementById('upload-all-progress-bar').style.width = '0%';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitUploadAll() {
|
// Show file count and filter info when folder is selected
|
||||||
|
document.getElementById('upload-all-input').addEventListener('change', function() {
|
||||||
|
const countEl = document.getElementById('upload-all-file-count');
|
||||||
|
const total = this.files.length;
|
||||||
|
if (!total) { countEl.classList.add('hidden'); return; }
|
||||||
|
const wanted = Array.from(this.files).filter(_isWantedFile).length;
|
||||||
|
countEl.textContent = `${wanted} of ${total} files will be uploaded (Leq + .rnh only)`;
|
||||||
|
countEl.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
function _isWantedFile(f) {
|
||||||
|
const n = (f.webkitRelativePath || f.name).toLowerCase();
|
||||||
|
const base = n.split('/').pop();
|
||||||
|
if (base.endsWith('.rnh')) return true;
|
||||||
|
if (base.endsWith('.rnd')) {
|
||||||
|
if (base.includes('_leq_')) return true; // NL-43 Leq
|
||||||
|
if (base.startsWith('au2_')) return true; // AU2/NL-23 format
|
||||||
|
if (!base.includes('_lp')) return true; // unknown format — keep
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitUploadAll() {
|
||||||
const input = document.getElementById('upload-all-input');
|
const input = document.getElementById('upload-all-input');
|
||||||
const status = document.getElementById('upload-all-status');
|
const status = document.getElementById('upload-all-status');
|
||||||
const resultsEl = document.getElementById('upload-all-results');
|
const resultsEl = document.getElementById('upload-all-results');
|
||||||
|
const btn = document.getElementById('upload-all-btn');
|
||||||
|
const cancelBtn = document.getElementById('upload-all-cancel-btn');
|
||||||
|
const progressWrap = document.getElementById('upload-all-progress-wrap');
|
||||||
|
const progressBar = document.getElementById('upload-all-progress-bar');
|
||||||
|
const progressLabel = document.getElementById('upload-all-progress-label');
|
||||||
|
|
||||||
if (!input.files.length) {
|
if (!input.files.length) {
|
||||||
alert('Please select a folder to upload.');
|
alert('Please select a folder to upload.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter client-side — only send Leq .rnd and .rnh files
|
||||||
|
const filesToSend = Array.from(input.files).filter(_isWantedFile);
|
||||||
|
if (!filesToSend.length) {
|
||||||
|
alert('No Leq .rnd or .rnh files found in selected folder.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
for (const f of input.files) {
|
for (const f of filesToSend) {
|
||||||
// webkitRelativePath gives the path relative to the selected folder root
|
|
||||||
formData.append('files', f);
|
formData.append('files', f);
|
||||||
formData.append('paths', f.webkitRelativePath || f.name);
|
formData.append('paths', f.webkitRelativePath || f.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
status.textContent = `Uploading ${input.files.length} files\u2026`;
|
// Disable controls and show progress
|
||||||
status.className = 'text-sm text-gray-500';
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Uploading\u2026';
|
||||||
|
btn.classList.add('opacity-60', 'cursor-not-allowed');
|
||||||
|
cancelBtn.disabled = true;
|
||||||
|
cancelBtn.classList.add('opacity-40', 'cursor-not-allowed');
|
||||||
|
status.className = 'text-sm hidden';
|
||||||
resultsEl.classList.add('hidden');
|
resultsEl.classList.add('hidden');
|
||||||
|
progressWrap.classList.remove('hidden');
|
||||||
|
progressBar.style.width = '0%';
|
||||||
|
progressLabel.textContent = `Uploading ${filesToSend.length} files\u2026`;
|
||||||
|
|
||||||
try {
|
const xhr = new XMLHttpRequest();
|
||||||
const response = await fetch(
|
|
||||||
`/api/projects/{{ project_id }}/upload-all`,
|
|
||||||
{ method: 'POST', body: formData }
|
|
||||||
);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
const s = data.sessions_created;
|
if (e.lengthComputable) {
|
||||||
const f = data.files_imported;
|
const pct = Math.round((e.loaded / e.total) * 100);
|
||||||
status.textContent = `\u2713 Imported ${f} file${f !== 1 ? 's' : ''} across ${s} session${s !== 1 ? 's' : ''}`;
|
progressBar.style.width = pct + '%';
|
||||||
status.className = 'text-sm text-green-600 dark:text-green-400';
|
progressLabel.textContent = `Uploading ${filesToSend.length} files\u2026 ${pct}%`;
|
||||||
input.value = '';
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Build results summary
|
xhr.upload.addEventListener('load', () => {
|
||||||
let html = '';
|
progressBar.style.width = '100%';
|
||||||
if (data.sessions && data.sessions.length) {
|
progressLabel.textContent = 'Processing files on server\u2026';
|
||||||
html += '<div class="font-medium text-gray-700 dark:text-gray-300 mb-1">Sessions created:</div>';
|
});
|
||||||
html += '<ul class="space-y-0.5 ml-2">';
|
|
||||||
for (const sess of data.sessions) {
|
function _resetControls() {
|
||||||
html += `<li class="text-xs text-gray-600 dark:text-gray-400">\u2022 <span class="font-medium">${sess.location_name}</span> — ${sess.files} files`;
|
progressWrap.classList.add('hidden');
|
||||||
if (sess.leq_files || sess.lp_files) html += ` (${sess.leq_files} Leq, ${sess.lp_files} Lp)`;
|
btn.disabled = false;
|
||||||
if (sess.store_name) html += ` — ${sess.store_name}`;
|
btn.textContent = 'Import';
|
||||||
html += '</li>';
|
btn.classList.remove('opacity-60', 'cursor-not-allowed');
|
||||||
|
cancelBtn.disabled = false;
|
||||||
|
cancelBtn.classList.remove('opacity-40', 'cursor-not-allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.addEventListener('load', () => {
|
||||||
|
_resetControls();
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(xhr.responseText);
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
const s = data.sessions_created;
|
||||||
|
const f = data.files_imported;
|
||||||
|
status.textContent = `\u2713 Imported ${f} file${f !== 1 ? 's' : ''} across ${s} session${s !== 1 ? 's' : ''}`;
|
||||||
|
status.className = 'text-sm text-green-600 dark:text-green-400';
|
||||||
|
input.value = '';
|
||||||
|
document.getElementById('upload-all-file-count').classList.add('hidden');
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
if (data.sessions && data.sessions.length) {
|
||||||
|
html += '<div class="font-medium text-gray-700 dark:text-gray-300 mb-1">Sessions created:</div>';
|
||||||
|
html += '<ul class="space-y-0.5 ml-2">';
|
||||||
|
for (const sess of data.sessions) {
|
||||||
|
html += `<li class="text-xs text-gray-600 dark:text-gray-400">\u2022 <span class="font-medium">${sess.location_name}</span> — ${sess.files} files`;
|
||||||
|
if (sess.leq_files || sess.lp_files) html += ` (${sess.leq_files} Leq, ${sess.lp_files} Lp)`;
|
||||||
|
if (sess.store_name) html += ` — ${sess.store_name}`;
|
||||||
|
html += '</li>';
|
||||||
|
}
|
||||||
|
html += '</ul>';
|
||||||
}
|
}
|
||||||
html += '</ul>';
|
if (data.unmatched_folders && data.unmatched_folders.length) {
|
||||||
|
html += `<div class="mt-2 text-xs text-amber-600 dark:text-amber-400">\u26a0 Unmatched folders (no NRL location found): ${data.unmatched_folders.join(', ')}</div>`;
|
||||||
|
}
|
||||||
|
if (html) {
|
||||||
|
resultsEl.innerHTML = html;
|
||||||
|
resultsEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
htmx.trigger(document.getElementById('unified-files'), 'refresh');
|
||||||
|
} else {
|
||||||
|
status.textContent = `Error: ${data.detail || 'Upload failed'}`;
|
||||||
|
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
}
|
}
|
||||||
if (data.unmatched_folders && data.unmatched_folders.length) {
|
} catch {
|
||||||
html += `<div class="mt-2 text-xs text-amber-600 dark:text-amber-400">\u26a0 Unmatched folders (no NRL location found): ${data.unmatched_folders.join(', ')}</div>`;
|
status.textContent = 'Error: Unexpected server response';
|
||||||
}
|
|
||||||
if (html) {
|
|
||||||
resultsEl.innerHTML = html;
|
|
||||||
resultsEl.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh the unified files view
|
|
||||||
htmx.trigger(document.getElementById('unified-files'), 'refresh');
|
|
||||||
} else {
|
|
||||||
status.textContent = `Error: ${data.detail || 'Upload failed'}`;
|
|
||||||
status.className = 'text-sm text-red-600 dark:text-red-400';
|
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
}
|
}
|
||||||
} catch (err) {
|
});
|
||||||
status.textContent = `Error: ${err.message}`;
|
|
||||||
|
xhr.addEventListener('error', () => {
|
||||||
|
_resetControls();
|
||||||
|
status.textContent = 'Error: Network error during upload';
|
||||||
status.className = 'text-sm text-red-600 dark:text-red-400';
|
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
}
|
});
|
||||||
|
|
||||||
|
xhr.open('POST', `/api/projects/{{ project_id }}/upload-all`);
|
||||||
|
xhr.send(formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load project details on page load and restore active tab from URL hash
|
// Load project details on page load and restore active tab from URL hash
|
||||||
@@ -1724,5 +1842,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
switchTab(hash);
|
switchTab(hash);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -36,12 +36,17 @@
|
|||||||
<nav class="flex space-x-8 px-6" aria-label="Tabs">
|
<nav class="flex space-x-8 px-6" aria-label="Tabs">
|
||||||
<button onclick="switchTab('all')"
|
<button onclick="switchTab('all')"
|
||||||
id="tab-all"
|
id="tab-all"
|
||||||
class="tab-button border-b-2 border-seismo-orange text-seismo-orange px-1 py-4 text-sm font-medium">
|
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
||||||
All Projects
|
All Projects
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="switchTab('upcoming')"
|
||||||
|
id="tab-upcoming"
|
||||||
|
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
||||||
|
Upcoming
|
||||||
|
</button>
|
||||||
<button onclick="switchTab('active')"
|
<button onclick="switchTab('active')"
|
||||||
id="tab-active"
|
id="tab-active"
|
||||||
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
class="tab-button border-b-2 border-seismo-orange text-seismo-orange px-1 py-4 text-sm font-medium">
|
||||||
Active
|
Active
|
||||||
</button>
|
</button>
|
||||||
<button onclick="switchTab('on_hold')"
|
<button onclick="switchTab('on_hold')"
|
||||||
@@ -66,7 +71,7 @@
|
|||||||
<!-- Projects List -->
|
<!-- Projects List -->
|
||||||
<div id="projects-list"
|
<div id="projects-list"
|
||||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
||||||
hx-get="/api/projects/list"
|
hx-get="/api/projects/list?status=active"
|
||||||
hx-trigger="load"
|
hx-trigger="load"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
<!-- Loading skeletons -->
|
<!-- Loading skeletons -->
|
||||||
|
|||||||
@@ -181,6 +181,27 @@ let chartInstance = null;
|
|||||||
let allData = [];
|
let allData = [];
|
||||||
let allHeaders = [];
|
let allHeaders = [];
|
||||||
|
|
||||||
|
// Session period window (null = no filtering)
|
||||||
|
const SESSION_PERIOD_START_HOUR = {{ period_start_hour if period_start_hour is not none else 'null' }};
|
||||||
|
const SESSION_PERIOD_END_HOUR = {{ period_end_hour if period_end_hour is not none else 'null' }};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given hour integer is within the session's period window.
|
||||||
|
* Always returns true when no period window is configured.
|
||||||
|
*/
|
||||||
|
function _isInPeriodWindow(hour) {
|
||||||
|
if (SESSION_PERIOD_START_HOUR === null || SESSION_PERIOD_END_HOUR === null) return true;
|
||||||
|
const sh = SESSION_PERIOD_START_HOUR;
|
||||||
|
const eh = SESSION_PERIOD_END_HOUR;
|
||||||
|
if (eh > sh) {
|
||||||
|
// Same-day window, e.g. 7–19
|
||||||
|
return hour >= sh && hour < eh;
|
||||||
|
} else {
|
||||||
|
// Crosses midnight, e.g. 19–7
|
||||||
|
return hour >= sh || hour < eh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load data on page load
|
// Load data on page load
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
loadRndData();
|
loadRndData();
|
||||||
@@ -387,19 +408,21 @@ function renderChart(data, fileType) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTable(headers, data) {
|
function _rowHour(row) {
|
||||||
const headerRow = document.getElementById('table-header');
|
// Parse hour from "Start Time" field (format: "YYYY/MM/DD HH:MM:SS")
|
||||||
const tbody = document.getElementById('table-body');
|
const t = row['Start Time'];
|
||||||
|
if (!t) return null;
|
||||||
|
const parts = t.split(' ');
|
||||||
|
if (parts.length < 2) return null;
|
||||||
|
return parseInt(parts[1].split(':')[0], 10);
|
||||||
|
}
|
||||||
|
|
||||||
// Render headers
|
function _buildRow(headers, row) {
|
||||||
headerRow.innerHTML = '<tr>' + headers.map(h =>
|
const hour = _rowHour(row);
|
||||||
`<th class="px-4 py-3 text-left font-medium">${escapeHtml(h)}</th>`
|
const inWindow = hour === null || _isInPeriodWindow(hour);
|
||||||
).join('') + '</tr>';
|
const dimClass = inWindow ? '' : 'opacity-40';
|
||||||
|
const titleAttr = (!inWindow) ? ' title="Outside period window"' : '';
|
||||||
// Render rows (limit to first 500 for performance)
|
return `<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 ${dimClass}"${titleAttr}>` +
|
||||||
const displayData = data.slice(0, 500);
|
|
||||||
tbody.innerHTML = displayData.map(row =>
|
|
||||||
'<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">' +
|
|
||||||
headers.map(h => {
|
headers.map(h => {
|
||||||
const val = row[h];
|
const val = row[h];
|
||||||
let displayVal = val;
|
let displayVal = val;
|
||||||
@@ -410,12 +433,34 @@ function renderTable(headers, data) {
|
|||||||
}
|
}
|
||||||
return `<td class="px-4 py-2 text-gray-700 dark:text-gray-300">${escapeHtml(String(displayVal))}</td>`;
|
return `<td class="px-4 py-2 text-gray-700 dark:text-gray-300">${escapeHtml(String(displayVal))}</td>`;
|
||||||
}).join('') +
|
}).join('') +
|
||||||
'</tr>'
|
'</tr>';
|
||||||
).join('');
|
}
|
||||||
|
|
||||||
|
function renderTable(headers, data) {
|
||||||
|
const headerRow = document.getElementById('table-header');
|
||||||
|
const tbody = document.getElementById('table-body');
|
||||||
|
|
||||||
|
// Render headers — add period window indicator if configured
|
||||||
|
let periodNote = '';
|
||||||
|
if (SESSION_PERIOD_START_HOUR !== null && SESSION_PERIOD_END_HOUR !== null) {
|
||||||
|
function _fmtH(h) { const ampm = h < 12 ? 'AM' : 'PM'; return `${h%12||12}:00 ${ampm}`; }
|
||||||
|
periodNote = ` <span class="ml-2 text-indigo-500 dark:text-indigo-400 font-normal normal-case text-xs" title="Dimmed rows are outside this window">Period: ${_fmtH(SESSION_PERIOD_START_HOUR)} → ${_fmtH(SESSION_PERIOD_END_HOUR)}</span>`;
|
||||||
|
}
|
||||||
|
headerRow.innerHTML = '<tr>' + headers.map((h, i) =>
|
||||||
|
`<th class="px-4 py-3 text-left font-medium">${escapeHtml(h)}${i === 0 ? periodNote : ''}</th>`
|
||||||
|
).join('') + '</tr>';
|
||||||
|
|
||||||
|
// Render rows (limit to first 500 for performance)
|
||||||
|
const displayData = data.slice(0, 500);
|
||||||
|
tbody.innerHTML = displayData.map(row => _buildRow(headers, row)).join('');
|
||||||
|
|
||||||
// Update row count
|
// Update row count
|
||||||
|
const inWindowCount = data.filter(r => { const h = _rowHour(r); return h === null || _isInPeriodWindow(h); }).length;
|
||||||
|
const windowNote = (SESSION_PERIOD_START_HOUR !== null && inWindowCount < data.length)
|
||||||
|
? ` (${inWindowCount} in period window)`
|
||||||
|
: '';
|
||||||
document.getElementById('row-count').textContent =
|
document.getElementById('row-count').textContent =
|
||||||
data.length > 500 ? `Showing 500 of ${data.length.toLocaleString()} rows` : `${data.length.toLocaleString()} rows`;
|
data.length > 500 ? `Showing 500 of ${data.length.toLocaleString()} rows${windowNote}` : `${data.length.toLocaleString()} rows${windowNote}`;
|
||||||
|
|
||||||
// Search functionality
|
// Search functionality
|
||||||
document.getElementById('table-search').addEventListener('input', function(e) {
|
document.getElementById('table-search').addEventListener('input', function(e) {
|
||||||
@@ -427,20 +472,7 @@ function renderTable(headers, data) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const displayFiltered = filtered.slice(0, 500);
|
const displayFiltered = filtered.slice(0, 500);
|
||||||
tbody.innerHTML = displayFiltered.map(row =>
|
tbody.innerHTML = displayFiltered.map(row => _buildRow(headers, row)).join('');
|
||||||
'<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">' +
|
|
||||||
headers.map(h => {
|
|
||||||
const val = row[h];
|
|
||||||
let displayVal = val;
|
|
||||||
if (val === null || val === undefined) {
|
|
||||||
displayVal = '-';
|
|
||||||
} else if (typeof val === 'number') {
|
|
||||||
displayVal = val.toFixed(1);
|
|
||||||
}
|
|
||||||
return `<td class="px-4 py-2 text-gray-700 dark:text-gray-300">${escapeHtml(String(displayVal))}</td>`;
|
|
||||||
}).join('') +
|
|
||||||
'</tr>'
|
|
||||||
).join('');
|
|
||||||
|
|
||||||
document.getElementById('row-count').textContent =
|
document.getElementById('row-count').textContent =
|
||||||
filtered.length > 500 ? `Showing 500 of ${filtered.length.toLocaleString()} filtered rows` : `${filtered.length.toLocaleString()} rows`;
|
filtered.length > 500 ? `Showing 500 of ${filtered.length.toLocaleString()} filtered rows` : `${filtered.length.toLocaleString()} rows`;
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
<button class="filter-btn filter-status active-filter" data-value="all">All</button>
|
<button class="filter-btn filter-status active-filter" data-value="all">All</button>
|
||||||
<button class="filter-btn filter-status" data-value="deployed">Deployed</button>
|
<button class="filter-btn filter-status" data-value="deployed">Deployed</button>
|
||||||
<button class="filter-btn filter-status" data-value="benched">Benched</button>
|
<button class="filter-btn filter-status" data-value="benched">Benched</button>
|
||||||
|
<button class="filter-btn filter-status" data-value="allocated">Allocated</button>
|
||||||
<button class="filter-btn filter-status" data-value="retired">Retired</button>
|
<button class="filter-btn filter-status" data-value="retired">Retired</button>
|
||||||
<button class="filter-btn filter-status" data-value="ignored">Ignored</button>
|
<button class="filter-btn filter-status" data-value="ignored">Ignored</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1352,7 +1353,7 @@
|
|||||||
|
|
||||||
// Toggle health filter visibility (hide for retired/ignored)
|
// Toggle health filter visibility (hide for retired/ignored)
|
||||||
const healthGroup = document.getElementById('health-filter-group');
|
const healthGroup = document.getElementById('health-filter-group');
|
||||||
if (this.dataset.value === 'retired' || this.dataset.value === 'ignored') {
|
if (this.dataset.value === 'retired' || this.dataset.value === 'ignored' || this.dataset.value === 'allocated') {
|
||||||
healthGroup.style.display = 'none';
|
healthGroup.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
healthGroup.style.display = 'flex';
|
healthGroup.style.display = 'flex';
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
<option value="">All Status</option>
|
<option value="">All Status</option>
|
||||||
<option value="deployed">Deployed</option>
|
<option value="deployed">Deployed</option>
|
||||||
<option value="benched">Benched</option>
|
<option value="benched">Benched</option>
|
||||||
|
<option value="out_for_calibration">Out for Calibration</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- Modem Filter -->
|
<!-- Modem Filter -->
|
||||||
|
|||||||
437
templates/session_detail.html
Normal file
437
templates/session_detail.html
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ session.session_label or 'Session' }} — {{ project.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-5xl mx-auto">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<nav class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
<a href="/projects" class="hover:text-seismo-orange">Projects</a>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
<a href="/projects/{{ project_id }}" class="hover:text-seismo-orange">{{ project.name }}</a>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-gray-900 dark:text-white truncate max-w-xs">{{ session.session_label or ('Session ' + session.id[:8] + '…') }}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-start justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||||
|
<svg class="w-7 h-7 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
|
</svg>
|
||||||
|
<span id="header-label">{{ session.session_label or ('Session ' + session.id[:8] + '…') }}</span>
|
||||||
|
</h1>
|
||||||
|
{% if location %}
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ location.name }}{% if unit %} · {{ unit.id }}{% endif %}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
{% if session.status == 'completed' %}
|
||||||
|
<span class="px-3 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Completed</span>
|
||||||
|
{% elif session.status == 'recording' %}
|
||||||
|
<span class="px-3 py-1 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded-full flex items-center gap-1">
|
||||||
|
<span class="w-2 h-2 bg-red-500 rounded-full animate-pulse"></span> Recording
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
|
||||||
|
<!-- LEFT COLUMN: Info + Edit -->
|
||||||
|
<div class="lg:col-span-1 space-y-4">
|
||||||
|
|
||||||
|
<!-- Session Info Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-3">Session Info</h2>
|
||||||
|
<dl class="space-y-2 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Label</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white text-right max-w-[180px] truncate"
|
||||||
|
id="info-label">{{ session.session_label or '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Location</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white">{{ location.name if location else '—' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Period</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white" id="info-period">
|
||||||
|
{% set PLABELS = {'weekday_day':'Weekday Day','weekday_night':'Weekday Night','weekend_day':'Weekend Day','weekend_night':'Weekend Night'} %}
|
||||||
|
{{ PLABELS.get(session.period_type, '—') }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{% if effective_range %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Effective</dt>
|
||||||
|
<dd class="font-medium text-indigo-600 dark:text-indigo-400 text-right text-xs" id="info-effective">{{ effective_range }}</dd>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="flex justify-between hidden" id="info-effective-row">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Effective</dt>
|
||||||
|
<dd class="font-medium text-indigo-600 dark:text-indigo-400 text-right text-xs" id="info-effective"></dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Report Date</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white" id="info-report-date">
|
||||||
|
{{ report_date or '— (auto)' }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{% if session.started_at %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Started</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white text-right">{{ session.started_at|local_datetime }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if session.stopped_at %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Ended</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white text-right">{{ session.stopped_at|local_datetime }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if session.duration_seconds %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Duration</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white">{{ session.duration_seconds // 3600 }}h {{ (session.duration_seconds % 3600) // 60 }}m</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if session.device_model %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Device Model</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white">{{ session.device_model }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if session_meta.get('store_name') %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Store Name</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white">{{ session_meta.store_name }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if session_meta.get('serial_number') %}
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">Serial #</dt>
|
||||||
|
<dd class="font-medium text-gray-900 dark:text-white">{{ session_meta.serial_number }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-4">Edit Session</h2>
|
||||||
|
<form id="edit-form" onsubmit="saveSession(event)">
|
||||||
|
|
||||||
|
<!-- Label -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Label</label>
|
||||||
|
<input type="text" id="edit-label" name="session_label"
|
||||||
|
value="{{ session.session_label or '' }}"
|
||||||
|
placeholder="e.g. NRL-1 — Mon 3/24 — Night"
|
||||||
|
class="w-full text-sm bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section: Required Recording Window -->
|
||||||
|
<div class="mb-4 p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-100 dark:border-indigo-800">
|
||||||
|
<p class="text-xs font-semibold text-indigo-700 dark:text-indigo-300 mb-0.5">Required Recording Window</p>
|
||||||
|
<p class="text-xs text-indigo-500 dark:text-indigo-400 mb-3">The hours that count for reports. Only data within this window is included.</p>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Period Type</label>
|
||||||
|
<select id="edit-period-type" name="period_type"
|
||||||
|
onchange="fillPeriodDefaults()"
|
||||||
|
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||||
|
<option value="">— Not Set —</option>
|
||||||
|
<option value="weekday_day" {% if session.period_type == 'weekday_day' %}selected{% endif %}>Weekday Day</option>
|
||||||
|
<option value="weekday_night" {% if session.period_type == 'weekday_night' %}selected{% endif %}>Weekday Night</option>
|
||||||
|
<option value="weekend_day" {% if session.period_type == 'weekend_day' %}selected{% endif %}>Weekend Day</option>
|
||||||
|
<option value="weekend_night" {% if session.period_type == 'weekend_night' %}selected{% endif %}>Weekend Night</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">From (hour)</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input type="number" min="0" max="23" id="edit-start-hour" name="period_start_hour"
|
||||||
|
value="{{ session.period_start_hour if session.period_start_hour is not none else '' }}"
|
||||||
|
placeholder="e.g. 19"
|
||||||
|
oninput="updateWindowPreview()"
|
||||||
|
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">To (hour)</label>
|
||||||
|
<input type="number" min="0" max="23" id="edit-end-hour" name="period_end_hour"
|
||||||
|
value="{{ session.period_end_hour if session.period_end_hour is not none else '' }}"
|
||||||
|
placeholder="e.g. 7"
|
||||||
|
oninput="updateWindowPreview()"
|
||||||
|
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live preview -->
|
||||||
|
<div id="window-preview" class="text-xs font-medium text-indigo-600 dark:text-indigo-300 min-h-[1rem]">
|
||||||
|
{% if session.period_start_hour is not none and session.period_end_hour is not none %}
|
||||||
|
{% set sh = session.period_start_hour %}
|
||||||
|
{% set eh = session.period_end_hour %}
|
||||||
|
Window: {{ (sh % 12) or 12 }}:00 {{ 'AM' if sh < 12 else 'PM' }} → {{ (eh % 12) or 12 }}:00 {{ 'AM' if eh < 12 else 'PM' }}{% if eh <= sh %} (crosses midnight){% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
Target Date <span class="text-gray-400">(optional — day sessions only)</span>
|
||||||
|
</label>
|
||||||
|
<input type="date" id="edit-report-date" name="report_date"
|
||||||
|
value="{{ report_date or '' }}"
|
||||||
|
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Leave blank to auto-select the last day with data in the window.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section: Device On/Off Times -->
|
||||||
|
<div class="mb-4 p-3 bg-gray-50 dark:bg-slate-700/40 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||||
|
<p class="text-xs font-semibold text-gray-600 dark:text-gray-400 mb-0.5">Device On/Off Times</p>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mb-3">When the meter was actually running. Usually set automatically from the data file.</p>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Powered on</label>
|
||||||
|
<input type="datetime-local" id="edit-started-at" name="started_at"
|
||||||
|
value="{{ session.started_at|local_datetime_input if session.started_at else '' }}"
|
||||||
|
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 dark:text-gray-400 mb-1">Powered off</label>
|
||||||
|
<input type="datetime-local" id="edit-stopped-at" name="stopped_at"
|
||||||
|
value="{{ session.stopped_at|local_datetime_input if session.stopped_at else '' }}"
|
||||||
|
class="w-full text-sm bg-white dark:bg-slate-700 border border-gray-200 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white focus:outline-none focus:border-seismo-orange">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit"
|
||||||
|
class="flex-1 text-sm py-2 bg-seismo-orange text-white rounded-lg hover:bg-orange-600 transition-colors font-medium">
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="save-status" class="hidden text-xs text-center pt-2"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT COLUMN: Files + Report Actions -->
|
||||||
|
<div class="lg:col-span-2 space-y-5">
|
||||||
|
|
||||||
|
<!-- Files List -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div class="px-5 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
|
||||||
|
Data Files
|
||||||
|
<span class="ml-2 text-xs font-normal text-gray-400">({{ files|length }})</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{% if files %}
|
||||||
|
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{% for f in files %}
|
||||||
|
{% set fname = f.file_path.split('/')[-1] %}
|
||||||
|
{% set is_rnd = fname.lower().endswith('.rnd') %}
|
||||||
|
{% set is_leq = '_leq_' in fname.lower() or fname.lower().startswith('au2_') %}
|
||||||
|
<div class="flex items-center gap-3 px-5 py-3 hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
|
||||||
|
<!-- Icon -->
|
||||||
|
<div class="shrink-0 w-8 h-8 rounded-lg flex items-center justify-center
|
||||||
|
{% if is_rnd %}bg-green-100 dark:bg-green-900/30{% else %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||||
|
{% if is_rnd %}
|
||||||
|
<svg class="w-4 h-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
|
</svg>
|
||||||
|
{% else %}
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Name + meta -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="text-sm font-medium text-gray-900 dark:text-white truncate">{{ fname }}</div>
|
||||||
|
<div class="text-xs text-gray-400 flex items-center gap-2 mt-0.5">
|
||||||
|
<span>{{ f.file_type | upper }}</span>
|
||||||
|
{% if f.file_size_bytes %}
|
||||||
|
<span>{{ (f.file_size_bytes / 1024) | round(1) }} KB</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if is_leq %}<span class="text-green-600 dark:text-green-400 font-medium">Leq</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
{% if is_rnd %}
|
||||||
|
<a href="/api/projects/{{ project_id }}/files/{{ f.id }}/view-rnd"
|
||||||
|
class="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors">
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
{% if is_leq %}
|
||||||
|
<button onclick="openSingleFileReport('{{ f.id }}', '{{ fname }}')"
|
||||||
|
class="px-2 py-1 text-xs bg-emerald-600 text-white rounded hover:bg-emerald-700 transition-colors">
|
||||||
|
Report
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<a href="/api/projects/{{ project_id }}/files/{{ f.id }}/download"
|
||||||
|
class="px-2 py-1 text-xs border border-gray-200 dark:border-gray-600 text-gray-600 dark:text-gray-400 rounded hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors">
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="px-5 py-10 text-center text-gray-400">
|
||||||
|
<p>No files found for this session.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Report Actions -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||||
|
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-3">Report Actions</h2>
|
||||||
|
|
||||||
|
{% if session.status == 'completed' %}
|
||||||
|
{% set has_rnd = files | selectattr('file_type', 'equalto', 'rnd') | list | length > 0 %}
|
||||||
|
{% if has_rnd %}
|
||||||
|
<div class="p-3 bg-gray-50 dark:bg-slate-700/50 rounded-lg space-y-2">
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
Use the <strong>Combined Report Wizard</strong> to generate an Excel report for this session, or click <strong>View</strong> on a Leq file above to access per-file reporting.
|
||||||
|
{% if session.period_start_hour is not none %}
|
||||||
|
<br><span class="text-indigo-600 dark:text-indigo-400">Period window {{ session.period_start_hour }}:00–{{ session.period_end_hour }}:00 will be applied.</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<a href="/projects/{{ project_id }}?tab=data"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg hover:bg-emerald-700 transition-colors font-medium">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
Go to Combined Report Wizard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500">No .rnd files found — upload data to generate a report.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500">Reports are available after the session is completed.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const PROJECT_ID = '{{ project_id }}';
|
||||||
|
const SESSION_ID = '{{ session.id }}';
|
||||||
|
|
||||||
|
const PERIOD_DEFAULT_HOURS = {
|
||||||
|
weekday_day: {start: 7, end: 19},
|
||||||
|
weekday_night: {start: 19, end: 7},
|
||||||
|
weekend_day: {start: 7, end: 19},
|
||||||
|
weekend_night: {start: 19, end: 7},
|
||||||
|
};
|
||||||
|
|
||||||
|
function fillPeriodDefaults() {
|
||||||
|
const pt = document.getElementById('edit-period-type').value;
|
||||||
|
const defaults = PERIOD_DEFAULT_HOURS[pt];
|
||||||
|
if (defaults) {
|
||||||
|
document.getElementById('edit-start-hour').value = defaults.start;
|
||||||
|
document.getElementById('edit-end-hour').value = defaults.end;
|
||||||
|
}
|
||||||
|
updateWindowPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWindowPreview() {
|
||||||
|
const sh = parseInt(document.getElementById('edit-start-hour').value, 10);
|
||||||
|
const eh = parseInt(document.getElementById('edit-end-hour').value, 10);
|
||||||
|
const el = document.getElementById('window-preview');
|
||||||
|
if (!el) return;
|
||||||
|
if (isNaN(sh) || isNaN(eh)) { el.textContent = ''; return; }
|
||||||
|
function fmt(h) { return `${h % 12 || 12}:00 ${h < 12 ? 'AM' : 'PM'}`; }
|
||||||
|
const crosses = eh <= sh;
|
||||||
|
el.textContent = `Window: ${fmt(sh)} → ${fmt(eh)}${crosses ? ' (crosses midnight)' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run once on load to populate preview if values already set
|
||||||
|
document.addEventListener('DOMContentLoaded', updateWindowPreview);
|
||||||
|
|
||||||
|
async function saveSession(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const status = document.getElementById('save-status');
|
||||||
|
status.className = 'text-xs text-center pt-1 text-gray-400';
|
||||||
|
status.textContent = 'Saving…';
|
||||||
|
status.classList.remove('hidden');
|
||||||
|
|
||||||
|
const form = document.getElementById('edit-form');
|
||||||
|
const payload = {};
|
||||||
|
|
||||||
|
const label = form.session_label.value.trim();
|
||||||
|
payload.session_label = label || null;
|
||||||
|
|
||||||
|
const pt = form.period_type.value;
|
||||||
|
payload.period_type = pt || null;
|
||||||
|
|
||||||
|
const sh = form.period_start_hour.value;
|
||||||
|
const eh = form.period_end_hour.value;
|
||||||
|
payload.period_start_hour = sh !== '' ? parseInt(sh, 10) : null;
|
||||||
|
payload.period_end_hour = eh !== '' ? parseInt(eh, 10) : null;
|
||||||
|
|
||||||
|
const rd = form.report_date.value;
|
||||||
|
payload.report_date = rd || null;
|
||||||
|
|
||||||
|
const sa = form.started_at.value;
|
||||||
|
if (sa) payload.started_at = sa;
|
||||||
|
|
||||||
|
const st = form.stopped_at.value;
|
||||||
|
if (st) payload.stopped_at = st;
|
||||||
|
else if (form.stopped_at.value === '') payload.stopped_at = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/projects/${PROJECT_ID}/sessions/${SESSION_ID}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(await resp.text());
|
||||||
|
const result = await resp.json();
|
||||||
|
|
||||||
|
// Update displayed label
|
||||||
|
const newLabel = result.session_label || ('Session ' + SESSION_ID.slice(0, 8) + '…');
|
||||||
|
document.getElementById('header-label').textContent = newLabel;
|
||||||
|
document.getElementById('info-label').textContent = result.session_label || '—';
|
||||||
|
document.getElementById('info-period').textContent = {
|
||||||
|
weekday_day: 'Weekday Day', weekday_night: 'Weekday Night',
|
||||||
|
weekend_day: 'Weekend Day', weekend_night: 'Weekend Night'
|
||||||
|
}[result.period_type] || '—';
|
||||||
|
document.getElementById('info-report-date').textContent = result.report_date || '— (auto)';
|
||||||
|
|
||||||
|
status.className = 'text-xs text-center pt-1 text-green-600 dark:text-green-400';
|
||||||
|
status.textContent = 'Saved!';
|
||||||
|
setTimeout(() => status.classList.add('hidden'), 2500);
|
||||||
|
} catch(err) {
|
||||||
|
status.className = 'text-xs text-center pt-1 text-red-500';
|
||||||
|
status.textContent = 'Error: ' + err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSingleFileReport(fileId, filename) {
|
||||||
|
window.location.href = `/api/projects/${PROJECT_ID}/files/${fileId}/view-rnd`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -41,6 +41,12 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Danger Zone
|
Danger Zone
|
||||||
</button>
|
</button>
|
||||||
|
<button class="settings-tab text-gray-400 dark:text-gray-500" data-tab="developer" onclick="showTab('developer')">
|
||||||
|
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
Developer
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- General Tab -->
|
<!-- General Tab -->
|
||||||
@@ -514,6 +520,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Developer Tab -->
|
||||||
|
<div id="developer-tab" class="tab-content hidden">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-1">Developer Tools</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">Admin-only tools for managing field watcher agents and diagnosing connectivity.</p>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Watcher Manager -->
|
||||||
|
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-slate-700 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white">Watcher Manager</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
Monitor series3-watcher and thor-watcher agents. View status, log tails, and push remote updates.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/admin/watchers"
|
||||||
|
class="ml-6 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap">
|
||||||
|
Open
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.settings-tab {
|
.settings-tab {
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
<p id="deployedStatus" class="font-medium text-gray-900 dark:text-white">--</p>
|
<p id="deployedStatus" class="font-medium text-gray-900 dark:text-white">--</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-sm text-gray-500 dark:text-gray-400">Retired</span>
|
<span class="text-sm text-gray-500 dark:text-gray-400">Unit Status</span>
|
||||||
<p id="retiredStatus" class="font-medium text-gray-900 dark:text-white">--</p>
|
<p id="retiredStatus" class="font-medium text-gray-900 dark:text-white">--</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -278,6 +278,22 @@
|
|||||||
<p id="viewNote" class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">--</p>
|
<p id="viewNote" class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">--</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Deployment History -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Deployment History</h3>
|
||||||
|
<button onclick="openNewDeploymentModal()" class="px-3 py-1.5 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg text-sm transition-colors flex items-center gap-1.5">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||||
|
</svg>
|
||||||
|
Log Deployment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="deploymentHistory" class="space-y-3">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Unit History Timeline -->
|
<!-- Unit History Timeline -->
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Timeline</h3>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Timeline</h3>
|
||||||
@@ -320,6 +336,53 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Deployment Modal -->
|
||||||
|
<div id="deploymentModal" class="hidden fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-lg">
|
||||||
|
<div class="flex justify-between items-center p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 id="deploymentModalTitle" class="text-lg font-semibold text-gray-900 dark:text-white">Log Deployment</h3>
|
||||||
|
<button onclick="closeDeploymentModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<input type="hidden" id="deploymentModalId">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Deployed Date</label>
|
||||||
|
<input type="date" id="deploymentDeployedDate" class="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Est. Removal Date</label>
|
||||||
|
<input type="date" id="deploymentEstRemovalDate" class="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="actualRemovalRow">
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Actual Removal Date <span class="text-gray-400 font-normal">(fill when returned)</span></label>
|
||||||
|
<input type="date" id="deploymentActualRemovalDate" class="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Job / Project</label>
|
||||||
|
<input type="text" id="deploymentProjectRef" placeholder="e.g. Fay I-80, CMU Campus" class="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Location Name</label>
|
||||||
|
<input type="text" id="deploymentLocationName" placeholder="e.g. North Gate, VP-001" class="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Notes</label>
|
||||||
|
<textarea id="deploymentNotes" rows="2" class="mt-1 w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange resize-none"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-3 p-6 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button onclick="closeDeploymentModal()" class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg text-sm transition-colors">Cancel</button>
|
||||||
|
<button onclick="saveDeployment()" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg text-sm transition-colors">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Edit Mode: Unit Information Form (Hidden by default) -->
|
<!-- Edit Mode: Unit Information Form (Hidden by default) -->
|
||||||
<div id="editMode" class="hidden rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
<div id="editMode" class="hidden rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
@@ -497,18 +560,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Checkboxes -->
|
<!-- Status Checkboxes -->
|
||||||
<div class="flex items-center gap-6 border-t border-gray-200 dark:border-gray-700 pt-4">
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 space-y-3">
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<div class="flex items-center gap-6 flex-wrap">
|
||||||
<input type="checkbox" name="deployed" id="deployed" value="true"
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
<input type="checkbox" name="deployed" id="deployed" value="true"
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span>
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
</label>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span>
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
</label>
|
||||||
<input type="checkbox" name="retired" id="retired" value="true"
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
<input type="checkbox" name="out_for_calibration" id="outForCalibration" value="true"
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Retired</span>
|
class="w-4 h-4 text-purple-600 focus:ring-purple-500 rounded">
|
||||||
</label>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Out for Calibration</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="allocated" id="allocated" value="true"
|
||||||
|
onchange="document.getElementById('allocatedProjectRow').style.display = this.checked ? '' : 'none'"
|
||||||
|
class="w-4 h-4 text-orange-500 focus:ring-orange-400 rounded">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Allocated</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="allocatedProjectRow" style="display:none">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Allocated to Project</label>
|
||||||
|
<input type="text" name="allocated_to_project_id" id="allocatedToProjectId"
|
||||||
|
placeholder="Project name or ID"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-orange-400 text-sm">
|
||||||
|
</div>
|
||||||
|
<!-- Hidden field for retired — controlled by the Retire button below -->
|
||||||
|
<input type="hidden" name="retired" id="retired" value="">
|
||||||
|
<div id="retireButtonSection">
|
||||||
|
<button type="button" id="retireBtn"
|
||||||
|
class="px-4 py-2 text-sm font-medium rounded-lg border transition-colors"
|
||||||
|
onclick="toggleRetired()">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Notes -->
|
<!-- Notes -->
|
||||||
@@ -808,16 +893,34 @@ function populateViewMode() {
|
|||||||
|
|
||||||
document.getElementById('age').textContent = unitStatus.age || '--';
|
document.getElementById('age').textContent = unitStatus.age || '--';
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('statusIndicator').className = 'w-3 h-3 rounded-full bg-gray-400';
|
const isAllocated = currentUnit.allocated && !currentUnit.deployed;
|
||||||
document.getElementById('statusText').className = 'font-semibold text-gray-600 dark:text-gray-400';
|
document.getElementById('statusIndicator').className = isAllocated
|
||||||
// Show "Benched" if not deployed, otherwise "No status data"
|
? 'w-3 h-3 rounded-full bg-orange-400'
|
||||||
document.getElementById('statusText').textContent = !currentUnit.deployed ? 'Benched' : 'No status data';
|
: 'w-3 h-3 rounded-full bg-gray-400';
|
||||||
|
document.getElementById('statusText').className = isAllocated
|
||||||
|
? 'font-semibold text-orange-500 dark:text-orange-400'
|
||||||
|
: 'font-semibold text-gray-600 dark:text-gray-400';
|
||||||
|
document.getElementById('statusText').textContent = isAllocated ? 'Allocated' : (!currentUnit.deployed ? 'Benched' : 'No status data');
|
||||||
document.getElementById('lastSeen').textContent = '--';
|
document.getElementById('lastSeen').textContent = '--';
|
||||||
document.getElementById('age').textContent = '--';
|
document.getElementById('age').textContent = '--';
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('deployedStatus').textContent = currentUnit.deployed ? 'Yes' : 'No';
|
document.getElementById('deployedStatus').textContent = currentUnit.deployed ? 'Yes' : 'No';
|
||||||
document.getElementById('retiredStatus').textContent = currentUnit.retired ? 'Yes' : 'No';
|
if (currentUnit.retired) {
|
||||||
|
document.getElementById('retiredStatus').textContent = 'Retired';
|
||||||
|
document.getElementById('retiredStatus').className = 'font-medium text-red-600 dark:text-red-400';
|
||||||
|
} else if (currentUnit.out_for_calibration) {
|
||||||
|
document.getElementById('retiredStatus').textContent = 'Out for Calibration';
|
||||||
|
document.getElementById('retiredStatus').className = 'font-medium text-purple-600 dark:text-purple-400';
|
||||||
|
} else if (currentUnit.allocated && !currentUnit.deployed) {
|
||||||
|
document.getElementById('retiredStatus').textContent = currentUnit.allocated_to_project_id
|
||||||
|
? `Allocated — ${currentUnit.allocated_to_project_id}`
|
||||||
|
: 'Allocated';
|
||||||
|
document.getElementById('retiredStatus').className = 'font-medium text-orange-500 dark:text-orange-400';
|
||||||
|
} else {
|
||||||
|
document.getElementById('retiredStatus').textContent = 'Active';
|
||||||
|
document.getElementById('retiredStatus').className = 'font-medium text-gray-900 dark:text-white';
|
||||||
|
}
|
||||||
|
|
||||||
// Basic info
|
// Basic info
|
||||||
document.getElementById('viewDeviceType').textContent = currentUnit.device_type || '--';
|
document.getElementById('viewDeviceType').textContent = currentUnit.device_type || '--';
|
||||||
@@ -1009,8 +1112,14 @@ function populateEditForm() {
|
|||||||
document.getElementById('address').value = currentUnit.address || '';
|
document.getElementById('address').value = currentUnit.address || '';
|
||||||
document.getElementById('coordinates').value = currentUnit.coordinates || '';
|
document.getElementById('coordinates').value = currentUnit.coordinates || '';
|
||||||
document.getElementById('deployed').checked = currentUnit.deployed;
|
document.getElementById('deployed').checked = currentUnit.deployed;
|
||||||
document.getElementById('retired').checked = currentUnit.retired;
|
document.getElementById('outForCalibration').checked = currentUnit.out_for_calibration || false;
|
||||||
|
document.getElementById('retired').value = currentUnit.retired ? 'true' : '';
|
||||||
|
updateRetireButton(currentUnit.retired);
|
||||||
document.getElementById('note').value = currentUnit.note || '';
|
document.getElementById('note').value = currentUnit.note || '';
|
||||||
|
const allocatedChecked = currentUnit.allocated || false;
|
||||||
|
document.getElementById('allocated').checked = allocatedChecked;
|
||||||
|
document.getElementById('allocatedToProjectId').value = currentUnit.allocated_to_project_id || '';
|
||||||
|
document.getElementById('allocatedProjectRow').style.display = allocatedChecked ? '' : 'none';
|
||||||
|
|
||||||
// Seismograph fields
|
// Seismograph fields
|
||||||
document.getElementById('lastCalibrated').value = currentUnit.last_calibrated || '';
|
document.getElementById('lastCalibrated').value = currentUnit.last_calibrated || '';
|
||||||
@@ -1153,6 +1262,25 @@ function cancelEdit() {
|
|||||||
populateEditForm();
|
populateEditForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateRetireButton(isRetired) {
|
||||||
|
const btn = document.getElementById('retireBtn');
|
||||||
|
if (!btn) return;
|
||||||
|
if (isRetired) {
|
||||||
|
btn.textContent = 'Un-Retire Unit';
|
||||||
|
btn.className = 'px-4 py-2 text-sm font-medium rounded-lg border transition-colors bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600';
|
||||||
|
} else {
|
||||||
|
btn.textContent = 'Retire Unit';
|
||||||
|
btn.className = 'px-4 py-2 text-sm font-medium rounded-lg border transition-colors bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 border-red-300 dark:border-red-700 hover:bg-red-100 dark:hover:bg-red-900/40';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRetired() {
|
||||||
|
const hiddenInput = document.getElementById('retired');
|
||||||
|
const isCurrentlyRetired = hiddenInput.value === 'true';
|
||||||
|
hiddenInput.value = isCurrentlyRetired ? '' : 'true';
|
||||||
|
updateRetireButton(!isCurrentlyRetired);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle form submission
|
// Handle form submission
|
||||||
document.getElementById('editForm').addEventListener('submit', async function(e) {
|
document.getElementById('editForm').addEventListener('submit', async function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1591,12 +1719,173 @@ async function pingModem() {
|
|||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Deployment History
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function loadDeploymentHistory() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/deployments/${unitId}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const container = document.getElementById('deploymentHistory');
|
||||||
|
const deployments = data.deployments || [];
|
||||||
|
|
||||||
|
if (deployments.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No deployment records yet.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
deployments.forEach(d => {
|
||||||
|
container.appendChild(createDeploymentRow(d));
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('deploymentHistory').innerHTML =
|
||||||
|
'<p class="text-sm text-red-500">Failed to load deployment history.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateDisplay(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const [y, m, d] = iso.split('-');
|
||||||
|
return `${parseInt(m)}/${parseInt(d)}/${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeploymentRow(d) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'flex items-start gap-3 p-3 rounded-lg ' +
|
||||||
|
(d.is_active
|
||||||
|
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
|
||||||
|
: 'bg-gray-50 dark:bg-slate-700/50');
|
||||||
|
|
||||||
|
const statusDot = d.is_active
|
||||||
|
? '<span class="mt-1 flex-shrink-0 w-2.5 h-2.5 rounded-full bg-green-500"></span>'
|
||||||
|
: '<span class="mt-1 flex-shrink-0 w-2.5 h-2.5 rounded-full bg-gray-400 dark:bg-gray-500"></span>';
|
||||||
|
|
||||||
|
const jobLabel = d.project_ref || d.project_id || 'Unspecified job';
|
||||||
|
const locLabel = d.location_name ? `<span class="text-gray-500 dark:text-gray-400"> · ${d.location_name}</span>` : '';
|
||||||
|
|
||||||
|
const deployedStr = formatDateDisplay(d.deployed_date);
|
||||||
|
const estStr = d.estimated_removal_date ? formatDateDisplay(d.estimated_removal_date) : 'TBD';
|
||||||
|
const actualStr = d.actual_removal_date ? formatDateDisplay(d.actual_removal_date) : null;
|
||||||
|
|
||||||
|
const dateRange = actualStr
|
||||||
|
? `${deployedStr} → ${actualStr}`
|
||||||
|
: `${deployedStr} → <span class="font-medium ${d.is_active ? 'text-green-700 dark:text-green-400' : 'text-gray-600 dark:text-gray-300'}">Est. ${estStr}</span>`;
|
||||||
|
|
||||||
|
const activeTag = d.is_active
|
||||||
|
? '<span class="ml-2 px-1.5 py-0.5 text-xs font-medium bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-400 rounded">In Field</span>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
${statusDot}
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
${jobLabel}${activeTag}${locLabel}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">${dateRange}</div>
|
||||||
|
${d.notes ? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 italic">${d.notes}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1 flex-shrink-0">
|
||||||
|
<button onclick="openEditDeploymentModal(${JSON.stringify(d).replace(/"/g, '"')})"
|
||||||
|
class="p-1.5 text-gray-400 hover:text-seismo-orange rounded transition-colors" title="Edit">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onclick="deleteDeployment('${d.id}')"
|
||||||
|
class="p-1.5 text-gray-400 hover:text-red-500 rounded transition-colors" title="Delete">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNewDeploymentModal() {
|
||||||
|
document.getElementById('deploymentModalTitle').textContent = 'Log Deployment';
|
||||||
|
document.getElementById('deploymentModalId').value = '';
|
||||||
|
document.getElementById('deploymentDeployedDate').value = '';
|
||||||
|
document.getElementById('deploymentEstRemovalDate').value = '';
|
||||||
|
document.getElementById('deploymentActualRemovalDate').value = '';
|
||||||
|
document.getElementById('deploymentProjectRef').value = '';
|
||||||
|
document.getElementById('deploymentLocationName').value = '';
|
||||||
|
document.getElementById('deploymentNotes').value = '';
|
||||||
|
document.getElementById('deploymentModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDeploymentModal(d) {
|
||||||
|
document.getElementById('deploymentModalTitle').textContent = 'Edit Deployment';
|
||||||
|
document.getElementById('deploymentModalId').value = d.id;
|
||||||
|
document.getElementById('deploymentDeployedDate').value = d.deployed_date || '';
|
||||||
|
document.getElementById('deploymentEstRemovalDate').value = d.estimated_removal_date || '';
|
||||||
|
document.getElementById('deploymentActualRemovalDate').value = d.actual_removal_date || '';
|
||||||
|
document.getElementById('deploymentProjectRef').value = d.project_ref || '';
|
||||||
|
document.getElementById('deploymentLocationName').value = d.location_name || '';
|
||||||
|
document.getElementById('deploymentNotes').value = d.notes || '';
|
||||||
|
document.getElementById('deploymentModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeploymentModal() {
|
||||||
|
document.getElementById('deploymentModal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDeployment() {
|
||||||
|
const id = document.getElementById('deploymentModalId').value;
|
||||||
|
const payload = {
|
||||||
|
deployed_date: document.getElementById('deploymentDeployedDate').value || null,
|
||||||
|
estimated_removal_date: document.getElementById('deploymentEstRemovalDate').value || null,
|
||||||
|
actual_removal_date: document.getElementById('deploymentActualRemovalDate').value || null,
|
||||||
|
project_ref: document.getElementById('deploymentProjectRef').value || null,
|
||||||
|
location_name: document.getElementById('deploymentLocationName').value || null,
|
||||||
|
notes: document.getElementById('deploymentNotes').value || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
let res;
|
||||||
|
if (id) {
|
||||||
|
res = await fetch(`/api/deployments/${unitId}/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res = await fetch(`/api/deployments/${unitId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
closeDeploymentModal();
|
||||||
|
loadDeploymentHistory();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Failed to save deployment: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDeployment(deploymentId) {
|
||||||
|
if (!confirm('Delete this deployment record?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/deployments/${unitId}/${deploymentId}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
loadDeploymentHistory();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Failed to delete: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Load data when page loads
|
// Load data when page loads
|
||||||
loadCalibrationInterval();
|
loadCalibrationInterval();
|
||||||
setupCalibrationAutoCalc();
|
setupCalibrationAutoCalc();
|
||||||
loadUnitData().then(() => {
|
loadUnitData().then(() => {
|
||||||
loadPhotos();
|
loadPhotos();
|
||||||
loadUnitHistory();
|
loadUnitHistory();
|
||||||
|
loadDeploymentHistory();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== Pair Device Modal Functions =====
|
// ===== Pair Device Modal Functions =====
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
{{ location.name }}
|
{{ location.name }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
Monitoring Location • {{ project.name }}
|
Monitoring Location • {{ project.name }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -116,20 +116,36 @@
|
|||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Unit Assignment</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Unit Assignment</h2>
|
||||||
{% if assigned_unit %}
|
{% if assigned_unit %}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
<!-- Seismograph row -->
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Unit</div>
|
<div class="text-sm text-gray-600 dark:text-gray-400">Seismograph</div>
|
||||||
<div class="text-lg font-medium text-gray-900 dark:text-white">
|
<div class="text-lg font-medium text-gray-900 dark:text-white">
|
||||||
<a href="/unit/{{ assigned_unit.id }}" class="text-seismo-orange hover:text-seismo-navy">
|
<a href="/unit/{{ assigned_unit.id }}" class="text-seismo-orange hover:text-seismo-navy">
|
||||||
{{ assigned_unit.id }}
|
{{ assigned_unit.id }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{% if assigned_unit.unit_type %}
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ assigned_unit.unit_type }}</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if assigned_unit.device_type %}
|
<!-- Modem row -->
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Device Type</div>
|
<div class="text-sm text-gray-600 dark:text-gray-400">Modem</div>
|
||||||
<div class="text-gray-900 dark:text-white">{{ assigned_unit.device_type|capitalize }}</div>
|
{% if assigned_modem %}
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">
|
||||||
|
<a href="/unit/{{ assigned_modem.id }}" class="text-seismo-orange hover:text-seismo-navy">
|
||||||
|
{{ assigned_modem.id }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% if assigned_modem.hardware_model or assigned_modem.ip_address %}
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{{ assigned_modem.hardware_model or '' }}{% if assigned_modem.hardware_model and assigned_modem.ip_address %} • {% endif %}{{ assigned_modem.ip_address or '' }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="text-sm text-gray-400 dark:text-gray-500 italic">No modem paired</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
{% if assignment %}
|
{% if assignment %}
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Since</div>
|
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Since</div>
|
||||||
@@ -142,10 +158,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="pt-2">
|
<div class="pt-2 flex gap-2 flex-wrap">
|
||||||
|
<button onclick="openSwapModal()"
|
||||||
|
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors text-sm">
|
||||||
|
Swap Unit / Modem
|
||||||
|
</button>
|
||||||
<button onclick="unassignUnit('{{ assignment.id }}')"
|
<button onclick="unassignUnit('{{ assignment.id }}')"
|
||||||
class="px-4 py-2 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-900/50 transition-colors">
|
class="px-4 py-2 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-900/50 transition-colors text-sm">
|
||||||
Unassign Unit
|
Unassign
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,7 +175,7 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-gray-500 dark:text-gray-400 mb-4">No unit currently assigned</p>
|
<p class="text-gray-500 dark:text-gray-400 mb-4">No unit currently assigned</p>
|
||||||
<button onclick="openAssignModal()"
|
<button onclick="openSwapModal()"
|
||||||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||||
Assign a Unit
|
Assign a Unit
|
||||||
</button>
|
</button>
|
||||||
@@ -214,47 +234,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Assign Unit Modal -->
|
<!-- Assign / Swap Modal -->
|
||||||
<div id="assign-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
<div id="swap-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Assign Unit</h2>
|
<h2 id="swap-modal-title" class="text-2xl font-bold text-gray-900 dark:text-white">Assign Unit</h2>
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Attach a seismograph to this location</p>
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Select a seismograph and optionally a modem for this location</p>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="closeAssignModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
<button onclick="closeSwapModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="assign-form" class="p-6 space-y-4">
|
<form id="swap-form" class="p-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Available Units</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Seismograph <span class="text-red-500">*</span></label>
|
||||||
<select id="assign-unit-id" name="unit_id"
|
<select id="swap-unit-id" name="unit_id"
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
|
||||||
<option value="">Loading units...</option>
|
<option value="">Loading units...</option>
|
||||||
</select>
|
</select>
|
||||||
<p id="assign-empty" class="hidden text-xs text-gray-500 mt-2">No available seismographs for this project.</p>
|
<p id="swap-units-empty" class="hidden text-xs text-gray-500 mt-1">No available seismographs.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Modem <span class="text-xs text-gray-400">(optional)</span></label>
|
||||||
|
<select id="swap-modem-id" name="modem_id"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">No modem</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
|
||||||
<textarea id="assign-notes" name="notes" rows="2"
|
<textarea id="swap-notes" name="notes" rows="2"
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="assign-error" class="hidden text-sm text-red-600"></div>
|
<div id="swap-error" class="hidden text-sm text-red-600"></div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
<button type="button" onclick="closeAssignModal()"
|
<button type="button" onclick="closeSwapModal()"
|
||||||
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button type="submit"
|
<button type="submit" id="swap-submit-btn"
|
||||||
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
||||||
Assign Unit
|
Assign
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -264,6 +292,7 @@
|
|||||||
<script>
|
<script>
|
||||||
const projectId = "{{ project_id }}";
|
const projectId = "{{ project_id }}";
|
||||||
const locationId = "{{ location_id }}";
|
const locationId = "{{ location_id }}";
|
||||||
|
const hasAssignment = {{ 'true' if assigned_unit else 'false' }};
|
||||||
|
|
||||||
// Tab switching
|
// Tab switching
|
||||||
function switchTab(tabName) {
|
function switchTab(tabName) {
|
||||||
@@ -314,60 +343,89 @@ document.getElementById('location-settings-form').addEventListener('submit', asy
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Assign modal
|
// Swap / Assign modal
|
||||||
function openAssignModal() {
|
async function openSwapModal() {
|
||||||
document.getElementById('assign-modal').classList.remove('hidden');
|
document.getElementById('swap-modal').classList.remove('hidden');
|
||||||
loadAvailableUnits();
|
document.getElementById('swap-modal-title').textContent = hasAssignment ? 'Swap Unit / Modem' : 'Assign Unit';
|
||||||
|
document.getElementById('swap-submit-btn').textContent = hasAssignment ? 'Swap' : 'Assign';
|
||||||
|
document.getElementById('swap-error').classList.add('hidden');
|
||||||
|
document.getElementById('swap-notes').value = '';
|
||||||
|
await Promise.all([loadSwapUnits(), loadSwapModems()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeAssignModal() {
|
function closeSwapModal() {
|
||||||
document.getElementById('assign-modal').classList.add('hidden');
|
document.getElementById('swap-modal').classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAvailableUnits() {
|
async function loadSwapUnits() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=vibration`);
|
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=vibration`);
|
||||||
if (!response.ok) throw new Error('Failed to load available units');
|
if (!response.ok) throw new Error('Failed to load units');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const select = document.getElementById('assign-unit-id');
|
const select = document.getElementById('swap-unit-id');
|
||||||
select.innerHTML = '<option value="">Select a unit</option>';
|
select.innerHTML = '<option value="">Select a seismograph</option>';
|
||||||
|
|
||||||
if (!data.length) {
|
if (!data.length) {
|
||||||
document.getElementById('assign-empty').classList.remove('hidden');
|
document.getElementById('swap-units-empty').classList.remove('hidden');
|
||||||
return;
|
} else {
|
||||||
|
document.getElementById('swap-units-empty').classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
data.forEach(unit => {
|
data.forEach(unit => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = unit.id;
|
option.value = unit.id;
|
||||||
option.textContent = `${unit.id} • ${unit.model || unit.device_type}`;
|
option.textContent = unit.id + (unit.model ? ` \u2022 ${unit.model}` : '') + (unit.location ? ` \u2014 ${unit.location}` : '');
|
||||||
select.appendChild(option);
|
select.appendChild(option);
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorEl = document.getElementById('assign-error');
|
document.getElementById('swap-error').textContent = 'Failed to load seismographs.';
|
||||||
errorEl.textContent = err.message || 'Failed to load units.';
|
document.getElementById('swap-error').classList.remove('hidden');
|
||||||
errorEl.classList.remove('hidden');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('assign-form').addEventListener('submit', async function(e) {
|
async function loadSwapModems() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/available-modems`);
|
||||||
|
if (!response.ok) throw new Error('Failed to load modems');
|
||||||
|
const data = await response.json();
|
||||||
|
const select = document.getElementById('swap-modem-id');
|
||||||
|
select.innerHTML = '<option value="">No modem</option>';
|
||||||
|
|
||||||
|
data.forEach(modem => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = modem.id;
|
||||||
|
let label = modem.id;
|
||||||
|
if (modem.hardware_model) label += ` \u2022 ${modem.hardware_model}`;
|
||||||
|
if (modem.ip_address) label += ` \u2014 ${modem.ip_address}`;
|
||||||
|
option.textContent = label;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Modem list failure is non-fatal — just leave blank
|
||||||
|
console.warn('Failed to load modems:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('swap-form').addEventListener('submit', async function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const unitId = document.getElementById('assign-unit-id').value;
|
const unitId = document.getElementById('swap-unit-id').value;
|
||||||
const notes = document.getElementById('assign-notes').value.trim();
|
const modemId = document.getElementById('swap-modem-id').value;
|
||||||
|
const notes = document.getElementById('swap-notes').value.trim();
|
||||||
|
|
||||||
if (!unitId) {
|
if (!unitId) {
|
||||||
document.getElementById('assign-error').textContent = 'Select a unit to assign.';
|
document.getElementById('swap-error').textContent = 'Please select a seismograph.';
|
||||||
document.getElementById('assign-error').classList.remove('hidden');
|
document.getElementById('swap-error').classList.remove('hidden');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('unit_id', unitId);
|
formData.append('unit_id', unitId);
|
||||||
formData.append('notes', notes);
|
if (modemId) formData.append('modem_id', modemId);
|
||||||
|
if (notes) formData.append('notes', notes);
|
||||||
|
|
||||||
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}/assign`, {
|
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}/swap`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
@@ -379,9 +437,8 @@ document.getElementById('assign-form').addEventListener('submit', async function
|
|||||||
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorEl = document.getElementById('assign-error');
|
document.getElementById('swap-error').textContent = err.message || 'Failed to assign unit.';
|
||||||
errorEl.textContent = err.message || 'Failed to assign unit.';
|
document.getElementById('swap-error').classList.remove('hidden');
|
||||||
errorEl.classList.remove('hidden');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -405,11 +462,11 @@ async function unassignUnit(assignmentId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Escape') closeAssignModal();
|
if (e.key === 'Escape') closeSwapModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('assign-modal')?.addEventListener('click', function(e) {
|
document.getElementById('swap-modal')?.addEventListener('click', function(e) {
|
||||||
if (e.target === this) closeAssignModal();
|
if (e.target === this) closeSwapModal();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user