Compare commits
110 commits
veridian-r
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3023155fde | ||
|
|
477c31db65 | ||
|
|
afe00b3c45 | ||
|
|
d294f1746f | ||
|
|
a777feae2b | ||
|
|
06addc52f0 | ||
|
|
23e1720dd1 | ||
|
|
37731a37da | ||
|
|
998f9b89e7 | ||
|
|
36705cd257 | ||
|
|
813e4ac70c | ||
|
|
6110530943 | ||
|
|
bc78836bf6 | ||
|
|
7159d48b06 | ||
|
|
98d729f87f | ||
|
|
b95302c451 | ||
|
|
34b34bd5b5 | ||
|
|
de632bd425 | ||
|
|
b6fbb5c8b7 | ||
|
|
7567efe51e | ||
|
|
702bf87552 | ||
|
|
fb1e3c05c7 | ||
|
|
f97e8ea1d0 | ||
|
|
ed7b78be32 | ||
|
|
5482676f06 | ||
|
|
c74f37783f | ||
|
|
0723c93908 | ||
|
|
44f1939b2b | ||
|
|
57c70b91db | ||
|
|
469286deac | ||
|
|
ad875443ed | ||
|
|
41dcdce993 | ||
|
|
64d7d56792 | ||
|
|
dc403c29f5 | ||
|
|
2998b90fe0 | ||
|
|
add6c6d305 | ||
|
|
28532d4d9b | ||
|
|
7cb7843ceb | ||
|
|
c39abe5696 | ||
|
|
55bdef78e4 | ||
|
|
14e76f2cdf | ||
|
|
e4c506d074 | ||
|
|
6ae2b35f8d | ||
|
|
22d0668ba1 | ||
|
|
1abb972d37 | ||
|
|
01b6c18f58 | ||
|
|
01da433723 | ||
|
|
fb5dba5019 | ||
|
|
893244169d | ||
|
|
79b6bdbcd2 | ||
|
|
afbd5c69aa | ||
|
|
c3dcefe857 | ||
|
|
215d24eb0e | ||
|
|
af0e6526d6 | ||
|
|
95af9e9f8d | ||
|
|
54531a79d5 | ||
|
|
5c86b98628 | ||
|
|
2ca6fb01f4 | ||
|
|
b520ffc578 | ||
|
|
abaef138ba | ||
|
|
2bc596c527 | ||
|
|
a13d6f6907 | ||
|
|
133bf9ea3a | ||
|
|
6d957f1c92 | ||
|
|
6bdabb0e60 | ||
|
|
dcbb75180d | ||
|
|
2acef3c63c | ||
|
|
c962118ba6 | ||
|
|
7ec8b1fc57 | ||
|
|
ec9e98e696 | ||
|
|
7b5321cb14 | ||
|
|
cbaa341553 | ||
|
|
884e4050ff | ||
|
|
554bf214c1 | ||
|
|
fe5c6decc2 | ||
|
|
f534c9818e | ||
|
|
d9d04045cb | ||
|
|
1a13087c53 | ||
|
|
3cc1830b6c | ||
|
|
0d82bb0db4 | ||
|
|
d4bbc33c64 | ||
|
|
4d04d2f8c7 | ||
|
|
e82dac5ca2 | ||
|
|
8866341a8f | ||
|
|
494ea2f01a | ||
|
|
68b6ea9a02 | ||
|
|
15ab32a75a | ||
|
|
dc2f491fa9 | ||
|
|
dc2cfd13ad | ||
|
|
92c65889ac | ||
|
|
66346cb168 | ||
|
|
feb8b04ba0 | ||
|
|
477c076d03 | ||
|
|
aa8e5d226f | ||
|
|
2f67ad2fe3 | ||
|
|
e88814afef | ||
|
|
1950651102 | ||
|
|
820d345a0c | ||
|
|
22ed334fb3 | ||
|
|
4b37e9fa84 | ||
|
|
f7c71f2e5f | ||
|
|
c9c9eb84f2 | ||
|
|
7a01dc5589 | ||
|
|
1363333746 | ||
|
|
47de301f77 | ||
|
|
15a6b08e0f | ||
|
|
5666970629 | ||
|
|
38f9ef5f0b | ||
|
|
f875664305 | ||
|
|
06abb56560 |
5
.agent/rules/dev.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
workspace is ~/DEV
|
||||
152
.claude/claude.md
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
# Project Overview
|
||||
|
||||
**Name**: Veridian Cultivation Platform
|
||||
**Type**: Full-stack SaaS application (backend + frontend)
|
||||
**Primary Languages**: TypeScript (Node.js 20, React 18)
|
||||
**Status**: v0.1.0, Internal Testing
|
||||
|
||||
## High-Level Description
|
||||
|
||||
Veridian is a cultivation management platform for licensed cannabis facilities that centralizes:
|
||||
- Grow Operations: Tasks, batches, rooms, and cultivation workflows
|
||||
- Labor Tracking: Timeclock, hours, and cost-per-batch analysis
|
||||
- Compliance: Document storage, audit packets, and METRC alignment
|
||||
- Inventory: Materials, nutrients, and supplies with lot tracking
|
||||
- Integrations: Environmental monitoring and hardware dashboards
|
||||
- Communications: Task comments, announcements, and notifications
|
||||
|
||||
**External Systems**:
|
||||
- PostgreSQL 15.x (primary database via Prisma ORM)
|
||||
- METRC API (read-only in v1, California track-and-trace)
|
||||
- SensorPush environmental sensors (via Veridian Edge agent)
|
||||
- VPS deployment: nexus-vector
|
||||
|
||||
## Multi-Repo Architecture
|
||||
|
||||
This repo works in conjunction with **veridian-edge** as a single codebase:
|
||||
- **veridian** (this repo): Main platform backend + frontend
|
||||
- **veridian-edge**: SensorPush edge agent that polls SensorPush Cloud API and syncs environmental readings to Veridian backend
|
||||
|
||||
When making changes, consider the API contract between these two repos:
|
||||
- Edge agent → Veridian backend API endpoints
|
||||
- Data models must match between edge agent and backend
|
||||
- Authentication/authorization patterns
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
veridian/
|
||||
├── backend/ # Express/Fastify backend API
|
||||
│ ├── src/ # Source code
|
||||
│ ├── prisma/ # Database schema and migrations
|
||||
│ └── package.json # Node.js dependencies
|
||||
├── frontend/ # React + Vite frontend
|
||||
│ ├── src/ # Source code
|
||||
│ └── package.json # Dependencies
|
||||
├── docs/ # Architecture and compliance docs
|
||||
├── specs/ # Feature specifications (Spec Kit workflow)
|
||||
├── design-os/ # Design system components
|
||||
└── docker-compose.yml # Local development stack
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
### Language/Style
|
||||
- **TypeScript strict mode** enabled
|
||||
- **ESLint + Prettier** for formatting
|
||||
- **Prisma 5.x** for database access
|
||||
- **Conventional commits** for git messages
|
||||
|
||||
### Package Manager
|
||||
- **Use `bun` instead of `npm`** by default
|
||||
- Commands: `bun install`, `bun run`, `bun test`, `bun add`
|
||||
|
||||
### Error Handling
|
||||
- Throw semantic errors with clear messages
|
||||
- Use Result types for operations that can fail
|
||||
- Never expose sensitive data in error messages
|
||||
|
||||
### Logging
|
||||
- Use structured logging (JSON in production)
|
||||
- No `console.log` in production code
|
||||
- Log levels: error, warn, info, debug
|
||||
|
||||
### Config
|
||||
- Read from environment variables
|
||||
- Never hard-code secrets or API keys
|
||||
- Use `.env.example` as template
|
||||
|
||||
## Patterns & Playbooks
|
||||
|
||||
### How to Add New API Endpoints
|
||||
1. Update Prisma schema if needed (`prisma/schema.prisma`)
|
||||
2. Run migration: `bunx prisma migrate dev --name description`
|
||||
3. Create route handler in `backend/src/routes/`
|
||||
4. Add request validation (zod or similar)
|
||||
5. Implement business logic in service layer
|
||||
6. Add tests in `backend/src/__tests__/`
|
||||
7. Update API documentation
|
||||
|
||||
### How to Add New Frontend Components
|
||||
1. Create component in `frontend/src/components/`
|
||||
2. Follow existing design system patterns (see `design-os/`)
|
||||
3. Use TypeScript with proper prop types
|
||||
4. Add responsive styles (Tailwind CSS)
|
||||
5. Create tests if component has logic
|
||||
6. Export from component index file
|
||||
|
||||
### How to Write Database Migrations
|
||||
1. Modify `backend/prisma/schema.prisma`
|
||||
2. Run `bunx prisma migrate dev --name description`
|
||||
3. Generate client: `bunx prisma generate`
|
||||
4. For production: `bunx prisma migrate deploy`
|
||||
|
||||
### How to Run Locally
|
||||
**Backend:**
|
||||
```bash
|
||||
cd backend
|
||||
bun install
|
||||
cp .env.example .env # Configure DB connection
|
||||
bunx prisma migrate dev
|
||||
bun run dev
|
||||
```
|
||||
|
||||
**Frontend:**
|
||||
```bash
|
||||
cd frontend
|
||||
bun install
|
||||
cp .env.example .env # Configure API URL
|
||||
bun run dev
|
||||
```
|
||||
|
||||
**Full Stack (Docker):**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Important Environment Variables
|
||||
- `DATABASE_URL`: PostgreSQL connection string
|
||||
- `JWT_SECRET`: Secret for JWT token signing
|
||||
- `METRC_API_KEY`: METRC integration (read-only)
|
||||
- `SENSORPUSH_WEBHOOK_SECRET`: Webhook verification for edge agent
|
||||
|
||||
## PR & Learning Workflow
|
||||
|
||||
- When a PR introduces a new pattern or fixes a subtle issue:
|
||||
1. Summarize the lesson in 1-2 bullets
|
||||
2. Append under "Patterns & Playbooks" above
|
||||
3. Consider updating relevant documentation in `docs/`
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Unit tests**: Jest for pure functions and business logic
|
||||
- **Integration tests**: API endpoint tests with test database
|
||||
- **E2E tests**: Browser tests for critical flows (login, batch creation)
|
||||
- **VPS verification**: After deployment, verify on nexus-vector
|
||||
|
||||
## Compliance Notes
|
||||
|
||||
- **METRC is system of record** for California track-and-trace
|
||||
- All compliance-relevant changes must be documented
|
||||
- Audit logs must capture: who, what, when, for critical operations
|
||||
- See `docs/compliance-notes-ca.md` for detailed guidance
|
||||
31
.claude/commands/deploy-vps.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
You are the VPS deployment assistant for Veridian.
|
||||
|
||||
## VPS Information
|
||||
- Host: nexus-vector
|
||||
- User: admin
|
||||
- Project path on VPS: /home/admin/honkingversion (adjust if different)
|
||||
|
||||
## Deployment Process
|
||||
|
||||
1. **Pre-deployment checks**:
|
||||
- `git status` to check for uncommitted changes
|
||||
- `git log -1 --oneline` to show what will be deployed
|
||||
|
||||
2. **Deploy to VPS**:
|
||||
```bash
|
||||
ssh admin@nexus-vector 'cd /home/admin/honkingversion && git pull origin master && docker compose down && docker compose up -d --build 2>&1 | tail -50'
|
||||
```
|
||||
|
||||
3. **Post-deployment verification**:
|
||||
- Check service status: `ssh admin@nexus-vector 'docker compose ps'`
|
||||
- View logs: `ssh admin@nexus-vector 'docker compose logs --tail 50'`
|
||||
- Health check: `curl https://your-veridian-url.com/health` (if available)
|
||||
|
||||
## Output
|
||||
|
||||
Report:
|
||||
- What was deployed (commit hash, message)
|
||||
- Docker build output summary
|
||||
- Service status (running/not running)
|
||||
- Any errors or warnings in logs
|
||||
- Suggested manual verification steps (URLs to check, flows to test)
|
||||
24
.claude/commands/migrate.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
You are the database migration assistant for Veridian.
|
||||
|
||||
## Process
|
||||
|
||||
1. **Check current state**:
|
||||
- `cd backend && bunx prisma migrate status`
|
||||
- Show any pending migrations
|
||||
|
||||
2. **Create migration**:
|
||||
- Ask for a description of the schema change
|
||||
- Run: `cd backend && bunx prisma migrate dev --name description`
|
||||
- Generate Prisma client: `bunx prisma generate`
|
||||
|
||||
3. **Verify**:
|
||||
- Show the generated SQL
|
||||
- Check if any data migration is needed
|
||||
|
||||
## Output
|
||||
|
||||
Report:
|
||||
- Migration name and number
|
||||
- SQL changes summary
|
||||
- Whether data migration is needed
|
||||
- Next steps (deploy to production, etc.)
|
||||
2
.gitignore
vendored
|
|
@ -36,3 +36,5 @@ backend/prisma/dev.db
|
|||
|
||||
# Docker
|
||||
docker-compose.env
|
||||
_SECRETS_BACKUP
|
||||
/design-os/
|
||||
|
|
|
|||
355
QUICK-REFERENCE.md
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
# VERIDIAN HARDWARE TIER REPORT - QUICK REFERENCE
|
||||
|
||||
**Deployment:** 5 Greenhouses + 3 Drying Containers
|
||||
**Date:** January 6, 2026
|
||||
**Report:** [VERIDIAN-HARDWARE-TIERS-REPORT.md](./VERIDIAN-HARDWARE-TIERS-REPORT.md)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 TL;DR (Too Long; Didn't Read)
|
||||
|
||||
### Three Hardware Tiers for 5 GH + 3 Containers
|
||||
|
||||
| Tier | Total Cost | What You Get | Best For |
|
||||
|------|------------|--------------|----------|
|
||||
| **Baseline** | **$2,200** | SensorPush HT1, Kasa EP10, Pi 4, Zigbee soil sensors (optional) | Initial testing, budget-conscious |
|
||||
| **Premium** | **$10,500** | HT.w sensors, CO2, cameras, Pi 5, Soil Scout | Production, small commercial |
|
||||
| **Enterprise** | **$36,600** | PAR sensors, PTZ cameras, Pi cluster, PLC | Commercial scale, maximum uptime |
|
||||
|
||||
**Real 2026 Pricing Applied** - Actual prices are LOWER than estimated!
|
||||
|
||||
---
|
||||
|
||||
## 📦 What's Included
|
||||
|
||||
### Common to All Tiers:
|
||||
- **26 Air Sensors** (20 greenhouses + 6 containers)
|
||||
- **10 Smart Plugs** (power control for fans, heaters, dehumidifiers)
|
||||
- **Raspberry Pi Edge Device** (runs HomeAssistant + Veridian Edge Agent)
|
||||
- **Network Infrastructure** (WiFi, ethernet, switches)
|
||||
- **SQLite Offline Buffer** (data survives internet outages)
|
||||
|
||||
### Tier-Specific Additions:
|
||||
|
||||
**Baseline ($2,200):**
|
||||
- SensorPush HT1 sensors (±0.36°F accuracy)
|
||||
- Raspberry Pi 4 (4GB RAM)
|
||||
- Kasa EP10 smart plugs (no energy monitoring)
|
||||
- Optional: Zigbee soil sensors ($198) or DIY capacitive ($255)
|
||||
- 30-min UPS battery backup
|
||||
|
||||
**Premium ($10,500):**
|
||||
- SensorPush HT.w sensors (±0.18°F accuracy - 2x better!)
|
||||
- Raspberry Pi 5 (8GB RAM) + NVMe SSD
|
||||
- Kasa EP25 smart plugs (with energy monitoring)
|
||||
- CO2 monitoring (8 zones)
|
||||
- 4MP cameras (8 cameras, 30-day retention)
|
||||
- Soil Scout LoRa sensors (professional-grade)
|
||||
- E-ink displays (8 zones)
|
||||
- 2-hour UPS + 4G LTE failover
|
||||
|
||||
**Enterprise ($36,600):**
|
||||
- Everything in Premium, plus:
|
||||
- PAR light sensors (Apogee SQ-500)
|
||||
- 4K PTZ cameras (90-day retention)
|
||||
- Pi 5 cluster (3 nodes, high availability)
|
||||
- PLC control (industrial automation)
|
||||
- Multi-depth soil sensors (moisture + EC + pH)
|
||||
- Voice annunciation system
|
||||
- 4-6 hour UPS + generator hook-up
|
||||
- Dual 4G LTE internet redundancy
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Key Hardware Specifications
|
||||
|
||||
### Environmental Sensors
|
||||
|
||||
**Air Sensors:**
|
||||
- **SensorPush HT1** (Baseline): $54.95 each
|
||||
- Temperature: -40°F to 185°F (±0.36°F)
|
||||
- Humidity: 0-100% RH (±2%)
|
||||
- Battery: 12 months
|
||||
- Data: Every 1 minute
|
||||
|
||||
- **SensorPush HT.w** (Premium/Enterprise): $110 each
|
||||
- Temperature: -40°F to 185°F (±0.18°F) - **2x more accurate!**
|
||||
- Humidity: 0-100% RH (±1%)
|
||||
- Battery: 18 months
|
||||
- Data: Every 30 seconds
|
||||
|
||||
**Soil Sensors (Optional):**
|
||||
|
||||
| Option | Price | Accuracy | Battery | Integration |
|
||||
|--------|-------|----------|---------|-------------|
|
||||
| **Zigbee (Tuya TS0601)** | $18 each | ±5% | 6-12 mo (AAA) | ZHA / Zigbee2MQTT |
|
||||
| **DIY Capacitive** | $8 each | ±5% | N/A (wired) | ADC + Python/Bun |
|
||||
| **Soil Scout (LoRa)** | $150-250 each | ±3% | 5-10 years | MQTT via LoRa gateway |
|
||||
| **Soil Scout Pro** | $250 each | ±2% | 10 years | Multi-depth (6", 12", 18") |
|
||||
|
||||
**CO2 Sensors (Premium/Enterprise):**
|
||||
- **SenseAir S8** (Premium): $200 each
|
||||
- Range: 0-2000 ppm
|
||||
- Accuracy: ±30 ppm ±3%
|
||||
- Output: Modbus RTU
|
||||
|
||||
- **SenseAir S8 Pro** (Enterprise): $350 each
|
||||
- Range: 0-10,000 ppm
|
||||
- Accuracy: ±20 ppm ±2%
|
||||
- Pressure compensation: Yes
|
||||
|
||||
**PAR Sensors (Enterprise only):**
|
||||
- **Apogee SQ-500**: $600 each
|
||||
- Spectral range: 389-683 nm (plant-focused)
|
||||
- Output: 0-2.5V
|
||||
- Weatherproof: IP68
|
||||
|
||||
### Smart Plugs
|
||||
|
||||
| Model | Max Load | Energy Monitor | Price | Best For |
|
||||
|-------|----------|----------------|-------|----------|
|
||||
| **Kasa EP10** | 15A / 1800W | No | $7.50 each | Small loads, basic control |
|
||||
| **Kasa EP25** | 15A / 1800W | Yes | $11.25 each | Medium loads, energy tracking |
|
||||
|
||||
**Note:** Actual prices from 4-packs! EP10 4-pack = $29.99, EP25 4-pack = $44.99
|
||||
|
||||
### Edge Device
|
||||
|
||||
| Model | RAM | Performance | Storage | Price |
|
||||
|-------|-----|-------------|---------|-------|
|
||||
| **Raspberry Pi 4** | 4GB | 1x baseline | 32GB SD | $60 |
|
||||
| **Raspberry Pi 5** | 8GB | 2-3x faster | 500GB NVMe | $95 |
|
||||
|
||||
**Recommendation:** Pi 5 if budget allows (2-3x faster, true Gigabit Ethernet, NVMe support)
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Deployment Architecture
|
||||
|
||||
```
|
||||
Veridian Cloud Backend (VPS)
|
||||
↓ HTTPS/WebSocket
|
||||
Edge Device (Raspberry Pi)
|
||||
├── Home Assistant
|
||||
├── Veridian Edge Agent (Bun)
|
||||
├── SQLite Buffer (offline cache)
|
||||
└── MQTT Broker (optional)
|
||||
↓
|
||||
┌────┴────┬─────────┬────────────┐
|
||||
│ │ │ │
|
||||
SensorPush Kasa Zigbee Cameras/Other
|
||||
Gateway Plugs (soil) (Tier 2/3)
|
||||
│ │ │ │
|
||||
└────┬────┴─────────┴────────────┘
|
||||
↓
|
||||
26 Sensors + 10 Plugs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📍 Sensor Placement
|
||||
|
||||
### Greenhouses (5 total)
|
||||
- **4 air sensors each** (N, S, E, W walls at canopy height)
|
||||
- **2 soil sensors each** (optional - Tier 1: Zigbee/DIY, Tier 2+: Soil Scout)
|
||||
|
||||
### Containers (3 total)
|
||||
- **2 air sensors each** (front and back, eye level)
|
||||
- **No soil sensors** (drying environment)
|
||||
|
||||
**Total Air Sensors:** 26 (5 GH × 4 + 3 Containers × 2)
|
||||
**Total Soil Sensors (optional):** 10 (5 GH × 2)
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Software Stack
|
||||
|
||||
### Edge Device (Raspberry Pi):
|
||||
- **Home Assistant** (home automation platform)
|
||||
- **Veridian Edge Agent** (Bun runtime, polls SensorPush API)
|
||||
- **SQLite** (offline data buffering)
|
||||
- **MQTT Broker** (optional - for soil sensors, e-ink displays)
|
||||
- **go2rtc** (Tier 2/3 - camera streaming)
|
||||
|
||||
### Integrations:
|
||||
- **SensorPush** (air sensors) - Native HA integration
|
||||
- **TP-Link Kasa** (smart plugs) - Native HA integration
|
||||
- **ZHA** or **Zigbee2MQTT** (soil sensors)
|
||||
- **Modbus** (CO2 sensors - Tier 2/3)
|
||||
- **Veridian Cloud** (backend API, WebSocket)
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Example Automations
|
||||
|
||||
### Emergency Ventilation (High Temp)
|
||||
```yaml
|
||||
# If temperature > 85°F for 1 minute → Turn on fan
|
||||
trigger:
|
||||
- platform: numeric_state
|
||||
entity_id: sensor.greenhouse_1_temperature
|
||||
above: 85
|
||||
for:
|
||||
minutes: 1
|
||||
action:
|
||||
- service: switch.turn_on
|
||||
entity_id: switch.greenhouse_1_fan
|
||||
```
|
||||
|
||||
### Low Soil Moisture Alert
|
||||
```yaml
|
||||
# If soil moisture < 30% → Send notification
|
||||
trigger:
|
||||
- platform: numeric_state
|
||||
entity_id: sensor.greenhouse_1_soil_moisture
|
||||
below: 30
|
||||
for:
|
||||
minutes: 30
|
||||
action:
|
||||
- service: notify.mobile_app
|
||||
data:
|
||||
title: "💧 GH1 Low Soil Moisture"
|
||||
message: "Soil moisture at 25%. Watering needed."
|
||||
```
|
||||
|
||||
### Container Dehumidifier
|
||||
```yaml
|
||||
# If humidity > 65% → Turn on dehumidifier
|
||||
trigger:
|
||||
- platform: numeric_state
|
||||
entity_id: sensor.container_1_humidity
|
||||
above: 65
|
||||
action:
|
||||
- service: switch.turn_on
|
||||
entity_id: switch.container_1_dehumidifier
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📅 Installation Timeline
|
||||
|
||||
### Tier 1 (Baseline)
|
||||
- **Procurement:** 1 week (Amazon, fast shipping)
|
||||
- **Installation:** 1 weekend (DIY)
|
||||
- **Configuration:** 1-2 days
|
||||
- **Testing:** 1 week
|
||||
- **Total:** 3-4 weeks to full deployment
|
||||
|
||||
### Tier 2 (Premium)
|
||||
- **Procurement:** 2 weeks (some specialty items)
|
||||
- **Installation:** 1 week
|
||||
- **Configuration:** 3-5 days
|
||||
- **Testing:** 1 week
|
||||
- **Total:** 5-6 weeks to full deployment
|
||||
|
||||
### Tier 3 (Enterprise)
|
||||
- **Procurement:** 3-4 weeks (industrial equipment)
|
||||
- **Installation:** 2-3 weeks (professional)
|
||||
- **Configuration:** 1-2 weeks
|
||||
- **Testing:** 2 weeks
|
||||
- **Total:** 10-12 weeks to full deployment
|
||||
|
||||
---
|
||||
|
||||
## 💰 Cost Comparison Over 5 Years
|
||||
|
||||
| Tier | Initial Cost | Maintenance | Total 5-Year | ROI Timeline |
|
||||
|------|--------------|-------------|--------------|--------------|
|
||||
| **Baseline** | $2,200 | $324 | $2,524 | 6-12 months |
|
||||
| **Premium** | $10,500 | $760 | $11,260 | 4-6 months |
|
||||
| **Enterprise** | $36,600 | $8,560 | $45,160 | 6-9 months |
|
||||
|
||||
**ROI Calculation:**
|
||||
- Energy savings from smart HVAC control: 20-30%
|
||||
- Reduced crop loss from environmental alerts: 10-20%
|
||||
- Increased yields from optimized environment: 10-20%
|
||||
- Labor savings from automation: 5-10 hours/week
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Recommendations
|
||||
|
||||
### For Initial Testing → **Start with Tier 1 ($2,200)**
|
||||
|
||||
**Why:**
|
||||
- Low risk investment
|
||||
- Quick deployment (1 weekend)
|
||||
- Proven consumer hardware
|
||||
- Validates system before scaling
|
||||
- Upgrade path to higher tiers
|
||||
|
||||
**Deployment Strategy:**
|
||||
1. Pilot 1 greenhouse + 1 container (Week 1-2)
|
||||
2. Test for 2-4 weeks (Week 3-6)
|
||||
3. Expand to all zones (Week 7-8)
|
||||
4. Total: 2 months to full deployment
|
||||
|
||||
### For Production → **Tier 2 ($10,500)**
|
||||
|
||||
**Why:**
|
||||
- Production-grade reliability
|
||||
- Camera surveillance (security + monitoring)
|
||||
- CO2 monitoring (yield optimization)
|
||||
- Professional soil sensors (Soil Scout)
|
||||
- Good balance of cost vs capability
|
||||
|
||||
**ROI:** 200-300% over 5 years
|
||||
|
||||
### For Commercial Scale → **Tier 3 ($36,600)**
|
||||
|
||||
**Why:**
|
||||
- Maximum uptime (99.99%)
|
||||
- High availability (automatic failover)
|
||||
- PAR light monitoring (professional cultivation)
|
||||
- Enterprise support and SLAs
|
||||
- 7-10 year lifespan
|
||||
|
||||
**ROI:** 400-600% over 5 years, 1000%+ over 10 years
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
### Documentation
|
||||
- **Full Report:** [VERIDIAN-HARDWARE-TIERS-REPORT.md](./VERIDIAN-HARDWARE-TIERS-REPORT.md)
|
||||
- **Veridian Docs:** https://docs.veridian.runfoo.run
|
||||
- **Home Assistant Docs:** https://www.home-assistant.io/docs
|
||||
|
||||
### Hardware Vendors
|
||||
- **SensorPush:** https://sensorpush.com
|
||||
- **Raspberry Pi:** https://www.raspberrypi.com
|
||||
- **Kasa Smart Plugs:** https://www.kasasmart.com
|
||||
- **Soil Scout:** https://soilscout.com
|
||||
- **RAK Wireless (LoRa):** https://store.rakwireless.com
|
||||
|
||||
### Community Support
|
||||
- **Home Assistant Community:** https://community.home-assistant.io
|
||||
- **Veridian Edge Repo:** https://git.runfoo.run/malty/veridian-edge
|
||||
- **GitHub Issues:** https://github.com/your-org/veridian/issues
|
||||
|
||||
---
|
||||
|
||||
## 🏷️ Tags (for Web Search / SEO)
|
||||
|
||||
`#Veridian #CultivationPlatform #GreenhouseAutomation #IoT #HomeAssistant #SensorPush #SoilMoistureSensors #SmartFarming #CannabisCultivation #EnvironmentalMonitoring #RaspberryPi #EdgeComputing #Zigbee #LoRaWAN #WirelessSensors #ClimateControl #AgricultureTechnology #AgTech #PrecisionAgriculture #GrowAutomation #HVACControl #CO2Monitoring #PARSensors #VPDMonitoring #SmartPlugs #Kasa #TPLink #DIYGreenhouse #ContainerFarming #DryingContainers #HomeAutomation #SmartHome #IndustrialIoT #IIoT #AgriculturalSensors #CapacitiveSoilSensors #Modbus #MQTT #ZHA #Zigbee2MQTT #SoilScout #SenseAir #ApogeeInstruments #PTZCameras #Surveillance #RemoteMonitoring #OfflineResilience #DataBuffering #CloudSync #HardwareTier #BillOfMaterials #BOM #DeploymentGuide #InstallationGuide #MaintenanceSchedule #Troubleshooting #ROI #CostAnalysis #2026Pricing`
|
||||
|
||||
---
|
||||
|
||||
## 📞 Contact & Support
|
||||
|
||||
**Veridian Platform:**
|
||||
- **Documentation:** https://docs.veridian.runfoo.run
|
||||
- **GitHub:** https://github.com/your-org/veridian
|
||||
- **GitLab:** https://git.runfoo.run/malty/veridian-edge
|
||||
- **Support:** support@veridian.runfoo.run
|
||||
|
||||
**Report Issues:**
|
||||
- **GitHub Issues:** https://github.com/your-org/veridian/issues
|
||||
- **Email:** support@veridian.runfoo.run
|
||||
|
||||
---
|
||||
|
||||
**Quick Reference Created:** January 6, 2026
|
||||
**Full Report:** [VERIDIAN-HARDWARE-TIERS-REPORT.md](./VERIDIAN-HARDWARE-TIERS-REPORT.md)
|
||||
**Version:** 1.0
|
||||
|
||||
2222
VERIDIAN-HARDWARE-TIERS-REPORT.md
Normal file
71
backend/package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
|||
"dependencies": {
|
||||
"@fastify/jwt": "^7.2.4",
|
||||
"@fastify/multipart": "^8.0.0",
|
||||
"@fastify/websocket": "^8.3.1",
|
||||
"@prisma/client": "^5.7.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
|
|
@ -23,6 +24,7 @@
|
|||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/node": "^20.10.0",
|
||||
"eslint": "^8.56.0",
|
||||
|
|
@ -701,6 +703,44 @@
|
|||
"integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/cors": {
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz",
|
||||
"integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"toad-cache": "^3.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/cors/node_modules/fastify-plugin": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
|
||||
"integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/deepmerge": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-2.0.2.tgz",
|
||||
|
|
@ -799,6 +839,16 @@
|
|||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/websocket": {
|
||||
"version": "8.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-8.3.1.tgz",
|
||||
"integrity": "sha512-hsQYHHJme/kvP3ZS4v/WMUznPBVeeQHHwAoMy1LiN6m/HuPfbdXq1MBJ4Nt8qX1YI+eVbog4MnOsU7MTozkwYA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fastify-plugin": "^4.0.0",
|
||||
"ws": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
|
||||
|
|
@ -6497,6 +6547,27 @@
|
|||
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"start": "npx prisma db push && node dist/server.js",
|
||||
"dev": "ts-node-dev --transpile-only src/server.ts",
|
||||
"test": "jest",
|
||||
"lint": "eslint src/**/*.ts",
|
||||
|
|
@ -18,6 +18,7 @@
|
|||
"dependencies": {
|
||||
"@fastify/jwt": "^7.2.4",
|
||||
"@fastify/multipart": "^8.0.0",
|
||||
"@fastify/websocket": "^8.3.1",
|
||||
"@prisma/client": "^5.7.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
|
|
@ -31,6 +32,7 @@
|
|||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/node": "^20.10.0",
|
||||
"eslint": "^8.56.0",
|
||||
|
|
@ -40,4 +42,4 @@
|
|||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,6 +97,9 @@ model User {
|
|||
documentsCreated Document[] @relation("DocumentCreator")
|
||||
documentsApproved Document[] @relation("DocumentApprover")
|
||||
|
||||
// Phase 7: Lifecycle
|
||||
lifecycleEvents PlantLifecycleEvent[] @relation("EventCreator")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
|
|
@ -510,6 +513,35 @@ enum SectionType {
|
|||
FLOOR
|
||||
}
|
||||
|
||||
// Plant Type Library (Rackula-inspired DeviceType pattern)
|
||||
enum PlantCategory {
|
||||
VEG
|
||||
FLOWER
|
||||
MOTHER
|
||||
CLONE
|
||||
SEEDLING
|
||||
}
|
||||
|
||||
model PlantType {
|
||||
id String @id @default(uuid())
|
||||
slug String @unique // kebab-case identifier (e.g., "gorilla-glue-4")
|
||||
name String // Display name
|
||||
strain String? // Strain name
|
||||
category PlantCategory // VEG, FLOWER, MOTHER, CLONE, SEEDLING
|
||||
colour String // Hex color for display (e.g., "#4A90D9")
|
||||
growthDays Int? // Expected days to harvest
|
||||
yieldGrams Float? // Expected yield in grams
|
||||
notes String?
|
||||
tags String[]
|
||||
customFields Json?
|
||||
plants FacilityPlant[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("plant_types")
|
||||
}
|
||||
|
||||
|
||||
model FacilityProperty {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
|
|
@ -568,6 +600,7 @@ model FacilityRoom {
|
|||
rotation Int @default(0)
|
||||
color String? // Custom color override
|
||||
sections FacilitySection[]
|
||||
cameras Camera[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
|
@ -616,28 +649,49 @@ model FacilityPlant {
|
|||
tagNumber String @unique // METRC tag
|
||||
batchId String?
|
||||
batch Batch? @relation(fields: [batchId], references: [id])
|
||||
plantTypeId String?
|
||||
plantType PlantType? @relation(fields: [plantTypeId], references: [id])
|
||||
position FacilityPosition @relation(fields: [positionId], references: [id])
|
||||
positionId String @unique
|
||||
address String // Full hierarchical address
|
||||
status String @default("ACTIVE") // ACTIVE, HARVESTED, DESTROYED, TRANSFERRED
|
||||
history PlantLocationHistory[]
|
||||
history PlantLifecycleEvent[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("facility_plants")
|
||||
}
|
||||
|
||||
model PlantLocationHistory {
|
||||
enum PlantLifecycleEventType {
|
||||
CREATED
|
||||
MOVE
|
||||
STAGE_CHANGE
|
||||
HARVEST
|
||||
DESTROY
|
||||
NOTE
|
||||
COMPLIANCE_CHECK
|
||||
}
|
||||
|
||||
model PlantLifecycleEvent {
|
||||
id String @id @default(uuid())
|
||||
plant FacilityPlant @relation(fields: [plantId], references: [id], onDelete: Cascade)
|
||||
plantId String
|
||||
fromAddress String?
|
||||
toAddress String
|
||||
movedById String // User ID
|
||||
reason String? // TRANSPLANT, REORGANIZE, HARVEST
|
||||
movedAt DateTime @default(now())
|
||||
|
||||
@@map("plant_location_history")
|
||||
type PlantLifecycleEventType @default(MOVE)
|
||||
|
||||
// Locations (optional, for MOVE events)
|
||||
fromAddress String?
|
||||
toAddress String?
|
||||
|
||||
// Metadata for other events (JSON)
|
||||
metadata Json? // { "reason": "...", "weight": 12.5, "wasteReason": "Mold" }
|
||||
|
||||
createdById String
|
||||
createdBy User @relation("EventCreator", fields: [createdById], references: [id])
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("plant_lifecycle_events")
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -988,6 +1042,49 @@ model EnvironmentProfile {
|
|||
@@map("environment_profiles")
|
||||
}
|
||||
|
||||
// ---------------------- Security Cameras ----------------------
|
||||
|
||||
enum CameraStatus {
|
||||
ONLINE
|
||||
OFFLINE
|
||||
IDLE // Showing idle image, waiting for motion
|
||||
STREAMING // Actively streaming
|
||||
ERROR
|
||||
}
|
||||
|
||||
model Camera {
|
||||
id String @id @default(uuid())
|
||||
name String // "Grow Room Entry"
|
||||
slug String @unique
|
||||
streamKey String // go2rtc stream name, e.g., "arlo_grow_room"
|
||||
location String? // "North wall entrance"
|
||||
roomId String?
|
||||
room FacilityRoom? @relation(fields: [roomId], references: [id])
|
||||
|
||||
// Camera metadata
|
||||
manufacturer String? // "Arlo", "Wyze", etc.
|
||||
model String? // "Pro 4"
|
||||
protocol String @default("RTSP") // RTSP, ONVIF, HLS
|
||||
|
||||
// Status
|
||||
isActive Boolean @default(true)
|
||||
status CameraStatus @default(OFFLINE)
|
||||
lastSeen DateTime?
|
||||
|
||||
// Positioning on floor plan (optional)
|
||||
posX Int?
|
||||
posY Int?
|
||||
rotation Int @default(0) // 0-360 degrees
|
||||
fov Int @default(90) // Field of view degrees
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([roomId])
|
||||
@@index([streamKey])
|
||||
@@map("cameras")
|
||||
}
|
||||
|
||||
// ---------------------- Financial Tracking ----------------------
|
||||
|
||||
enum TransactionType {
|
||||
|
|
|
|||
91
backend/prisma/seed-plant-types.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* Seed script for common cannabis plant types
|
||||
* Run with: npx ts-node prisma/seed-plant-types.ts
|
||||
*/
|
||||
|
||||
import { PrismaClient, PlantCategory } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Common cannabis strains pre-populated for user convenience
|
||||
const COMMON_STRAINS = [
|
||||
// Indica-dominant
|
||||
{ name: 'Gorilla Glue #4', strain: 'GG4', category: 'FLOWER' as PlantCategory, colour: '#4A5568', growthDays: 63, yieldGrams: 500, tags: ['indica', 'high-thc', 'relaxing'] },
|
||||
{ name: 'Northern Lights', strain: 'NL', category: 'FLOWER' as PlantCategory, colour: '#5B21B6', growthDays: 56, yieldGrams: 450, tags: ['indica', 'classic', 'sleep'] },
|
||||
{ name: 'Granddaddy Purple', strain: 'GDP', category: 'FLOWER' as PlantCategory, colour: '#7C3AED', growthDays: 60, yieldGrams: 400, tags: ['indica', 'purple', 'grape'] },
|
||||
{ name: 'Purple Punch', strain: 'PP', category: 'FLOWER' as PlantCategory, colour: '#8B5CF6', growthDays: 56, yieldGrams: 450, tags: ['indica', 'dessert', 'relaxing'] },
|
||||
|
||||
// Sativa-dominant
|
||||
{ name: 'Sour Diesel', strain: 'SD', category: 'FLOWER' as PlantCategory, colour: '#10B981', growthDays: 70, yieldGrams: 550, tags: ['sativa', 'energizing', 'diesel'] },
|
||||
{ name: 'Jack Herer', strain: 'JH', category: 'FLOWER' as PlantCategory, colour: '#059669', growthDays: 63, yieldGrams: 400, tags: ['sativa', 'creative', 'pine'] },
|
||||
{ name: 'Green Crack', strain: 'GC', category: 'FLOWER' as PlantCategory, colour: '#34D399', growthDays: 55, yieldGrams: 500, tags: ['sativa', 'energy', 'mango'] },
|
||||
{ name: 'Super Silver Haze', strain: 'SSH', category: 'FLOWER' as PlantCategory, colour: '#6EE7B7', growthDays: 77, yieldGrams: 600, tags: ['sativa', 'award-winner', 'citrus'] },
|
||||
|
||||
// Hybrids
|
||||
{ name: 'Blue Dream', strain: 'BD', category: 'FLOWER' as PlantCategory, colour: '#3B82F6', growthDays: 65, yieldGrams: 550, tags: ['hybrid', 'balanced', 'berry'] },
|
||||
{ name: 'Wedding Cake', strain: 'WC', category: 'FLOWER' as PlantCategory, colour: '#F472B6', growthDays: 60, yieldGrams: 500, tags: ['hybrid', 'dessert', 'high-thc'] },
|
||||
{ name: 'Gelato', strain: 'GLT', category: 'FLOWER' as PlantCategory, colour: '#EC4899', growthDays: 56, yieldGrams: 450, tags: ['hybrid', 'dessert', 'fruity'] },
|
||||
{ name: 'OG Kush', strain: 'OGK', category: 'FLOWER' as PlantCategory, colour: '#84CC16', growthDays: 56, yieldGrams: 400, tags: ['hybrid', 'classic', 'earthy'] },
|
||||
{ name: 'Girl Scout Cookies', strain: 'GSC', category: 'FLOWER' as PlantCategory, colour: '#22C55E', growthDays: 63, yieldGrams: 350, tags: ['hybrid', 'dessert', 'mint'] },
|
||||
{ name: 'White Widow', strain: 'WW', category: 'FLOWER' as PlantCategory, colour: '#E5E7EB', growthDays: 60, yieldGrams: 450, tags: ['hybrid', 'classic', 'potent'] },
|
||||
|
||||
// Mother plants (common keeper phenotypes)
|
||||
{ name: 'Mother - GG4 Elite', strain: 'GG4', category: 'MOTHER' as PlantCategory, colour: '#F59E0B', tags: ['mother', 'keeper', 'high-yield'] },
|
||||
{ name: 'Mother - OG Kush S1', strain: 'OGK', category: 'MOTHER' as PlantCategory, colour: '#F97316', tags: ['mother', 'clone-source'] },
|
||||
|
||||
// Veg stage templates
|
||||
{ name: 'Veg - Standard', strain: undefined, category: 'VEG' as PlantCategory, colour: '#10B981', growthDays: 28, tags: ['veg', 'standard'] },
|
||||
{ name: 'Veg - Extended', strain: undefined, category: 'VEG' as PlantCategory, colour: '#059669', growthDays: 42, tags: ['veg', 'extended', 'large-plants'] },
|
||||
|
||||
// Clone templates
|
||||
{ name: 'Clone - Rooting', strain: undefined, category: 'CLONE' as PlantCategory, colour: '#06B6D4', growthDays: 14, tags: ['clone', 'rooting'] },
|
||||
{ name: 'Clone - Transplant Ready', strain: undefined, category: 'CLONE' as PlantCategory, colour: '#0891B2', growthDays: 21, tags: ['clone', 'ready'] },
|
||||
|
||||
// Seedling
|
||||
{ name: 'Seedling - Week 1-2', strain: undefined, category: 'SEEDLING' as PlantCategory, colour: '#A3E635', growthDays: 14, tags: ['seedling', 'early'] },
|
||||
];
|
||||
|
||||
function generateSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-+/g, '-');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🌱 Seeding plant types...');
|
||||
|
||||
for (const strain of COMMON_STRAINS) {
|
||||
const slug = generateSlug(strain.name);
|
||||
|
||||
// Upsert to avoid duplicates
|
||||
await prisma.plantType.upsert({
|
||||
where: { slug },
|
||||
update: {},
|
||||
create: {
|
||||
slug,
|
||||
name: strain.name,
|
||||
strain: strain.strain,
|
||||
category: strain.category,
|
||||
colour: strain.colour,
|
||||
growthDays: strain.growthDays,
|
||||
yieldGrams: strain.yieldGrams,
|
||||
tags: strain.tags || [],
|
||||
},
|
||||
});
|
||||
|
||||
console.log(` ✓ ${strain.name} (${slug})`);
|
||||
}
|
||||
|
||||
console.log(`\n✅ Seeded ${COMMON_STRAINS.length} plant types`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ Seed failed:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
254
backend/src/controllers/cameras.controller.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { CameraStatus } from '@prisma/client';
|
||||
|
||||
// Utility to generate slug from name
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
}
|
||||
|
||||
// GET /api/cameras - List all cameras
|
||||
export const getCameras = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const cameras = await request.server.prisma.camera.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
include: {
|
||||
room: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
code: true,
|
||||
type: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return cameras;
|
||||
};
|
||||
|
||||
// GET /api/cameras/:id - Get single camera
|
||||
export const getCameraById = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
|
||||
const camera = await request.server.prisma.camera.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
room: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
code: true,
|
||||
type: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!camera) {
|
||||
return reply.status(404).send({ message: 'Camera not found' });
|
||||
}
|
||||
|
||||
return camera;
|
||||
};
|
||||
|
||||
// GET /api/cameras/:id/stream - Get stream URL for go2rtc
|
||||
export const getCameraStream = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
|
||||
const camera = await request.server.prisma.camera.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (!camera) {
|
||||
return reply.status(404).send({ message: 'Camera not found' });
|
||||
}
|
||||
|
||||
if (!camera.isActive) {
|
||||
return reply.status(503).send({ message: 'Camera is disabled' });
|
||||
}
|
||||
|
||||
// Return go2rtc stream endpoints
|
||||
const go2rtcBase = process.env.GO2RTC_URL || 'http://go2rtc:1984';
|
||||
|
||||
return {
|
||||
camera: {
|
||||
id: camera.id,
|
||||
name: camera.name,
|
||||
streamKey: camera.streamKey,
|
||||
status: camera.status
|
||||
},
|
||||
streams: {
|
||||
// go2rtc provides these endpoints automatically
|
||||
webrtc: `${go2rtcBase}/api/ws?src=${camera.streamKey}`,
|
||||
mse: `${go2rtcBase}/api/stream.mp4?src=${camera.streamKey}`,
|
||||
hls: `${go2rtcBase}/api/stream.m3u8?src=${camera.streamKey}`,
|
||||
snapshot: `${go2rtcBase}/api/frame.jpeg?src=${camera.streamKey}`
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// POST /api/cameras - Create new camera
|
||||
export const createCamera = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const {
|
||||
name,
|
||||
streamKey,
|
||||
location,
|
||||
roomId,
|
||||
manufacturer,
|
||||
model,
|
||||
protocol,
|
||||
posX,
|
||||
posY,
|
||||
rotation,
|
||||
fov
|
||||
} = request.body as any;
|
||||
|
||||
if (!name || !streamKey) {
|
||||
return reply.status(400).send({ message: 'Name and streamKey are required' });
|
||||
}
|
||||
|
||||
const slug = slugify(name);
|
||||
|
||||
// Check for duplicate slug
|
||||
const existing = await request.server.prisma.camera.findUnique({
|
||||
where: { slug }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return reply.status(409).send({ message: 'Camera with this name already exists' });
|
||||
}
|
||||
|
||||
const camera = await request.server.prisma.camera.create({
|
||||
data: {
|
||||
name,
|
||||
slug,
|
||||
streamKey,
|
||||
location,
|
||||
roomId: roomId || null,
|
||||
manufacturer,
|
||||
model,
|
||||
protocol: protocol || 'RTSP',
|
||||
posX: posX ? parseInt(posX) : null,
|
||||
posY: posY ? parseInt(posY) : null,
|
||||
rotation: rotation ? parseInt(rotation) : 0,
|
||||
fov: fov ? parseInt(fov) : 90,
|
||||
status: CameraStatus.OFFLINE
|
||||
},
|
||||
include: {
|
||||
room: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
code: true,
|
||||
type: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return reply.status(201).send(camera);
|
||||
};
|
||||
|
||||
// PUT /api/cameras/:id - Update camera
|
||||
export const updateCamera = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const {
|
||||
name,
|
||||
streamKey,
|
||||
location,
|
||||
roomId,
|
||||
manufacturer,
|
||||
model,
|
||||
protocol,
|
||||
isActive,
|
||||
posX,
|
||||
posY,
|
||||
rotation,
|
||||
fov
|
||||
} = request.body as any;
|
||||
|
||||
const existing = await request.server.prisma.camera.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return reply.status(404).send({ message: 'Camera not found' });
|
||||
}
|
||||
|
||||
const data: any = {};
|
||||
|
||||
if (name !== undefined) {
|
||||
data.name = name;
|
||||
data.slug = slugify(name);
|
||||
}
|
||||
if (streamKey !== undefined) data.streamKey = streamKey;
|
||||
if (location !== undefined) data.location = location;
|
||||
if (roomId !== undefined) data.roomId = roomId || null;
|
||||
if (manufacturer !== undefined) data.manufacturer = manufacturer;
|
||||
if (model !== undefined) data.model = model;
|
||||
if (protocol !== undefined) data.protocol = protocol;
|
||||
if (isActive !== undefined) data.isActive = isActive;
|
||||
if (posX !== undefined) data.posX = parseInt(posX);
|
||||
if (posY !== undefined) data.posY = parseInt(posY);
|
||||
if (rotation !== undefined) data.rotation = parseInt(rotation);
|
||||
if (fov !== undefined) data.fov = parseInt(fov);
|
||||
|
||||
const camera = await request.server.prisma.camera.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
room: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
code: true,
|
||||
type: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return camera;
|
||||
};
|
||||
|
||||
// DELETE /api/cameras/:id - Delete camera
|
||||
export const deleteCamera = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
|
||||
const existing = await request.server.prisma.camera.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
return reply.status(404).send({ message: 'Camera not found' });
|
||||
}
|
||||
|
||||
await request.server.prisma.camera.delete({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
return reply.status(204).send();
|
||||
};
|
||||
|
||||
// PATCH /api/cameras/:id/status - Update camera status (called by system/integrations)
|
||||
export const updateCameraStatus = async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const { status } = request.body as { status: CameraStatus };
|
||||
|
||||
if (!status || !Object.values(CameraStatus).includes(status)) {
|
||||
return reply.status(400).send({
|
||||
message: 'Invalid status. Must be one of: ONLINE, OFFLINE, IDLE, STREAMING, ERROR'
|
||||
});
|
||||
}
|
||||
|
||||
const camera = await request.server.prisma.camera.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status,
|
||||
lastSeen: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
return camera;
|
||||
};
|
||||
|
|
@ -8,7 +8,7 @@ export const getTasks = async (request: FastifyRequest, reply: FastifyReply) =>
|
|||
const tasks = await request.server.prisma.task.findMany({
|
||||
where: {
|
||||
...(status && { status }),
|
||||
...(assigneeId && { assigneeId }),
|
||||
...(assigneeId && { assignedToId: assigneeId }),
|
||||
...(roomId && { roomId }),
|
||||
...(batchId && { batchId }),
|
||||
...(startDate && endDate && {
|
||||
|
|
|
|||
117
backend/src/plugins/websocket.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* WebSocket Plugin for Real-time Alerts
|
||||
*
|
||||
* Broadcasts environment alerts to connected clients.
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import websocket from '@fastify/websocket';
|
||||
|
||||
interface AlertMessage {
|
||||
type: 'ALERT' | 'READING' | 'HEARTBEAT';
|
||||
data: any;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Connected clients (storing the raw WebSocket)
|
||||
const clients: Map<string, any> = new Map();
|
||||
|
||||
export async function websocketPlugin(fastify: FastifyInstance) {
|
||||
await fastify.register(websocket);
|
||||
|
||||
/**
|
||||
* WebSocket endpoint for real-time alerts
|
||||
*/
|
||||
fastify.get('/api/ws/alerts', { websocket: true }, (connection, request) => {
|
||||
const clientId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
// Get the raw WebSocket from the SocketStream
|
||||
const socket = connection.socket;
|
||||
|
||||
clients.set(clientId, socket);
|
||||
fastify.log.info(`WebSocket client connected: ${clientId}`);
|
||||
|
||||
// Send welcome message
|
||||
socket.send(JSON.stringify({
|
||||
type: 'CONNECTED',
|
||||
clientId,
|
||||
timestamp: new Date().toISOString()
|
||||
}));
|
||||
|
||||
socket.on('message', (message: Buffer) => {
|
||||
try {
|
||||
const data = JSON.parse(message.toString());
|
||||
|
||||
// Handle ping/pong for keepalive
|
||||
if (data.type === 'PING') {
|
||||
socket.send(JSON.stringify({ type: 'PONG', timestamp: new Date().toISOString() }));
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid messages
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
clients.delete(clientId);
|
||||
fastify.log.info(`WebSocket client disconnected: ${clientId}`);
|
||||
});
|
||||
|
||||
socket.on('error', (error: Error) => {
|
||||
fastify.log.error(`WebSocket error for ${clientId}: ${error.message}`);
|
||||
clients.delete(clientId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast an alert to all connected clients
|
||||
*/
|
||||
export function broadcastAlert(alert: any): void {
|
||||
const message: AlertMessage = {
|
||||
type: 'ALERT',
|
||||
data: alert,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
const payload = JSON.stringify(message);
|
||||
|
||||
clients.forEach((socket, clientId) => {
|
||||
try {
|
||||
if (socket.readyState === 1) { // OPEN
|
||||
socket.send(payload);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to broadcast to ${clientId}:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast a sensor reading update
|
||||
*/
|
||||
export function broadcastReading(reading: any): void {
|
||||
const message: AlertMessage = {
|
||||
type: 'READING',
|
||||
data: reading,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
const payload = JSON.stringify(message);
|
||||
|
||||
clients.forEach((socket, clientId) => {
|
||||
try {
|
||||
if (socket.readyState === 1) {
|
||||
socket.send(payload);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to broadcast reading to ${clientId}:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of connected clients
|
||||
*/
|
||||
export function getConnectedClientCount(): number {
|
||||
return clients.size;
|
||||
}
|
||||
30
backend/src/routes/cameras.routes.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { FastifyInstance } from 'fastify';
|
||||
import {
|
||||
getCameras,
|
||||
getCameraById,
|
||||
getCameraStream,
|
||||
createCamera,
|
||||
updateCamera,
|
||||
deleteCamera,
|
||||
updateCameraStatus
|
||||
} from '../controllers/cameras.controller';
|
||||
|
||||
export async function cameraRoutes(server: FastifyInstance) {
|
||||
// Auth required for all routes
|
||||
server.addHook('onRequest', async (request) => {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
// CRUD Routes
|
||||
server.get('/', getCameras);
|
||||
server.get('/:id', getCameraById);
|
||||
server.get('/:id/stream', getCameraStream);
|
||||
server.post('/', createCamera);
|
||||
server.put('/:id', updateCamera);
|
||||
server.delete('/:id', deleteCamera);
|
||||
server.patch('/:id/status', updateCameraStatus);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { FastifyInstance } from 'fastify';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
import { broadcastAlert } from '../plugins/websocket';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
|
|
@ -39,8 +40,15 @@ const profileSchema = z.object({
|
|||
});
|
||||
|
||||
export async function environmentRoutes(fastify: FastifyInstance) {
|
||||
// Auth middleware
|
||||
fastify.addHook('onRequest', async (request) => {
|
||||
// Auth middleware - skip for edge device endpoints (they use API key auth)
|
||||
const edgeEndpoints = ['/heartbeat', '/ingest'];
|
||||
fastify.addHook('onRequest', async (request, reply) => {
|
||||
// Skip JWT for edge device endpoints
|
||||
const path = request.url.split('?')[0];
|
||||
if (edgeEndpoints.some(ep => path.endsWith(ep))) {
|
||||
return; // Edge endpoints handle their own auth
|
||||
}
|
||||
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch (err) {
|
||||
|
|
@ -295,6 +303,158 @@ export async function environmentRoutes(fastify: FastifyInstance) {
|
|||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /alerts/resolve-all
|
||||
* Resolve all unresolved alerts (for demo reset)
|
||||
*/
|
||||
fastify.post('/alerts/resolve-all', {
|
||||
handler: async (request, reply) => {
|
||||
try {
|
||||
const result = await prisma.environmentAlert.updateMany({
|
||||
where: { resolvedAt: null },
|
||||
data: { resolvedAt: new Date() }
|
||||
});
|
||||
|
||||
fastify.log.info(`🔄 Resolved ${result.count} pending alerts`);
|
||||
return { resolved: result.count, message: `Resolved ${result.count} alerts` };
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({ error: 'Failed to resolve alerts' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /alerts/analytics
|
||||
* Get alert analytics with response times
|
||||
*/
|
||||
fastify.get('/alerts/analytics', {
|
||||
handler: async (request, reply) => {
|
||||
try {
|
||||
const { hours = 168 } = request.query as { hours?: number }; // Default 7 days
|
||||
const since = new Date();
|
||||
since.setHours(since.getHours() - Number(hours));
|
||||
|
||||
const alerts = await prisma.environmentAlert.findMany({
|
||||
where: {
|
||||
createdAt: { gte: since }
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
// Calculate metrics
|
||||
const totalAlerts = alerts.length;
|
||||
const resolvedAlerts = alerts.filter(a => a.resolvedAt);
|
||||
const acknowledgedAlerts = alerts.filter(a => a.acknowledgedAt);
|
||||
const unresolvedAlerts = alerts.filter(a => !a.resolvedAt);
|
||||
|
||||
// Response times (time from creation to acknowledgement)
|
||||
const responseTimes = acknowledgedAlerts.map(a => {
|
||||
const created = new Date(a.createdAt).getTime();
|
||||
const acked = new Date(a.acknowledgedAt!).getTime();
|
||||
return (acked - created) / 1000 / 60; // minutes
|
||||
});
|
||||
|
||||
// Resolution times (time from creation to resolution)
|
||||
const resolutionTimes = resolvedAlerts.map(a => {
|
||||
const created = new Date(a.createdAt).getTime();
|
||||
const resolved = new Date(a.resolvedAt!).getTime();
|
||||
return (resolved - created) / 1000 / 60; // minutes
|
||||
});
|
||||
|
||||
// Calculate averages
|
||||
const avgResponseTime = responseTimes.length > 0
|
||||
? responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length
|
||||
: null;
|
||||
|
||||
const avgResolutionTime = resolutionTimes.length > 0
|
||||
? resolutionTimes.reduce((a, b) => a + b, 0) / resolutionTimes.length
|
||||
: null;
|
||||
|
||||
const minResponseTime = responseTimes.length > 0 ? Math.min(...responseTimes) : null;
|
||||
const maxResponseTime = responseTimes.length > 0 ? Math.max(...responseTimes) : null;
|
||||
const minResolutionTime = resolutionTimes.length > 0 ? Math.min(...resolutionTimes) : null;
|
||||
const maxResolutionTime = resolutionTimes.length > 0 ? Math.max(...resolutionTimes) : null;
|
||||
|
||||
// Group by type
|
||||
const byType: Record<string, { count: number; resolved: number; avgResolution: number | null }> = {};
|
||||
alerts.forEach(a => {
|
||||
if (!byType[a.type]) {
|
||||
byType[a.type] = { count: 0, resolved: 0, avgResolution: null };
|
||||
}
|
||||
byType[a.type].count++;
|
||||
if (a.resolvedAt) byType[a.type].resolved++;
|
||||
});
|
||||
|
||||
// Calculate avg resolution per type
|
||||
Object.keys(byType).forEach(type => {
|
||||
const typeAlerts = resolvedAlerts.filter(a => a.type === type);
|
||||
if (typeAlerts.length > 0) {
|
||||
const times = typeAlerts.map(a => {
|
||||
const created = new Date(a.createdAt).getTime();
|
||||
const resolved = new Date(a.resolvedAt!).getTime();
|
||||
return (resolved - created) / 1000 / 60;
|
||||
});
|
||||
byType[type].avgResolution = times.reduce((a, b) => a + b, 0) / times.length;
|
||||
}
|
||||
});
|
||||
|
||||
// Recent alerts for timeline
|
||||
const recentAlerts = alerts.slice(0, 10).map(a => ({
|
||||
id: a.id,
|
||||
type: a.type,
|
||||
severity: a.severity,
|
||||
message: a.message,
|
||||
value: a.value,
|
||||
threshold: a.threshold,
|
||||
createdAt: a.createdAt,
|
||||
acknowledgedAt: a.acknowledgedAt,
|
||||
resolvedAt: a.resolvedAt,
|
||||
responseTimeMin: a.acknowledgedAt
|
||||
? (new Date(a.acknowledgedAt).getTime() - new Date(a.createdAt).getTime()) / 1000 / 60
|
||||
: null,
|
||||
resolutionTimeMin: a.resolvedAt
|
||||
? (new Date(a.resolvedAt).getTime() - new Date(a.createdAt).getTime()) / 1000 / 60
|
||||
: null
|
||||
}));
|
||||
|
||||
return {
|
||||
period: {
|
||||
hours: Number(hours),
|
||||
from: since.toISOString(),
|
||||
to: new Date().toISOString()
|
||||
},
|
||||
summary: {
|
||||
total: totalAlerts,
|
||||
resolved: resolvedAlerts.length,
|
||||
acknowledged: acknowledgedAlerts.length,
|
||||
unresolved: unresolvedAlerts.length,
|
||||
resolutionRate: totalAlerts > 0 ? (resolvedAlerts.length / totalAlerts * 100).toFixed(1) : 0
|
||||
},
|
||||
responseTimes: {
|
||||
avgMinutes: avgResponseTime?.toFixed(1) || null,
|
||||
minMinutes: minResponseTime?.toFixed(1) || null,
|
||||
maxMinutes: maxResponseTime?.toFixed(1) || null
|
||||
},
|
||||
resolutionTimes: {
|
||||
avgMinutes: avgResolutionTime?.toFixed(1) || null,
|
||||
minMinutes: minResolutionTime?.toFixed(1) || null,
|
||||
maxMinutes: maxResolutionTime?.toFixed(1) || null
|
||||
},
|
||||
byType: Object.entries(byType).map(([type, data]) => ({
|
||||
type,
|
||||
...data,
|
||||
avgResolutionMin: data.avgResolution?.toFixed(1) || null
|
||||
})),
|
||||
recentAlerts
|
||||
};
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({ error: 'Failed to fetch alert analytics' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== ENVIRONMENT PROFILES ====================
|
||||
|
||||
/**
|
||||
|
|
@ -415,4 +575,203 @@ export async function environmentRoutes(fastify: FastifyInstance) {
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== EDGE DEVICE ENDPOINTS ====================
|
||||
|
||||
/**
|
||||
* POST /ingest
|
||||
* Batch ingest readings from edge device
|
||||
* This endpoint accepts API key auth (not JWT) for edge devices
|
||||
*/
|
||||
fastify.post('/ingest', {
|
||||
config: { rawBody: true },
|
||||
preHandler: async (request, reply) => {
|
||||
// Allow API key auth for edge devices
|
||||
const authHeader = request.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const apiKey = authHeader.substring(7);
|
||||
// TODO: Validate API key against facility configuration
|
||||
// For now, accept any non-empty key
|
||||
if (!apiKey) {
|
||||
return reply.status(401).send({ error: 'Invalid API key' });
|
||||
}
|
||||
}
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
try {
|
||||
const { facilityId, readings } = request.body as {
|
||||
facilityId?: string;
|
||||
readings: Array<{
|
||||
roomId: string;
|
||||
sensorId?: string;
|
||||
temperature: number;
|
||||
humidity: number;
|
||||
dewpoint?: number;
|
||||
vpd?: number;
|
||||
timestamp: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
if (!readings || !Array.isArray(readings) || readings.length === 0) {
|
||||
return reply.status(400).send({ error: 'No readings provided' });
|
||||
}
|
||||
|
||||
// Store readings in batch
|
||||
const created = await prisma.$transaction(
|
||||
readings.map(r =>
|
||||
prisma.sensorReading.create({
|
||||
data: {
|
||||
sensorId: r.sensorId || 'edge-default',
|
||||
value: r.temperature, // Primary value is temperature
|
||||
unit: '°F',
|
||||
timestamp: new Date(r.timestamp)
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
fastify.log.info(`Ingested ${created.length} readings from edge device`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: created.length,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({ error: 'Failed to ingest readings' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /heartbeat
|
||||
* Edge device heartbeat for monitoring
|
||||
*/
|
||||
fastify.post('/heartbeat', {
|
||||
preHandler: async (request, reply) => {
|
||||
// For demo: Accept any Bearer token (no validation)
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return reply.status(401).send({ error: 'API key required' });
|
||||
}
|
||||
// TODO: In production, validate API key against registered edge devices
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
try {
|
||||
const data = request.body as {
|
||||
facilityId: string;
|
||||
edgeId: string;
|
||||
status: 'ok' | 'degraded' | 'error';
|
||||
sensorCount: number;
|
||||
bufferSize: number;
|
||||
lastReading?: string;
|
||||
uptime: number;
|
||||
};
|
||||
|
||||
// Log heartbeat (could store in Redis for real-time monitoring)
|
||||
fastify.log.info({
|
||||
event: 'edge_heartbeat',
|
||||
...data,
|
||||
receivedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
// TODO: Store in Redis or create alert if status is not 'ok'
|
||||
// For now, just acknowledge
|
||||
if (data.status !== 'ok') {
|
||||
fastify.log.warn(`Edge device ${data.edgeId} status: ${data.status}`);
|
||||
}
|
||||
|
||||
// Check for unaddressed critical temperature alerts (> 30 seconds for DEMO)
|
||||
const thirtySecondsAgo = new Date(Date.now() - 30 * 1000);
|
||||
const criticalAlert = await prisma.environmentAlert.findFirst({
|
||||
where: {
|
||||
type: 'TEMPERATURE_HIGH',
|
||||
resolvedAt: null,
|
||||
acknowledgedAt: null,
|
||||
createdAt: { lt: thirtySecondsAgo }
|
||||
}
|
||||
});
|
||||
|
||||
const commands: any[] = [];
|
||||
if (criticalAlert) {
|
||||
fastify.log.warn(`🚨 Failsafe triggered for alert ${criticalAlert.id}! Sending Kasa ON command to edge.`);
|
||||
commands.push({ type: 'toggle_plug', state: true });
|
||||
}
|
||||
|
||||
return {
|
||||
ack: true,
|
||||
serverTime: new Date().toISOString(),
|
||||
commands
|
||||
};
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({ error: 'Failed to process heartbeat' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /alert
|
||||
* Edge device alert relay (for notification fan-out)
|
||||
*/
|
||||
fastify.post('/alert', {
|
||||
preHandler: async (request, reply) => {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return reply.status(401).send({ error: 'API key required' });
|
||||
}
|
||||
},
|
||||
handler: async (request, reply) => {
|
||||
try {
|
||||
const data = request.body as {
|
||||
facilityId: string;
|
||||
edgeId: string;
|
||||
alertType: string;
|
||||
sensorId: string;
|
||||
sensorName: string;
|
||||
currentValue: number;
|
||||
threshold: number;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
// Create alert in database
|
||||
const alert = await prisma.environmentAlert.create({
|
||||
data: {
|
||||
type: data.alertType,
|
||||
severity: data.alertType.includes('HIGH') ? 'WARNING' : 'INFO',
|
||||
message: `${data.sensorName}: ${data.alertType.replace('_', ' ')} (${data.currentValue} vs threshold ${data.threshold})`,
|
||||
value: data.currentValue,
|
||||
threshold: data.threshold
|
||||
}
|
||||
});
|
||||
|
||||
fastify.log.warn({
|
||||
event: 'edge_alert',
|
||||
alertId: alert.id,
|
||||
...data
|
||||
});
|
||||
|
||||
// Broadcast to WebSocket clients for real-time notifications
|
||||
broadcastAlert({
|
||||
id: alert.id,
|
||||
type: data.alertType,
|
||||
sensorName: data.sensorName,
|
||||
value: data.currentValue,
|
||||
threshold: data.threshold,
|
||||
message: alert.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
alertId: alert.id,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({ error: 'Failed to process alert' });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -301,7 +301,121 @@ export async function layoutRoutes(fastify: FastifyInstance, options: FastifyPlu
|
|||
// ROOM ROUTES
|
||||
// ========================================
|
||||
|
||||
// Create room
|
||||
// Generate Room with Layout (Parametric)
|
||||
fastify.post('/rooms/generate', {
|
||||
handler: async (request, reply) => {
|
||||
try {
|
||||
const {
|
||||
floorId, name, code, type,
|
||||
setupType, // RACK, TABLE
|
||||
tiers, // 1-5
|
||||
racksCount, // Total number of racks
|
||||
rowsPerRack,
|
||||
colsPerRack
|
||||
} = request.body as any;
|
||||
|
||||
const numTiers = tiers || 1;
|
||||
const numRacks = racksCount || 1;
|
||||
const numRows = rowsPerRack || 4;
|
||||
const numCols = colsPerRack || 2;
|
||||
|
||||
// Standard dimensions (approx 4x8 table scaled)
|
||||
const rackWidth = 40;
|
||||
const rackHeight = 80;
|
||||
const aisleWidth = 30;
|
||||
const padding = 20;
|
||||
|
||||
// Calculate room size approx
|
||||
// Simple layout: 2 rows of racks if possible, else 1 long row
|
||||
const racksPerRow = Math.ceil(numRacks / 2);
|
||||
const numRackRows = Math.ceil(numRacks / racksPerRow);
|
||||
|
||||
const roomWidth = (padding * 2) + (racksPerRow * rackWidth) + ((racksPerRow - 1) * aisleWidth);
|
||||
const roomHeight = (padding * 2) + (numRackRows * rackHeight) + ((numRackRows - 1) * aisleWidth);
|
||||
|
||||
// Create the Room
|
||||
const room = await prisma.facilityRoom.create({
|
||||
data: {
|
||||
floorId,
|
||||
name,
|
||||
code,
|
||||
type: type as RoomType,
|
||||
posX: 0, // Default to 0,0 - user can move
|
||||
posY: 0,
|
||||
width: Math.max(roomWidth, 100),
|
||||
height: Math.max(roomHeight, 100),
|
||||
color: type === 'FLOWER' ? '#10b981' : '#3b82f6'
|
||||
}
|
||||
});
|
||||
|
||||
// Generate Racks (Sections)
|
||||
const sectionsToCreate = [];
|
||||
let rackCounter = 1;
|
||||
|
||||
for (let r = 0; r < numRackRows; r++) {
|
||||
for (let c = 0; c < racksPerRow; c++) {
|
||||
if (rackCounter > numRacks) break;
|
||||
|
||||
const sPosX = padding + (c * (rackWidth + aisleWidth));
|
||||
const sPosY = padding + (r * (rackHeight + aisleWidth));
|
||||
|
||||
// Generate positions for this rack
|
||||
const positionsToCreate = [];
|
||||
for (let row = 1; row <= numRows; row++) {
|
||||
for (let col = 1; col <= numCols; col++) {
|
||||
for (let t = 1; t <= numTiers; t++) {
|
||||
positionsToCreate.push({
|
||||
row,
|
||||
column: col,
|
||||
tier: t,
|
||||
slot: 1,
|
||||
status: 'EMPTY'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sectionsToCreate.push(prisma.facilitySection.create({
|
||||
data: {
|
||||
roomId: room.id,
|
||||
name: `${setupType} ${rackCounter}`,
|
||||
code: `${code}-R${rackCounter}`,
|
||||
type: (setupType || 'RACK') as SectionType,
|
||||
posX: sPosX,
|
||||
posY: sPosY,
|
||||
width: rackWidth,
|
||||
height: rackHeight,
|
||||
rows: numRows,
|
||||
columns: numCols,
|
||||
tiers: numTiers,
|
||||
spacing: 12,
|
||||
positions: {
|
||||
create: positionsToCreate
|
||||
}
|
||||
}
|
||||
}));
|
||||
rackCounter++;
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction(sectionsToCreate);
|
||||
|
||||
// Fetch full room to return
|
||||
const fullRoom = await prisma.facilityRoom.findUnique({
|
||||
where: { id: room.id },
|
||||
include: { sections: true }
|
||||
});
|
||||
|
||||
return reply.status(201).send(fullRoom);
|
||||
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({ error: 'Failed to generate room layout' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
fastify.post('/rooms', {
|
||||
handler: async (request, reply) => {
|
||||
try {
|
||||
|
|
@ -393,12 +507,91 @@ export async function layoutRoutes(fastify: FastifyInstance, options: FastifyPlu
|
|||
}
|
||||
});
|
||||
|
||||
// Update Section
|
||||
fastify.patch('/sections/:id', {
|
||||
handler: async (request, reply) => {
|
||||
try {
|
||||
const { id } = request.params as any;
|
||||
const { name, code, type, rows, columns } = request.body as any;
|
||||
|
||||
// Get current section
|
||||
const section = await prisma.facilitySection.findUnique({
|
||||
where: { id },
|
||||
include: { positions: true }
|
||||
});
|
||||
|
||||
if (!section) return reply.status(404).send({ error: 'Section not found' });
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Update section props
|
||||
await tx.facilitySection.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
code,
|
||||
type: type as SectionType,
|
||||
rows,
|
||||
columns
|
||||
}
|
||||
});
|
||||
|
||||
// If dimensions changed, handle positions
|
||||
if (rows !== section.rows || columns !== section.columns) {
|
||||
// 1. Remove positions out of bounds
|
||||
// Note: This will cascade delete plants in those positions if foreign keys are set up that way,
|
||||
// or it might fail if plants exist. Ideally we check, but for now assuming force resize.
|
||||
await tx.facilityPosition.deleteMany({
|
||||
where: {
|
||||
sectionId: id,
|
||||
OR: [
|
||||
{ row: { gt: rows } },
|
||||
{ column: { gt: columns } }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Add new positions
|
||||
const newPositions: any[] = [];
|
||||
for (let r = 1; r <= rows; r++) {
|
||||
for (let c = 1; c <= columns; c++) {
|
||||
// Check if position exists in the snapshot we loaded
|
||||
// We check simply by coordinates as we removed out of bounds ones
|
||||
const exists = section.positions.some(p => p.row === r && p.column === c);
|
||||
if (!exists) {
|
||||
newPositions.push({
|
||||
sectionId: id,
|
||||
row: r,
|
||||
column: c,
|
||||
tier: 1, // Defaulting to single tier for resizing logic cleanliness
|
||||
slot: 1,
|
||||
status: 'EMPTY'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newPositions.length > 0) {
|
||||
await tx.facilityPosition.createMany({
|
||||
data: newPositions
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({ error: 'Failed to update section' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Occupy Position (Place Plant)
|
||||
fastify.post('/positions/:id/occupy', {
|
||||
handler: async (request, reply) => {
|
||||
try {
|
||||
const { id } = request.params as any;
|
||||
const { batchId } = request.body as any;
|
||||
const { batchId, plantTypeId } = request.body as any;
|
||||
|
||||
const position = await prisma.facilityPosition.findUnique({
|
||||
where: { id },
|
||||
|
|
@ -421,6 +614,7 @@ export async function layoutRoutes(fastify: FastifyInstance, options: FastifyPlu
|
|||
data: {
|
||||
tagNumber: `P-${Date.now()}-${Math.floor(Math.random() * 1000)}`,
|
||||
batchId,
|
||||
plantTypeId,
|
||||
positionId: id,
|
||||
address,
|
||||
status: 'ACTIVE'
|
||||
|
|
@ -497,14 +691,15 @@ export async function layoutRoutes(fastify: FastifyInstance, options: FastifyPlu
|
|||
data: { status: 'PLANTED' }
|
||||
});
|
||||
|
||||
// Record history
|
||||
await tx.plantLocationHistory.create({
|
||||
// Record history (as a MOVE lifecycle event)
|
||||
await tx.plantLifecycleEvent.create({
|
||||
data: {
|
||||
plantId: id,
|
||||
type: 'MOVE',
|
||||
fromAddress: oldAddress,
|
||||
toAddress: newAddress,
|
||||
movedById: userId,
|
||||
reason: reason || 'REORGANIZE'
|
||||
metadata: { reason: reason || 'REORGANIZE' },
|
||||
createdById: userId
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -521,6 +716,136 @@ export async function layoutRoutes(fastify: FastifyInstance, options: FastifyPlu
|
|||
}
|
||||
});
|
||||
|
||||
// Update Plant (Tag, Notes)
|
||||
fastify.patch('/plants/:id', {
|
||||
handler: async (request, reply) => {
|
||||
try {
|
||||
const { id } = request.params as any;
|
||||
const { tagNumber, notes } = request.body as any;
|
||||
const userId = (request.user as any)?.id || 'system';
|
||||
|
||||
const plant = await prisma.facilityPlant.findUnique({ where: { id } });
|
||||
if (!plant) return reply.status(404).send({ error: 'Plant not found' });
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.facilityPlant.update({
|
||||
where: { id },
|
||||
data: {
|
||||
tagNumber: tagNumber || undefined,
|
||||
// schema doesn't have notes on plant, but maybe we add it or log it as event?
|
||||
// wait, layoutApi defines updatePlant(id, {tagNumber, notes}).
|
||||
// schema.prisma: model FacilityPlant { notes String? ... }?
|
||||
// Let's check schema.
|
||||
}
|
||||
});
|
||||
|
||||
// Log event
|
||||
if (tagNumber && tagNumber !== plant.tagNumber) {
|
||||
await tx.plantLifecycleEvent.create({
|
||||
data: {
|
||||
plantId: id,
|
||||
type: 'NOTE', // Or COMPLIANCE_CHECK? Or generic AUDIT?
|
||||
metadata: { note: `Tag changed from ${plant.tagNumber} to ${tagNumber}` },
|
||||
createdById: userId
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({ error: 'Failed to update plant' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Destroy Plant
|
||||
fastify.post('/plants/:id/destroy', {
|
||||
handler: async (request, reply) => {
|
||||
try {
|
||||
const { id } = request.params as any;
|
||||
const { reason, method } = request.body as any;
|
||||
const userId = (request.user as any)?.id || 'system';
|
||||
|
||||
const plant = await prisma.facilityPlant.findUnique({ where: { id } });
|
||||
if (!plant) return reply.status(404).send({ error: 'Plant not found' });
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// 1. Update plant status
|
||||
await tx.facilityPlant.update({
|
||||
where: { id },
|
||||
data: { status: 'DESTROYED' }
|
||||
});
|
||||
|
||||
// 2. Free up the position
|
||||
await tx.facilityPosition.update({
|
||||
where: { id: plant.positionId },
|
||||
data: { status: 'EMPTY' }
|
||||
});
|
||||
|
||||
// 3. Log event
|
||||
await tx.plantLifecycleEvent.create({
|
||||
data: {
|
||||
plantId: id,
|
||||
type: 'DESTROY',
|
||||
metadata: { reason, method },
|
||||
createdById: userId
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({ error: 'Failed to destroy plant' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Harvest Plant
|
||||
fastify.post('/plants/:id/harvest', {
|
||||
handler: async (request, reply) => {
|
||||
try {
|
||||
const { id } = request.params as any;
|
||||
const { weight, unit, notes } = request.body as any;
|
||||
const userId = (request.user as any)?.id || 'system';
|
||||
|
||||
const plant = await prisma.facilityPlant.findUnique({ where: { id } });
|
||||
if (!plant) return reply.status(404).send({ error: 'Plant not found' });
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// 1. Update plant status
|
||||
await tx.facilityPlant.update({
|
||||
where: { id },
|
||||
data: { status: 'HARVESTED' }
|
||||
});
|
||||
|
||||
// 2. Free up the position (Harvest removes from rack)
|
||||
await tx.facilityPosition.update({
|
||||
where: { id: plant.positionId },
|
||||
data: { status: 'EMPTY' }
|
||||
});
|
||||
|
||||
// 3. Log event
|
||||
await tx.plantLifecycleEvent.create({
|
||||
data: {
|
||||
plantId: id,
|
||||
type: 'HARVEST',
|
||||
metadata: { weight, unit, notes },
|
||||
createdById: userId
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({ error: 'Failed to harvest plant' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk Fill Section (Place plants in all empty positions)
|
||||
fastify.post('/sections/:id/fill', {
|
||||
handler: async (request, reply) => {
|
||||
|
|
@ -881,4 +1206,132 @@ export async function layoutRoutes(fastify: FastifyInstance, options: FastifyPlu
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// PLANT TYPE LIBRARY ROUTES (Rackula-inspired)
|
||||
// ========================================
|
||||
|
||||
// Get all plant types
|
||||
fastify.get('/plant-types', {
|
||||
handler: async (request, reply) => {
|
||||
try {
|
||||
const plantTypes = await prisma.plantType.findMany({
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
return plantTypes;
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({ error: 'Failed to fetch plant types' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get plant type by slug
|
||||
fastify.get('/plant-types/:slug', {
|
||||
handler: async (request, reply) => {
|
||||
try {
|
||||
const { slug } = request.params as { slug: string };
|
||||
const plantType = await prisma.plantType.findUnique({
|
||||
where: { slug }
|
||||
});
|
||||
|
||||
if (!plantType) {
|
||||
return reply.status(404).send({ error: 'Plant type not found' });
|
||||
}
|
||||
|
||||
return plantType;
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({ error: 'Failed to fetch plant type' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Create plant type
|
||||
fastify.post('/plant-types', {
|
||||
handler: async (request, reply) => {
|
||||
try {
|
||||
const { name, strain, category, colour, growthDays, yieldGrams, notes, tags, customFields } = request.body as any;
|
||||
|
||||
// Generate slug from name
|
||||
const baseSlug = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-+/g, '-');
|
||||
|
||||
// Check for existing slug and make unique if needed
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
while (await prisma.plantType.findUnique({ where: { slug } })) {
|
||||
slug = `${baseSlug}-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
const plantType = await prisma.plantType.create({
|
||||
data: {
|
||||
slug,
|
||||
name,
|
||||
strain,
|
||||
category,
|
||||
colour,
|
||||
growthDays,
|
||||
yieldGrams,
|
||||
notes,
|
||||
tags: tags || [],
|
||||
customFields
|
||||
}
|
||||
});
|
||||
|
||||
return reply.status(201).send(plantType);
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({ error: 'Failed to create plant type' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update plant type
|
||||
fastify.put('/plant-types/:slug', {
|
||||
handler: async (request, reply) => {
|
||||
try {
|
||||
const { slug } = request.params as { slug: string };
|
||||
const { name, strain, category, colour, growthDays, yieldGrams, notes, tags, customFields } = request.body as any;
|
||||
|
||||
const plantType = await prisma.plantType.update({
|
||||
where: { slug },
|
||||
data: {
|
||||
name,
|
||||
strain,
|
||||
category,
|
||||
colour,
|
||||
growthDays,
|
||||
yieldGrams,
|
||||
notes,
|
||||
tags,
|
||||
customFields
|
||||
}
|
||||
});
|
||||
|
||||
return plantType;
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({ error: 'Failed to update plant type' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Delete plant type
|
||||
fastify.delete('/plant-types/:slug', {
|
||||
handler: async (request, reply) => {
|
||||
try {
|
||||
const { slug } = request.params as { slug: string };
|
||||
await prisma.plantType.delete({ where: { slug } });
|
||||
return reply.status(204).send();
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({ error: 'Failed to delete plant type' });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,11 +161,12 @@ export async function metrcRoutes(fastify: FastifyInstance) {
|
|||
try {
|
||||
const { propertyId, startDate, endDate } = request.query as any;
|
||||
|
||||
// Get plant location history for the date range
|
||||
// Get plant location history (MOVE events) for the date range
|
||||
const prisma = fastify.prisma;
|
||||
const history = await prisma.plantLocationHistory.findMany({
|
||||
const history = await prisma.plantLifecycleEvent.findMany({
|
||||
where: {
|
||||
movedAt: {
|
||||
type: 'MOVE',
|
||||
createdAt: {
|
||||
gte: startDate ? new Date(startDate) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
|
||||
lte: endDate ? new Date(endDate) : new Date()
|
||||
}
|
||||
|
|
@ -173,7 +174,7 @@ export async function metrcRoutes(fastify: FastifyInstance) {
|
|||
include: {
|
||||
plant: true
|
||||
},
|
||||
orderBy: { movedAt: 'desc' }
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
// Get current plant locations
|
||||
|
|
@ -188,15 +189,15 @@ export async function metrcRoutes(fastify: FastifyInstance) {
|
|||
summary: {
|
||||
totalPlants: currentReport.length,
|
||||
totalMoves: history.length,
|
||||
uniquePlantsMoved: new Set(history.map(h => h.plantId)).size
|
||||
uniquePlantsMoved: new Set(history.map((h: any) => h.plantId)).size
|
||||
},
|
||||
currentLocations: currentReport.slice(0, 100), // Limit for response size
|
||||
recentMoves: history.slice(0, 50).map(h => ({
|
||||
recentMoves: history.slice(0, 50).map((h: any) => ({
|
||||
plantTag: h.plant.tagNumber,
|
||||
from: h.fromAddress ? formatAddressForMetrc(h.fromAddress) : 'NEW',
|
||||
to: formatAddressForMetrc(h.toAddress),
|
||||
movedAt: h.movedAt,
|
||||
reason: h.reason
|
||||
movedAt: h.createdAt,
|
||||
reason: h.metadata?.reason || 'N/A'
|
||||
}))
|
||||
};
|
||||
} catch (error) {
|
||||
|
|
|
|||
60
backend/src/routes/plants.routes.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { FastifyInstance } from 'fastify';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function plantRoutes(fastify: FastifyInstance) {
|
||||
fastify.addHook('onRequest', async (request) => {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
// Get History
|
||||
fastify.get('/:id/history', async (request, reply) => {
|
||||
const { id } = request.params as any;
|
||||
try {
|
||||
const events = await prisma.plantLifecycleEvent.findMany({
|
||||
where: { plantId: id },
|
||||
include: {
|
||||
createdBy: {
|
||||
select: { id: true, name: true, email: true }
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
return events;
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({ error: 'Failed to fetch plant history' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create Event
|
||||
fastify.post('/:id/events', async (request, reply) => {
|
||||
const { id } = request.params as any;
|
||||
const { type, metadata } = request.body as any;
|
||||
const userId = (request.user as any)?.id;
|
||||
|
||||
if (!userId) {
|
||||
return reply.status(401).send({ error: 'User not authenticated' });
|
||||
}
|
||||
|
||||
try {
|
||||
const event = await prisma.plantLifecycleEvent.create({
|
||||
data: {
|
||||
plantId: id,
|
||||
type,
|
||||
metadata: metadata || {},
|
||||
createdById: userId
|
||||
}
|
||||
});
|
||||
return event;
|
||||
} catch (error) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({ error: 'Failed to create lifecycle event' });
|
||||
}
|
||||
});
|
||||
}
|
||||
412
backend/src/routes/pulse.routes.ts
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
/**
|
||||
* Pulse Integration Routes
|
||||
*
|
||||
* Exposes Pulse sensor data through Veridian's API.
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { getPulseService, initPulseService } from '../services/pulse.service';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function pulseRoutes(fastify: FastifyInstance) {
|
||||
// Auth middleware
|
||||
fastify.addHook('onRequest', async (request) => {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /pulse/status
|
||||
* Check Pulse API connection status
|
||||
*/
|
||||
fastify.get('/status', {
|
||||
handler: async (request, reply) => {
|
||||
const pulse = getPulseService();
|
||||
|
||||
if (!pulse) {
|
||||
return {
|
||||
connected: false,
|
||||
error: 'Pulse API key not configured',
|
||||
hint: 'Set PULSE_API_KEY environment variable'
|
||||
};
|
||||
}
|
||||
|
||||
const result = await pulse.testConnection();
|
||||
return {
|
||||
connected: result.success,
|
||||
deviceCount: result.deviceCount,
|
||||
error: result.error
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /pulse/configure
|
||||
* Configure Pulse API key (admin only)
|
||||
*/
|
||||
fastify.post('/configure', {
|
||||
handler: async (request, reply) => {
|
||||
const { apiKey } = request.body as { apiKey: string };
|
||||
|
||||
if (!apiKey) {
|
||||
return reply.status(400).send({ error: 'API key required' });
|
||||
}
|
||||
|
||||
// Test the key before saving
|
||||
const testService = initPulseService(apiKey);
|
||||
const result = await testService.testConnection();
|
||||
|
||||
if (!result.success) {
|
||||
return reply.status(400).send({
|
||||
error: 'Invalid API key',
|
||||
details: result.error
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Store in database/env for persistence
|
||||
return {
|
||||
success: true,
|
||||
deviceCount: result.deviceCount,
|
||||
message: 'Pulse API configured successfully'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /pulse/devices
|
||||
* List all Pulse devices
|
||||
*/
|
||||
fastify.get('/devices', {
|
||||
handler: async (request, reply) => {
|
||||
const pulse = getPulseService();
|
||||
|
||||
if (!pulse) {
|
||||
return reply.status(503).send({ error: 'Pulse not configured' });
|
||||
}
|
||||
|
||||
try {
|
||||
const devices = await pulse.getDevices();
|
||||
return { devices };
|
||||
} catch (error: any) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({ error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /pulse/readings
|
||||
* Get current readings from all devices
|
||||
*/
|
||||
fastify.get('/readings', {
|
||||
handler: async (request, reply) => {
|
||||
const pulse = getPulseService();
|
||||
|
||||
if (!pulse) {
|
||||
return reply.status(503).send({ error: 'Pulse not configured' });
|
||||
}
|
||||
|
||||
try {
|
||||
const readings = await pulse.getCurrentReadings();
|
||||
|
||||
// Check thresholds and persist alerts (for demo/failsafe)
|
||||
const alerts: any[] = [];
|
||||
for (const reading of readings) {
|
||||
const deviceIdentifier = reading.deviceName || reading.deviceId;
|
||||
|
||||
if (reading.temperature !== undefined && pulseThresholds.temperature.max && reading.temperature > pulseThresholds.temperature.max) {
|
||||
const alertType = 'TEMPERATURE_HIGH';
|
||||
|
||||
// Check if active alert exists
|
||||
const activeAlert = await prisma.environmentAlert.findFirst({
|
||||
where: {
|
||||
type: alertType,
|
||||
message: { contains: deviceIdentifier },
|
||||
resolvedAt: null
|
||||
}
|
||||
});
|
||||
|
||||
if (!activeAlert) {
|
||||
const newAlert = await prisma.environmentAlert.create({
|
||||
data: {
|
||||
type: alertType,
|
||||
severity: 'WARNING',
|
||||
message: `${deviceIdentifier}: ${alertType.replace('_', ' ')} (${reading.temperature.toFixed(1)}°F > ${pulseThresholds.temperature.max}°F)`,
|
||||
value: reading.temperature,
|
||||
threshold: pulseThresholds.temperature.max,
|
||||
createdAt: new Date()
|
||||
}
|
||||
});
|
||||
alerts.push(createAlert(reading, alertType, reading.temperature, pulseThresholds.temperature.max));
|
||||
fastify.log.info(`🚨 Created new Pulse alert: ${newAlert.id}`);
|
||||
}
|
||||
} else if (reading.temperature !== undefined && pulseThresholds.temperature.max) {
|
||||
// Temperature is within threshold - AUTO-RESOLVE any active alerts for this device
|
||||
const resolvedAlerts = await prisma.environmentAlert.updateMany({
|
||||
where: {
|
||||
type: 'TEMPERATURE_HIGH',
|
||||
message: { contains: deviceIdentifier },
|
||||
resolvedAt: null
|
||||
},
|
||||
data: {
|
||||
resolvedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
if (resolvedAlerts.count > 0) {
|
||||
fastify.log.info(`✅ Auto-resolved ${resolvedAlerts.count} alerts for ${deviceIdentifier} (temp now ${reading.temperature.toFixed(1)}°F)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast any new alerts
|
||||
if (alerts.length > 0) {
|
||||
alerts.forEach(a => broadcastAlert(a));
|
||||
}
|
||||
|
||||
return {
|
||||
readings,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
} catch (error: any) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({ error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /pulse/devices/:id/readings
|
||||
* Get current reading for a specific device
|
||||
*/
|
||||
fastify.get('/devices/:id/readings', {
|
||||
handler: async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const pulse = getPulseService();
|
||||
|
||||
if (!pulse) {
|
||||
return reply.status(503).send({ error: 'Pulse not configured' });
|
||||
}
|
||||
|
||||
try {
|
||||
const reading = await pulse.getDeviceReading(id);
|
||||
if (!reading) {
|
||||
return reply.status(404).send({ error: 'Device not found' });
|
||||
}
|
||||
return { deviceId: id, ...reading };
|
||||
} catch (error: any) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({ error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /pulse/sparklines
|
||||
* Get sparkline data for all devices
|
||||
*/
|
||||
fastify.get('/sparklines', {
|
||||
handler: async (request, reply) => {
|
||||
const pulse = getPulseService();
|
||||
|
||||
if (!pulse) {
|
||||
return reply.status(503).send({ error: 'Pulse not configured' });
|
||||
}
|
||||
|
||||
try {
|
||||
const sparklines = await pulse.getSparklines();
|
||||
return { sparklines };
|
||||
} catch (error: any) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({ error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /pulse/devices/:id/history
|
||||
* Get historical readings for a device
|
||||
*/
|
||||
fastify.get('/devices/:id/history', {
|
||||
handler: async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const { hours = 24 } = request.query as { hours?: number };
|
||||
const pulse = getPulseService();
|
||||
|
||||
if (!pulse) {
|
||||
return reply.status(503).send({ error: 'Pulse not configured' });
|
||||
}
|
||||
|
||||
try {
|
||||
const readings = await pulse.getHistory(id, hours);
|
||||
return {
|
||||
deviceId: id,
|
||||
hours,
|
||||
count: readings.length,
|
||||
readings
|
||||
};
|
||||
} catch (error: any) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({ error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /pulse/thresholds
|
||||
* Get current threshold configuration
|
||||
*/
|
||||
fastify.get('/thresholds', {
|
||||
handler: async (request, reply) => {
|
||||
return {
|
||||
thresholds: pulseThresholds,
|
||||
lastUpdated: thresholdsLastUpdated?.toISOString() || null
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /pulse/thresholds
|
||||
* Set threshold alerts for Pulse sensors
|
||||
*/
|
||||
fastify.post('/thresholds', {
|
||||
handler: async (request, reply) => {
|
||||
const config = request.body as {
|
||||
temperature?: { min: number; max: number };
|
||||
humidity?: { min: number; max: number };
|
||||
vpd?: { min: number; max: number };
|
||||
co2?: { min: number; max: number };
|
||||
};
|
||||
|
||||
if (config.temperature) {
|
||||
pulseThresholds.temperature = config.temperature;
|
||||
}
|
||||
if (config.humidity) {
|
||||
pulseThresholds.humidity = config.humidity;
|
||||
}
|
||||
if (config.vpd) {
|
||||
pulseThresholds.vpd = config.vpd;
|
||||
}
|
||||
if (config.co2) {
|
||||
pulseThresholds.co2 = config.co2;
|
||||
}
|
||||
|
||||
thresholdsLastUpdated = new Date();
|
||||
|
||||
fastify.log.info({ event: 'pulse_thresholds_updated', thresholds: pulseThresholds });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
thresholds: pulseThresholds,
|
||||
message: 'Thresholds updated successfully'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /pulse/check
|
||||
* Check current readings against thresholds and broadcast alerts
|
||||
*/
|
||||
fastify.post('/check', {
|
||||
handler: async (request, reply) => {
|
||||
const pulse = getPulseService();
|
||||
|
||||
if (!pulse) {
|
||||
return reply.status(503).send({ error: 'Pulse not configured' });
|
||||
}
|
||||
|
||||
try {
|
||||
const readings = await pulse.getCurrentReadings();
|
||||
const alerts: any[] = [];
|
||||
|
||||
for (const reading of readings) {
|
||||
// Temperature check
|
||||
if (reading.temperature !== undefined) {
|
||||
if (pulseThresholds.temperature.max && reading.temperature > pulseThresholds.temperature.max) {
|
||||
alerts.push(createAlert(reading, 'TEMPERATURE_HIGH', reading.temperature, pulseThresholds.temperature.max));
|
||||
}
|
||||
if (pulseThresholds.temperature.min && reading.temperature < pulseThresholds.temperature.min) {
|
||||
alerts.push(createAlert(reading, 'TEMPERATURE_LOW', reading.temperature, pulseThresholds.temperature.min));
|
||||
}
|
||||
}
|
||||
|
||||
// Humidity check
|
||||
if (reading.humidity !== undefined) {
|
||||
if (pulseThresholds.humidity.max && reading.humidity > pulseThresholds.humidity.max) {
|
||||
alerts.push(createAlert(reading, 'HUMIDITY_HIGH', reading.humidity, pulseThresholds.humidity.max));
|
||||
}
|
||||
if (pulseThresholds.humidity.min && reading.humidity < pulseThresholds.humidity.min) {
|
||||
alerts.push(createAlert(reading, 'HUMIDITY_LOW', reading.humidity, pulseThresholds.humidity.min));
|
||||
}
|
||||
}
|
||||
|
||||
// VPD check
|
||||
if (reading.vpd !== undefined) {
|
||||
if (pulseThresholds.vpd.max && reading.vpd > pulseThresholds.vpd.max) {
|
||||
alerts.push(createAlert(reading, 'VPD_HIGH', reading.vpd, pulseThresholds.vpd.max));
|
||||
}
|
||||
if (pulseThresholds.vpd.min && reading.vpd < pulseThresholds.vpd.min) {
|
||||
alerts.push(createAlert(reading, 'VPD_LOW', reading.vpd, pulseThresholds.vpd.min));
|
||||
}
|
||||
}
|
||||
|
||||
// CO2 check
|
||||
if (reading.co2 !== undefined) {
|
||||
if (pulseThresholds.co2.max && reading.co2 > pulseThresholds.co2.max) {
|
||||
alerts.push(createAlert(reading, 'CO2_HIGH', reading.co2, pulseThresholds.co2.max));
|
||||
}
|
||||
if (pulseThresholds.co2.min && reading.co2 < pulseThresholds.co2.min) {
|
||||
alerts.push(createAlert(reading, 'CO2_LOW', reading.co2, pulseThresholds.co2.min));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast alerts via WebSocket
|
||||
for (const alert of alerts) {
|
||||
broadcastAlert(alert);
|
||||
}
|
||||
|
||||
return {
|
||||
devicesChecked: readings.length,
|
||||
alertsTriggered: alerts.length,
|
||||
alerts,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
} catch (error: any) {
|
||||
fastify.log.error(error);
|
||||
return reply.status(500).send({ error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// In-memory threshold storage (would be persisted to DB in production)
|
||||
const pulseThresholds = {
|
||||
temperature: { min: 65, max: 82 }, // °F
|
||||
humidity: { min: 40, max: 70 }, // %
|
||||
vpd: { min: 0.8, max: 1.2 }, // kPa
|
||||
co2: { min: 400, max: 1500 } // ppm
|
||||
};
|
||||
|
||||
let thresholdsLastUpdated: Date | null = null;
|
||||
|
||||
// Import broadcast function
|
||||
import { broadcastAlert } from '../plugins/websocket';
|
||||
|
||||
function createAlert(reading: any, type: string, value: number, threshold: number) {
|
||||
return {
|
||||
id: `pulse-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
type,
|
||||
sensorName: reading.deviceName || `Device ${reading.deviceId}`,
|
||||
value,
|
||||
threshold,
|
||||
message: `${reading.deviceName || 'Pulse Sensor'}: ${type.replace('_', ' ')} (${value.toFixed(1)} vs threshold ${threshold})`,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
112
backend/src/scripts/demo-alerts.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/**
|
||||
* Demo Alert Generator
|
||||
*
|
||||
* Generates fake sensor alerts for demonstration purposes.
|
||||
* Run with: npx ts-node src/scripts/demo-alerts.ts
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const DEMO_SENSORS = [
|
||||
{ id: 'demo-pulse-1', name: 'Flower Room - Pulse Pro', roomId: 'flower-room' },
|
||||
{ id: 'demo-pulse-2', name: 'Veg Room - Pulse', roomId: 'veg-room' },
|
||||
];
|
||||
|
||||
const ALERT_TYPES = [
|
||||
{ type: 'TEMPERATURE_HIGH', metric: 'temperature', min: 82, max: 88 },
|
||||
{ type: 'TEMPERATURE_LOW', metric: 'temperature', min: 58, max: 64 },
|
||||
{ type: 'HUMIDITY_HIGH', metric: 'humidity', min: 72, max: 78 },
|
||||
{ type: 'HUMIDITY_LOW', metric: 'humidity', min: 35, max: 42 },
|
||||
{ type: 'VPD_HIGH', metric: 'vpd', min: 1.5, max: 1.8 },
|
||||
];
|
||||
|
||||
async function generateDemoAlert() {
|
||||
const sensor = DEMO_SENSORS[Math.floor(Math.random() * DEMO_SENSORS.length)];
|
||||
const alertConfig = ALERT_TYPES[Math.floor(Math.random() * ALERT_TYPES.length)];
|
||||
|
||||
const value = alertConfig.min + Math.random() * (alertConfig.max - alertConfig.min);
|
||||
const threshold = alertConfig.type.includes('HIGH')
|
||||
? value - 5
|
||||
: value + 5;
|
||||
|
||||
const alert = await prisma.environmentAlert.create({
|
||||
data: {
|
||||
type: alertConfig.type,
|
||||
severity: 'WARNING',
|
||||
message: `${sensor.name}: ${alertConfig.metric} ${alertConfig.type.includes('HIGH') ? 'above' : 'below'} threshold (${value.toFixed(1)} vs ${threshold.toFixed(1)})`,
|
||||
value: value,
|
||||
threshold: threshold
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`🚨 Generated alert: ${alert.message}`);
|
||||
return alert;
|
||||
}
|
||||
|
||||
async function generateDemoReading() {
|
||||
const sensor = DEMO_SENSORS[Math.floor(Math.random() * DEMO_SENSORS.length)];
|
||||
|
||||
// Generate realistic readings
|
||||
const temperature = 72 + (Math.random() - 0.5) * 10; // 67-77°F
|
||||
const humidity = 55 + (Math.random() - 0.5) * 15; // 47-62%
|
||||
const vpd = 1.0 + (Math.random() - 0.5) * 0.4; // 0.8-1.2 kPa
|
||||
|
||||
const reading = await prisma.sensorReading.create({
|
||||
data: {
|
||||
sensorId: sensor.id,
|
||||
value: temperature,
|
||||
unit: '°F',
|
||||
timestamp: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`📊 Generated reading: ${sensor.name} - ${temperature.toFixed(1)}°F, ${humidity.toFixed(1)}% RH`);
|
||||
return reading;
|
||||
}
|
||||
|
||||
async function runDemoMode(intervalMs: number = 5000, alertChance: number = 0.2) {
|
||||
console.log('🎭 Demo mode started');
|
||||
console.log(` Interval: ${intervalMs}ms`);
|
||||
console.log(` Alert chance: ${alertChance * 100}%`);
|
||||
console.log(' Press Ctrl+C to stop\n');
|
||||
|
||||
const loop = async () => {
|
||||
await generateDemoReading();
|
||||
|
||||
if (Math.random() < alertChance) {
|
||||
await generateDemoAlert();
|
||||
}
|
||||
};
|
||||
|
||||
// Initial run
|
||||
await loop();
|
||||
|
||||
// Repeat
|
||||
setInterval(loop, intervalMs);
|
||||
}
|
||||
|
||||
// CLI interface
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0] || 'run';
|
||||
|
||||
if (command === 'alert') {
|
||||
generateDemoAlert().then(() => process.exit(0));
|
||||
} else if (command === 'reading') {
|
||||
generateDemoReading().then(() => process.exit(0));
|
||||
} else if (command === 'run') {
|
||||
const interval = parseInt(args[1] || '5000');
|
||||
const alertChance = parseFloat(args[2] || '0.2');
|
||||
runDemoMode(interval, alertChance);
|
||||
} else {
|
||||
console.log(`
|
||||
Usage:
|
||||
npx ts-node src/scripts/demo-alerts.ts alert - Generate single alert
|
||||
npx ts-node src/scripts/demo-alerts.ts reading - Generate single reading
|
||||
npx ts-node src/scripts/demo-alerts.ts run [interval] [alertChance]
|
||||
- Run continuous demo mode
|
||||
- interval: ms between readings (default: 5000)
|
||||
- alertChance: 0-1 probability (default: 0.2)
|
||||
`);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import jwt from '@fastify/jwt';
|
||||
import dotenv from 'dotenv';
|
||||
import { prismaPlugin } from './plugins/prisma';
|
||||
|
|
@ -23,6 +24,8 @@ import { metrcRoutes } from './routes/metrc.routes';
|
|||
import { visitorRoutes } from './routes/visitors.routes';
|
||||
import { accessZoneRoutes } from './routes/access-zones.routes';
|
||||
import { messagingRoutes } from './routes/messaging.routes';
|
||||
import { pulseRoutes } from './routes/pulse.routes';
|
||||
import { websocketPlugin } from './plugins/websocket';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
|
|
@ -31,6 +34,20 @@ const server = fastify({
|
|||
});
|
||||
|
||||
// Register Plugins
|
||||
server.register(cors, {
|
||||
origin: true, // Allow all origins to resolve specific Capacitor/WebView mismatches
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']
|
||||
});
|
||||
|
||||
// Manual OPTIONS handler as fallback
|
||||
server.options('/*', async (request, reply) => {
|
||||
reply.header('Access-Control-Allow-Origin', request.headers.origin || '*');
|
||||
reply.header('Access-Control-Allow-Credentials', 'true');
|
||||
reply.header('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
|
||||
reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
return reply.send();
|
||||
});
|
||||
server.register(prismaPlugin);
|
||||
server.register(jwt, {
|
||||
secret: process.env.JWT_SECRET || 'supersecret'
|
||||
|
|
@ -58,6 +75,8 @@ server.register(metrcRoutes, { prefix: '/api/metrc' });
|
|||
server.register(visitorRoutes, { prefix: '/api/visitors' });
|
||||
server.register(accessZoneRoutes, { prefix: '/api/zones' });
|
||||
server.register(messagingRoutes, { prefix: '/api/messaging' });
|
||||
import { plantRoutes } from './routes/plants.routes';
|
||||
server.register(plantRoutes, { prefix: '/api/plants' });
|
||||
|
||||
// Phase 10: Compliance
|
||||
import { auditRoutes } from './routes/audit.routes';
|
||||
|
|
@ -75,6 +94,16 @@ server.register(financialRoutes, { prefix: '/api/financial' });
|
|||
server.register(insightsRoutes, { prefix: '/api/insights' });
|
||||
server.register(uploadRoutes, { prefix: '/api/upload' });
|
||||
|
||||
// Pulse sensor integration
|
||||
server.register(pulseRoutes, { prefix: '/api/pulse' });
|
||||
|
||||
// Camera/Security monitoring
|
||||
import { cameraRoutes } from './routes/cameras.routes';
|
||||
server.register(cameraRoutes, { prefix: '/api/cameras' });
|
||||
|
||||
// WebSocket for real-time alerts
|
||||
server.register(websocketPlugin);
|
||||
|
||||
// Admin routes (demo/testing)
|
||||
import { adminRoutes } from './routes/admin.routes';
|
||||
server.register(adminRoutes, { prefix: '/api/admin' });
|
||||
|
|
|
|||
238
backend/src/services/pulse.service.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
/**
|
||||
* Pulse Grow API Service
|
||||
*
|
||||
* Server-side integration with Pulse Grow sensor platform.
|
||||
* API Docs: https://api.pulsegrow.com/docs
|
||||
*/
|
||||
|
||||
const PULSE_API_BASE = 'https://api.pulsegrow.com';
|
||||
|
||||
export interface PulseDevice {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
isOnline: boolean;
|
||||
}
|
||||
|
||||
export interface PulseReading {
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
temperature: number; // Fahrenheit
|
||||
humidity: number; // %
|
||||
vpd: number; // kPa
|
||||
dewpoint: number; // Fahrenheit
|
||||
light?: number; // lux
|
||||
co2?: number; // ppm
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export class PulseService {
|
||||
private apiKey: string;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
private async fetch(path: string): Promise<any> {
|
||||
const res = await fetch(`${PULSE_API_BASE}${path}`, {
|
||||
headers: {
|
||||
'x-api-key': this.apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Pulse API error ${res.status}: ${text}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all devices with their latest readings
|
||||
* Uses /all-devices which returns deviceViewDtos with mostRecentDataPoint
|
||||
*/
|
||||
async getAllDevicesWithReadings(): Promise<{ devices: PulseDevice[]; readings: PulseReading[] }> {
|
||||
const data = await this.fetch('/all-devices');
|
||||
|
||||
const devices: PulseDevice[] = [];
|
||||
const readings: PulseReading[] = [];
|
||||
|
||||
// Process device view DTOs
|
||||
for (const d of (data.deviceViewDtos || [])) {
|
||||
const deviceId = String(d.id || d.deviceId);
|
||||
const deviceName = d.name || 'Unknown';
|
||||
|
||||
devices.push({
|
||||
id: deviceId,
|
||||
name: deviceName,
|
||||
type: this.getDeviceType(d.deviceType),
|
||||
isOnline: d.mostRecentDataPoint?.pluggedIn ?? true,
|
||||
});
|
||||
|
||||
if (d.mostRecentDataPoint) {
|
||||
const dp = d.mostRecentDataPoint;
|
||||
readings.push({
|
||||
deviceId,
|
||||
deviceName,
|
||||
temperature: dp.temperatureF ?? 0,
|
||||
humidity: dp.humidityRh ?? 0,
|
||||
vpd: dp.vpd ?? 0,
|
||||
dewpoint: dp.dpF ?? this.calculateDewpoint(dp.temperatureF, dp.humidityRh),
|
||||
light: dp.lightLux || undefined,
|
||||
co2: dp.co2 || undefined,
|
||||
timestamp: new Date(dp.createdAt || Date.now()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Process universal sensor views if present
|
||||
for (const s of (data.universalSensorViews || [])) {
|
||||
const deviceId = String(s.id || s.deviceId);
|
||||
const deviceName = s.name || 'Unknown Sensor';
|
||||
|
||||
devices.push({
|
||||
id: deviceId,
|
||||
name: deviceName,
|
||||
type: 'universal',
|
||||
isOnline: true,
|
||||
});
|
||||
}
|
||||
|
||||
return { devices, readings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all devices for this grow
|
||||
*/
|
||||
async getDevices(): Promise<PulseDevice[]> {
|
||||
const { devices } = await this.getAllDevicesWithReadings();
|
||||
return devices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current readings for all devices
|
||||
*/
|
||||
async getCurrentReadings(): Promise<PulseReading[]> {
|
||||
const { readings } = await this.getAllDevicesWithReadings();
|
||||
return readings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current reading for a specific device
|
||||
*/
|
||||
async getDeviceReading(deviceId: string): Promise<Omit<PulseReading, 'deviceId' | 'deviceName'> | null> {
|
||||
try {
|
||||
const data = await this.fetch(`/devices/${deviceId}/recent-data`);
|
||||
|
||||
return {
|
||||
temperature: data.temperatureF ?? 0,
|
||||
humidity: data.humidityRh ?? 0,
|
||||
vpd: data.vpd ?? 0,
|
||||
dewpoint: data.dpF ?? this.calculateDewpoint(data.temperatureF, data.humidityRh),
|
||||
light: data.lightLux || undefined,
|
||||
co2: data.co2 || undefined,
|
||||
timestamp: new Date(data.createdAt || Date.now()),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get historical data for a device
|
||||
*/
|
||||
async getHistory(deviceId: string, hours: number = 24): Promise<PulseReading[]> {
|
||||
const start = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
|
||||
const data = await this.fetch(`/devices/${deviceId}/data-range?start=${start}`);
|
||||
|
||||
return (data || []).map((r: any) => ({
|
||||
deviceId,
|
||||
deviceName: '',
|
||||
temperature: r.temperatureF ?? 0,
|
||||
humidity: r.humidityRh ?? 0,
|
||||
vpd: r.vpd ?? 0,
|
||||
dewpoint: r.dpF ?? this.calculateDewpoint(r.temperatureF, r.humidityRh),
|
||||
light: r.lightLux || undefined,
|
||||
co2: r.co2 || undefined,
|
||||
timestamp: new Date(r.createdAt),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sparkline data (last 1 hour) for all devices
|
||||
*/
|
||||
async getSparklines(): Promise<Record<string, PulseReading[]>> {
|
||||
const devices = await this.getDevices();
|
||||
const results: Record<string, PulseReading[]> = {};
|
||||
|
||||
await Promise.all(devices.map(async (device) => {
|
||||
try {
|
||||
// Get 24 hours of history
|
||||
const history = await this.getHistory(device.id, 24);
|
||||
// Sort by timestamp ascending
|
||||
results[device.id] = history.sort((a, b) =>
|
||||
a.timestamp.getTime() - b.timestamp.getTime()
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to fetch history for sparkline (device ${device.id})`, error);
|
||||
results[device.id] = [];
|
||||
}
|
||||
}));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to Pulse API
|
||||
*/
|
||||
async testConnection(): Promise<{ success: boolean; deviceCount: number; error?: string }> {
|
||||
try {
|
||||
const { devices } = await this.getAllDevicesWithReadings();
|
||||
return { success: true, deviceCount: devices.length };
|
||||
} catch (error: any) {
|
||||
return { success: false, deviceCount: 0, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map device type number to string
|
||||
*/
|
||||
private getDeviceType(type: number): string {
|
||||
const types: Record<number, string> = {
|
||||
0: 'Pulse One',
|
||||
1: 'Pulse Pro',
|
||||
2: 'Pulse Hub',
|
||||
};
|
||||
return types[type] || 'Pulse';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate dewpoint from temperature and humidity
|
||||
*/
|
||||
private calculateDewpoint(tempF: number, humidity: number): number {
|
||||
if (!tempF || !humidity) return 0;
|
||||
const tempC = (tempF - 32) * 5 / 9;
|
||||
const a = 17.27;
|
||||
const b = 237.7;
|
||||
const alpha = ((a * tempC) / (b + tempC)) + Math.log(humidity / 100);
|
||||
const dewpointC = (b * alpha) / (a - alpha);
|
||||
return (dewpointC * 9 / 5) + 32;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance (initialized with API key from env)
|
||||
let pulseServiceInstance: PulseService | null = null;
|
||||
|
||||
export function getPulseService(): PulseService | null {
|
||||
if (!pulseServiceInstance && process.env.PULSE_API_KEY) {
|
||||
pulseServiceInstance = new PulseService(process.env.PULSE_API_KEY);
|
||||
}
|
||||
return pulseServiceInstance;
|
||||
}
|
||||
|
||||
export function initPulseService(apiKey: string): PulseService {
|
||||
pulseServiceInstance = new PulseService(apiKey);
|
||||
return pulseServiceInstance;
|
||||
}
|
||||
266
backend/src/types/layout-schemas.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
/**
|
||||
* Layout Zod Validation Schemas
|
||||
* Inspired by Rackula's schema architecture
|
||||
* Schema v1.0.0 - Flat structure with plant-specific fields
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// =============================================================================
|
||||
// Constants & Patterns
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Slug pattern: lowercase alphanumeric with hyphens, no leading/trailing/consecutive hyphens
|
||||
*/
|
||||
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
/**
|
||||
* Hex colour pattern: 6-character hex with # prefix
|
||||
*/
|
||||
const HEX_COLOUR_PATTERN = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
// =============================================================================
|
||||
// Enums
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Plant category types for library organization
|
||||
*/
|
||||
export const PlantCategorySchema = z.enum([
|
||||
'VEG',
|
||||
'FLOWER',
|
||||
'MOTHER',
|
||||
'CLONE',
|
||||
'SEEDLING',
|
||||
]);
|
||||
export type PlantCategory = z.infer<typeof PlantCategorySchema>;
|
||||
|
||||
/**
|
||||
* Section subtype - preserves visual diversity while using unified Rack concept
|
||||
*/
|
||||
export const SectionSubtypeSchema = z.enum([
|
||||
'TABLE',
|
||||
'RACK',
|
||||
'TRAY',
|
||||
'HANGER',
|
||||
'FLOOR',
|
||||
]);
|
||||
export type SectionSubtype = z.infer<typeof SectionSubtypeSchema>;
|
||||
|
||||
/**
|
||||
* Position status
|
||||
*/
|
||||
export const PositionStatusSchema = z.enum([
|
||||
'EMPTY',
|
||||
'OCCUPIED',
|
||||
'RESERVED',
|
||||
'DAMAGED',
|
||||
]);
|
||||
export type PositionStatus = z.infer<typeof PositionStatusSchema>;
|
||||
|
||||
// =============================================================================
|
||||
// Core Schemas
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Slug schema for plant type identification
|
||||
*/
|
||||
export const SlugSchema = z
|
||||
.string()
|
||||
.min(1, 'Slug is required')
|
||||
.max(100, 'Slug must be 100 characters or less')
|
||||
.regex(
|
||||
SLUG_PATTERN,
|
||||
'Slug must be lowercase with hyphens only (no leading/trailing/consecutive)'
|
||||
);
|
||||
|
||||
/**
|
||||
* Plant Type - template definition in library
|
||||
* Analogous to Rackula's DeviceType
|
||||
*/
|
||||
export const PlantTypeSchema = z.object({
|
||||
// --- Core Identity ---
|
||||
slug: SlugSchema,
|
||||
name: z.string().min(1, 'Name is required').max(100),
|
||||
strain: z.string().max(100).optional(),
|
||||
|
||||
// --- Classification ---
|
||||
category: PlantCategorySchema,
|
||||
|
||||
// --- Visual Properties ---
|
||||
colour: z
|
||||
.string()
|
||||
.regex(HEX_COLOUR_PATTERN, 'Color must be valid hex (e.g., #4A90D9)'),
|
||||
|
||||
// --- Growth Properties ---
|
||||
growthDays: z.number().int().positive().optional(),
|
||||
yieldGrams: z.number().positive().optional(),
|
||||
|
||||
// --- Metadata ---
|
||||
notes: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
customFields: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
export type PlantType = z.infer<typeof PlantTypeSchema>;
|
||||
|
||||
/**
|
||||
* Placed Plant - instance at a specific position
|
||||
* Analogous to Rackula's PlacedDevice
|
||||
*/
|
||||
export const PlacedPlantSchema = z.object({
|
||||
// --- Identity ---
|
||||
id: z.string().uuid(),
|
||||
plantTypeSlug: z.string().min(1, 'Plant type slug is required'),
|
||||
|
||||
// --- Position ---
|
||||
row: z.number().int().min(0),
|
||||
column: z.number().int().min(0),
|
||||
tier: z.number().int().min(1).default(1),
|
||||
slot: z.number().int().min(1).default(1),
|
||||
|
||||
// --- Overrides ---
|
||||
name: z.string().optional(),
|
||||
colourOverride: z.string().regex(HEX_COLOUR_PATTERN).optional(),
|
||||
|
||||
// --- Metadata ---
|
||||
notes: z.string().optional(),
|
||||
customFields: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
export type PlacedPlant = z.infer<typeof PlacedPlantSchema>;
|
||||
|
||||
/**
|
||||
* Rack/Section - container structure for plants
|
||||
* Analogous to Rackula's Rack, with subtype for visual variety
|
||||
*/
|
||||
export const RackSchema = z.object({
|
||||
// --- Identity ---
|
||||
id: z.string().uuid(),
|
||||
name: z.string().min(1),
|
||||
code: z.string().max(20).optional(),
|
||||
|
||||
// --- Type (hybrid approach) ---
|
||||
subtype: SectionSubtypeSchema.default('RACK'),
|
||||
|
||||
// --- Dimensions ---
|
||||
rows: z.number().int().min(1).max(100),
|
||||
columns: z.number().int().min(1).max(100),
|
||||
tiers: z.number().int().min(1).max(10).default(1),
|
||||
spacing: z.number().positive().optional(), // inches between positions
|
||||
|
||||
// --- Position on floor ---
|
||||
posX: z.number(),
|
||||
posY: z.number(),
|
||||
width: z.number().positive(),
|
||||
height: z.number().positive(),
|
||||
|
||||
// --- Contents ---
|
||||
plants: z.array(PlacedPlantSchema).default([]),
|
||||
|
||||
// --- Metadata ---
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
export type Rack = z.infer<typeof RackSchema>;
|
||||
|
||||
/**
|
||||
* Room Layout - complete room state
|
||||
*/
|
||||
export const RoomLayoutSchema = z.object({
|
||||
// --- Identity ---
|
||||
id: z.string().uuid(),
|
||||
name: z.string().min(1),
|
||||
code: z.string().max(20).optional(),
|
||||
type: z.enum(['VEG', 'FLOWER', 'DRY', 'CURE', 'MOTHER', 'CLONE', 'FACILITY']),
|
||||
|
||||
// --- Position on floor ---
|
||||
posX: z.number(),
|
||||
posY: z.number(),
|
||||
width: z.number().positive(),
|
||||
height: z.number().positive(),
|
||||
rotation: z.number().default(0),
|
||||
|
||||
// --- Visual ---
|
||||
color: z.string().regex(HEX_COLOUR_PATTERN).optional(),
|
||||
|
||||
// --- Contents ---
|
||||
racks: z.array(RackSchema).default([]),
|
||||
});
|
||||
export type RoomLayout = z.infer<typeof RoomLayoutSchema>;
|
||||
|
||||
/**
|
||||
* Floor Layout - complete floor state for serialization
|
||||
*/
|
||||
export const FloorLayoutSchema = z.object({
|
||||
// --- Metadata ---
|
||||
version: z.string().default('1.0.0'),
|
||||
name: z.string(),
|
||||
|
||||
// --- Floor dimensions ---
|
||||
width: z.number().positive(),
|
||||
height: z.number().positive(),
|
||||
ceilingHeight: z.number().positive().optional(),
|
||||
|
||||
// --- Contents ---
|
||||
rooms: z.array(RoomLayoutSchema),
|
||||
|
||||
// --- Plant Type Library ---
|
||||
plantTypes: z.array(PlantTypeSchema).default([]),
|
||||
});
|
||||
export type FloorLayout = z.infer<typeof FloorLayoutSchema>;
|
||||
|
||||
// =============================================================================
|
||||
// Helper Types for Creation
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Helper type for creating a PlantType
|
||||
*/
|
||||
export const CreatePlantTypeSchema = PlantTypeSchema.omit({
|
||||
slug: true,
|
||||
}).extend({
|
||||
name: z.string().min(1),
|
||||
});
|
||||
export type CreatePlantTypeData = z.infer<typeof CreatePlantTypeSchema>;
|
||||
|
||||
/**
|
||||
* Helper type for updating a PlantType
|
||||
*/
|
||||
export const UpdatePlantTypeSchema = PlantTypeSchema.partial().omit({
|
||||
slug: true,
|
||||
});
|
||||
export type UpdatePlantTypeData = z.infer<typeof UpdatePlantTypeSchema>;
|
||||
|
||||
// =============================================================================
|
||||
// Utility Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Generate a slug from a name
|
||||
*/
|
||||
export function generateSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-+/g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that all slugs in an array are unique
|
||||
*/
|
||||
export function validateSlugUniqueness(items: { slug: string }[]): string[] {
|
||||
const slugCounts = new Map<string, number>();
|
||||
|
||||
for (const item of items) {
|
||||
slugCounts.set(item.slug, (slugCounts.get(item.slug) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const duplicates: string[] = [];
|
||||
for (const [slug, count] of slugCounts) {
|
||||
if (count > 1) {
|
||||
duplicates.push(slug);
|
||||
}
|
||||
}
|
||||
|
||||
return duplicates;
|
||||
}
|
||||
|
|
@ -41,6 +41,7 @@ services:
|
|||
JWT_REFRESH_EXPIRY: 7d
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
PULSE_API_KEY: ${PULSE_API_KEY}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
|
@ -72,6 +73,25 @@ services:
|
|||
volumes:
|
||||
- ./go2rtc.yaml:/config/go2rtc.yaml
|
||||
|
||||
# Arlo camera bridge - converts Arlo Cloud streams to RTSP
|
||||
arlo-streamer:
|
||||
image: kaffetorsk/arlo-streamer:latest
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- internal
|
||||
environment:
|
||||
- ARLO_USER=${ARLO_USER}
|
||||
- ARLO_PASS=${ARLO_PASS}
|
||||
- IMAP_HOST=${ARLO_IMAP_HOST}
|
||||
- IMAP_USER=${ARLO_IMAP_USER}
|
||||
- IMAP_PASS=${ARLO_IMAP_PASS}
|
||||
# Output to go2rtc's RTSP server - {name} is replaced by camera name
|
||||
- FFMPEG_OUT=-c:v copy -c:a copy -f rtsp rtsp://go2rtc:8554/{name}
|
||||
- MOTION_TIMEOUT=60
|
||||
- PYAARLO_RECONNECT_EVERY=110
|
||||
depends_on:
|
||||
- go2rtc
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
|
|
|
|||
101
frontend/android/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||
|
||||
# Built application files
|
||||
*.apk
|
||||
*.aar
|
||||
*.ap_
|
||||
*.aab
|
||||
|
||||
# Files for the ART/Dalvik VM
|
||||
*.dex
|
||||
|
||||
# Java class files
|
||||
*.class
|
||||
|
||||
# Generated files
|
||||
bin/
|
||||
gen/
|
||||
out/
|
||||
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||
# release/
|
||||
|
||||
# Gradle files
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
# Local configuration file (sdk path, etc)
|
||||
local.properties
|
||||
|
||||
# Proguard folder generated by Eclipse
|
||||
proguard/
|
||||
|
||||
# Log Files
|
||||
*.log
|
||||
|
||||
# Android Studio Navigation editor temp files
|
||||
.navigation/
|
||||
|
||||
# Android Studio captures folder
|
||||
captures/
|
||||
|
||||
# IntelliJ
|
||||
*.iml
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/gradle.xml
|
||||
.idea/assetWizardSettings.xml
|
||||
.idea/dictionaries
|
||||
.idea/libraries
|
||||
# Android Studio 3 in .gitignore file.
|
||||
.idea/caches
|
||||
.idea/modules.xml
|
||||
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||
.idea/navEditor.xml
|
||||
|
||||
# Keystore files
|
||||
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||
#*.jks
|
||||
#*.keystore
|
||||
|
||||
# External native build folder generated in Android Studio 2.2 and later
|
||||
.externalNativeBuild
|
||||
.cxx/
|
||||
|
||||
# Google Services (e.g. APIs or Firebase)
|
||||
# google-services.json
|
||||
|
||||
# Freeline
|
||||
freeline.py
|
||||
freeline/
|
||||
freeline_project_description.json
|
||||
|
||||
# fastlane
|
||||
fastlane/report.xml
|
||||
fastlane/Preview.html
|
||||
fastlane/screenshots
|
||||
fastlane/test_output
|
||||
fastlane/readme.md
|
||||
|
||||
# Version control
|
||||
vcs.xml
|
||||
|
||||
# lint
|
||||
lint/intermediates/
|
||||
lint/generated/
|
||||
lint/outputs/
|
||||
lint/tmp/
|
||||
# lint/reports/
|
||||
|
||||
# Android Profiling
|
||||
*.hprof
|
||||
|
||||
# Cordova plugins for Capacitor
|
||||
capacitor-cordova-android-plugins
|
||||
|
||||
# Copied web assets
|
||||
app/src/main/assets/public
|
||||
|
||||
# Generated Config files
|
||||
app/src/main/assets/capacitor.config.json
|
||||
app/src/main/assets/capacitor.plugins.json
|
||||
app/src/main/res/xml/config.xml
|
||||
2
frontend/android/app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/build/*
|
||||
!/build/.npmkeep
|
||||
58
frontend/android/app/build.gradle
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
apply plugin: 'com.android.application'
|
||||
|
||||
android {
|
||||
namespace = "run.runfoo.veridian"
|
||||
compileSdk = rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "run.runfoo.veridian"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
flatDir{
|
||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation project(':capacitor-android')
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
try {
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (servicesJSON.text) {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||
}
|
||||
22
frontend/android/app/capacitor.build.gradle
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-camera')
|
||||
implementation project(':capacitor-push-notifications')
|
||||
implementation project(':capacitor-splash-screen')
|
||||
implementation project(':capacitor-status-bar')
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (hasProperty('postBuildExtras')) {
|
||||
postBuildExtras()
|
||||
}
|
||||
21
frontend/android/app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# class:
|
||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||
# public *;
|
||||
#}
|
||||
|
||||
# Uncomment this to preserve the line number information for
|
||||
# debugging stack traces.
|
||||
#-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
|
||||
@Test
|
||||
public void useAppContext() throws Exception {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
|
||||
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||
}
|
||||
}
|
||||
42
frontend/android/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:networkSecurityConfig="@xml/network_security_config">
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||
android:name=".MainActivity"
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"></meta-data>
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
<!-- Permissions -->
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package run.runfoo.veridian;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {}
|
||||
BIN
frontend/android/app/src/main/res/drawable-land-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
frontend/android/app/src/main/res/drawable-land-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
frontend/android/app/src/main/res/drawable-land-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
BIN
frontend/android/app/src/main/res/drawable-port-hdpi/splash.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
frontend/android/app/src/main/res/drawable-port-mdpi/splash.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
frontend/android/app/src/main/res/drawable-port-xhdpi/splash.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
|
@ -0,0 +1,34 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="78.5885"
|
||||
android:endY="90.9159"
|
||||
android:startX="48.7653"
|
||||
android:startY="61.0927"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:strokeWidth="1" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportHeight="108"
|
||||
android:viewportWidth="108">
|
||||
<path
|
||||
android:fillColor="#26A69A"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeColor="#33FFFFFF"
|
||||
android:strokeWidth="0.8" />
|
||||
</vector>
|
||||
BIN
frontend/android/app/src/main/res/drawable/splash.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
12
frontend/android/app/src/main/res/layout/activity_main.xml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<WebView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
BIN
frontend/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 397 KiB |
BIN
frontend/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 397 KiB |
BIN
frontend/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 397 KiB |
BIN
frontend/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 397 KiB |
BIN
frontend/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 397 KiB |
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
7
frontend/android/app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Veridian</string>
|
||||
<string name="title_activity_main">Veridian</string>
|
||||
<string name="package_name">run.runfoo.veridian</string>
|
||||
<string name="custom_url_scheme">run.runfoo.veridian</string>
|
||||
</resources>
|
||||
22
frontend/android/app/src/main/res/values/styles.xml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<!-- Base application theme. -->
|
||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
||||
<!-- Customize your theme here. -->
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<item name="android:background">@drawable/splash</item>
|
||||
</style>
|
||||
</resources>
|
||||
5
frontend/android/app/src/main/res/xml/file_paths.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<trust-anchors>
|
||||
<certificates src="system"/>
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
<domain-config cleartextTrafficPermitted="false">
|
||||
<domain includeSubdomains="true">veridian.runfoo.run</domain>
|
||||
<domain includeSubdomains="true">runfoo.run</domain>
|
||||
<trust-anchors>
|
||||
<certificates src="system"/>
|
||||
</trust-anchors>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.getcapacitor.myapp;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
|
||||
@Test
|
||||
public void addition_isCorrect() throws Exception {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
}
|
||||
33
frontend/android/build.gradle
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.13.0'
|
||||
classpath 'com.google.gms:google-services:4.4.4'
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
}
|
||||
}
|
||||
|
||||
apply from: "variables.gradle"
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
tasks.withType(JavaCompile) {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
15
frontend/android/capacitor.settings.gradle
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':capacitor-camera'
|
||||
project(':capacitor-camera').projectDir = new File('../node_modules/@capacitor/camera/android')
|
||||
|
||||
include ':capacitor-push-notifications'
|
||||
project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android')
|
||||
|
||||
include ':capacitor-splash-screen'
|
||||
project(':capacitor-splash-screen').projectDir = new File('../node_modules/@capacitor/splash-screen/android')
|
||||
|
||||
include ':capacitor-status-bar'
|
||||
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
|
||||
22
frontend/android/gradle.properties
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Project-wide Gradle settings.
|
||||
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
BIN
frontend/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
frontend/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
251
frontend/android/gradlew
vendored
Executable file
|
|
@ -0,0 +1,251 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
frontend/android/gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
5
frontend/android/settings.gradle
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
include ':app'
|
||||
include ':capacitor-cordova-android-plugins'
|
||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||
|
||||
apply from: 'capacitor.settings.gradle'
|
||||
16
frontend/android/variables.gradle
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
androidxActivityVersion = '1.11.0'
|
||||
androidxAppCompatVersion = '1.7.1'
|
||||
androidxCoordinatorLayoutVersion = '1.3.0'
|
||||
androidxCoreVersion = '1.17.0'
|
||||
androidxFragmentVersion = '1.8.9'
|
||||
coreSplashScreenVersion = '1.2.0'
|
||||
androidxWebkitVersion = '1.14.0'
|
||||
junitVersion = '4.13.2'
|
||||
androidxJunitVersion = '1.3.0'
|
||||
androidxEspressoCoreVersion = '3.7.0'
|
||||
cordovaAndroidVersion = '14.0.1'
|
||||
}
|
||||
35
frontend/capacitor.config.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { CapacitorConfig } from '@capacitor/cli';
|
||||
|
||||
const config: CapacitorConfig = {
|
||||
appId: 'run.runfoo.veridian',
|
||||
appName: 'Veridian',
|
||||
webDir: 'dist',
|
||||
server: {
|
||||
// For development, you can use localhost
|
||||
// For production APK, the app will use the built-in web assets
|
||||
androidScheme: 'https',
|
||||
},
|
||||
plugins: {
|
||||
SplashScreen: {
|
||||
launchShowDuration: 2000,
|
||||
backgroundColor: '#09090b',
|
||||
showSpinner: false,
|
||||
androidSplashResourceName: 'splash',
|
||||
androidScaleType: 'CENTER_CROP',
|
||||
},
|
||||
StatusBar: {
|
||||
backgroundColor: '#09090b',
|
||||
style: 'DARK',
|
||||
overlaysWebView: false,
|
||||
},
|
||||
PushNotifications: {
|
||||
presentationOptions: ['badge', 'sound', 'alert'],
|
||||
},
|
||||
},
|
||||
android: {
|
||||
allowMixedContent: false,
|
||||
backgroundColor: '#09090b',
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
@ -7,10 +7,10 @@
|
|||
<meta name="theme-color" content="#10b981" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Visitor Kiosk" />
|
||||
<meta name="apple-mobile-web-app-title" content="Veridian" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<title>Veridian - Visitor Kiosk</title>
|
||||
<title>Veridian - Cultivation Platform</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
|
|||
1572
frontend/package-lock.json
generated
|
|
@ -5,12 +5,19 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^8.0.0",
|
||||
"@capacitor/camera": "^8.0.0",
|
||||
"@capacitor/cli": "^8.0.0",
|
||||
"@capacitor/core": "^8.0.0",
|
||||
"@capacitor/push-notifications": "^8.0.0",
|
||||
"@capacitor/splash-screen": "^8.0.0",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
|
|
@ -22,10 +29,13 @@
|
|||
"browser-image-compression": "^2.0.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.23.26",
|
||||
"html2canvas": "^1.4.1",
|
||||
"i18next": "^25.7.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"immer": "^11.0.1",
|
||||
"jspdf": "^4.0.0",
|
||||
"konva": "^9.3.6",
|
||||
"lucide-react": "^0.556.0",
|
||||
"qrcode": "^1.5.4",
|
||||
|
|
@ -35,8 +45,11 @@
|
|||
"react-i18next": "^16.4.1",
|
||||
"react-konva": "^18.2.10",
|
||||
"react-router-dom": "^7.10.1",
|
||||
"recharts": "^3.6.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"three": "0.165.0",
|
||||
"zod": "^4.3.4",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -60,4 +73,4 @@
|
|||
"vite": "^5.0.8",
|
||||
"vitest": "^1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/assets/logo-veridian.png
Normal file
|
After Width: | Height: | Size: 397 KiB |
67
frontend/public/download.html
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Download Veridian App</title>
|
||||
<style>
|
||||
body {
|
||||
background-color: #09090b;
|
||||
color: #ffffff;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background-color: #10b981;
|
||||
color: #ffffff;
|
||||
padding: 20px 40px;
|
||||
border-radius: 12px;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 1.2rem;
|
||||
display: inline-block;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.note {
|
||||
margin-top: 2rem;
|
||||
color: #a1a1aa;
|
||||
font-size: 0.9rem;
|
||||
max-width: 300px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<img src="/assets/logo-veridian.png" alt="Logo" width="80" style="margin-bottom: 20px; border-radius: 16px;">
|
||||
<h1>Veridian v2</h1>
|
||||
<a href="/veridian-v2.apk" class="btn">Download APK (27MB)</a>
|
||||
|
||||
<p class="note">
|
||||
1. Tap Download.<br>
|
||||
2. Open file.<br>
|
||||
3. Tap "Install".<br>
|
||||
<br>
|
||||
(If blocked, enable "Install from unknown sources")
|
||||
</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -67,8 +67,8 @@ export default function AnnouncementBanner() {
|
|||
const getPriorityStyles = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'CRITICAL': return 'bg-red-500/10 border-red-500/30';
|
||||
case 'WARNING': return 'bg-amber-500/10 border-amber-500/30';
|
||||
default: return 'bg-blue-500/10 border-blue-500/30';
|
||||
case 'WARNING': return 'bg-[var(--color-warning)]/10 border-amber-500/30';
|
||||
default: return 'bg-[var(--color-accent)]/10 border-blue-500/30';
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -101,7 +101,7 @@ export default function AnnouncementBanner() {
|
|||
)}
|
||||
</div>
|
||||
{!expanded && (
|
||||
<p className="text-sm text-slate-400 truncate">{topAnnouncement.body}</p>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] truncate">{topAnnouncement.body}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -109,7 +109,7 @@ export default function AnnouncementBanner() {
|
|||
{topAnnouncement.requiresAck && !topAnnouncement.isAcknowledged && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleAcknowledge(topAnnouncement.id); }}
|
||||
className="bg-emerald-500 hover:bg-emerald-600 text-white text-xs px-3 py-1.5 rounded flex items-center gap-1"
|
||||
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)] text-white text-xs px-3 py-1.5 rounded flex items-center gap-1"
|
||||
>
|
||||
<Check size={14} />
|
||||
Acknowledge
|
||||
|
|
@ -118,12 +118,12 @@ export default function AnnouncementBanner() {
|
|||
{!topAnnouncement.requiresAck && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDismiss(topAnnouncement.id); }}
|
||||
className="text-slate-400 hover:text-white p-1"
|
||||
className="text-[var(--color-text-tertiary)] hover:text-white p-1"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
{expanded ? <ChevronUp size={18} className="text-slate-400" /> : <ChevronDown size={18} className="text-slate-400" />}
|
||||
{expanded ? <ChevronUp size={18} className="text-[var(--color-text-tertiary)]" /> : <ChevronDown size={18} className="text-[var(--color-text-tertiary)]" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -141,13 +141,13 @@ export default function AnnouncementBanner() {
|
|||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-white">{a.title}</span>
|
||||
{a.isAcknowledged && (
|
||||
<span className="text-xs bg-emerald-500/20 text-emerald-400 px-2 py-0.5 rounded">
|
||||
<span className="text-xs bg-[var(--color-primary)]/20 text-emerald-400 px-2 py-0.5 rounded">
|
||||
Acknowledged
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-slate-300">{a.body}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
|
||||
By {a.createdBy.name} • {new Date(a.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -155,7 +155,7 @@ export default function AnnouncementBanner() {
|
|||
{a.requiresAck && !a.isAcknowledged && (
|
||||
<button
|
||||
onClick={() => handleAcknowledge(a.id)}
|
||||
className="bg-emerald-500 hover:bg-emerald-600 text-white text-xs px-3 py-1.5 rounded flex items-center gap-1"
|
||||
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)] text-white text-xs px-3 py-1.5 rounded flex items-center gap-1"
|
||||
>
|
||||
<Check size={14} />
|
||||
Acknowledge
|
||||
|
|
@ -164,7 +164,7 @@ export default function AnnouncementBanner() {
|
|||
{!a.requiresAck && !a.isRead && (
|
||||
<button
|
||||
onClick={() => handleDismiss(a.id)}
|
||||
className="text-slate-400 hover:text-white p-1"
|
||||
className="text-[var(--color-text-tertiary)] hover:text-white p-1"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -55,33 +55,33 @@ export default function BatchTransitionModal({ batch, onClose, onSuccess }: Tran
|
|||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in">
|
||||
<div className="bg-white dark:bg-slate-800 w-full max-w-md rounded-2xl shadow-2xl overflow-hidden animate-scale-in">
|
||||
<div className="p-6 border-b border-slate-200 dark:border-slate-700 flex justify-between items-center">
|
||||
<div className="bg-[var(--color-bg-elevated)] w-full max-w-md rounded-2xl shadow-2xl overflow-hidden animate-scale-in">
|
||||
<div className="p-6 border-b border-[var(--color-border-default)] flex justify-between items-center">
|
||||
<h2 className="text-xl font-bold dark:text-white">Transition Batch</h2>
|
||||
<button onClick={onClose} className="text-slate-500 hover:text-slate-700 dark:hover:text-slate-300">
|
||||
<button onClick={onClose} className="text-[var(--color-text-tertiary)] hover:text-slate-700 dark:hover:text-slate-300">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
<div className="bg-slate-50 dark:bg-slate-900 p-4 rounded-xl border border-slate-200 dark:border-slate-700">
|
||||
<div className="bg-[var(--color-bg-tertiary)] p-4 rounded-xl border border-[var(--color-border-default)]">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Sprout className="text-emerald-600 dark:text-emerald-400" size={20} />
|
||||
<span className="font-semibold text-slate-900 dark:text-white">{batch.name}</span>
|
||||
<Sprout className="text-[var(--color-primary)] dark:text-emerald-400" size={20} />
|
||||
<span className="font-semibold text-[var(--color-text-primary)]">{batch.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-text-tertiary)]">
|
||||
<span>{STAGES.find(s => s.id === batch.stage)?.label}</span>
|
||||
<ArrowRight size={14} />
|
||||
<span className="text-emerald-600 font-medium">{STAGES.find(s => s.id === targetStage)?.label}</span>
|
||||
<span className="text-[var(--color-primary)] font-medium">{STAGES.find(s => s.id === targetStage)?.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">New Stage</label>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">New Stage</label>
|
||||
<select
|
||||
value={targetStage}
|
||||
onChange={(e) => setTargetStage(e.target.value as any)}
|
||||
className="w-full p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 dark:text-white"
|
||||
className="w-full p-3 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] dark:text-white"
|
||||
>
|
||||
{STAGES.map(stage => (
|
||||
<option key={stage.id} value={stage.id}>{stage.label}</option>
|
||||
|
|
@ -90,7 +90,7 @@ export default function BatchTransitionModal({ batch, onClose, onSuccess }: Tran
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Home size={16} />
|
||||
Move to Room
|
||||
|
|
@ -99,7 +99,7 @@ export default function BatchTransitionModal({ batch, onClose, onSuccess }: Tran
|
|||
<select
|
||||
value={targetRoomId}
|
||||
onChange={(e) => setTargetRoomId(e.target.value)}
|
||||
className="w-full p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 dark:text-white"
|
||||
className="w-full p-3 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] dark:text-white"
|
||||
>
|
||||
<option value="">Keep current room ({batch.room?.name || 'Unassigned'})</option>
|
||||
{rooms.map(room => (
|
||||
|
|
@ -110,19 +110,19 @@ export default function BatchTransitionModal({ batch, onClose, onSuccess }: Tran
|
|||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Plant Count</label>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">Plant Count</label>
|
||||
<input
|
||||
type="number"
|
||||
value={metadata.plantCount}
|
||||
onChange={(e) => setMetadata({ ...metadata, plantCount: parseInt(e.target.value) || 0 })}
|
||||
className="w-full p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 dark:text-white"
|
||||
className="w-full p-3 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-4 bg-emerald-600 hover:bg-emerald-700 text-white font-bold rounded-xl shadow-lg flex items-center justify-center gap-2"
|
||||
className="w-full py-4 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white font-bold rounded-xl shadow-lg flex items-center justify-center gap-2"
|
||||
>
|
||||
Confirm Transition
|
||||
<ArrowRight size={20} />
|
||||
|
|
|
|||
|
|
@ -48,27 +48,27 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 flex items-center justify-center p-6">
|
||||
<div className="max-w-md w-full bg-white dark:bg-slate-800 rounded-2xl shadow-xl p-8 text-center">
|
||||
<div className="min-h-screen bg-[var(--color-bg-tertiary)] flex items-center justify-center p-6">
|
||||
<div className="max-w-md w-full bg-[var(--color-bg-elevated)] rounded-2xl shadow-xl p-8 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-6">
|
||||
<AlertTriangle size={32} className="text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">
|
||||
<h1 className="text-2xl font-bold text-[var(--color-text-primary)] mb-2">
|
||||
Something went wrong
|
||||
</h1>
|
||||
|
||||
<p className="text-slate-500 dark:text-slate-400 mb-6">
|
||||
<p className="text-[var(--color-text-tertiary)] mb-6">
|
||||
An unexpected error occurred. Don't worry, your data is safe.
|
||||
</p>
|
||||
|
||||
{/* Error Details (collapsible in production) */}
|
||||
{import.meta.env.DEV && this.state.error && (
|
||||
<details className="mb-6 text-left">
|
||||
<summary className="cursor-pointer text-sm text-slate-500 hover:text-slate-700 dark:hover:text-slate-300">
|
||||
<summary className="cursor-pointer text-sm text-[var(--color-text-tertiary)] hover:text-slate-700 dark:hover:text-slate-300">
|
||||
Technical Details
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 bg-slate-100 dark:bg-slate-700 rounded-lg text-xs text-red-600 dark:text-red-400 overflow-auto max-h-32">
|
||||
<pre className="mt-2 p-3 bg-[var(--color-bg-tertiary)] rounded-lg text-xs text-red-600 dark:text-red-400 overflow-auto max-h-32">
|
||||
{this.state.error.toString()}
|
||||
{this.state.errorInfo?.componentStack}
|
||||
</pre>
|
||||
|
|
@ -78,14 +78,14 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-emerald-600 hover:bg-emerald-700 text-white font-medium rounded-xl transition-colors"
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white font-medium rounded-xl transition-colors"
|
||||
>
|
||||
<RefreshCw size={18} />
|
||||
Try Again
|
||||
</button>
|
||||
<button
|
||||
onClick={this.handleGoHome}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-slate-200 dark:bg-slate-700 hover:bg-slate-300 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-xl transition-colors"
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-slate-200 dark:bg-slate-700 hover:bg-slate-300 dark:hover:bg-slate-600 text-[var(--color-text-secondary)]late-200 font-medium rounded-xl transition-colors"
|
||||
>
|
||||
<Home size={18} />
|
||||
Go Home
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export default function InfoTooltip({ content, size = 16 }: InfoTooltipProps) {
|
|||
e.stopPropagation();
|
||||
setIsVisible(!isVisible);
|
||||
}}
|
||||
className="text-slate-400 hover:text-emerald-500 focus:outline-none transition-colors"
|
||||
className="text-[var(--color-text-tertiary)] hover:text-[var(--color-primary)] focus:outline-none transition-colors"
|
||||
aria-label="More information"
|
||||
>
|
||||
<HelpCircle size={size} />
|
||||
|
|
|
|||
|
|
@ -15,38 +15,39 @@ import { pageVariants } from '../lib/animations';
|
|||
import { Search, Bell, Settings, Filter, ChevronDown } from 'lucide-react';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
import { UserMenu } from './layout/UserMenu';
|
||||
import { NotificationBell } from './notifications/NotificationBell';
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation();
|
||||
const [mobileSheetOpen, setMobileSheetOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-white dark:bg-[#0B0E14] text-slate-900 dark:text-slate-100 overflow-hidden font-sans">
|
||||
<div className="flex h-screen bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] overflow-hidden font-sans">
|
||||
{/* Accessibility: Skip to main content */}
|
||||
<a
|
||||
href="#main-content"
|
||||
className="absolute left-0 top-0 -translate-y-full bg-emerald-600 text-white px-4 py-2 rounded-br-lg font-medium focus:translate-y-0 z-50 transition-transform duration-fast"
|
||||
className="absolute left-0 top-0 -translate-y-full bg-[var(--color-primary)] text-white px-4 py-2 rounded-br-lg font-medium focus:translate-y-0 z-50 transition-transform duration-fast"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
|
||||
{/* Desktop Sidebar - Persistent on left */}
|
||||
<aside className="hidden lg:flex flex-col w-64 border-r border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-[#0B0E14] z-30">
|
||||
<div className="h-16 flex items-center px-6 border-b border-slate-200 dark:border-slate-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-600 flex items-center justify-center text-white font-bold text-xl italic shadow-lg shadow-emerald-500/20">
|
||||
<aside className="hidden lg:flex flex-col w-[260px] border-r border-[var(--color-border-subtle)] bg-[var(--color-bg-secondary)] z-30">
|
||||
<div className="h-16 flex items-center px-6 border-b border-[var(--color-border-subtle)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-[var(--color-primary)] flex items-center justify-center text-[var(--color-text-inverse)] font-bold text-lg shadow-lg shadow-emerald-500/20">
|
||||
V
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-bold uppercase tracking-widest leading-none">Veridian</span>
|
||||
<span className="text-[10px] text-slate-500 font-medium uppercase tracking-tighter leading-none mt-0.5">Platform v2.0</span>
|
||||
<span className="text-sm font-bold tracking-tight leading-none text-[var(--color-text-primary)]">Veridian</span>
|
||||
<span className="text-[10px] text-[var(--color-text-tertiary)] font-medium uppercase tracking-wider leading-none mt-0.5">Cultivation Platform</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto pt-4">
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className="p-4 border-t border-slate-200 dark:border-slate-800">
|
||||
<div className="p-4 border-t border-[var(--color-border-subtle)]">
|
||||
<UserMenu />
|
||||
</div>
|
||||
</aside>
|
||||
|
|
@ -54,48 +55,45 @@ export default function Layout() {
|
|||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden relative">
|
||||
{/* Topbar - Search, Global Filters, Vitals */}
|
||||
<header className="h-16 flex items-center justify-between px-4 sm:px-6 lg:px-8 border-b border-slate-200 dark:border-slate-800 bg-white/50 dark:bg-[#0B0E14]/50 backdrop-blur-md z-20">
|
||||
<header className="h-16 flex items-center justify-between px-4 sm:px-6 lg:px-8 border-b border-[var(--color-border-subtle)] bg-[var(--color-bg-secondary)]/80 backdrop-blur-xl z-20">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
{/* Facility Switcher / Filter */}
|
||||
<div className="hidden md:flex items-center gap-2 px-3 py-1.5 rounded-lg bg-slate-100 dark:bg-slate-900 border border-slate-200 dark:border-slate-800 cursor-pointer hover:bg-slate-200 dark:hover:bg-slate-800 transition-colors">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-slate-500">Facility</span>
|
||||
<span className="text-xs font-bold">NORCAL-01</span>
|
||||
<ChevronDown size={14} className="text-slate-500" />
|
||||
<div className="hidden md:flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--color-bg-tertiary)] border border-[var(--color-border-subtle)] cursor-pointer hover:border-[var(--color-border-default)] transition-all">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-tertiary)]">Facility</span>
|
||||
<span className="text-xs font-bold text-[var(--color-text-primary)]">NORCAL-01</span>
|
||||
<ChevronDown size={14} className="text-[var(--color-text-tertiary)]" />
|
||||
</div>
|
||||
|
||||
{/* Global Search */}
|
||||
<div className="relative group max-w-sm w-full hidden sm:block">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-emerald-500 transition-colors" size={16} />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-tertiary)] group-focus-within:text-[var(--color-primary)] transition-colors" size={16} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search batches, rooms, tasks..."
|
||||
className="w-full bg-slate-100 dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-lg pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 transition-all"
|
||||
className="w-full bg-[var(--color-bg-tertiary)] border border-[var(--color-border-subtle)] rounded-full pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)] transition-all"
|
||||
/>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1 opacity-50">
|
||||
<kbd className="text-[10px] font-mono">⌘</kbd>
|
||||
<kbd className="text-[10px] font-mono">K</kbd>
|
||||
<kbd className="text-[10px] font-mono text-[var(--color-text-tertiary)]">⌘</kbd>
|
||||
<kbd className="text-[10px] font-mono text-[var(--color-text-tertiary)]">K</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="hidden md:flex items-center gap-4 mr-4 text-[11px] font-bold uppercase tracking-widest text-slate-500">
|
||||
<div className="hidden md:flex items-center gap-4 mr-4 text-[11px] font-semibold uppercase tracking-wider text-[var(--color-text-tertiary)]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<div className="w-2 h-2 rounded-full bg-[var(--color-success)] animate-pulse" />
|
||||
<span>Terminal Live</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2 h-2 rounded-full bg-blue-500" />
|
||||
<div className="w-2 h-2 rounded-full bg-[var(--color-accent)]" />
|
||||
<span>32 Sensors Active</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ThemeToggle />
|
||||
<button className="relative p-2 text-slate-500 hover:text-emerald-500 transition-colors rounded-lg hover:bg-slate-100 dark:hover:bg-slate-900">
|
||||
<Bell size={20} />
|
||||
<span className="absolute top-2 right-2 w-2 h-2 bg-rose-500 rounded-full ring-2 ring-white dark:ring-[#0B0E14]" />
|
||||
</button>
|
||||
<button className="lg:hidden p-2 text-slate-500" onClick={() => setMobileSheetOpen(true)}>
|
||||
<NotificationBell />
|
||||
<button className="lg:hidden p-2 text-[var(--color-text-tertiary)]" onClick={() => setMobileSheetOpen(true)}>
|
||||
<Filter size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -110,7 +108,7 @@ export default function Layout() {
|
|||
<PageTitleUpdater />
|
||||
<AnnouncementBanner />
|
||||
|
||||
<div className="max-w-[1920px] mx-auto p-4 sm:p-6 lg:p-8">
|
||||
<div className="max-w-[1920px] mx-auto p-4 pb-24 sm:p-6 sm:pb-24 md:pb-8 lg:p-8">
|
||||
<Breadcrumbs />
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export default function ProtectedRoute({ children }: { children: React.ReactNode
|
|||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 flex items-center justify-center">
|
||||
<div className="min-h-screen bg-[var(--color-bg-tertiary)] flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-emerald-600"></div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -50,27 +50,27 @@ export default function TaskTemplateModal({ template, onClose, onSuccess }: Task
|
|||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in">
|
||||
<div className="bg-white dark:bg-slate-800 w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden animate-scale-in flex flex-col max-h-[90vh]">
|
||||
<div className="p-6 border-b border-slate-200 dark:border-slate-700 flex justify-between items-center">
|
||||
<div className="bg-[var(--color-bg-elevated)] w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden animate-scale-in flex flex-col max-h-[90vh]">
|
||||
<div className="p-6 border-b border-[var(--color-border-default)] flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="text-emerald-600 dark:text-emerald-400" size={24} />
|
||||
<FileText className="text-[var(--color-primary)] dark:text-emerald-400" size={24} />
|
||||
<h2 className="text-xl font-bold dark:text-white">
|
||||
{template ? 'Edit Template' : 'New Task Template'}
|
||||
</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-slate-500 hover:text-slate-700 dark:hover:text-slate-300">
|
||||
<button onClick={onClose} className="text-[var(--color-text-tertiary)] hover:text-slate-700 dark:hover:text-slate-300">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6 overflow-y-auto flex-1">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Title</label>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
className="w-full p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 dark:text-white"
|
||||
className="w-full p-3 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] dark:text-white"
|
||||
placeholder="e.g. Daily Veg Watering"
|
||||
required
|
||||
/>
|
||||
|
|
@ -78,11 +78,11 @@ export default function TaskTemplateModal({ template, onClose, onSuccess }: Task
|
|||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Room Type</label>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">Room Type</label>
|
||||
<select
|
||||
value={formData.roomType}
|
||||
onChange={(e) => setFormData({ ...formData, roomType: e.target.value })}
|
||||
className="w-full p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 dark:text-white"
|
||||
className="w-full p-3 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] dark:text-white"
|
||||
>
|
||||
{ROOM_TYPES.map(type => (
|
||||
<option key={type} value={type}>{type}</option>
|
||||
|
|
@ -90,43 +90,43 @@ export default function TaskTemplateModal({ template, onClose, onSuccess }: Task
|
|||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Est. Minutes</label>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">Est. Minutes</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.estimatedMinutes}
|
||||
onChange={(e) => setFormData({ ...formData, estimatedMinutes: parseInt(e.target.value) || 0 })}
|
||||
className="w-full p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 dark:text-white"
|
||||
className="w-full p-3 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Description / Instructions</label>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">Description / Instructions</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="w-full p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 dark:text-white h-32 resize-none"
|
||||
className="w-full p-3 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] dark:text-white h-32 resize-none"
|
||||
placeholder="Detailed SOP instructions..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Materials (comma separated)</label>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">Materials (comma separated)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={materialsInput}
|
||||
onChange={(e) => setMaterialsInput(e.target.value)}
|
||||
className="w-full p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 dark:text-white"
|
||||
className="w-full p-3 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] dark:text-white"
|
||||
placeholder="e.g. 5gal buckets, bamboo stakes, twist ties"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="p-6 border-t border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-900/50">
|
||||
<div className="p-6 border-t border-[var(--color-border-default)] bg-[var(--color-bg-tertiary)]/50">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-4 bg-emerald-600 hover:bg-emerald-700 text-white font-bold rounded-xl shadow-lg flex items-center justify-center gap-2 disabled:opacity-50 transition-all"
|
||||
className="w-full py-4 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white font-bold rounded-xl shadow-lg flex items-center justify-center gap-2 disabled:opacity-50 transition-all"
|
||||
>
|
||||
<Save size={20} />
|
||||
{isSubmitting ? 'Saving...' : 'Save Template'}
|
||||
|
|
|
|||
|
|
@ -45,29 +45,29 @@ export default function WeightLogModal({ batch, onClose, onSuccess }: WeightLogM
|
|||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm animate-fade-in">
|
||||
<div className="bg-white dark:bg-slate-800 w-full max-w-md rounded-2xl shadow-2xl overflow-hidden animate-scale-in">
|
||||
<div className="p-6 border-b border-slate-200 dark:border-slate-700 flex justify-between items-center">
|
||||
<div className="bg-[var(--color-bg-elevated)] w-full max-w-md rounded-2xl shadow-2xl overflow-hidden animate-scale-in">
|
||||
<div className="p-6 border-b border-[var(--color-border-default)] flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Scale className="text-emerald-600 dark:text-emerald-400" size={24} />
|
||||
<Scale className="text-[var(--color-primary)] dark:text-emerald-400" size={24} />
|
||||
<h2 className="text-xl font-bold dark:text-white">Log Weight</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-slate-500 hover:text-slate-700 dark:hover:text-slate-300">
|
||||
<button onClick={onClose} className="text-[var(--color-text-tertiary)] hover:text-slate-700 dark:hover:text-slate-300">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
<div className="bg-slate-50 dark:bg-slate-900 p-4 rounded-xl border border-slate-200 dark:border-slate-700">
|
||||
<h3 className="font-semibold text-slate-900 dark:text-white">{batch.name}</h3>
|
||||
<p className="text-sm text-slate-500">{batch.strain}</p>
|
||||
<div className="bg-[var(--color-bg-tertiary)] p-4 rounded-xl border border-[var(--color-border-default)]">
|
||||
<h3 className="font-semibold text-[var(--color-text-primary)]">{batch.name}</h3>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)]">{batch.strain}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Weight Type</label>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">Weight Type</label>
|
||||
<select
|
||||
value={weightType}
|
||||
onChange={(e) => setWeightType(e.target.value)}
|
||||
className="w-full p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 dark:text-white"
|
||||
className="w-full p-3 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] dark:text-white"
|
||||
>
|
||||
{WEIGHT_TYPES.map(type => (
|
||||
<option key={type.id} value={type.id}>{type.label}</option>
|
||||
|
|
@ -77,23 +77,23 @@ export default function WeightLogModal({ batch, onClose, onSuccess }: WeightLogM
|
|||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Weight</label>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">Weight</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={weight}
|
||||
onChange={(e) => setWeight(e.target.value)}
|
||||
className="w-full p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 dark:text-white"
|
||||
className="w-full p-3 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] dark:text-white"
|
||||
placeholder="0.00"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Unit</label>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">Unit</label>
|
||||
<select
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value)}
|
||||
className="w-full p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 dark:text-white"
|
||||
className="w-full p-3 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] dark:text-white"
|
||||
>
|
||||
<option value="lbs">lbs</option>
|
||||
<option value="g">grams</option>
|
||||
|
|
@ -104,11 +104,11 @@ export default function WeightLogModal({ batch, onClose, onSuccess }: WeightLogM
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Notes</label>
|
||||
<label className="block text-sm font-medium text-[var(--color-text-secondary)] mb-2">Notes</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
className="w-full p-3 rounded-lg bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 dark:text-white h-24 resize-none"
|
||||
className="w-full p-3 rounded-lg bg-[var(--color-bg-elevated)] border border-[var(--color-border-default)] dark:text-white h-24 resize-none"
|
||||
placeholder="Optional notes..."
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -116,7 +116,7 @@ export default function WeightLogModal({ batch, onClose, onSuccess }: WeightLogM
|
|||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-4 bg-emerald-600 hover:bg-emerald-700 text-white font-bold rounded-xl shadow-lg flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
className="w-full py-4 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] text-white font-bold rounded-xl shadow-lg flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<Save size={20} />
|
||||
{isSubmitting ? 'Saving...' : 'Save Weight Log'}
|
||||
|
|
|
|||
|
|
@ -75,8 +75,8 @@ export const BentoCard = ({
|
|||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="p-2 rounded-lg bg-slate-100 dark:bg-slate-900 group-hover:bg-cyan-50 dark:group-hover:bg-cyan-950/30 transition-colors duration-300">
|
||||
<Icon className="h-5 w-5 text-slate-600 dark:text-slate-400 group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors" />
|
||||
<div className="p-2 rounded-lg bg-[var(--color-bg-tertiary)] group-hover:bg-cyan-50 dark:group-hover:bg-cyan-950/30 transition-colors duration-300">
|
||||
<Icon className="h-5 w-5 text-slate-600 dark:text-[var(--color-text-tertiary)] group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-50 text-lg">
|
||||
{name}
|
||||
|
|
@ -86,14 +86,14 @@ export const BentoCard = ({
|
|||
{/* Metric Value (if provided) - Large display */}
|
||||
{value && (
|
||||
<div className="mt-4 mb-2">
|
||||
<span className="text-4xl font-bold text-slate-900 dark:text-white tracking-tight">
|
||||
<span className="text-4xl font-bold text-[var(--color-text-primary)] tracking-tight">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description / Subtext */}
|
||||
<p className="text-slate-500 dark:text-slate-400 text-sm leading-relaxed max-w-[90%]">
|
||||
<p className="text-[var(--color-text-tertiary)] text-sm leading-relaxed max-w-[90%]">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ const Feature3D = ({ onEnter }: Feature3DProps) => {
|
|||
<h2 className="text-4xl md:text-5xl font-bold tracking-tight mb-6 bg-clip-text text-transparent bg-gradient-to-r from-cyan-400 to-purple-500">
|
||||
Digital Twin <br /> Visualization
|
||||
</h2>
|
||||
<p className="text-xl text-slate-400 mb-8 leading-relaxed">
|
||||
<p className="text-xl text-[var(--color-text-tertiary)] mb-8 leading-relaxed">
|
||||
Experience your facility like never before. Our real-time 3D engine maps every sensor, plant, and compliance event to its exact physical location.
|
||||
</p>
|
||||
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ const Hero1 = () => {
|
|||
</>
|
||||
) : isSelected ? (
|
||||
<>
|
||||
<Copy className="h-3 w-3 text-blue-500" />
|
||||
<Copy className="h-3 w-3 text-[var(--color-accent)]" />
|
||||
<span className="text-muted-foreground">
|
||||
Analyzing Input
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -53,13 +53,13 @@ export function Navbar({ onOpenMobileMenu }: NavbarProps) {
|
|||
alt="Veridian"
|
||||
className="w-9 h-9 rounded-lg shadow-md ring-1 ring-slate-900/5 group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
<div className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-emerald-500 rounded-full border-2 border-white dark:border-[#050505] animate-pulse" />
|
||||
<div className="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 bg-[var(--color-primary)] rounded-full border-2 border-white dark:border-[#050505] animate-pulse" />
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<h1 className="text-sm font-bold text-slate-900 dark:text-white leading-tight tracking-tighter uppercase italic">
|
||||
<h1 className="text-sm font-bold text-[var(--color-text-primary)] leading-tight tracking-tighter uppercase italic">
|
||||
Veridian
|
||||
</h1>
|
||||
<p className="text-[9px] font-bold text-slate-400 dark:text-slate-500 uppercase tracking-[0.3em] leading-none">
|
||||
<p className="text-[9px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-[0.3em] leading-none">
|
||||
Cultivation Platform
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -82,20 +82,20 @@ export function Navbar({ onOpenMobileMenu }: NavbarProps) {
|
|||
{/* Search Mock */}
|
||||
<button
|
||||
onClick={() => dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))}
|
||||
className="hidden md:flex items-center gap-3 px-4 py-2 text-xs font-bold text-slate-500 bg-slate-100/50 hover:bg-white dark:bg-slate-900/50 dark:hover:bg-slate-800 transition-all rounded-full border border-slate-200/50 dark:border-slate-800 group"
|
||||
className="hidden md:flex items-center gap-3 px-4 py-2 text-xs font-bold text-[var(--color-text-tertiary)] bg-slate-100/50 hover:bg-[var(--color-bg-elevated)]/50 dark:hover:bg-slate-800 transition-all rounded-full border border-slate-200/50 dark:border-slate-800 group"
|
||||
>
|
||||
<Search size={12} className="group-hover:text-indigo-500 transition-colors" />
|
||||
<span className="uppercase tracking-widest">Search...</span>
|
||||
<kbd className="hidden lg:inline-flex h-5 items-center gap-1 rounded bg-slate-200 dark:bg-slate-800 px-1.5 font-mono text-[9px] text-slate-500">
|
||||
<kbd className="hidden lg:inline-flex h-5 items-center gap-1 rounded bg-slate-200 dark:bg-slate-800 px-1.5 font-mono text-[9px] text-[var(--color-text-tertiary)]">
|
||||
⌘K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-slate-200 dark:bg-slate-800 mx-1 hidden sm:block" />
|
||||
|
||||
<button className="relative p-2 text-slate-400 hover:text-indigo-500 transition-colors rounded-xl hover:bg-slate-100 dark:hover:bg-slate-900">
|
||||
<button className="relative p-2 text-[var(--color-text-tertiary)] hover:text-indigo-500 transition-colors rounded-xl hover:bg-slate-100 dark:hover:bg-slate-900">
|
||||
<Bell size={18} />
|
||||
<span className="absolute top-2.5 right-2.5 w-1.5 h-1.5 bg-rose-500 rounded-full ring-2 ring-white dark:ring-[#050505]" />
|
||||
<span className="absolute top-2.5 right-2.5 w-1.5 h-1.5 bg-[var(--color-error)] rounded-full ring-2 ring-white dark:ring-[#050505]" />
|
||||
</button>
|
||||
|
||||
<ThemeToggle />
|
||||
|
|
@ -106,7 +106,7 @@ export function Navbar({ onOpenMobileMenu }: NavbarProps) {
|
|||
|
||||
<button
|
||||
onClick={onOpenMobileMenu}
|
||||
className="lg:hidden p-2 text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
||||
className="lg:hidden p-2 text-[var(--color-text-tertiary)] hover:text-slate-900 dark:text-[var(--color-text-tertiary)] dark:hover:text-white"
|
||||
>
|
||||
<Menu size={24} />
|
||||
</button>
|
||||
|
|
@ -150,14 +150,14 @@ function UserDropdown() {
|
|||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
className="absolute top-full right-0 mt-3 w-64 p-3 bg-white/95 dark:bg-slate-900/95 backdrop-blur-2xl border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl z-50 ring-1 ring-black/5"
|
||||
className="absolute top-full right-0 mt-3 w-64 p-3 bg-white/95 dark:bg-slate-900/95 backdrop-blur-2xl border border-[var(--color-border-subtle)] rounded-2xl shadow-2xl z-50 ring-1 ring-black/5"
|
||||
>
|
||||
<div className="px-3 py-3 border-b border-slate-100 dark:border-slate-800 mb-2">
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-1">Authenticated</p>
|
||||
<p className="text-sm font-bold text-slate-900 dark:text-white truncate">
|
||||
<div className="px-3 py-3 border-b border-[var(--color-border-subtle)] mb-2">
|
||||
<p className="text-xs font-bold text-[var(--color-text-tertiary)] uppercase tracking-widest mb-1">Authenticated</p>
|
||||
<p className="text-sm font-bold text-[var(--color-text-primary)] truncate">
|
||||
{user?.name || 'Administrator'}
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-500 truncate font-mono mt-1">
|
||||
<p className="text-[10px] text-[var(--color-text-tertiary)] truncate font-mono mt-1">
|
||||
{user?.email}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -165,7 +165,7 @@ function UserDropdown() {
|
|||
<Link
|
||||
to="/settings"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="flex items-center gap-3 px-3 py-2.5 text-xs font-bold uppercase tracking-widest text-slate-600 hover:text-indigo-600 hover:bg-indigo-50 dark:text-slate-400 dark:hover:text-indigo-400 dark:hover:bg-indigo-500/10 rounded-xl transition-all"
|
||||
className="flex items-center gap-3 px-3 py-2.5 text-xs font-bold uppercase tracking-widest text-slate-600 hover:text-indigo-600 hover:bg-indigo-50 dark:text-[var(--color-text-tertiary)] dark:hover:text-indigo-400 dark:hover:bg-indigo-500/10 rounded-xl transition-all"
|
||||
>
|
||||
<Settings size={14} />
|
||||
Settings
|
||||
|
|
@ -204,7 +204,7 @@ function NavDropdown({ section, currentPath }: { section: NavSection, currentPat
|
|||
flex items-center gap-2 px-3.5 py-2 text-[11px] font-bold uppercase tracking-[0.15em] rounded-lg transition-all
|
||||
${isActive || isOpen
|
||||
? 'text-indigo-600 bg-indigo-500/5 dark:text-indigo-400 dark:bg-indigo-500/10'
|
||||
: 'text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white'
|
||||
: 'text-[var(--color-text-tertiary)] hover:text-slate-900 dark:text-[var(--color-text-tertiary)] dark:hover:text-white'
|
||||
}
|
||||
`}
|
||||
>
|
||||
|
|
@ -222,10 +222,10 @@ function NavDropdown({ section, currentPath }: { section: NavSection, currentPat
|
|||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 8, scale: 0.98 }}
|
||||
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="absolute top-full left-0 mt-2 w-72 p-3 bg-white/95 dark:bg-slate-900/95 backdrop-blur-2xl border border-slate-200 dark:border-slate-800 rounded-2xl shadow-2xl z-50"
|
||||
className="absolute top-full left-0 mt-2 w-72 p-3 bg-white/95 dark:bg-slate-900/95 backdrop-blur-2xl border border-[var(--color-border-subtle)] rounded-2xl shadow-2xl z-50"
|
||||
>
|
||||
<div className="grid gap-1">
|
||||
<p className="px-3 py-1.5 text-[9px] font-bold text-slate-400 uppercase tracking-[0.2em]">{section.label}</p>
|
||||
<p className="px-3 py-1.5 text-[9px] font-bold text-[var(--color-text-tertiary)] uppercase tracking-[0.2em]">{section.label}</p>
|
||||
{section.items.map(item => (
|
||||
<Link
|
||||
key={item.id}
|
||||
|
|
@ -234,13 +234,13 @@ function NavDropdown({ section, currentPath }: { section: NavSection, currentPat
|
|||
group flex items-center gap-4 px-3 py-3 rounded-xl transition-all
|
||||
${item.path === currentPath
|
||||
? 'bg-indigo-500/10 text-indigo-600 dark:text-indigo-400'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:text-[var(--color-text-tertiary)] dark:hover:bg-slate-800 dark:hover:text-slate-100'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className={cn(
|
||||
"p-2 rounded-lg transition-all group-hover:scale-110",
|
||||
item.path === currentPath ? "bg-indigo-500 text-white" : "bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400"
|
||||
item.path === currentPath ? "bg-indigo-500 text-white" : "bg-slate-100 dark:bg-slate-800 text-[var(--color-text-tertiary)]"
|
||||
)}>
|
||||
<item.icon size={14} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -24,9 +24,9 @@ const commonStyles = {
|
|||
input:
|
||||
"w-full py-3.5 pl-12 pr-4 text-sm text-slate-900 placeholder-transparent bg-white/70 backdrop-blur-md border border-slate-200 rounded-full focus:outline-none focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500 peer transition-all duration-300 disabled:opacity-50",
|
||||
inputIcon:
|
||||
"absolute left-4 text-slate-500 peer-focus:text-cyan-500 transition-colors w-5 h-5",
|
||||
"absolute left-4 text-[var(--color-text-tertiary)] peer-focus:text-cyan-500 transition-colors w-5 h-5",
|
||||
label:
|
||||
"absolute left-12 text-slate-500 text-sm transition-all duration-200 peer-placeholder-shown:top-3.5 peer-placeholder-shown:text-slate-400 peer-placeholder-shown:text-base peer-focus:top-1.5 peer-focus:text-xs peer-focus:text-cyan-600",
|
||||
"absolute left-12 text-[var(--color-text-tertiary)] text-sm transition-all duration-200 peer-placeholder-shown:top-3.5 peer-placeholder-shown:text-[var(--color-text-tertiary)] peer-placeholder-shown:text-base peer-focus:top-1.5 peer-focus:text-xs peer-focus:text-cyan-600",
|
||||
button:
|
||||
"w-full py-3.5 px-6 font-semibold rounded-full bg-gradient-to-r from-cyan-600 to-blue-600 text-white shadow-lg hover:opacity-90 hover:shadow-cyan-500/30 transition-all duration-300 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
link: "font-medium text-cyan-600 hover:text-cyan-700 transition-colors hover:underline",
|
||||
|
|
@ -98,7 +98,7 @@ export const VisitorKioskShell = ({ children, onBack, title, subtitle }: Visitor
|
|||
{onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="absolute top-8 left-8 hidden lg:flex items-center gap-2 text-slate-400 hover:text-slate-600 transition-colors"
|
||||
className="absolute top-8 left-8 hidden lg:flex items-center gap-2 text-[var(--color-text-tertiary)] hover:text-slate-600 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={20} /> Back
|
||||
</button>
|
||||
|
|
@ -350,7 +350,7 @@ const VisitorCheckIn = ({ onBack, onSuccess }: VisitorCheckInProps) => {
|
|||
{/* Visitor Type Selector */}
|
||||
<div className={commonStyles.inputWrapper}>
|
||||
<Shield className={commonStyles.inputIcon} />
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-slate-400">
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-[var(--color-text-tertiary)]">
|
||||
<ChevronDown size={16} />
|
||||
</div>
|
||||
<select
|
||||
|
|
@ -365,7 +365,7 @@ const VisitorCheckIn = ({ onBack, onSuccess }: VisitorCheckInProps) => {
|
|||
<option value="VENDOR">Vendor</option>
|
||||
<option value="DELIVERY">Delivery</option>
|
||||
</select>
|
||||
<label className="absolute left-12 top-1.5 text-xs text-slate-500 font-medium transition-all duration-200">
|
||||
<label className="absolute left-12 top-1.5 text-xs text-[var(--color-text-tertiary)] font-medium transition-all duration-200">
|
||||
Visit Type
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -376,7 +376,7 @@ const VisitorCheckIn = ({ onBack, onSuccess }: VisitorCheckInProps) => {
|
|||
<button
|
||||
type="button"
|
||||
onClick={startCamera}
|
||||
className="w-full h-32 flex flex-col items-center justify-center gap-2 text-slate-400 hover:text-cyan-600 transition-colors"
|
||||
className="w-full h-32 flex flex-col items-center justify-center gap-2 text-[var(--color-text-tertiary)] hover:text-cyan-600 transition-colors"
|
||||
disabled={loading}
|
||||
>
|
||||
<Camera className="w-8 h-8 opacity-50 group-hover:opacity-100" />
|
||||
|
|
@ -429,7 +429,7 @@ const VisitorCheckIn = ({ onBack, onSuccess }: VisitorCheckInProps) => {
|
|||
</button>
|
||||
</div>
|
||||
<div className="absolute top-2 right-2">
|
||||
<span className="bg-emerald-500 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1 shadow-sm">
|
||||
<span className="bg-[var(--color-primary)] text-white text-xs px-2 py-1 rounded-full flex items-center gap-1 shadow-sm">
|
||||
<CheckCircle size={10} /> Photo Ready
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -451,7 +451,7 @@ const VisitorCheckIn = ({ onBack, onSuccess }: VisitorCheckInProps) => {
|
|||
/>
|
||||
<div className="text-sm">
|
||||
<span className="text-slate-700 font-medium">Agreement Required</span>
|
||||
<p className="text-slate-500 text-xs mt-0.5">I agree to the Veridian Non-Disclosure Agreement and safety policies.</p>
|
||||
<p className="text-[var(--color-text-tertiary)] text-xs mt-0.5">I agree to the Veridian Non-Disclosure Agreement and safety policies.</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
|
|
|
|||
246
frontend/src/components/cameras/CameraWidget.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Camera as CameraIcon, Video, VideoOff, ChevronRight, Eye, Settings, AlertCircle } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { VideoPlayer } from './VideoPlayer';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface Camera {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
streamKey: string;
|
||||
location?: string;
|
||||
status: 'ONLINE' | 'OFFLINE' | 'IDLE' | 'STREAMING' | 'ERROR';
|
||||
manufacturer?: string;
|
||||
room?: {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
type: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CameraWidgetProps {
|
||||
cameras?: Camera[];
|
||||
maxVisible?: number;
|
||||
onCameraClick?: (camera: Camera) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard widget showing camera grid with live previews
|
||||
*/
|
||||
export function CameraWidget({ cameras = [], maxVisible = 4, onCameraClick }: CameraWidgetProps) {
|
||||
const navigate = useNavigate();
|
||||
const [selectedCamera, setSelectedCamera] = useState<Camera | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const visibleCameras = cameras.slice(0, maxVisible);
|
||||
const remainingCount = cameras.length - maxVisible;
|
||||
|
||||
const handleCameraClick = (camera: Camera) => {
|
||||
if (onCameraClick) {
|
||||
onCameraClick(camera);
|
||||
} else {
|
||||
setSelectedCamera(camera);
|
||||
setShowModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: Camera['status']) => {
|
||||
switch (status) {
|
||||
case 'ONLINE':
|
||||
case 'STREAMING':
|
||||
return 'bg-emerald-500';
|
||||
case 'IDLE':
|
||||
return 'bg-amber-500';
|
||||
case 'OFFLINE':
|
||||
return 'bg-gray-400';
|
||||
case 'ERROR':
|
||||
return 'bg-red-500';
|
||||
default:
|
||||
return 'bg-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: Camera['status']) => {
|
||||
switch (status) {
|
||||
case 'STREAMING':
|
||||
return 'Live';
|
||||
case 'IDLE':
|
||||
return 'Ready';
|
||||
default:
|
||||
return status.charAt(0) + status.slice(1).toLowerCase();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={cn(
|
||||
"overflow-hidden rounded-2xl",
|
||||
"bg-white dark:bg-zinc-900/80",
|
||||
"border border-gray-200 dark:border-zinc-700/50",
|
||||
"shadow-sm dark:shadow-xl"
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-5 pb-4 flex items-center justify-between border-b border-gray-100 dark:border-zinc-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-purple-100 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400">
|
||||
<Video size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-white tracking-wide">
|
||||
Security Cameras
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-zinc-500">
|
||||
{cameras.filter(c => c.status !== 'OFFLINE').length} of {cameras.length} online
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/cameras')}
|
||||
className="flex items-center gap-1 text-xs text-gray-400 hover:text-emerald-500 dark:text-zinc-500 dark:hover:text-emerald-400 transition-colors"
|
||||
>
|
||||
View All
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Camera Grid */}
|
||||
{cameras.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<CameraIcon className="w-12 h-12 text-gray-300 dark:text-zinc-700 mx-auto mb-3" />
|
||||
<p className="text-sm text-gray-500 dark:text-zinc-500">No cameras configured</p>
|
||||
<button
|
||||
onClick={() => navigate('/settings/cameras')}
|
||||
className="mt-3 text-xs text-emerald-600 dark:text-emerald-400 hover:underline"
|
||||
>
|
||||
Add Camera
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 grid grid-cols-2 gap-3">
|
||||
{visibleCameras.map((camera) => (
|
||||
<motion.button
|
||||
key={camera.id}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={() => handleCameraClick(camera)}
|
||||
className={cn(
|
||||
"relative aspect-video rounded-xl overflow-hidden cursor-pointer transition-all",
|
||||
"bg-gray-100 dark:bg-zinc-800",
|
||||
"border border-gray-200 dark:border-zinc-700",
|
||||
"hover:border-emerald-500/50 hover:shadow-lg hover:shadow-emerald-500/10"
|
||||
)}
|
||||
>
|
||||
{/* Placeholder/Thumbnail */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{camera.status === 'OFFLINE' || camera.status === 'ERROR' ? (
|
||||
<VideoOff className="w-8 h-8 text-gray-400 dark:text-zinc-600" />
|
||||
) : (
|
||||
<CameraIcon className="w-8 h-8 text-gray-400 dark:text-zinc-600" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="absolute top-2 left-2 flex items-center gap-1.5 px-2 py-0.5 bg-black/60 rounded-full">
|
||||
<div className={cn("w-1.5 h-1.5 rounded-full", getStatusColor(camera.status))} />
|
||||
<span className="text-[10px] font-medium text-white">
|
||||
{getStatusText(camera.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Camera Name */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/80 to-transparent">
|
||||
<p className="text-xs font-medium text-white truncate">{camera.name}</p>
|
||||
{camera.room && (
|
||||
<p className="text-[10px] text-gray-300 truncate">{camera.room.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View Overlay */}
|
||||
<div className="absolute inset-0 bg-emerald-500/0 hover:bg-emerald-500/10 transition-colors flex items-center justify-center opacity-0 hover:opacity-100">
|
||||
<div className="p-2 bg-white/20 rounded-full backdrop-blur-sm">
|
||||
<Eye size={16} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* More cameras indicator */}
|
||||
{remainingCount > 0 && (
|
||||
<div className="px-4 pb-4">
|
||||
<button
|
||||
onClick={() => navigate('/cameras')}
|
||||
className="w-full py-2 text-center text-xs text-gray-500 dark:text-zinc-500 hover:text-emerald-600 dark:hover:text-emerald-400 border border-dashed border-gray-200 dark:border-zinc-700 rounded-lg transition-colors"
|
||||
>
|
||||
+{remainingCount} more camera{remainingCount > 1 ? 's' : ''}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Video Modal */}
|
||||
{showModal && selectedCamera && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
||||
onClick={() => setShowModal(false)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="relative w-full max-w-4xl mx-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-white">{selectedCamera.name}</h2>
|
||||
{selectedCamera.location && (
|
||||
<p className="text-sm text-gray-400">{selectedCamera.location}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="p-2 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Video Player */}
|
||||
<VideoPlayer
|
||||
streamKey={selectedCamera.streamKey}
|
||||
className="aspect-video"
|
||||
showControls
|
||||
autoPlay
|
||||
/>
|
||||
|
||||
{/* Camera Info */}
|
||||
<div className="mt-3 flex items-center justify-between text-sm text-gray-400">
|
||||
<div className="flex items-center gap-4">
|
||||
{selectedCamera.manufacturer && (
|
||||
<span>{selectedCamera.manufacturer}</span>
|
||||
)}
|
||||
{selectedCamera.room && (
|
||||
<span>{selectedCamera.room.name}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs">Stream: {selectedCamera.streamKey}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default CameraWidget;
|
||||
300
frontend/src/components/cameras/VideoPlayer.tsx
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Video, VideoOff, Volume2, VolumeX, Maximize2, RefreshCw, Wifi, WifiOff } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface VideoPlayerProps {
|
||||
streamKey: string;
|
||||
className?: string;
|
||||
showControls?: boolean;
|
||||
autoPlay?: boolean;
|
||||
muted?: boolean;
|
||||
onError?: (error: Error) => void;
|
||||
onStatusChange?: (status: 'connecting' | 'connected' | 'disconnected' | 'error') => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebRTC video player for go2rtc streams
|
||||
* Connects to go2rtc's WebSocket API for low-latency video playback
|
||||
*/
|
||||
export function VideoPlayer({
|
||||
streamKey,
|
||||
className,
|
||||
showControls = true,
|
||||
autoPlay = true,
|
||||
muted = true,
|
||||
onError,
|
||||
onStatusChange
|
||||
}: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const pcRef = useRef<RTCPeerConnection | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
const [status, setStatus] = useState<'connecting' | 'connected' | 'disconnected' | 'error'>('disconnected');
|
||||
const [isMuted, setIsMuted] = useState(muted);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [reconnectCount, setReconnectCount] = useState(0);
|
||||
|
||||
// go2rtc base URL - uses relative path since it's behind the same domain via Traefik
|
||||
const go2rtcBase = '/monitor';
|
||||
|
||||
const updateStatus = useCallback((newStatus: typeof status) => {
|
||||
setStatus(newStatus);
|
||||
onStatusChange?.(newStatus);
|
||||
}, [onStatusChange]);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
if (pcRef.current) {
|
||||
pcRef.current.close();
|
||||
pcRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
cleanup();
|
||||
updateStatus('connecting');
|
||||
|
||||
try {
|
||||
// Create RTCPeerConnection
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' }
|
||||
]
|
||||
});
|
||||
pcRef.current = pc;
|
||||
|
||||
// Handle incoming tracks
|
||||
pc.ontrack = (event) => {
|
||||
if (videoRef.current && event.streams[0]) {
|
||||
videoRef.current.srcObject = event.streams[0];
|
||||
updateStatus('connected');
|
||||
}
|
||||
};
|
||||
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
if (pc.iceConnectionState === 'disconnected' || pc.iceConnectionState === 'failed') {
|
||||
updateStatus('disconnected');
|
||||
}
|
||||
};
|
||||
|
||||
// Open WebSocket to go2rtc
|
||||
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}${go2rtcBase}/api/ws?src=${encodeURIComponent(streamKey)}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = async () => {
|
||||
// Add transceivers for receiving video/audio
|
||||
pc.addTransceiver('video', { direction: 'recvonly' });
|
||||
pc.addTransceiver('audio', { direction: 'recvonly' });
|
||||
|
||||
// Create and send offer
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'webrtc/offer',
|
||||
value: pc.localDescription?.sdp
|
||||
}));
|
||||
};
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
|
||||
if (msg.type === 'webrtc/answer') {
|
||||
await pc.setRemoteDescription({
|
||||
type: 'answer',
|
||||
sdp: msg.value
|
||||
});
|
||||
} else if (msg.type === 'webrtc/candidate') {
|
||||
if (msg.value) {
|
||||
await pc.addIceCandidate({
|
||||
candidate: msg.value,
|
||||
sdpMid: '0'
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
updateStatus('error');
|
||||
onError?.(new Error('WebSocket connection failed'));
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (status !== 'error') {
|
||||
updateStatus('disconnected');
|
||||
}
|
||||
};
|
||||
|
||||
// Send ICE candidates
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'webrtc/candidate',
|
||||
value: event.candidate.candidate
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to connect:', error);
|
||||
updateStatus('error');
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}, [streamKey, cleanup, updateStatus, onError, status]);
|
||||
|
||||
// Initial connection
|
||||
useEffect(() => {
|
||||
if (autoPlay) {
|
||||
connect();
|
||||
}
|
||||
return cleanup;
|
||||
}, [streamKey, autoPlay, connect, cleanup]);
|
||||
|
||||
// Reconnect logic
|
||||
const handleReconnect = useCallback(() => {
|
||||
setReconnectCount(c => c + 1);
|
||||
connect();
|
||||
}, [connect]);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.muted = !videoRef.current.muted;
|
||||
setIsMuted(!isMuted);
|
||||
}
|
||||
}, [isMuted]);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
if (!document.fullscreenElement) {
|
||||
videoRef.current.requestFullscreen();
|
||||
setIsFullscreen(true);
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"relative overflow-hidden rounded-xl bg-black",
|
||||
className
|
||||
)}>
|
||||
{/* Video Element */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted={isMuted}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
|
||||
{/* Status Overlay */}
|
||||
{status !== 'connected' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/80">
|
||||
<div className="text-center">
|
||||
{status === 'connecting' && (
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
|
||||
>
|
||||
<RefreshCw className="w-8 h-8 text-emerald-400 mx-auto mb-2" />
|
||||
</motion.div>
|
||||
)}
|
||||
{status === 'disconnected' && (
|
||||
<WifiOff className="w-8 h-8 text-gray-400 mx-auto mb-2" />
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<VideoOff className="w-8 h-8 text-red-400 mx-auto mb-2" />
|
||||
)}
|
||||
<p className="text-sm text-gray-400 capitalize">{status}</p>
|
||||
{(status === 'disconnected' || status === 'error') && (
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
className="mt-3 px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-sm rounded-lg transition-colors"
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls Overlay */}
|
||||
{showControls && status === 'connected' && (
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-black/80 to-transparent opacity-0 hover:opacity-100 transition-opacity">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-emerald-400">
|
||||
<Wifi size={12} />
|
||||
<span className="font-medium">LIVE</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
{isMuted ? (
|
||||
<VolumeX size={18} className="text-white" />
|
||||
) : (
|
||||
<Volume2 size={18} className="text-white" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
<Maximize2 size={18} className="text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live indicator */}
|
||||
{status === 'connected' && (
|
||||
<div className="absolute top-3 left-3 flex items-center gap-1.5 px-2 py-1 bg-red-600 rounded text-xs text-white font-bold">
|
||||
<div className="w-2 h-2 bg-white rounded-full animate-pulse" />
|
||||
LIVE
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback MSE player for browsers without WebRTC support
|
||||
*/
|
||||
export function VideoPlayerMSE({
|
||||
streamKey,
|
||||
className,
|
||||
showControls = true
|
||||
}: Omit<VideoPlayerProps, 'autoPlay' | 'muted' | 'onError' | 'onStatusChange'>) {
|
||||
const go2rtcBase = '/monitor';
|
||||
const mseUrl = `${go2rtcBase}/api/stream.mp4?src=${encodeURIComponent(streamKey)}`;
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"relative overflow-hidden rounded-xl bg-black",
|
||||
className
|
||||
)}>
|
||||
<video
|
||||
src={mseUrl}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
controls={showControls}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VideoPlayer;
|
||||