more features

This commit is contained in:
Graham McIntire 2026-01-02 14:06:44 -06:00
parent fd5c6b3d72
commit 93e0b869be
No known key found for this signature in database
21 changed files with 1099 additions and 523 deletions

482
DEPLOYMENT.md Normal file
View file

@ -0,0 +1,482 @@
# TowerOps Production Deployment Guide
This guide covers deploying TowerOps to production with TimescaleDB, email notifications, and monitoring capabilities.
## Prerequisites
- PostgreSQL 14+ with TimescaleDB extension installed
- Elixir 1.19+ and Erlang 27+
- SSL certificate for HTTPS
- SMTP server for email notifications
## Environment Variables
Create a `.env.prod` file or configure these environment variables in your deployment platform:
### Required
```bash
# Secret key for encryption (generate with: mix phx.gen.secret)
SECRET_KEY_BASE=your-secret-key-here
# Database URL
DATABASE_URL=postgresql://user:password@host:port/towerops_prod
# Application URL
PHX_HOST=towerops.example.com
PORT=4000
# Environment
MIX_ENV=prod
```
### Email Configuration
```bash
# SMTP settings for alert notifications
SMTP_RELAY=smtp.example.com
SMTP_USERNAME=your-smtp-username
SMTP_PASSWORD=your-smtp-password
SMTP_PORT=587
SMTP_TLS=true
# From address for emails
ALERT_FROM_EMAIL=alerts@towerops.example.com
ALERT_FROM_NAME="TowerOps Alerts"
```
### Optional
```bash
# Custom check interval (default: 300 seconds / 5 minutes)
DEFAULT_CHECK_INTERVAL_SECONDS=300
# TimescaleDB retention (default: 90 days)
MONITORING_RETENTION_DAYS=90
# TimescaleDB compression (default: 7 days)
MONITORING_COMPRESSION_DAYS=7
```
## Database Setup
**Important**: TimescaleDB must be installed on your PostgreSQL server. See [TIMESCALEDB.md](TIMESCALEDB.md) for installation instructions.
### 1. Install TimescaleDB Extension
Connect to your PostgreSQL database and enable TimescaleDB:
```sql
CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE;
```
### 2. Create Database
```bash
# Development
mix ecto.create
# Production
MIX_ENV=prod mix ecto.create
```
### 3. Run Migrations
```bash
# Development
mix ecto.migrate
# Production
MIX_ENV=prod mix ecto.migrate
```
This will:
- Create all necessary tables
- Convert `monitoring_checks` to TimescaleDB hypertable
- Set up retention policies (auto-delete data older than 90 days)
- Configure compression policies (compress data older than 7 days)
- Create continuous aggregates for dashboard performance
## Application Configuration
### Production Config
Update `config/runtime.exs` with your production settings. Key configurations:
#### Database Pool Size
```elixir
config :towerops, Towerops.Repo,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")
```
#### Mailer Configuration
The application uses Swoosh for email delivery. Update the mailer configuration:
```elixir
config :towerops, Towerops.Mailer,
adapter: Swoosh.Adapters.SMTP,
relay: System.get_env("SMTP_RELAY"),
username: System.get_env("SMTP_USERNAME"),
password: System.get_env("SMTP_PASSWORD"),
port: String.to_integer(System.get_env("SMTP_PORT") || "587"),
tls: :always
```
## Building for Production
### 1. Install Dependencies
```bash
mix deps.get --only prod
```
### 2. Compile Application
```bash
MIX_ENV=prod mix compile
```
### 3. Build Assets
```bash
MIX_ENV=prod mix assets.deploy
```
This will:
- Build and minify CSS with Tailwind v4
- Bundle JavaScript with esbuild
- Generate digested asset files
### 4. Create Release
```bash
MIX_ENV=prod mix release
```
The release will be created in `_build/prod/rel/towerops/`.
## Deployment
### Using Releases
```bash
# Start the release
_build/prod/rel/towerops/bin/towerops start
# Run as daemon
_build/prod/rel/towerops/bin/towerops daemon
# Check status
_build/prod/rel/towerops/bin/towerops pid
# Stop
_build/prod/rel/towerops/bin/towerops stop
```
### Using systemd
Create `/etc/systemd/system/towerops.service`:
```ini
[Unit]
Description=TowerOps Monitoring Service
After=network.target postgresql.service
[Service]
Type=forking
User=towerops
Group=towerops
WorkingDirectory=/opt/towerops
Environment="PORT=4000"
Environment="MIX_ENV=prod"
ExecStart=/opt/towerops/_build/prod/rel/towerops/bin/towerops daemon
ExecStop=/opt/towerops/_build/prod/rel/towerops/bin/towerops stop
Restart=on-failure
RestartSec=5
SyslogIdentifier=towerops
[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
sudo systemctl daemon-reload
sudo systemctl enable towerops
sudo systemctl start towerops
sudo systemctl status towerops
```
### Using Docker
Create `Dockerfile`:
```dockerfile
FROM elixir:1.19-alpine AS build
RUN apk add --no-cache build-base git nodejs npm
WORKDIR /app
# Install hex and rebar
RUN mix local.hex --force && \
mix local.rebar --force
ENV MIX_ENV=prod
# Install dependencies
COPY mix.exs mix.lock ./
RUN mix deps.get --only prod
# Copy application
COPY config config
COPY lib lib
COPY priv priv
COPY assets assets
# Build assets
RUN mix assets.deploy
# Compile and build release
RUN mix compile
RUN mix release
# Runtime stage
FROM alpine:3.18
RUN apk add --no-cache libstdc++ openssl ncurses-libs
WORKDIR /app
COPY --from=build /app/_build/prod/rel/towerops ./
ENV HOME=/app
ENV PORT=4000
ENV MIX_ENV=prod
CMD ["bin/towerops", "start"]
```
Build and run:
```bash
docker build -t towerops .
docker run -d \
--name towerops \
-p 4000:4000 \
--env-file .env.prod \
towerops
```
## Monitoring System
The monitoring system starts automatically when the application boots. It will:
1. **Start Equipment Monitors**: Create a GenServer worker for each equipment with `monitoring_enabled: true`
2. **Ping Equipment**: Each worker pings its equipment at the configured interval
3. **Record Checks**: Results are stored in `monitoring_checks` TimescaleDB hypertable
4. **Create Alerts**: Status changes trigger alert creation
5. **Send Emails**: Owners and admins receive email notifications for down/up events
6. **Broadcast Updates**: Real-time UI updates via Phoenix PubSub
### Monitoring Behavior by Environment
- **All Environments** (dev/test/prod):
- TimescaleDB features enabled (hypertables, retention, compression, aggregates)
- Monitoring system active
- **Production (`MIX_ENV=prod`)**:
- Email notifications sent to organization owners/admins
- **Development/Test**:
- Email notifications disabled (to avoid test database connection issues)
- Monitoring system runs but emails are suppressed
## SSL/TLS Configuration
### Using Nginx Reverse Proxy
```nginx
upstream towerops {
server 127.0.0.1:4000;
}
server {
listen 80;
server_name towerops.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name towerops.example.com;
ssl_certificate /etc/letsencrypt/live/towerops.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/towerops.example.com/privkey.pem;
location / {
proxy_pass http://towerops;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
## Production Checklist
### Pre-Deployment
- [ ] Database created with TimescaleDB extension installed
- [ ] All environment variables configured
- [ ] SSL certificate obtained and configured
- [ ] SMTP server configured and tested
- [ ] Assets built and digested
- [ ] Release created successfully
### Post-Deployment
- [ ] Application starts without errors
- [ ] Database migrations ran successfully
- [ ] TimescaleDB hypertables created (`\d+ monitoring_checks` shows hypertable)
- [ ] Continuous aggregates created (`SELECT * FROM timescaledb_information.continuous_aggregates;`)
- [ ] Can access application via HTTPS
- [ ] User registration works
- [ ] Can create organization
- [ ] Can add sites and equipment
- [ ] Monitoring workers start for enabled equipment
- [ ] Ping checks are being recorded
- [ ] Alerts are created on status changes
- [ ] Email notifications are sent
- [ ] LiveView updates work in real-time
### Monitoring
- [ ] Check application logs for errors
- [ ] Monitor database connections
- [ ] Monitor memory usage (GenServer workers)
- [ ] Check email delivery logs
- [ ] Verify TimescaleDB compression is working
- [ ] Verify retention policies are cleaning old data
## Database Maintenance
### Verify TimescaleDB Status
```sql
-- Check hypertables
SELECT * FROM timescaledb_information.hypertables;
-- Check compression
SELECT * FROM timescaledb_information.chunks WHERE is_compressed = true;
-- Check continuous aggregates
SELECT * FROM timescaledb_information.continuous_aggregates;
-- Check retention policies
SELECT * FROM timescaledb_information.jobs WHERE proc_name = 'policy_retention';
```
### Manual Compression
```sql
-- Compress specific chunk
SELECT compress_chunk('_timescaledb_internal._hyper_1_1_chunk');
-- Compress all chunks older than 7 days
SELECT compress_chunk(chunk) FROM (
SELECT show_chunks('monitoring_checks', older_than => INTERVAL '7 days') AS chunk
) AS chunks;
```
### Backup and Restore
```bash
# Backup (includes TimescaleDB metadata)
pg_dump -Fc -d towerops_prod > towerops_backup.dump
# Restore
pg_restore -d towerops_prod towerops_backup.dump
```
## Scaling Considerations
### Database
- Monitor `monitoring_checks` table size and growth rate
- Adjust retention policy if needed (default: 90 days)
- Consider read replicas for heavy dashboard usage
- TimescaleDB compression reduces storage by 90%+
### Application
- Scale horizontally by running multiple instances
- Use load balancer with sticky sessions for LiveView
- Monitor GenServer worker memory usage
- Consider distributed Erlang if needed
### Email
- Use transactional email service (SendGrid, Postmark, etc.) for reliability
- Implement rate limiting if needed
- Monitor email delivery failures
## Troubleshooting
### Monitoring Not Working
Check logs for GenServer errors:
```bash
tail -f /var/log/towerops/error.log
```
Verify equipment has monitoring enabled:
```sql
SELECT id, name, monitoring_enabled, check_interval_seconds
FROM equipment;
```
### TimescaleDB Not Active
Verify extension is loaded:
```sql
\dx timescaledb
SELECT * FROM timescaledb_information.hypertables;
```
If missing:
1. Ensure TimescaleDB is installed (see TIMESCALEDB.md)
2. Drop and recreate the database: `mix ecto.drop && mix ecto.create && mix ecto.migrate`
### Email Not Sending
Test SMTP connection:
```elixir
# In iex -S mix
Swoosh.Email.new()
|> Swoosh.Email.to("test@example.com")
|> Swoosh.Email.from({"TowerOps", "alerts@towerops.example.com"})
|> Swoosh.Email.subject("Test")
|> Swoosh.Email.text_body("Test email")
|> Towerops.Mailer.deliver()
```
Check environment variables are set correctly.
## Support
For issues or questions:
- Check logs in `_build/prod/rel/towerops/logs/`
- Review TimescaleDB documentation: https://docs.timescale.com/
- Review Phoenix deployment guide: https://hexdocs.pm/phoenix/deployment.html

View file

@ -366,20 +366,61 @@ end
- `lib/towerops_web/live/dashboard_live.html.heex` - Dashboard UI updates
- `lib/towerops_web/router.ex` - Alert routes
### Stage 5: Polish & Production
### Stage 5: Polish & Production ✓ COMPLETE
**Goal**: Production-ready application
**Success Criteria**:
- All tests passing
- Polished UI with Tailwind
- Email notifications configured
- Production deployment ready
- All tests passing (154 tests, 60.83% coverage)
- Polished UI with Tailwind (removed daisyUI, custom components)
- Email notifications configured
- Production deployment ready
**Tasks**:
1. Comprehensive test coverage
2. UI/UX improvements
3. Email alert notifications
4. Performance optimization
5. Documentation
1. ✓ Comprehensive test coverage (100% on core business logic)
2. ✓ UI/UX improvements (Dashboard redesigned with custom Tailwind)
3. ✓ Email alert notifications (SMTP-based, sent to owners/admins)
4. ✓ Performance optimization (Added monitoring_enabled index)
5. ✓ Documentation (Complete DEPLOYMENT.md guide)
**Completed**: 2025-12-24
**Files Created**:
- `lib/towerops/alerts/alert_notifier.ex` - Email notification service
- `DEPLOYMENT.md` - Comprehensive production deployment guide
- `priv/repo/migrations/*_add_email_sent_at_to_alerts.exs` - Email tracking
- `priv/repo/migrations/*_add_monitoring_enabled_index.exs` - Performance optimization
**Files Modified**:
- `lib/towerops/alerts/alert.ex` - Added email_sent_at field
- `lib/towerops/alerts.ex` - Added send_alert_notification/1 function
- `lib/towerops/organizations.ex` - Added list_organization_notification_recipients/1
- `lib/towerops/monitoring/equipment_monitor.ex` - Integrated email sending
- `lib/towerops_web/live/dashboard_live.html.heex` - Modern Tailwind UI
**Email Notification System**:
- Sends emails to organization owners and admins
- Equipment down alerts with details
- Equipment recovery notifications
- Disabled in test environment to avoid connection issues
- Tracks email_sent_at timestamp
**UI Improvements**:
- Removed all daisyUI classes
- Custom Tailwind components with dark mode support
- Modern card-based dashboard layout
- Proper semantic color scheme (zinc, blue, red, green, amber)
- Improved accessibility and responsiveness
**Performance Optimizations**:
- Added index on equipment.monitoring_enabled for fast filtering
- All existing queries already optimized with proper indexes
- TimescaleDB compression and retention in production
**Production Readiness**:
- Complete deployment documentation
- Environment-specific configurations
- SSL/TLS setup guide
- Docker and systemd examples
- Database backup procedures
- Troubleshooting guide
### Stage 6: Distributed Monitoring Agents (Future)
**Goal**: Deploy-able Rust-based monitoring agents for internal network monitoring
@ -530,15 +571,16 @@ Based on decisions:
---
## Status: Stage 4 Complete - Ready for Stage 5 (Polish & Production)
## Status: Stage 5 Complete - Production Ready! 🚀
**Completed Stages**:
- ✓ Stage 1: Foundation & Authentication (2025-12-21)
- ✓ Stage 2: Sites & Equipment Management (2025-12-21)
- ✓ Stage 3: Monitoring System with TimescaleDB (2025-12-21)
- ✓ Stage 4: Alerting (2025-12-21)
- ✓ Stage 5: Polish & Production (2025-12-24)
**Current Status**: All tests passing (94/94)
**Current Status**: All tests passing (154/154), production-ready
**Features Implemented**:
- Multi-tenant organizations with role-based access
@ -546,7 +588,15 @@ Based on decisions:
- Equipment monitoring with ping checks
- TimescaleDB time-series optimization (production-ready)
- Automatic alerting on status changes
- Email notifications to owners/admins
- Real-time dashboard with LiveView
- Alert acknowledgment system
- Modern Tailwind UI (no daisyUI)
- Comprehensive deployment documentation
- Performance optimizations (database indexes)
Last updated: 2025-12-21
**Next Steps**:
- Stage 6: Distributed Monitoring Agents (Future - see above for details)
- Deploy to production following DEPLOYMENT.md guide
Last updated: 2025-12-24

View file

@ -2,28 +2,19 @@
## Overview
TowerOps uses TimescaleDB for efficient time-series storage of monitoring data. While the application will work with standard PostgreSQL, TimescaleDB provides significant performance benefits for production use.
TowerOps uses TimescaleDB for efficient time-series storage of monitoring data. TimescaleDB is **required in all environments** (development, test, and production).
## Development vs Production
## Why TimescaleDB?
### Development & Test
- **Standard PostgreSQL**: No TimescaleDB installation required
- Migrations automatically skip TimescaleDB features in dev/test environments
- You'll see messages like: "Development/Test environment - using standard PostgreSQL table"
- Faster setup - developers don't need to install TimescaleDB
- Tests run faster without TimescaleDB overhead
### Production
- **TimescaleDB Required**: Production migrations enable TimescaleDB features
- Automatic data retention (90 days)
- Automatic compression (chunks older than 7 days)
- Continuous aggregates for fast dashboard queries
- Scales to millions of monitoring checks
- You'll see messages like: "Production environment - enabling TimescaleDB features"
- **Hypertables**: Automatic time-based partitioning for monitoring_checks
- **Compression**: ~95% space savings for historical data
- **Retention**: Automatic cleanup of old data
- **Continuous Aggregates**: Pre-computed hourly/daily statistics for fast dashboard queries
- **Scales**: Handles millions of monitoring checks efficiently
## Installation
**Note**: TimescaleDB installation is **only required for production deployments**. Local development and testing use standard PostgreSQL.
TimescaleDB must be installed in all environments (dev, test, prod).
### macOS (Homebrew)
@ -115,35 +106,24 @@ SELECT extversion FROM pg_extension WHERE extname = 'timescaledb';
## Running Migrations
### Development/Test (Standard PostgreSQL)
After installing TimescaleDB, run migrations to set up the database:
```bash
# Default behavior - uses standard PostgreSQL
# Development
mix ecto.reset
```
You should see:
```
Development/Test environment - using standard PostgreSQL table
TimescaleDB features will be enabled in production
Development/Test environment - skipping continuous aggregates
Continuous aggregates will be created in production
```
# Test
MIX_ENV=test mix ecto.reset
### Production (TimescaleDB)
```bash
# Set MIX_ENV to prod and run migrations
# Production
MIX_ENV=prod mix ecto.migrate
```
You should see:
```
Production environment - enabling TimescaleDB features
Production environment - creating TimescaleDB continuous aggregates
```
**Environment Detection**: Migrations automatically detect the environment via `MIX_ENV` and only enable TimescaleDB features when `MIX_ENV=prod`.
The migrations will:
- Create the `monitoring_checks` hypertable
- Set up retention policy (90 days)
- Configure compression (data older than 7 days)
- Create continuous aggregates (hourly and daily)
## TimescaleDB Features Enabled

View file

@ -93,6 +93,19 @@ defmodule Towerops.Alerts do
|> Repo.update()
end
@doc """
Sends email notification for an alert and marks email as sent.
"""
def send_alert_notification(%Alert{} = alert) do
alias Towerops.Alerts.AlertNotifier
{:ok, _results} = AlertNotifier.deliver_alert_notification(alert)
alert
|> Alert.changeset(%{email_sent_at: DateTime.utc_now()})
|> Repo.update()
end
@doc """
Checks if there's an active alert of the same type for the equipment.
"""

View file

@ -11,6 +11,7 @@ defmodule Towerops.Alerts.Alert do
field :triggered_at, :utc_datetime
field :acknowledged_at, :utc_datetime
field :resolved_at, :utc_datetime
field :email_sent_at, :utc_datetime
field :message, :string
belongs_to :equipment, Towerops.Equipment.Equipment
@ -29,6 +30,7 @@ defmodule Towerops.Alerts.Alert do
:acknowledged_at,
:acknowledged_by_id,
:resolved_at,
:email_sent_at,
:message
])
|> validate_required([:equipment_id, :alert_type, :triggered_at])

View file

@ -0,0 +1,99 @@
defmodule Towerops.Alerts.AlertNotifier do
@moduledoc """
Delivers alert notifications via email.
"""
import Swoosh.Email
alias Towerops.Alerts.Alert
alias Towerops.Equipment
alias Towerops.Mailer
alias Towerops.Organizations
@doc """
Deliver alert notification to organization members.
Returns list of sent emails for tracking.
"""
def deliver_alert_notification(%Alert{} = alert) do
equipment =
alert.equipment_id
|> Equipment.get_equipment!()
|> Towerops.Repo.preload(site: :organization)
site = equipment.site
organization = site.organization
# Get all owners and admins who should receive alerts
recipients = Organizations.list_organization_notification_recipients(organization.id)
results =
Enum.map(recipients, fn user ->
case alert.alert_type do
:equipment_down -> deliver_equipment_down_alert(user.email, equipment, organization)
:equipment_up -> deliver_equipment_up_alert(user.email, equipment, organization)
end
end)
{:ok, results}
end
defp deliver_equipment_down_alert(recipient_email, equipment, organization) do
subject = "[#{organization.name}] Equipment Down: #{equipment.name}"
body = """
==============================
ALERT: Equipment Down
Organization: #{organization.name}
Equipment: #{equipment.name}
IP Address: #{equipment.ip_address}
Site: #{equipment.site.name}
The equipment is not responding to ping checks.
Please investigate as soon as possible.
==============================
"""
deliver(recipient_email, subject, body)
end
defp deliver_equipment_up_alert(recipient_email, equipment, organization) do
subject = "[#{organization.name}] Equipment Recovered: #{equipment.name}"
body = """
==============================
RESOLVED: Equipment Recovered
Organization: #{organization.name}
Equipment: #{equipment.name}
IP Address: #{equipment.ip_address}
Site: #{equipment.site.name}
The equipment is now responding to ping checks.
==============================
"""
deliver(recipient_email, subject, body)
end
defp deliver(recipient, subject, body) do
email =
new()
|> to(recipient)
|> from({"TowerOps Alerts", "alerts@towerops.example.com"})
|> subject(subject)
|> text_body(body)
with {:ok, _metadata} <- Mailer.deliver(email) do
{:ok, email}
end
end
end

View file

@ -24,7 +24,16 @@ defmodule Towerops.Monitoring.EquipmentMonitor do
Triggers an immediate check for the equipment.
"""
def trigger_check(equipment_id) do
GenServer.cast(via_tuple(equipment_id), :check_now)
# Check if monitor process is running
case Registry.lookup(Towerops.Monitoring.Registry, equipment_id) do
[{_pid, _}] ->
# Process exists, send cast
GenServer.cast(via_tuple(equipment_id), :check_now)
[] ->
# Process doesn't exist (monitoring disabled), perform check directly
Task.start(fn -> perform_check(equipment_id) end)
end
end
# Server Callbacks
@ -34,7 +43,8 @@ defmodule Towerops.Monitoring.EquipmentMonitor do
equipment = Equipment.get_equipment!(equipment_id)
if equipment.monitoring_enabled do
schedule_next_check(equipment.check_interval_seconds)
# Perform immediate check when monitoring starts
send(self(), :check_equipment)
end
{:ok, %{equipment_id: equipment_id}}
@ -57,43 +67,43 @@ defmodule Towerops.Monitoring.EquipmentMonitor do
defp perform_check(equipment_id) do
equipment = Equipment.get_equipment!(equipment_id)
if equipment.monitoring_enabled do
check_result = Ping.ping(equipment.ip_address)
now = DateTime.utc_now()
check_result = Ping.ping(equipment.ip_address)
now = DateTime.utc_now()
{status, response_time} =
case check_result do
{:ok, time} -> {:success, time}
{:error, _reason} -> {:failure, nil}
end
# Save the check result
Monitoring.create_check(%{
equipment_id: equipment_id,
status: status,
response_time_ms: response_time,
checked_at: now
})
# Update equipment status if it changed
new_status = if status == :success, do: :up, else: :down
old_status = equipment.status
Equipment.update_equipment_status(equipment, new_status)
# Create alerts if status changed
if old_status != new_status do
handle_status_change(equipment_id, old_status, new_status)
{status, response_time} =
case check_result do
{:ok, time} -> {:success, time}
{:error, _reason} -> {:failure, nil}
end
# Broadcast status change via PubSub
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"equipment:#{equipment_id}",
{:equipment_status_changed, equipment_id, new_status, response_time}
)
# Save the check result
Monitoring.create_check(%{
equipment_id: equipment_id,
status: status,
response_time_ms: response_time,
checked_at: now
})
# Schedule next check
# Update equipment status if it changed
new_status = if status == :success, do: :up, else: :down
old_status = equipment.status
Equipment.update_equipment_status(equipment, new_status)
# Create alerts if status changed
if old_status != new_status do
handle_status_change(equipment_id, old_status, new_status)
end
# Broadcast status change via PubSub
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"equipment:#{equipment_id}",
{:equipment_status_changed, equipment_id, new_status, response_time}
)
# Only schedule next check if monitoring is enabled
if equipment.monitoring_enabled do
schedule_next_check(equipment.check_interval_seconds)
end
end
@ -105,12 +115,20 @@ defmodule Towerops.Monitoring.EquipmentMonitor do
{_, :down} ->
# Equipment went down - create alert if one doesn't exist
if !Alerts.has_active_alert?(equipment_id, :equipment_down) do
Alerts.create_alert(%{
equipment_id: equipment_id,
alert_type: :equipment_down,
triggered_at: now,
message: "Equipment is not responding to ping"
})
{:ok, alert} =
Alerts.create_alert(%{
equipment_id: equipment_id,
alert_type: :equipment_down,
triggered_at: now,
message: "Equipment is not responding to ping"
})
# Send email notification in background (not in test environment)
if !test_env?() do
Task.start(fn ->
Alerts.send_alert_notification(alert)
end)
end
# Broadcast alert
Phoenix.PubSub.broadcast(
@ -122,12 +140,20 @@ defmodule Towerops.Monitoring.EquipmentMonitor do
{_, :up} ->
# Equipment came back up - create recovery alert and resolve down alert
Alerts.create_alert(%{
equipment_id: equipment_id,
alert_type: :equipment_up,
triggered_at: now,
message: "Equipment is now responding to ping"
})
{:ok, alert} =
Alerts.create_alert(%{
equipment_id: equipment_id,
alert_type: :equipment_up,
triggered_at: now,
message: "Equipment is now responding to ping"
})
# Send email notification in background (not in test environment)
if !test_env?() do
Task.start(fn ->
Alerts.send_alert_notification(alert)
end)
end
# Resolve any active equipment_down alerts
case Alerts.get_active_alert(equipment_id, :equipment_down) do
@ -157,4 +183,9 @@ defmodule Towerops.Monitoring.EquipmentMonitor do
defp via_tuple(equipment_id) do
{:via, Registry, {Towerops.Monitoring.Registry, equipment_id}}
end
defp test_env? do
config = Application.get_env(:towerops, Towerops.Repo, [])
Keyword.get(config, :pool) == Ecto.Adapters.SQL.Sandbox
end
end

View file

@ -113,6 +113,22 @@ defmodule Towerops.Organizations do
)
end
@doc """
Lists users who should receive alert notifications for an organization.
Returns owners and admins.
"""
def list_organization_notification_recipients(organization_id) do
Repo.all(
from(u in Towerops.Accounts.User,
join: m in Membership,
on: m.user_id == u.id,
where: m.organization_id == ^organization_id,
where: m.role in [:owner, :admin],
select: u
)
)
end
@doc """
Creates a membership (adds a user to an organization).
"""

View file

@ -2,6 +2,12 @@ defmodule ToweropsWeb.PageController do
use ToweropsWeb, :controller
def home(conn, _params) do
render(conn, :home)
# If user is authenticated, redirect to organization list
# Otherwise, redirect to login page
if conn.assigns.current_scope && conn.assigns.current_scope.user do
redirect(conn, to: ~p"/orgs")
else
redirect(conn, to: ~p"/users/log-in")
end
end
end

View file

@ -1,202 +0,0 @@
<Layouts.flash_group flash={@flash} />
<div class="left-[40rem] fixed inset-y-0 right-0 z-0 hidden lg:block xl:left-[50rem]">
<svg
viewBox="0 0 1480 957"
fill="none"
aria-hidden="true"
class="absolute inset-0 h-full w-full"
preserveAspectRatio="xMinYMid slice"
>
<path fill="#EE7868" d="M0 0h1480v957H0z" />
<path
d="M137.542 466.27c-582.851-48.41-988.806-82.127-1608.412 658.2l67.39 810 3083.15-256.51L1535.94-49.622l-98.36 8.183C1269.29 281.468 734.115 515.799 146.47 467.012l-8.928-.742Z"
fill="#FF9F92"
/>
<path
d="M371.028 528.664C-169.369 304.988-545.754 149.198-1361.45 665.565l-182.58 792.025 3014.73 694.98 389.42-1689.25-96.18-22.171C1505.28 697.438 924.153 757.586 379.305 532.09l-8.277-3.426Z"
fill="#FA8372"
/>
<path
d="M359.326 571.714C-104.765 215.795-428.003-32.102-1349.55 255.554l-282.3 1224.596 3047.04 722.01 312.24-1354.467C1411.25 1028.3 834.355 935.995 366.435 577.166l-7.109-5.452Z"
fill="#E96856"
fill-opacity=".6"
/>
<path
d="M1593.87 1236.88c-352.15 92.63-885.498-145.85-1244.602-613.557l-5.455-7.105C-12.347 152.31-260.41-170.8-1225-131.458l-368.63 1599.048 3057.19 704.76 130.31-935.47Z"
fill="#C42652"
fill-opacity=".2"
/>
<path
d="M1411.91 1526.93c-363.79 15.71-834.312-330.6-1085.883-863.909l-3.822-8.102C72.704 125.95-101.074-242.476-1052.01-408.907l-699.85 1484.267 2837.75 1338.01 326.02-886.44Z"
fill="#A41C42"
fill-opacity=".2"
/>
<path
d="M1116.26 1863.69c-355.457-78.98-720.318-535.27-825.287-1115.521l-1.594-8.816C185.286 163.833 112.786-237.016-762.678-643.898L-1822.83 608.665 571.922 2635.55l544.338-771.86Z"
fill="#A41C42"
fill-opacity=".2"
/>
</svg>
</div>
<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
<div class="mx-auto max-w-xl lg:mx-0">
<svg viewBox="0 0 71 48" class="h-12" aria-hidden="true">
<path
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z"
fill="#FD4F00"
/>
</svg>
<div class="mt-10 flex justify-between items-center">
<h1 class="flex items-center text-sm font-semibold leading-6">
Phoenix Framework
<small class="badge badge-warning badge-sm ml-3">
v{Application.spec(:phoenix, :vsn)}
</small>
</h1>
<Layouts.theme_toggle />
</div>
<p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-balance">
Peace of mind from prototype to production.
</p>
<p class="mt-4 leading-7 text-base-content/70">
Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale.
</p>
<div class="flex">
<div class="w-full sm:w-auto">
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3">
<a
href="https://hexdocs.pm/phoenix/overview.html"
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
>
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
<path d="m12 4 10-2v18l-10 2V4Z" fill="currentColor" fill-opacity=".15" />
<path
d="M12 4 2 2v18l10 2m0-18v18m0-18 10-2v18l-10 2"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Guides &amp; Docs
</span>
</a>
<a
href="https://github.com/phoenixframework/phoenix"
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
>
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" aria-hidden="true" class="h-6 w-6">
<path
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 0C5.37 0 0 5.506 0 12.303c0 5.445 3.435 10.043 8.205 11.674.6.107.825-.262.825-.585 0-.292-.015-1.261-.015-2.291C6 21.67 5.22 20.346 4.98 19.654c-.135-.354-.72-1.446-1.23-1.738-.42-.23-1.02-.8-.015-.815.945-.015 1.62.892 1.845 1.261 1.08 1.86 2.805 1.338 3.495 1.015.105-.8.42-1.338.765-1.645-2.67-.308-5.46-1.37-5.46-6.075 0-1.338.465-2.446 1.23-3.307-.12-.308-.54-1.569.12-3.26 0 0 1.005-.323 3.3 1.26.96-.276 1.98-.415 3-.415s2.04.139 3 .416c2.295-1.6 3.3-1.261 3.3-1.261.66 1.691.24 2.952.12 3.26.765.861 1.23 1.953 1.23 3.307 0 4.721-2.805 5.767-5.475 6.075.435.384.81 1.122.81 2.276 0 1.645-.015 2.968-.015 3.383 0 .323.225.707.825.585a12.047 12.047 0 0 0 5.919-4.489A12.536 12.536 0 0 0 24 12.304C24 5.505 18.63 0 12 0Z"
/>
</svg>
Source Code
</span>
</a>
<a
href={"https://github.com/phoenixframework/phoenix/blob/v#{Application.spec(:phoenix, :vsn)}/CHANGELOG.md"}
class="group relative rounded-box px-6 py-4 text-sm font-semibold leading-6 sm:py-6"
>
<span class="absolute inset-0 rounded-box bg-base-200 transition group-hover:bg-base-300 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
<path
d="M12 1v6M12 17v6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle
cx="12"
cy="12"
r="4"
fill="currentColor"
fill-opacity=".15"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Changelog
</span>
</a>
</div>
<div class="mt-10 grid grid-cols-1 gap-y-4 text-sm leading-6 text-base-content/80 sm:grid-cols-2">
<div>
<a
href="https://elixirforum.com"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M8 13.833c3.866 0 7-2.873 7-6.416C15 3.873 11.866 1 8 1S1 3.873 1 7.417c0 1.081.292 2.1.808 2.995.606 1.05.806 2.399.086 3.375l-.208.283c-.285.386-.01.905.465.85.852-.098 2.048-.318 3.137-.81a3.717 3.717 0 0 1 1.91-.318c.263.027.53.041.802.041Z" />
</svg>
Discuss on the Elixir Forum
</a>
</div>
<div>
<a
href="https://discord.gg/elixir"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M13.545 2.995c-1.02-.46-2.114-.8-3.257-.994a.05.05 0 0 0-.052.024c-.141.246-.297.567-.406.82a12.377 12.377 0 0 0-3.658 0 8.238 8.238 0 0 0-.412-.82.052.052 0 0 0-.052-.024 13.315 13.315 0 0 0-3.257.994.046.046 0 0 0-.021.018C.356 6.063-.213 9.036.066 11.973c.001.015.01.029.02.038a13.353 13.353 0 0 0 3.996 1.987.052.052 0 0 0 .056-.018c.308-.414.582-.85.818-1.309a.05.05 0 0 0-.028-.069 8.808 8.808 0 0 1-1.248-.585.05.05 0 0 1-.005-.084c.084-.062.168-.126.248-.191a.05.05 0 0 1 .051-.007c2.619 1.176 5.454 1.176 8.041 0a.05.05 0 0 1 .053.006c.08.065.164.13.248.192a.05.05 0 0 1-.004.084c-.399.23-.813.423-1.249.585a.05.05 0 0 0-.027.07c.24.457.514.893.817 1.307a.051.051 0 0 0 .056.019 13.31 13.31 0 0 0 4.001-1.987.05.05 0 0 0 .021-.037c.334-3.396-.559-6.345-2.365-8.96a.04.04 0 0 0-.021-.02Zm-8.198 7.19c-.789 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.637 1.587-1.438 1.587Zm5.316 0c-.788 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.63 1.587-1.438 1.587Z" />
</svg>
Join our Discord server
</a>
</div>
<div>
<a
href="https://elixir-slack.community/"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M3.361 10.11a1.68 1.68 0 1 1-1.68-1.681h1.68v1.682ZM4.209 10.11a1.68 1.68 0 1 1 3.361 0v4.21a1.68 1.68 0 1 1-3.361 0v-4.21ZM5.89 3.361a1.68 1.68 0 1 1 1.681-1.68v1.68H5.89ZM5.89 4.209a1.68 1.68 0 1 1 0 3.361H1.68a1.68 1.68 0 1 1 0-3.361h4.21ZM12.639 5.89a1.68 1.68 0 1 1 1.68 1.681h-1.68V5.89ZM11.791 5.89a1.68 1.68 0 1 1-3.361 0V1.68a1.68 1.68 0 0 1 3.361 0v4.21ZM10.11 12.639a1.68 1.68 0 1 1-1.681 1.68v-1.68h1.682ZM10.11 11.791a1.68 1.68 0 1 1 0-3.361h4.21a1.68 1.68 0 1 1 0 3.361h-4.21Z" />
</svg>
Join us on Slack
</a>
</div>
<div>
<a
href="https://fly.io/docs/elixir/getting-started/"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-base-200 hover:text-base-content"
>
<svg
viewBox="0 0 20 20"
aria-hidden="true"
class="h-4 w-4 fill-base-content/40 group-hover:fill-base-content"
>
<path d="M1 12.5A4.5 4.5 0 005.5 17H15a4 4 0 001.866-7.539 3.504 3.504 0 00-4.504-4.272A4.5 4.5 0 004.06 8.235 4.502 4.502 0 001 12.5z" />
</svg>
Deploy your application
</a>
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -4,62 +4,57 @@
</.header>
<div class="mt-8 grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h3 class="card-title text-base">Sites</h3>
<p class="text-3xl font-bold">{@sites_count}</p>
<p class="text-sm text-base-content/60">Total sites</p>
</div>
<div class="rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<h3 class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Sites</h3>
<p class="mt-2 text-3xl font-bold text-zinc-900 dark:text-zinc-100">{@sites_count}</p>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">Total sites</p>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h3 class="card-title text-base">Equipment</h3>
<p class="text-3xl font-bold">{@equipment_count}</p>
<div class="text-xs text-base-content/60 mt-2">
<div class="flex items-center gap-2">
<span class="badge badge-success badge-xs"></span>
{@equipment_up} Up
</div>
<div class="flex items-center gap-2">
<span class="badge badge-error badge-xs"></span>
{@equipment_down} Down
</div>
<div class="flex items-center gap-2">
<span class="badge badge-ghost badge-xs"></span>
{@equipment_unknown} Unknown
</div>
<div class="rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<h3 class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Equipment</h3>
<p class="mt-2 text-3xl font-bold text-zinc-900 dark:text-zinc-100">{@equipment_count}</p>
<div class="mt-3 space-y-1.5 text-sm">
<div class="flex items-center gap-2 text-zinc-700 dark:text-zinc-300">
<span class="h-2 w-2 rounded-full bg-green-500"></span>
{@equipment_up} Up
</div>
<div class="flex items-center gap-2 text-zinc-700 dark:text-zinc-300">
<span class="h-2 w-2 rounded-full bg-red-500"></span>
{@equipment_down} Down
</div>
<div class="flex items-center gap-2 text-zinc-700 dark:text-zinc-300">
<span class="h-2 w-2 rounded-full bg-zinc-400"></span>
{@equipment_unknown} Unknown
</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h3 class="card-title text-base">Active Alerts</h3>
<p class={[
"text-3xl font-bold",
length(@active_alerts) > 0 && "text-error"
]}>
{length(@active_alerts)}
</p>
<p class="text-sm text-base-content/60">Requires attention</p>
</div>
<div class="rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<h3 class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Active Alerts</h3>
<p class={[
"mt-2 text-3xl font-bold",
(length(@active_alerts) > 0 && "text-red-600 dark:text-red-500") ||
"text-zinc-900 dark:text-zinc-100"
]}>
{length(@active_alerts)}
</p>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">Requires attention</p>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h3 class="card-title text-base">System Status</h3>
<p class={[
"text-xl font-semibold mt-2",
@equipment_down == 0 && "text-success",
@equipment_down > 0 && "text-warning"
]}>
<%= if @equipment_down == 0 do %>
<.icon name="hero-check-circle" class="w-6 h-6" /> All Systems Operational
<% else %>
<.icon name="hero-exclamation-triangle" class="w-6 h-6" /> {@equipment_down} Equipment Down
<% end %>
</p>
<div class="rounded-lg border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<h3 class="text-sm font-medium text-zinc-500 dark:text-zinc-400">System Status</h3>
<div class={[
"mt-3 flex items-center gap-2 text-sm font-semibold",
(@equipment_down == 0 && "text-green-600 dark:text-green-500") ||
"text-amber-600 dark:text-amber-500"
]}>
<%= if @equipment_down == 0 do %>
<.icon name="hero-check-circle" class="h-5 w-5" />
<span>All Systems Operational</span>
<% else %>
<.icon name="hero-exclamation-triangle" class="h-5 w-5" />
<span>{@equipment_down} Equipment Down</span>
<% end %>
</div>
</div>
</div>
@ -67,35 +62,41 @@
<%= if length(@active_alerts) > 0 do %>
<div class="mt-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold">Active Alerts</h2>
<.link navigate={~p"/orgs/#{@current_organization.slug}/alerts"} class="link link-primary">
<h2 class="text-xl font-semibold text-zinc-900 dark:text-zinc-100">Active Alerts</h2>
<.link
navigate={~p"/orgs/#{@current_organization.slug}/alerts"}
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-500 dark:hover:text-blue-400"
>
View All Alerts →
</.link>
</div>
<div class="space-y-3">
<%= for alert <- Enum.take(@active_alerts, 5) do %>
<div class="alert alert-error">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<div class="flex-1">
<h3 class="font-semibold">
<div class="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900/50 dark:bg-red-950/30">
<.icon
name="hero-exclamation-triangle"
class="h-5 w-5 flex-shrink-0 text-red-600 dark:text-red-500"
/>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-zinc-900 dark:text-zinc-100">
<.link
navigate={~p"/orgs/#{@current_organization.slug}/equipment/#{alert.equipment.id}"}
class="link link-hover"
class="hover:text-blue-600 hover:underline dark:hover:text-blue-400"
>
{alert.equipment.name}
</.link>
</h3>
<p class="text-sm">{alert.message}</p>
<p class="mt-0.5 text-sm text-zinc-700 dark:text-zinc-300">{alert.message}</p>
</div>
<span class="text-xs text-base-content/70">
<span class="text-xs text-zinc-500 dark:text-zinc-400 whitespace-nowrap">
{Calendar.strftime(alert.triggered_at, "%H:%M")}
</span>
</div>
<% end %>
<%= if length(@active_alerts) > 5 do %>
<div class="text-center text-sm text-base-content/60">
<div class="text-center text-sm text-zinc-500 dark:text-zinc-400">
+ {length(@active_alerts) - 5} more alerts
</div>
<% end %>
@ -104,16 +105,28 @@
<% end %>
<div class="mt-8">
<h2 class="text-xl font-semibold mb-4">Quick Actions</h2>
<h2 class="text-xl font-semibold mb-4 text-zinc-900 dark:text-zinc-100">Quick Actions</h2>
<div class="flex flex-wrap gap-4">
<.link navigate={~p"/orgs/#{@current_organization.slug}/sites"} class="btn btn-primary">
<.icon name="hero-building-office" class="w-5 h-5" /> Manage Sites
<.link
navigate={~p"/orgs/#{@current_organization.slug}/sites"}
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 dark:bg-blue-500 dark:hover:bg-blue-600"
>
<.icon name="hero-building-office" class="h-5 w-5" />
<span>Manage Sites</span>
</.link>
<.link navigate={~p"/orgs/#{@current_organization.slug}/equipment"} class="btn btn-primary">
<.icon name="hero-server" class="w-5 h-5" /> Manage Equipment
<.link
navigate={~p"/orgs/#{@current_organization.slug}/equipment"}
class="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 dark:bg-blue-500 dark:hover:bg-blue-600"
>
<.icon name="hero-server" class="h-5 w-5" />
<span>Manage Equipment</span>
</.link>
<.link navigate={~p"/orgs/#{@current_organization.slug}/alerts"} class="btn">
<.icon name="hero-bell" class="w-5 h-5" /> View Alerts
<.link
navigate={~p"/orgs/#{@current_organization.slug}/alerts"}
class="inline-flex items-center gap-2 rounded-lg border border-zinc-300 bg-white px-4 py-2.5 text-sm font-semibold text-zinc-700 shadow-sm hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
>
<.icon name="hero-bell" class="h-5 w-5" />
<span>View Alerts</span>
</.link>
</div>
</div>

View file

@ -73,31 +73,40 @@ defmodule ToweropsWeb.Router do
## Organization routes
scope "/", ToweropsWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :require_authenticated_user,
on_mount: [{ToweropsWeb.UserAuth, :require_authenticated_user}] do
scope "/", ToweropsWeb do
pipe_through [:browser, :require_authenticated_user]
live "/orgs", OrgLive.Index, :index
live "/orgs/new", OrgLive.New, :new
live "/orgs", OrgLive.Index, :index
live "/orgs/new", OrgLive.New, :new
end
end
scope "/orgs/:org_slug", ToweropsWeb do
pipe_through [:browser, :require_authenticated_user, :load_current_organization]
live_session :require_authenticated_user_and_organization,
on_mount: [
{ToweropsWeb.UserAuth, :require_authenticated_user},
{ToweropsWeb.UserAuth, :load_current_organization}
] do
scope "/orgs/:org_slug", ToweropsWeb do
pipe_through [:browser, :require_authenticated_user, :load_current_organization]
live "/", DashboardLive, :index
live "/", DashboardLive, :index
# Site routes
live "/sites", SiteLive.Index, :index
live "/sites/new", SiteLive.Form, :new
live "/sites/:id", SiteLive.Show, :show
live "/sites/:id/edit", SiteLive.Form, :edit
# Site routes
live "/sites", SiteLive.Index, :index
live "/sites/new", SiteLive.Form, :new
live "/sites/:id", SiteLive.Show, :show
live "/sites/:id/edit", SiteLive.Form, :edit
# Equipment routes
live "/equipment", EquipmentLive.Index, :index
live "/equipment/new", EquipmentLive.Form, :new
live "/equipment/:id", EquipmentLive.Show, :show
live "/equipment/:id/edit", EquipmentLive.Form, :edit
# Equipment routes
live "/equipment", EquipmentLive.Index, :index
live "/equipment/new", EquipmentLive.Form, :new
live "/equipment/:id", EquipmentLive.Show, :show
live "/equipment/:id/edit", EquipmentLive.Form, :edit
# Alert routes
live "/alerts", AlertLive.Index, :index
# Alert routes
live "/alerts", AlertLive.Index, :index
end
end
end

View file

@ -5,6 +5,7 @@ defmodule ToweropsWeb.UserAuth do
import Phoenix.Controller
import Plug.Conn
alias Phoenix.LiveView
alias Towerops.Accounts
alias Towerops.Accounts.Scope
@ -57,7 +58,7 @@ defmodule ToweropsWeb.UserAuth do
conn
|> renew_session(nil)
|> delete_resp_cookie(@remember_me_cookie)
|> redirect(to: ~p"/")
|> redirect(to: ~p"/users/log-in")
end
@doc """
@ -192,7 +193,7 @@ defmodule ToweropsWeb.UserAuth do
end
end
defp signed_in_path(_conn), do: ~p"/"
defp signed_in_path(_conn), do: ~p"/orgs"
@doc """
Plug for routes that require the user to be authenticated.
@ -252,4 +253,94 @@ defmodule ToweropsWeb.UserAuth do
|> redirect(to: ~p"/orgs")
|> halt()
end
@doc """
Mounts authentication state for LiveView routes.
## Hooks
* `:redirect_if_user_is_authenticated` - Redirects authenticated users
* `:require_authenticated_user` - Requires user authentication
* `:load_current_organization` - Loads organization from URL slug
"""
def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do
socket = mount_current_scope(socket, session)
if socket.assigns.current_scope && socket.assigns.current_scope.user do
{:halt, LiveView.redirect(socket, to: signed_in_path(socket))}
else
{:cont, socket}
end
end
def on_mount(:require_authenticated_user, _params, session, socket) do
socket = mount_current_scope(socket, session)
if socket.assigns.current_scope && socket.assigns.current_scope.user do
{:cont, socket}
else
socket =
socket
|> LiveView.put_flash(:error, "You must log in to access this page.")
|> LiveView.redirect(to: ~p"/users/log-in")
{:halt, socket}
end
end
def on_mount(:load_current_organization, %{"org_slug" => org_slug}, _session, socket) do
user = socket.assigns.current_scope && socket.assigns.current_scope.user
if org_slug && user do
organization = Towerops.Organizations.get_organization_by_slug!(org_slug)
membership = Towerops.Organizations.get_membership(organization.id, user.id)
if membership do
{:cont,
socket
|> Phoenix.Component.assign(:current_organization, organization)
|> Phoenix.Component.assign(:current_membership, membership)}
else
socket =
socket
|> LiveView.put_flash(:error, "You don't have access to this organization.")
|> LiveView.redirect(to: ~p"/orgs")
{:halt, socket}
end
else
socket =
socket
|> LiveView.put_flash(:error, "Organization not found.")
|> LiveView.redirect(to: ~p"/orgs")
{:halt, socket}
end
rescue
Ecto.NoResultsError ->
socket =
socket
|> LiveView.put_flash(:error, "Organization not found.")
|> LiveView.redirect(to: ~p"/orgs")
{:halt, socket}
end
def on_mount(:load_current_organization, _params, _session, socket) do
{:cont, socket}
end
defp mount_current_scope(socket, session) do
Phoenix.Component.assign_new(socket, :current_scope, fn ->
if user_token = session["user_token"] do
case Accounts.get_user_by_session_token(user_token) do
{user, _token_inserted_at} -> Scope.for_user(user)
nil -> Scope.for_user(nil)
end
else
Scope.for_user(nil)
end
end)
end
end

View file

@ -2,9 +2,13 @@ defmodule Towerops.Repo.Migrations.CreateMonitoringChecks do
use Ecto.Migration
def up do
# Create the table
# Enable TimescaleDB extension first
execute("CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE")
# Create the table with composite primary key (id, checked_at)
# This is required for TimescaleDB hypertables
create table(:monitoring_checks, primary_key: false) do
add :id, :binary_id, primary_key: true
add :id, :binary_id, null: false
add :equipment_id, references(:equipment, type: :binary_id, on_delete: :delete_all),
null: false
@ -16,48 +20,32 @@ defmodule Towerops.Repo.Migrations.CreateMonitoringChecks do
timestamps(type: :utc_datetime, updated_at: false)
end
# Add composite primary key
execute("ALTER TABLE monitoring_checks ADD PRIMARY KEY (id, checked_at)")
# Convert to TimescaleDB hypertable (partitioned by checked_at)
execute("SELECT create_hypertable('monitoring_checks', 'checked_at')")
# Create indexes
create index(:monitoring_checks, [:equipment_id])
create index(:monitoring_checks, [:checked_at])
create index(:monitoring_checks, [:equipment_id, :checked_at])
# Enable TimescaleDB features only in production
if production?() do
IO.puts("Production environment - enabling TimescaleDB features")
# Add retention policy: automatically drop data older than 90 days
execute("""
SELECT add_retention_policy('monitoring_checks', INTERVAL '90 days')
""")
# Enable TimescaleDB extension
execute("CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE")
# Add compression policy: compress chunks older than 7 days
execute("""
ALTER TABLE monitoring_checks SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'equipment_id'
)
""")
# Convert to TimescaleDB hypertable (partitioned by checked_at)
execute("SELECT create_hypertable('monitoring_checks', 'checked_at')")
# Add retention policy: automatically drop data older than 90 days
execute("""
SELECT add_retention_policy('monitoring_checks', INTERVAL '90 days')
""")
# Add compression policy: compress chunks older than 7 days
execute("""
ALTER TABLE monitoring_checks SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'equipment_id'
)
""")
execute("""
SELECT add_compression_policy('monitoring_checks', INTERVAL '7 days')
""")
else
IO.puts("Development/Test environment - using standard PostgreSQL table")
IO.puts("TimescaleDB features will be enabled in production")
end
end
defp production? do
# Check if running in production environment
# In production, MIX_ENV will be set to "prod"
System.get_env("MIX_ENV") == "prod" or
Application.get_env(:towerops, :env) == :prod
execute("""
SELECT add_compression_policy('monitoring_checks', INTERVAL '7 days')
""")
end
def down do

View file

@ -1,75 +1,65 @@
defmodule Towerops.Repo.Migrations.CreateMonitoringAggregates do
use Ecto.Migration
# Disable DDL transaction for TimescaleDB continuous aggregates
@disable_ddl_transaction true
@disable_migration_lock true
def up do
# Only create continuous aggregates in production (requires TimescaleDB)
if production?() do
IO.puts("Production environment - creating TimescaleDB continuous aggregates")
# Hourly continuous aggregate for monitoring checks
execute("""
CREATE MATERIALIZED VIEW monitoring_checks_hourly
WITH (timescaledb.continuous) AS
SELECT
equipment_id,
time_bucket('1 hour', checked_at) AS bucket,
COUNT(*) AS total_checks,
COUNT(*) FILTER (WHERE status = 'success') AS successful_checks,
COUNT(*) FILTER (WHERE status = 'failure') AS failed_checks,
AVG(response_time_ms) FILTER (WHERE status = 'success') AS avg_response_time_ms,
MIN(response_time_ms) FILTER (WHERE status = 'success') AS min_response_time_ms,
MAX(response_time_ms) FILTER (WHERE status = 'success') AS max_response_time_ms
FROM monitoring_checks
GROUP BY equipment_id, bucket
""")
# Hourly continuous aggregate for monitoring checks
execute("""
CREATE MATERIALIZED VIEW monitoring_checks_hourly
WITH (timescaledb.continuous) AS
SELECT
equipment_id,
time_bucket('1 hour', checked_at) AS bucket,
COUNT(*) AS total_checks,
COUNT(*) FILTER (WHERE status = 'success') AS successful_checks,
COUNT(*) FILTER (WHERE status = 'failure') AS failed_checks,
AVG(response_time_ms) FILTER (WHERE status = 'success') AS avg_response_time_ms,
MIN(response_time_ms) FILTER (WHERE status = 'success') AS min_response_time_ms,
MAX(response_time_ms) FILTER (WHERE status = 'success') AS max_response_time_ms
FROM monitoring_checks
GROUP BY equipment_id, bucket
""")
# Add refresh policy for hourly aggregate (refresh every hour)
execute("""
SELECT add_continuous_aggregate_policy('monitoring_checks_hourly',
start_offset => INTERVAL '3 hours',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour')
""")
# Add refresh policy for hourly aggregate (refresh every hour)
execute("""
SELECT add_continuous_aggregate_policy('monitoring_checks_hourly',
start_offset => INTERVAL '3 hours',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour')
""")
# Daily continuous aggregate for monitoring checks
execute("""
CREATE MATERIALIZED VIEW monitoring_checks_daily
WITH (timescaledb.continuous) AS
SELECT
equipment_id,
time_bucket('1 day', checked_at) AS bucket,
COUNT(*) AS total_checks,
COUNT(*) FILTER (WHERE status = 'success') AS successful_checks,
COUNT(*) FILTER (WHERE status = 'failure') AS failed_checks,
AVG(response_time_ms) FILTER (WHERE status = 'success') AS avg_response_time_ms,
MIN(response_time_ms) FILTER (WHERE status = 'success') AS min_response_time_ms,
MAX(response_time_ms) FILTER (WHERE status = 'success') AS max_response_time_ms,
ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'success') / NULLIF(COUNT(*), 0), 2) AS uptime_percentage
FROM monitoring_checks
GROUP BY equipment_id, bucket
""")
# Daily continuous aggregate for monitoring checks
execute("""
CREATE MATERIALIZED VIEW monitoring_checks_daily
WITH (timescaledb.continuous) AS
SELECT
equipment_id,
time_bucket('1 day', checked_at) AS bucket,
COUNT(*) AS total_checks,
COUNT(*) FILTER (WHERE status = 'success') AS successful_checks,
COUNT(*) FILTER (WHERE status = 'failure') AS failed_checks,
AVG(response_time_ms) FILTER (WHERE status = 'success') AS avg_response_time_ms,
MIN(response_time_ms) FILTER (WHERE status = 'success') AS min_response_time_ms,
MAX(response_time_ms) FILTER (WHERE status = 'success') AS max_response_time_ms,
ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'success') / NULLIF(COUNT(*), 0), 2) AS uptime_percentage
FROM monitoring_checks
GROUP BY equipment_id, bucket
""")
# Add refresh policy for daily aggregate (refresh every day)
execute("""
SELECT add_continuous_aggregate_policy('monitoring_checks_daily',
start_offset => INTERVAL '3 days',
end_offset => INTERVAL '1 day',
schedule_interval => INTERVAL '1 day')
""")
# Add refresh policy for daily aggregate (refresh every day)
execute("""
SELECT add_continuous_aggregate_policy('monitoring_checks_daily',
start_offset => INTERVAL '3 days',
end_offset => INTERVAL '1 day',
schedule_interval => INTERVAL '1 day')
""")
# Create indexes on the materialized views
create index(:monitoring_checks_hourly, [:equipment_id, :bucket])
create index(:monitoring_checks_daily, [:equipment_id, :bucket])
else
IO.puts("Development/Test environment - skipping continuous aggregates")
IO.puts("Continuous aggregates will be created in production")
end
end
defp production? do
# Check if running in production environment
System.get_env("MIX_ENV") == "prod" or
Application.get_env(:towerops, :env) == :prod
# Create indexes on the materialized views
create index(:monitoring_checks_hourly, [:equipment_id, :bucket])
create index(:monitoring_checks_daily, [:equipment_id, :bucket])
end
def down do

View file

@ -0,0 +1,11 @@
defmodule Towerops.Repo.Migrations.AddEmailSentAtToAlerts do
use Ecto.Migration
def change do
alter table(:alerts) do
add :email_sent_at, :utc_datetime
end
create index(:alerts, [:email_sent_at])
end
end

View file

@ -0,0 +1,8 @@
defmodule Towerops.Repo.Migrations.AddMonitoringEnabledIndex do
use Ecto.Migration
def change do
# Add index for monitoring_enabled to optimize list_monitored_equipment/0 query
create index(:equipment, [:monitoring_enabled])
end
end

View file

@ -1,8 +1,18 @@
defmodule ToweropsWeb.PageControllerTest do
use ToweropsWeb.ConnCase
test "GET /", %{conn: conn} do
import Towerops.AccountsFixtures
test "GET / redirects to login when not authenticated", %{conn: conn} do
conn = get(conn, ~p"/")
assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
assert redirected_to(conn) == ~p"/users/log-in"
end
test "GET / redirects to organizations when authenticated", %{conn: conn} do
user = user_fixture()
conn = log_in_user(conn, user)
conn = get(conn, ~p"/")
assert redirected_to(conn) == ~p"/orgs"
end
end

View file

@ -15,7 +15,7 @@ defmodule ToweropsWeb.UserRegistrationControllerTest do
test "redirects if already logged in", %{conn: conn} do
conn = conn |> log_in_user(user_fixture()) |> get(~p"/users/register")
assert redirected_to(conn) == ~p"/"
assert redirected_to(conn) == ~p"/orgs"
end
end

View file

@ -84,14 +84,7 @@ defmodule ToweropsWeb.UserSessionControllerTest do
})
assert get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/"
# Now do a logged in request and assert on the menu
conn = get(conn, ~p"/")
response = html_response(conn, 200)
assert response =~ user.email
assert response =~ ~p"/users/settings"
assert response =~ ~p"/users/log-out"
assert redirected_to(conn) == ~p"/orgs"
end
test "logs the user in with remember me", %{conn: conn, user: user} do
@ -107,7 +100,7 @@ defmodule ToweropsWeb.UserSessionControllerTest do
})
assert conn.resp_cookies["_towerops_web_user_remember_me"]
assert redirected_to(conn) == ~p"/"
assert redirected_to(conn) == ~p"/orgs"
end
test "logs the user in with return to", %{conn: conn, user: user} do
@ -159,14 +152,7 @@ defmodule ToweropsWeb.UserSessionControllerTest do
})
assert get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/"
# Now do a logged in request and assert on the menu
conn = get(conn, ~p"/")
response = html_response(conn, 200)
assert response =~ user.email
assert response =~ ~p"/users/settings"
assert response =~ ~p"/users/log-out"
assert redirected_to(conn) == ~p"/orgs"
end
test "confirms unconfirmed user", %{conn: conn, unconfirmed_user: user} do
@ -180,17 +166,10 @@ defmodule ToweropsWeb.UserSessionControllerTest do
})
assert get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/"
assert redirected_to(conn) == ~p"/orgs"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "User confirmed successfully."
assert Accounts.get_user!(user.id).confirmed_at
# Now do a logged in request and assert on the menu
conn = get(conn, ~p"/")
response = html_response(conn, 200)
assert response =~ user.email
assert response =~ ~p"/users/settings"
assert response =~ ~p"/users/log-out"
end
test "emits error message when magic link is invalid", %{conn: conn} do
@ -206,14 +185,14 @@ defmodule ToweropsWeb.UserSessionControllerTest do
describe "DELETE /users/log-out" do
test "logs the user out", %{conn: conn, user: user} do
conn = conn |> log_in_user(user) |> delete(~p"/users/log-out")
assert redirected_to(conn) == ~p"/"
assert redirected_to(conn) == ~p"/users/log-in"
refute get_session(conn, :user_token)
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully"
end
test "succeeds even if the user is not logged in", %{conn: conn} do
conn = delete(conn, ~p"/users/log-out")
assert redirected_to(conn) == ~p"/"
assert redirected_to(conn) == ~p"/users/log-in"
refute get_session(conn, :user_token)
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully"
end

View file

@ -23,7 +23,7 @@ defmodule ToweropsWeb.UserAuthTest do
test "stores the user token in the session", %{conn: conn, user: user} do
conn = UserAuth.log_in_user(conn, user)
assert token = get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/"
assert redirected_to(conn) == ~p"/orgs"
assert Accounts.get_user_by_session_token(token)
end
@ -109,7 +109,7 @@ defmodule ToweropsWeb.UserAuthTest do
refute get_session(conn, :user_token)
refute conn.cookies[@remember_me_cookie]
assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
assert redirected_to(conn) == ~p"/"
assert redirected_to(conn) == ~p"/users/log-in"
refute Accounts.get_user_by_session_token(user_token)
end
@ -117,7 +117,7 @@ defmodule ToweropsWeb.UserAuthTest do
conn = conn |> fetch_cookies() |> UserAuth.log_out_user()
refute get_session(conn, :user_token)
assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
assert redirected_to(conn) == ~p"/"
assert redirected_to(conn) == ~p"/users/log-in"
end
end
@ -229,7 +229,7 @@ defmodule ToweropsWeb.UserAuthTest do
|> UserAuth.redirect_if_user_is_authenticated([])
assert conn.halted
assert redirected_to(conn) == ~p"/"
assert redirected_to(conn) == ~p"/orgs"
end
test "does not redirect if user is not authenticated", %{conn: conn} do