diff --git a/CHANGELOG.md b/CHANGELOG.md index cfe0043..bd3d0df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,65 @@ All notable changes to Seismo Fleet Manager will be documented in this file. 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). +## [0.3.0] - 2025-12-09 + +### Added +- **Series 4 (Micromate) Support**: New `/api/series4/heartbeat` endpoint for receiving telemetry from Series 4 Micromate units + - Auto-detection of Series 4 units via UM##### ID pattern + - Stores project hints from emitter payload in unit notes + - Automatic unit type classification across both Series 3 and Series 4 endpoints +- **Development Environment Labels**: Visual indicators to distinguish dev from production deployments + - Yellow "DEV" badge in sidebar navigation + - "[DEV]" prefix in browser title + - Yellow banner on dashboard when running in development mode + - Environment variable support in docker-compose.yml (ENVIRONMENT=production|development) +- **Quality of Life Improvements**: + - Human-readable relative timestamps (e.g., "2h 15m ago", "3d ago") with full date in tooltips + - "Last Updated" timestamp indicator on dashboard + - Status icons for colorblind accessibility (checkmark for OK, clock for Pending, X for Missing) + - Breadcrumb navigation on unit detail pages + - Copy-to-clipboard buttons for unit IDs + - Search/filter functionality for fleet roster table + - Improved empty state messages with icons +- **Timezone Support**: Comprehensive timezone handling across the application + - Timezone selector in Settings (defaults to America/New_York EST) + - Human-readable timestamp format (e.g., "9/10/2020 8:00 AM EST") + - Timezone-aware display for all timestamps site-wide + - Settings stored in localStorage for immediate effect +- **Settings Page Redesign**: Complete overhaul with tabbed interface and persistent preferences + - **General Tab**: Display preferences (timezone, theme, auto-refresh interval) + - **Data Management Tab**: Safe operations (CSV export, merge import, roster table) + - **Advanced Tab**: Power user settings (replace mode import, calibration defaults, status thresholds) + - **Danger Zone Tab**: Destructive operations isolated with enhanced warnings + - Backend preferences storage via new UserPreferences model + - Tab state persistence in localStorage + - Smooth animations and consistent styling with existing pages +- **User Preferences API**: New backend endpoints for persistent settings storage + - `GET /api/settings/preferences` - Retrieve all user preferences + - `PUT /api/settings/preferences` - Update preferences (supports partial updates) + - Database-backed storage for cross-device preference sync + - Migration script: `backend/migrate_add_user_preferences.py` + +### Changed +- Timestamps now display in user-selected timezone with human-readable format throughout the application +- Settings page reorganized from 644-line flat layout to clean 4-tab interface +- CSV Replace Mode moved from Data Management to Advanced tab with additional warnings +- Import operations separated: safe merge in Data Management tab, destructive replace in Advanced tab +- Page title changed from "Roster Manager" to "Settings" for better clarity +- All preferences now persist to backend database instead of relying solely on localStorage + +### Fixed +- Unit type classification now consistent across Series 3 and Series 4 heartbeat endpoints +- Auto-correction of misclassified unit types when they report to wrong endpoint + +### Technical Details +- New `detect_unit_type()` helper function for pattern-based unit classification +- UserPreferences model with single-row table pattern (id=1) for global settings +- Series 4 units identified by UM prefix followed by digits (e.g., UM11719) +- JavaScript Intl API used for client-side timezone conversion +- Pydantic schema for partial preference updates (PreferencesUpdate model) +- Environment context injection via custom FastAPI template response wrapper + ## [0.2.1] - 2025-12-03 ### Added @@ -92,6 +151,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Photo management per unit - Automated status categorization (OK/Pending/Missing) +[0.3.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.2.1...v0.3.0 [0.2.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.2.0...v0.2.1 [0.2.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.1.1...v0.2.0 [0.1.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.1.0...v0.1.1 diff --git a/README.md b/README.md index 5bc60c8..bb47f1c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Seismo Fleet Manager v0.2.1 +# Seismo Fleet Manager v0.3.0 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. @@ -100,15 +100,27 @@ cp /tmp/sfm_test.db data/seismo_fleet.db The helper script creates a modem/seismograph mix so you can exercise the dashboard, roster tabs, and unit detail screens immediately. -## Upgrading from v0.1.x +## Upgrading from Previous Versions -Versions ≥0.2 introduce new roster columns (device_type, calibration dates, modem metadata, addresses, etc.). Run the migration once per database file before starting the app: +### From v0.2.x to v0.3.0 + +Version 0.3.0 introduces user preferences storage. Run the migration once per database file: + +```bash +python backend/migrate_add_user_preferences.py +``` + +This creates the `user_preferences` table for persistent settings storage (timezone, theme, auto-refresh interval, calibration defaults, status thresholds). + +### From v0.1.x to v0.2.x or later + +Versions ≥0.2 introduce new roster columns (device_type, calibration dates, modem metadata, addresses, etc.). Run the migration once per database file: ```bash python backend/migrate_add_device_types.py ``` -The script is idempotent—if the new columns already exist it simply exits. +Both migration scripts are idempotent—if the columns/tables already exist, they simply exit. ## API Endpoints @@ -156,6 +168,8 @@ The script is idempotent—if the new columns already exist it simply exits. - **GET** `/api/settings/stats` - Counts for roster, emitters, and ignored tables - **GET** `/api/settings/roster-units` - Raw roster dump for the settings data grid - **POST** `/api/settings/import-csv-replace` - Replace the entire roster in one atomic transaction +- **GET** `/api/settings/preferences` - Get user preferences (timezone, theme, calibration defaults, etc.) +- **PUT** `/api/settings/preferences` - Update user preferences (supports partial updates) - **POST** `/api/settings/clear-all` - Danger-zone action that wipes roster, emitters, and ignored tables - **POST** `/api/settings/clear-roster` - Delete only roster entries - **POST** `/api/settings/clear-emitters` - Delete auto-discovered emitters @@ -182,7 +196,8 @@ See [sample_roster.csv](sample_roster.csv) for a minimal working example. ### Emitter Reporting - **POST** `/emitters/report` - Submit status report from a seismograph unit -- **POST** `/api/series3/heartbeat` - Series3 multi-unit telemetry payload +- **POST** `/api/series3/heartbeat` - Series 3 multi-unit telemetry payload +- **POST** `/api/series4/heartbeat` - Series 4 (Micromate) multi-unit telemetry payload - **GET** `/fleet/status` - Retrieve status of all seismograph units (legacy) ### Photo Management @@ -314,6 +329,22 @@ print(response.json()) | reason | string | Optional context for ignoring | | ignored_at | datetime | When the ignore action occurred | +### UserPreferences Table (Settings Storage) + +| Field | Type | Description | +|-------|------|-------------| +| id | integer | Always 1 (single-row table) | +| timezone | string | Display timezone (default: America/New_York) | +| theme | string | UI theme: auto, light, or dark | +| auto_refresh_interval | integer | Dashboard refresh interval in seconds | +| date_format | string | Date format preference | +| table_rows_per_page | integer | Default pagination size | +| calibration_interval_days | integer | Default days between calibrations | +| calibration_warning_days | integer | Warning threshold before calibration due | +| status_ok_threshold_hours | integer | Hours for OK status threshold | +| status_pending_threshold_hours | integer | Hours for Pending status threshold | +| updated_at | datetime | Last preference update timestamp | + ## Project Structure ``` @@ -321,8 +352,8 @@ seismo-fleet-manager/ ├── backend/ │ ├── main.py # FastAPI app entry point │ ├── database.py # SQLAlchemy database configuration -│ ├── models.py # Database models (RosterUnit, Emitter, IgnoredUnit) -│ ├── routes.py # Legacy API endpoints +│ ├── models.py # Database models (RosterUnit, Emitter, IgnoredUnit, UserPreferences) +│ ├── routes.py # Legacy API endpoints + Series 3/4 heartbeat endpoints │ ├── routers/ # Modular API routers │ │ ├── roster.py # Fleet status endpoints │ │ ├── roster_edit.py # Roster management & CSV import @@ -330,10 +361,11 @@ seismo-fleet-manager/ │ │ ├── photos.py # Photo management │ │ ├── dashboard.py # Dashboard partials │ │ ├── dashboard_tabs.py # Dashboard tab endpoints -│ │ └── settings.py # Roster manager/data operations +│ │ └── settings.py # Settings, preferences, and data management │ ├── services/ │ │ └── snapshot.py # Fleet status snapshot logic │ ├── migrate_add_device_types.py # SQLite migration for v0.2 schema +│ ├── migrate_add_user_preferences.py # SQLite migration for v0.3 schema │ └── static/ # Static assets (CSS, etc.) ├── create_test_db.py # Generate a sample SQLite DB with mixed devices ├── templates/ # Jinja2 HTML templates @@ -400,24 +432,31 @@ docker compose down -v ## Release Highlights +### v0.3.0 — 2025-12-09 +- **Series 4 Support**: New `/api/series4/heartbeat` endpoint with auto-detection for Micromate units (UM##### pattern) +- **Settings Redesign**: Completely redesigned Settings page with 4-tab interface (General, Data Management, Advanced, Danger Zone) +- **User Preferences**: Backend storage for timezone, theme, auto-refresh interval, calibration defaults, and status thresholds +- **Development Labels**: Visual indicators to distinguish dev from production environments +- **Timezone Support**: Comprehensive timezone handling with human-readable timestamps site-wide +- **Quality of Life**: Relative timestamps, status icons for accessibility, breadcrumb navigation, copy-to-clipboard, search functionality + ### v0.2.1 — 2025-12-03 -- Added the `/settings` roster manager with CSV export/import, live stats, and danger-zone table reset actions. -- Deployed/Benched/Retired/Ignored tabs now have dedicated HTMX partials, sorting, and inline actions (edit, deploy toggle, ignore, delete). -- Unit detail pages expose device-type specific metadata (calibration windows, modem pairing, IP/phone fields) with a refreshed editing experience. -- Snapshot summary and dashboard counts now focus on deployed units and include address/coordinate data for mapping widgets. +- Added the `/settings` roster manager with CSV export/import, live stats, and danger-zone table reset actions +- Deployed/Benched/Retired/Ignored tabs now have dedicated HTMX partials, sorting, and inline actions +- Unit detail pages expose device-type specific metadata (calibration windows, modem pairing, IP/phone fields) +- Snapshot summary and dashboard counts now focus on deployed units and include address/coordinate data ### v0.2.0 — 2025-12-03 -- Introduced device-type aware roster schema (seismograph vs modem) plus migration + `create_test_db.py` helper for new installs. -- Added Ignore list model/endpoints to quarantine noisy emitters directly from the roster. -- Roster page gained Add Unit + CSV Import modals, HTMX-driven updates, and unknown emitter callouts. -- Snapshot service now returns active/benched/retired/unknown buckets containing richer metadata for the dashboard and roster tabs. +- Introduced device-type aware roster schema (seismograph vs modem) plus migration + `create_test_db.py` helper +- Added Ignore list model/endpoints to quarantine noisy emitters directly from the roster +- Roster page gained Add Unit + CSV Import modals, HTMX-driven updates, and unknown emitter callouts +- Snapshot service now returns active/benched/retired/unknown buckets containing richer metadata ### v0.1.1 — 2025-12-02 - **Roster Editing API**: Full CRUD operations for managing your fleet roster - **CSV Import**: Bulk upload roster data from CSV files - **Enhanced Data Model**: Added project_id and location fields to roster - **Bug Fixes**: Improved database session management and error handling -- **Dashboard Improvements**: Separate views for Active, Benched, and Retired units See [CHANGELOG.md](CHANGELOG.md) for the full release notes. @@ -437,9 +476,11 @@ MIT ## Version -**Current: 0.2.1** — Settings & roster manager refresh (2025-12-03) +**Current: 0.3.0** — Series 4 support, settings redesign, user preferences (2025-12-09) -Previous: 0.2.0 — Device-type aware roster + ignore list (2025-12-03) +Previous: 0.2.1 — Settings & roster manager refresh (2025-12-03) + +0.2.0 — Device-type aware roster + ignore list (2025-12-03) 0.1.1 — Roster Management & CSV Import (2025-12-02) diff --git a/backend/main.py b/backend/main.py index 4b5a155..d707063 100644 --- a/backend/main.py +++ b/backend/main.py @@ -18,7 +18,7 @@ Base.metadata.create_all(bind=engine) ENVIRONMENT = os.getenv("ENVIRONMENT", "production") # Initialize FastAPI app -VERSION = "0.2.2" +VERSION = "0.3.0" app = FastAPI( title="Seismo Fleet Manager", description="Backend API for managing seismograph fleet status", @@ -258,9 +258,9 @@ async def unknown_emitters_partial(request: Request): def health_check(): """Health check endpoint""" return { - "message": "Seismo Fleet Manager v0.1.1", + "message": f"Seismo Fleet Manager v{VERSION}", "status": "running", - "version": "0.1.1" + "version": VERSION } diff --git a/backend/migrate_add_user_preferences.py b/backend/migrate_add_user_preferences.py new file mode 100644 index 0000000..73a4e9c --- /dev/null +++ b/backend/migrate_add_user_preferences.py @@ -0,0 +1,80 @@ +""" +Migration script to add user_preferences table. + +This creates a new table for storing persistent user preferences: +- Display settings (timezone, theme, date format) +- Auto-refresh configuration +- Calibration defaults +- Status threshold customization + +Run this script once to migrate an existing database. +""" + +import sqlite3 +import os + +# Database path +DB_PATH = "./data/seismo_fleet.db" + +def migrate_database(): + """Create user_preferences table""" + + if not os.path.exists(DB_PATH): + print(f"Database not found at {DB_PATH}") + print("The database will be created automatically when you run the application.") + return + + print(f"Migrating database: {DB_PATH}") + + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Check if user_preferences table already exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='user_preferences'") + table_exists = cursor.fetchone() + + if table_exists: + print("Migration already applied - user_preferences table exists") + conn.close() + return + + print("Creating user_preferences table...") + + try: + cursor.execute(""" + CREATE TABLE user_preferences ( + id INTEGER PRIMARY KEY DEFAULT 1, + timezone TEXT DEFAULT 'America/New_York', + theme TEXT DEFAULT 'auto', + auto_refresh_interval INTEGER DEFAULT 10, + date_format TEXT DEFAULT 'MM/DD/YYYY', + table_rows_per_page INTEGER DEFAULT 25, + calibration_interval_days INTEGER DEFAULT 365, + calibration_warning_days INTEGER DEFAULT 30, + status_ok_threshold_hours INTEGER DEFAULT 12, + status_pending_threshold_hours INTEGER DEFAULT 24, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + print(" ✓ Created user_preferences table") + + # Insert default preferences + cursor.execute(""" + INSERT INTO user_preferences (id) VALUES (1) + """) + print(" ✓ Inserted default preferences") + + conn.commit() + print("\nMigration completed successfully!") + + except sqlite3.Error as e: + print(f"\nError during migration: {e}") + conn.rollback() + raise + + finally: + conn.close() + + +if __name__ == "__main__": + migrate_database() diff --git a/backend/models.py b/backend/models.py index d941264..4b36061 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, String, DateTime, Boolean, Text, Date +from sqlalchemy import Column, String, DateTime, Boolean, Text, Date, Integer from datetime import datetime from backend.database import Base @@ -56,4 +56,24 @@ class IgnoredUnit(Base): id = Column(String, primary_key=True, index=True) reason = Column(String, nullable=True) - ignored_at = Column(DateTime, default=datetime.utcnow) \ No newline at end of file + ignored_at = Column(DateTime, default=datetime.utcnow) + + +class UserPreferences(Base): + """ + User preferences: persistent storage for application settings. + Single-row table (id=1) to store global user preferences. + """ + __tablename__ = "user_preferences" + + id = Column(Integer, primary_key=True, default=1) + timezone = Column(String, default="America/New_York") + theme = Column(String, default="auto") # auto, light, dark + auto_refresh_interval = Column(Integer, default=10) # seconds + date_format = Column(String, default="MM/DD/YYYY") + table_rows_per_page = Column(Integer, default=25) + calibration_interval_days = Column(Integer, default=365) + calibration_warning_days = Column(Integer, default=30) + status_ok_threshold_hours = Column(Integer, default=12) + status_pending_threshold_hours = Column(Integer, default=24) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) \ No newline at end of file diff --git a/backend/routers/settings.py b/backend/routers/settings.py index 7063209..1af0547 100644 --- a/backend/routers/settings.py +++ b/backend/routers/settings.py @@ -2,11 +2,13 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from datetime import datetime, date +from pydantic import BaseModel +from typing import Optional import csv import io from backend.database import get_db -from backend.models import RosterUnit, Emitter, IgnoredUnit +from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences router = APIRouter(prefix="/api/settings", tags=["settings"]) @@ -239,3 +241,87 @@ def clear_ignored(db: Session = Depends(get_db)): except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}") + + +# User Preferences Endpoints + +class PreferencesUpdate(BaseModel): + """Schema for updating user preferences (all fields optional)""" + timezone: Optional[str] = None + theme: Optional[str] = None + auto_refresh_interval: Optional[int] = None + date_format: Optional[str] = None + table_rows_per_page: Optional[int] = None + calibration_interval_days: Optional[int] = None + calibration_warning_days: Optional[int] = None + status_ok_threshold_hours: Optional[int] = None + status_pending_threshold_hours: Optional[int] = None + + +@router.get("/preferences") +def get_preferences(db: Session = Depends(get_db)): + """ + Get user preferences. Creates default preferences if none exist. + """ + prefs = db.query(UserPreferences).filter(UserPreferences.id == 1).first() + + if not prefs: + # Create default preferences + prefs = UserPreferences(id=1) + db.add(prefs) + db.commit() + db.refresh(prefs) + + return { + "timezone": prefs.timezone, + "theme": prefs.theme, + "auto_refresh_interval": prefs.auto_refresh_interval, + "date_format": prefs.date_format, + "table_rows_per_page": prefs.table_rows_per_page, + "calibration_interval_days": prefs.calibration_interval_days, + "calibration_warning_days": prefs.calibration_warning_days, + "status_ok_threshold_hours": prefs.status_ok_threshold_hours, + "status_pending_threshold_hours": prefs.status_pending_threshold_hours, + "updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None + } + + +@router.put("/preferences") +def update_preferences( + updates: PreferencesUpdate, + db: Session = Depends(get_db) +): + """ + Update user preferences. Accepts partial updates. + Creates default preferences if none exist. + """ + prefs = db.query(UserPreferences).filter(UserPreferences.id == 1).first() + + if not prefs: + # Create default preferences + prefs = UserPreferences(id=1) + db.add(prefs) + + # Update only provided fields + update_data = updates.dict(exclude_unset=True) + for field, value in update_data.items(): + setattr(prefs, field, value) + + prefs.updated_at = datetime.utcnow() + + db.commit() + db.refresh(prefs) + + return { + "message": "Preferences updated successfully", + "timezone": prefs.timezone, + "theme": prefs.theme, + "auto_refresh_interval": prefs.auto_refresh_interval, + "date_format": prefs.date_format, + "table_rows_per_page": prefs.table_rows_per_page, + "calibration_interval_days": prefs.calibration_interval_days, + "calibration_warning_days": prefs.calibration_warning_days, + "status_ok_threshold_hours": prefs.status_ok_threshold_hours, + "status_pending_threshold_hours": prefs.status_pending_threshold_hours, + "updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None + } diff --git a/templates/base.html b/templates/base.html index 8814760..b16ba60 100644 --- a/templates/base.html +++ b/templates/base.html @@ -149,6 +149,116 @@ } else if (localStorage.getItem('theme') === 'dark') { document.documentElement.classList.add('dark'); } + + // Helper function: Convert timestamp to relative time + function timeAgo(dateString) { + if (!dateString) return 'Never'; + + const date = new Date(dateString); + const now = new Date(); + const seconds = Math.floor((now - date) / 1000); + + if (seconds < 60) return `${seconds}s ago`; + + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + + const hours = Math.floor(minutes / 60); + if (hours < 24) { + const remainingMins = minutes % 60; + return remainingMins > 0 ? `${hours}h ${remainingMins}m ago` : `${hours}h ago`; + } + + const days = Math.floor(hours / 24); + if (days < 7) { + const remainingHours = hours % 24; + return remainingHours > 0 ? `${days}d ${remainingHours}h ago` : `${days}d ago`; + } + + const weeks = Math.floor(days / 7); + if (weeks < 4) { + const remainingDays = days % 7; + return remainingDays > 0 ? `${weeks}w ${remainingDays}d ago` : `${weeks}w ago`; + } + + const months = Math.floor(days / 30); + return `${months}mo ago`; + } + + // Helper function: Get user's selected timezone + function getTimezone() { + return localStorage.getItem('timezone') || 'America/New_York'; + } + + // Helper function: Format timestamp with tooltip (timezone-aware) + function formatTimestamp(dateString) { + if (!dateString) return 'Never'; + + const date = new Date(dateString); + const timezone = getTimezone(); + + const fullDate = date.toLocaleString('en-US', { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + timeZone: timezone, + timeZoneName: 'short' + }); + + return `${timeAgo(dateString)}`; + } + + // Helper function: Format timestamp as full date/time (no relative time) + // Format: "9/10/2020 8:00 AM EST" + function formatFullTimestamp(dateString) { + if (!dateString) return 'Never'; + + const date = new Date(dateString); + const timezone = getTimezone(); + + return date.toLocaleString('en-US', { + month: 'numeric', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + timeZone: timezone, + timeZoneName: 'short' + }); + } + + // Update all timestamps on page load and periodically + function updateAllTimestamps() { + document.querySelectorAll('[data-timestamp]').forEach(el => { + const timestamp = el.getAttribute('data-timestamp'); + el.innerHTML = formatTimestamp(timestamp); + }); + } + + // Run on load and every minute + updateAllTimestamps(); + setInterval(updateAllTimestamps, 60000); + + // Copy to clipboard helper + function copyToClipboard(text, button) { + navigator.clipboard.writeText(text).then(() => { + // Visual feedback + const originalHTML = button.innerHTML; + button.innerHTML = ''; + button.classList.add('text-green-500'); + + setTimeout(() => { + button.innerHTML = originalHTML; + button.classList.remove('text-green-500'); + }, 1500); + }).catch(err => { + console.error('Failed to copy:', err); + }); + } {% block extra_scripts %}{% endblock %} diff --git a/templates/dashboard.html b/templates/dashboard.html index cdbd881..851c1ca 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -10,9 +10,15 @@ {% endif %} -
Fleet overview and recent activity
+Fleet overview and recent activity
+Last updated
+--
+Deployed Status:
-No active units
+Get started by adding a new unit to the fleet.
+No benched units
+Units awaiting deployment will appear here.
+