refactor: 🎨 remove caching #48
@@ -1,7 +1,5 @@
|
|||||||
FROM mcr.microsoft.com/devcontainers/python:3.13
|
FROM mcr.microsoft.com/devcontainers/python:3.14
|
||||||
|
|
||||||
# Set the working directory
|
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
# Default command
|
|
||||||
CMD ["sleep", "infinity"]
|
CMD ["sleep", "infinity"]
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"postCreateCommand": "pip install -r requirements.txt; curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64; chmod +x tailwindcss-linux-x64; mv tailwindcss-linux-x64 tailwindcss",
|
"postCreateCommand": "pip install -r requirements.txt; curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -; sudo apt-get install -y nodejs",
|
||||||
"forwardPorts": [5000],
|
"forwardPorts": [5000],
|
||||||
"remoteUser": "vscode"
|
"remoteUser": "vscode"
|
||||||
}
|
}
|
||||||
+4
-13
@@ -1,11 +1,10 @@
|
|||||||
|
# Frontend dev
|
||||||
|
frontend/node_modules/
|
||||||
|
|
||||||
# Documentation
|
# Documentation
|
||||||
README.md
|
|
||||||
CHANGELOG.md
|
|
||||||
*.md
|
*.md
|
||||||
|
|
||||||
# Deployment files
|
# Deployment files
|
||||||
deployment-dev.yml
|
|
||||||
deployment-prod.yml
|
|
||||||
run.sh
|
run.sh
|
||||||
Dockerfile
|
Dockerfile
|
||||||
.dockerignore
|
.dockerignore
|
||||||
@@ -14,6 +13,7 @@ Dockerfile
|
|||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
.gitattributes
|
.gitattributes
|
||||||
|
.gitea
|
||||||
|
|
||||||
# Python cache
|
# Python cache
|
||||||
__pycache__/
|
__pycache__/
|
||||||
@@ -44,15 +44,6 @@ ENV/
|
|||||||
*.log
|
*.log
|
||||||
logs/
|
logs/
|
||||||
|
|
||||||
# Build tools
|
|
||||||
tailwindcss
|
|
||||||
|
|
||||||
# OS files
|
# OS files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Minified files
|
|
||||||
**/*.js
|
|
||||||
!**/*.min.js
|
|
||||||
device_types.css
|
|
||||||
devices.css
|
|
||||||
@@ -16,3 +16,30 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker build -t cr.jdbnet.co.uk/public/ipam:dev --build-arg VERSION=dev .
|
docker build -t cr.jdbnet.co.uk/public/ipam:dev --build-arg VERSION=dev .
|
||||||
docker push cr.jdbnet.co.uk/public/ipam:dev
|
docker push cr.jdbnet.co.uk/public/ipam:dev
|
||||||
|
|
||||||
|
sonarqube:
|
||||||
|
name: SonarQube
|
||||||
|
runs-on: build-htz-01
|
||||||
|
steps:
|
||||||
|
- name: Checkout Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Create Valid Project Key
|
||||||
|
id: sonar_setup
|
||||||
|
run: |
|
||||||
|
CLEAN_KEY=$(echo "${{ gitea.repository }}" | tr '/' ':')
|
||||||
|
echo "key=$CLEAN_KEY" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: SonarQube Scan
|
||||||
|
uses: sonarsource/sonarqube-scan-action@master
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
|
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||||
|
with:
|
||||||
|
args: >
|
||||||
|
-Dsonar.projectKey=${{ steps.sonar_setup.outputs.key }}
|
||||||
|
-Dsonar.projectName=${{ gitea.repository }}
|
||||||
|
-Dsonar.qualitygate.wait=true
|
||||||
+2
-3
@@ -1,5 +1,4 @@
|
|||||||
__pycache__
|
__pycache__
|
||||||
tailwindcss
|
|
||||||
static/css/output.css
|
|
||||||
.env
|
.env
|
||||||
backups/
|
frontend/node_modules/
|
||||||
|
static/dist/
|
||||||
@@ -1,238 +1,53 @@
|
|||||||
# IPAM API Documentation
|
# IPAM API v2
|
||||||
|
|
||||||
REST API for programmatic access to IPAM. All endpoints live under `/api/v1`.
|
All endpoints are JSON under `/api/v2`. The Vue SPA uses **session cookies** (`credentials: include`); automation uses **API keys** (`X-API-Key`, `Authorization: Bearer`, or `?api_key=`).
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
Every request requires an API key. Generate or regenerate keys from **Admin → Users** in the web UI.
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| POST | `/api/v2/auth/login` | `{ email, password }` → `{ ok }` or `{ requires_2fa }` / `{ requires_setup }` |
|
||||||
|
| POST | `/api/v2/auth/verify-2fa` | `{ code, use_backup? }` |
|
||||||
|
| POST | `/api/v2/auth/setup-2fa` | `{ action: "generate" \| "verify", code? }` |
|
||||||
|
| POST | `/api/v2/auth/logout` | Clear session |
|
||||||
|
| GET | `/api/v2/auth/me` | User, permissions, org branding |
|
||||||
|
|
||||||
Provide the key in one of three ways:
|
## Account
|
||||||
|
|
||||||
| Method | Example |
|
| Method | Endpoint |
|
||||||
|--------|---------|
|
|--------|----------|
|
||||||
| Header | `X-API-Key: your_api_key` |
|
| GET | `/api/v2/account` |
|
||||||
| Authorization header | `Authorization: Bearer your_api_key` |
|
| POST | `/api/v2/account/change-password` |
|
||||||
| Query parameter | `?api_key=your_api_key` |
|
| POST | `/api/v2/account/disable-2fa` |
|
||||||
|
| POST | `/api/v2/account/regenerate-backup-codes` |
|
||||||
|
|
||||||
Verify credentials with:
|
## Core resources
|
||||||
|
|
||||||
```http
|
List endpoints return `{ "items": [...] }` unless noted.
|
||||||
GET /api/v1/info
|
|
||||||
X-API-Key: your_api_key
|
|
||||||
```
|
|
||||||
|
|
||||||
## Permissions
|
| Resource | Endpoints |
|
||||||
|
|----------|-----------|
|
||||||
|
| Dashboard | `GET /api/v2/dashboard` |
|
||||||
|
| Search | `GET /api/v2/search?q=` |
|
||||||
|
| Devices | CRUD + `/ips`, `/tags`, `/ip-history`, `/custom-fields` |
|
||||||
|
| Subnets | CRUD + `/available-ips`, `/export`, `/dhcp`, `/custom-fields` |
|
||||||
|
| IP addresses | `PATCH /api/v2/ip-addresses/{id}` (notes) |
|
||||||
|
| IP history | `GET /api/v2/ips/{ip}/history` |
|
||||||
|
| Tags | CRUD + device tag assign/remove |
|
||||||
|
| Racks | CRUD + `/devices`, `/export` |
|
||||||
|
| Custom fields | CRUD + `POST /custom-fields/reorder` |
|
||||||
|
| Audit | `GET /audit`, `GET /audit/export` |
|
||||||
|
| Users & roles | CRUD + `POST /users/{id}/regenerate-api-key` |
|
||||||
|
| Permissions | `GET /permissions` |
|
||||||
|
| Bulk | `POST /bulk/assign-ips`, `/create-devices`, `/assign-tags`, `/export-subnets` |
|
||||||
|
|
||||||
Endpoints use the same role-based permissions as the web UI. If a user lacks the required permission, the API returns `403 Forbidden` with details about the missing permission.
|
See route handlers in `app.py` for required permissions and request bodies.
|
||||||
|
|
||||||
## Response format
|
|
||||||
|
|
||||||
All responses are JSON.
|
|
||||||
|
|
||||||
**Success**
|
|
||||||
|
|
||||||
| Status | Meaning |
|
|
||||||
|--------|---------|
|
|
||||||
| `200 OK` | Request successful |
|
|
||||||
| `201 Created` | Resource created |
|
|
||||||
| `204 No Content` | Success with no response body |
|
|
||||||
|
|
||||||
**Errors**
|
|
||||||
|
|
||||||
| Status | Meaning |
|
|
||||||
|--------|---------|
|
|
||||||
| `400 Bad Request` | Invalid request data |
|
|
||||||
| `401 Unauthorized` | Missing or invalid API key |
|
|
||||||
| `403 Forbidden` | Insufficient permissions |
|
|
||||||
| `404 Not Found` | Resource not found |
|
|
||||||
|
|
||||||
Error bodies typically include an `error` field with a descriptive message.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Devices
|
|
||||||
|
|
||||||
| Method | Endpoint | Permission | Description |
|
|
||||||
|--------|----------|------------|-------------|
|
|
||||||
| GET | `/api/v1/devices` | `view_devices` | List all devices (includes IP addresses, tags, custom fields) |
|
|
||||||
| GET | `/api/v1/devices/{id}` | `view_device` | Get device details |
|
|
||||||
| GET | `/api/v1/devices/by-tag/{tag}` | `view_devices` | List devices by tag name or ID. Use `?format=simple` for `{device, ip}` pairs |
|
|
||||||
| POST | `/api/v1/devices` | `add_device` | Create device |
|
|
||||||
| PUT | `/api/v1/devices/{id}` | `edit_device` | Update device |
|
|
||||||
| DELETE | `/api/v1/devices/{id}` | `delete_device` | Delete device |
|
|
||||||
| POST | `/api/v1/devices/{id}/ips` | `add_device_ip` | Assign IP to device |
|
|
||||||
| DELETE | `/api/v1/devices/{id}/ips/{ip_id}` | `remove_device_ip` | Remove IP from device |
|
|
||||||
|
|
||||||
**Create device** (`POST /api/v1/devices`)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "server-01",
|
|
||||||
"description": "Optional description",
|
|
||||||
"device_type_id": 1
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Update device** (`PUT /api/v1/devices/{id}`) — include only fields to change: `name`, `description`, `device_type_id`.
|
|
||||||
|
|
||||||
**Add IP** (`POST /api/v1/devices/{id}/ips`)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ip_id": 42
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Subnets
|
|
||||||
|
|
||||||
| Method | Endpoint | Permission | Description |
|
|
||||||
|--------|----------|------------|-------------|
|
|
||||||
| GET | `/api/v1/subnets` | `view_subnet` | List all subnets |
|
|
||||||
| GET | `/api/v1/subnets/{id}` | `view_subnet` | Get subnet details |
|
|
||||||
| GET | `/api/v1/subnets/{id}/next_free_ip` | `view_subnet` | Get next available IP |
|
|
||||||
| POST | `/api/v1/subnets` | `add_subnet` | Create subnet |
|
|
||||||
| PUT | `/api/v1/subnets/{id}` | `edit_subnet` | Update subnet |
|
|
||||||
| DELETE | `/api/v1/subnets/{id}` | `delete_subnet` | Delete subnet |
|
|
||||||
|
|
||||||
**Create subnet** (`POST /api/v1/subnets`)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "Office LAN",
|
|
||||||
"cidr": "192.168.1.0/24",
|
|
||||||
"site": "HQ",
|
|
||||||
"vlan_id": 100,
|
|
||||||
"vlan_description": "Optional",
|
|
||||||
"vlan_notes": "Optional"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Subnets must be `/24` or smaller (prefix length ≥ 24).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DHCP
|
|
||||||
|
|
||||||
| Method | Endpoint | Permission | Description |
|
|
||||||
|--------|----------|------------|-------------|
|
|
||||||
| GET | `/api/v1/subnets/{id}/dhcp` | `view_dhcp` | Get DHCP pool configuration |
|
|
||||||
| POST | `/api/v1/subnets/{id}/dhcp` | `configure_dhcp` | Create or update DHCP pool |
|
|
||||||
|
|
||||||
**Configure pool** (`POST /api/v1/subnets/{id}/dhcp`)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"pools": [
|
|
||||||
{
|
|
||||||
"start_ip": "192.168.1.10",
|
|
||||||
"end_ip": "192.168.1.200",
|
|
||||||
"excluded_ips": ["192.168.1.1", "192.168.1.254"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Remove pool**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"remove": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Racks
|
|
||||||
|
|
||||||
| Method | Endpoint | Permission | Description |
|
|
||||||
|--------|----------|------------|-------------|
|
|
||||||
| GET | `/api/v1/racks` | `view_racks` | List all racks |
|
|
||||||
| GET | `/api/v1/racks/{id}` | `view_rack` | Get rack details |
|
|
||||||
| POST | `/api/v1/racks` | `add_rack` | Create rack |
|
|
||||||
| DELETE | `/api/v1/racks/{id}` | `delete_rack` | Delete rack |
|
|
||||||
| POST | `/api/v1/racks/{id}/devices` | `add_device_to_rack` | Add device to rack position |
|
|
||||||
| DELETE | `/api/v1/racks/{id}/devices/{rack_device_id}` | `remove_device_from_rack` | Remove device from rack |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tags
|
|
||||||
|
|
||||||
| Method | Endpoint | Permission | Description |
|
|
||||||
|--------|----------|------------|-------------|
|
|
||||||
| GET | `/api/v1/tags` | `view_tags` | List all tags |
|
|
||||||
| GET | `/api/v1/tags?format=simple` | `view_tags` | List tags in simple format |
|
|
||||||
| GET | `/api/v1/tags/{id}` | `view_tags` | Get tag details |
|
|
||||||
| POST | `/api/v1/tags` | `add_tag` | Create tag |
|
|
||||||
| PUT | `/api/v1/tags/{id}` | `edit_tag` | Update tag |
|
|
||||||
| DELETE | `/api/v1/tags/{id}` | `delete_tag` | Delete tag |
|
|
||||||
| GET | `/api/v1/devices/{id}/tags` | `view_device` | Get tags for a device |
|
|
||||||
| POST | `/api/v1/devices/{id}/tags` | `assign_device_tag` | Assign tag to device |
|
|
||||||
| DELETE | `/api/v1/devices/{id}/tags/{tag_id}` | `remove_device_tag` | Remove tag from device |
|
|
||||||
|
|
||||||
**Assign tag** (`POST /api/v1/devices/{id}/tags`)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"tag_id": 3
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Custom fields
|
|
||||||
|
|
||||||
| Method | Endpoint | Permission | Description |
|
|
||||||
|--------|----------|------------|-------------|
|
|
||||||
| GET | `/api/v1/custom_fields/{entity_type}` | `view_custom_fields` | List field definitions (`device` or `subnet`) |
|
|
||||||
| POST | `/api/v1/custom_fields` | `manage_custom_fields` | Create field definition |
|
|
||||||
| PUT | `/api/v1/custom_fields/{id}` | `manage_custom_fields` | Update field definition |
|
|
||||||
| DELETE | `/api/v1/custom_fields/{id}` | `manage_custom_fields` | Delete field definition |
|
|
||||||
|
|
||||||
**Create field** (`POST /api/v1/custom_fields`)
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"entity_type": "device",
|
|
||||||
"name": "Asset tag",
|
|
||||||
"field_key": "asset_tag",
|
|
||||||
"field_type": "text",
|
|
||||||
"required": false,
|
|
||||||
"default_value": "",
|
|
||||||
"help_text": "Optional help text",
|
|
||||||
"display_order": 0,
|
|
||||||
"validation_rules": {
|
|
||||||
"max_length": 32
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## System & admin
|
|
||||||
|
|
||||||
| Method | Endpoint | Permission | Description |
|
|
||||||
|--------|----------|------------|-------------|
|
|
||||||
| GET | `/api/v1/info` | *(authenticated)* | API version (`2.0`) and current user info |
|
|
||||||
| GET | `/api/v1/device-types` | `view_device_types` | List device types |
|
|
||||||
| GET | `/api/v1/devices/{id}/ip_history` | `view_device` | IP assignment history for a device |
|
|
||||||
| GET | `/api/v1/ips/{ip}/history` | `view_subnet` | IP assignment history for an address |
|
|
||||||
| GET | `/api/v1/audit` | `view_audit` | List audit log entries (`limit`, `offset` query params) |
|
|
||||||
| GET | `/api/v1/users` | `view_users` | List users |
|
|
||||||
| GET | `/api/v1/roles` | `view_users` | List roles with permissions |
|
|
||||||
|
|
||||||
Mutating API calls (`POST`, `PUT`, `DELETE`, `PATCH`) are logged to the audit log as `api_usage`. GET requests are not audited (v2 change — see [v1-to-v2-breaking-changes.md](v1-to-v2-breaking-changes.md)).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -H "X-API-Key: YOUR_KEY" https://your-ipam-host/api/v1/devices
|
curl -H "X-API-Key: YOUR_KEY" https://host/api/v2/devices
|
||||||
|
curl -c cookies.txt -X POST -H "Content-Type: application/json" \
|
||||||
curl -H "X-API-Key: YOUR_KEY" \
|
-d '{"email":"admin@example.com","password":"password"}' \
|
||||||
-H "Content-Type: application/json" \
|
https://host/api/v2/auth/login
|
||||||
-d '{"name": "switch-01", "description": "Core switch"}' \
|
|
||||||
https://your-ipam-host/api/v1/devices
|
|
||||||
```
|
```
|
||||||
|
|||||||
+12
-9
@@ -1,15 +1,18 @@
|
|||||||
FROM python:3.13-slim
|
FROM node:22-bookworm-slim AS frontend
|
||||||
|
WORKDIR /app
|
||||||
|
COPY frontend/package.json ./frontend/
|
||||||
|
RUN cd frontend && npm install
|
||||||
|
COPY frontend/ ./frontend/
|
||||||
|
RUN cd frontend && npm run build
|
||||||
|
|
||||||
|
FROM python:3.14-slim
|
||||||
LABEL org.opencontainers.image.vendor="JDB-NET"
|
LABEL org.opencontainers.image.vendor="JDB-NET"
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . /app
|
COPY requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY app.py db.py ./
|
||||||
|
COPY --from=frontend /app/static/dist ./static/dist
|
||||||
ARG VERSION=unknown
|
ARG VERSION=unknown
|
||||||
ENV VERSION=${VERSION}
|
ENV VERSION=${VERSION}
|
||||||
RUN pip install -r requirements.txt
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
RUN curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 \
|
|
||||||
&& chmod +x tailwindcss-linux-x64 \
|
|
||||||
&& ./tailwindcss-linux-x64 -i ./static/css/input.css -o ./static/css/output.css --content "./templates/*.html,./static/js/*.min.js" --minify \
|
|
||||||
&& rm tailwindcss-linux-x64
|
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app", "--log-level", "warning"]
|
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app", "--log-level", "warning"]
|
||||||
@@ -19,10 +19,22 @@ A Flask-based web application for comprehensive IP Address Management (IPAM). Ma
|
|||||||
- **Audit Logging**: Complete audit trail of all changes with user, action, details, and timestamps
|
- **Audit Logging**: Complete audit trail of all changes with user, action, details, and timestamps
|
||||||
- **User Management**: Multi-user support with secure password authentication
|
- **User Management**: Multi-user support with secure password authentication
|
||||||
- **Role-Based Access Control (RBAC)**: Granular permission system with default roles (admin, user, view_only) and custom role creation
|
- **Role-Based Access Control (RBAC)**: Granular permission system with default roles (admin, user, view_only) and custom role creation
|
||||||
- **REST API**: Full-featured REST API with API key authentication for programmatic access
|
- **Web Interface**: Vue 3 SPA with automatic light/dark theme and mobile-first layout
|
||||||
- **CSV Export**: Export subnet and rack data to CSV files
|
- **REST API v2**: JSON API at `/api/v2` (session cookies for browser, API keys for automation)
|
||||||
- **Device Statistics**: View device counts by type
|
|
||||||
- **Web Interface**: Modern, responsive web GUI built with Tailwind CSS and dark mode support
|
## Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
pip install -r requirements.txt
|
||||||
|
./run.sh # builds frontend if needed, starts Flask on :5000
|
||||||
|
|
||||||
|
# Frontend hot reload (optional)
|
||||||
|
cd frontend && npm install && npm run dev
|
||||||
|
# Vite proxies /api to http://127.0.0.1:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
API reference: [API.md](API.md)
|
||||||
|
|
||||||
## Quick Start with Docker
|
## Quick Start with Docker
|
||||||
|
|
||||||
@@ -112,7 +124,7 @@ See [v1-to-v2-breaking-changes.md](v1-to-v2-breaking-changes.md) for removed fea
|
|||||||
|
|
||||||
1. Navigate to "Devices" from the main menu
|
1. Navigate to "Devices" from the main menu
|
||||||
2. Click "Add Device"
|
2. Click "Add Device"
|
||||||
3. Enter device name and select device type
|
3. Enter device name (and optional description)
|
||||||
4. Click "Create Device"
|
4. Click "Create Device"
|
||||||
|
|
||||||
### Assigning IP Addresses to Devices
|
### Assigning IP Addresses to Devices
|
||||||
@@ -206,7 +218,6 @@ The application includes a comprehensive REST API for programmatic access:
|
|||||||
- **Tag Assignment**: `GET`, `POST /api/v1/devices/{id}/tags`, `DELETE /api/v1/devices/{id}/tags/{tag_id}`
|
- **Tag Assignment**: `GET`, `POST /api/v1/devices/{id}/tags`, `DELETE /api/v1/devices/{id}/tags/{tag_id}`
|
||||||
- **Subnets**: `GET`, `POST`, `PUT`, `DELETE /api/v1/subnets`
|
- **Subnets**: `GET`, `POST`, `PUT`, `DELETE /api/v1/subnets`
|
||||||
- **Racks**: `GET`, `POST`, `DELETE /api/v1/racks`
|
- **Racks**: `GET`, `POST`, `DELETE /api/v1/racks`
|
||||||
- **Device Types**: `GET /api/v1/device-types`
|
|
||||||
- **DHCP**: `GET`, `POST /api/v1/subnets/{id}/dhcp`
|
- **DHCP**: `GET`, `POST /api/v1/subnets/{id}/dhcp`
|
||||||
- **Audit Log**: `GET /api/v1/audit`
|
- **Audit Log**: `GET /api/v1/audit`
|
||||||
- **Users & Roles**: `GET /api/v1/users`, `GET /api/v1/roles` (admin only)
|
- **Users & Roles**: `GET /api/v1/users`, `GET /api/v1/roles` (admin only)
|
||||||
|
|||||||
@@ -80,19 +80,10 @@ def init_db(app=None):
|
|||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS DeviceType (
|
|
||||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
|
||||||
name VARCHAR(255) NOT NULL UNIQUE,
|
|
||||||
icon_class VARCHAR(255) NOT NULL
|
|
||||||
)
|
|
||||||
''')
|
|
||||||
cursor.execute('''
|
|
||||||
CREATE TABLE IF NOT EXISTS Device (
|
CREATE TABLE IF NOT EXISTS Device (
|
||||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
description TEXT,
|
description TEXT
|
||||||
device_type_id INTEGER DEFAULT 1,
|
|
||||||
FOREIGN KEY (device_type_id) REFERENCES DeviceType(id)
|
|
||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
@@ -134,37 +125,6 @@ def init_db(app=None):
|
|||||||
FOREIGN KEY (device_id) REFERENCES Device(id) ON DELETE CASCADE
|
FOREIGN KEY (device_id) REFERENCES Device(id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
# Initialize default device types only if table is empty
|
|
||||||
cursor.execute('SELECT COUNT(*) FROM DeviceType')
|
|
||||||
if cursor.fetchone()[0] == 0:
|
|
||||||
cursor.executemany('INSERT INTO DeviceType (name, icon_class) VALUES (%s, %s)', [
|
|
||||||
('Server', 'fa-server'),
|
|
||||||
('Virtual Machine', 'fa-boxes-stacked'),
|
|
||||||
('Switch', 'fa-network-wired'),
|
|
||||||
('Firewall', 'fa-shield-halved'),
|
|
||||||
('WiFi AP', 'fa-wifi'),
|
|
||||||
('Printer', 'fa-print'),
|
|
||||||
('Other', 'fa-question')
|
|
||||||
])
|
|
||||||
conn.commit() # Commit the inserts before querying
|
|
||||||
|
|
||||||
# Add device_type_id column if it doesn't exist
|
|
||||||
cursor.execute("SHOW COLUMNS FROM Device LIKE 'device_type_id'")
|
|
||||||
if not cursor.fetchone():
|
|
||||||
cursor.execute('ALTER TABLE Device ADD COLUMN device_type_id INTEGER DEFAULT NULL')
|
|
||||||
|
|
||||||
# Set default device_type_id for devices that don't have one
|
|
||||||
# Use the first available device type, or leave NULL if no types exist
|
|
||||||
cursor.execute('SELECT id FROM DeviceType ORDER BY id LIMIT 1')
|
|
||||||
first_type_result = cursor.fetchone()
|
|
||||||
if first_type_result:
|
|
||||||
first_type_id = first_type_result[0]
|
|
||||||
cursor.execute('UPDATE Device SET device_type_id = %s WHERE device_type_id IS NULL', (first_type_id,))
|
|
||||||
try:
|
|
||||||
cursor.execute('ALTER TABLE Device ADD CONSTRAINT fk_device_type FOREIGN KEY (device_type_id) REFERENCES DeviceType(id)')
|
|
||||||
except mysql.connector.Error as e:
|
|
||||||
if e.errno != 1061 and e.errno != 1826 and 'Duplicate' not in str(e):
|
|
||||||
raise
|
|
||||||
# Create Role table
|
# Create Role table
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS Role (
|
CREATE TABLE IF NOT EXISTS Role (
|
||||||
@@ -362,14 +322,11 @@ def init_db(app=None):
|
|||||||
('view_audit', 'View Audit Log', 'View'),
|
('view_audit', 'View Audit Log', 'View'),
|
||||||
('view_admin', 'View Admin panel', 'View'),
|
('view_admin', 'View Admin panel', 'View'),
|
||||||
('view_users', 'View Users page', 'View'),
|
('view_users', 'View Users page', 'View'),
|
||||||
('view_device_types', 'View Device Types page', 'View'),
|
|
||||||
('view_device_type_stats', 'View Device Type Statistics', 'View'),
|
|
||||||
('view_devices_by_type', 'View Devices by Type', 'View'),
|
|
||||||
('view_dhcp', 'View DHCP configuration', 'View'),
|
('view_dhcp', 'View DHCP configuration', 'View'),
|
||||||
|
|
||||||
# Device permissions
|
# Device permissions
|
||||||
('add_device', 'Add new device', 'Device'),
|
('add_device', 'Add new device', 'Device'),
|
||||||
('edit_device', 'Edit device (rename, description, type)', 'Device'),
|
('edit_device', 'Edit device (rename, description)', 'Device'),
|
||||||
('delete_device', 'Delete device', 'Device'),
|
('delete_device', 'Delete device', 'Device'),
|
||||||
('add_device_ip', 'Add IP address to device', 'Device'),
|
('add_device_ip', 'Add IP address to device', 'Device'),
|
||||||
('remove_device_ip', 'Remove IP address from device', 'Device'),
|
('remove_device_ip', 'Remove IP address from device', 'Device'),
|
||||||
@@ -391,11 +348,6 @@ def init_db(app=None):
|
|||||||
# DHCP permissions
|
# DHCP permissions
|
||||||
('configure_dhcp', 'Configure DHCP pools', 'DHCP'),
|
('configure_dhcp', 'Configure DHCP pools', 'DHCP'),
|
||||||
|
|
||||||
# Device Type permissions
|
|
||||||
('add_device_type', 'Add device type', 'Device Type'),
|
|
||||||
('edit_device_type', 'Edit device type', 'Device Type'),
|
|
||||||
('delete_device_type', 'Delete device type', 'Device Type'),
|
|
||||||
|
|
||||||
# Tag permissions
|
# Tag permissions
|
||||||
('view_tags', 'View tags', 'Tag'),
|
('view_tags', 'View tags', 'Tag'),
|
||||||
('add_tag', 'Add new tag', 'Tag'),
|
('add_tag', 'Add new tag', 'Tag'),
|
||||||
@@ -461,14 +413,13 @@ def init_db(app=None):
|
|||||||
# Assign non-admin permissions to user role
|
# Assign non-admin permissions to user role
|
||||||
non_admin_permissions = [
|
non_admin_permissions = [
|
||||||
'view_index', 'view_devices', 'view_device', 'view_subnet', 'view_racks', 'view_rack',
|
'view_index', 'view_devices', 'view_device', 'view_subnet', 'view_racks', 'view_rack',
|
||||||
'view_audit', 'view_device_types', 'view_device_type_stats', 'view_devices_by_type',
|
'view_audit',
|
||||||
'view_dhcp',
|
'view_dhcp',
|
||||||
'add_device', 'edit_device', 'delete_device', 'add_device_ip', 'remove_device_ip',
|
'add_device', 'edit_device', 'delete_device', 'add_device_ip', 'remove_device_ip',
|
||||||
'add_subnet', 'edit_subnet', 'delete_subnet', 'export_subnet_csv',
|
'add_subnet', 'edit_subnet', 'delete_subnet', 'export_subnet_csv',
|
||||||
'add_rack', 'delete_rack', 'add_device_to_rack', 'remove_device_from_rack',
|
'add_rack', 'delete_rack', 'add_device_to_rack', 'remove_device_from_rack',
|
||||||
'add_nonnet_device_to_rack', 'export_rack_csv',
|
'add_nonnet_device_to_rack', 'export_rack_csv',
|
||||||
'configure_dhcp',
|
'configure_dhcp',
|
||||||
'add_device_type', 'edit_device_type', 'delete_device_type',
|
|
||||||
'view_tags', 'add_tag', 'edit_tag', 'delete_tag', 'assign_device_tag', 'remove_device_tag',
|
'view_tags', 'add_tag', 'edit_tag', 'delete_tag', 'assign_device_tag', 'remove_device_tag',
|
||||||
'view_custom_fields', 'manage_custom_fields'
|
'view_custom_fields', 'manage_custom_fields'
|
||||||
]
|
]
|
||||||
@@ -488,7 +439,7 @@ def init_db(app=None):
|
|||||||
# Same view permissions as user role, but excluding admin views (view_admin, view_users)
|
# Same view permissions as user role, but excluding admin views (view_admin, view_users)
|
||||||
view_only_permissions = [
|
view_only_permissions = [
|
||||||
'view_index', 'view_devices', 'view_device', 'view_subnet', 'view_racks', 'view_rack',
|
'view_index', 'view_devices', 'view_device', 'view_subnet', 'view_racks', 'view_rack',
|
||||||
'view_audit', 'view_device_types', 'view_device_type_stats', 'view_devices_by_type',
|
'view_audit',
|
||||||
'view_dhcp', 'view_tags', 'view_custom_fields'
|
'view_dhcp', 'view_tags', 'view_custom_fields'
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -578,7 +529,6 @@ def init_db(app=None):
|
|||||||
create_index_if_not_exists(cursor, 'idx_rackdevice_rack_side', 'RackDevice', 'rack_id, side')
|
create_index_if_not_exists(cursor, 'idx_rackdevice_rack_side', 'RackDevice', 'rack_id, side')
|
||||||
|
|
||||||
# Device table indexes
|
# Device table indexes
|
||||||
create_index_if_not_exists(cursor, 'idx_device_device_type_id', 'Device', 'device_type_id')
|
|
||||||
|
|
||||||
# User table indexes (api_key already has UNIQUE index)
|
# User table indexes (api_key already has UNIQUE index)
|
||||||
create_index_if_not_exists(cursor, 'idx_user_role_id', 'User', 'role_id')
|
create_index_if_not_exists(cursor, 'idx_user_role_id', 'User', 'role_id')
|
||||||
@@ -616,4 +566,33 @@ def run_v2_migrations(cursor, conn):
|
|||||||
cursor.execute('DELETE FROM Permission WHERE id = %s', (perm_id,))
|
cursor.execute('DELETE FROM Permission WHERE id = %s', (perm_id,))
|
||||||
logging.info("Removed orphaned permission: %s", perm_name)
|
logging.info("Removed orphaned permission: %s", perm_name)
|
||||||
|
|
||||||
|
cursor.execute("SHOW COLUMNS FROM Device LIKE 'device_type_id'")
|
||||||
|
if cursor.fetchone():
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'Device'
|
||||||
|
AND COLUMN_NAME = 'device_type_id' AND REFERENCED_TABLE_NAME IS NOT NULL
|
||||||
|
""")
|
||||||
|
for (fk_name,) in cursor.fetchall():
|
||||||
|
cursor.execute(f'ALTER TABLE Device DROP FOREIGN KEY `{fk_name}`')
|
||||||
|
cursor.execute('ALTER TABLE Device DROP COLUMN device_type_id')
|
||||||
|
logging.info("Dropped Device.device_type_id column")
|
||||||
|
|
||||||
|
cursor.execute("SHOW TABLES LIKE 'DeviceType'")
|
||||||
|
if cursor.fetchone():
|
||||||
|
cursor.execute('DROP TABLE DeviceType')
|
||||||
|
logging.info("Dropped DeviceType table")
|
||||||
|
|
||||||
|
for perm_name in (
|
||||||
|
'view_device_types', 'view_device_type_stats', 'view_devices_by_type',
|
||||||
|
'add_device_type', 'edit_device_type', 'delete_device_type',
|
||||||
|
):
|
||||||
|
cursor.execute('SELECT id FROM Permission WHERE name = %s', (perm_name,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
perm_id = row[0]
|
||||||
|
cursor.execute('DELETE FROM RolePermission WHERE permission_id = %s', (perm_id,))
|
||||||
|
cursor.execute('DELETE FROM Permission WHERE id = %s', (perm_id,))
|
||||||
|
logging.info("Removed orphaned permission: %s", perm_name)
|
||||||
|
|
||||||
logging.info("v2 database migrations complete")
|
logging.info("v2 database migrations complete")
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>IPAM</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" type="image/png" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body class="bg-surface text-slate-900 dark:text-slate-100 antialiased">
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2671
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "ipam-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "2.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-vue-next": "^0.468.0",
|
||||||
|
"pinia": "^2.2.6",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.16",
|
||||||
|
"typescript": "~5.6.3",
|
||||||
|
"vite": "^6.0.3",
|
||||||
|
"vue-tsc": "^2.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterView } from "vue-router";
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
const jsonHeaders = { "Content-Type": "application/json" };
|
||||||
|
|
||||||
|
async function handle<T>(res: Response): Promise<T> {
|
||||||
|
if (res.status === 401) throw new Error("unauthorized");
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error((data as { error?: string }).error || res.statusText);
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchApi(path: string, init?: RequestInit) {
|
||||||
|
return fetch(path, { credentials: "include", ...init });
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeResponse {
|
||||||
|
logged_in: boolean;
|
||||||
|
app_version?: string;
|
||||||
|
org?: { name: string; logo: string };
|
||||||
|
user?: { id: number; name: string; email: string };
|
||||||
|
permissions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Device {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
ip_addresses?: IpOnDevice[];
|
||||||
|
tags?: Tag[];
|
||||||
|
custom_fields?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IpOnDevice {
|
||||||
|
id: number;
|
||||||
|
ip: string;
|
||||||
|
hostname?: string;
|
||||||
|
subnet_id?: number;
|
||||||
|
subnet_name?: string;
|
||||||
|
cidr?: string;
|
||||||
|
site?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Subnet {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
cidr: string;
|
||||||
|
site?: string;
|
||||||
|
vlan_id?: number;
|
||||||
|
vlan_description?: string;
|
||||||
|
vlan_notes?: string;
|
||||||
|
utilization?: number;
|
||||||
|
total_ips?: number;
|
||||||
|
used_ips?: number;
|
||||||
|
custom_fields?: Record<string, unknown>;
|
||||||
|
ip_addresses?: SubnetIp[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubnetIp {
|
||||||
|
id: number;
|
||||||
|
ip: string;
|
||||||
|
hostname?: string;
|
||||||
|
device_id?: number;
|
||||||
|
device_name?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Rack {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
site: string;
|
||||||
|
height_u: number;
|
||||||
|
used_u?: number;
|
||||||
|
percent_full?: number;
|
||||||
|
devices?: RackDevice[];
|
||||||
|
site_devices?: { id: number; name: string; description?: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RackDevice {
|
||||||
|
id: number;
|
||||||
|
position_u: number;
|
||||||
|
side: string;
|
||||||
|
device_id?: number;
|
||||||
|
device_name?: string;
|
||||||
|
nonnet_device_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditEntry {
|
||||||
|
id: number;
|
||||||
|
user_name?: string;
|
||||||
|
action: string;
|
||||||
|
details?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role_id?: number;
|
||||||
|
role_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoleRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
require_2fa?: boolean;
|
||||||
|
permissions?: { id: number; name: string; category?: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomFieldDef {
|
||||||
|
id: number;
|
||||||
|
entity_type: string;
|
||||||
|
name: string;
|
||||||
|
field_key: string;
|
||||||
|
field_type: string;
|
||||||
|
required?: boolean;
|
||||||
|
display_order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
async me(): Promise<MeResponse> {
|
||||||
|
return handle(await fetchApi("/api/v2/auth/me"));
|
||||||
|
},
|
||||||
|
async login(email: string, password: string) {
|
||||||
|
return handle<{ ok?: boolean; requires_2fa?: boolean; requires_setup?: boolean }>(
|
||||||
|
await fetchApi("/api/v2/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: jsonHeaders,
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async verify2fa(code: string, useBackup = false) {
|
||||||
|
return handle(await fetchApi("/api/v2/auth/verify-2fa", {
|
||||||
|
method: "POST",
|
||||||
|
headers: jsonHeaders,
|
||||||
|
body: JSON.stringify({ code, use_backup: useBackup }),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
async setup2fa(action: "generate" | "verify", code?: string) {
|
||||||
|
return handle<{ secret?: string; qr_code?: string; backup_codes?: string[] }>(
|
||||||
|
await fetchApi("/api/v2/auth/setup-2fa", {
|
||||||
|
method: "POST",
|
||||||
|
headers: jsonHeaders,
|
||||||
|
body: JSON.stringify({ action, code }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
async logout() {
|
||||||
|
return handle(await fetchApi("/api/v2/auth/logout", { method: "POST" }));
|
||||||
|
},
|
||||||
|
async dashboard() {
|
||||||
|
return handle<{ sites: Record<string, Subnet[]> }>(await fetchApi("/api/v2/dashboard"));
|
||||||
|
},
|
||||||
|
async search(q: string) {
|
||||||
|
return handle<Record<string, unknown[]>>(await fetchApi(`/api/v2/search?q=${encodeURIComponent(q)}`));
|
||||||
|
},
|
||||||
|
async devices(params?: { tag?: string; site?: string }) {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (params?.tag) p.set("tag", params.tag);
|
||||||
|
if (params?.site) p.set("site", params.site);
|
||||||
|
const q = p.toString();
|
||||||
|
const d = await handle<{ items: Device[] }>(await fetchApi(`/api/v2/devices${q ? `?${q}` : ""}`));
|
||||||
|
return d.items;
|
||||||
|
},
|
||||||
|
async device(id: number) {
|
||||||
|
return handle<Device>(await fetchApi(`/api/v2/devices/${id}`));
|
||||||
|
},
|
||||||
|
async createDevice(body: Partial<Device>) {
|
||||||
|
return handle(await fetchApi("/api/v2/devices", { method: "POST", headers: jsonHeaders, body: JSON.stringify(body) }));
|
||||||
|
},
|
||||||
|
async updateDevice(id: number, body: Partial<Device>) {
|
||||||
|
return handle(await fetchApi(`/api/v2/devices/${id}`, { method: "PUT", headers: jsonHeaders, body: JSON.stringify(body) }));
|
||||||
|
},
|
||||||
|
async deleteDevice(id: number) {
|
||||||
|
return handle(await fetchApi(`/api/v2/devices/${id}`, { method: "DELETE" }));
|
||||||
|
},
|
||||||
|
async assignIp(deviceId: number, ipId: number) {
|
||||||
|
return handle(await fetchApi(`/api/v2/devices/${deviceId}/ips`, {
|
||||||
|
method: "POST", headers: jsonHeaders, body: JSON.stringify({ ip_id: ipId }),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
async removeIp(deviceId: number, ipId: number) {
|
||||||
|
return handle(await fetchApi(`/api/v2/devices/${deviceId}/ips/${ipId}`, { method: "DELETE" }));
|
||||||
|
},
|
||||||
|
async deviceIpHistory(deviceId: number) {
|
||||||
|
const d = await handle<{ items: unknown[] }>(await fetchApi(`/api/v2/devices/${deviceId}/ip-history`));
|
||||||
|
return d.items;
|
||||||
|
},
|
||||||
|
async subnets(includeUtil = true) {
|
||||||
|
const d = await handle<{ items: Subnet[] }>(
|
||||||
|
await fetchApi(`/api/v2/subnets${includeUtil ? "?include=utilization" : ""}`),
|
||||||
|
);
|
||||||
|
return d.items;
|
||||||
|
},
|
||||||
|
async subnet(id: number) {
|
||||||
|
return handle<Subnet>(await fetchApi(`/api/v2/subnets/${id}`));
|
||||||
|
},
|
||||||
|
async createSubnet(body: Partial<Subnet>) {
|
||||||
|
return handle(await fetchApi("/api/v2/subnets", { method: "POST", headers: jsonHeaders, body: JSON.stringify(body) }));
|
||||||
|
},
|
||||||
|
async updateSubnet(id: number, body: Partial<Subnet>) {
|
||||||
|
return handle(await fetchApi(`/api/v2/subnets/${id}`, { method: "PUT", headers: jsonHeaders, body: JSON.stringify(body) }));
|
||||||
|
},
|
||||||
|
async deleteSubnet(id: number) {
|
||||||
|
return handle(await fetchApi(`/api/v2/subnets/${id}`, { method: "DELETE" }));
|
||||||
|
},
|
||||||
|
async availableIps(subnetId: number) {
|
||||||
|
const d = await handle<{ items: { id: number; ip: string }[] }>(await fetchApi(`/api/v2/subnets/${subnetId}/available-ips`));
|
||||||
|
return d.items;
|
||||||
|
},
|
||||||
|
async patchIpNotes(ipId: number, notes: string) {
|
||||||
|
return handle(await fetchApi(`/api/v2/ip-addresses/${ipId}`, {
|
||||||
|
method: "PATCH", headers: jsonHeaders, body: JSON.stringify({ notes }),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
async ipHistory(ip: string) {
|
||||||
|
const d = await handle<{ items: unknown[] }>(await fetchApi(`/api/v2/ips/${encodeURIComponent(ip)}/history`));
|
||||||
|
return d.items;
|
||||||
|
},
|
||||||
|
subnetExportUrl(id: number) {
|
||||||
|
return `/api/v2/subnets/${id}/export`;
|
||||||
|
},
|
||||||
|
async tags() {
|
||||||
|
const d = await handle<{ tags?: Tag[]; items?: Tag[] }>(await fetchApi("/api/v2/tags"));
|
||||||
|
return d.items ?? d.tags ?? [];
|
||||||
|
},
|
||||||
|
async createTag(body: Partial<Tag>) {
|
||||||
|
return handle(await fetchApi("/api/v2/tags", { method: "POST", headers: jsonHeaders, body: JSON.stringify(body) }));
|
||||||
|
},
|
||||||
|
async updateTag(id: number, body: Partial<Tag>) {
|
||||||
|
return handle(await fetchApi(`/api/v2/tags/${id}`, { method: "PUT", headers: jsonHeaders, body: JSON.stringify(body) }));
|
||||||
|
},
|
||||||
|
async deleteTag(id: number) {
|
||||||
|
return handle(await fetchApi(`/api/v2/tags/${id}`, { method: "DELETE" }));
|
||||||
|
},
|
||||||
|
async assignTag(deviceId: number, tagId: number) {
|
||||||
|
return handle(await fetchApi(`/api/v2/devices/${deviceId}/tags`, {
|
||||||
|
method: "POST", headers: jsonHeaders, body: JSON.stringify({ tag_id: tagId }),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
async removeTag(deviceId: number, tagId: number) {
|
||||||
|
return handle(await fetchApi(`/api/v2/devices/${deviceId}/tags/${tagId}`, { method: "DELETE" }));
|
||||||
|
},
|
||||||
|
async racks() {
|
||||||
|
const d = await handle<{ racks?: Rack[]; items?: Rack[] }>(await fetchApi("/api/v2/racks"));
|
||||||
|
return d.racks ?? d.items ?? [];
|
||||||
|
},
|
||||||
|
async rack(id: number) {
|
||||||
|
return handle<Rack>(await fetchApi(`/api/v2/racks/${id}`));
|
||||||
|
},
|
||||||
|
async createRack(body: Partial<Rack>) {
|
||||||
|
return handle(await fetchApi("/api/v2/racks", { method: "POST", headers: jsonHeaders, body: JSON.stringify(body) }));
|
||||||
|
},
|
||||||
|
async deleteRack(id: number) {
|
||||||
|
return handle(await fetchApi(`/api/v2/racks/${id}`, { method: "DELETE" }));
|
||||||
|
},
|
||||||
|
async updateRack(id: number, body: Partial<Rack>) {
|
||||||
|
return handle(await fetchApi(`/api/v2/racks/${id}`, { method: "PUT", headers: jsonHeaders, body: JSON.stringify(body) }));
|
||||||
|
},
|
||||||
|
async addRackDevice(rackId: number, body: { position_u: number; side: string; device_id?: number; nonnet_device_name?: string }) {
|
||||||
|
return handle(await fetchApi(`/api/v2/racks/${rackId}/devices`, { method: "POST", headers: jsonHeaders, body: JSON.stringify(body) }));
|
||||||
|
},
|
||||||
|
async removeRackDevice(rackId: number, rackDeviceId: number) {
|
||||||
|
return handle(await fetchApi(`/api/v2/racks/${rackId}/devices/${rackDeviceId}`, { method: "DELETE" }));
|
||||||
|
},
|
||||||
|
rackExportUrl(id: number) {
|
||||||
|
return `/api/v2/racks/${id}/export`;
|
||||||
|
},
|
||||||
|
async createCustomField(body: Record<string, unknown>) {
|
||||||
|
return handle(await fetchApi("/api/v2/custom_fields", { method: "POST", headers: jsonHeaders, body: JSON.stringify(body) }));
|
||||||
|
},
|
||||||
|
async updateCustomField(id: number, body: Record<string, unknown>) {
|
||||||
|
return handle(await fetchApi(`/api/v2/custom_fields/${id}`, { method: "PUT", headers: jsonHeaders, body: JSON.stringify(body) }));
|
||||||
|
},
|
||||||
|
async deleteCustomField(id: number) {
|
||||||
|
return handle(await fetchApi(`/api/v2/custom_fields/${id}`, { method: "DELETE" }));
|
||||||
|
},
|
||||||
|
async reorderCustomFields(entityType: string, fieldOrders: Record<number, number>) {
|
||||||
|
return handle(await fetchApi("/api/v2/custom-fields/reorder", {
|
||||||
|
method: "POST", headers: jsonHeaders, body: JSON.stringify({ entity_type: entityType, field_orders: fieldOrders }),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
async createUser(body: { name: string; email: string; password: string; role_id?: number }) {
|
||||||
|
return handle(await fetchApi("/api/v2/users", { method: "POST", headers: jsonHeaders, body: JSON.stringify(body) }));
|
||||||
|
},
|
||||||
|
async updateUser(id: number, body: Record<string, unknown>) {
|
||||||
|
return handle(await fetchApi(`/api/v2/users/${id}`, { method: "PUT", headers: jsonHeaders, body: JSON.stringify(body) }));
|
||||||
|
},
|
||||||
|
async deleteUser(id: number) {
|
||||||
|
return handle(await fetchApi(`/api/v2/users/${id}`, { method: "DELETE" }));
|
||||||
|
},
|
||||||
|
async regenerateApiKey(userId: number) {
|
||||||
|
return handle<{ api_key: string }>(await fetchApi(`/api/v2/users/${userId}/regenerate-api-key`, { method: "POST" }));
|
||||||
|
},
|
||||||
|
async createRole(body: { name: string; description?: string; permission_ids?: number[]; require_2fa?: boolean }) {
|
||||||
|
return handle(await fetchApi("/api/v2/roles", { method: "POST", headers: jsonHeaders, body: JSON.stringify(body) }));
|
||||||
|
},
|
||||||
|
async updateRole(id: number, body: Record<string, unknown>) {
|
||||||
|
return handle(await fetchApi(`/api/v2/roles/${id}`, { method: "PUT", headers: jsonHeaders, body: JSON.stringify(body) }));
|
||||||
|
},
|
||||||
|
async deleteRole(id: number) {
|
||||||
|
return handle(await fetchApi(`/api/v2/roles/${id}`, { method: "DELETE" }));
|
||||||
|
},
|
||||||
|
async disable2fa(password: string) {
|
||||||
|
return handle(await fetchApi("/api/v2/account/disable-2fa", {
|
||||||
|
method: "POST", headers: jsonHeaders, body: JSON.stringify({ password }),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
async regenerateBackupCodes(password: string) {
|
||||||
|
return handle<{ backup_codes: string[] }>(await fetchApi("/api/v2/account/regenerate-backup-codes", {
|
||||||
|
method: "POST", headers: jsonHeaders, body: JSON.stringify({ password }),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
async audit(limit = 100) {
|
||||||
|
const d = await handle<{ logs: AuditEntry[] }>(await fetchApi(`/api/v2/audit?limit=${limit}`));
|
||||||
|
return d.logs;
|
||||||
|
},
|
||||||
|
auditExportUrl: "/api/v2/audit/export",
|
||||||
|
async users() {
|
||||||
|
const d = await handle<{ users?: UserRow[]; items?: UserRow[] }>(await fetchApi("/api/v2/users"));
|
||||||
|
return d.users ?? d.items ?? [];
|
||||||
|
},
|
||||||
|
async roles() {
|
||||||
|
const d = await handle<{ roles?: RoleRow[] }>(await fetchApi("/api/v2/roles"));
|
||||||
|
return d.roles ?? [];
|
||||||
|
},
|
||||||
|
async permissions() {
|
||||||
|
const d = await handle<{ items: { id: number; name: string; category?: string }[] }>(
|
||||||
|
await fetchApi("/api/v2/permissions"),
|
||||||
|
);
|
||||||
|
return d.items;
|
||||||
|
},
|
||||||
|
async customFields(entityType: string) {
|
||||||
|
const d = await handle<{ fields?: CustomFieldDef[] }>(await fetchApi(`/api/v2/custom_fields/${entityType}`));
|
||||||
|
return d.fields ?? [];
|
||||||
|
},
|
||||||
|
async bulkAssignIps(deviceId: number, ipIds: number[]) {
|
||||||
|
return handle(await fetchApi("/api/v2/bulk/assign-ips", {
|
||||||
|
method: "POST", headers: jsonHeaders, body: JSON.stringify({ device_id: deviceId, ip_ids: ipIds }),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
async bulkCreateDevices(names: string[]) {
|
||||||
|
return handle(await fetchApi("/api/v2/bulk/create-devices", {
|
||||||
|
method: "POST", headers: jsonHeaders, body: JSON.stringify({ names }),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
async bulkAssignTags(deviceIds: number[], tagId: number) {
|
||||||
|
return handle(await fetchApi("/api/v2/bulk/assign-tags", {
|
||||||
|
method: "POST", headers: jsonHeaders, body: JSON.stringify({ device_ids: deviceIds, tag_id: tagId }),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
async account() {
|
||||||
|
return handle(await fetchApi("/api/v2/account"));
|
||||||
|
},
|
||||||
|
async changePassword(current: string, newPw: string) {
|
||||||
|
return handle(await fetchApi("/api/v2/account/change-password", {
|
||||||
|
method: "POST", headers: jsonHeaders, body: JSON.stringify({ current_password: current, new_password: newPw }),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
async getDhcp(subnetId: number) {
|
||||||
|
return handle(await fetchApi(`/api/v2/subnets/${subnetId}/dhcp`));
|
||||||
|
},
|
||||||
|
async setDhcp(subnetId: number, body: unknown) {
|
||||||
|
return handle(await fetchApi(`/api/v2/subnets/${subnetId}/dhcp`, {
|
||||||
|
method: "POST", headers: jsonHeaders, body: JSON.stringify(body),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
|
||||||
|
import { RouterLink, RouterView, useRoute, useRouter } from "vue-router";
|
||||||
|
import { Menu, Search, X, Home, Server, Grid3x3, Settings, Users, Tag, Layers, FileText, User } from "lucide-vue-next";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { api } from "@/api";
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const sidebarOpen = ref(false);
|
||||||
|
const searchOpen = ref(false);
|
||||||
|
const searchQ = ref("");
|
||||||
|
const searchInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const searchResults = ref<Record<string, unknown[]>>({});
|
||||||
|
const searchLoading = ref(false);
|
||||||
|
|
||||||
|
const nav = computed(() =>
|
||||||
|
[
|
||||||
|
{ to: "/", label: "Home", icon: Home, perm: "view_index" },
|
||||||
|
{ to: "/devices", label: "Devices", icon: Server, perm: "view_devices" },
|
||||||
|
{ to: "/racks", label: "Racks", icon: Grid3x3, perm: "view_racks" },
|
||||||
|
{ to: "/tags", label: "Tags", icon: Tag, perm: "view_tags" },
|
||||||
|
{ to: "/audit", label: "Audit", icon: FileText, perm: "view_audit" },
|
||||||
|
{ to: "/subnets/manage", label: "Subnet Management", icon: Settings, perm: "view_admin" },
|
||||||
|
{ to: "/users", label: "Users", icon: Users, perm: "view_users" },
|
||||||
|
{ to: "/custom-fields", label: "Fields", icon: Layers, perm: "view_custom_fields" },
|
||||||
|
{ to: "/account", label: "Account", icon: User, perm: null },
|
||||||
|
].filter((n) => !n.perm || auth.can(n.perm)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasResults = computed(() =>
|
||||||
|
Object.values(searchResults.value).some((items) => items.length > 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await auth.logout();
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSearch() {
|
||||||
|
searchOpen.value = true;
|
||||||
|
searchQ.value = "";
|
||||||
|
searchResults.value = {};
|
||||||
|
nextTick(() => searchInput.value?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSearch() {
|
||||||
|
searchOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSearch() {
|
||||||
|
const q = searchQ.value.trim();
|
||||||
|
if (!q) {
|
||||||
|
searchResults.value = {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchLoading.value = true;
|
||||||
|
try {
|
||||||
|
searchResults.value = await api.search(q);
|
||||||
|
} finally {
|
||||||
|
searchLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "/" && !["INPUT", "TEXTAREA"].includes((e.target as HTMLElement)?.tagName)) {
|
||||||
|
e.preventDefault();
|
||||||
|
openSearch();
|
||||||
|
}
|
||||||
|
if (e.key === "Escape" && searchOpen.value) {
|
||||||
|
closeSearch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(searchQ, () => {
|
||||||
|
if (!searchOpen.value) return;
|
||||||
|
if (searchTimer) clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(runSearch, 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => window.addEventListener("keydown", onKeydown));
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener("keydown", onKeydown);
|
||||||
|
if (searchTimer) clearTimeout(searchTimer);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex min-h-screen bg-surface font-sans">
|
||||||
|
<!-- Mobile overlay -->
|
||||||
|
<div v-if="sidebarOpen" class="fixed inset-0 z-40 bg-black/50 lg:hidden" @click="sidebarOpen = false" />
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside
|
||||||
|
class="fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-slate-200 bg-surface-raised transition-transform dark:border-slate-800 lg:static lg:translate-x-0"
|
||||||
|
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 border-b border-slate-200 p-4 dark:border-slate-800">
|
||||||
|
<img v-if="auth.org.logo" :src="auth.org.logo" alt="" class="h-8 rounded" />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="truncate text-sm font-semibold">{{ auth.org.name }} IPAM</div>
|
||||||
|
<div class="text-xs text-slate-500">{{ auth.version }}</div>
|
||||||
|
</div>
|
||||||
|
<button class="lg:hidden" @click="sidebarOpen = false"><X class="h-5 w-5" /></button>
|
||||||
|
</div>
|
||||||
|
<nav class="flex-1 overflow-y-auto p-2">
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in nav"
|
||||||
|
:key="item.to"
|
||||||
|
:to="item.to"
|
||||||
|
class="mb-0.5 flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition"
|
||||||
|
:class="route.path === item.to || route.path.startsWith(item.to + '/')
|
||||||
|
? 'bg-accent/15 text-accent font-medium'
|
||||||
|
: 'text-slate-600 hover:bg-surface-overlay dark:text-slate-400'"
|
||||||
|
@click="sidebarOpen = false"
|
||||||
|
>
|
||||||
|
<component :is="item.icon" class="h-4 w-4 shrink-0" />
|
||||||
|
{{ item.label }}
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
<div class="border-t border-slate-200 p-3 dark:border-slate-800">
|
||||||
|
<div class="truncate text-xs text-slate-500">{{ auth.user?.name }}</div>
|
||||||
|
<button class="mt-2 text-xs text-accent hover:underline" @click="logout">Sign out</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main -->
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
|
<header class="flex items-center gap-3 border-b border-slate-200 bg-surface-raised px-4 py-3 dark:border-slate-800">
|
||||||
|
<button class="lg:hidden" @click="sidebarOpen = true"><Menu class="h-6 w-6" /></button>
|
||||||
|
<span class="font-semibold lg:hidden">{{ auth.org.name }} IPAM</span>
|
||||||
|
<button
|
||||||
|
class="ml-auto rounded-lg p-2 text-slate-600 transition hover:bg-surface-overlay hover:text-accent dark:text-slate-400"
|
||||||
|
title="Search (/)"
|
||||||
|
@click="openSearch"
|
||||||
|
>
|
||||||
|
<Search class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<main class="flex-1 overflow-auto p-4 md:p-6">
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search modal -->
|
||||||
|
<div v-if="searchOpen" class="fixed inset-0 z-50 flex items-start justify-center bg-black/40 p-4 pt-[10vh]" @click.self="closeSearch">
|
||||||
|
<div class="card flex max-h-[75vh] w-full max-w-xl flex-col">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Search class="h-5 w-5 shrink-0 text-slate-400" />
|
||||||
|
<input
|
||||||
|
ref="searchInput"
|
||||||
|
v-model="searchQ"
|
||||||
|
class="input-field flex-1 border-0 bg-transparent px-0 shadow-none focus:ring-0"
|
||||||
|
placeholder="Search subnets, IPs, devices…"
|
||||||
|
autofocus
|
||||||
|
@keydown.esc="closeSearch"
|
||||||
|
/>
|
||||||
|
<button class="rounded-lg p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200" @click="closeSearch">
|
||||||
|
<X class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">Press <kbd class="rounded bg-surface-overlay px-1">/</kbd> to open · <kbd class="rounded bg-surface-overlay px-1">Esc</kbd> to close</p>
|
||||||
|
|
||||||
|
<div v-if="searchLoading" class="mt-4 text-sm text-slate-500">Searching…</div>
|
||||||
|
<div v-else-if="searchQ.trim() && !hasResults" class="mt-4 text-sm text-slate-500">No results</div>
|
||||||
|
<div v-else-if="hasResults" class="mt-4 -mx-1 flex-1 space-y-4 overflow-y-auto px-1">
|
||||||
|
<section v-if="searchResults.devices?.length">
|
||||||
|
<h2 class="text-xs font-semibold uppercase tracking-wide text-slate-500">Devices</h2>
|
||||||
|
<ul class="mt-1">
|
||||||
|
<li v-for="d in searchResults.devices as { id: number; name: string }[]" :key="d.id">
|
||||||
|
<RouterLink :to="`/devices/${d.id}`" class="block rounded-lg px-2 py-1.5 text-sm hover:bg-surface-overlay" @click="closeSearch">{{ d.name }}</RouterLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section v-if="searchResults.subnets?.length">
|
||||||
|
<h2 class="text-xs font-semibold uppercase tracking-wide text-slate-500">Subnets</h2>
|
||||||
|
<ul class="mt-1">
|
||||||
|
<li v-for="s in searchResults.subnets as { id: number; name: string; cidr: string }[]" :key="s.id">
|
||||||
|
<RouterLink :to="`/subnets/${s.id}`" class="block rounded-lg px-2 py-1.5 text-sm hover:bg-surface-overlay" @click="closeSearch">{{ s.name }} <span class="font-mono text-slate-500">({{ s.cidr }})</span></RouterLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section v-if="searchResults.ips?.length">
|
||||||
|
<h2 class="text-xs font-semibold uppercase tracking-wide text-slate-500">IPs</h2>
|
||||||
|
<ul class="mt-1">
|
||||||
|
<li v-for="ip in searchResults.ips as { ip: string; subnet_id: number; hostname?: string }[]" :key="ip.ip">
|
||||||
|
<RouterLink :to="`/subnets/${ip.subnet_id}`" class="block rounded-lg px-2 py-1.5 font-mono text-sm hover:bg-surface-overlay" @click="closeSearch">
|
||||||
|
{{ ip.ip }}<span v-if="ip.hostname" class="ml-2 font-sans text-slate-500">{{ ip.hostname }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section v-if="searchResults.racks?.length">
|
||||||
|
<h2 class="text-xs font-semibold uppercase tracking-wide text-slate-500">Racks</h2>
|
||||||
|
<ul class="mt-1">
|
||||||
|
<li v-for="r in searchResults.racks as { id: number; name: string; site: string }[]" :key="r.id">
|
||||||
|
<RouterLink :to="`/racks/${r.id}`" class="block rounded-lg px-2 py-1.5 text-sm hover:bg-surface-overlay" @click="closeSearch">{{ r.name }} <span class="text-slate-500">· {{ r.site }}</span></RouterLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section v-if="searchResults.tags?.length">
|
||||||
|
<h2 class="text-xs font-semibold uppercase tracking-wide text-slate-500">Tags</h2>
|
||||||
|
<ul class="mt-1">
|
||||||
|
<li v-for="t in searchResults.tags as { id: number; name: string }[]" :key="t.id">
|
||||||
|
<RouterLink to="/tags" class="block rounded-lg px-2 py-1.5 text-sm hover:bg-surface-overlay" @click="closeSearch">{{ t.name }}</RouterLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
import { X } from "lucide-vue-next";
|
||||||
|
import { api } from "@/api";
|
||||||
|
import { formatLocalTime } from "@/utils/datetime";
|
||||||
|
|
||||||
|
export interface IpHistoryEntry {
|
||||||
|
ip: string;
|
||||||
|
action: "assigned" | "removed";
|
||||||
|
device_name: string;
|
||||||
|
subnet_name?: string;
|
||||||
|
subnet_cidr?: string;
|
||||||
|
user_name?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
ip: string | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
const history = ref<IpHistoryEntry[]>([]);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.ip,
|
||||||
|
async (ip) => {
|
||||||
|
if (!ip) {
|
||||||
|
history.value = [];
|
||||||
|
error.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
history.value = (await api.ipHistory(ip)) as IpHistoryEntry[];
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : "Failed to load history";
|
||||||
|
history.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function formatTime(ts?: string) {
|
||||||
|
return formatLocalTime(ts, "Unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") emit("close");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="ip"
|
||||||
|
class="fixed inset-0 z-50 flex items-end justify-center bg-black/50 p-4 sm:items-center"
|
||||||
|
@click.self="emit('close')"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
>
|
||||||
|
<div class="card max-h-[80vh] w-full max-w-lg overflow-hidden p-0 shadow-xl">
|
||||||
|
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3 dark:border-slate-700">
|
||||||
|
<h2 class="font-semibold">IP history · <span class="font-mono text-accent">{{ ip }}</span></h2>
|
||||||
|
<button type="button" class="rounded-lg p-1 hover:bg-surface-overlay" aria-label="Close" @click="emit('close')">
|
||||||
|
<X class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-[60vh] overflow-y-auto p-4">
|
||||||
|
<p v-if="loading" class="text-center text-sm text-slate-500">Loading…</p>
|
||||||
|
<p v-else-if="error" class="text-center text-sm text-red-500">{{ error }}</p>
|
||||||
|
<p v-else-if="history.length === 0" class="text-center text-sm text-slate-500">No assignment history for this address.</p>
|
||||||
|
<ul v-else class="space-y-3">
|
||||||
|
<li
|
||||||
|
v-for="(entry, i) in history"
|
||||||
|
:key="i"
|
||||||
|
class="flex gap-3 border-b border-slate-100 pb-3 last:border-0 dark:border-slate-800"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="mt-0.5 shrink-0 text-xs font-semibold uppercase"
|
||||||
|
:class="entry.action === 'assigned' ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-500'"
|
||||||
|
>
|
||||||
|
{{ entry.action === "assigned" ? "Assigned" : "Removed" }}
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0 flex-1 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="font-medium">{{ entry.device_name }}</span>
|
||||||
|
<span v-if="entry.subnet_name" class="text-slate-500">
|
||||||
|
· {{ entry.subnet_name }}<span v-if="entry.subnet_cidr"> ({{ entry.subnet_cidr }})</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-slate-500">
|
||||||
|
{{ entry.user_name || "Unknown" }} · {{ formatTime(entry.timestamp) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import { createPinia } from "pinia";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import router from "./router";
|
||||||
|
import "./style.css";
|
||||||
|
|
||||||
|
createApp(App).use(createPinia()).use(router).mount("#app");
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: "/login", name: "login", component: () => import("@/views/LoginView.vue"), meta: { public: true } },
|
||||||
|
{ path: "/verify-2fa", name: "verify-2fa", component: () => import("@/views/Verify2faView.vue"), meta: { public: true } },
|
||||||
|
{ path: "/setup-2fa", name: "setup-2fa", component: () => import("@/views/Setup2faView.vue"), meta: { public: true } },
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
component: () => import("@/components/AppLayout.vue"),
|
||||||
|
children: [
|
||||||
|
{ path: "", name: "dashboard", component: () => import("@/views/DashboardView.vue") },
|
||||||
|
{ path: "devices", name: "devices", component: () => import("@/views/DevicesView.vue") },
|
||||||
|
{ path: "devices/:id", name: "device", component: () => import("@/views/DeviceDetailView.vue") },
|
||||||
|
{ path: "subnets/:id", name: "subnet", component: () => import("@/views/SubnetDetailView.vue") },
|
||||||
|
{ path: "subnets/:id/dhcp", name: "dhcp", component: () => import("@/views/DhcpView.vue") },
|
||||||
|
{ path: "racks", name: "racks", component: () => import("@/views/RacksView.vue") },
|
||||||
|
{ path: "racks/:id", name: "rack", component: () => import("@/views/RackDetailView.vue") },
|
||||||
|
{ path: "search", redirect: "/" },
|
||||||
|
{ path: "tags", name: "tags", component: () => import("@/views/TagsView.vue") },
|
||||||
|
{ path: "device-types", redirect: "/devices" },
|
||||||
|
{ path: "custom-fields", name: "custom-fields", component: () => import("@/views/CustomFieldsView.vue") },
|
||||||
|
{ path: "bulk", redirect: "/devices" },
|
||||||
|
{ path: "audit", name: "audit", component: () => import("@/views/AuditView.vue") },
|
||||||
|
{ path: "subnets/manage", name: "subnet-management", component: () => import("@/views/SubnetsView.vue") },
|
||||||
|
{ path: "admin", redirect: "/subnets/manage" },
|
||||||
|
{ path: "users", name: "users", component: () => import("@/views/UsersView.vue") },
|
||||||
|
{ path: "account", name: "account", component: () => import("@/views/AccountView.vue") },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
router.beforeEach(async (to) => {
|
||||||
|
const auth = useAuthStore();
|
||||||
|
if (!auth.loaded) await auth.fetchMe().catch(() => {});
|
||||||
|
if (to.meta.public) return true;
|
||||||
|
if (!auth.loggedIn) return { name: "login", query: { redirect: to.fullPath } };
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { api, type MeResponse } from "@/api";
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore("auth", {
|
||||||
|
state: () => ({
|
||||||
|
loaded: false,
|
||||||
|
loggedIn: false,
|
||||||
|
user: null as MeResponse["user"] | null,
|
||||||
|
permissions: [] as string[],
|
||||||
|
org: { name: "IPAM", logo: "" },
|
||||||
|
version: "unknown",
|
||||||
|
}),
|
||||||
|
getters: {
|
||||||
|
can: (state) => (perm: string) => state.permissions.includes(perm),
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
async fetchMe() {
|
||||||
|
const data = await api.me();
|
||||||
|
this.loaded = true;
|
||||||
|
this.loggedIn = data.logged_in;
|
||||||
|
this.user = data.user ?? null;
|
||||||
|
this.permissions = data.permissions ?? [];
|
||||||
|
this.org = data.org ?? this.org;
|
||||||
|
this.version = data.app_version ?? "unknown";
|
||||||
|
},
|
||||||
|
async login(email: string, password: string) {
|
||||||
|
return api.login(email, password);
|
||||||
|
},
|
||||||
|
async logout() {
|
||||||
|
await api.logout();
|
||||||
|
this.loggedIn = false;
|
||||||
|
this.user = null;
|
||||||
|
this.permissions = [];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--surface: 248 250 252;
|
||||||
|
--surface-raised: 255 255 255;
|
||||||
|
--surface-overlay: 241 245 249;
|
||||||
|
--accent: 6 182 212;
|
||||||
|
--accent-muted: 8 145 178;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--surface: 15 20 25;
|
||||||
|
--surface-raised: 21 28 36;
|
||||||
|
--surface-overlay: 26 35 46;
|
||||||
|
--accent: 34 211 238;
|
||||||
|
--accent-muted: 6 182 212;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn-primary {
|
||||||
|
@apply rounded-lg bg-accent px-4 py-2 text-sm font-medium text-slate-950 transition hover:opacity-90 disabled:opacity-50;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
@apply rounded-lg border border-slate-300 bg-surface-raised px-4 py-2 text-sm font-medium transition hover:bg-surface-overlay dark:border-slate-700;
|
||||||
|
}
|
||||||
|
.input-field {
|
||||||
|
@apply w-full rounded-lg border border-slate-300 bg-surface-overlay px-3 py-2 text-sm outline-none ring-accent focus:border-accent focus:ring-1 dark:border-slate-700;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
@apply rounded-xl border border-slate-200 bg-surface-raised p-4 shadow-sm dark:border-slate-800;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/** Parse API timestamps (GMT strings, ISO, or naive UTC) for local display. */
|
||||||
|
export function parseApiTimestamp(ts?: string | null): Date | null {
|
||||||
|
if (!ts) return null;
|
||||||
|
const trimmed = ts.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
// RFC/GMT strings from Flask/MySQL — parse as-is
|
||||||
|
if (/GMT|Z|[+-]\d{2}:\d{2}$/.test(trimmed)) {
|
||||||
|
const d = new Date(trimmed);
|
||||||
|
if (!Number.isNaN(d.getTime())) return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Naive datetime — treat as UTC
|
||||||
|
const normalized = trimmed.includes("T") ? trimmed : trimmed.replace(" ", "T");
|
||||||
|
const d = new Date(normalized.endsWith("Z") ? normalized : `${normalized}Z`);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLocalTime(ts?: string | null, fallback = "—"): string {
|
||||||
|
const d = parseApiTimestamp(ts);
|
||||||
|
if (!d) return ts?.trim() || fallback;
|
||||||
|
return d.toLocaleString();
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { RouterLink } from "vue-router";
|
||||||
|
import { api } from "@/api";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const profile = ref<{
|
||||||
|
totp_enabled?: boolean;
|
||||||
|
role_requires_2fa?: boolean;
|
||||||
|
backup_codes?: string[];
|
||||||
|
} | null>(null);
|
||||||
|
const pw = ref({ current: "", newPw: "" });
|
||||||
|
const mfaPw = ref("");
|
||||||
|
const msg = ref("");
|
||||||
|
const err = ref("");
|
||||||
|
const newBackupCodes = ref<string[]>([]);
|
||||||
|
|
||||||
|
onMounted(async () => { profile.value = await api.account() as typeof profile.value; });
|
||||||
|
|
||||||
|
async function changePw() {
|
||||||
|
err.value = "";
|
||||||
|
try {
|
||||||
|
await api.changePassword(pw.value.current, pw.value.newPw);
|
||||||
|
msg.value = "Password updated";
|
||||||
|
pw.value = { current: "", newPw: "" };
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "Failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disable2fa() {
|
||||||
|
if (!mfaPw.value || !confirm("Disable two-factor authentication?")) return;
|
||||||
|
err.value = "";
|
||||||
|
try {
|
||||||
|
await api.disable2fa(mfaPw.value);
|
||||||
|
mfaPw.value = "";
|
||||||
|
profile.value = await api.account() as typeof profile.value;
|
||||||
|
msg.value = "2FA disabled";
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "Failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regenCodes() {
|
||||||
|
if (!mfaPw.value || !confirm("Regenerate backup codes? Old codes will stop working.")) return;
|
||||||
|
err.value = "";
|
||||||
|
try {
|
||||||
|
const r = await api.regenerateBackupCodes(mfaPw.value);
|
||||||
|
newBackupCodes.value = r.backup_codes;
|
||||||
|
mfaPw.value = "";
|
||||||
|
profile.value = await api.account() as typeof profile.value;
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "Failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">Account</h1>
|
||||||
|
<div class="card mt-6 max-w-md space-y-2">
|
||||||
|
<p><strong>{{ auth.user?.name }}</strong></p>
|
||||||
|
<p class="text-slate-500">{{ auth.user?.email }}</p>
|
||||||
|
<p class="text-sm">2FA: {{ profile?.totp_enabled ? "Enabled" : "Disabled" }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-6 max-w-md space-y-4">
|
||||||
|
<h2 class="font-semibold">Two-factor authentication</h2>
|
||||||
|
<template v-if="profile?.totp_enabled">
|
||||||
|
<div v-if="profile.backup_codes?.length">
|
||||||
|
<p class="text-sm text-slate-500">Backup codes:</p>
|
||||||
|
<ul class="mt-2 rounded-lg bg-surface-overlay p-3 font-mono text-sm">
|
||||||
|
<li v-for="c in profile.backup_codes" :key="c">{{ c }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div v-if="newBackupCodes.length">
|
||||||
|
<p class="text-sm font-medium text-accent">New backup codes — save these now:</p>
|
||||||
|
<ul class="mt-2 rounded-lg bg-surface-overlay p-3 font-mono text-sm">
|
||||||
|
<li v-for="c in newBackupCodes" :key="c">{{ c }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<input v-model="mfaPw" type="password" class="input-field" placeholder="Password to confirm" />
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button class="btn-secondary text-sm" @click="regenCodes">Regenerate backup codes</button>
|
||||||
|
<button
|
||||||
|
v-if="!profile.role_requires_2fa"
|
||||||
|
class="text-sm text-red-500 hover:underline"
|
||||||
|
@click="disable2fa"
|
||||||
|
>Disable 2FA</button>
|
||||||
|
<p v-else class="text-sm text-slate-500">Your role requires 2FA — it cannot be disabled.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p class="text-sm text-slate-500">Protect your account with an authenticator app.</p>
|
||||||
|
<RouterLink to="/setup-2fa" class="btn-primary inline-block text-sm">Enable 2FA</RouterLink>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="card mt-6 max-w-md space-y-3" @submit.prevent="changePw">
|
||||||
|
<h2 class="font-semibold">Change password</h2>
|
||||||
|
<input v-model="pw.current" type="password" class="input-field" placeholder="Current password" />
|
||||||
|
<input v-model="pw.newPw" type="password" class="input-field" placeholder="New password" />
|
||||||
|
<button class="btn-primary">Update</button>
|
||||||
|
<p v-if="msg" class="text-sm text-accent">{{ msg }}</p>
|
||||||
|
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { api, type AuditEntry } from "@/api";
|
||||||
|
import { formatLocalTime } from "@/utils/datetime";
|
||||||
|
|
||||||
|
const logs = ref<AuditEntry[]>([]);
|
||||||
|
|
||||||
|
onMounted(async () => { logs.value = await api.audit(200); });
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold">Audit log</h1>
|
||||||
|
<a href="/api/v2/audit/export" class="btn-secondary text-sm">Export CSV</a>
|
||||||
|
</div>
|
||||||
|
<div class="card mt-6 overflow-x-auto">
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead><tr class="border-b dark:border-slate-700"><th class="p-2">Time</th><th class="p-2">User</th><th class="p-2">Action</th><th class="p-2">Details</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="l in logs" :key="l.id" class="border-b dark:border-slate-800">
|
||||||
|
<td class="p-2 whitespace-nowrap text-xs text-slate-500">{{ formatLocalTime(l.timestamp) }}</td>
|
||||||
|
<td class="p-2">{{ l.user_name }}</td>
|
||||||
|
<td class="p-2 font-mono text-xs">{{ l.action }}</td>
|
||||||
|
<td class="p-2 max-w-md truncate">{{ l.details }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { api, type CustomFieldDef } from "@/api";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const tab = ref<"device" | "subnet">("device");
|
||||||
|
const fields = ref<CustomFieldDef[]>([]);
|
||||||
|
const form = ref({ name: "", field_key: "", field_type: "text", required: false, default_value: "", help_text: "" });
|
||||||
|
const editForm = ref({ id: 0, name: "", field_key: "", field_type: "text", required: false, default_value: "", help_text: "" });
|
||||||
|
const showAdd = ref(false);
|
||||||
|
const showEdit = ref(false);
|
||||||
|
const err = ref("");
|
||||||
|
|
||||||
|
const fieldTypes = ["text", "textarea", "number", "select", "checkbox", "date"];
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
fields.value = await api.customFields(tab.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
err.value = "";
|
||||||
|
try {
|
||||||
|
await api.createCustomField({ ...form.value, entity_type: tab.value });
|
||||||
|
showAdd.value = false;
|
||||||
|
form.value = { name: "", field_key: "", field_type: "text", required: false, default_value: "", help_text: "" };
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "Failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(f: CustomFieldDef) {
|
||||||
|
editForm.value = {
|
||||||
|
id: f.id,
|
||||||
|
name: f.name,
|
||||||
|
field_key: f.field_key,
|
||||||
|
field_type: f.field_type,
|
||||||
|
required: !!f.required,
|
||||||
|
default_value: "",
|
||||||
|
help_text: "",
|
||||||
|
};
|
||||||
|
showEdit.value = true;
|
||||||
|
err.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
err.value = "";
|
||||||
|
try {
|
||||||
|
await api.updateCustomField(editForm.value.id, {
|
||||||
|
name: editForm.value.name,
|
||||||
|
field_type: editForm.value.field_type,
|
||||||
|
required: editForm.value.required,
|
||||||
|
default_value: editForm.value.default_value || null,
|
||||||
|
help_text: editForm.value.help_text || null,
|
||||||
|
});
|
||||||
|
showEdit.value = false;
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "Failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function del(id: number) {
|
||||||
|
if (!confirm("Delete this custom field?")) return;
|
||||||
|
await api.deleteCustomField(id);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveField(index: number, dir: -1 | 1) {
|
||||||
|
const target = index + dir;
|
||||||
|
if (target < 0 || target >= fields.value.length) return;
|
||||||
|
const reordered = [...fields.value];
|
||||||
|
const [item] = reordered.splice(index, 1);
|
||||||
|
reordered.splice(target, 0, item);
|
||||||
|
const orders: Record<number, number> = {};
|
||||||
|
reordered.forEach((f, i) => { orders[f.id] = i; });
|
||||||
|
await api.reorderCustomFields(tab.value, orders);
|
||||||
|
fields.value = reordered;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">Custom fields</h1>
|
||||||
|
<div class="mt-4 flex flex-wrap items-center gap-2">
|
||||||
|
<button class="rounded-lg px-3 py-1 text-sm" :class="tab === 'device' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="tab = 'device'; load()">Device</button>
|
||||||
|
<button class="rounded-lg px-3 py-1 text-sm" :class="tab === 'subnet' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="tab = 'subnet'; load()">Subnet</button>
|
||||||
|
<button v-if="auth.can('manage_custom_fields')" class="btn-primary ml-auto text-sm" @click="showAdd = true; err = ''">Add field</button>
|
||||||
|
</div>
|
||||||
|
<ul class="mt-6 space-y-2">
|
||||||
|
<li v-for="(f, i) in fields" :key="f.id" class="card flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span>{{ f.name }} <span class="text-slate-500">({{ f.field_type }})</span></span>
|
||||||
|
<span class="font-mono text-xs text-slate-500">{{ f.field_key }}</span>
|
||||||
|
<div v-if="auth.can('manage_custom_fields')" class="flex gap-2">
|
||||||
|
<button class="text-sm text-slate-500 hover:underline" :disabled="i === 0" @click="moveField(i, -1)">↑</button>
|
||||||
|
<button class="text-sm text-slate-500 hover:underline" :disabled="i === fields.length - 1" @click="moveField(i, 1)">↓</button>
|
||||||
|
<button class="text-sm text-accent hover:underline" @click="openEdit(f)">Edit</button>
|
||||||
|
<button class="text-sm text-red-500 hover:underline" @click="del(f.id)">Delete</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div v-if="showAdd" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showAdd = false">
|
||||||
|
<form class="card w-full max-w-md space-y-3" @submit.prevent="create">
|
||||||
|
<h2 class="text-lg font-semibold">Add custom field</h2>
|
||||||
|
<input v-model="form.name" class="input-field" placeholder="Display name" required />
|
||||||
|
<input v-model="form.field_key" class="input-field font-mono text-sm" placeholder="field_key" required />
|
||||||
|
<select v-model="form.field_type" class="input-field">
|
||||||
|
<option v-for="t in fieldTypes" :key="t" :value="t">{{ t }}</option>
|
||||||
|
</select>
|
||||||
|
<label class="flex items-center gap-2 text-sm"><input v-model="form.required" type="checkbox" /> Required</label>
|
||||||
|
<input v-model="form.default_value" class="input-field" placeholder="Default value" />
|
||||||
|
<input v-model="form.help_text" class="input-field" placeholder="Help text" />
|
||||||
|
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit" class="btn-primary">Create</button>
|
||||||
|
<button type="button" class="btn-secondary" @click="showAdd = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showEdit" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showEdit = false">
|
||||||
|
<form class="card w-full max-w-md space-y-3" @submit.prevent="saveEdit">
|
||||||
|
<h2 class="text-lg font-semibold">Edit custom field</h2>
|
||||||
|
<input v-model="editForm.name" class="input-field" required />
|
||||||
|
<input v-model="editForm.field_key" class="input-field font-mono text-sm" disabled />
|
||||||
|
<select v-model="editForm.field_type" class="input-field">
|
||||||
|
<option v-for="t in fieldTypes" :key="t" :value="t">{{ t }}</option>
|
||||||
|
</select>
|
||||||
|
<label class="flex items-center gap-2 text-sm"><input v-model="editForm.required" type="checkbox" /> Required</label>
|
||||||
|
<input v-model="editForm.default_value" class="input-field" placeholder="Default value" />
|
||||||
|
<input v-model="editForm.help_text" class="input-field" placeholder="Help text" />
|
||||||
|
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit" class="btn-primary">Save</button>
|
||||||
|
<button type="button" class="btn-secondary" @click="showEdit = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { RouterLink } from "vue-router";
|
||||||
|
import { api, type Subnet } from "@/api";
|
||||||
|
|
||||||
|
const sites = ref<Record<string, Subnet[]>>({});
|
||||||
|
const loading = ref(true);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const d = await api.dashboard();
|
||||||
|
sites.value = d.sites;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">Dashboard</h1>
|
||||||
|
<p class="mt-1 text-slate-500">Subnets grouped by site</p>
|
||||||
|
<div v-if="loading" class="mt-8 text-slate-500">Loading…</div>
|
||||||
|
<div v-else class="mt-6 space-y-8">
|
||||||
|
<section v-for="(subnets, site) in sites" :key="site">
|
||||||
|
<h2 class="mb-3 text-lg font-semibold text-accent">{{ site }}</h2>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<RouterLink
|
||||||
|
v-for="s in subnets"
|
||||||
|
:key="s.id"
|
||||||
|
:to="`/subnets/${s.id}`"
|
||||||
|
class="card block transition hover:border-accent/50"
|
||||||
|
>
|
||||||
|
<div class="font-medium">{{ s.name }}</div>
|
||||||
|
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
<span class="font-mono text-sm text-slate-500">{{ s.cidr }}</span>
|
||||||
|
<span
|
||||||
|
v-if="s.vlan_id"
|
||||||
|
class="rounded-full bg-surface-overlay px-2 py-0.5 text-xs font-semibold text-slate-600 dark:text-slate-300"
|
||||||
|
>VLAN {{ s.vlan_id }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="h-2 overflow-hidden rounded-full bg-surface-overlay">
|
||||||
|
<div class="h-full rounded-full bg-accent transition-all" :style="{ width: `${s.utilization ?? 0}%` }" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-xs text-slate-500">{{ s.utilization ?? 0 }}% used</div>
|
||||||
|
</div>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from "vue";
|
||||||
|
import { useRoute, useRouter, RouterLink } from "vue-router";
|
||||||
|
import { api, type Device, type Tag, type Subnet } from "@/api";
|
||||||
|
import type { IpHistoryEntry } from "@/components/IpHistoryModal.vue";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { formatLocalTime } from "@/utils/datetime";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const device = ref<Device | null>(null);
|
||||||
|
const allTags = ref<Tag[]>([]);
|
||||||
|
const subnets = ref<Subnet[]>([]);
|
||||||
|
const availableIps = ref<{ id: number; ip: string }[]>([]);
|
||||||
|
const history = ref<IpHistoryEntry[]>([]);
|
||||||
|
const editName = ref("");
|
||||||
|
const saving = ref(false);
|
||||||
|
const showAssignIp = ref(false);
|
||||||
|
const assignForm = ref({ site: "", subnet_id: 0, ip_id: 0 });
|
||||||
|
const err = ref("");
|
||||||
|
|
||||||
|
const sites = computed(() => {
|
||||||
|
const list = [...new Set(subnets.value.map((s) => s.site || "Unassigned"))];
|
||||||
|
return list.sort((a, b) => {
|
||||||
|
if (a === "Unassigned") return -1;
|
||||||
|
if (b === "Unassigned") return 1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const deviceSites = computed(() =>
|
||||||
|
[...new Set((device.value?.ip_addresses ?? []).map((ip) => ip.site || "Unassigned"))],
|
||||||
|
);
|
||||||
|
|
||||||
|
const assignableSites = computed(() =>
|
||||||
|
deviceSites.value.length ? sites.value.filter((s) => deviceSites.value.includes(s)) : sites.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
const subnetsForSite = computed(() =>
|
||||||
|
subnets.value.filter((s) => (s.site || "Unassigned") === assignForm.value.site),
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const id = Number(route.params.id);
|
||||||
|
const [d, tags, h, sn] = await Promise.all([
|
||||||
|
api.device(id),
|
||||||
|
api.tags(),
|
||||||
|
api.deviceIpHistory(id).catch(() => []),
|
||||||
|
api.subnets(false),
|
||||||
|
]);
|
||||||
|
device.value = d;
|
||||||
|
editName.value = d.name;
|
||||||
|
allTags.value = tags;
|
||||||
|
subnets.value = sn;
|
||||||
|
history.value = h as IpHistoryEntry[];
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadAvailableIps(subnetId: number) {
|
||||||
|
if (!subnetId) {
|
||||||
|
availableIps.value = [];
|
||||||
|
assignForm.value.ip_id = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
availableIps.value = await api.availableIps(subnetId);
|
||||||
|
assignForm.value.ip_id = availableIps.value[0]?.id ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSiteChange() {
|
||||||
|
const list = subnetsForSite.value;
|
||||||
|
assignForm.value.subnet_id = list[0]?.id ?? 0;
|
||||||
|
await loadAvailableIps(assignForm.value.subnet_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubnetChange() {
|
||||||
|
await loadAvailableIps(assignForm.value.subnet_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAssignIpModal() {
|
||||||
|
err.value = "";
|
||||||
|
const defaultSite = assignableSites.value[0] ?? sites.value[0] ?? "";
|
||||||
|
const defaultSubnet = subnets.value.find((s) => (s.site || "Unassigned") === defaultSite) ?? subnets.value[0];
|
||||||
|
assignForm.value = {
|
||||||
|
site: defaultSite,
|
||||||
|
subnet_id: defaultSubnet?.id ?? 0,
|
||||||
|
ip_id: 0,
|
||||||
|
};
|
||||||
|
if (assignForm.value.subnet_id) await loadAvailableIps(assignForm.value.subnet_id);
|
||||||
|
showAssignIp.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveName() {
|
||||||
|
if (!device.value) return;
|
||||||
|
saving.value = true;
|
||||||
|
await api.updateDevice(device.value.id, { name: editName.value });
|
||||||
|
device.value.name = editName.value;
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assignTag(tagId: number) {
|
||||||
|
if (!device.value || !tagId) return;
|
||||||
|
await api.assignTag(device.value.id, tagId);
|
||||||
|
device.value = await api.device(device.value.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTag(tagId: number) {
|
||||||
|
if (!device.value || !confirm("Remove this tag?")) return;
|
||||||
|
await api.removeTag(device.value.id, tagId);
|
||||||
|
device.value = await api.device(device.value.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assignIp() {
|
||||||
|
if (!device.value || !assignForm.value.ip_id) return;
|
||||||
|
err.value = "";
|
||||||
|
try {
|
||||||
|
await api.assignIp(device.value.id, assignForm.value.ip_id);
|
||||||
|
showAssignIp.value = false;
|
||||||
|
device.value = await api.device(device.value.id);
|
||||||
|
history.value = (await api.deviceIpHistory(device.value.id)) as IpHistoryEntry[];
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "Failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeIp(ipId: number) {
|
||||||
|
if (!device.value || !confirm("Remove this IP from the device?")) return;
|
||||||
|
await api.removeIp(device.value.id, ipId);
|
||||||
|
device.value = await api.device(device.value.id);
|
||||||
|
history.value = (await api.deviceIpHistory(device.value.id)) as IpHistoryEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDevice() {
|
||||||
|
if (!device.value || !confirm(`Delete device "${device.value.name}"? This cannot be undone.`)) return;
|
||||||
|
await api.deleteDevice(device.value.id);
|
||||||
|
router.push("/devices");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts?: string) {
|
||||||
|
return formatLocalTime(ts, "Unknown");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div v-if="device">
|
||||||
|
<RouterLink to="/devices" class="text-sm text-accent hover:underline">← Devices</RouterLink>
|
||||||
|
<div class="mt-4 flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<input v-if="auth.can('edit_device')" v-model="editName" class="input-field max-w-md text-xl font-bold" @blur="saveName" />
|
||||||
|
<h1 v-else class="text-2xl font-bold">{{ device.name }}</h1>
|
||||||
|
<p class="mt-1 text-slate-500">{{ device.description || "No description" }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="auth.can('delete_device')"
|
||||||
|
class="text-sm text-red-500 hover:underline"
|
||||||
|
@click="deleteDevice"
|
||||||
|
>Delete device</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 grid gap-4 lg:grid-cols-2">
|
||||||
|
<div class="card">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="font-semibold">IP addresses</h2>
|
||||||
|
<button v-if="auth.can('add_device_ip')" class="text-sm text-accent hover:underline" @click="openAssignIpModal">Assign IP</button>
|
||||||
|
</div>
|
||||||
|
<ul class="mt-3 space-y-2">
|
||||||
|
<li v-for="ip in device.ip_addresses" :key="ip.id" class="flex items-center justify-between font-mono text-sm">
|
||||||
|
<span>{{ ip.ip }} <span class="text-slate-500">({{ ip.subnet_name }})</span></span>
|
||||||
|
<button v-if="auth.can('remove_device_ip')" class="text-red-500 hover:underline" @click="removeIp(ip.id)">Remove</button>
|
||||||
|
</li>
|
||||||
|
<li v-if="!device.ip_addresses?.length" class="text-sm text-slate-500">None assigned</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="font-semibold">Tags</h2>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<span v-for="t in device.tags" :key="t.id" class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs" :style="{ backgroundColor: (t.color || '#6B7280') + '33' }">
|
||||||
|
{{ t.name }}
|
||||||
|
<button v-if="auth.can('assign_device_tag')" class="text-red-500 hover:underline" @click="removeTag(t.id)">×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<select v-if="auth.can('assign_device_tag')" class="input-field mt-3" @change="assignTag(Number(($event.target as HTMLSelectElement).value)); ($event.target as HTMLSelectElement).value = ''">
|
||||||
|
<option value="">Add tag…</option>
|
||||||
|
<option v-for="t in allTags.filter((t) => !device!.tags?.some((dt) => dt.id === t.id))" :key="t.id" :value="t.id">{{ t.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="card lg:col-span-2">
|
||||||
|
<h2 class="font-semibold">IP history</h2>
|
||||||
|
<p v-if="!history.length" class="mt-2 text-sm text-slate-500">No assignment history.</p>
|
||||||
|
<ul v-else class="mt-3 space-y-3">
|
||||||
|
<li v-for="(entry, i) in history" :key="i" class="flex gap-3 text-sm">
|
||||||
|
<span class="shrink-0 font-semibold uppercase text-xs" :class="entry.action === 'assigned' ? 'text-emerald-600' : 'text-red-500'">
|
||||||
|
{{ entry.action === "assigned" ? "Assigned" : "Removed" }}
|
||||||
|
</span>
|
||||||
|
<span class="font-mono">{{ entry.ip }}</span>
|
||||||
|
<span class="text-slate-500">· {{ entry.user_name }} · {{ formatTime(entry.timestamp) }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showAssignIp" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showAssignIp = false">
|
||||||
|
<form class="card w-full max-w-md space-y-3" @submit.prevent="assignIp">
|
||||||
|
<h2 class="text-lg font-semibold">Assign IP</h2>
|
||||||
|
<select v-if="!deviceSites.length" v-model="assignForm.site" class="input-field" @change="onSiteChange">
|
||||||
|
<option v-for="site in assignableSites" :key="site" :value="site">{{ site }}</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="assignForm.subnet_id" class="input-field" @change="onSubnetChange">
|
||||||
|
<option v-for="s in subnetsForSite" :key="s.id" :value="s.id">{{ s.name }} ({{ s.cidr }})</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="assignForm.ip_id" class="input-field" required>
|
||||||
|
<option v-for="ip in availableIps" :key="ip.id" :value="ip.id">{{ ip.ip }}</option>
|
||||||
|
</select>
|
||||||
|
<p v-if="assignForm.subnet_id && !availableIps.length" class="text-sm text-slate-500">No available IPs in this subnet</p>
|
||||||
|
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit" class="btn-primary" :disabled="!assignForm.ip_id">Assign</button>
|
||||||
|
<button type="button" class="btn-secondary" @click="showAssignIp = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from "vue";
|
||||||
|
import { RouterLink } from "vue-router";
|
||||||
|
import { api, type Device, type Subnet } from "@/api";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const devices = ref<Device[]>([]);
|
||||||
|
const tagFilter = ref("");
|
||||||
|
const tags = ref<string[]>([]);
|
||||||
|
const subnets = ref<Subnet[]>([]);
|
||||||
|
const availableIps = ref<{ id: number; ip: string }[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
|
||||||
|
const showAdd = ref(false);
|
||||||
|
const showBulk = ref(false);
|
||||||
|
const assignIpOnCreate = ref(false);
|
||||||
|
const addForm = ref({ name: "", description: "", site: "", subnet_id: 0, ip_id: 0 });
|
||||||
|
const bulkForm = ref({ names: "" });
|
||||||
|
const err = ref("");
|
||||||
|
|
||||||
|
const sites = computed(() =>
|
||||||
|
[...new Set(subnets.value.map((s) => s.site || "Unassigned"))].sort(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const subnetsForSite = computed(() =>
|
||||||
|
subnets.value.filter((s) => (s.site || "Unassigned") === addForm.value.site),
|
||||||
|
);
|
||||||
|
|
||||||
|
const bySite = computed(() => {
|
||||||
|
const m: Record<string, Device[]> = {};
|
||||||
|
for (const d of devices.value) {
|
||||||
|
const site = d.ip_addresses?.[0]?.site || "Unassigned";
|
||||||
|
if (!m[site]) m[site] = [];
|
||||||
|
m[site].push(d);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
|
||||||
|
const siteOrder = computed(() =>
|
||||||
|
Object.keys(bySite.value).sort((a, b) => {
|
||||||
|
if (a === "Unassigned") return -1;
|
||||||
|
if (b === "Unassigned") return 1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function loadDevices() {
|
||||||
|
loading.value = true;
|
||||||
|
devices.value = await api.devices({ tag: tagFilter.value || undefined });
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const [tagList, sn] = await Promise.all([api.tags(), api.subnets(false)]);
|
||||||
|
tags.value = tagList.map((t) => t.name);
|
||||||
|
subnets.value = sn;
|
||||||
|
if (sn.length) {
|
||||||
|
addForm.value.site = sn[0].site || "Unassigned";
|
||||||
|
addForm.value.subnet_id = sn[0].id;
|
||||||
|
}
|
||||||
|
await loadDevices();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadAvailableIps(subnetId: number) {
|
||||||
|
if (!subnetId) {
|
||||||
|
availableIps.value = [];
|
||||||
|
addForm.value.ip_id = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
availableIps.value = await api.availableIps(subnetId);
|
||||||
|
addForm.value.ip_id = availableIps.value[0]?.id ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onAddSiteChange() {
|
||||||
|
const list = subnetsForSite.value;
|
||||||
|
addForm.value.subnet_id = list[0]?.id ?? 0;
|
||||||
|
await loadAvailableIps(addForm.value.subnet_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onAddSubnetChange() {
|
||||||
|
await loadAvailableIps(addForm.value.subnet_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onAssignIpToggle() {
|
||||||
|
if (assignIpOnCreate.value) {
|
||||||
|
if (!addForm.value.site) addForm.value.site = sites.value[0] ?? "";
|
||||||
|
await onAddSiteChange();
|
||||||
|
} else {
|
||||||
|
availableIps.value = [];
|
||||||
|
addForm.value.ip_id = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAddModal() {
|
||||||
|
err.value = "";
|
||||||
|
assignIpOnCreate.value = false;
|
||||||
|
availableIps.value = [];
|
||||||
|
const defaultSite = sites.value[0] ?? "";
|
||||||
|
const defaultSubnet = subnets.value.find((s) => (s.site || "Unassigned") === defaultSite) ?? subnets.value[0];
|
||||||
|
addForm.value = {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
site: defaultSite,
|
||||||
|
subnet_id: defaultSubnet?.id ?? 0,
|
||||||
|
ip_id: 0,
|
||||||
|
};
|
||||||
|
showAdd.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filterTag(t: string) {
|
||||||
|
tagFilter.value = t;
|
||||||
|
await loadDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDevice() {
|
||||||
|
err.value = "";
|
||||||
|
try {
|
||||||
|
const created = await api.createDevice({
|
||||||
|
name: addForm.value.name,
|
||||||
|
description: addForm.value.description,
|
||||||
|
}) as { id: number };
|
||||||
|
if (assignIpOnCreate.value) {
|
||||||
|
if (!addForm.value.ip_id) {
|
||||||
|
err.value = "Select an IP address or uncheck “Assign an IP address”";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (auth.can("add_device_ip")) {
|
||||||
|
await api.assignIp(created.id, addForm.value.ip_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showAdd.value = false;
|
||||||
|
assignIpOnCreate.value = false;
|
||||||
|
availableIps.value = [];
|
||||||
|
addForm.value = {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
site: sites.value[0] ?? "",
|
||||||
|
subnet_id: subnets.value[0]?.id ?? 0,
|
||||||
|
ip_id: 0,
|
||||||
|
};
|
||||||
|
await loadDevices();
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "Failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkCreate() {
|
||||||
|
err.value = "";
|
||||||
|
const names = bulkForm.value.names.split("\n").map((n) => n.trim()).filter(Boolean);
|
||||||
|
if (!names.length) {
|
||||||
|
err.value = "Enter at least one device name";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.bulkCreateDevices(names);
|
||||||
|
showBulk.value = false;
|
||||||
|
bulkForm.value.names = "";
|
||||||
|
await loadDevices();
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "Failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h1 class="text-2xl font-bold">Devices</h1>
|
||||||
|
<div v-if="auth.can('add_device')" class="flex gap-2">
|
||||||
|
<button class="btn-primary text-sm" @click="openAddModal">Add device</button>
|
||||||
|
<button class="btn-secondary text-sm" @click="showBulk = true; err = ''">Bulk add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
|
<button class="rounded-full px-3 py-1 text-xs" :class="!tagFilter ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="filterTag('')">All</button>
|
||||||
|
<button v-for="t in tags" :key="t" class="rounded-full px-3 py-1 text-xs" :class="tagFilter === t ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="filterTag(t)">{{ t }}</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="mt-8 text-slate-500">Loading…</div>
|
||||||
|
<div v-else class="mt-6 space-y-6">
|
||||||
|
<section v-for="site in siteOrder" :key="site">
|
||||||
|
<h2 class="mb-2 font-semibold text-accent">{{ site }}</h2>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<RouterLink v-for="d in bySite[site]" :key="d.id" :to="`/devices/${d.id}`" class="card flex items-center gap-3 py-3 transition hover:border-accent/50">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="truncate font-medium">{{ d.name }}</div>
|
||||||
|
<div class="truncate text-xs text-slate-500">{{ d.ip_addresses?.map((i) => i.ip).join(", ") || "No IPs" }}</div>
|
||||||
|
</div>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showAdd" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showAdd = false">
|
||||||
|
<form class="card w-full max-w-md space-y-3" @submit.prevent="createDevice">
|
||||||
|
<h2 class="text-lg font-semibold">Add device</h2>
|
||||||
|
<input v-model="addForm.name" class="input-field" placeholder="Name" required />
|
||||||
|
<input v-model="addForm.description" class="input-field" placeholder="Description" />
|
||||||
|
<template v-if="auth.can('add_device_ip') && subnets.length">
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input v-model="assignIpOnCreate" type="checkbox" @change="onAssignIpToggle" />
|
||||||
|
Assign an IP address
|
||||||
|
</label>
|
||||||
|
<template v-if="assignIpOnCreate">
|
||||||
|
<select v-model="addForm.site" class="input-field" @change="onAddSiteChange">
|
||||||
|
<option v-for="site in sites" :key="site" :value="site">{{ site }}</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="addForm.subnet_id" class="input-field" @change="onAddSubnetChange">
|
||||||
|
<option v-for="s in subnetsForSite" :key="s.id" :value="s.id">{{ s.name }} ({{ s.cidr }})</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="addForm.ip_id" class="input-field" required>
|
||||||
|
<option v-for="ip in availableIps" :key="ip.id" :value="ip.id">{{ ip.ip }}</option>
|
||||||
|
</select>
|
||||||
|
<p v-if="addForm.subnet_id && !availableIps.length" class="text-xs text-slate-500">No available IPs in this subnet</p>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit" class="btn-primary">Create</button>
|
||||||
|
<button type="button" class="btn-secondary" @click="showAdd = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showBulk" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showBulk = false">
|
||||||
|
<form class="card w-full max-w-md space-y-3" @submit.prevent="bulkCreate">
|
||||||
|
<h2 class="text-lg font-semibold">Bulk add devices</h2>
|
||||||
|
<textarea v-model="bulkForm.names" class="input-field h-32" placeholder="One device name per line" required />
|
||||||
|
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit" class="btn-primary">Create</button>
|
||||||
|
<button type="button" class="btn-secondary" @click="showBulk = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { useRoute, RouterLink } from "vue-router";
|
||||||
|
import { api } from "@/api";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const pool = ref<{ start_ip?: string; end_ip?: string; excluded_ips?: string } | null>(null);
|
||||||
|
const form = ref({ start_ip: "", end_ip: "", excluded_ips: "" });
|
||||||
|
const msg = ref("");
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const d = await api.getDhcp(Number(route.params.id)) as { pools?: { start_ip: string; end_ip: string; excluded_ips?: string }[] };
|
||||||
|
if (d.pools?.[0]) {
|
||||||
|
pool.value = d.pools[0];
|
||||||
|
form.value.start_ip = d.pools[0].start_ip;
|
||||||
|
form.value.end_ip = d.pools[0].end_ip;
|
||||||
|
form.value.excluded_ips = d.pools[0].excluded_ips || "";
|
||||||
|
}
|
||||||
|
} catch { /* no pool */ }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
await api.setDhcp(Number(route.params.id), {
|
||||||
|
pools: [{ start_ip: form.value.start_ip, end_ip: form.value.end_ip, excluded_ips: form.value.excluded_ips.split(",").map((s) => s.trim()).filter(Boolean) }],
|
||||||
|
});
|
||||||
|
msg.value = "Saved";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
await api.setDhcp(Number(route.params.id), { remove: true });
|
||||||
|
pool.value = null;
|
||||||
|
msg.value = "Removed";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<RouterLink :to="`/subnets/${route.params.id}`" class="text-sm text-accent hover:underline">← Subnet</RouterLink>
|
||||||
|
<h1 class="mt-4 text-2xl font-bold">DHCP pool</h1>
|
||||||
|
<form class="card mt-6 max-w-lg space-y-4" @submit.prevent="save">
|
||||||
|
<input v-model="form.start_ip" class="input-field" placeholder="Start IP" required />
|
||||||
|
<input v-model="form.end_ip" class="input-field" placeholder="End IP" required />
|
||||||
|
<input v-model="form.excluded_ips" class="input-field" placeholder="Excluded IPs (comma-separated)" />
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit" class="btn-primary">Save</button>
|
||||||
|
<button v-if="pool" type="button" class="btn-secondary" @click="remove">Remove pool</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="msg" class="text-sm text-accent">{{ msg }}</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useRouter, useRoute } from "vue-router";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
const email = ref("");
|
||||||
|
const password = ref("");
|
||||||
|
const err = ref("");
|
||||||
|
const busy = ref(false);
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
err.value = "";
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
const r = await auth.login(email.value.trim(), password.value);
|
||||||
|
if (r.requires_setup) {
|
||||||
|
router.push("/setup-2fa");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (r.requires_2fa) {
|
||||||
|
router.push("/verify-2fa");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await auth.fetchMe();
|
||||||
|
router.push((route.query.redirect as string) || "/");
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "Login failed";
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="flex min-h-screen items-center justify-center bg-surface p-6">
|
||||||
|
<div class="card w-full max-w-md p-8">
|
||||||
|
<h1 class="text-2xl font-semibold">Sign in</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Access your IP address management workspace.</p>
|
||||||
|
<form class="mt-8 space-y-4" @submit.prevent="submit">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Email</label>
|
||||||
|
<input v-model="email" type="email" class="input-field" required autocomplete="username" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Password</label>
|
||||||
|
<input v-model="password" type="password" class="input-field" required autocomplete="current-password" />
|
||||||
|
</div>
|
||||||
|
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
|
||||||
|
<button type="submit" class="btn-primary w-full" :disabled="busy">{{ busy ? "Signing in…" : "Sign in" }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { useRoute, RouterLink } from "vue-router";
|
||||||
|
import { api, type Rack } from "@/api";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const rack = ref<Rack | null>(null);
|
||||||
|
const side = ref("front");
|
||||||
|
const showAddDevice = ref(false);
|
||||||
|
const showAddNonnet = ref(false);
|
||||||
|
const addForm = ref({ device_id: 0, position_u: 1, side: "front" });
|
||||||
|
const nonnetForm = ref({ nonnet_device_name: "", position_u: 1, side: "front" });
|
||||||
|
const err = ref("");
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
rack.value = await api.rack(Number(route.params.id));
|
||||||
|
const devs = rack.value?.site_devices || [];
|
||||||
|
if (devs.length) addForm.value.device_id = devs[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
|
||||||
|
const siteDevices = () => rack.value?.site_devices || [];
|
||||||
|
|
||||||
|
const slots = (r: Rack) => {
|
||||||
|
const h = r.height_u;
|
||||||
|
const map: Record<number, typeof r.devices> = {};
|
||||||
|
for (const d of r.devices || []) {
|
||||||
|
if (d.side === side.value) (map[d.position_u] ??= []).push(d);
|
||||||
|
}
|
||||||
|
return Array.from({ length: h }, (_, i) => ({ u: h - i, devices: map[h - i] || [] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
async function addDevice() {
|
||||||
|
err.value = "";
|
||||||
|
try {
|
||||||
|
await api.addRackDevice(Number(route.params.id), {
|
||||||
|
device_id: addForm.value.device_id,
|
||||||
|
position_u: addForm.value.position_u,
|
||||||
|
side: addForm.value.side,
|
||||||
|
});
|
||||||
|
showAddDevice.value = false;
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "Failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addNonnet() {
|
||||||
|
err.value = "";
|
||||||
|
try {
|
||||||
|
await api.addRackDevice(Number(route.params.id), {
|
||||||
|
nonnet_device_name: nonnetForm.value.nonnet_device_name,
|
||||||
|
position_u: nonnetForm.value.position_u,
|
||||||
|
side: nonnetForm.value.side,
|
||||||
|
});
|
||||||
|
showAddNonnet.value = false;
|
||||||
|
nonnetForm.value.nonnet_device_name = "";
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "Failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeDevice(rackDeviceId: number) {
|
||||||
|
if (!confirm("Remove this device from the rack?")) return;
|
||||||
|
await api.removeRackDevice(Number(route.params.id), rackDeviceId);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div v-if="rack">
|
||||||
|
<RouterLink to="/racks" class="text-sm text-accent hover:underline">← Racks</RouterLink>
|
||||||
|
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">{{ rack.name }}</h1>
|
||||||
|
<p class="text-slate-500">{{ rack.site }} · {{ rack.height_u }}U</p>
|
||||||
|
</div>
|
||||||
|
<a v-if="auth.can('export_rack_csv')" :href="`/api/v2/racks/${rack.id}/export`" class="btn-secondary text-sm">Export CSV</a>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
|
<button class="rounded-lg px-3 py-1 text-sm" :class="side === 'front' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="side = 'front'">Front</button>
|
||||||
|
<button class="rounded-lg px-3 py-1 text-sm" :class="side === 'back' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="side = 'back'">Back</button>
|
||||||
|
<button v-if="auth.can('add_device_to_rack')" class="btn-secondary text-sm" @click="showAddDevice = true; err = ''">Add device</button>
|
||||||
|
<button v-if="auth.can('add_nonnet_device_to_rack')" class="btn-secondary text-sm" @click="showAddNonnet = true; err = ''">Add non-networked</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-6 max-w-md font-mono text-sm">
|
||||||
|
<div v-for="row in slots(rack)" :key="row.u" class="flex border-b border-slate-200 py-2 dark:border-slate-700">
|
||||||
|
<span class="w-10 shrink-0 text-slate-500">U{{ row.u }}</span>
|
||||||
|
<span class="flex flex-1 flex-col gap-1">
|
||||||
|
<span v-for="d in row.devices" :key="d.id" class="flex items-center gap-2">
|
||||||
|
<RouterLink v-if="d.device_id" :to="`/devices/${d.device_id}`" class="text-accent hover:underline">{{ d.device_name }}</RouterLink>
|
||||||
|
<span v-else>{{ d.nonnet_device_name }}</span>
|
||||||
|
<button v-if="auth.can('remove_device_from_rack')" class="text-red-500 hover:underline" @click="removeDevice(d.id)">×</button>
|
||||||
|
</span>
|
||||||
|
<span v-if="!row.devices.length" class="text-slate-500">—</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showAddDevice" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showAddDevice = false">
|
||||||
|
<form class="card w-full max-w-md space-y-3" @submit.prevent="addDevice">
|
||||||
|
<h2 class="text-lg font-semibold">Add device to rack</h2>
|
||||||
|
<select v-model="addForm.device_id" class="input-field" required>
|
||||||
|
<option v-for="d in siteDevices()" :key="d.id" :value="d.id">{{ d.name }}</option>
|
||||||
|
</select>
|
||||||
|
<input v-model.number="addForm.position_u" type="number" :min="1" :max="rack.height_u" class="input-field" placeholder="U position" required />
|
||||||
|
<select v-model="addForm.side" class="input-field">
|
||||||
|
<option value="front">Front</option>
|
||||||
|
<option value="back">Back</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-slate-500">For multi-U devices, add each U separately.</p>
|
||||||
|
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit" class="btn-primary">Add</button>
|
||||||
|
<button type="button" class="btn-secondary" @click="showAddDevice = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showAddNonnet" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showAddNonnet = false">
|
||||||
|
<form class="card w-full max-w-md space-y-3" @submit.prevent="addNonnet">
|
||||||
|
<h2 class="text-lg font-semibold">Add non-networked device</h2>
|
||||||
|
<input v-model="nonnetForm.nonnet_device_name" class="input-field" placeholder="Device name" required />
|
||||||
|
<input v-model.number="nonnetForm.position_u" type="number" :min="1" :max="rack.height_u" class="input-field" placeholder="U position" required />
|
||||||
|
<select v-model="nonnetForm.side" class="input-field">
|
||||||
|
<option value="front">Front</option>
|
||||||
|
<option value="back">Back</option>
|
||||||
|
</select>
|
||||||
|
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit" class="btn-primary">Add</button>
|
||||||
|
<button type="button" class="btn-secondary" @click="showAddNonnet = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { RouterLink } from "vue-router";
|
||||||
|
import { api, type Rack } from "@/api";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const racks = ref<Rack[]>([]);
|
||||||
|
const showAdd = ref(false);
|
||||||
|
const showEdit = ref(false);
|
||||||
|
const form = ref({ name: "", site: "", height_u: 42 });
|
||||||
|
const editId = ref(0);
|
||||||
|
const err = ref("");
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
racks.value = await api.racks();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
err.value = "";
|
||||||
|
try {
|
||||||
|
await api.createRack({ ...form.value, height_u: Number(form.value.height_u) });
|
||||||
|
showAdd.value = false;
|
||||||
|
form.value = { name: "", site: "", height_u: 42 };
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "Failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(r: Rack) {
|
||||||
|
editId.value = r.id;
|
||||||
|
form.value = { name: r.name, site: r.site, height_u: r.height_u };
|
||||||
|
showEdit.value = true;
|
||||||
|
err.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
err.value = "";
|
||||||
|
try {
|
||||||
|
await api.updateRack(editId.value, { ...form.value, height_u: Number(form.value.height_u) });
|
||||||
|
showEdit.value = false;
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "Failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function del(id: number) {
|
||||||
|
if (!confirm("Delete this rack?")) return;
|
||||||
|
await api.deleteRack(id);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h1 class="text-2xl font-bold">Racks</h1>
|
||||||
|
<button v-if="auth.can('add_rack')" class="btn-primary text-sm" @click="showAdd = true; err = ''">Add rack</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div v-for="r in racks" :key="r.id" class="card">
|
||||||
|
<RouterLink :to="`/racks/${r.id}`" class="block transition hover:text-accent">
|
||||||
|
<div class="font-medium">{{ r.name }}</div>
|
||||||
|
<div class="text-sm text-slate-500">{{ r.site }} · {{ r.height_u }}U · {{ r.percent_full ?? 0 }}% full</div>
|
||||||
|
</RouterLink>
|
||||||
|
<div v-if="auth.can('add_rack') || auth.can('delete_rack')" class="mt-3 flex gap-2">
|
||||||
|
<button v-if="auth.can('add_rack')" class="text-sm text-accent hover:underline" @click="openEdit(r)">Edit</button>
|
||||||
|
<button v-if="auth.can('delete_rack')" class="text-sm text-red-500 hover:underline" @click="del(r.id)">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showAdd || showEdit" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showAdd = false; showEdit = false">
|
||||||
|
<form class="card w-full max-w-md space-y-3" @submit.prevent="showEdit ? saveEdit() : create()">
|
||||||
|
<h2 class="text-lg font-semibold">{{ showEdit ? "Edit rack" : "Add rack" }}</h2>
|
||||||
|
<input v-model="form.name" class="input-field" placeholder="Name" required />
|
||||||
|
<input v-model="form.site" class="input-field" placeholder="Site" required />
|
||||||
|
<input v-model.number="form.height_u" type="number" min="1" class="input-field" placeholder="Height (U)" required />
|
||||||
|
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit" class="btn-primary">{{ showEdit ? "Save" : "Create" }}</button>
|
||||||
|
<button type="button" class="btn-secondary" @click="showAdd = false; showEdit = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { api } from "@/api";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
const step = ref<"generate" | "verify" | "done">("generate");
|
||||||
|
const qrCode = ref("");
|
||||||
|
const secret = ref("");
|
||||||
|
const code = ref("");
|
||||||
|
const backupCodes = ref<string[]>([]);
|
||||||
|
const err = ref("");
|
||||||
|
const router = useRouter();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const r = await api.setup2fa("generate");
|
||||||
|
qrCode.value = r.qr_code || "";
|
||||||
|
secret.value = r.secret || "";
|
||||||
|
step.value = "verify";
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "Failed to start setup";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function verify() {
|
||||||
|
err.value = "";
|
||||||
|
try {
|
||||||
|
const r = await api.setup2fa("verify", code.value.trim());
|
||||||
|
backupCodes.value = r.backup_codes || [];
|
||||||
|
step.value = "done";
|
||||||
|
await auth.fetchMe();
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "Invalid code";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish() {
|
||||||
|
router.push("/");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="flex min-h-screen items-center justify-center p-6">
|
||||||
|
<div class="card w-full max-w-md p-8">
|
||||||
|
<h1 class="text-xl font-semibold">Set up 2FA</h1>
|
||||||
|
<div v-if="step === 'verify'" class="mt-4 space-y-4">
|
||||||
|
<img v-if="qrCode" :src="`data:image/png;base64,${qrCode}`" alt="QR" class="mx-auto rounded-lg" />
|
||||||
|
<p class="break-all font-mono text-xs text-slate-500">{{ secret }}</p>
|
||||||
|
<input v-model="code" class="input-field text-center font-mono" placeholder="6-digit code" maxlength="6" />
|
||||||
|
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
|
||||||
|
<button class="btn-primary w-full" @click="verify">Verify & enable</button>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="step === 'done'" class="mt-4 space-y-4">
|
||||||
|
<p class="text-sm text-slate-500">Save these backup codes securely:</p>
|
||||||
|
<ul class="rounded-lg bg-surface-overlay p-3 font-mono text-sm">
|
||||||
|
<li v-for="c in backupCodes" :key="c">{{ c }}</li>
|
||||||
|
</ul>
|
||||||
|
<button class="btn-primary w-full" @click="finish">Continue</button>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="err" class="mt-4 text-red-500">{{ err }}</p>
|
||||||
|
<p v-else class="mt-4 text-slate-500">Loading…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { useRoute, RouterLink } from "vue-router";
|
||||||
|
import { api, type Subnet } from "@/api";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import IpHistoryModal from "@/components/IpHistoryModal.vue";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const subnet = ref<Subnet | null>(null);
|
||||||
|
const historyIp = ref<string | null>(null);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
subnet.value = await api.subnet(Number(route.params.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveNotes(ipId: number, notes: string) {
|
||||||
|
await api.patchIpNotes(ipId, notes);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div v-if="subnet">
|
||||||
|
<RouterLink to="/" class="text-sm text-accent hover:underline">← Home</RouterLink>
|
||||||
|
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">{{ subnet.name }}</h1>
|
||||||
|
<p class="font-mono text-slate-500">{{ subnet.cidr }} · {{ subnet.site || "Unassigned" }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<RouterLink :to="`/subnets/${subnet.id}/dhcp`" class="btn-secondary text-sm">DHCP</RouterLink>
|
||||||
|
<a v-if="auth.can('export_subnet_csv')" :href="`/api/v2/subnets/${subnet.id}/export`" class="btn-secondary text-sm">Export CSV</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card mt-6 overflow-x-auto">
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<th class="p-2 font-medium">IP</th>
|
||||||
|
<th class="p-2 font-medium">Hostname</th>
|
||||||
|
<th class="p-2 font-medium">Notes</th>
|
||||||
|
<th class="p-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="ip in subnet.ip_addresses" :key="ip.id" class="border-b border-slate-100 dark:border-slate-800">
|
||||||
|
<td class="p-2 font-mono">{{ ip.ip }}</td>
|
||||||
|
<td class="p-2">
|
||||||
|
<RouterLink v-if="ip.device_id" :to="`/devices/${ip.device_id}`" class="text-accent hover:underline">{{ ip.device_name || ip.hostname }}</RouterLink>
|
||||||
|
<span v-else>{{ ip.hostname || "—" }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-2">
|
||||||
|
<input
|
||||||
|
v-if="auth.can('edit_subnet')"
|
||||||
|
:value="ip.notes || ''"
|
||||||
|
class="input-field py-1 text-xs"
|
||||||
|
@change="saveNotes(ip.id, ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
<span v-else>{{ ip.notes || "—" }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-2">
|
||||||
|
<button type="button" class="text-xs text-accent hover:underline" @click="historyIp = ip.ip">History</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<IpHistoryModal :ip="historyIp" @close="historyIp = null" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { RouterLink } from "vue-router";
|
||||||
|
import { api, type Subnet } from "@/api";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const subnets = ref<Subnet[]>([]);
|
||||||
|
const form = ref({ name: "", cidr: "", site: "", vlan_id: "" as string | number, vlan_description: "", vlan_notes: "" });
|
||||||
|
const editForm = ref({ id: 0, name: "", cidr: "", site: "", vlan_id: "" as string | number, vlan_description: "", vlan_notes: "" });
|
||||||
|
const showEdit = ref(false);
|
||||||
|
const err = ref("");
|
||||||
|
|
||||||
|
onMounted(async () => { subnets.value = await api.subnets(); });
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
subnets.value = await api.subnets();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function add() {
|
||||||
|
err.value = "";
|
||||||
|
try {
|
||||||
|
const body: Partial<Subnet> = {
|
||||||
|
name: form.value.name,
|
||||||
|
cidr: form.value.cidr,
|
||||||
|
site: form.value.site,
|
||||||
|
vlan_description: form.value.vlan_description || undefined,
|
||||||
|
vlan_notes: form.value.vlan_notes || undefined,
|
||||||
|
};
|
||||||
|
if (form.value.vlan_id) body.vlan_id = Number(form.value.vlan_id);
|
||||||
|
await api.createSubnet(body);
|
||||||
|
form.value = { name: "", cidr: "", site: "", vlan_id: "", vlan_description: "", vlan_notes: "" };
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "Failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(s: Subnet) {
|
||||||
|
editForm.value = {
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
cidr: s.cidr,
|
||||||
|
site: s.site || "",
|
||||||
|
vlan_id: s.vlan_id ?? "",
|
||||||
|
vlan_description: s.vlan_description || "",
|
||||||
|
vlan_notes: s.vlan_notes || "",
|
||||||
|
};
|
||||||
|
showEdit.value = true;
|
||||||
|
err.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
err.value = "";
|
||||||
|
try {
|
||||||
|
const body: Partial<Subnet> = {
|
||||||
|
name: editForm.value.name,
|
||||||
|
cidr: editForm.value.cidr,
|
||||||
|
site: editForm.value.site,
|
||||||
|
vlan_description: editForm.value.vlan_description || null,
|
||||||
|
vlan_notes: editForm.value.vlan_notes || null,
|
||||||
|
vlan_id: editForm.value.vlan_id ? Number(editForm.value.vlan_id) : null,
|
||||||
|
};
|
||||||
|
await api.updateSubnet(editForm.value.id, body);
|
||||||
|
showEdit.value = false;
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "Failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function del(id: number) {
|
||||||
|
if (!confirm("Delete subnet and all IPs?")) return;
|
||||||
|
await api.deleteSubnet(id);
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">Subnet management</h1>
|
||||||
|
<form v-if="auth.can('add_subnet')" class="card mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3" @submit.prevent="add">
|
||||||
|
<input v-model="form.name" class="input-field" placeholder="Name" required />
|
||||||
|
<input v-model="form.cidr" class="input-field font-mono" placeholder="192.168.1.0/24" required />
|
||||||
|
<input v-model="form.site" class="input-field" placeholder="Site" />
|
||||||
|
<input v-model="form.vlan_id" type="number" class="input-field" placeholder="VLAN ID" min="1" max="4094" />
|
||||||
|
<input v-model="form.vlan_description" class="input-field" placeholder="VLAN description" />
|
||||||
|
<input v-model="form.vlan_notes" class="input-field" placeholder="VLAN notes" />
|
||||||
|
<button class="btn-primary sm:col-span-2 lg:col-span-3 sm:max-w-xs">Add subnet</button>
|
||||||
|
<p v-if="err" class="text-sm text-red-500 sm:col-span-3">{{ err }}</p>
|
||||||
|
</form>
|
||||||
|
<ul class="mt-8 space-y-2">
|
||||||
|
<li v-for="s in subnets" :key="s.id" class="card flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<RouterLink :to="`/subnets/${s.id}`" class="font-medium text-accent hover:underline">{{ s.name }}</RouterLink>
|
||||||
|
<span class="font-mono text-sm text-slate-500">{{ s.cidr }}</span>
|
||||||
|
<span v-if="s.vlan_id" class="rounded-full bg-surface-overlay px-2 py-0.5 text-xs">VLAN {{ s.vlan_id }}</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button v-if="auth.can('edit_subnet')" class="text-sm text-accent hover:underline" @click="openEdit(s)">Edit</button>
|
||||||
|
<button v-if="auth.can('delete_subnet')" class="text-sm text-red-500" @click="del(s.id)">Delete</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div v-if="showEdit" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showEdit = false">
|
||||||
|
<form class="card w-full max-w-lg space-y-3" @submit.prevent="saveEdit">
|
||||||
|
<h2 class="text-lg font-semibold">Edit subnet</h2>
|
||||||
|
<input v-model="editForm.name" class="input-field" placeholder="Name" required />
|
||||||
|
<input v-model="editForm.cidr" class="input-field font-mono" placeholder="CIDR" required />
|
||||||
|
<input v-model="editForm.site" class="input-field" placeholder="Site" />
|
||||||
|
<input v-model="editForm.vlan_id" type="number" class="input-field" placeholder="VLAN ID" min="1" max="4094" />
|
||||||
|
<input v-model="editForm.vlan_description" class="input-field" placeholder="VLAN description" />
|
||||||
|
<input v-model="editForm.vlan_notes" class="input-field" placeholder="VLAN notes" />
|
||||||
|
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit" class="btn-primary">Save</button>
|
||||||
|
<button type="button" class="btn-secondary" @click="showEdit = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { api, type Tag } from "@/api";
|
||||||
|
|
||||||
|
const tags = ref<Tag[]>([]);
|
||||||
|
const form = ref({ name: "", color: "#06b6d4", description: "" });
|
||||||
|
const editForm = ref({ id: 0, name: "", color: "#06b6d4", description: "" });
|
||||||
|
const showEdit = ref(false);
|
||||||
|
const err = ref("");
|
||||||
|
|
||||||
|
onMounted(async () => { tags.value = await api.tags(); });
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
await api.createTag(form.value);
|
||||||
|
tags.value = await api.tags();
|
||||||
|
form.value = { name: "", color: "#06b6d4", description: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function del(id: number) {
|
||||||
|
if (!confirm("Delete tag?")) return;
|
||||||
|
await api.deleteTag(id);
|
||||||
|
tags.value = await api.tags();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(t: Tag) {
|
||||||
|
editForm.value = { id: t.id, name: t.name, color: t.color || "#06b6d4", description: t.description || "" };
|
||||||
|
showEdit.value = true;
|
||||||
|
err.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
err.value = "";
|
||||||
|
try {
|
||||||
|
await api.updateTag(editForm.value.id, {
|
||||||
|
name: editForm.value.name,
|
||||||
|
color: editForm.value.color,
|
||||||
|
description: editForm.value.description,
|
||||||
|
});
|
||||||
|
showEdit.value = false;
|
||||||
|
tags.value = await api.tags();
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "Failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">Tags</h1>
|
||||||
|
<form class="card mt-6 flex flex-wrap gap-3" @submit.prevent="create">
|
||||||
|
<input v-model="form.name" class="input-field max-w-xs" placeholder="Name" required />
|
||||||
|
<input v-model="form.color" type="color" class="h-10 w-14 rounded border-0" />
|
||||||
|
<input v-model="form.description" class="input-field max-w-xs" placeholder="Description" />
|
||||||
|
<button class="btn-primary">Add tag</button>
|
||||||
|
</form>
|
||||||
|
<ul class="mt-6 space-y-2">
|
||||||
|
<li v-for="t in tags" :key="t.id" class="card flex items-center justify-between">
|
||||||
|
<span><span class="inline-block h-3 w-3 rounded-full mr-2" :style="{ backgroundColor: t.color }" />{{ t.name }}</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button class="text-sm text-accent hover:underline" @click="openEdit(t)">Edit</button>
|
||||||
|
<button class="text-sm text-red-500" @click="del(t.id)">Delete</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div v-if="showEdit" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showEdit = false">
|
||||||
|
<form class="card w-full max-w-md space-y-3" @submit.prevent="saveEdit">
|
||||||
|
<h2 class="text-lg font-semibold">Edit tag</h2>
|
||||||
|
<input v-model="editForm.name" class="input-field" placeholder="Name" required />
|
||||||
|
<input v-model="editForm.color" type="color" class="h-10 w-14 rounded border-0" />
|
||||||
|
<input v-model="editForm.description" class="input-field" placeholder="Description" />
|
||||||
|
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit" class="btn-primary">Save</button>
|
||||||
|
<button type="button" class="btn-secondary" @click="showEdit = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from "vue";
|
||||||
|
import { api, type UserRow, type RoleRow } from "@/api";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const tab = ref<"users" | "roles">("users");
|
||||||
|
const users = ref<UserRow[]>([]);
|
||||||
|
const roles = ref<RoleRow[]>([]);
|
||||||
|
const permissions = ref<{ id: number; name: string; category?: string }[]>([]);
|
||||||
|
const err = ref("");
|
||||||
|
|
||||||
|
const showUserForm = ref(false);
|
||||||
|
const editUserId = ref<number | null>(null);
|
||||||
|
const userForm = ref({ name: "", email: "", password: "", role_id: 0 });
|
||||||
|
|
||||||
|
const showRoleForm = ref(false);
|
||||||
|
const editRoleId = ref<number | null>(null);
|
||||||
|
const roleForm = ref({ name: "", description: "", require_2fa: false, permission_ids: [] as number[] });
|
||||||
|
|
||||||
|
const showApiKey = ref("");
|
||||||
|
const permByCategory = computed(() => {
|
||||||
|
const m: Record<string, typeof permissions.value> = {};
|
||||||
|
for (const p of permissions.value) {
|
||||||
|
const cat = p.category || "Other";
|
||||||
|
(m[cat] ??= []).push(p);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
[users.value, roles.value] = await Promise.all([api.users(), api.roles()]);
|
||||||
|
if (auth.can("manage_roles")) {
|
||||||
|
permissions.value = await api.permissions().catch(() => []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
|
||||||
|
function openAddUser() {
|
||||||
|
editUserId.value = null;
|
||||||
|
userForm.value = { name: "", email: "", password: "", role_id: roles.value[0]?.id ?? 0 };
|
||||||
|
showUserForm.value = true;
|
||||||
|
err.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditUser(u: UserRow) {
|
||||||
|
editUserId.value = u.id;
|
||||||
|
userForm.value = { name: u.name, email: u.email, password: "", role_id: u.role_id ?? 0 };
|
||||||
|
showUserForm.value = true;
|
||||||
|
err.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUser() {
|
||||||
|
err.value = "";
|
||||||
|
try {
|
||||||
|
if (editUserId.value) {
|
||||||
|
const body: Record<string, unknown> = { name: userForm.value.name, email: userForm.value.email, role_id: userForm.value.role_id };
|
||||||
|
if (userForm.value.password) body.password = userForm.value.password;
|
||||||
|
await api.updateUser(editUserId.value, body);
|
||||||
|
} else {
|
||||||
|
await api.createUser(userForm.value);
|
||||||
|
}
|
||||||
|
showUserForm.value = false;
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "Failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function delUser(id: number) {
|
||||||
|
if (!confirm("Delete this user?")) return;
|
||||||
|
await api.deleteUser(id);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regenKey(id: number) {
|
||||||
|
if (!confirm("Regenerate API key? The old key will stop working.")) return;
|
||||||
|
const r = await api.regenerateApiKey(id);
|
||||||
|
showApiKey.value = r.api_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddRole() {
|
||||||
|
editRoleId.value = null;
|
||||||
|
roleForm.value = { name: "", description: "", require_2fa: false, permission_ids: [] };
|
||||||
|
showRoleForm.value = true;
|
||||||
|
err.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditRole(r: RoleRow) {
|
||||||
|
editRoleId.value = r.id;
|
||||||
|
roleForm.value = {
|
||||||
|
name: r.name,
|
||||||
|
description: r.description || "",
|
||||||
|
require_2fa: !!r.require_2fa,
|
||||||
|
permission_ids: r.permissions?.map((p) => p.id) ?? [],
|
||||||
|
};
|
||||||
|
showRoleForm.value = true;
|
||||||
|
err.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePerm(id: number) {
|
||||||
|
const idx = roleForm.value.permission_ids.indexOf(id);
|
||||||
|
if (idx >= 0) roleForm.value.permission_ids.splice(idx, 1);
|
||||||
|
else roleForm.value.permission_ids.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRole() {
|
||||||
|
err.value = "";
|
||||||
|
try {
|
||||||
|
if (editRoleId.value) {
|
||||||
|
await api.updateRole(editRoleId.value, roleForm.value);
|
||||||
|
} else {
|
||||||
|
await api.createRole(roleForm.value);
|
||||||
|
}
|
||||||
|
showRoleForm.value = false;
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "Failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function delRole(id: number) {
|
||||||
|
if (!confirm("Delete this role?")) return;
|
||||||
|
await api.deleteRole(id);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">Users & roles</h1>
|
||||||
|
<div class="mt-4 flex gap-2">
|
||||||
|
<button class="rounded-lg px-3 py-1 text-sm" :class="tab === 'users' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="tab = 'users'">Users</button>
|
||||||
|
<button class="rounded-lg px-3 py-1 text-sm" :class="tab === 'roles' ? 'bg-accent text-slate-950' : 'bg-surface-overlay'" @click="tab = 'roles'">Roles</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-if="tab === 'users'" class="mt-8">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h2 class="font-semibold text-accent">Users</h2>
|
||||||
|
<button v-if="auth.can('manage_users')" class="btn-primary text-sm" @click="openAddUser">Add user</button>
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li v-for="u in users" :key="u.id" class="card flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span>{{ u.name }} <span class="text-slate-500"><{{ u.email }}></span></span>
|
||||||
|
<span class="text-sm text-slate-500">{{ u.role_name }}</span>
|
||||||
|
<div v-if="auth.can('manage_users')" class="flex gap-2">
|
||||||
|
<button class="text-sm text-accent hover:underline" @click="openEditUser(u)">Edit</button>
|
||||||
|
<button class="text-sm text-accent hover:underline" @click="regenKey(u.id)">API key</button>
|
||||||
|
<button class="text-sm text-red-500 hover:underline" @click="delUser(u.id)">Delete</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="tab === 'roles'" class="mt-8">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h2 class="font-semibold text-accent">Roles</h2>
|
||||||
|
<button v-if="auth.can('manage_roles')" class="btn-primary text-sm" @click="openAddRole">Add role</button>
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li v-for="r in roles" :key="r.id" class="card">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ r.name }} <span v-if="r.require_2fa" class="text-xs text-slate-500">(2FA required)</span></div>
|
||||||
|
<div class="text-sm text-slate-500">{{ r.description }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="auth.can('manage_roles')" class="flex gap-2">
|
||||||
|
<button class="text-sm text-accent hover:underline" @click="openEditRole(r)">Edit</button>
|
||||||
|
<button class="text-sm text-red-500 hover:underline" @click="delRole(r.id)">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div v-if="showUserForm" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showUserForm = false">
|
||||||
|
<form class="card w-full max-w-md space-y-3" @submit.prevent="saveUser">
|
||||||
|
<h2 class="text-lg font-semibold">{{ editUserId ? "Edit user" : "Add user" }}</h2>
|
||||||
|
<input v-model="userForm.name" class="input-field" placeholder="Name" required />
|
||||||
|
<input v-model="userForm.email" type="email" class="input-field" placeholder="Email" required />
|
||||||
|
<input v-model="userForm.password" type="password" class="input-field" :placeholder="editUserId ? 'New password (optional)' : 'Password'" :required="!editUserId" />
|
||||||
|
<select v-model="userForm.role_id" class="input-field">
|
||||||
|
<option v-for="r in roles" :key="r.id" :value="r.id">{{ r.name }}</option>
|
||||||
|
</select>
|
||||||
|
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit" class="btn-primary">Save</button>
|
||||||
|
<button type="button" class="btn-secondary" @click="showUserForm = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showRoleForm" class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 p-4 pt-[10vh]" @click.self="showRoleForm = false">
|
||||||
|
<form class="card w-full max-w-lg space-y-3" @submit.prevent="saveRole">
|
||||||
|
<h2 class="text-lg font-semibold">{{ editRoleId ? "Edit role" : "Add role" }}</h2>
|
||||||
|
<input v-model="roleForm.name" class="input-field" placeholder="Name" required />
|
||||||
|
<input v-model="roleForm.description" class="input-field" placeholder="Description" />
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input v-model="roleForm.require_2fa" type="checkbox" />
|
||||||
|
Require 2FA
|
||||||
|
</label>
|
||||||
|
<div v-if="permissions.length" class="max-h-48 overflow-y-auto rounded-lg border border-slate-200 p-3 dark:border-slate-700">
|
||||||
|
<div v-for="(perms, cat) in permByCategory" :key="cat" class="mb-3">
|
||||||
|
<div class="text-xs font-semibold uppercase text-slate-500">{{ cat }}</div>
|
||||||
|
<label v-for="p in perms" :key="p.id" class="mt-1 flex items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" :checked="roleForm.permission_ids.includes(p.id)" @change="togglePerm(p.id)" />
|
||||||
|
{{ p.name }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="submit" class="btn-primary">Save</button>
|
||||||
|
<button type="button" class="btn-secondary" @click="showRoleForm = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showApiKey" class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" @click.self="showApiKey = ''">
|
||||||
|
<div class="card w-full max-w-md space-y-3">
|
||||||
|
<h2 class="text-lg font-semibold">New API key</h2>
|
||||||
|
<p class="text-sm text-slate-500">Copy this key now — it won't be shown again.</p>
|
||||||
|
<code class="block break-all rounded-lg bg-surface-overlay p-3 text-sm">{{ showApiKey }}</code>
|
||||||
|
<button class="btn-primary" @click="showApiKey = ''">Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { api } from "@/api";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
const code = ref("");
|
||||||
|
const useBackup = ref(false);
|
||||||
|
const err = ref("");
|
||||||
|
const busy = ref(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
err.value = "";
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
await api.verify2fa(code.value.trim(), useBackup.value);
|
||||||
|
await auth.fetchMe();
|
||||||
|
router.push("/");
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "Verification failed";
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="flex min-h-screen items-center justify-center p-6">
|
||||||
|
<div class="card w-full max-w-md p-8">
|
||||||
|
<h1 class="text-xl font-semibold">Two-factor authentication</h1>
|
||||||
|
<form class="mt-6 space-y-4" @submit.prevent="submit">
|
||||||
|
<input v-model="code" class="input-field text-center font-mono text-lg tracking-widest" :placeholder="useBackup ? 'Backup code' : '000000'" />
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input v-model="useBackup" type="checkbox" /> Use backup code
|
||||||
|
</label>
|
||||||
|
<p v-if="err" class="text-sm text-red-500">{{ err }}</p>
|
||||||
|
<button class="btn-primary w-full" :disabled="busy">Verify</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
|
||||||
|
darkMode: "media",
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
surface: {
|
||||||
|
DEFAULT: "rgb(var(--surface) / <alpha-value>)",
|
||||||
|
raised: "rgb(var(--surface-raised) / <alpha-value>)",
|
||||||
|
overlay: "rgb(var(--surface-overlay) / <alpha-value>)",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "rgb(var(--accent) / <alpha-value>)",
|
||||||
|
muted: "rgb(var(--accent-muted) / <alpha-value>)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["IBM Plex Sans", "system-ui", "sans-serif"],
|
||||||
|
mono: ["IBM Plex Mono", "ui-monospace", "monospace"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"paths": { "@/*": ["./src/*"] },
|
||||||
|
"baseUrl": "."
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { defineConfig, type Plugin } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const staticRoot = path.resolve(__dirname, "../static");
|
||||||
|
|
||||||
|
function servePwaFromStatic(): Plugin {
|
||||||
|
return {
|
||||||
|
name: "serve-pwa-from-static",
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use((req, res, next) => {
|
||||||
|
const url = req.url?.split("?")[0] ?? "";
|
||||||
|
if (url !== "/manifest.webmanifest" && url !== "/sw.js") {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = url.slice(1);
|
||||||
|
const filePath = path.join(staticRoot, name);
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = fs.readFileSync(filePath);
|
||||||
|
const type = name.endsWith(".webmanifest")
|
||||||
|
? "application/manifest+json"
|
||||||
|
: "application/javascript";
|
||||||
|
res.setHeader("Content-Type", type);
|
||||||
|
res.end(body);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue(), servePwaFromStatic()],
|
||||||
|
resolve: {
|
||||||
|
alias: { "@": path.resolve(__dirname, "src") },
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "../static/dist",
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://127.0.0.1:5000",
|
||||||
|
"/ws": {
|
||||||
|
target: "ws://127.0.0.1:5000",
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
echo "Generating CSS..."
|
if [ ! -f static/dist/index.html ]; then
|
||||||
./tailwindcss -i ./static/css/input.css -o ./static/css/output.css --content "./templates/*.html,./static/js/*.min.js" --minify
|
echo "Building frontend..."
|
||||||
|
(cd frontend && npm ci && npm run build)
|
||||||
|
fi
|
||||||
echo "Starting app..."
|
echo "Starting app..."
|
||||||
python app.py
|
python app.py
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
/* Icon search suggestions styling */
|
|
||||||
.icon-suggestions {
|
|
||||||
max-height: 240px;
|
|
||||||
overflow-y: auto;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-suggestion-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s ease-in-out;
|
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-suggestion-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-suggestion-item:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .icon-suggestion-item {
|
|
||||||
border-bottom-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .icon-suggestion-item:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-suggestion-item i {
|
|
||||||
width: 20px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 1.125rem;
|
|
||||||
color: #4b5563;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .icon-suggestion-item i {
|
|
||||||
color: #d1d5db;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-suggestion-item span {
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .icon-suggestion-item span {
|
|
||||||
color: #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Icon preview styling */
|
|
||||||
.icon-preview {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scrollbar styling for suggestions */
|
|
||||||
.icon-suggestions::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-suggestions::-webkit-scrollbar-track {
|
|
||||||
background: rgba(0, 0, 0, 0.05);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .icon-suggestions::-webkit-scrollbar-track {
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-suggestions::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .icon-suggestions::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-suggestions::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .icon-suggestions::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
.icon-suggestions{max-height:240px;overflow-y:auto;border-radius:.5rem;box-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -2px rgba(0,0,0,.05)}.icon-suggestion-item{display:flex;align-items:center;gap:.75rem;padding:.75rem 1rem;cursor:pointer;transition:background-color .15s ease-in-out;border-bottom:1px solid rgba(0,0,0,.1)}.icon-suggestion-item:last-child{border-bottom:none}.icon-suggestion-item:hover{background-color:rgba(0,0,0,.05)}.dark .icon-suggestion-item{border-bottom-color:rgba(255,255,255,.1)}.dark .icon-suggestion-item:hover{background-color:rgba(255,255,255,.1)}.icon-suggestion-item i{width:20px;text-align:center;font-size:1.125rem;color:#4b5563}.dark .icon-suggestion-item i{color:#d1d5db}.icon-suggestion-item span{font-family:'Courier New',monospace;font-size:.875rem;color:#374151}.dark .icon-suggestion-item span{color:#e5e7eb}.icon-preview{display:flex;align-items:center;justify-content:center;min-width:2rem}.icon-suggestions::-webkit-scrollbar{width:8px}.icon-suggestions::-webkit-scrollbar-track{background:rgba(0,0,0,.05);border-radius:4px}.dark .icon-suggestions::-webkit-scrollbar-track{background:rgba(255,255,255,.05)}.icon-suggestions::-webkit-scrollbar-thumb{background:rgba(0,0,0,.2);border-radius:4px}.dark .icon-suggestions::-webkit-scrollbar-thumb{background:rgba(255,255,255,.2)}.icon-suggestions::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.3)}.dark .icon-suggestions::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.3)}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
h2 {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.container form:not(.mb-6), .mt-4 {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.allocated-ips {
|
|
||||||
display: block;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
.button-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
justify-items: center;
|
|
||||||
}
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
h2{cursor:pointer}.container form:not(.mb-6),.mt-4{display:none}.allocated-ips{display:block;margin-top:1rem}.button-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:1rem;justify-items:center}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
@import "tailwindcss"
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
function validateSubnetForm() {
|
|
||||||
const cidrInput = document.getElementById('cidr-input');
|
|
||||||
const errorSpan = document.getElementById('cidr-error');
|
|
||||||
const cidrPattern = /^(?:\d{1,3}\.){3}\d{1,3}\/([0-9]|[1-2][0-9]|3[0-2])$/;
|
|
||||||
if (!cidrPattern.test(cidrInput.value.trim())) {
|
|
||||||
errorSpan.textContent = 'Please enter a valid CIDR (e.g., 192.168.1.0/24)';
|
|
||||||
errorSpan.classList.remove('hidden');
|
|
||||||
cidrInput.classList.add('border-red-500');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
errorSpan.textContent = '';
|
|
||||||
errorSpan.classList.add('hidden');
|
|
||||||
cidrInput.classList.remove('border-red-500');
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
function validateSubnetForm(){let e=document.getElementById("cidr-input"),t=document.getElementById("cidr-error");return/^(?:\d{1,3}\.){3}\d{1,3}\/([0-9]|[1-2][0-9]|3[0-2])$/.test(e.value.trim())?(t.textContent="",t.classList.add("hidden"),e.classList.remove("border-red-500"),!0):(t.textContent="Please enter a valid CIDR (e.g., 192.168.1.0/24)",t.classList.remove("hidden"),e.classList.add("border-red-500"),!1)}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
function showAddSubnetModal() {
|
|
||||||
document.getElementById('add-subnet-modal').classList.remove('hidden');
|
|
||||||
document.getElementById('add-subnet-name').value = '';
|
|
||||||
document.getElementById('add-subnet-cidr').value = '';
|
|
||||||
document.getElementById('add-subnet-site').value = '';
|
|
||||||
document.getElementById('add-subnet-vlan-id').value = '';
|
|
||||||
document.getElementById('add-subnet-vlan-description').value = '';
|
|
||||||
document.getElementById('add-subnet-vlan-notes').value = '';
|
|
||||||
document.getElementById('vlan-id-error').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeAddSubnetModal() {
|
|
||||||
document.getElementById('add-subnet-modal').classList.add('hidden');
|
|
||||||
document.getElementById('cidr-error').classList.add('hidden');
|
|
||||||
document.getElementById('vlan-id-error').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function editSubnet(subnetId, name, cidr, site, vlanId, vlanDescription, vlanNotes) {
|
|
||||||
document.getElementById('edit-subnet-id').value = subnetId;
|
|
||||||
document.getElementById('edit-subnet-name').value = name;
|
|
||||||
document.getElementById('edit-subnet-cidr').value = cidr;
|
|
||||||
document.getElementById('edit-subnet-site').value = site;
|
|
||||||
document.getElementById('edit-subnet-vlan-id').value = vlanId || '';
|
|
||||||
document.getElementById('edit-subnet-vlan-description').value = vlanDescription || '';
|
|
||||||
document.getElementById('edit-subnet-vlan-notes').value = vlanNotes || '';
|
|
||||||
document.getElementById('edit-subnet-modal').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeEditSubnetModal() {
|
|
||||||
document.getElementById('edit-subnet-modal').classList.add('hidden');
|
|
||||||
document.getElementById('edit-cidr-error').classList.add('hidden');
|
|
||||||
document.getElementById('edit-vlan-id-error').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateVlanId(vlanIdValue, errorElementId) {
|
|
||||||
if (!vlanIdValue || vlanIdValue.trim() === '') {
|
|
||||||
return true; // VLAN ID is optional
|
|
||||||
}
|
|
||||||
|
|
||||||
const vlanId = parseInt(vlanIdValue.trim());
|
|
||||||
if (isNaN(vlanId)) {
|
|
||||||
const errorElement = document.getElementById(errorElementId);
|
|
||||||
if (errorElement) {
|
|
||||||
errorElement.textContent = 'VLAN ID must be a valid integer';
|
|
||||||
errorElement.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (vlanId < 1 || vlanId > 4094) {
|
|
||||||
const errorElement = document.getElementById(errorElementId);
|
|
||||||
if (errorElement) {
|
|
||||||
errorElement.textContent = 'VLAN ID must be between 1 and 4094';
|
|
||||||
errorElement.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorElement = document.getElementById(errorElementId);
|
|
||||||
if (errorElement) {
|
|
||||||
errorElement.classList.add('hidden');
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateSubnetForm() {
|
|
||||||
const cidrInput = document.getElementById('add-subnet-cidr');
|
|
||||||
const cidrError = document.getElementById('cidr-error');
|
|
||||||
const cidr = cidrInput.value.trim();
|
|
||||||
|
|
||||||
// Basic CIDR validation
|
|
||||||
const cidrPattern = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/;
|
|
||||||
if (!cidrPattern.test(cidr)) {
|
|
||||||
cidrError.textContent = 'Invalid CIDR format. Use format like 192.168.1.0/24';
|
|
||||||
cidrError.classList.remove('hidden');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check prefix length
|
|
||||||
const parts = cidr.split('/');
|
|
||||||
if (parts.length === 2) {
|
|
||||||
const prefixLen = parseInt(parts[1]);
|
|
||||||
if (prefixLen < 24 || prefixLen > 32) {
|
|
||||||
cidrError.textContent = 'Subnet must be /24 or smaller (e.g., /24, /25, ... /32)';
|
|
||||||
cidrError.classList.remove('hidden');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cidrError.classList.add('hidden');
|
|
||||||
|
|
||||||
// Validate VLAN ID
|
|
||||||
const vlanIdInput = document.getElementById('add-subnet-vlan-id');
|
|
||||||
if (!validateVlanId(vlanIdInput.value, 'vlan-id-error')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateEditSubnetForm() {
|
|
||||||
const cidrInput = document.getElementById('edit-subnet-cidr');
|
|
||||||
const cidrError = document.getElementById('edit-cidr-error');
|
|
||||||
const cidr = cidrInput.value.trim();
|
|
||||||
|
|
||||||
// Basic CIDR validation
|
|
||||||
const cidrPattern = /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/;
|
|
||||||
if (!cidrPattern.test(cidr)) {
|
|
||||||
cidrError.textContent = 'Invalid CIDR format. Use format like 192.168.1.0/24';
|
|
||||||
cidrError.classList.remove('hidden');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check prefix length
|
|
||||||
const parts = cidr.split('/');
|
|
||||||
if (parts.length === 2) {
|
|
||||||
const prefixLen = parseInt(parts[1]);
|
|
||||||
if (prefixLen < 24 || prefixLen > 32) {
|
|
||||||
cidrError.textContent = 'Subnet must be /24 or smaller (e.g., /24, /25, ... /32)';
|
|
||||||
cidrError.classList.remove('hidden');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cidrError.classList.add('hidden');
|
|
||||||
|
|
||||||
// Validate VLAN ID
|
|
||||||
const vlanIdInput = document.getElementById('edit-subnet-vlan-id');
|
|
||||||
if (!validateVlanId(vlanIdInput.value, 'edit-vlan-id-error')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modals when clicking outside
|
|
||||||
window.onclick = function(event) {
|
|
||||||
const addModal = document.getElementById('add-subnet-modal');
|
|
||||||
const editModal = document.getElementById('edit-subnet-modal');
|
|
||||||
if (event.target === addModal) {
|
|
||||||
closeAddSubnetModal();
|
|
||||||
}
|
|
||||||
if (event.target === editModal) {
|
|
||||||
closeEditSubnetModal();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
function showAddSubnetModal(){document.getElementById("add-subnet-modal").classList.remove("hidden"),document.getElementById("add-subnet-name").value="",document.getElementById("add-subnet-cidr").value="",document.getElementById("add-subnet-site").value="",document.getElementById("add-subnet-vlan-id").value="",document.getElementById("add-subnet-vlan-description").value="",document.getElementById("add-subnet-vlan-notes").value="",document.getElementById("vlan-id-error").classList.add("hidden")}function closeAddSubnetModal(){document.getElementById("add-subnet-modal").classList.add("hidden"),document.getElementById("cidr-error").classList.add("hidden"),document.getElementById("vlan-id-error").classList.add("hidden")}function editSubnet(e,t,d,n,l,i,a){document.getElementById("edit-subnet-id").value=e,document.getElementById("edit-subnet-name").value=t,document.getElementById("edit-subnet-cidr").value=d,document.getElementById("edit-subnet-site").value=n,document.getElementById("edit-subnet-vlan-id").value=l||"",document.getElementById("edit-subnet-vlan-description").value=i||"",document.getElementById("edit-subnet-vlan-notes").value=a||"",document.getElementById("edit-subnet-modal").classList.remove("hidden")}function closeEditSubnetModal(){document.getElementById("edit-subnet-modal").classList.add("hidden"),document.getElementById("edit-cidr-error").classList.add("hidden"),document.getElementById("edit-vlan-id-error").classList.add("hidden")}function validateVlanId(e,t){if(!e||""===e.trim())return!0;let d=parseInt(e.trim());if(isNaN(d)){let n=document.getElementById(t);return n&&(n.textContent="VLAN ID must be a valid integer",n.classList.remove("hidden")),!1}if(d<1||d>4094){let l=document.getElementById(t);return l&&(l.textContent="VLAN ID must be between 1 and 4094",l.classList.remove("hidden")),!1}let i=document.getElementById(t);return i&&i.classList.add("hidden"),!0}function validateSubnetForm(){let e=document.getElementById("add-subnet-cidr"),t=document.getElementById("cidr-error"),d=e.value.trim();if(!/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(d))return t.textContent="Invalid CIDR format. Use format like 192.168.1.0/24",t.classList.remove("hidden"),!1;let n=d.split("/");if(2===n.length){let l=parseInt(n[1]);if(l<24||l>32)return t.textContent="Subnet must be /24 or smaller (e.g., /24, /25, ... /32)",t.classList.remove("hidden"),!1}t.classList.add("hidden");let i=document.getElementById("add-subnet-vlan-id");return!!validateVlanId(i.value,"vlan-id-error")}function validateEditSubnetForm(){let e=document.getElementById("edit-subnet-cidr"),t=document.getElementById("edit-cidr-error"),d=e.value.trim();if(!/^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(d))return t.textContent="Invalid CIDR format. Use format like 192.168.1.0/24",t.classList.remove("hidden"),!1;let n=d.split("/");if(2===n.length){let l=parseInt(n[1]);if(l<24||l>32)return t.textContent="Subnet must be /24 or smaller (e.g., /24, /25, ... /32)",t.classList.remove("hidden"),!1}t.classList.add("hidden");let i=document.getElementById("edit-subnet-vlan-id");return!!validateVlanId(i.value,"edit-vlan-id-error")}window.onclick=function(e){let t=document.getElementById("add-subnet-modal"),d=document.getElementById("edit-subnet-modal");e.target===t&&closeAddSubnetModal(),e.target===d&&closeEditSubnetModal()};
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Filter toggle functionality
|
|
||||||
const filterToggle = document.getElementById('filter-toggle');
|
|
||||||
const filterForm = document.getElementById('audit-filter-form');
|
|
||||||
const filterArrow = document.getElementById('filter-arrow');
|
|
||||||
|
|
||||||
if (filterToggle && filterForm && filterArrow) {
|
|
||||||
filterToggle.addEventListener('click', function() {
|
|
||||||
filterForm.classList.toggle('hidden');
|
|
||||||
// Toggle rotation using inline style for better compatibility
|
|
||||||
if (filterForm.classList.contains('hidden')) {
|
|
||||||
filterArrow.style.transform = 'rotate(0deg)';
|
|
||||||
} else {
|
|
||||||
filterArrow.style.transform = 'rotate(180deg)';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set initial arrow rotation if form is visible (has active filters or expand_filters param)
|
|
||||||
if (!filterForm.classList.contains('hidden')) {
|
|
||||||
filterArrow.style.transform = 'rotate(180deg)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format timestamps
|
|
||||||
document.querySelectorAll('td[data-utc]').forEach(function(td) {
|
|
||||||
const utc = td.getAttribute('data-utc');
|
|
||||||
if (utc) {
|
|
||||||
const date = new Date(utc + 'Z');
|
|
||||||
td.textContent = date.toLocaleString();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Parse and display visual diffs
|
|
||||||
document.querySelectorAll('.diff-container').forEach(function(container) {
|
|
||||||
const details = container.getAttribute('data-details');
|
|
||||||
if (!details) return;
|
|
||||||
|
|
||||||
// Try to parse common change patterns
|
|
||||||
let html = details;
|
|
||||||
|
|
||||||
// Pattern 1: "Changed X from 'old' to 'new'"
|
|
||||||
html = html.replace(/Changed (.+?) from ['"](.+?)['"] to ['"](.+?)['"]/gi, function(match, field, oldVal, newVal) {
|
|
||||||
return `Changed ${field} from <span class="diff-removed">${oldVal}</span> to <span class="diff-added">${newVal}</span>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pattern 2: "Renamed X to Y"
|
|
||||||
html = html.replace(/Renamed (.+?) to ['"](.+?)['"]/gi, function(match, oldVal, newVal) {
|
|
||||||
return `Renamed <span class="diff-removed">${oldVal}</span> to <span class="diff-added">${newVal}</span>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pattern 3: "Updated X: old -> new"
|
|
||||||
html = html.replace(/Updated (.+?):\s*(.+?)\s*->\s*(.+?)(?:\s|$)/gi, function(match, field, oldVal, newVal) {
|
|
||||||
return `Updated ${field}: <span class="diff-removed">${oldVal}</span> → <span class="diff-added">${newVal}</span>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pattern 4: "Set X to Y" (when it was previously something else, look for context)
|
|
||||||
html = html.replace(/Set (.+?) to ['"](.+?)['"]/gi, function(match, field, newVal) {
|
|
||||||
return `Set ${field} to <span class="diff-added">${newVal}</span>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pattern 5: "Removed X" or "Deleted X"
|
|
||||||
html = html.replace(/(Removed|Deleted) ['"](.+?)['"]/gi, function(match, action, val) {
|
|
||||||
return `${action} <span class="diff-removed">${val}</span>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pattern 6: "Added X"
|
|
||||||
html = html.replace(/Added ['"](.+?)['"]/gi, function(match, val) {
|
|
||||||
return `Added <span class="diff-added">${val}</span>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pattern 7: "Assigned X to Y" or "Unassigned X from Y"
|
|
||||||
// Capture everything after "to " or "from " to preserve all spaces in target
|
|
||||||
html = html.replace(/(Assigned|Unassigned) (.+?) (to|from) (.+)$/gi, function(match, action, item, prep, target) {
|
|
||||||
const actionClass = action === 'Assigned' ? 'diff-added' : 'diff-removed';
|
|
||||||
// Preserve the space between prep and target
|
|
||||||
return `${action} <span class="${actionClass}">${item}</span> ${prep} ${target}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pattern 8: Generic "from X to Y" pattern
|
|
||||||
html = html.replace(/from ['"](.+?)['"] to ['"](.+?)['"]/gi, function(match, oldVal, newVal) {
|
|
||||||
return `from <span class="diff-removed">${oldVal}</span> to <span class="diff-added">${newVal}</span>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
container.innerHTML = html || details;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Export button handler
|
|
||||||
const exportBtn = document.getElementById('export-btn');
|
|
||||||
if (exportBtn) {
|
|
||||||
exportBtn.addEventListener('click', function() {
|
|
||||||
const form = document.getElementById('audit-filter-form');
|
|
||||||
const formData = new FormData(form);
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
|
|
||||||
// Add all form fields to params
|
|
||||||
for (const [key, value] of formData.entries()) {
|
|
||||||
if (value) {
|
|
||||||
if (key === 'user_ids') {
|
|
||||||
// Handle multiple user_ids
|
|
||||||
params.append('user_ids', value);
|
|
||||||
} else {
|
|
||||||
params.append(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle multiple user_ids separately
|
|
||||||
const userSelect = form.querySelector('select[name="user_ids"]');
|
|
||||||
if (userSelect) {
|
|
||||||
const selectedUsers = Array.from(userSelect.selectedOptions).map(opt => opt.value);
|
|
||||||
params.delete('user_ids');
|
|
||||||
selectedUsers.forEach(userId => {
|
|
||||||
params.append('user_ids', userId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect to export endpoint
|
|
||||||
window.location.href = '/audit/export_csv?' + params.toString();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("filter-toggle"),t=document.getElementById("audit-filter-form"),n=document.getElementById("filter-arrow");e&&t&&n&&(e.addEventListener("click",function(){t.classList.toggle("hidden"),t.classList.contains("hidden")?n.style.transform="rotate(0deg)":n.style.transform="rotate(180deg)"}),t.classList.contains("hidden")||(n.style.transform="rotate(180deg)")),document.querySelectorAll("td[data-utc]").forEach(function(e){let t=e.getAttribute("data-utc");if(t){let n=new Date(t+"Z");e.textContent=n.toLocaleString()}}),document.querySelectorAll(".diff-container").forEach(function(e){let t=e.getAttribute("data-details");if(!t)return;let n=t;n=(n=(n=(n=(n=(n=(n=(n=n.replace(/Changed (.+?) from ['"](.+?)['"] to ['"](.+?)['"]/gi,function(e,t,n,d){return`Changed ${t} from <span class="diff-removed">${n}</span> to <span class="diff-added">${d}</span>`})).replace(/Renamed (.+?) to ['"](.+?)['"]/gi,function(e,t,n){return`Renamed <span class="diff-removed">${t}</span> to <span class="diff-added">${n}</span>`})).replace(/Updated (.+?):\s*(.+?)\s*->\s*(.+?)(?:\s|$)/gi,function(e,t,n,d){return`Updated ${t}: <span class="diff-removed">${n}</span> → <span class="diff-added">${d}</span>`})).replace(/Set (.+?) to ['"](.+?)['"]/gi,function(e,t,n){return`Set ${t} to <span class="diff-added">${n}</span>`})).replace(/(Removed|Deleted) ['"](.+?)['"]/gi,function(e,t,n){return`${t} <span class="diff-removed">${n}</span>`})).replace(/Added ['"](.+?)['"]/gi,function(e,t){return`Added <span class="diff-added">${t}</span>`})).replace(/(Assigned|Unassigned) (.+?) (to|from) (.+)$/gi,function(e,t,n,d,a){return`${t} <span class="${"Assigned"===t?"diff-added":"diff-removed"}">${n}</span> ${d} ${a}`})).replace(/from ['"](.+?)['"] to ['"](.+?)['"]/gi,function(e,t,n){return`from <span class="diff-removed">${t}</span> to <span class="diff-added">${n}</span>`}),e.innerHTML=n||t});let d=document.getElementById("export-btn");d&&d.addEventListener("click",function(){let e=document.getElementById("audit-filter-form"),t=new FormData(e),n=new URLSearchParams;for(let[d,a]of t.entries())a&&("user_ids"===d?n.append("user_ids",a):n.append(d,a));let s=e.querySelector('select[name="user_ids"]');if(s){let r=Array.from(s.selectedOptions).map(e=>e.value);n.delete("user_ids"),r.forEach(e=>{n.append("user_ids",e)})}window.location.href="/audit/export_csv?"+n.toString()})});
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
function showTab(tabName) {
|
|
||||||
// Hide all panels
|
|
||||||
document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.add('hidden'));
|
|
||||||
|
|
||||||
// Update all tab buttons to inactive state
|
|
||||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
||||||
btn.classList.remove('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
|
|
||||||
btn.classList.add('border-transparent', 'text-gray-500');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show selected panel
|
|
||||||
document.getElementById('panel-' + tabName).classList.remove('hidden');
|
|
||||||
|
|
||||||
// Update selected tab to active state
|
|
||||||
const activeTab = document.getElementById('tab-' + tabName);
|
|
||||||
activeTab.classList.remove('border-transparent', 'text-gray-500');
|
|
||||||
activeTab.classList.add('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Update selected IP count
|
|
||||||
document.getElementById('bulk-ip-select')?.addEventListener('change', function() {
|
|
||||||
document.getElementById('selected-ip-count').textContent = this.selectedOptions.length;
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('bulk-tag-device-select')?.addEventListener('change', function() {
|
|
||||||
document.getElementById('selected-tag-device-count').textContent = this.selectedOptions.length;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load available IPs when subnet changes
|
|
||||||
document.getElementById('bulk-subnet-select')?.addEventListener('change', function() {
|
|
||||||
const subnetId = this.value;
|
|
||||||
const ipSelect = document.getElementById('bulk-ip-select');
|
|
||||||
if (!subnetId) {
|
|
||||||
ipSelect.innerHTML = '<option value="" disabled>Select a subnet first...</option>';
|
|
||||||
document.getElementById('selected-ip-count').textContent = '0';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ipSelect.innerHTML = '<option value="" disabled>Loading...</option>';
|
|
||||||
fetch(`/get_available_ips?subnet_id=${subnetId}`)
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
ipSelect.innerHTML = '';
|
|
||||||
if (data.available_ips.length === 0) {
|
|
||||||
ipSelect.innerHTML = '<option value="" disabled>No available IPs in this subnet</option>';
|
|
||||||
} else {
|
|
||||||
data.available_ips.forEach(ip => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = ip.id;
|
|
||||||
option.textContent = ip.ip;
|
|
||||||
ipSelect.appendChild(option);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
document.getElementById('selected-ip-count').textContent = '0';
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
ipSelect.innerHTML = '<option value="" disabled>Error loading IPs</option>';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Bulk IP Assignment
|
|
||||||
document.getElementById('bulk-assign-ips-form')?.addEventListener('submit', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const formData = new FormData(this);
|
|
||||||
const resultDiv = document.getElementById('assign-ips-result');
|
|
||||||
resultDiv.classList.remove('hidden');
|
|
||||||
resultDiv.innerHTML = '<p class="text-blue-500">Processing...</p>';
|
|
||||||
|
|
||||||
fetch('/bulk/assign_ips', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
let html = '<div class="space-y-2">';
|
|
||||||
if (data.success.length > 0) {
|
|
||||||
html += `<div class="text-green-600 dark:text-green-400"><strong>Successfully assigned ${data.success.length} IP(s):</strong><ul class="list-disc list-inside mt-2">`;
|
|
||||||
data.success.forEach(item => {
|
|
||||||
html += `<li>${item.ip}</li>`;
|
|
||||||
});
|
|
||||||
html += '</ul></div>';
|
|
||||||
}
|
|
||||||
if (data.failed.length > 0) {
|
|
||||||
html += `<div class="text-red-600 dark:text-red-400"><strong>Failed ${data.failed.length} assignment(s):</strong><ul class="list-disc list-inside mt-2">`;
|
|
||||||
data.failed.forEach(item => {
|
|
||||||
const ipDisplay = item.ip ? ` (${item.ip})` : '';
|
|
||||||
html += `<li>IP ID ${item.ip_id}${ipDisplay}: ${item.reason}</li>`;
|
|
||||||
});
|
|
||||||
html += '</ul></div>';
|
|
||||||
}
|
|
||||||
html += '</div>';
|
|
||||||
resultDiv.innerHTML = html;
|
|
||||||
// Reload IP list if successful
|
|
||||||
if (data.success.length > 0) {
|
|
||||||
const subnetSelect = document.getElementById('bulk-subnet-select');
|
|
||||||
if (subnetSelect.value) {
|
|
||||||
subnetSelect.dispatchEvent(new Event('change'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
resultDiv.innerHTML = `<p class="text-red-600">Error: ${error.message}</p>`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Bulk Device Creation
|
|
||||||
document.getElementById('bulk-create-devices-form')?.addEventListener('submit', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const formData = new FormData(this);
|
|
||||||
const resultDiv = document.getElementById('create-devices-result');
|
|
||||||
resultDiv.classList.remove('hidden');
|
|
||||||
resultDiv.innerHTML = '<p class="text-blue-500">Processing...</p>';
|
|
||||||
|
|
||||||
fetch('/bulk/create_devices', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
let html = '<div class="space-y-2">';
|
|
||||||
if (data.success.length > 0) {
|
|
||||||
html += `<div class="text-green-600 dark:text-green-400"><strong>Successfully created ${data.success.length} device(s):</strong><ul class="list-disc list-inside mt-2">`;
|
|
||||||
data.success.forEach(item => {
|
|
||||||
html += `<li>${item.name}</li>`;
|
|
||||||
});
|
|
||||||
html += '</ul></div>';
|
|
||||||
}
|
|
||||||
if (data.failed.length > 0) {
|
|
||||||
html += `<div class="text-red-600 dark:text-red-400"><strong>Failed ${data.failed.length} creation(s):</strong><ul class="list-disc list-inside mt-2">`;
|
|
||||||
data.failed.forEach(item => {
|
|
||||||
html += `<li>${item.name}: ${item.reason}</li>`;
|
|
||||||
});
|
|
||||||
html += '</ul></div>';
|
|
||||||
}
|
|
||||||
html += '</div>';
|
|
||||||
resultDiv.innerHTML = html;
|
|
||||||
if (data.success.length > 0) {
|
|
||||||
setTimeout(() => window.location.reload(), 2000);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
resultDiv.innerHTML = `<p class="text-red-600">Error: ${error.message}</p>`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Bulk Tag Assignment
|
|
||||||
document.getElementById('bulk-assign-tags-form')?.addEventListener('submit', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const formData = new FormData(this);
|
|
||||||
const resultDiv = document.getElementById('assign-tags-result');
|
|
||||||
resultDiv.classList.remove('hidden');
|
|
||||||
resultDiv.innerHTML = '<p class="text-blue-500">Processing...</p>';
|
|
||||||
|
|
||||||
fetch('/bulk/assign_tags', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
let html = '<div class="space-y-2">';
|
|
||||||
if (data.success.length > 0) {
|
|
||||||
html += `<div class="text-green-600 dark:text-green-400"><strong>Successfully assigned ${data.success.length} tag(s):</strong><ul class="list-disc list-inside mt-2">`;
|
|
||||||
data.success.forEach(item => {
|
|
||||||
html += `<li>${item.device_name}: ${item.tag_name}</li>`;
|
|
||||||
});
|
|
||||||
html += '</ul></div>';
|
|
||||||
}
|
|
||||||
if (data.failed.length > 0) {
|
|
||||||
html += `<div class="text-red-600 dark:text-red-400"><strong>Failed ${data.failed.length} assignment(s):</strong><ul class="list-disc list-inside mt-2">`;
|
|
||||||
data.failed.forEach(item => {
|
|
||||||
html += `<li>Device ID ${item.device_id}, Tag ID ${item.tag_id}: ${item.reason}</li>`;
|
|
||||||
});
|
|
||||||
html += '</ul></div>';
|
|
||||||
}
|
|
||||||
html += '</div>';
|
|
||||||
resultDiv.innerHTML = html;
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
resultDiv.innerHTML = `<p class="text-red-600">Error: ${error.message}</p>`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
function showTab(e){document.querySelectorAll(".tab-panel").forEach(e=>e.classList.add("hidden")),document.querySelectorAll(".tab-btn").forEach(e=>{e.classList.remove("border-gray-600","text-gray-900","dark:text-gray-100"),e.classList.add("border-transparent","text-gray-500")}),document.getElementById("panel-"+e).classList.remove("hidden");let t=document.getElementById("tab-"+e);t.classList.remove("border-transparent","text-gray-500"),t.classList.add("border-gray-600","text-gray-900","dark:text-gray-100")}document.addEventListener("DOMContentLoaded",function(){document.getElementById("bulk-ip-select")?.addEventListener("change",function(){document.getElementById("selected-ip-count").textContent=this.selectedOptions.length}),document.getElementById("bulk-tag-device-select")?.addEventListener("change",function(){document.getElementById("selected-tag-device-count").textContent=this.selectedOptions.length}),document.getElementById("bulk-subnet-select")?.addEventListener("change",function(){let e=this.value,t=document.getElementById("bulk-ip-select");if(!e){t.innerHTML='<option value="" disabled>Select a subnet first...</option>',document.getElementById("selected-ip-count").textContent="0";return}t.innerHTML='<option value="" disabled>Loading...</option>',fetch(`/get_available_ips?subnet_id=${e}`).then(e=>e.json()).then(e=>{t.innerHTML="",0===e.available_ips.length?t.innerHTML='<option value="" disabled>No available IPs in this subnet</option>':e.available_ips.forEach(e=>{let s=document.createElement("option");s.value=e.id,s.textContent=e.ip,t.appendChild(s)}),document.getElementById("selected-ip-count").textContent="0"}).catch(()=>{t.innerHTML='<option value="" disabled>Error loading IPs</option>'})}),document.getElementById("bulk-assign-ips-form")?.addEventListener("submit",function(e){e.preventDefault();let t=new FormData(this),s=document.getElementById("assign-ips-result");s.classList.remove("hidden"),s.innerHTML='<p class="text-blue-500">Processing...</p>',fetch("/bulk/assign_ips",{method:"POST",body:t}).then(e=>e.json()).then(e=>{let t='<div class="space-y-2">';if(e.success.length>0&&(t+=`<div class="text-green-600 dark:text-green-400"><strong>Successfully assigned ${e.success.length} IP(s):</strong><ul class="list-disc list-inside mt-2">`,e.success.forEach(e=>{t+=`<li>${e.ip}</li>`}),t+="</ul></div>"),e.failed.length>0&&(t+=`<div class="text-red-600 dark:text-red-400"><strong>Failed ${e.failed.length} assignment(s):</strong><ul class="list-disc list-inside mt-2">`,e.failed.forEach(e=>{let s=e.ip?` (${e.ip})`:"";t+=`<li>IP ID ${e.ip_id}${s}: ${e.reason}</li>`}),t+="</ul></div>"),t+="</div>",s.innerHTML=t,e.success.length>0){let n=document.getElementById("bulk-subnet-select");n.value&&n.dispatchEvent(new Event("change"))}}).catch(e=>{s.innerHTML=`<p class="text-red-600">Error: ${e.message}</p>`})}),document.getElementById("bulk-create-devices-form")?.addEventListener("submit",function(e){e.preventDefault();let t=new FormData(this),s=document.getElementById("create-devices-result");s.classList.remove("hidden"),s.innerHTML='<p class="text-blue-500">Processing...</p>',fetch("/bulk/create_devices",{method:"POST",body:t}).then(e=>e.json()).then(e=>{let t='<div class="space-y-2">';e.success.length>0&&(t+=`<div class="text-green-600 dark:text-green-400"><strong>Successfully created ${e.success.length} device(s):</strong><ul class="list-disc list-inside mt-2">`,e.success.forEach(e=>{t+=`<li>${e.name}</li>`}),t+="</ul></div>"),e.failed.length>0&&(t+=`<div class="text-red-600 dark:text-red-400"><strong>Failed ${e.failed.length} creation(s):</strong><ul class="list-disc list-inside mt-2">`,e.failed.forEach(e=>{t+=`<li>${e.name}: ${e.reason}</li>`}),t+="</ul></div>"),t+="</div>",s.innerHTML=t,e.success.length>0&&setTimeout(()=>window.location.reload(),2e3)}).catch(e=>{s.innerHTML=`<p class="text-red-600">Error: ${e.message}</p>`})}),document.getElementById("bulk-assign-tags-form")?.addEventListener("submit",function(e){e.preventDefault();let t=new FormData(this),s=document.getElementById("assign-tags-result");s.classList.remove("hidden"),s.innerHTML='<p class="text-blue-500">Processing...</p>',fetch("/bulk/assign_tags",{method:"POST",body:t}).then(e=>e.json()).then(e=>{let t='<div class="space-y-2">';e.success.length>0&&(t+=`<div class="text-green-600 dark:text-green-400"><strong>Successfully assigned ${e.success.length} tag(s):</strong><ul class="list-disc list-inside mt-2">`,e.success.forEach(e=>{t+=`<li>${e.device_name}: ${e.tag_name}</li>`}),t+="</ul></div>"),e.failed.length>0&&(t+=`<div class="text-red-600 dark:text-red-400"><strong>Failed ${e.failed.length} assignment(s):</strong><ul class="list-disc list-inside mt-2">`,e.failed.forEach(e=>{t+=`<li>Device ID ${e.device_id}, Tag ID ${e.tag_id}: ${e.reason}</li>`}),t+="</ul></div>"),t+="</div>",s.innerHTML=t}).catch(e=>{s.innerHTML=`<p class="text-red-600">Error: ${e.message}</p>`})})});
|
|
||||||
@@ -1,326 +0,0 @@
|
|||||||
// Custom Fields Management JavaScript
|
|
||||||
|
|
||||||
// Get initial tab from URL parameter or default to 'device'
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
let currentTab = urlParams.get('tab') || 'device';
|
|
||||||
if (currentTab !== 'device' && currentTab !== 'subnet') {
|
|
||||||
currentTab = 'device';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Switch to the correct tab on page load
|
|
||||||
if (currentTab === 'subnet') {
|
|
||||||
switchTab('subnet');
|
|
||||||
} else {
|
|
||||||
// Ensure device tab is active on load
|
|
||||||
switchTab('device');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to get current active tab
|
|
||||||
function getCurrentTab() {
|
|
||||||
return currentTab;
|
|
||||||
}
|
|
||||||
|
|
||||||
let fieldData = {};
|
|
||||||
|
|
||||||
// Tab switching
|
|
||||||
function switchTab(entityType) {
|
|
||||||
currentTab = entityType;
|
|
||||||
|
|
||||||
// Update tab buttons
|
|
||||||
document.getElementById('tab-device').classList.remove('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
|
|
||||||
document.getElementById('tab-device').classList.add('border-transparent', 'text-gray-500');
|
|
||||||
document.getElementById('tab-subnet').classList.remove('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
|
|
||||||
document.getElementById('tab-subnet').classList.add('border-transparent', 'text-gray-500');
|
|
||||||
|
|
||||||
if (entityType === 'device') {
|
|
||||||
document.getElementById('tab-device').classList.remove('border-transparent', 'text-gray-500');
|
|
||||||
document.getElementById('tab-device').classList.add('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
|
|
||||||
document.getElementById('device-fields-tab').classList.remove('hidden');
|
|
||||||
document.getElementById('subnet-fields-tab').classList.add('hidden');
|
|
||||||
} else {
|
|
||||||
document.getElementById('tab-subnet').classList.remove('border-transparent', 'text-gray-500');
|
|
||||||
document.getElementById('tab-subnet').classList.add('border-gray-600', 'text-gray-900', 'dark:text-gray-100');
|
|
||||||
document.getElementById('device-fields-tab').classList.add('hidden');
|
|
||||||
document.getElementById('subnet-fields-tab').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update URL without reloading page
|
|
||||||
const newUrl = new URL(window.location);
|
|
||||||
newUrl.searchParams.set('tab', entityType);
|
|
||||||
window.history.pushState({}, '', newUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show add field modal
|
|
||||||
function showAddFieldModal(entityType) {
|
|
||||||
// Determine the target entity type - prioritize explicit parameter, then read from DOM
|
|
||||||
let targetEntityType = entityType;
|
|
||||||
|
|
||||||
if (!targetEntityType) {
|
|
||||||
// Read from active tab button - check which tab has the active styling
|
|
||||||
const deviceTab = document.getElementById('tab-device');
|
|
||||||
const subnetTab = document.getElementById('tab-subnet');
|
|
||||||
|
|
||||||
if (deviceTab && deviceTab.classList.contains('border-gray-600')) {
|
|
||||||
targetEntityType = 'device';
|
|
||||||
} else if (subnetTab && subnetTab.classList.contains('border-gray-600')) {
|
|
||||||
targetEntityType = 'subnet';
|
|
||||||
} else {
|
|
||||||
// Fallback to currentTab variable
|
|
||||||
targetEntityType = currentTab || 'device';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure targetEntityType is valid
|
|
||||||
if (targetEntityType !== 'device' && targetEntityType !== 'subnet') {
|
|
||||||
targetEntityType = 'device';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure we're on the correct tab
|
|
||||||
if (targetEntityType !== currentTab) {
|
|
||||||
switchTab(targetEntityType);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('modal-title').textContent = 'Add Custom Field';
|
|
||||||
document.getElementById('form-action').value = 'add_field';
|
|
||||||
document.getElementById('form-field-id').value = '';
|
|
||||||
|
|
||||||
// Always set entity_type explicitly - double check it's set
|
|
||||||
const entityTypeInput = document.getElementById('form-entity-type');
|
|
||||||
entityTypeInput.value = targetEntityType;
|
|
||||||
|
|
||||||
// Debug: log to verify
|
|
||||||
console.log('Opening modal for entity type:', targetEntityType, 'currentTab:', currentTab, 'input value:', entityTypeInput.value);
|
|
||||||
|
|
||||||
// Reset form
|
|
||||||
document.getElementById('field-name').value = '';
|
|
||||||
document.getElementById('field-key').value = '';
|
|
||||||
document.getElementById('field-type').value = 'text';
|
|
||||||
document.getElementById('field-required').checked = false;
|
|
||||||
document.getElementById('field-default-value').value = '';
|
|
||||||
document.getElementById('field-help-text').value = '';
|
|
||||||
document.getElementById('field-display-order').value = '0';
|
|
||||||
|
|
||||||
// Reset validation fields
|
|
||||||
document.getElementById('field-min-length').value = '';
|
|
||||||
document.getElementById('field-max-length').value = '';
|
|
||||||
document.getElementById('field-regex-pattern').value = '';
|
|
||||||
document.getElementById('field-min-value').value = '';
|
|
||||||
document.getElementById('field-max-value').value = '';
|
|
||||||
document.getElementById('field-select-options').value = '';
|
|
||||||
|
|
||||||
updateFieldTypeOptions();
|
|
||||||
document.getElementById('field-modal').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close field modal
|
|
||||||
function closeFieldModal() {
|
|
||||||
document.getElementById('field-modal').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update field type options visibility
|
|
||||||
function updateFieldTypeOptions() {
|
|
||||||
const fieldType = document.getElementById('field-type').value;
|
|
||||||
|
|
||||||
// Hide all validation sections
|
|
||||||
document.getElementById('text-validation').classList.add('hidden');
|
|
||||||
document.getElementById('number-validation').classList.add('hidden');
|
|
||||||
document.getElementById('select-validation').classList.add('hidden');
|
|
||||||
|
|
||||||
// Show relevant validation section
|
|
||||||
if (fieldType === 'text' || fieldType === 'textarea') {
|
|
||||||
document.getElementById('text-validation').classList.remove('hidden');
|
|
||||||
} else if (fieldType === 'number' || fieldType === 'decimal') {
|
|
||||||
document.getElementById('number-validation').classList.remove('hidden');
|
|
||||||
} else if (fieldType === 'select') {
|
|
||||||
document.getElementById('select-validation').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-generate field key from name
|
|
||||||
function generateFieldKey(name) {
|
|
||||||
return name.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, '_')
|
|
||||||
.replace(/^_+|_+$/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edit field
|
|
||||||
function editField(fieldId, entityType) {
|
|
||||||
// Get field data from embedded JSON
|
|
||||||
const fieldsDataElement = document.getElementById('fields-data');
|
|
||||||
if (!fieldsDataElement) {
|
|
||||||
console.error('Fields data not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fieldsData = JSON.parse(fieldsDataElement.textContent);
|
|
||||||
const fields = fieldsData[entityType] || [];
|
|
||||||
const field = fields.find(f => f.id === fieldId);
|
|
||||||
|
|
||||||
if (field) {
|
|
||||||
populateEditForm(field, entityType);
|
|
||||||
} else {
|
|
||||||
console.error('Field not found:', fieldId, entityType);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing fields data:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function populateEditForm(field, entityType) {
|
|
||||||
document.getElementById('modal-title').textContent = 'Edit Custom Field';
|
|
||||||
document.getElementById('form-action').value = 'edit_field';
|
|
||||||
document.getElementById('form-field-id').value = field.id;
|
|
||||||
document.getElementById('form-entity-type').value = entityType;
|
|
||||||
|
|
||||||
document.getElementById('field-name').value = field.name || '';
|
|
||||||
document.getElementById('field-key').value = field.field_key || '';
|
|
||||||
document.getElementById('field-type').value = field.field_type || 'text';
|
|
||||||
document.getElementById('field-required').checked = field.required || false;
|
|
||||||
document.getElementById('field-default-value').value = field.default_value || '';
|
|
||||||
document.getElementById('field-help-text').value = field.help_text || '';
|
|
||||||
document.getElementById('field-display-order').value = field.display_order || 0;
|
|
||||||
|
|
||||||
// Parse validation rules
|
|
||||||
let validationRules = {};
|
|
||||||
if (field.validation_rules) {
|
|
||||||
if (typeof field.validation_rules === 'string') {
|
|
||||||
try {
|
|
||||||
validationRules = JSON.parse(field.validation_rules);
|
|
||||||
} catch (e) {
|
|
||||||
validationRules = {};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
validationRules = field.validation_rules;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate validation fields
|
|
||||||
document.getElementById('field-min-length').value = validationRules.min_length || '';
|
|
||||||
document.getElementById('field-max-length').value = validationRules.max_length || '';
|
|
||||||
document.getElementById('field-regex-pattern').value = validationRules.regex_pattern || '';
|
|
||||||
document.getElementById('field-min-value').value = validationRules.min_value || '';
|
|
||||||
document.getElementById('field-max-value').value = validationRules.max_value || '';
|
|
||||||
|
|
||||||
if (validationRules.select_options) {
|
|
||||||
document.getElementById('field-select-options').value = validationRules.select_options.join(', ');
|
|
||||||
} else {
|
|
||||||
document.getElementById('field-select-options').value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFieldTypeOptions();
|
|
||||||
document.getElementById('field-modal').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move field up/down
|
|
||||||
function moveField(entityType, fieldId, direction) {
|
|
||||||
// Get all fields for this entity type
|
|
||||||
const tbody = document.getElementById(`${entityType}-fields-tbody`);
|
|
||||||
const rows = Array.from(tbody.querySelectorAll('tr'));
|
|
||||||
const currentIndex = rows.findIndex(row => row.dataset.fieldId == fieldId);
|
|
||||||
|
|
||||||
if (currentIndex === -1) return;
|
|
||||||
|
|
||||||
let targetIndex;
|
|
||||||
if (direction === 'up' && currentIndex > 0) {
|
|
||||||
targetIndex = currentIndex - 1;
|
|
||||||
} else if (direction === 'down' && currentIndex < rows.length - 1) {
|
|
||||||
targetIndex = currentIndex + 1;
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Swap rows
|
|
||||||
const currentRow = rows[currentIndex];
|
|
||||||
const targetRow = rows[targetIndex];
|
|
||||||
tbody.insertBefore(currentRow, direction === 'up' ? targetRow : targetRow.nextSibling);
|
|
||||||
|
|
||||||
// Update display orders and submit
|
|
||||||
const fieldOrders = {};
|
|
||||||
Array.from(tbody.querySelectorAll('tr')).forEach((row, index) => {
|
|
||||||
fieldOrders[row.dataset.fieldId] = index;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Submit reorder
|
|
||||||
const form = document.createElement('form');
|
|
||||||
form.method = 'POST';
|
|
||||||
form.action = '/custom_fields';
|
|
||||||
|
|
||||||
const actionInput = document.createElement('input');
|
|
||||||
actionInput.type = 'hidden';
|
|
||||||
actionInput.name = 'action';
|
|
||||||
actionInput.value = 'reorder';
|
|
||||||
form.appendChild(actionInput);
|
|
||||||
|
|
||||||
const entityTypeInput = document.createElement('input');
|
|
||||||
entityTypeInput.type = 'hidden';
|
|
||||||
entityTypeInput.name = 'entity_type';
|
|
||||||
entityTypeInput.value = entityType;
|
|
||||||
form.appendChild(entityTypeInput);
|
|
||||||
|
|
||||||
const ordersInput = document.createElement('input');
|
|
||||||
ordersInput.type = 'hidden';
|
|
||||||
ordersInput.name = 'field_orders';
|
|
||||||
ordersInput.value = JSON.stringify(fieldOrders);
|
|
||||||
form.appendChild(ordersInput);
|
|
||||||
|
|
||||||
document.body.appendChild(form);
|
|
||||||
form.submit();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event listeners
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Auto-generate field key from name
|
|
||||||
const nameInput = document.getElementById('field-name');
|
|
||||||
const keyInput = document.getElementById('field-key');
|
|
||||||
|
|
||||||
if (nameInput && keyInput) {
|
|
||||||
nameInput.addEventListener('input', function() {
|
|
||||||
// Only auto-generate if key is empty or matches previous generated value
|
|
||||||
if (!keyInput.value || keyInput.dataset.autoGenerated === 'true') {
|
|
||||||
keyInput.value = generateFieldKey(this.value);
|
|
||||||
keyInput.dataset.autoGenerated = 'true';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
keyInput.addEventListener('input', function() {
|
|
||||||
// Mark as manually edited
|
|
||||||
this.dataset.autoGenerated = 'false';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update field type options when type changes
|
|
||||||
const fieldTypeSelect = document.getElementById('field-type');
|
|
||||||
if (fieldTypeSelect) {
|
|
||||||
fieldTypeSelect.addEventListener('change', updateFieldTypeOptions);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure entity_type is set correctly before form submission
|
|
||||||
const fieldForm = document.getElementById('field-form');
|
|
||||||
if (fieldForm) {
|
|
||||||
fieldForm.addEventListener('submit', function(e) {
|
|
||||||
const entityTypeInput = document.getElementById('form-entity-type');
|
|
||||||
// Always ensure entity_type is set to currentTab
|
|
||||||
// This handles cases where the modal was opened without explicitly setting it
|
|
||||||
if (!entityTypeInput.value || entityTypeInput.value.trim() === '') {
|
|
||||||
entityTypeInput.value = currentTab;
|
|
||||||
console.log('Entity type was empty, setting to:', currentTab);
|
|
||||||
}
|
|
||||||
// Double-check it's a valid value
|
|
||||||
if (entityTypeInput.value !== 'device' && entityTypeInput.value !== 'subnet') {
|
|
||||||
entityTypeInput.value = currentTab;
|
|
||||||
console.log('Entity type was invalid, setting to currentTab:', currentTab);
|
|
||||||
}
|
|
||||||
console.log('Submitting form with entity_type:', entityTypeInput.value, 'currentTab:', currentTab);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close modal when clicking outside
|
|
||||||
window.onclick = function(event) {
|
|
||||||
const modal = document.getElementById('field-modal');
|
|
||||||
if (event.target === modal) {
|
|
||||||
closeFieldModal();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Vendored
-1
File diff suppressed because one or more lines are too long
@@ -1,61 +0,0 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const siteSelect = document.getElementById('site-select');
|
|
||||||
const subnetSelect = document.getElementById('subnet-select');
|
|
||||||
const ipSelect = document.getElementById('ip-select');
|
|
||||||
const renameBtn = document.querySelector('.rename-btn');
|
|
||||||
const saveBtn = document.querySelector('.save-btn');
|
|
||||||
const cancelBtn = document.querySelector('.cancel-btn');
|
|
||||||
const nameInput = document.querySelector('input[name="new_name"]');
|
|
||||||
const h1 = document.querySelector('h1');
|
|
||||||
siteSelect.addEventListener('change', function() {
|
|
||||||
const selectedSite = this.value;
|
|
||||||
let firstSubnet = null;
|
|
||||||
Array.from(subnetSelect.options).forEach(option => {
|
|
||||||
if (!option.value) return;
|
|
||||||
if (option.getAttribute('data-site') === selectedSite) {
|
|
||||||
option.style.display = '';
|
|
||||||
if (!firstSubnet) firstSubnet = option.value;
|
|
||||||
} else {
|
|
||||||
option.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
subnetSelect.value = firstSubnet || '';
|
|
||||||
const event = new Event('change', { bubbles: true });
|
|
||||||
subnetSelect.dispatchEvent(event);
|
|
||||||
});
|
|
||||||
subnetSelect.addEventListener('change', function() {
|
|
||||||
const subnetId = this.value;
|
|
||||||
if (!subnetId) {
|
|
||||||
ipSelect.innerHTML = '<option value="" disabled selected>Select IP</option>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fetch(`/get_available_ips?subnet_id=${subnetId}`)
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
ipSelect.innerHTML = '<option value="" disabled selected>Select IP</option>';
|
|
||||||
data.available_ips.forEach(ip => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = ip.id;
|
|
||||||
option.textContent = ip.ip;
|
|
||||||
ipSelect.appendChild(option);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
if (renameBtn && saveBtn && cancelBtn && nameInput && h1) {
|
|
||||||
renameBtn.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
nameInput.classList.remove('hidden');
|
|
||||||
saveBtn.classList.remove('hidden');
|
|
||||||
cancelBtn.classList.remove('hidden');
|
|
||||||
h1.classList.add('hidden');
|
|
||||||
nameInput.focus();
|
|
||||||
});
|
|
||||||
cancelBtn.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
nameInput.classList.add('hidden');
|
|
||||||
saveBtn.classList.add('hidden');
|
|
||||||
cancelBtn.classList.add('hidden');
|
|
||||||
h1.classList.remove('hidden');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("site-select"),t=document.getElementById("subnet-select"),n=document.getElementById("ip-select"),i=document.querySelector(".rename-btn"),l=document.querySelector(".save-btn"),s=document.querySelector(".cancel-btn"),a=document.querySelector('input[name="new_name"]'),d=document.querySelector("h1");e.addEventListener("change",function(){let e=this.value,n=null;Array.from(t.options).forEach(t=>{t.value&&(t.getAttribute("data-site")===e?(t.style.display="",n||(n=t.value)):t.style.display="none")}),t.value=n||"";let i=new Event("change",{bubbles:!0});t.dispatchEvent(i)}),t.addEventListener("change",function(){let e=this.value;if(!e){n.innerHTML='<option value="" disabled selected>Select IP</option>';return}fetch(`/get_available_ips?subnet_id=${e}`).then(e=>e.json()).then(e=>{n.innerHTML='<option value="" disabled selected>Select IP</option>',e.available_ips.forEach(e=>{let t=document.createElement("option");t.value=e.id,t.textContent=e.ip,n.appendChild(t)})})}),i&&l&&s&&a&&d&&(i.addEventListener("click",function(e){e.preventDefault(),a.classList.remove("hidden"),l.classList.remove("hidden"),s.classList.remove("hidden"),d.classList.add("hidden"),a.focus()}),s.addEventListener("click",function(e){e.preventDefault(),a.classList.add("hidden"),l.classList.add("hidden"),s.classList.add("hidden"),d.classList.remove("hidden")}))});
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
// Font Awesome icon search functionality
|
|
||||||
// Common Font Awesome icons for device types
|
|
||||||
const fontAwesomeIcons = [
|
|
||||||
// Network & Server
|
|
||||||
'fa-server', 'fa-router', 'fa-network-wired', 'fa-switch', 'fa-hub', 'fa-ethernet',
|
|
||||||
'fa-satellite-dish', 'fa-broadcast-tower', 'fa-tower-cell', 'fa-wifi', 'fa-network',
|
|
||||||
'fa-project-diagram', 'fa-sitemap', 'fa-diagram-project', 'fa-cloud',
|
|
||||||
|
|
||||||
// Security
|
|
||||||
'fa-shield-halved', 'fa-shield', 'fa-shield-alt', 'fa-firewall', 'fa-lock', 'fa-unlock',
|
|
||||||
'fa-key', 'fa-fingerprint', 'fa-user-shield', 'fa-user-lock',
|
|
||||||
|
|
||||||
// Hardware
|
|
||||||
'fa-print', 'fa-boxes-stacked', 'fa-database', 'fa-hard-drive', 'fa-memory', 'fa-microchip',
|
|
||||||
'fa-cpu', 'fa-usb', 'fa-fan', 'fa-battery-full', 'fa-power-off', 'fa-plug', 'fa-bolt',
|
|
||||||
'fa-lightbulb', 'fa-monitor', 'fa-display', 'fa-tv', 'fa-camera', 'fa-video',
|
|
||||||
|
|
||||||
// Computing
|
|
||||||
'fa-laptop', 'fa-desktop', 'fa-tablet', 'fa-mobile-alt', 'fa-phone', 'fa-keyboard',
|
|
||||||
'fa-mouse', 'fa-microphone', 'fa-headphones', 'fa-speaker',
|
|
||||||
|
|
||||||
// Storage & Files
|
|
||||||
'fa-box', 'fa-package', 'fa-archive', 'fa-folder', 'fa-file', 'fa-hdd', 'fa-ssd',
|
|
||||||
'fa-floppy-disk', 'fa-disk', 'fa-save', 'fa-folder-open', 'fa-folder-plus',
|
|
||||||
|
|
||||||
// Data & Analytics
|
|
||||||
'fa-chart-line', 'fa-chart-bar', 'fa-chart-pie', 'fa-graph', 'fa-analytics',
|
|
||||||
'fa-database', 'fa-file-database', 'fa-file-chart-line', 'fa-file-chart-pie',
|
|
||||||
|
|
||||||
// Location & Infrastructure
|
|
||||||
'fa-globe', 'fa-earth', 'fa-map', 'fa-location', 'fa-map-marker', 'fa-building',
|
|
||||||
'fa-warehouse', 'fa-home', 'fa-office', 'fa-industry',
|
|
||||||
|
|
||||||
// Tools & Utilities
|
|
||||||
'fa-robot', 'fa-cog', 'fa-gear', 'fa-wrench', 'fa-tools', 'fa-question',
|
|
||||||
'fa-code', 'fa-terminal', 'fa-console', 'fa-bug', 'fa-bug-slash',
|
|
||||||
|
|
||||||
// Identification
|
|
||||||
'fa-id-card', 'fa-credit-card', 'fa-qrcode', 'fa-barcode', 'fa-rfid',
|
|
||||||
|
|
||||||
// Transport & Logistics
|
|
||||||
'fa-truck', 'fa-shipping-fast', 'fa-conveyor-belt', 'fa-pallet', 'fa-dolly',
|
|
||||||
'fa-cube', 'fa-cubes', 'fa-layer-group', 'fa-stack',
|
|
||||||
|
|
||||||
// UI & Display
|
|
||||||
'fa-th', 'fa-th-large', 'fa-th-list', 'fa-list', 'fa-list-ul', 'fa-list-ol',
|
|
||||||
'fa-table', 'fa-columns', 'fa-grid', 'fa-window-maximize', 'fa-window-restore',
|
|
||||||
'fa-window-minimize', 'fa-window-close', 'fa-expand', 'fa-compress',
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
'fa-sync', 'fa-sync-alt', 'fa-redo', 'fa-undo', 'fa-refresh', 'fa-download',
|
|
||||||
'fa-upload', 'fa-exchange-alt', 'fa-share', 'fa-link', 'fa-unlink', 'fa-chain',
|
|
||||||
'fa-chain-broken', 'fa-arrows-alt', 'fa-arrows', 'fa-move',
|
|
||||||
|
|
||||||
// Time & Calendar
|
|
||||||
'fa-clock', 'fa-hourglass', 'fa-stopwatch', 'fa-timer', 'fa-calendar',
|
|
||||||
'fa-calendar-alt', 'fa-calendar-check', 'fa-calendar-times', 'fa-history',
|
|
||||||
|
|
||||||
// Media
|
|
||||||
'fa-play', 'fa-pause', 'fa-stop', 'fa-step-backward', 'fa-step-forward',
|
|
||||||
'fa-fast-backward', 'fa-fast-forward', 'fa-eject', 'fa-record-vinyl',
|
|
||||||
'fa-compact-disc', 'fa-cd', 'fa-dvd',
|
|
||||||
|
|
||||||
// Users
|
|
||||||
'fa-user-shield', 'fa-user-lock', 'fa-user-secret', 'fa-user-cog', 'fa-user-gear',
|
|
||||||
'fa-user-tie', 'fa-user-ninja', 'fa-users', 'fa-users-cog', 'fa-user-group',
|
|
||||||
'fa-user-friends', 'fa-user-plus', 'fa-user-minus', 'fa-user-times', 'fa-user-check',
|
|
||||||
'fa-user-xmark', 'fa-user-slash'
|
|
||||||
];
|
|
||||||
|
|
||||||
function initIconSearch() {
|
|
||||||
const iconInputs = document.querySelectorAll('.icon-search-input');
|
|
||||||
|
|
||||||
iconInputs.forEach(input => {
|
|
||||||
const container = input.closest('.icon-search-container');
|
|
||||||
const preview = container.querySelector('.icon-preview');
|
|
||||||
const suggestions = container.querySelector('.icon-suggestions');
|
|
||||||
|
|
||||||
if (!preview || !suggestions) return;
|
|
||||||
|
|
||||||
// Initialize preview if input already has a value
|
|
||||||
if (input.value && input.value.trim()) {
|
|
||||||
const iconClass = input.value.trim().startsWith('fa-') ? input.value.trim() : `fa-${input.value.trim()}`;
|
|
||||||
preview.innerHTML = `<i class="fas ${iconClass}"></i>`;
|
|
||||||
preview.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
input.addEventListener('input', (e) => {
|
|
||||||
const query = e.target.value.toLowerCase().trim();
|
|
||||||
|
|
||||||
// Update preview
|
|
||||||
if (query) {
|
|
||||||
const iconClass = query.startsWith('fa-') ? query : `fa-${query}`;
|
|
||||||
preview.innerHTML = `<i class="fas ${iconClass}"></i>`;
|
|
||||||
preview.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
preview.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter and display suggestions
|
|
||||||
if (query.length > 0) {
|
|
||||||
const filtered = fontAwesomeIcons.filter(icon =>
|
|
||||||
icon.includes(query) || icon.replace('fa-', '').includes(query)
|
|
||||||
).slice(0, 10); // Show top 10 matches
|
|
||||||
|
|
||||||
if (filtered.length > 0) {
|
|
||||||
suggestions.innerHTML = filtered.map(icon => `
|
|
||||||
<div class="icon-suggestion-item" data-icon="${icon}">
|
|
||||||
<i class="fas ${icon}"></i>
|
|
||||||
<span>${icon}</span>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
suggestions.classList.remove('hidden');
|
|
||||||
|
|
||||||
// Add click handlers
|
|
||||||
suggestions.querySelectorAll('.icon-suggestion-item').forEach(item => {
|
|
||||||
item.addEventListener('click', () => {
|
|
||||||
input.value = item.dataset.icon;
|
|
||||||
preview.innerHTML = `<i class="fas ${item.dataset.icon}"></i>`;
|
|
||||||
preview.classList.remove('hidden');
|
|
||||||
suggestions.classList.add('hidden');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
suggestions.classList.add('hidden');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
suggestions.classList.add('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hide suggestions when clicking outside
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (!container.contains(e.target)) {
|
|
||||||
suggestions.classList.add('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update preview on blur if value exists
|
|
||||||
input.addEventListener('blur', () => {
|
|
||||||
const value = input.value.trim();
|
|
||||||
if (value && preview) {
|
|
||||||
const iconClass = value.startsWith('fa-') ? value : `fa-${value}`;
|
|
||||||
preview.innerHTML = `<i class="fas ${iconClass}"></i>`;
|
|
||||||
preview.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize when DOM is ready
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initIconSearch);
|
|
||||||
} else {
|
|
||||||
initIconSearch();
|
|
||||||
}
|
|
||||||
|
|
||||||
Vendored
-6
@@ -1,6 +0,0 @@
|
|||||||
const fontAwesomeIcons=["fa-server","fa-router","fa-network-wired","fa-switch","fa-hub","fa-ethernet","fa-satellite-dish","fa-broadcast-tower","fa-tower-cell","fa-wifi","fa-network","fa-project-diagram","fa-sitemap","fa-diagram-project","fa-cloud","fa-shield-halved","fa-shield","fa-shield-alt","fa-firewall","fa-lock","fa-unlock","fa-key","fa-fingerprint","fa-user-shield","fa-user-lock","fa-print","fa-boxes-stacked","fa-database","fa-hard-drive","fa-memory","fa-microchip","fa-cpu","fa-usb","fa-fan","fa-battery-full","fa-power-off","fa-plug","fa-bolt","fa-lightbulb","fa-monitor","fa-display","fa-tv","fa-camera","fa-video","fa-laptop","fa-desktop","fa-tablet","fa-mobile-alt","fa-phone","fa-keyboard","fa-mouse","fa-microphone","fa-headphones","fa-speaker","fa-box","fa-package","fa-archive","fa-folder","fa-file","fa-hdd","fa-ssd","fa-floppy-disk","fa-disk","fa-save","fa-folder-open","fa-folder-plus","fa-chart-line","fa-chart-bar","fa-chart-pie","fa-graph","fa-analytics","fa-database","fa-file-database","fa-file-chart-line","fa-file-chart-pie","fa-globe","fa-earth","fa-map","fa-location","fa-map-marker","fa-building","fa-warehouse","fa-home","fa-office","fa-industry","fa-robot","fa-cog","fa-gear","fa-wrench","fa-tools","fa-question","fa-code","fa-terminal","fa-console","fa-bug","fa-bug-slash","fa-id-card","fa-credit-card","fa-qrcode","fa-barcode","fa-rfid","fa-truck","fa-shipping-fast","fa-conveyor-belt","fa-pallet","fa-dolly","fa-cube","fa-cubes","fa-layer-group","fa-stack","fa-th","fa-th-large","fa-th-list","fa-list","fa-list-ul","fa-list-ol","fa-table","fa-columns","fa-grid","fa-window-maximize","fa-window-restore","fa-window-minimize","fa-window-close","fa-expand","fa-compress","fa-sync","fa-sync-alt","fa-redo","fa-undo","fa-refresh","fa-download","fa-upload","fa-exchange-alt","fa-share","fa-link","fa-unlink","fa-chain","fa-chain-broken","fa-arrows-alt","fa-arrows","fa-move","fa-clock","fa-hourglass","fa-stopwatch","fa-timer","fa-calendar","fa-calendar-alt","fa-calendar-check","fa-calendar-times","fa-history","fa-play","fa-pause","fa-stop","fa-step-backward","fa-step-forward","fa-fast-backward","fa-fast-forward","fa-eject","fa-record-vinyl","fa-compact-disc","fa-cd","fa-dvd","fa-user-shield","fa-user-lock","fa-user-secret","fa-user-cog","fa-user-gear","fa-user-tie","fa-user-ninja","fa-users","fa-users-cog","fa-user-group","fa-user-friends","fa-user-plus","fa-user-minus","fa-user-times","fa-user-check","fa-user-xmark","fa-user-slash"];function initIconSearch(){let a=document.querySelectorAll(".icon-search-input");a.forEach(a=>{let e=a.closest(".icon-search-container"),f=e.querySelector(".icon-preview"),s=e.querySelector(".icon-suggestions");if(f&&s){if(a.value&&a.value.trim()){let i=a.value.trim().startsWith("fa-")?a.value.trim():`fa-${a.value.trim()}`;f.innerHTML=`<i class="fas ${i}"></i>`,f.classList.remove("hidden")}a.addEventListener("input",e=>{let i=e.target.value.toLowerCase().trim();if(i){let r=i.startsWith("fa-")?i:`fa-${i}`;f.innerHTML=`<i class="fas ${r}"></i>`,f.classList.remove("hidden")}else f.classList.add("hidden");if(i.length>0){let t=fontAwesomeIcons.filter(a=>a.includes(i)||a.replace("fa-","").includes(i)).slice(0,10);t.length>0?(s.innerHTML=t.map(a=>`
|
|
||||||
<div class="icon-suggestion-item" data-icon="${a}">
|
|
||||||
<i class="fas ${a}"></i>
|
|
||||||
<span>${a}</span>
|
|
||||||
</div>
|
|
||||||
`).join(""),s.classList.remove("hidden"),s.querySelectorAll(".icon-suggestion-item").forEach(e=>{e.addEventListener("click",()=>{a.value=e.dataset.icon,f.innerHTML=`<i class="fas ${e.dataset.icon}"></i>`,f.classList.remove("hidden"),s.classList.add("hidden")})})):s.classList.add("hidden")}else s.classList.add("hidden")}),document.addEventListener("click",a=>{e.contains(a.target)||s.classList.add("hidden")}),a.addEventListener("blur",()=>{let e=a.value.trim();if(e&&f){let s=e.startsWith("fa-")?e:`fa-${e}`;f.innerHTML=`<i class="fas ${s}"></i>`,f.classList.remove("hidden")}})}})}"loading"===document.readyState?document.addEventListener("DOMContentLoaded",initIconSearch):initIconSearch();
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
|
|
||||||
// Tag filter functionality
|
|
||||||
const tagFilter = document.getElementById('tag-filter');
|
|
||||||
if (tagFilter) {
|
|
||||||
tagFilter.addEventListener('change', function() {
|
|
||||||
const selectedTag = this.value;
|
|
||||||
if (selectedTag) {
|
|
||||||
window.location.href = '/devices?tag=' + encodeURIComponent(selectedTag);
|
|
||||||
} else {
|
|
||||||
window.location.href = '/devices';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expand/collapse site groups
|
|
||||||
document.querySelectorAll('.site-header').forEach(header => {
|
|
||||||
header.addEventListener('click', function(e) {
|
|
||||||
const deviceList = this.closest('.site-group').querySelector('.device-list');
|
|
||||||
const icon = this.querySelector('.expand-btn i');
|
|
||||||
if (deviceList.classList.contains('hidden')) {
|
|
||||||
deviceList.classList.remove('hidden');
|
|
||||||
icon.classList.remove('fa-chevron-down');
|
|
||||||
icon.classList.add('fa-chevron-up');
|
|
||||||
} else {
|
|
||||||
deviceList.classList.add('hidden');
|
|
||||||
icon.classList.remove('fa-chevron-up');
|
|
||||||
icon.classList.add('fa-chevron-down');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Scroll to Top Button
|
|
||||||
const scrollToTopButton = document.createElement('button');
|
|
||||||
scrollToTopButton.innerHTML = '<i class="fas fa-arrow-up"></i>';
|
|
||||||
scrollToTopButton.style.fontSize = '26px';
|
|
||||||
scrollToTopButton.className = 'fixed bottom-5 right-5 bg-gray-200 dark:bg-zinc-800 text-black dark:text-white p-3 rounded-full shadow-lg hidden';
|
|
||||||
scrollToTopButton.style.width = '60px';
|
|
||||||
scrollToTopButton.style.height = '60px';
|
|
||||||
scrollToTopButton.style.borderRadius = '50%';
|
|
||||||
document.body.appendChild(scrollToTopButton);
|
|
||||||
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.textContent = `
|
|
||||||
@keyframes bob {
|
|
||||||
0%, 100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translateY(-5px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bobbing {
|
|
||||||
animation: bob 1.5s infinite;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
|
|
||||||
scrollToTopButton.classList.add('bobbing');
|
|
||||||
|
|
||||||
window.addEventListener('scroll', () => {
|
|
||||||
if (window.scrollY > 200) {
|
|
||||||
scrollToTopButton.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
scrollToTopButton.classList.add('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
scrollToTopButton.addEventListener('click', () => {
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Vendored
-14
@@ -1,14 +0,0 @@
|
|||||||
document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("tag-filter");e&&e.addEventListener("change",function(){let e=this.value;e?window.location.href="/devices?tag="+encodeURIComponent(e):window.location.href="/devices"}),document.querySelectorAll(".site-header").forEach(e=>{e.addEventListener("click",function(e){let t=this.closest(".site-group").querySelector(".device-list"),s=this.querySelector(".expand-btn i");t.classList.contains("hidden")?(t.classList.remove("hidden"),s.classList.remove("fa-chevron-down"),s.classList.add("fa-chevron-up")):(t.classList.add("hidden"),s.classList.remove("fa-chevron-up"),s.classList.add("fa-chevron-down"))})});let t=document.createElement("button");t.innerHTML='<i class="fas fa-arrow-up"></i>',t.style.fontSize="26px",t.className="fixed bottom-5 right-5 bg-gray-200 dark:bg-zinc-800 text-black dark:text-white p-3 rounded-full shadow-lg hidden",t.style.width="60px",t.style.height="60px",t.style.borderRadius="50%",document.body.appendChild(t);let s=document.createElement("style");s.textContent=`
|
|
||||||
@keyframes bob {
|
|
||||||
0%, 100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translateY(-5px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bobbing {
|
|
||||||
animation: bob 1.5s infinite;
|
|
||||||
}
|
|
||||||
`,document.head.appendChild(s),t.classList.add("bobbing"),window.addEventListener("scroll",()=>{window.scrollY>200?t.classList.remove("hidden"):t.classList.add("hidden")}),t.addEventListener("click",()=>{window.scrollTo({top:0,behavior:"smooth"})})});
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
document.querySelectorAll('.export-csv-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', function(e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
const subnetId = this.getAttribute('data-subnet-id');
|
|
||||||
window.location.href = `/subnet/${subnetId}/export_csv`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
document.querySelectorAll(".export-csv-btn").forEach(t=>{t.addEventListener("click",function(t){t.stopPropagation();let e=this.getAttribute("data-subnet-id");window.location.href=`/subnet/${e}/export_csv`})});
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const navToggle = document.getElementById('nav-toggle');
|
|
||||||
const mobileNav = document.getElementById('mobile-nav');
|
|
||||||
const searchModal = document.getElementById('search-modal');
|
|
||||||
const searchModalOpen = document.getElementById('search-modal-open');
|
|
||||||
const searchModalOpenMobile = document.getElementById('search-modal-open-mobile');
|
|
||||||
const searchModalClose = document.getElementById('search-modal-close');
|
|
||||||
const searchModalBackdrop = document.getElementById('search-modal-backdrop');
|
|
||||||
const searchModalInput = document.getElementById('search-modal-input');
|
|
||||||
|
|
||||||
if (navToggle && mobileNav) {
|
|
||||||
navToggle.addEventListener('click', function() {
|
|
||||||
mobileNav.classList.toggle('hidden');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function openSearchModal() {
|
|
||||||
if (!searchModal) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
searchModal.classList.remove('hidden');
|
|
||||||
searchModal.classList.add('flex');
|
|
||||||
document.body.classList.add('overflow-hidden');
|
|
||||||
|
|
||||||
if (mobileNav) {
|
|
||||||
mobileNav.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(function() {
|
|
||||||
if (searchModalInput) {
|
|
||||||
searchModalInput.focus();
|
|
||||||
searchModalInput.select();
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeSearchModal() {
|
|
||||||
if (!searchModal) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
searchModal.classList.add('hidden');
|
|
||||||
searchModal.classList.remove('flex');
|
|
||||||
document.body.classList.remove('overflow-hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchModalOpen) {
|
|
||||||
searchModalOpen.addEventListener('click', openSearchModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchModalOpenMobile) {
|
|
||||||
searchModalOpenMobile.addEventListener('click', openSearchModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchModalClose) {
|
|
||||||
searchModalClose.addEventListener('click', closeSearchModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchModalBackdrop) {
|
|
||||||
searchModalBackdrop.addEventListener('click', closeSearchModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('click', function(e) {
|
|
||||||
if (mobileNav && navToggle && !mobileNav.contains(e.target) && !navToggle.contains(e.target)) {
|
|
||||||
mobileNav.classList.add('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
const target = e.target;
|
|
||||||
const isEditableTarget = target && (
|
|
||||||
target.tagName === 'INPUT' ||
|
|
||||||
target.tagName === 'TEXTAREA' ||
|
|
||||||
target.tagName === 'SELECT' ||
|
|
||||||
target.isContentEditable
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
e.key === '/' &&
|
|
||||||
!e.ctrlKey &&
|
|
||||||
!e.metaKey &&
|
|
||||||
!e.altKey &&
|
|
||||||
searchModal &&
|
|
||||||
!isEditableTarget
|
|
||||||
) {
|
|
||||||
e.preventDefault();
|
|
||||||
openSearchModal();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
closeSearchModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("nav-toggle"),t=document.getElementById("mobile-nav"),o=document.getElementById("search-modal"),n=document.getElementById("search-modal-open"),d=document.getElementById("search-modal-open-mobile"),c=document.getElementById("search-modal-close"),l=document.getElementById("search-modal-backdrop"),a=document.getElementById("search-modal-input");function s(){o&&(o.classList.remove("hidden"),o.classList.add("flex"),document.body.classList.add("overflow-hidden"),t&&t.classList.add("hidden"),setTimeout(function(){a&&(a.focus(),a.select())},0))}function i(){o&&(o.classList.add("hidden"),o.classList.remove("flex"),document.body.classList.remove("overflow-hidden"))}e&&t&&e.addEventListener("click",function(){t.classList.toggle("hidden")}),n&&n.addEventListener("click",s),d&&d.addEventListener("click",s),c&&c.addEventListener("click",i),l&&l.addEventListener("click",i),document.addEventListener("click",function(o){t&&e&&!t.contains(o.target)&&!e.contains(o.target)&&t.classList.add("hidden")}),document.addEventListener("keydown",function(e){let t=e.target,n=t&&("INPUT"===t.tagName||"TEXTAREA"===t.tagName||"SELECT"===t.tagName||t.isContentEditable);if("/"===e.key&&!e.ctrlKey&&!e.metaKey&&!e.altKey&&o&&!n)return e.preventDefault(),void s();"Escape"===e.key&&i()})});
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
// IP History Modal functionality
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const modal = document.getElementById('ip-history-modal');
|
|
||||||
const closeBtn = document.getElementById('close-ip-history-modal');
|
|
||||||
const content = document.getElementById('ip-history-content');
|
|
||||||
const ipAddressSpan = document.getElementById('modal-ip-address');
|
|
||||||
|
|
||||||
// Open modal when IP is clicked
|
|
||||||
document.querySelectorAll('.ip-history-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', function() {
|
|
||||||
const ip = this.getAttribute('data-ip');
|
|
||||||
ipAddressSpan.textContent = ip;
|
|
||||||
modal.classList.remove('hidden');
|
|
||||||
modal.classList.add('flex');
|
|
||||||
loadIPHistory(ip);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close modal
|
|
||||||
closeBtn.addEventListener('click', function() {
|
|
||||||
modal.classList.add('hidden');
|
|
||||||
modal.classList.remove('flex');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close modal when clicking outside
|
|
||||||
modal.addEventListener('click', function(e) {
|
|
||||||
if (e.target === modal) {
|
|
||||||
modal.classList.add('hidden');
|
|
||||||
modal.classList.remove('flex');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close modal with Escape key
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Escape' && !modal.classList.contains('hidden')) {
|
|
||||||
modal.classList.add('hidden');
|
|
||||||
modal.classList.remove('flex');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function loadIPHistory(ip) {
|
|
||||||
content.innerHTML = '<div class="text-center text-gray-600 dark:text-gray-400">Loading...</div>';
|
|
||||||
|
|
||||||
fetch(`/ip/${encodeURIComponent(ip)}/history`)
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.history && data.history.length > 0) {
|
|
||||||
displayHistory(data.history);
|
|
||||||
} else {
|
|
||||||
content.innerHTML = '<div class="text-center text-gray-600 dark:text-gray-400">No history found for this IP address.</div>';
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error loading IP history:', error);
|
|
||||||
content.innerHTML = '<div class="text-center text-red-500">Error loading IP history. Please try again.</div>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayHistory(history) {
|
|
||||||
let html = '<div class="space-y-3">';
|
|
||||||
|
|
||||||
history.forEach((entry, index) => {
|
|
||||||
const isAssigned = entry.action === 'assigned';
|
|
||||||
const icon = isAssigned ? 'fa-plus-circle text-green-500' : 'fa-minus-circle text-red-500';
|
|
||||||
const actionText = isAssigned ? 'Assigned' : 'Removed';
|
|
||||||
|
|
||||||
// Format timestamp
|
|
||||||
let timestamp = 'Unknown';
|
|
||||||
if (entry.timestamp) {
|
|
||||||
try {
|
|
||||||
const date = new Date(entry.timestamp);
|
|
||||||
timestamp = date.toLocaleString();
|
|
||||||
} catch (e) {
|
|
||||||
timestamp = entry.timestamp;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="flex items-start gap-3 pb-3 ${index < history.length - 1 ? 'border-b border-gray-400 dark:border-zinc-600' : ''}">
|
|
||||||
<div class="flex-shrink-0 mt-1">
|
|
||||||
<i class="fas ${icon}"></i>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
|
||||||
<span class="font-semibold">${actionText}</span>
|
|
||||||
<span class="text-gray-600 dark:text-gray-400">to</span>
|
|
||||||
<span class="font-semibold">${entry.device_name || 'Unknown'}</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
${entry.subnet_name || 'Unknown'} (${entry.subnet_cidr || 'N/A'})
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
|
||||||
by ${entry.user_name || 'Unknown'} • ${timestamp}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
html += '</div>';
|
|
||||||
content.innerHTML = html;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Vendored
-20
@@ -1,20 +0,0 @@
|
|||||||
document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("ip-history-modal"),t=document.getElementById("close-ip-history-modal"),s=document.getElementById("ip-history-content"),i=document.getElementById("modal-ip-address");document.querySelectorAll(".ip-history-btn").forEach(t=>{t.addEventListener("click",function(){var t;let a=this.getAttribute("data-ip");i.textContent=a,e.classList.remove("hidden"),e.classList.add("flex"),t=a,s.innerHTML='<div class="text-center text-gray-600 dark:text-gray-400">Loading...</div>',fetch(`/ip/${encodeURIComponent(t)}/history`).then(e=>e.json()).then(e=>{var t;let i;e.history&&e.history.length>0?(t=e.history,i='<div class="space-y-3">',t.forEach((e,s)=>{let a="assigned"===e.action,n="Unknown";if(e.timestamp)try{let d=new Date(e.timestamp);n=d.toLocaleString()}catch(r){n=e.timestamp}i+=`
|
|
||||||
<div class="flex items-start gap-3 pb-3 ${s<t.length-1?"border-b border-gray-400 dark:border-zinc-600":""}">
|
|
||||||
<div class="flex-shrink-0 mt-1">
|
|
||||||
<i class="fas ${a?"fa-plus-circle text-green-500":"fa-minus-circle text-red-500"}"></i>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
|
||||||
<span class="font-semibold">${a?"Assigned":"Removed"}</span>
|
|
||||||
<span class="text-gray-600 dark:text-gray-400">to</span>
|
|
||||||
<span class="font-semibold">${e.device_name||"Unknown"}</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
${e.subnet_name||"Unknown"} (${e.subnet_cidr||"N/A"})
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
|
||||||
by ${e.user_name||"Unknown"} • ${n}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`}),i+="</div>",s.innerHTML=i):s.innerHTML='<div class="text-center text-gray-600 dark:text-gray-400">No history found for this IP address.</div>'}).catch(e=>{console.error("Error loading IP history:",e),s.innerHTML='<div class="text-center text-red-500">Error loading IP history. Please try again.</div>'})})}),t.addEventListener("click",function(){e.classList.add("hidden"),e.classList.remove("flex")}),e.addEventListener("click",function(t){t.target===e&&(e.classList.add("hidden"),e.classList.remove("flex"))}),document.addEventListener("keydown",function(t){"Escape"!==t.key||e.classList.contains("hidden")||(e.classList.add("hidden"),e.classList.remove("flex"))})});
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Export CSV button
|
|
||||||
const exportBtn = document.getElementById('export-csv');
|
|
||||||
if (exportBtn) {
|
|
||||||
exportBtn.addEventListener('click', function() {
|
|
||||||
const rackId = exportBtn.getAttribute('data-rack-id');
|
|
||||||
if (rackId) {
|
|
||||||
window.location = '/rack/' + rackId + '/export_csv';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form toggle functionality
|
|
||||||
function showBothAddButtons() {
|
|
||||||
document.getElementById('show-add-device-form').classList.remove('hidden');
|
|
||||||
document.getElementById('show-nonnet-form').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
showBothAddButtons();
|
|
||||||
|
|
||||||
document.getElementById('show-nonnet-form').onclick = function() {
|
|
||||||
document.getElementById('nonnet-form').classList.remove('hidden');
|
|
||||||
this.classList.add('hidden');
|
|
||||||
};
|
|
||||||
|
|
||||||
document.getElementById('hide-nonnet-form').onclick = function() {
|
|
||||||
document.getElementById('nonnet-form').classList.add('hidden');
|
|
||||||
showBothAddButtons();
|
|
||||||
};
|
|
||||||
|
|
||||||
document.getElementById('show-add-device-form').onclick = function() {
|
|
||||||
document.getElementById('add-device-form').classList.remove('hidden');
|
|
||||||
this.classList.add('hidden');
|
|
||||||
};
|
|
||||||
|
|
||||||
document.getElementById('hide-add-device-form').onclick = function() {
|
|
||||||
document.getElementById('add-device-form').classList.add('hidden');
|
|
||||||
showBothAddButtons();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("export-csv");function d(){document.getElementById("show-add-device-form").classList.remove("hidden"),document.getElementById("show-nonnet-form").classList.remove("hidden")}e&&e.addEventListener("click",function(){let d=e.getAttribute("data-rack-id");d&&(window.location="/rack/"+d+"/export_csv")}),d(),document.getElementById("show-nonnet-form").onclick=function(){document.getElementById("nonnet-form").classList.remove("hidden"),this.classList.add("hidden")},document.getElementById("hide-nonnet-form").onclick=function(){document.getElementById("nonnet-form").classList.add("hidden"),d()},document.getElementById("show-add-device-form").onclick=function(){document.getElementById("add-device-form").classList.remove("hidden"),this.classList.add("hidden")},document.getElementById("hide-add-device-form").onclick=function(){document.getElementById("add-device-form").classList.add("hidden"),d()}});
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
document.querySelectorAll('.site-header').forEach(header => {
|
|
||||||
header.addEventListener('click', function(e) {
|
|
||||||
if (e.target.closest('button')) return;
|
|
||||||
const subnetList = this.closest('.site-group').querySelector('.subnet-list');
|
|
||||||
const icon = this.querySelector('.expand-btn i');
|
|
||||||
if (subnetList.classList.contains('hidden')) {
|
|
||||||
subnetList.classList.remove('hidden');
|
|
||||||
icon.classList.remove('fa-chevron-down');
|
|
||||||
icon.classList.add('fa-chevron-up');
|
|
||||||
} else {
|
|
||||||
subnetList.classList.add('hidden');
|
|
||||||
icon.classList.remove('fa-chevron-up');
|
|
||||||
icon.classList.add('fa-chevron-down');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
document.querySelectorAll('.expand-btn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', function(e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
const subnetList = this.closest('.site-group').querySelector('.subnet-list');
|
|
||||||
const icon = this.querySelector('i');
|
|
||||||
if (subnetList.classList.contains('hidden')) {
|
|
||||||
subnetList.classList.remove('hidden');
|
|
||||||
icon.classList.remove('fa-chevron-down');
|
|
||||||
icon.classList.add('fa-chevron-up');
|
|
||||||
} else {
|
|
||||||
subnetList.classList.add('hidden');
|
|
||||||
icon.classList.remove('fa-chevron-up');
|
|
||||||
icon.classList.add('fa-chevron-down');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
document.addEventListener("DOMContentLoaded",function(){document.querySelectorAll(".site-header").forEach(e=>{e.addEventListener("click",function(e){if(e.target.closest("button"))return;let s=this.closest(".site-group").querySelector(".subnet-list"),t=this.querySelector(".expand-btn i");s.classList.contains("hidden")?(s.classList.remove("hidden"),t.classList.remove("fa-chevron-down"),t.classList.add("fa-chevron-up")):(s.classList.add("hidden"),t.classList.remove("fa-chevron-up"),t.classList.add("fa-chevron-down"))})}),document.querySelectorAll(".expand-btn").forEach(e=>{e.addEventListener("click",function(e){e.stopPropagation();let s=this.closest(".site-group").querySelector(".subnet-list"),t=this.querySelector("i");s.classList.contains("hidden")?(s.classList.remove("hidden"),t.classList.remove("fa-chevron-down"),t.classList.add("fa-chevron-up")):(s.classList.add("hidden"),t.classList.remove("fa-chevron-up"),t.classList.add("fa-chevron-down"))})})});
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
// Only target the form on the subnet page, not the header search form
|
|
||||||
// Look for a form that's not in the header (header forms have action="/search")
|
|
||||||
const allForms = document.querySelectorAll('form');
|
|
||||||
let form = null;
|
|
||||||
for (let f of allForms) {
|
|
||||||
if (f.action !== '/search' && f.method === 'POST') {
|
|
||||||
form = f;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (form) {
|
|
||||||
// Check if search input already exists to prevent duplicates
|
|
||||||
if (!document.querySelector('input[placeholder="Search by IP or Hostname"]')) {
|
|
||||||
form.addEventListener('submit', (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
});
|
|
||||||
|
|
||||||
const searchInput = document.createElement('input');
|
|
||||||
searchInput.type = 'text';
|
|
||||||
searchInput.placeholder = 'Search by IP or Hostname';
|
|
||||||
searchInput.className = 'p-2 w-full rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600 focus:outline-none focus:border-blue-400 mb-4 text-center';
|
|
||||||
form.insertAdjacentElement('beforebegin', searchInput);
|
|
||||||
|
|
||||||
searchInput.addEventListener('keypress', (event) => {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault();
|
|
||||||
const searchTerm = searchInput.value.toLowerCase();
|
|
||||||
const rows = document.querySelectorAll('tbody tr');
|
|
||||||
|
|
||||||
rows.forEach(row => {
|
|
||||||
const ipCell = row.querySelector('td:nth-child(1)').textContent.toLowerCase();
|
|
||||||
const hostnameCell = row.querySelector('td:nth-child(2)').textContent.toLowerCase();
|
|
||||||
const descCell = row.querySelector('td:nth-child(3)');
|
|
||||||
const descText = descCell ? descCell.textContent.toLowerCase() : '';
|
|
||||||
|
|
||||||
if (ipCell.includes(searchTerm) || hostnameCell.includes(searchTerm) || descText.includes(searchTerm)) {
|
|
||||||
row.style.backgroundColor = 'rgba(59, 130, 246, 0.5)';
|
|
||||||
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
row.style.backgroundColor = '';
|
|
||||||
}, 3000);
|
|
||||||
} else {
|
|
||||||
row.style.backgroundColor = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Description toggle functionality
|
|
||||||
const toggleBtn = document.getElementById('toggle-desc');
|
|
||||||
const descCols = document.querySelectorAll('.desc-col');
|
|
||||||
const descHeader = document.getElementById('desc-col-header');
|
|
||||||
let shown = false;
|
|
||||||
if (toggleBtn) {
|
|
||||||
toggleBtn.addEventListener('click', function() {
|
|
||||||
shown = !shown;
|
|
||||||
descCols.forEach(col => col.classList.toggle('hidden', !shown));
|
|
||||||
if (descHeader) descHeader.classList.toggle('hidden', !shown);
|
|
||||||
toggleBtn.textContent = shown ? 'Hide Descriptions' : 'Show Descriptions';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scroll to Top Button
|
|
||||||
const scrollToTopButton = document.createElement('button');
|
|
||||||
scrollToTopButton.innerHTML = '<i class="fas fa-arrow-up"></i>';
|
|
||||||
scrollToTopButton.style.fontSize = '26px';
|
|
||||||
scrollToTopButton.className = 'fixed bottom-5 right-5 bg-gray-200 dark:bg-zinc-800 text-black dark:text-white p-3 rounded-full shadow-lg hidden';
|
|
||||||
scrollToTopButton.style.width = '60px';
|
|
||||||
scrollToTopButton.style.height = '60px';
|
|
||||||
scrollToTopButton.style.borderRadius = '50%';
|
|
||||||
document.body.appendChild(scrollToTopButton);
|
|
||||||
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.textContent = `
|
|
||||||
@keyframes bob {
|
|
||||||
0%, 100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translateY(-5px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bobbing {
|
|
||||||
animation: bob 1.5s infinite;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
|
|
||||||
scrollToTopButton.classList.add('bobbing');
|
|
||||||
|
|
||||||
window.addEventListener('scroll', () => {
|
|
||||||
if (window.scrollY > 200) {
|
|
||||||
scrollToTopButton.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
scrollToTopButton.classList.add('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
scrollToTopButton.addEventListener('click', () => {
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Force scrollbar thumb to render on page load
|
|
||||||
// This fixes the issue where scrollbar thumb is missing on initial page load
|
|
||||||
// The scrollbar only renders its thumb after a scroll event has occurred
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const isScrollable = document.documentElement.scrollHeight > document.documentElement.clientHeight;
|
|
||||||
if (isScrollable && window.scrollY === 0) {
|
|
||||||
// Trigger a minimal scroll to force scrollbar rendering, then scroll back
|
|
||||||
window.scrollBy(0, 1);
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
window.scrollBy(0, -1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Scroll to IP anchor if present in URL hash
|
|
||||||
if (window.location.hash) {
|
|
||||||
const hash = window.location.hash.substring(1);
|
|
||||||
const element = document.getElementById(hash);
|
|
||||||
if (element) {
|
|
||||||
setTimeout(() => {
|
|
||||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
// Highlight the row briefly
|
|
||||||
element.style.backgroundColor = 'rgba(59, 130, 246, 0.5)';
|
|
||||||
setTimeout(() => {
|
|
||||||
element.style.backgroundColor = '';
|
|
||||||
}, 3000);
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-resize all description textareas (both editable and readonly)
|
|
||||||
const allDescTextareas = document.querySelectorAll('.desc-col textarea');
|
|
||||||
allDescTextareas.forEach(textarea => {
|
|
||||||
textarea.style.overflow = 'hidden';
|
|
||||||
textarea.style.resize = 'none';
|
|
||||||
function autoResize() {
|
|
||||||
textarea.style.height = 'auto';
|
|
||||||
textarea.style.height = textarea.scrollHeight + 'px';
|
|
||||||
}
|
|
||||||
autoResize();
|
|
||||||
});
|
|
||||||
|
|
||||||
// IP Notes inline editing functionality
|
|
||||||
const ipNotesTextareas = document.querySelectorAll('.ip-notes-textarea');
|
|
||||||
const originalValues = new Map();
|
|
||||||
|
|
||||||
// Helper function to show toast notification
|
|
||||||
function showToast(message, type = 'success') {
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = `fixed top-20 right-4 px-4 py-3 rounded-lg shadow-lg z-50 ${
|
|
||||||
type === 'success'
|
|
||||||
? 'bg-green-500 text-white'
|
|
||||||
: 'bg-red-500 text-white'
|
|
||||||
}`;
|
|
||||||
toast.textContent = message;
|
|
||||||
document.body.appendChild(toast);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.style.transition = 'opacity 0.3s';
|
|
||||||
toast.style.opacity = '0';
|
|
||||||
setTimeout(() => toast.remove(), 300);
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
ipNotesTextareas.forEach(textarea => {
|
|
||||||
// Store original value
|
|
||||||
originalValues.set(textarea, textarea.value);
|
|
||||||
|
|
||||||
// Ensure overflow is hidden and resize is disabled
|
|
||||||
textarea.style.overflow = 'hidden';
|
|
||||||
textarea.style.resize = 'none';
|
|
||||||
|
|
||||||
// Auto-resize textarea
|
|
||||||
function autoResize() {
|
|
||||||
textarea.style.height = 'auto';
|
|
||||||
textarea.style.height = textarea.scrollHeight + 'px';
|
|
||||||
}
|
|
||||||
autoResize();
|
|
||||||
|
|
||||||
// Handle input to auto-resize
|
|
||||||
textarea.addEventListener('input', autoResize);
|
|
||||||
|
|
||||||
// Handle blur event to save notes
|
|
||||||
textarea.addEventListener('blur', async function() {
|
|
||||||
const ipId = this.getAttribute('data-ip-id');
|
|
||||||
const deviceDesc = this.getAttribute('data-device-desc') || '';
|
|
||||||
const fullValue = this.value;
|
|
||||||
const originalValue = originalValues.get(this);
|
|
||||||
|
|
||||||
// Extract IP notes: everything after the device description
|
|
||||||
let ipNotes = '';
|
|
||||||
if (deviceDesc) {
|
|
||||||
// If device description exists, check if textarea starts with it
|
|
||||||
const deviceDescTrimmed = deviceDesc.trim();
|
|
||||||
const fullValueTrimmed = fullValue.trim();
|
|
||||||
|
|
||||||
if (fullValueTrimmed.startsWith(deviceDescTrimmed)) {
|
|
||||||
// Remove device description from the beginning
|
|
||||||
ipNotes = fullValueTrimmed.substring(deviceDescTrimmed.length).trim();
|
|
||||||
// Also handle case where there's a newline separator
|
|
||||||
if (ipNotes.startsWith('\n')) {
|
|
||||||
ipNotes = ipNotes.substring(1).trim();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Device description was modified or removed - extract everything as IP notes
|
|
||||||
// This shouldn't normally happen, but handle gracefully
|
|
||||||
ipNotes = fullValueTrimmed;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No device description, so entire value is IP notes
|
|
||||||
ipNotes = fullValue.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only save if value changed
|
|
||||||
if (fullValue !== originalValue) {
|
|
||||||
// Show loading indicator
|
|
||||||
const originalBg = this.style.backgroundColor;
|
|
||||||
this.style.backgroundColor = 'rgba(59, 130, 246, 0.2)';
|
|
||||||
this.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/ip/${ipId}/update_notes`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ notes: ipNotes })
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
// Update the displayed value to reflect what was saved
|
|
||||||
let newDisplayValue = '';
|
|
||||||
if (deviceDesc) {
|
|
||||||
newDisplayValue = deviceDesc;
|
|
||||||
if (ipNotes) {
|
|
||||||
newDisplayValue += '\n' + ipNotes;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
newDisplayValue = ipNotes;
|
|
||||||
}
|
|
||||||
this.value = newDisplayValue;
|
|
||||||
originalValues.set(this, newDisplayValue);
|
|
||||||
autoResize();
|
|
||||||
showToast('Notes saved successfully', 'success');
|
|
||||||
} else {
|
|
||||||
// Restore original value on error
|
|
||||||
this.value = originalValue;
|
|
||||||
autoResize();
|
|
||||||
showToast(data.error || 'Failed to save notes', 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Restore original value on error
|
|
||||||
this.value = originalValue;
|
|
||||||
autoResize();
|
|
||||||
showToast('Error saving notes. Please try again.', 'error');
|
|
||||||
console.error('Error saving IP notes:', error);
|
|
||||||
} finally {
|
|
||||||
this.style.backgroundColor = originalBg;
|
|
||||||
this.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle Escape key to cancel editing
|
|
||||||
textarea.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
this.value = originalValues.get(this);
|
|
||||||
autoResize();
|
|
||||||
this.blur();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Vendored
-14
@@ -1,14 +0,0 @@
|
|||||||
document.addEventListener("DOMContentLoaded",()=>{let e=document.querySelectorAll("form"),t=null;for(let l of e)if("/search"!==l.action&&"POST"===l.method){t=l;break}if(t&&!document.querySelector('input[placeholder="Search by IP or Hostname"]')){t.addEventListener("submit",e=>{e.preventDefault()});let o=document.createElement("input");o.type="text",o.placeholder="Search by IP or Hostname",o.className="p-2 w-full rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600 focus:outline-none focus:border-blue-400 mb-4 text-center",t.insertAdjacentElement("beforebegin",o),o.addEventListener("keypress",e=>{if("Enter"===e.key){e.preventDefault();let t=o.value.toLowerCase(),l=document.querySelectorAll("tbody tr");l.forEach(e=>{let l=e.querySelector("td:nth-child(1)").textContent.toLowerCase(),o=e.querySelector("td:nth-child(2)").textContent.toLowerCase(),s=e.querySelector("td:nth-child(3)"),r=s?s.textContent.toLowerCase():"";l.includes(t)||o.includes(t)||r.includes(t)?(e.style.backgroundColor="rgba(59, 130, 246, 0.5)",e.scrollIntoView({behavior:"smooth",block:"center"}),setTimeout(()=>{e.style.backgroundColor=""},3e3)):e.style.backgroundColor=""})}})}let s=document.getElementById("toggle-desc"),r=document.querySelectorAll(".desc-col"),n=document.getElementById("desc-col-header"),i=!1;s&&s.addEventListener("click",function(){i=!i,r.forEach(e=>e.classList.toggle("hidden",!i)),n&&n.classList.toggle("hidden",!i),s.textContent=i?"Hide Descriptions":"Show Descriptions"});let a=document.createElement("button");a.innerHTML='<i class="fas fa-arrow-up"></i>',a.style.fontSize="26px",a.className="fixed bottom-5 right-5 bg-gray-200 dark:bg-zinc-800 text-black dark:text-white p-3 rounded-full shadow-lg hidden",a.style.width="60px",a.style.height="60px",a.style.borderRadius="50%",document.body.appendChild(a);let d=document.createElement("style");if(d.textContent=`
|
|
||||||
@keyframes bob {
|
|
||||||
0%, 100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translateY(-5px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bobbing {
|
|
||||||
animation: bob 1.5s infinite;
|
|
||||||
}
|
|
||||||
`,document.head.appendChild(d),a.classList.add("bobbing"),window.addEventListener("scroll",()=>{window.scrollY>200?a.classList.remove("hidden"):a.classList.add("hidden")}),a.addEventListener("click",()=>{window.scrollTo({top:0,behavior:"smooth"})}),requestAnimationFrame(()=>{let e=document.documentElement.scrollHeight>document.documentElement.clientHeight;e&&0===window.scrollY&&(window.scrollBy(0,1),requestAnimationFrame(()=>{window.scrollBy(0,-1)}))}),window.location.hash){let c=window.location.hash.substring(1),h=document.getElementById(c);h&&setTimeout(()=>{h.scrollIntoView({behavior:"smooth",block:"center"}),h.style.backgroundColor="rgba(59, 130, 246, 0.5)",setTimeout(()=>{h.style.backgroundColor=""},3e3)},100)}let u=document.querySelectorAll(".desc-col textarea");u.forEach(e=>{function t(){e.style.height="auto",e.style.height=e.scrollHeight+"px"}e.style.overflow="hidden",e.style.resize="none",t()});let y=document.querySelectorAll(".ip-notes-textarea"),b=new Map;function g(e,t="success"){let l=document.createElement("div");l.className=`fixed top-20 right-4 px-4 py-3 rounded-lg shadow-lg z-50 ${"success"===t?"bg-green-500 text-white":"bg-red-500 text-white"}`,l.textContent=e,document.body.appendChild(l),setTimeout(()=>{l.style.transition="opacity 0.3s",l.style.opacity="0",setTimeout(()=>l.remove(),300)},3e3)}y.forEach(e=>{function t(){e.style.height="auto",e.style.height=e.scrollHeight+"px"}b.set(e,e.value),e.style.overflow="hidden",e.style.resize="none",t(),e.addEventListener("input",t),e.addEventListener("blur",async function(){let e=this.getAttribute("data-ip-id"),l=this.getAttribute("data-device-desc")||"",o=this.value,s=b.get(this),r="";if(l){let n=l.trim(),i=o.trim();i.startsWith(n)?(r=i.substring(n.length).trim()).startsWith("\n")&&(r=r.substring(1).trim()):r=i}else r=o.trim();if(o!==s){let a=this.style.backgroundColor;this.style.backgroundColor="rgba(59, 130, 246, 0.2)",this.disabled=!0;try{let d=await fetch(`/ip/${e}/update_notes`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({notes:r})}),c=await d.json();if(c.success){let h="";l?(h=l,r&&(h+="\n"+r)):h=r,this.value=h,b.set(this,h),t(),g("Notes saved successfully","success")}else this.value=s,t(),g(c.error||"Failed to save notes","error")}catch(u){this.value=s,t(),g("Error saving notes. Please try again.","error"),console.error("Error saving IP notes:",u)}finally{this.style.backgroundColor=a,this.disabled=!1}}}),e.addEventListener("keydown",function(e){"Escape"===e.key&&(this.value=b.get(this),t(),this.blur())})})});
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
// Auto-save custom fields on blur (subnet page)
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const customFieldsForm = document.getElementById('custom-fields-form');
|
|
||||||
if (!customFieldsForm) {
|
|
||||||
return; // No custom fields form on this page
|
|
||||||
}
|
|
||||||
|
|
||||||
const subnetId = customFieldsForm.action.match(/\/subnet\/(\d+)\/update_custom_fields/)?.[1];
|
|
||||||
if (!subnetId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all form fields
|
|
||||||
const formFields = customFieldsForm.querySelectorAll('input, textarea, select');
|
|
||||||
const originalValues = new Map();
|
|
||||||
|
|
||||||
// Store original values
|
|
||||||
formFields.forEach(field => {
|
|
||||||
if (field.type === 'checkbox') {
|
|
||||||
originalValues.set(field, field.checked);
|
|
||||||
} else {
|
|
||||||
originalValues.set(field, field.value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper function to show toast notification (reuse from subnet.js if available)
|
|
||||||
function showToast(message, type = 'success') {
|
|
||||||
const toast = document.createElement('div');
|
|
||||||
toast.className = `fixed top-20 right-4 px-4 py-3 rounded-lg shadow-lg z-50 ${
|
|
||||||
type === 'success'
|
|
||||||
? 'bg-green-500 text-white'
|
|
||||||
: 'bg-red-500 text-white'
|
|
||||||
}`;
|
|
||||||
toast.textContent = message;
|
|
||||||
document.body.appendChild(toast);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
toast.style.transition = 'opacity 0.3s';
|
|
||||||
toast.style.opacity = '0';
|
|
||||||
setTimeout(() => toast.remove(), 300);
|
|
||||||
}, 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-resize textareas
|
|
||||||
const textareas = customFieldsForm.querySelectorAll('textarea');
|
|
||||||
textareas.forEach(textarea => {
|
|
||||||
textarea.style.overflow = 'hidden';
|
|
||||||
textarea.style.resize = 'none';
|
|
||||||
|
|
||||||
function autoResize() {
|
|
||||||
textarea.style.height = 'auto';
|
|
||||||
textarea.style.height = textarea.scrollHeight + 'px';
|
|
||||||
}
|
|
||||||
autoResize();
|
|
||||||
textarea.addEventListener('input', autoResize);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if form has changes
|
|
||||||
function hasChanges() {
|
|
||||||
for (const field of formFields) {
|
|
||||||
let currentValue;
|
|
||||||
if (field.type === 'checkbox') {
|
|
||||||
currentValue = field.checked;
|
|
||||||
} else {
|
|
||||||
currentValue = field.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalValue = originalValues.get(field);
|
|
||||||
if (currentValue !== originalValue) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save all custom fields
|
|
||||||
let saveInProgress = false;
|
|
||||||
async function saveCustomFields() {
|
|
||||||
if (saveInProgress) {
|
|
||||||
return; // Prevent multiple simultaneous saves
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasChanges()) {
|
|
||||||
return; // No changes to save
|
|
||||||
}
|
|
||||||
|
|
||||||
saveInProgress = true;
|
|
||||||
|
|
||||||
// Show loading indicator on form
|
|
||||||
const originalOpacity = customFieldsForm.style.opacity;
|
|
||||||
customFieldsForm.style.opacity = '0.6';
|
|
||||||
customFieldsForm.style.pointerEvents = 'none';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create FormData from form and convert to JSON
|
|
||||||
const formData = new FormData(customFieldsForm);
|
|
||||||
const data = {};
|
|
||||||
|
|
||||||
// Process all fields
|
|
||||||
for (const [key, value] of formData.entries()) {
|
|
||||||
data[key] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle checkboxes that weren't checked (they don't appear in FormData)
|
|
||||||
formFields.forEach(field => {
|
|
||||||
if (field.type === 'checkbox' && !field.checked) {
|
|
||||||
data[field.name] = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(customFieldsForm.action, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// Update original values
|
|
||||||
formFields.forEach(field => {
|
|
||||||
if (field.type === 'checkbox') {
|
|
||||||
originalValues.set(field, field.checked);
|
|
||||||
} else {
|
|
||||||
originalValues.set(field, field.value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
showToast('Custom fields saved successfully', 'success');
|
|
||||||
} else {
|
|
||||||
const data = await response.json().catch(() => ({}));
|
|
||||||
const errorMsg = data.errors ? data.errors.join(', ') : (data.error || 'Failed to save custom fields');
|
|
||||||
showToast(errorMsg, 'error');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
showToast('Error saving custom fields. Please try again.', 'error');
|
|
||||||
console.error('Error saving custom fields:', error);
|
|
||||||
} finally {
|
|
||||||
customFieldsForm.style.opacity = originalOpacity;
|
|
||||||
customFieldsForm.style.pointerEvents = '';
|
|
||||||
saveInProgress = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add blur event listeners to all fields
|
|
||||||
formFields.forEach(field => {
|
|
||||||
// Skip if it's a checkbox (we'll handle change event instead)
|
|
||||||
if (field.type === 'checkbox') {
|
|
||||||
field.addEventListener('change', () => {
|
|
||||||
// Small delay to ensure value is updated
|
|
||||||
setTimeout(saveCustomFields, 100);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
field.addEventListener('blur', saveCustomFields);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Prevent form submission (since we're using auto-save)
|
|
||||||
customFieldsForm.addEventListener('submit', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
saveCustomFields();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
document.addEventListener("DOMContentLoaded",()=>{let e=document.getElementById("custom-fields-form");if(!e)return;let t=e.action.match(/\/subnet\/(\d+)\/update_custom_fields/)?.[1];if(!t)return;let r=e.querySelectorAll("input, textarea, select"),s=new Map;function o(e,t="success"){let r=document.createElement("div");r.className=`fixed top-20 right-4 px-4 py-3 rounded-lg shadow-lg z-50 ${"success"===t?"bg-green-500 text-white":"bg-red-500 text-white"}`,r.textContent=e,document.body.appendChild(r),setTimeout(()=>{r.style.transition="opacity 0.3s",r.style.opacity="0",setTimeout(()=>r.remove(),300)},3e3)}r.forEach(e=>{"checkbox"===e.type?s.set(e,e.checked):s.set(e,e.value)});let n=e.querySelectorAll("textarea");function c(){for(let e of r){let t;t="checkbox"===e.type?e.checked:e.value;let o=s.get(e);if(t!==o)return!0}return!1}n.forEach(e=>{function t(){e.style.height="auto",e.style.height=e.scrollHeight+"px"}e.style.overflow="hidden",e.style.resize="none",t(),e.addEventListener("input",t)});let l=!1;async function i(){if(l||!c())return;l=!0;let t=e.style.opacity;e.style.opacity="0.6",e.style.pointerEvents="none";try{let n=new FormData(e),i={};for(let[a,d]of n.entries())i[a]=d;r.forEach(e=>{"checkbox"!==e.type||e.checked||(i[e.name]="")});let u=await fetch(e.action,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)});if(u.ok)r.forEach(e=>{"checkbox"===e.type?s.set(e,e.checked):s.set(e,e.value)}),o("Custom fields saved successfully","success");else{let y=await u.json().catch(()=>({})),f=y.errors?y.errors.join(", "):y.error||"Failed to save custom fields";o(f,"error")}}catch(h){o("Error saving custom fields. Please try again.","error"),console.error("Error saving custom fields:",h)}finally{e.style.opacity=t,e.style.pointerEvents="",l=!1}}r.forEach(e=>{"checkbox"===e.type?e.addEventListener("change",()=>{setTimeout(i,100)}):e.addEventListener("blur",i)}),e.addEventListener("submit",e=>{e.preventDefault(),i()})});
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
// Tag Management JavaScript
|
|
||||||
|
|
||||||
function showAddTagModal() {
|
|
||||||
document.getElementById('add-tag-modal').classList.remove('hidden');
|
|
||||||
document.getElementById('add-tag-name').value = '';
|
|
||||||
document.getElementById('add-tag-color').value = '#6B7280';
|
|
||||||
document.getElementById('add-tag-description').value = '';
|
|
||||||
updateColorPreview('add');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeAddTagModal() {
|
|
||||||
document.getElementById('add-tag-modal').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function editTag(tagId, name, color, description) {
|
|
||||||
document.getElementById('edit-tag-id').value = tagId;
|
|
||||||
document.getElementById('edit-tag-name').value = name;
|
|
||||||
document.getElementById('edit-tag-color').value = color;
|
|
||||||
document.getElementById('edit-tag-description').value = description || '';
|
|
||||||
updateColorPreview('edit');
|
|
||||||
document.getElementById('edit-tag-modal').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeEditTagModal() {
|
|
||||||
document.getElementById('edit-tag-modal').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateColorPreview(mode) {
|
|
||||||
const colorInput = document.getElementById(`${mode}-tag-color`);
|
|
||||||
const preview = document.getElementById(`${mode}-color-preview`);
|
|
||||||
preview.textContent = colorInput.value.toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event listeners
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const addColorInput = document.getElementById('add-tag-color');
|
|
||||||
const editColorInput = document.getElementById('edit-tag-color');
|
|
||||||
|
|
||||||
if (addColorInput) {
|
|
||||||
addColorInput.addEventListener('input', () => updateColorPreview('add'));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editColorInput) {
|
|
||||||
editColorInput.addEventListener('input', () => updateColorPreview('edit'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle edit tag button clicks
|
|
||||||
document.querySelectorAll('.edit-tag-btn').forEach(button => {
|
|
||||||
button.addEventListener('click', function() {
|
|
||||||
const tagId = this.dataset.tagId;
|
|
||||||
const tagName = this.dataset.tagName;
|
|
||||||
const tagColor = this.dataset.tagColor;
|
|
||||||
const tagDescription = this.dataset.tagDescription;
|
|
||||||
editTag(tagId, tagName, tagColor, tagDescription);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close modals when clicking outside
|
|
||||||
window.onclick = function(event) {
|
|
||||||
const addModal = document.getElementById('add-tag-modal');
|
|
||||||
const editModal = document.getElementById('edit-tag-modal');
|
|
||||||
if (event.target === addModal) {
|
|
||||||
closeAddTagModal();
|
|
||||||
}
|
|
||||||
if (event.target === editModal) {
|
|
||||||
closeEditTagModal();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
function showAddTagModal(){document.getElementById("add-tag-modal").classList.remove("hidden"),document.getElementById("add-tag-name").value="",document.getElementById("add-tag-color").value="#6B7280",document.getElementById("add-tag-description").value="",updateColorPreview("add")}function closeAddTagModal(){document.getElementById("add-tag-modal").classList.add("hidden")}function editTag(e,t,d,a){document.getElementById("edit-tag-id").value=e,document.getElementById("edit-tag-name").value=t,document.getElementById("edit-tag-color").value=d,document.getElementById("edit-tag-description").value=a||"",updateColorPreview("edit"),document.getElementById("edit-tag-modal").classList.remove("hidden")}function closeEditTagModal(){document.getElementById("edit-tag-modal").classList.add("hidden")}function updateColorPreview(e){let t=document.getElementById(`${e}-tag-color`),d=document.getElementById(`${e}-color-preview`);d.textContent=t.value.toUpperCase()}document.addEventListener("DOMContentLoaded",function(){let e=document.getElementById("add-tag-color"),t=document.getElementById("edit-tag-color");e&&e.addEventListener("input",()=>updateColorPreview("add")),t&&t.addEventListener("input",()=>updateColorPreview("edit")),document.querySelectorAll(".edit-tag-btn").forEach(e=>{e.addEventListener("click",function(){let e=this.dataset.tagId,t=this.dataset.tagName,d=this.dataset.tagColor,a=this.dataset.tagDescription;editTag(e,t,d,a)})})}),window.onclick=function(e){let t=document.getElementById("add-tag-modal"),d=document.getElementById("edit-tag-modal");e.target===t&&closeAddTagModal(),e.target===d&&closeEditTagModal()};
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
// These variables are set inline in the template from server data
|
|
||||||
// permissions and rolePermissions are passed from the template
|
|
||||||
|
|
||||||
function showTab(tab) {
|
|
||||||
document.getElementById('users-tab').classList.add('hidden');
|
|
||||||
document.getElementById('roles-tab').classList.add('hidden');
|
|
||||||
document.getElementById('tab-users').classList.remove('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
|
|
||||||
document.getElementById('tab-users').classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
|
||||||
document.getElementById('tab-roles').classList.remove('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
|
|
||||||
document.getElementById('tab-roles').classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
|
||||||
|
|
||||||
if (tab === 'users') {
|
|
||||||
document.getElementById('users-tab').classList.remove('hidden');
|
|
||||||
document.getElementById('tab-users').classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
|
||||||
document.getElementById('tab-users').classList.add('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
|
|
||||||
} else {
|
|
||||||
document.getElementById('roles-tab').classList.remove('hidden');
|
|
||||||
document.getElementById('tab-roles').classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
|
||||||
document.getElementById('tab-roles').classList.add('border-blue-500', 'text-blue-600', 'dark:text-blue-400');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function editUser(userId, name, email, roleId, apiKey) {
|
|
||||||
document.getElementById('edit-user-id').value = userId;
|
|
||||||
document.getElementById('edit-user-name').value = name;
|
|
||||||
document.getElementById('edit-user-email').value = email;
|
|
||||||
document.getElementById('edit-user-password').value = '';
|
|
||||||
document.getElementById('edit-user-role').value = (roleId === null || roleId === 'null') ? '' : roleId;
|
|
||||||
document.getElementById('edit-user-api-key').textContent = apiKey || 'No API Key';
|
|
||||||
document.getElementById('edit-user-modal').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeEditUserModal() {
|
|
||||||
document.getElementById('edit-user-modal').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showAddRoleModal() {
|
|
||||||
// Make sure edit modal is closed first
|
|
||||||
document.getElementById('edit-role-modal').classList.add('hidden');
|
|
||||||
// Clear any form data
|
|
||||||
const addForm = document.querySelector('#add-role-modal form');
|
|
||||||
if (addForm) {
|
|
||||||
addForm.reset();
|
|
||||||
}
|
|
||||||
// Show add modal
|
|
||||||
document.getElementById('add-role-modal').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeAddRoleModal() {
|
|
||||||
document.getElementById('add-role-modal').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function editRole(roleId, roleName, roleDescription, require2fa) {
|
|
||||||
// Make sure add modal is closed first
|
|
||||||
document.getElementById('add-role-modal').classList.add('hidden');
|
|
||||||
document.getElementById('edit-role-id').value = roleId;
|
|
||||||
document.getElementById('edit-role-name').value = roleName;
|
|
||||||
document.getElementById('edit-role-description').value = roleDescription || '';
|
|
||||||
document.getElementById('edit-role-require-2fa').checked = require2fa === true || require2fa === 'True' || require2fa === 1;
|
|
||||||
|
|
||||||
const permissionsDiv = document.getElementById('edit-role-permissions');
|
|
||||||
permissionsDiv.innerHTML = '';
|
|
||||||
|
|
||||||
const rolePerms = rolePermissions[roleId] || [];
|
|
||||||
|
|
||||||
// Group permissions by merged categories
|
|
||||||
const viewPerms = permissions.filter(p => p[3] === 'View');
|
|
||||||
const devicePerms = permissions.filter(p => p[3] === 'Device');
|
|
||||||
const deviceTypePerms = permissions.filter(p => p[3] === 'Device Type');
|
|
||||||
const subnetPerms = permissions.filter(p => p[3] === 'Subnet');
|
|
||||||
const dhcpPerms = permissions.filter(p => p[3] === 'DHCP');
|
|
||||||
const rackPerms = permissions.filter(p => p[3] === 'Rack');
|
|
||||||
const adminPerms = permissions.filter(p => p[3] === 'Admin');
|
|
||||||
|
|
||||||
let html = '';
|
|
||||||
|
|
||||||
// View Permissions
|
|
||||||
html += ' <!-- View Permissions -->\n';
|
|
||||||
html += ' <div class="col-span-full">\n';
|
|
||||||
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">View Permissions</h4>\n';
|
|
||||||
html += ' <div class="grid grid-cols-1 md:grid-cols-2 gap-2">\n';
|
|
||||||
viewPerms.forEach(perm => {
|
|
||||||
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
|
|
||||||
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
|
||||||
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
|
|
||||||
<span class="text-sm">${perm[2]}</span>
|
|
||||||
</label>\n`;
|
|
||||||
});
|
|
||||||
html += ' </div>\n';
|
|
||||||
html += ' </div>\n';
|
|
||||||
html += ' \n';
|
|
||||||
|
|
||||||
// Device Management
|
|
||||||
html += ' <!-- Device Management -->\n';
|
|
||||||
html += ' <div>\n';
|
|
||||||
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Device Management</h4>\n';
|
|
||||||
devicePerms.forEach(perm => {
|
|
||||||
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
|
|
||||||
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
|
||||||
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
|
|
||||||
<span class="text-sm">${perm[2]}</span>
|
|
||||||
</label>\n`;
|
|
||||||
});
|
|
||||||
deviceTypePerms.forEach(perm => {
|
|
||||||
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
|
|
||||||
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
|
||||||
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
|
|
||||||
<span class="text-sm">${perm[2]}</span>
|
|
||||||
</label>\n`;
|
|
||||||
});
|
|
||||||
html += ' </div>\n';
|
|
||||||
html += ' \n';
|
|
||||||
|
|
||||||
// Network Management
|
|
||||||
html += ' <!-- Network Management -->\n';
|
|
||||||
html += ' <div>\n';
|
|
||||||
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Network Management</h4>\n';
|
|
||||||
subnetPerms.forEach(perm => {
|
|
||||||
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
|
|
||||||
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
|
||||||
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
|
|
||||||
<span class="text-sm">${perm[2]}</span>
|
|
||||||
</label>\n`;
|
|
||||||
});
|
|
||||||
dhcpPerms.forEach(perm => {
|
|
||||||
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
|
|
||||||
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
|
||||||
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
|
|
||||||
<span class="text-sm">${perm[2]}</span>
|
|
||||||
</label>\n`;
|
|
||||||
});
|
|
||||||
html += ' </div>\n';
|
|
||||||
html += ' \n';
|
|
||||||
|
|
||||||
// Rack Management
|
|
||||||
html += ' <!-- Rack Management -->\n';
|
|
||||||
html += ' <div>\n';
|
|
||||||
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Rack Management</h4>\n';
|
|
||||||
rackPerms.forEach(perm => {
|
|
||||||
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
|
|
||||||
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
|
||||||
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
|
|
||||||
<span class="text-sm">${perm[2]}</span>
|
|
||||||
</label>\n`;
|
|
||||||
});
|
|
||||||
html += ' </div>\n';
|
|
||||||
html += ' \n';
|
|
||||||
|
|
||||||
// Admin
|
|
||||||
html += ' <!-- Admin -->\n';
|
|
||||||
html += ' <div>\n';
|
|
||||||
html += ' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Administration</h4>\n';
|
|
||||||
adminPerms.forEach(perm => {
|
|
||||||
const checked = rolePerms.includes(perm[0]) ? 'checked' : '';
|
|
||||||
html += ` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
|
||||||
<input type="checkbox" name="permissions" value="${perm[0]}" ${checked} class="mr-2">
|
|
||||||
<span class="text-sm">${perm[2]}</span>
|
|
||||||
</label>\n`;
|
|
||||||
});
|
|
||||||
html += ' </div>\n';
|
|
||||||
|
|
||||||
permissionsDiv.innerHTML = html;
|
|
||||||
|
|
||||||
document.getElementById('edit-role-modal').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeEditRoleModal() {
|
|
||||||
document.getElementById('edit-role-modal').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteRole(roleId, roleName) {
|
|
||||||
if (confirm(`Are you sure you want to delete the role "${roleName}"?`)) {
|
|
||||||
const form = document.createElement('form');
|
|
||||||
form.method = 'POST';
|
|
||||||
form.action = '/users';
|
|
||||||
form.innerHTML = `
|
|
||||||
<input type="hidden" name="action" value="delete_role">
|
|
||||||
<input type="hidden" name="role_id" value="${roleId}">
|
|
||||||
`;
|
|
||||||
document.body.appendChild(form);
|
|
||||||
form.submit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modals when clicking outside
|
|
||||||
window.onclick = function(event) {
|
|
||||||
const editUserModal = document.getElementById('edit-user-modal');
|
|
||||||
const editRoleModal = document.getElementById('edit-role-modal');
|
|
||||||
const addRoleModal = document.getElementById('add-role-modal');
|
|
||||||
if (event.target === editUserModal) {
|
|
||||||
closeEditUserModal();
|
|
||||||
}
|
|
||||||
if (event.target === editRoleModal) {
|
|
||||||
closeEditRoleModal();
|
|
||||||
}
|
|
||||||
if (event.target === addRoleModal) {
|
|
||||||
closeAddRoleModal();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Vendored
-32
@@ -1,32 +0,0 @@
|
|||||||
function showTab(e){document.getElementById("users-tab").classList.add("hidden"),document.getElementById("roles-tab").classList.add("hidden"),document.getElementById("tab-users").classList.remove("border-blue-500","text-blue-600","dark:text-blue-400"),document.getElementById("tab-users").classList.add("border-transparent","text-gray-600","dark:text-gray-400"),document.getElementById("tab-roles").classList.remove("border-blue-500","text-blue-600","dark:text-blue-400"),document.getElementById("tab-roles").classList.add("border-transparent","text-gray-600","dark:text-gray-400"),"users"===e?(document.getElementById("users-tab").classList.remove("hidden"),document.getElementById("tab-users").classList.remove("border-transparent","text-gray-600","dark:text-gray-400"),document.getElementById("tab-users").classList.add("border-blue-500","text-blue-600","dark:text-blue-400")):(document.getElementById("roles-tab").classList.remove("hidden"),document.getElementById("tab-roles").classList.remove("border-transparent","text-gray-600","dark:text-gray-400"),document.getElementById("tab-roles").classList.add("border-blue-500","text-blue-600","dark:text-blue-400"))}function editUser(e,t,s,l,d){document.getElementById("edit-user-id").value=e,document.getElementById("edit-user-name").value=t,document.getElementById("edit-user-email").value=s,document.getElementById("edit-user-password").value="",document.getElementById("edit-user-role").value=null===l||"null"===l?"":l,document.getElementById("edit-user-api-key").textContent=d||"No API Key",document.getElementById("edit-user-modal").classList.remove("hidden")}function closeEditUserModal(){document.getElementById("edit-user-modal").classList.add("hidden")}function showAddRoleModal(){document.getElementById("edit-role-modal").classList.add("hidden");let e=document.querySelector("#add-role-modal form");e&&e.reset(),document.getElementById("add-role-modal").classList.remove("hidden")}function closeAddRoleModal(){document.getElementById("add-role-modal").classList.add("hidden")}function editRole(e,t,s,l){document.getElementById("add-role-modal").classList.add("hidden"),document.getElementById("edit-role-id").value=e,document.getElementById("edit-role-name").value=t,document.getElementById("edit-role-description").value=s||"",document.getElementById("edit-role-require-2fa").checked=!0===l||"True"===l||1===l;let d=document.getElementById("edit-role-permissions");d.innerHTML="";let a=rolePermissions[e]||[],r=permissions.filter(e=>"View"===e[3]),n=permissions.filter(e=>"Device"===e[3]),o=permissions.filter(e=>"Device Type"===e[3]),i=permissions.filter(e=>"Subnet"===e[3]),c=permissions.filter(e=>"DHCP"===e[3]),m=permissions.filter(e=>"Rack"===e[3]),b=permissions.filter(e=>"Admin"===e[3]),u="";u+=" <!-- View Permissions -->\n",u+=' <div class="col-span-full">\n',u+=' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">View Permissions</h4>\n',u+=' <div class="grid grid-cols-1 md:grid-cols-2 gap-2">\n',r.forEach(e=>{let t=a.includes(e[0])?"checked":"";u+=` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
|
||||||
<input type="checkbox" name="permissions" value="${e[0]}" ${t} class="mr-2">
|
|
||||||
<span class="text-sm">${e[2]}</span>
|
|
||||||
</label>
|
|
||||||
`}),u+=" </div>\n",u+=" </div>\n",u+=" \n",u+=" <!-- Device Management -->\n",u+=" <div>\n",u+=' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Device Management</h4>\n',n.forEach(e=>{let t=a.includes(e[0])?"checked":"";u+=` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
|
||||||
<input type="checkbox" name="permissions" value="${e[0]}" ${t} class="mr-2">
|
|
||||||
<span class="text-sm">${e[2]}</span>
|
|
||||||
</label>
|
|
||||||
`}),o.forEach(e=>{let t=a.includes(e[0])?"checked":"";u+=` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
|
||||||
<input type="checkbox" name="permissions" value="${e[0]}" ${t} class="mr-2">
|
|
||||||
<span class="text-sm">${e[2]}</span>
|
|
||||||
</label>
|
|
||||||
`}),u+=" </div>\n",u+=" \n",u+=" <!-- Network Management -->\n",u+=" <div>\n",u+=' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Network Management</h4>\n',i.forEach(e=>{let t=a.includes(e[0])?"checked":"";u+=` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
|
||||||
<input type="checkbox" name="permissions" value="${e[0]}" ${t} class="mr-2">
|
|
||||||
<span class="text-sm">${e[2]}</span>
|
|
||||||
</label>
|
|
||||||
`}),c.forEach(e=>{let t=a.includes(e[0])?"checked":"";u+=` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
|
||||||
<input type="checkbox" name="permissions" value="${e[0]}" ${t} class="mr-2">
|
|
||||||
<span class="text-sm">${e[2]}</span>
|
|
||||||
</label>
|
|
||||||
`}),u+=" </div>\n",u+=" \n",u+=" <!-- Rack Management -->\n",u+=" <div>\n",u+=' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Rack Management</h4>\n',m.forEach(e=>{let t=a.includes(e[0])?"checked":"";u+=` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
|
||||||
<input type="checkbox" name="permissions" value="${e[0]}" ${t} class="mr-2">
|
|
||||||
<span class="text-sm">${e[2]}</span>
|
|
||||||
</label>
|
|
||||||
`}),u+=" </div>\n",u+=" \n",u+=" <!-- Admin -->\n",u+=" <div>\n",u+=' <h4 class="font-semibold text-base mb-2 border-b border-gray-500 pb-1">Administration</h4>\n',b.forEach(e=>{let t=a.includes(e[0])?"checked":"";u+=` <label class="flex items-center mb-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-zinc-800 p-2 rounded">
|
|
||||||
<input type="checkbox" name="permissions" value="${e[0]}" ${t} class="mr-2">
|
|
||||||
<span class="text-sm">${e[2]}</span>
|
|
||||||
</label>
|
|
||||||
`}),u+=" </div>\n",d.innerHTML=u,document.getElementById("edit-role-modal").classList.remove("hidden")}function closeEditRoleModal(){document.getElementById("edit-role-modal").classList.add("hidden")}function deleteRole(e,t){if(confirm(`Are you sure you want to delete the role "${t}"?`)){let s=document.createElement("form");s.method="POST",s.action="/users",s.innerHTML=`
|
|
||||||
<input type="hidden" name="action" value="delete_role">
|
|
||||||
<input type="hidden" name="role_id" value="${e}">
|
|
||||||
`,document.body.appendChild(s),s.submit()}}window.onclick=function(e){let t=document.getElementById("edit-user-modal"),s=document.getElementById("edit-role-modal"),l=document.getElementById("add-role-modal");e.target===t&&closeEditUserModal(),e.target===s&&closeEditRoleModal(),e.target===l&&closeAddRoleModal()};
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Account Settings - {{ NAME }} IPAM</title>
|
|
||||||
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
|
||||||
<link href="/static/css/output.css" rel="stylesheet">
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
|
||||||
{% include 'header.html' %}
|
|
||||||
<div class="flex-1 flex items-center justify-center mx-4">
|
|
||||||
<div class="container py-8 max-w-3xl pt-20">
|
|
||||||
<h1 class="text-3xl font-bold mb-6 text-center">
|
|
||||||
<i class="fas fa-user-cog mr-2"></i>
|
|
||||||
Account Settings
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{% if success %}
|
|
||||||
<div class="bg-green-200 dark:bg-green-900 text-green-800 dark:text-green-200 p-4 rounded-lg mb-6">
|
|
||||||
<i class="fas fa-check-circle mr-2"></i>{{ success }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if error %}
|
|
||||||
<div class="bg-red-200 dark:bg-red-900 text-red-800 dark:text-red-200 p-4 rounded-lg mb-6">
|
|
||||||
<i class="fas fa-exclamation-circle mr-2"></i>{{ error }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
|
||||||
<!-- Change Password Section -->
|
|
||||||
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
|
|
||||||
<h2 class="text-2xl font-bold mb-4">
|
|
||||||
<i class="fas fa-key mr-2"></i>
|
|
||||||
Change Password
|
|
||||||
</h2>
|
|
||||||
<form method="POST" action="/account/change-password" class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label for="current_password" class="block text-sm font-medium mb-2">Current Password</label>
|
|
||||||
<input type="password" name="current_password" id="current_password"
|
|
||||||
class="w-full p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border border-gray-600" required>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="new_password" class="block text-sm font-medium mb-2">New Password</label>
|
|
||||||
<input type="password" name="new_password" id="new_password"
|
|
||||||
class="w-full p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border border-gray-600" required>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="confirm_password" class="block text-sm font-medium mb-2">Confirm New Password</label>
|
|
||||||
<input type="password" name="confirm_password" id="confirm_password"
|
|
||||||
class="w-full p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border border-gray-600" required>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="w-full bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
|
|
||||||
<i class="fas fa-save"></i>
|
|
||||||
<span>Change Password</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Two-Factor Authentication Section -->
|
|
||||||
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
|
|
||||||
<h2 class="text-2xl font-bold mb-4">
|
|
||||||
<i class="fas fa-shield-alt mr-2"></i>
|
|
||||||
Two-Factor Authentication
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{% if totp_enabled %}
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="bg-green-200 dark:bg-green-900 text-green-800 dark:text-green-200 p-4 rounded-lg">
|
|
||||||
<i class="fas fa-check-circle mr-2"></i>
|
|
||||||
2FA is currently <strong>enabled</strong> for your account.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="POST" action="/account/disable-2fa" class="mt-4" onsubmit="return confirm('Are you sure you want to disable two-factor authentication? This will make your account less secure.');">
|
|
||||||
<input type="hidden" name="confirm_disable" value="true">
|
|
||||||
<button type="submit" class="w-full bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
<span>Disable 2FA</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="mt-6">
|
|
||||||
<h3 class="text-lg font-semibold mb-3">
|
|
||||||
<i class="fas fa-key mr-2"></i>
|
|
||||||
Backup Codes
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
|
||||||
Backup codes can be used to access your account if you lose your authenticator device.
|
|
||||||
Each code can only be used once.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{% if backup_codes %}
|
|
||||||
<div class="bg-gray-300 dark:bg-zinc-900 p-6 rounded-lg mb-4">
|
|
||||||
<div class="grid grid-cols-2 gap-4 font-mono text-center">
|
|
||||||
{% for code_pair in backup_codes %}
|
|
||||||
<div class="p-3 bg-gray-200 dark:bg-zinc-800 rounded border border-gray-400 dark:border-zinc-600">
|
|
||||||
{{ code_pair }}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button onclick="window.print()" class="w-full bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
|
|
||||||
<i class="fas fa-print"></i>
|
|
||||||
<span>Print Backup Codes</span>
|
|
||||||
</button>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
|
||||||
You don't have any backup codes. Generate new ones below.
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form method="POST" action="/account/regenerate-backup-codes" class="mt-4" onsubmit="return confirm('This will invalidate your existing backup codes. Are you sure you want to generate new ones?');">
|
|
||||||
<button type="submit" class="w-full bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
|
|
||||||
<i class="fas fa-redo"></i>
|
|
||||||
<span>Regenerate Backup Codes</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="bg-yellow-200 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 p-4 rounded-lg">
|
|
||||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
|
||||||
2FA is currently <strong>disabled</strong> for your account.
|
|
||||||
{% if role_requires_2fa %}
|
|
||||||
<br><strong>Note:</strong> Your role requires 2FA. You should enable it now.
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a href="/account/enable-2fa" class="block w-full bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center text-center">
|
|
||||||
<i class="fas fa-shield-alt"></i>
|
|
||||||
<span>Enable 2FA</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Add Device</title>
|
|
||||||
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
|
||||||
<link href="/static/css/output.css" rel="stylesheet">
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
|
||||||
{% include 'header.html' %}
|
|
||||||
<div class="flex-1 flex items-center justify-center mx-4">
|
|
||||||
<div class="container py-8 max-w-md pt-20">
|
|
||||||
<div class="flex items-center mb-6 relative">
|
|
||||||
<a href="/devices" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
|
|
||||||
<h1 class="text-3xl font-bold text-center w-full">Add Device</h1>
|
|
||||||
</div>
|
|
||||||
<form action="/add_device" method="POST" class="flex flex-col space-y-4">
|
|
||||||
<input type="text" name="device_name" placeholder="Device Name" class="p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600" required>
|
|
||||||
<label for="device_type" class="block mb-2">Device Type</label>
|
|
||||||
<select id="device_type" name="device_type" class="p-2 rounded w-full mb-4 bg-gray-200 dark:bg-zinc-800 border border-gray-600" required>
|
|
||||||
{% for dtype in device_types %}
|
|
||||||
<option value="{{ dtype[0] }}">{{ dtype[1] }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">Add Device</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Add Rack</title>
|
|
||||||
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
|
||||||
<link href="/static/css/output.css" rel="stylesheet">
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
|
||||||
{% include 'header.html' %}
|
|
||||||
<div class="flex-1 flex items-center justify-center mx-4">
|
|
||||||
<div class="container py-8 max-w-md pt-20">
|
|
||||||
<div class="flex items-center mb-6 relative">
|
|
||||||
<a href="/racks" class="absolute left-0 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 flex items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
|
|
||||||
<h1 class="text-3xl font-bold text-center w-full">Add Rack</h1>
|
|
||||||
</div>
|
|
||||||
<form action="/rack/add" method="POST" class="space-y-6 bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
|
|
||||||
<div>
|
|
||||||
<label for="name" class="block font-medium mb-1">Rack Name</label>
|
|
||||||
<input type="text" id="name" name="name" class="p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600 w-full" required>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="site" class="block font-medium mb-1">Site</label>
|
|
||||||
<input type="text" id="site" name="site" class="p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600 w-full" required>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="height_u" class="block font-medium mb-1">Height (U)</label>
|
|
||||||
<input type="number" id="height_u" name="height_u" min="1" max="60" class="p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border border-gray-600 w-full" required>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg w-full">Add Rack</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Admin Panel</title>
|
|
||||||
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
|
||||||
<link href="/static/css/output.css" rel="stylesheet">
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
|
||||||
{% include 'header.html' %}
|
|
||||||
<div class="flex-1 mx-4 py-8 pt-20">
|
|
||||||
<div class="container max-w-7xl mx-auto">
|
|
||||||
<h1 class="text-3xl font-bold mb-6 text-center">Admin Panel</h1>
|
|
||||||
|
|
||||||
{% if error %}
|
|
||||||
<div class="bg-red-200 dark:bg-red-800 text-red-800 dark:text-red-200 p-4 rounded-lg mb-6">
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Quick Links -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
|
||||||
<a href="/audit" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<i class="fas fa-clipboard-list text-3xl text-gray-600 dark:text-gray-400"></i>
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-bold">Audit Log</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">View system activity</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
|
||||||
</a>
|
|
||||||
<a href="/users" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<i class="fas fa-users text-3xl text-gray-600 dark:text-gray-400"></i>
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-bold">User Management</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">Manage users & roles</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
|
||||||
</a>
|
|
||||||
{% if has_permission('view_tags') %}
|
|
||||||
<a href="/tags" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<i class="fas fa-tags text-3xl text-gray-600 dark:text-gray-400"></i>
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-bold">Tag Management</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">Manage device tags</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if has_permission('view_custom_fields') %}
|
|
||||||
<a href="/custom_fields" class="bg-gray-200 dark:bg-zinc-800 hover:bg-gray-300 dark:hover:bg-zinc-700 p-6 rounded-lg shadow-md flex items-center justify-between transition-colors">
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<i class="fas fa-list-ul text-3xl text-gray-600 dark:text-gray-400"></i>
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-bold">Custom Fields</h3>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">Manage custom fields for devices and subnets</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<i class="fas fa-chevron-right text-gray-400"></i>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Subnet Management Section -->
|
|
||||||
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
|
|
||||||
<div class="flex justify-between items-center mb-6">
|
|
||||||
<h2 class="text-2xl font-bold">Subnet Management</h2>
|
|
||||||
{% if can_add_subnet %}
|
|
||||||
<button onclick="showAddSubnetModal()" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg font-medium">
|
|
||||||
<i class="fas fa-plus mr-2"></i>Add Subnet
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if subnets %}
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr class="border-b border-gray-600">
|
|
||||||
<th class="text-center p-3">Name</th>
|
|
||||||
<th class="text-center p-3">CIDR</th>
|
|
||||||
<th class="text-center p-3">Site</th>
|
|
||||||
<th class="text-center p-3">VLAN ID</th>
|
|
||||||
<th class="text-center p-3">Utilisation</th>
|
|
||||||
<th class="text-center p-3">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for subnet in subnets %}
|
|
||||||
<tr class="border-b border-gray-600 hover:bg-gray-300 dark:hover:bg-zinc-700">
|
|
||||||
<td class="p-3 font-medium text-center">{{ subnet.name }}</td>
|
|
||||||
<td class="p-3 font-mono text-sm text-center">{{ subnet.cidr }}</td>
|
|
||||||
<td class="p-3 text-center">
|
|
||||||
<span class="px-2 py-1 bg-gray-300 dark:bg-zinc-700 rounded text-sm">{{ subnet.site }}</span>
|
|
||||||
</td>
|
|
||||||
<td class="p-3 text-center">
|
|
||||||
{% if subnet.vlan_id %}
|
|
||||||
<span class="px-2 py-1 bg-gray-300 dark:bg-zinc-700 rounded text-sm font-mono">{{ subnet.vlan_id }}</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-sm text-gray-500">—</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="p-3 text-center">
|
|
||||||
{% if subnet.utilization %}
|
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{ subnet.utilization.percent }}%</span>
|
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-500 ml-1">({{ subnet.utilization.used }}/{{ subnet.utilization.total }})</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-sm text-gray-500">—</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="p-3 text-center">
|
|
||||||
<div class="flex items-center justify-center space-x-2">
|
|
||||||
<a href="/subnet/{{ subnet.id }}" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300" title="View Subnet">
|
|
||||||
<i class="fas fa-eye"></i>
|
|
||||||
</a>
|
|
||||||
{% if can_edit_subnet %}
|
|
||||||
<button onclick="editSubnet({{ subnet.id }}, '{{ subnet.name|replace("'", "\\'") }}', '{{ subnet.cidr|replace("'", "\\'") }}', '{{ subnet.site|replace("'", "\\'") }}', {{ subnet.vlan_id if subnet.vlan_id else 'null' }}, '{{ subnet.vlan_description|replace("'", "\\'") if subnet.vlan_description else "" }}', '{{ subnet.vlan_notes|replace("'", "\\'")|replace("\n", "\\n") if subnet.vlan_notes else "" }}')" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Edit Subnet">
|
|
||||||
<i class="fas fa-edit"></i>
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
{% if can_delete_subnet %}
|
|
||||||
<form action="/delete_subnet" method="POST" onsubmit="return confirm('Are you sure you want to delete this subnet and all its IPs? This action is irreversible.');" class="inline">
|
|
||||||
<input type="hidden" name="subnet_id" value="{{ subnet.id }}">
|
|
||||||
<button type="submit" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Delete Subnet">
|
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center py-8 text-gray-500">
|
|
||||||
<i class="fas fa-network-wired text-4xl mb-4"></i>
|
|
||||||
<p>No subnets found. Add your first subnet to get started.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add Subnet Modal -->
|
|
||||||
<div id="add-subnet-modal" class="hidden fixed inset-0 bg-black/30 backdrop-blur-md flex items-center justify-center z-50">
|
|
||||||
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-lg max-w-md w-full mx-4">
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h2 class="text-xl font-bold">Add New Subnet</h2>
|
|
||||||
<button onclick="closeAddSubnetModal()" class="text-gray-500 hover:text-gray-700">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form action="/add_subnet" method="POST" onsubmit="return validateSubnetForm();">
|
|
||||||
<div class="space-y-4">
|
|
||||||
<input type="text" name="name" id="add-subnet-name" placeholder="Subnet Name" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
|
|
||||||
<input type="text" name="cidr" id="add-subnet-cidr" placeholder="CIDR (e.g., 192.168.1.0/24)" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
|
|
||||||
<input type="text" name="site" id="add-subnet-site" placeholder="Site/Location" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
|
|
||||||
<input type="number" name="vlan_id" id="add-subnet-vlan-id" placeholder="VLAN ID (1-4094, optional)" min="1" max="4094" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full">
|
|
||||||
<input type="text" name="vlan_description" id="add-subnet-vlan-description" placeholder="VLAN Description (optional)" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full">
|
|
||||||
<textarea name="vlan_notes" id="add-subnet-vlan-notes" placeholder="VLAN Notes (optional)" rows="3" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full resize-y"></textarea>
|
|
||||||
<span id="cidr-error" class="text-red-500 text-sm hidden"></span>
|
|
||||||
<span id="vlan-id-error" class="text-red-500 text-sm hidden"></span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end space-x-2 mt-6">
|
|
||||||
<button type="button" onclick="closeAddSubnetModal()" class="px-4 py-2 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
|
|
||||||
<button type="submit" class="px-4 py-2 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Add Subnet</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Edit Subnet Modal -->
|
|
||||||
<div id="edit-subnet-modal" class="hidden fixed inset-0 bg-black/30 backdrop-blur-md flex items-center justify-center z-50">
|
|
||||||
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-lg max-w-md w-full mx-4">
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h2 class="text-xl font-bold">Edit Subnet</h2>
|
|
||||||
<button onclick="closeEditSubnetModal()" class="text-gray-500 hover:text-gray-700">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form action="/edit_subnet" method="POST" onsubmit="return validateEditSubnetForm();">
|
|
||||||
<input type="hidden" name="subnet_id" id="edit-subnet-id">
|
|
||||||
<div class="space-y-4">
|
|
||||||
<input type="text" name="name" id="edit-subnet-name" placeholder="Subnet Name" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
|
|
||||||
<input type="text" name="cidr" id="edit-subnet-cidr" placeholder="CIDR (e.g., 192.168.1.0/24)" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
|
|
||||||
<input type="text" name="site" id="edit-subnet-site" placeholder="Site/Location" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
|
|
||||||
<input type="number" name="vlan_id" id="edit-subnet-vlan-id" placeholder="VLAN ID (1-4094, optional)" min="1" max="4094" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full">
|
|
||||||
<input type="text" name="vlan_description" id="edit-subnet-vlan-description" placeholder="VLAN Description (optional)" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full">
|
|
||||||
<textarea name="vlan_notes" id="edit-subnet-vlan-notes" placeholder="VLAN Notes (optional)" rows="3" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full resize-y"></textarea>
|
|
||||||
<span id="edit-cidr-error" class="text-red-500 text-sm hidden"></span>
|
|
||||||
<span id="edit-vlan-id-error" class="text-red-500 text-sm hidden"></span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end space-x-2 mt-6">
|
|
||||||
<button type="button" onclick="closeEditSubnetModal()" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
|
|
||||||
<button type="submit" class="px-4 py-2 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Save Changes</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/static/js/add_subnet.min.js"></script>
|
|
||||||
<script src="/static/js/admin.min.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Audit Log</title>
|
|
||||||
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
|
||||||
<link href="/static/css/output.css" rel="stylesheet">
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
|
||||||
{% include 'header.html' %}
|
|
||||||
<div class="flex-1 flex items-center justify-center mx-4">
|
|
||||||
<div class="container py-8 max-w-8xl pt-20">
|
|
||||||
<h1 class="text-3xl font-bold mb-6 text-center">Audit Log</h1>
|
|
||||||
|
|
||||||
<!-- Collapsible Filter Section -->
|
|
||||||
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg mb-6">
|
|
||||||
<button type="button" id="filter-toggle" class="w-full flex items-center justify-between p-4 hover:bg-gray-300 dark:hover:bg-zinc-700 rounded-lg transition-colors hover:cursor-pointer">
|
|
||||||
<h2 class="text-lg font-semibold">Filters</h2>
|
|
||||||
<i class="fas fa-chevron-down transition-transform duration-200 transform" id="filter-arrow"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Advanced Filter Form -->
|
|
||||||
<form method="GET" id="audit-filter-form" class="px-4 pb-4 {% if not (search_query or selected_user_ids or request.args.get('subnet_id') or request.args.get('action') or request.args.get('device_name') or date_from or date_to or request.args.get('expand_filters')) %}hidden{% endif %}">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
|
|
||||||
<!-- Search -->
|
|
||||||
<div class="lg:col-span-3">
|
|
||||||
<label class="block text-sm font-medium mb-1">Search</label>
|
|
||||||
<input type="text" name="search" value="{{ search_query or '' }}" placeholder="Search in details, user, action, subnet..." class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Multiple Users -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1">Users</label>
|
|
||||||
<select name="user_ids" multiple size="5" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
|
|
||||||
{% for user in users %}
|
|
||||||
<option value="{{ user[0] }}" {% if selected_user_ids and user[0]|string in selected_user_ids %}selected{% endif %}>{{ user[1] }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">Hold Ctrl/Cmd to select multiple</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Subnet -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1">Subnet</label>
|
|
||||||
<select name="subnet_id" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
|
|
||||||
<option value="">All Subnets</option>
|
|
||||||
{% for subnet in subnets %}
|
|
||||||
<option value="{{ subnet[0] }}" {% if request.args.get('subnet_id') == subnet[0]|string %}selected{% endif %}>{{ subnet[1] }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1">Action</label>
|
|
||||||
<select name="action" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
|
|
||||||
<option value="">All Actions</option>
|
|
||||||
{% for a in actions %}
|
|
||||||
<option value="{{ a }}" {% if request.args.get('action') == a %}selected{% endif %}>{{ a }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Device Name -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1">Device</label>
|
|
||||||
<select name="device_name" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
|
|
||||||
<option value="">All Devices</option>
|
|
||||||
{% for device in devices %}
|
|
||||||
<option value="{{ device[0] }}" {% if request.args.get('device_name') == device[0] %}selected{% endif %}>{{ device[0] }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Date From -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1">Date From</label>
|
|
||||||
<input type="date" name="date_from" value="{{ date_from or '' }}" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Date To -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1">Date To</label>
|
|
||||||
<input type="date" name="date_to" value="{{ date_to or '' }}" class="w-full border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2 justify-center">
|
|
||||||
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
|
|
||||||
<i class="fas fa-search"></i>
|
|
||||||
<span>Filter</span>
|
|
||||||
</button>
|
|
||||||
<a href="/audit?expand_filters=1" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
<span>Clear</span>
|
|
||||||
</a>
|
|
||||||
<button type="button" id="export-btn" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg flex items-center gap-2">
|
|
||||||
<i class="fas fa-file-csv"></i>
|
|
||||||
<span>Export CSV</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Audit Log Table -->
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full table-auto bg-gray-200 dark:bg-zinc-800 rounded-lg overflow-hidden">
|
|
||||||
<thead>
|
|
||||||
<tr class="bg-gray-400 dark:bg-zinc-700">
|
|
||||||
<th class="px-4 py-2 text-center">User</th>
|
|
||||||
<th class="px-4 py-2 text-center">Action</th>
|
|
||||||
<th class="px-4 py-2 text-center details-cell">Details</th>
|
|
||||||
<th class="px-4 py-2 text-center">Subnet</th>
|
|
||||||
<th class="px-4 py-2 text-center">Timestamp</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for log in logs %}
|
|
||||||
<tr class="border-b border-gray-700">
|
|
||||||
<td class="px-4 py-2 text-center">{{ log[1] or 'Unknown' }}</td>
|
|
||||||
<td class="px-4 py-2 text-center">{{ log[2] }}</td>
|
|
||||||
<td class="px-4 py-2 text-center details-cell">
|
|
||||||
<div class="diff-container" data-details="{{ log[3]|e }}"></div>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2 text-center">{{ log[4] or 'N/A' }}</td>
|
|
||||||
<td class="px-4 py-2 text-center" data-utc="{{ log[5] }}">{{ log[5] }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if total_pages > 1 %}
|
|
||||||
<div class="flex justify-center mt-6 space-x-2">
|
|
||||||
{% if page > 1 %}
|
|
||||||
{% set prev_args = query_args.copy() %}
|
|
||||||
{% set _ = prev_args.update({'page': page-1}) %}
|
|
||||||
<a href="{{ url_for('audit', **prev_args) }}" class="px-3 py-1 rounded bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer flex items-center gap-2">
|
|
||||||
<i class="fa fa-angle-left"></i>
|
|
||||||
<span class="hidden sm:inline">Prev</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# Smart pagination logic #}
|
|
||||||
{% set delta = 2 %}
|
|
||||||
{% set start_page = [1, page - delta]|max %}
|
|
||||||
{% set end_page = [total_pages, page + delta]|min %}
|
|
||||||
|
|
||||||
{# Show first page if we're not near the start #}
|
|
||||||
{% if start_page > 1 %}
|
|
||||||
{% set page_args = query_args.copy() %}
|
|
||||||
{% set _ = page_args.update({'page': 1}) %}
|
|
||||||
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded hover:cursor-pointer {{ 'bg-gray-200 dark:bg-gray-500' if 1 == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">1</a>
|
|
||||||
{% if start_page > 2 %}
|
|
||||||
<span class="px-3 py-1 text-gray-600 dark:text-gray-400">…</span>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# Show pages around current page #}
|
|
||||||
{% for p in range(start_page, end_page + 1) %}
|
|
||||||
{% set page_args = query_args.copy() %}
|
|
||||||
{% set _ = page_args.update({'page': p}) %}
|
|
||||||
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded hover:cursor-pointer {{ 'bg-gray-200 dark:bg-gray-500' if p == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">{{ p }}</a>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{# Show last page if we're not near the end #}
|
|
||||||
{% if end_page < total_pages %}
|
|
||||||
{% if end_page < total_pages - 1 %}
|
|
||||||
<span class="px-3 py-1 text-gray-600 dark:text-gray-400">…</span>
|
|
||||||
{% endif %}
|
|
||||||
{% set page_args = query_args.copy() %}
|
|
||||||
{% set _ = page_args.update({'page': total_pages}) %}
|
|
||||||
<a href="{{ url_for('audit', **page_args) }}" class="px-3 py-1 rounded hover:cursor-pointer {{ 'bg-gray-200 dark:bg-gray-500' if total_pages == page else 'bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600' }}">{{ total_pages }}</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if page < total_pages %}
|
|
||||||
{% set next_args = query_args.copy() %}
|
|
||||||
{% set _ = next_args.update({'page': page+1}) %}
|
|
||||||
<a href="{{ url_for('audit', **next_args) }}" class="px-3 py-1 rounded bg-gray-400 hover:bg-gray-200 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer flex items-center gap-2">
|
|
||||||
<span class="hidden sm:inline">Next</span>
|
|
||||||
<i class="fa fa-angle-right"></i>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script src="/static/js/audit.min.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Bulk Operations</title>
|
|
||||||
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
|
||||||
<link href="/static/css/output.css" rel="stylesheet">
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
|
||||||
{% include 'header.html' %}
|
|
||||||
<div class="flex-1 mx-4 py-8 pt-20">
|
|
||||||
<div class="container max-w-6xl mx-auto">
|
|
||||||
<div class="flex items-center mb-6 relative">
|
|
||||||
<a href="/devices" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
|
|
||||||
<h1 class="text-3xl font-bold text-center w-full">Bulk Operations</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tabs -->
|
|
||||||
<div class="mb-6 border-b border-gray-600">
|
|
||||||
<div class="flex space-x-4">
|
|
||||||
<button onclick="showTab('assign-ips')" id="tab-assign-ips" class="tab-btn px-4 py-2 font-medium border-b-2 border-gray-600 text-gray-900 dark:text-gray-100 hover:cursor-pointer">Bulk IP Assignment</button>
|
|
||||||
<button onclick="showTab('create-devices')" id="tab-create-devices" class="tab-btn px-4 py-2 font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:cursor-pointer">Bulk Device Creation</button>
|
|
||||||
<button onclick="showTab('assign-tags')" id="tab-assign-tags" class="tab-btn px-4 py-2 font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:cursor-pointer">Bulk Tag Assignment</button>
|
|
||||||
<button onclick="showTab('export')" id="tab-export" class="tab-btn px-4 py-2 font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:cursor-pointer">Bulk Export</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bulk IP Assignment -->
|
|
||||||
<div id="panel-assign-ips" class="tab-panel bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
|
|
||||||
{% if can_add_device_ip %}
|
|
||||||
<h2 class="text-2xl font-bold mb-4">Bulk IP Assignment</h2>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400 mb-4">Select a device and assign multiple IPs from a subnet. Hold Ctrl/Cmd to select multiple IPs.</p>
|
|
||||||
<form id="bulk-assign-ips-form" class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="block mb-2 font-medium">Select Device:</label>
|
|
||||||
<select id="bulk-device-select" name="device_id" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
|
|
||||||
<option value="">Select a device...</option>
|
|
||||||
{% for device in devices %}
|
|
||||||
<option value="{{ device[0] }}">{{ device[1] }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block mb-2 font-medium">Select Subnet:</label>
|
|
||||||
<select id="bulk-subnet-select" name="subnet_id" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
|
|
||||||
<option value="">Select a subnet...</option>
|
|
||||||
{% for subnet in subnets %}
|
|
||||||
<option value="{{ subnet[0] }}">{{ subnet[1] }} ({{ subnet[2] }}) - {{ subnet[3] or 'Unassigned' }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block mb-2 font-medium">Select IPs (hold Ctrl/Cmd to select multiple):</label>
|
|
||||||
<select id="bulk-ip-select" name="ip_ids[]" multiple size="15" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
|
|
||||||
<option value="" disabled>Select a subnet first...</option>
|
|
||||||
</select>
|
|
||||||
<p class="text-sm text-gray-500 mt-1">Selected: <span id="selected-ip-count">0</span> IPs</p>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg">Assign IPs</button>
|
|
||||||
</form>
|
|
||||||
<div id="assign-ips-result" class="mt-4 hidden"></div>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-gray-500">You don't have permission to assign IPs to devices.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bulk Device Creation -->
|
|
||||||
<div id="panel-create-devices" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
|
|
||||||
{% if can_add_device %}
|
|
||||||
<h2 class="text-2xl font-bold mb-4">Bulk Device Creation</h2>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400 mb-4">Create multiple devices at once. Enter one device name per line.</p>
|
|
||||||
<form id="bulk-create-devices-form" class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="block mb-2 font-medium">Device Names (one per line):</label>
|
|
||||||
<textarea id="device-names" name="device_names" rows="10" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" placeholder="Device 1 Device 2 Device 3" required></textarea>
|
|
||||||
<p class="text-sm text-gray-500 mt-1">Enter device names, one per line</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block mb-2 font-medium">Device Type:</label>
|
|
||||||
<select name="device_type" class="border p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
|
|
||||||
{% for dtype in device_types %}
|
|
||||||
<option value="{{ dtype[0] }}">{{ dtype[1] }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg">Create Devices</button>
|
|
||||||
</form>
|
|
||||||
<div id="create-devices-result" class="mt-4 hidden"></div>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-gray-500">You don't have permission to create devices.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bulk Tag Assignment -->
|
|
||||||
<div id="panel-assign-tags" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
|
|
||||||
{% if can_assign_device_tag %}
|
|
||||||
<h2 class="text-2xl font-bold mb-4">Bulk Tag Assignment</h2>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400 mb-4">Select multiple devices and assign one or more tags to them.</p>
|
|
||||||
<form id="bulk-assign-tags-form" class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="block mb-2 font-medium">Select Devices (hold Ctrl/Cmd to select multiple):</label>
|
|
||||||
<select id="bulk-tag-device-select" name="device_ids[]" multiple size="10" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full">
|
|
||||||
{% for device in devices %}
|
|
||||||
<option value="{{ device[0] }}">{{ device[1] }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<p class="text-sm text-gray-500 mt-1">Selected: <span id="selected-tag-device-count">0</span> devices</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block mb-2 font-medium">Select Tags (hold Ctrl/Cmd to select multiple):</label>
|
|
||||||
<select name="tag_ids[]" multiple size="5" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
|
|
||||||
{% for tag in tags %}
|
|
||||||
<option value="{{ tag[0] }}">{{ tag[1] }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg">Assign Tags</button>
|
|
||||||
</form>
|
|
||||||
<div id="assign-tags-result" class="mt-4 hidden"></div>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-gray-500">You don't have permission to assign tags to devices.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bulk Export -->
|
|
||||||
<div id="panel-export" class="tab-panel hidden bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
|
|
||||||
{% if can_export_subnet_csv %}
|
|
||||||
<h2 class="text-2xl font-bold mb-4">Bulk Subnet Export</h2>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400 mb-4">Select multiple subnets and export them to a single CSV file.</p>
|
|
||||||
<form id="bulk-export-form" method="POST" action="/bulk/export_subnets" class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="block mb-2 font-medium">Select Subnets (hold Ctrl/Cmd to select multiple):</label>
|
|
||||||
<select name="subnet_ids[]" multiple size="10" class="border p-2 rounded-lg bg-gray-300 dark:bg-zinc-900 border-gray-600 w-full" required>
|
|
||||||
{% for subnet in subnets %}
|
|
||||||
<option value="{{ subnet[0] }}">{{ subnet[1] }} ({{ subnet[2] }}) - {{ subnet[3] or 'Unassigned' }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg">Export to CSV</button>
|
|
||||||
</form>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-gray-500">You don't have permission to export subnets.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/static/js/bulk_operations.min.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Custom Fields Management</title>
|
|
||||||
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
|
||||||
<link href="/static/css/output.css" rel="stylesheet">
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
|
||||||
{% include 'header.html' %}
|
|
||||||
<div class="flex-1 mx-4 py-8 pt-20">
|
|
||||||
<div class="container max-w-7xl mx-auto">
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h1 class="text-3xl font-bold">Custom Fields Management</h1>
|
|
||||||
{% if can_manage %}
|
|
||||||
<button onclick="showAddFieldModal()" id="add-field-btn" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-6 py-2 rounded-lg font-medium">
|
|
||||||
<i class="fas fa-plus mr-2"></i>Add Field
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if error %}
|
|
||||||
<div class="bg-red-200 dark:bg-red-800 text-red-800 dark:text-red-200 p-4 rounded-lg mb-6">
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Tabs -->
|
|
||||||
<div class="mb-6 border-b border-gray-600">
|
|
||||||
<div class="flex space-x-4">
|
|
||||||
<button onclick="switchTab('device')" id="tab-device" class="tab-btn px-4 py-2 font-medium border-b-2 {% if active_tab == 'device' %}border-gray-600 text-gray-900 dark:text-gray-100{% else %}border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300{% endif %} hover:cursor-pointer">
|
|
||||||
Device Fields
|
|
||||||
</button>
|
|
||||||
<button onclick="switchTab('subnet')" id="tab-subnet" class="tab-btn px-4 py-2 font-medium border-b-2 {% if active_tab == 'subnet' %}border-gray-600 text-gray-900 dark:text-gray-100{% else %}border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300{% endif %} hover:cursor-pointer">
|
|
||||||
Subnet Fields
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Device Fields Tab -->
|
|
||||||
<div id="device-fields-tab" class="tab-content {% if active_tab != 'device' %}hidden{% endif %}">
|
|
||||||
{% if device_fields %}
|
|
||||||
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr class="border-b border-gray-600">
|
|
||||||
<th class="text-left p-3">Order</th>
|
|
||||||
<th class="text-left p-3">Name</th>
|
|
||||||
<th class="text-left p-3">Field Key</th>
|
|
||||||
<th class="text-left p-3">Type</th>
|
|
||||||
<th class="text-center p-3">Required</th>
|
|
||||||
<th class="text-center p-3">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="device-fields-tbody">
|
|
||||||
{% for field in device_fields %}
|
|
||||||
<tr class="border-b border-gray-600 hover:bg-gray-300 dark:hover:bg-zinc-700" data-field-id="{{ field.id }}">
|
|
||||||
<td class="p-3">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<button onclick="moveField('device', {{ field.id }}, 'up')" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" title="Move Up">
|
|
||||||
<i class="fas fa-arrow-up text-xs"></i>
|
|
||||||
</button>
|
|
||||||
<span class="text-sm">{{ field.display_order }}</span>
|
|
||||||
<button onclick="moveField('device', {{ field.id }}, 'down')" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" title="Move Down">
|
|
||||||
<i class="fas fa-arrow-down text-xs"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="p-3 font-medium">{{ field.name }}</td>
|
|
||||||
<td class="p-3 font-mono text-sm">{{ field.field_key }}</td>
|
|
||||||
<td class="p-3 text-sm">{{ field.field_type }}</td>
|
|
||||||
<td class="p-3 text-center">
|
|
||||||
{% if field.required %}
|
|
||||||
<i class="fas fa-check text-green-500"></i>
|
|
||||||
{% else %}
|
|
||||||
<i class="fas fa-times text-gray-400"></i>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="p-3 text-center">
|
|
||||||
<div class="flex items-center justify-center space-x-2">
|
|
||||||
{% if can_manage %}
|
|
||||||
<button onclick="editField({{ field.id }}, 'device')" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Edit Field">
|
|
||||||
<i class="fas fa-edit"></i>
|
|
||||||
</button>
|
|
||||||
<form action="/custom_fields" method="POST" onsubmit="return confirm('Are you sure you want to delete this field? Existing data will be preserved.');" class="inline">
|
|
||||||
<input type="hidden" name="action" value="delete_field">
|
|
||||||
<input type="hidden" name="field_id" value="{{ field.id }}">
|
|
||||||
<button type="submit" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Delete Field">
|
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
|
|
||||||
<div class="text-center py-8 text-gray-500">
|
|
||||||
<i class="fas fa-list text-4xl mb-4"></i>
|
|
||||||
<p>No device custom fields defined. Add your first field to get started.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Subnet Fields Tab -->
|
|
||||||
<div id="subnet-fields-tab" class="tab-content {% if active_tab != 'subnet' %}hidden{% endif %}">
|
|
||||||
{% if subnet_fields %}
|
|
||||||
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr class="border-b border-gray-600">
|
|
||||||
<th class="text-left p-3">Order</th>
|
|
||||||
<th class="text-left p-3">Name</th>
|
|
||||||
<th class="text-left p-3">Field Key</th>
|
|
||||||
<th class="text-left p-3">Type</th>
|
|
||||||
<th class="text-center p-3">Required</th>
|
|
||||||
<th class="text-center p-3">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="subnet-fields-tbody">
|
|
||||||
{% for field in subnet_fields %}
|
|
||||||
<tr class="border-b border-gray-600 hover:bg-gray-300 dark:hover:bg-zinc-700" data-field-id="{{ field.id }}">
|
|
||||||
<td class="p-3">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<button onclick="moveField('subnet', {{ field.id }}, 'up')" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" title="Move Up">
|
|
||||||
<i class="fas fa-arrow-up text-xs"></i>
|
|
||||||
</button>
|
|
||||||
<span class="text-sm">{{ field.display_order }}</span>
|
|
||||||
<button onclick="moveField('subnet', {{ field.id }}, 'down')" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" title="Move Down">
|
|
||||||
<i class="fas fa-arrow-down text-xs"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="p-3 font-medium">{{ field.name }}</td>
|
|
||||||
<td class="p-3 font-mono text-sm">{{ field.field_key }}</td>
|
|
||||||
<td class="p-3 text-sm">{{ field.field_type }}</td>
|
|
||||||
<td class="p-3 text-center">
|
|
||||||
{% if field.required %}
|
|
||||||
<i class="fas fa-check text-green-500"></i>
|
|
||||||
{% else %}
|
|
||||||
<i class="fas fa-times text-gray-400"></i>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="p-3 text-center">
|
|
||||||
<div class="flex items-center justify-center space-x-2">
|
|
||||||
{% if can_manage %}
|
|
||||||
<button onclick="editField({{ field.id }}, 'subnet')" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Edit Field">
|
|
||||||
<i class="fas fa-edit"></i>
|
|
||||||
</button>
|
|
||||||
<form action="/custom_fields" method="POST" onsubmit="return confirm('Are you sure you want to delete this field? Existing data will be preserved.');" class="inline">
|
|
||||||
<input type="hidden" name="action" value="delete_field">
|
|
||||||
<input type="hidden" name="field_id" value="{{ field.id }}">
|
|
||||||
<button type="submit" class="text-gray-900 hover:text-gray-600 dark:text-gray-100 dark:hover:text-gray-300 hover:cursor-pointer" title="Delete Field">
|
|
||||||
<i class="fas fa-trash"></i>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
|
|
||||||
<div class="text-center py-8 text-gray-500">
|
|
||||||
<i class="fas fa-list text-4xl mb-4"></i>
|
|
||||||
<p>No subnet custom fields defined. Add your first field to get started.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add/Edit Field Modal -->
|
|
||||||
<div id="field-modal" class="hidden fixed inset-0 bg-black/30 backdrop-blur-md flex items-center justify-center z-50">
|
|
||||||
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-lg max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
|
||||||
<div class="flex justify-between items-center mb-4">
|
|
||||||
<h2 class="text-xl font-bold" id="modal-title">Add Custom Field</h2>
|
|
||||||
<button onclick="closeFieldModal()" class="text-gray-500 hover:text-gray-700">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form id="field-form" action="/custom_fields" method="POST">
|
|
||||||
<input type="hidden" name="action" id="form-action" value="add_field">
|
|
||||||
<input type="hidden" name="field_id" id="form-field-id">
|
|
||||||
<input type="hidden" name="entity_type" id="form-entity-type">
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1">Field Name *</label>
|
|
||||||
<input type="text" name="name" id="field-name" placeholder="e.g., Serial Number"
|
|
||||||
class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1">Field Key *</label>
|
|
||||||
<input type="text" name="field_key" id="field-key" placeholder="e.g., serial_number"
|
|
||||||
class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full font-mono text-sm" required>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">Internal identifier (lowercase, underscores only)</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1">Field Type *</label>
|
|
||||||
<select name="field_type" id="field-type"
|
|
||||||
class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required onchange="updateFieldTypeOptions()">
|
|
||||||
<option value="text">Text</option>
|
|
||||||
<option value="textarea">Textarea</option>
|
|
||||||
<option value="ip_address">IP Address</option>
|
|
||||||
<option value="date">Date</option>
|
|
||||||
<option value="datetime">Date & Time</option>
|
|
||||||
<option value="number">Number (Integer)</option>
|
|
||||||
<option value="decimal">Decimal/Float</option>
|
|
||||||
<option value="email">Email</option>
|
|
||||||
<option value="url">URL</option>
|
|
||||||
<option value="boolean">Boolean/Checkbox</option>
|
|
||||||
<option value="select">Select/Dropdown</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<input type="checkbox" name="required" id="field-required" class="w-4 h-4">
|
|
||||||
<label for="field-required" class="text-sm font-medium">Required</label>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1">Default Value</label>
|
|
||||||
<input type="text" name="default_value" id="field-default-value" placeholder="Default value (optional)"
|
|
||||||
class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1">Help Text</label>
|
|
||||||
<textarea name="help_text" id="field-help-text" placeholder="Help text/description (optional)" rows="2"
|
|
||||||
class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full resize-y"></textarea>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1">Display Order</label>
|
|
||||||
<input type="number" name="display_order" id="field-display-order" value="0" min="0"
|
|
||||||
class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Validation Rules Section -->
|
|
||||||
<div id="validation-rules-section" class="border-t border-gray-600 pt-4">
|
|
||||||
<h3 class="text-lg font-medium mb-3">Validation Rules</h3>
|
|
||||||
|
|
||||||
<!-- Text/Textarea validation -->
|
|
||||||
<div id="text-validation" class="hidden space-y-2">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1">Min Length</label>
|
|
||||||
<input type="number" name="min_length" id="field-min-length" min="0"
|
|
||||||
class="border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1">Max Length</label>
|
|
||||||
<input type="number" name="max_length" id="field-max-length" min="1"
|
|
||||||
class="border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1">Regex Pattern</label>
|
|
||||||
<input type="text" name="regex_pattern" id="field-regex-pattern" placeholder="^[A-Z].*$"
|
|
||||||
class="border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full font-mono text-sm">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Number/Decimal validation -->
|
|
||||||
<div id="number-validation" class="hidden space-y-2">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1">Min Value</label>
|
|
||||||
<input type="number" name="min_value" id="field-min-value" step="any"
|
|
||||||
class="border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1">Max Value</label>
|
|
||||||
<input type="number" name="max_value" id="field-max-value" step="any"
|
|
||||||
class="border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Select validation -->
|
|
||||||
<div id="select-validation" class="hidden">
|
|
||||||
<label class="block text-sm font-medium mb-1">Options (comma-separated) *</label>
|
|
||||||
<input type="text" name="select_options" id="field-select-options" placeholder="option1, option2, option3"
|
|
||||||
class="border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full">
|
|
||||||
<p class="text-xs text-gray-500 mt-1">Enter options separated by commas</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end space-x-2 mt-6">
|
|
||||||
<button type="button" onclick="closeFieldModal()"
|
|
||||||
class="px-4 py-2 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Cancel</button>
|
|
||||||
<button type="submit"
|
|
||||||
class="px-4 py-2 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer rounded-lg">Save Field</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Embed field data for JavaScript -->
|
|
||||||
<script type="application/json" id="fields-data">
|
|
||||||
{
|
|
||||||
"device": {{ device_fields|tojson }},
|
|
||||||
"subnet": {{ subnet_fields|tojson }}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<script src="/static/js/custom_fields.min.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>{{ device.name }} - Device Details</title>
|
|
||||||
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
|
||||||
<link href="/static/css/output.css" rel="stylesheet">
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
|
||||||
{% include 'header.html' %}
|
|
||||||
<div class="flex-1 flex items-center justify-center mx-4">
|
|
||||||
<div class="container py-8 max-w-2xl pt-20">
|
|
||||||
<div class="flex items-center mb-8 relative justify-between gap-4">
|
|
||||||
<a href="/devices" class="hidden sm:flex bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 flex items-center justify-center rounded-full w-11 h-11 shrink-0"><i class="fas fa-arrow-left"></i></a>
|
|
||||||
<h1 class="text-3xl font-bold text-center flex-1 min-w-0 truncate">{{ device.name }}</h1>
|
|
||||||
<form action="/update_device_type" method="POST" class="hidden md:inline ml-2">
|
|
||||||
<input type="hidden" name="device_id" value="{{ device.id }}">
|
|
||||||
<select name="device_type_id" class="border p-2 rounded bg-gray-200 dark:bg-zinc-800 border-gray-600" onchange="this.form.submit()">
|
|
||||||
{% for dtype in device_types %}
|
|
||||||
<option value="{{ dtype[0] }}" {% if device.device_type_id == dtype[0] %}selected{% endif %}>{{ dtype[1] }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</form>
|
|
||||||
<div class="flex items-center shrink-0">
|
|
||||||
<form action="/rename_device" method="POST" class="inline">
|
|
||||||
<input type="hidden" name="device_id" value="{{ device.id }}">
|
|
||||||
<input type="text" name="new_name" value="{{ device.name }}" class="hidden border p-1 rounded bg-gray-200 dark:bg-zinc-800 border-gray-600 w-32 mr-2" style="vertical-align: middle;" required>
|
|
||||||
<button type="button" class="text-blue-400 hover:text-blue-600 hover:cursor-pointer ml-2 rename-btn" title="Rename Device"><i class="fas fa-pencil-alt"></i></button>
|
|
||||||
<button type="submit" class="text-green-400 hover:text-green-600 hover:cursor-pointer ml-2 save-btn hidden" title="Save Name"><i class="fas fa-check"></i></button>
|
|
||||||
<button type="button" class="text-gray-400 hover:text-gray-600 hover:cursor-pointer ml-2 cancel-btn hidden" title="Cancel"><i class="fas fa-times"></i></button>
|
|
||||||
</form>
|
|
||||||
<form action="/delete_device" method="POST" onsubmit="return confirm('Are you sure you want to delete this device?');">
|
|
||||||
<input type="hidden" name="device_id" value="{{ device.id }}">
|
|
||||||
<button type="submit" class="ml-4 text-red-500 hover:text-red-700 hover:cursor-pointer" title="Delete Device">
|
|
||||||
<i class="fas fa-trash fa-lg"></i>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form action="/device/{{ device.id }}/add_ip" method="POST" class="mb-6">
|
|
||||||
<div class="flex flex-col space-y-4">
|
|
||||||
<select name="site" id="site-select" class="border p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full" required>
|
|
||||||
<option value="" disabled selected>Select Site...</option>
|
|
||||||
{% set sites = subnets | map(attribute='site') | unique | list %}
|
|
||||||
{% for site in sites %}
|
|
||||||
<option value="{{ site }}">{{ site }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<select name="subnet_id" id="subnet-select" class="border p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full" required>
|
|
||||||
<option value="" disabled selected>Select Subnet...</option>
|
|
||||||
{% for subnet in subnets %}
|
|
||||||
<option value="{{ subnet.id }}" data-site="{{ subnet.site }}">{{ subnet.name }} ({{ subnet.cidr }})</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<select name="ip_id" id="ip-select" class="border p-3 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full" required>
|
|
||||||
<option value="" disabled selected>Select IP...</option>
|
|
||||||
</select>
|
|
||||||
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg w-full">Add IP</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div class="allocated-ips mb-6">
|
|
||||||
<h3 class="text-lg font-bold mb-2">Allocated IPs:</h3>
|
|
||||||
<ul class="space-y-2">
|
|
||||||
{% for ip in device_ips %}
|
|
||||||
<li class="flex justify-between items-center bg-gray-200 dark:bg-zinc-700 p-2 rounded-lg">
|
|
||||||
<span class="allocated-ip">{{ ip.ip }}</span>
|
|
||||||
<form action="/device/{{ device.id }}/delete_ip" method="POST" class="inline">
|
|
||||||
<input type="hidden" name="device_ip_id" value="{{ ip.device_ip_id }}">
|
|
||||||
<button type="submit" class="text-red-500 hover:text-red-600 hover:cursor-pointer py-1 mr-2 text-lg"><i class="fas fa-trash"></i></button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- IP History Section -->
|
|
||||||
{% if ip_history %}
|
|
||||||
<div class="ip-history mb-6">
|
|
||||||
<h3 class="text-lg font-bold mb-2">IP Assignment History:</h3>
|
|
||||||
<div class="bg-gray-200 dark:bg-zinc-700 rounded-lg p-4 max-h-96 overflow-y-auto">
|
|
||||||
<div class="space-y-3">
|
|
||||||
{% for entry in ip_history %}
|
|
||||||
<div class="flex items-start gap-3 pb-3 {% if not loop.last %}border-b border-gray-400 dark:border-zinc-600{% endif %}">
|
|
||||||
<div class="flex-shrink-0 mt-1">
|
|
||||||
{% if entry.action == 'assigned' %}
|
|
||||||
<i class="fas fa-plus-circle text-green-500"></i>
|
|
||||||
{% else %}
|
|
||||||
<i class="fas fa-minus-circle text-red-500"></i>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
|
||||||
<span class="font-mono font-semibold">{{ entry.ip }}</span>
|
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{% if entry.action == 'assigned' %}Assigned{% else %}Removed{% endif %}
|
|
||||||
</span>
|
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
to {{ entry.device_name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
{{ entry.subnet_name }} ({{ entry.subnet_cidr }})
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
|
||||||
by {{ entry.user_name }} • {{ entry.timestamp.strftime('%Y-%m-%d %H:%M:%S') if entry.timestamp else 'Unknown' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Tags Section -->
|
|
||||||
<div class="tags-section mb-6">
|
|
||||||
<h3 class="text-lg font-bold mb-2">Tags:</h3>
|
|
||||||
<div class="flex flex-wrap gap-2 mb-4">
|
|
||||||
{% if device_tags %}
|
|
||||||
{% for tag in device_tags %}
|
|
||||||
<div class="flex items-center space-x-1 px-3 py-1 rounded-full text-sm" style="background-color: {{ tag.color }}20; border: 1px solid {{ tag.color }}">
|
|
||||||
<div class="w-2 h-2 rounded-full" style="background-color: {{ tag.color }}"></div>
|
|
||||||
<span>{{ tag.name }}</span>
|
|
||||||
{% if can_remove_device_tag %}
|
|
||||||
<form action="/device/{{ device.id }}/remove_tag" method="POST" class="inline">
|
|
||||||
<input type="hidden" name="tag_id" value="{{ tag.id }}">
|
|
||||||
<button type="submit" class="ml-1 text-red-500 hover:text-red-700 text-xs" title="Remove tag">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<span class="text-gray-500">No tags assigned</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if can_assign_device_tag and all_tags %}
|
|
||||||
<form action="/device/{{ device.id }}/assign_tag" method="POST" class="flex gap-2">
|
|
||||||
<select name="tag_id" class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 flex-1" required>
|
|
||||||
<option value="" disabled selected>Select a tag to assign...</option>
|
|
||||||
{% for tag in all_tags %}
|
|
||||||
{% set already_assigned = device_tags|selectattr('id', 'equalto', tag.id)|list|length > 0 %}
|
|
||||||
{% if not already_assigned %}
|
|
||||||
<option value="{{ tag.id }}">{{ tag.name }}</option>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">
|
|
||||||
<i class="fas fa-plus mr-1"></i>Assign Tag
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form action="/update_device_description" method="POST" class="mb-6 mt-4">
|
|
||||||
<input type="hidden" name="device_id" value="{{ device.id }}">
|
|
||||||
<label for="description" class="block mb-2 text-lg font-bold">Description</label>
|
|
||||||
<textarea id="description" name="description" rows="3" class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full resize-y" placeholder="Enter device description...">{{ device.description or '' }}</textarea>
|
|
||||||
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg w-full mt-2">Save Description</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<!-- Custom Fields Section -->
|
|
||||||
{% if custom_fields %}
|
|
||||||
<div class="custom-fields-section mb-6">
|
|
||||||
<h3 class="text-lg font-bold mb-4">Custom Fields</h3>
|
|
||||||
{% if can_edit_device %}
|
|
||||||
<form action="/device/{{ device.id }}/update_custom_fields" method="POST" id="custom-fields-form">
|
|
||||||
<div class="space-y-4">
|
|
||||||
{% for field in custom_fields %}
|
|
||||||
<div class="custom-field-item">
|
|
||||||
<label for="custom_field_{{ field.field_key }}" class="block mb-1 text-sm font-medium">
|
|
||||||
{{ field.name }}
|
|
||||||
{% if field.required %}<span class="text-red-500">*</span>{% endif %}
|
|
||||||
{% if field.help_text %}
|
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400 ml-2" title="{{ field.help_text }}">
|
|
||||||
<i class="fas fa-info-circle"></i>
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</label>
|
|
||||||
{% if field.field_type == 'textarea' %}
|
|
||||||
<textarea name="custom_field_{{ field.field_key }}"
|
|
||||||
id="custom_field_{{ field.field_key }}"
|
|
||||||
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full resize-y"
|
|
||||||
{% if field.required %}required{% endif %}
|
|
||||||
placeholder="{{ field.help_text or '' }}">{{ field.current_value or field.default_value or '' }}</textarea>
|
|
||||||
{% elif field.field_type == 'boolean' %}
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<input type="checkbox" name="custom_field_{{ field.field_key }}"
|
|
||||||
id="custom_field_{{ field.field_key }}"
|
|
||||||
value="true"
|
|
||||||
{% if field.current_value or (not field.current_value and field.default_value == 'true') %}checked{% endif %}
|
|
||||||
class="w-4 h-4">
|
|
||||||
<label for="custom_field_{{ field.field_key }}" class="text-sm">Yes</label>
|
|
||||||
</div>
|
|
||||||
{% elif field.field_type == 'select' %}
|
|
||||||
{% set options = [] %}
|
|
||||||
{% if field.validation_rules and field.validation_rules.select_options %}
|
|
||||||
{% set options = field.validation_rules.select_options %}
|
|
||||||
{% endif %}
|
|
||||||
<select name="custom_field_{{ field.field_key }}"
|
|
||||||
id="custom_field_{{ field.field_key }}"
|
|
||||||
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full"
|
|
||||||
{% if field.required %}required{% endif %}>
|
|
||||||
{% if not field.required %}
|
|
||||||
<option value="">-- None --</option>
|
|
||||||
{% endif %}
|
|
||||||
{% for option in options %}
|
|
||||||
<option value="{{ option }}" {% if field.current_value == option or (not field.current_value and field.default_value == option) %}selected{% endif %}>{{ option }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
{% elif field.field_type == 'date' %}
|
|
||||||
<input type="date" name="custom_field_{{ field.field_key }}"
|
|
||||||
id="custom_field_{{ field.field_key }}"
|
|
||||||
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full"
|
|
||||||
value="{{ field.current_value or field.default_value or '' }}"
|
|
||||||
{% if field.required %}required{% endif %}>
|
|
||||||
{% elif field.field_type == 'datetime' %}
|
|
||||||
<input type="datetime-local" name="custom_field_{{ field.field_key }}"
|
|
||||||
id="custom_field_{{ field.field_key }}"
|
|
||||||
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full"
|
|
||||||
value="{{ field.current_value or field.default_value or '' }}"
|
|
||||||
{% if field.required %}required{% endif %}>
|
|
||||||
{% elif field.field_type == 'number' %}
|
|
||||||
<input type="number" name="custom_field_{{ field.field_key }}"
|
|
||||||
id="custom_field_{{ field.field_key }}"
|
|
||||||
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full"
|
|
||||||
value="{{ field.current_value or field.default_value or '' }}"
|
|
||||||
{% if field.required %}required{% endif %}
|
|
||||||
{% if field.validation_rules and field.validation_rules.min_value %}min="{{ field.validation_rules.min_value }}"{% endif %}
|
|
||||||
{% if field.validation_rules and field.validation_rules.max_value %}max="{{ field.validation_rules.max_value }}"{% endif %}>
|
|
||||||
{% elif field.field_type == 'decimal' %}
|
|
||||||
<input type="number" step="any" name="custom_field_{{ field.field_key }}"
|
|
||||||
id="custom_field_{{ field.field_key }}"
|
|
||||||
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full"
|
|
||||||
value="{{ field.current_value or field.default_value or '' }}"
|
|
||||||
{% if field.required %}required{% endif %}
|
|
||||||
{% if field.validation_rules and field.validation_rules.min_value %}min="{{ field.validation_rules.min_value }}"{% endif %}
|
|
||||||
{% if field.validation_rules and field.validation_rules.max_value %}max="{{ field.validation_rules.max_value }}"{% endif %}>
|
|
||||||
{% elif field.field_type == 'ip_address' %}
|
|
||||||
<input type="text" name="custom_field_{{ field.field_key }}"
|
|
||||||
id="custom_field_{{ field.field_key }}"
|
|
||||||
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full font-mono"
|
|
||||||
value="{{ field.current_value or field.default_value or '' }}"
|
|
||||||
placeholder="192.168.1.1"
|
|
||||||
{% if field.required %}required{% endif %}>
|
|
||||||
{% elif field.field_type == 'email' %}
|
|
||||||
<input type="email" name="custom_field_{{ field.field_key }}"
|
|
||||||
id="custom_field_{{ field.field_key }}"
|
|
||||||
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full"
|
|
||||||
value="{{ field.current_value or field.default_value or '' }}"
|
|
||||||
placeholder="user@example.com"
|
|
||||||
{% if field.required %}required{% endif %}>
|
|
||||||
{% elif field.field_type == 'url' %}
|
|
||||||
<input type="url" name="custom_field_{{ field.field_key }}"
|
|
||||||
id="custom_field_{{ field.field_key }}"
|
|
||||||
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full"
|
|
||||||
value="{{ field.current_value or field.default_value or '' }}"
|
|
||||||
placeholder="https://example.com"
|
|
||||||
{% if field.required %}required{% endif %}>
|
|
||||||
{% else %}
|
|
||||||
<input type="text" name="custom_field_{{ field.field_key }}"
|
|
||||||
id="custom_field_{{ field.field_key }}"
|
|
||||||
class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full"
|
|
||||||
value="{{ field.current_value or field.default_value or '' }}"
|
|
||||||
placeholder="{{ field.help_text or '' }}"
|
|
||||||
{% if field.required %}required{% endif %}
|
|
||||||
{% if field.validation_rules and field.validation_rules.min_length %}minlength="{{ field.validation_rules.min_length }}"{% endif %}
|
|
||||||
{% if field.validation_rules and field.validation_rules.max_length %}maxlength="{{ field.validation_rules.max_length }}"{% endif %}>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg w-full mt-4">Save Custom Fields</button>
|
|
||||||
</form>
|
|
||||||
{% else %}
|
|
||||||
<div class="space-y-4">
|
|
||||||
{% for field in custom_fields %}
|
|
||||||
<div class="custom-field-item">
|
|
||||||
<label class="block mb-1 text-sm font-medium">{{ field.name }}</label>
|
|
||||||
<div class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600 w-full">
|
|
||||||
{{ field.current_value or field.default_value or '-' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script src="/static/js/device.min.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Device Type Statistics</title>
|
|
||||||
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
|
||||||
<link href="/static/css/output.css" rel="stylesheet">
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
|
||||||
{% include 'header.html' %}
|
|
||||||
<div class="flex-1 flex items-center justify-center mx-4">
|
|
||||||
<div class="container py-8 max-w-2xl pt-20">
|
|
||||||
<div class="flex items-center mb-6 relative">
|
|
||||||
<a href="/devices" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
|
|
||||||
<h1 class="text-3xl font-bold text-center w-full">Device Stats</h1>
|
|
||||||
<a href="/device_types" class="hidden sm:flex absolute right-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-lg px-4 py-2 text-sm"><i class="fas fa-cog mr-2"></i>Manage Types</a>
|
|
||||||
</div>
|
|
||||||
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-6">
|
|
||||||
<table class="w-full table-auto">
|
|
||||||
<thead>
|
|
||||||
<tr class="dark:bg-zinc-700">
|
|
||||||
<th class="px-4 py-2 text-left">Type</th>
|
|
||||||
<th class="px-4 py-2 text-center">Count</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for name, icon_class, count in stats %}
|
|
||||||
<tr class="border-b border-gray-700">
|
|
||||||
<td class="px-4 py-3 flex items-center gap-2">
|
|
||||||
<a href="{{ url_for('devices_by_type', device_type=name) }}" class="hover:underline flex items-center gap-2">
|
|
||||||
<i class="fas {{ icon_class }} dark:text-white"></i> {{ name }}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-center font-bold">{{ count }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Device Type Management</title>
|
|
||||||
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
|
||||||
<link href="/static/css/output.css" rel="stylesheet">
|
|
||||||
<link href="/static/css/device_types.min.css" rel="stylesheet">
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
|
||||||
{% include 'header.html' %}
|
|
||||||
<div class="flex-1 flex items-center justify-center mx-4">
|
|
||||||
<div class="container py-8 max-w-6xl pt-20">
|
|
||||||
<div class="flex items-center mb-6 relative">
|
|
||||||
<a href="/device_type_stats" class="hidden sm:flex absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
|
|
||||||
<h1 class="text-3xl font-bold text-center w-full">Device Type Management</h1>
|
|
||||||
</div>
|
|
||||||
{% if error %}
|
|
||||||
<div class="mb-4 bg-red-200 dark:bg-red-800 text-red-900 dark:text-red-100 p-4 rounded-lg">
|
|
||||||
{{ error }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<form action="/device_types" method="POST" class="mb-8 bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md">
|
|
||||||
<input type="hidden" name="action" value="add">
|
|
||||||
<h2 class="text-xl font-bold mb-4">Add New Device Type</h2>
|
|
||||||
<div class="flex flex-col space-y-4">
|
|
||||||
<div class="flex flex-col md:flex-row gap-4">
|
|
||||||
<input type="text" name="name" placeholder="Device Type Name (e.g., Router, Load Balancer)" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 flex-1" required>
|
|
||||||
<div class="icon-search-container relative flex-1">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="icon-preview hidden text-2xl text-gray-600 dark:text-gray-400 flex-shrink-0"></div>
|
|
||||||
<input type="text" name="icon_class" placeholder="Icon Class (e.g., fa-server, fa-router)" class="icon-search-input border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 flex-1" required>
|
|
||||||
</div>
|
|
||||||
<div class="icon-suggestions hidden absolute z-10 w-full mt-1 bg-gray-300 dark:bg-zinc-900 border border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg whitespace-nowrap">Add Device Type</button>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
<p><strong>Icon Class Format:</strong> Start typing to see icon suggestions. Use Font Awesome icon classes (e.g., <code>fa-server</code>, <code>fa-router</code>, <code>fa-database</code>).</p>
|
|
||||||
<p class="mt-1">Common icons: <code>fa-server</code>, <code>fa-network-wired</code>, <code>fa-shield-halved</code>, <code>fa-wifi</code>, <code>fa-print</code>, <code>fa-boxes-stacked</code>, <code>fa-question</code></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<h2 class="text-xl font-bold mb-4">Existing Device Types</h2>
|
|
||||||
{% if device_types %}
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{% for device_type in device_types %}
|
|
||||||
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md hover:shadow-lg transition-shadow">
|
|
||||||
<form id="edit-form-{{ device_type[0] }}" action="/device_types" method="POST" class="space-y-4">
|
|
||||||
<input type="hidden" name="action" value="edit">
|
|
||||||
<input type="hidden" name="device_type_id" value="{{ device_type[0] }}">
|
|
||||||
<div class="flex flex-col space-y-3">
|
|
||||||
<div class="flex items-center justify-center mb-2">
|
|
||||||
<i class="fas {{ device_type[2] }} text-4xl text-gray-700 dark:text-gray-300"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
|
|
||||||
<input type="text" name="name" value="{{ device_type[1] }}" class="border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 w-full" required>
|
|
||||||
</div>
|
|
||||||
<div class="icon-search-container relative">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Icon</label>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="icon-preview text-xl text-gray-600 dark:text-gray-400 flex-shrink-0">
|
|
||||||
<i class="fas {{ device_type[2] }}"></i>
|
|
||||||
</div>
|
|
||||||
<input type="text" name="icon_class" value="{{ device_type[2] }}" class="icon-search-input border p-2 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600 flex-1" required>
|
|
||||||
</div>
|
|
||||||
<div class="icon-suggestions hidden absolute z-10 w-full mt-1 bg-gray-300 dark:bg-zinc-900 border border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div class="flex gap-2 pt-2">
|
|
||||||
<button type="submit" form="edit-form-{{ device_type[0] }}" class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-3 py-2 rounded-lg text-sm font-medium transition-colors">
|
|
||||||
<i class="fas fa-save mr-1"></i> Save
|
|
||||||
</button>
|
|
||||||
<form action="/device_types" method="POST" onsubmit="return confirm('Are you sure you want to delete this device type?');" class="inline">
|
|
||||||
<input type="hidden" name="action" value="delete">
|
|
||||||
<input type="hidden" name="device_type_id" value="{{ device_type[0] }}">
|
|
||||||
<button type="submit" class="bg-red-500 hover:bg-red-600 dark:bg-red-600 dark:hover:bg-red-700 hover:cursor-pointer text-white px-3 py-2 rounded-lg text-sm font-medium transition-colors" title="Delete Device Type">
|
|
||||||
<i class="fas fa-trash mr-1"></i> Delete
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg text-center text-gray-600 dark:text-gray-400">
|
|
||||||
<p>No device types found. Add your first device type above.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script src="/static/js/device_types.min.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Device Manager</title>
|
|
||||||
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
|
||||||
<link href="/static/css/output.css" rel="stylesheet">
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
|
||||||
<link href="/static/css/devices.min.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
|
||||||
{% include 'header.html' %}
|
|
||||||
<div class="flex-1 flex items-center justify-center mx-4">
|
|
||||||
<div class="container py-8 max-w-4xl pt-20">
|
|
||||||
<h1 class="text-3xl font-bold mb-6 text-center">Device Manager</h1>
|
|
||||||
<div class="flex flex-row justify-center gap-4 mb-6 flex-wrap">
|
|
||||||
<a href="/add_device" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">Add New Device</a>
|
|
||||||
<a href="/bulk" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">Bulk Operations</a>
|
|
||||||
<a href="/device_type_stats" class="text-center bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 px-4 py-2 rounded-lg">View Device Stats</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filters Section -->
|
|
||||||
<div class="mb-6 space-y-4">
|
|
||||||
<!-- Tag Filter -->
|
|
||||||
{% if all_tag_names %}
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<label class="text-sm font-medium">Filter by tag:</label>
|
|
||||||
<select id="tag-filter" class="border p-2 rounded-lg bg-gray-200 dark:bg-zinc-800 border-gray-600">
|
|
||||||
<option value="">All devices</option>
|
|
||||||
{% for tag_name in all_tag_names %}
|
|
||||||
<option value="{{ tag_name }}" {% if current_tag_filter == tag_name %}selected{% endif %}>{{ tag_name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
{% if current_tag_filter %}
|
|
||||||
<a href="/devices" class="text-red-500 hover:text-red-700 text-sm">
|
|
||||||
<i class="fas fa-times"></i> Clear filter
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div id="site-list" class="space-y-6">
|
|
||||||
{% for site, devices in sites_devices.items() %}
|
|
||||||
<div class="site-group bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md">
|
|
||||||
<div class="flex flex-row items-center justify-between p-4 cursor-pointer site-header">
|
|
||||||
<h2 class="text-xl font-bold mb-0 dark:text-white">{{ site }}</h2>
|
|
||||||
<button type="button" class="expand-btn text-gray-400 hover:text-gray-200 hover:cursor-pointer ml-2 flex items-center" aria-label="Expand site">
|
|
||||||
<i class="fas fa-chevron-down"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<ul class="device-list hidden px-6 pb-4">
|
|
||||||
{% for device in devices %}
|
|
||||||
<li class="my-2">
|
|
||||||
<a href="/device/{{ device.id }}" class="block bg-gray-300 hover:bg-gray-100 dark:bg-zinc-900 dark:hover:bg-gray-700 dark:text-gray-200 font-semibold rounded-lg px-4 py-2 shadow transition-colors duration-150">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span><i class="fas {{ device.icon_class or 'fa-server' }} mr-2"></i>{{ device.name }}</span>
|
|
||||||
{% set ips = device_ips.get(device.id, []) %}
|
|
||||||
<span class="flex flex-row flex-wrap justify-end items-center ml-4 text-xs text-blue-300 font-normal align-middle">
|
|
||||||
{% if ips|length > 0 %}
|
|
||||||
{% for ip in ips %}
|
|
||||||
<span class="inline-block bg-gray-200 dark:bg-zinc-800 text-gray-900 dark:text-white rounded px-2 py-1 ml-1 font-mono">{{ ip[1] }}</span>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<span class="text-gray-400">No IPs</span>
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<!-- Tags -->
|
|
||||||
{% set tags = device_tags.get(device.id, []) %}
|
|
||||||
{% if tags %}
|
|
||||||
<div class="flex flex-wrap gap-1 mt-2">
|
|
||||||
{% for tag in tags %}
|
|
||||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs" style="background-color: {{ tag.color }}20; border: 1px solid {{ tag.color }}; color: {{ tag.color }}">
|
|
||||||
<div class="w-1.5 h-1.5 rounded-full mr-1" style="background-color: {{ tag.color }}"></div>
|
|
||||||
{{ tag.name }}
|
|
||||||
</span>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script src="/static/js/devices.min.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>{{ tag_name }} - Tagged Devices</title>
|
|
||||||
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
|
||||||
<link href="/static/css/output.css" rel="stylesheet">
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
|
||||||
{% include 'header.html' %}
|
|
||||||
<div class="flex-1 flex items-center justify-center mx-4">
|
|
||||||
<div class="container py-8 max-w-3xl pt-20">
|
|
||||||
<div class="flex items-center mb-6 relative">
|
|
||||||
<a href="/tags" class="absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 flex items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
|
|
||||||
<h1 class="text-3xl font-bold text-center w-full">
|
|
||||||
<div class="flex items-center justify-center space-x-2">
|
|
||||||
<div class="w-5 h-5 rounded-full border border-gray-600" style="background-color: {{ tag_color }}"></div>
|
|
||||||
<span>{{ tag_name }} - Tagged Devices</span>
|
|
||||||
</div>
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
{% if site_devices %}
|
|
||||||
{% for site, devices in site_devices.items() %}
|
|
||||||
<div class="mb-8">
|
|
||||||
<h2 class="text-xl font-bold mb-4 dark:text-white">{{ site }}</h2>
|
|
||||||
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-4">
|
|
||||||
<table class="w-full table-auto mb-2">
|
|
||||||
<thead>
|
|
||||||
<tr class="bg-gray-200 dark:bg-zinc-700">
|
|
||||||
<th class="px-4 py-2 text-left">Device Name</th>
|
|
||||||
<th class="px-4 py-2 text-left">Description</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for device in devices %}
|
|
||||||
<tr class="border-b border-gray-700">
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<a href="/device/{{ device.id }}" class="hover:underline dark:text-white hover:cursor-pointer">{{ device.name }}</a>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3">{{ device.description or '' }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr class="bg-gray-200 dark:bg-zinc-900 font-bold">
|
|
||||||
<td class="px-4 py-2 text-right" colspan="2">Total: {{ devices|length }}</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-8 text-center">
|
|
||||||
<p class="text-gray-500">No devices found with this tag.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>{{ device_type }} Devices</title>
|
|
||||||
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
|
||||||
<link href="/static/css/output.css" rel="stylesheet">
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
|
||||||
{% include 'header.html' %}
|
|
||||||
<div class="flex-1 flex items-center justify-center mx-4">
|
|
||||||
<div class="container py-8 max-w-3xl pt-20">
|
|
||||||
<div class="flex items-center mb-6 relative">
|
|
||||||
<a href="/device_type_stats" class="absolute left-0 bg-gray-200 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 flex items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
|
|
||||||
<h1 class="text-3xl font-bold text-center w-full">
|
|
||||||
<i class="fas {{ icon_class }}"></i> {{ device_type }} Devices
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
{% for site, devices in site_devices.items() %}
|
|
||||||
<div class="mb-8">
|
|
||||||
<h2 class="text-xl font-bold mb-4 dark:text-white">{{ site }}</h2>
|
|
||||||
<div class="bg-gray-200 dark:bg-zinc-800 rounded-lg shadow-md p-4">
|
|
||||||
<table class="w-full table-auto mb-2">
|
|
||||||
<thead>
|
|
||||||
<tr class="bg-gray-200 dark:bg-zinc-700">
|
|
||||||
<th class="px-4 py-2 text-left">Device Name</th>
|
|
||||||
<th class="px-4 py-2 text-left">Description</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for device in devices %}
|
|
||||||
<tr class="border-b border-gray-700">
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<a href="/device/{{ device.id }}" class="hover:underline dark:text-white">{{ device.name }}</a>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3">{{ device.description or '' }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr class="bg-gray-200 dark:bg-zinc-900 font-bold">
|
|
||||||
<td class="px-4 py-2 text-right" colspan="2">Total: {{ devices|length }}</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Define DHCP Pool - {{ subnet.name }}</title>
|
|
||||||
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
|
||||||
<link href="/static/css/output.css" rel="stylesheet">
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
|
||||||
{% include 'header.html' %}
|
|
||||||
<div class="flex-1 flex items-center justify-center mx-4">
|
|
||||||
<div class="container py-8 w-full sm:max-w-3/4 pt-20">
|
|
||||||
<div class="flex items-center mb-6 relative">
|
|
||||||
<a href="/subnet/{{ subnet.id }}" class="absolute left-0 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 flex items-center justify-center rounded-full w-11 h-11"><i class="fas fa-arrow-left"></i></a>
|
|
||||||
<h1 class="text-3xl font-bold text-center w-full">DHCP Pool for {{ subnet.name }}</h1>
|
|
||||||
</div>
|
|
||||||
{% if error %}
|
|
||||||
<div class="text-red-500 text-center mb-4">{{ error }}</div>
|
|
||||||
{% endif %}
|
|
||||||
<form action="" method="POST" class="bg-gray-200 dark:bg-zinc-800 p-6 rounded-lg shadow-md flex flex-col gap-4">
|
|
||||||
<label for="start_ip" class="font-medium">Start IP Address</label>
|
|
||||||
<input type="text" id="start_ip" name="start_ip" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600" placeholder="e.g. 192.168.1.100" required value="{{ dhcp_pool.start_ip if dhcp_pool else '' }}">
|
|
||||||
<label for="end_ip" class="font-medium">End IP Address</label>
|
|
||||||
<input type="text" id="end_ip" name="end_ip" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600" placeholder="e.g. 192.168.1.200" required value="{{ dhcp_pool.end_ip if dhcp_pool else '' }}">
|
|
||||||
<label for="excluded_ips" class="font-medium">Exclude IPs (comma separated)</label>
|
|
||||||
<input type="text" id="excluded_ips" name="excluded_ips" class="border p-3 rounded-lg bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 border-gray-600" placeholder="e.g. 192.168.1.105,192.168.1.110" value="{{ dhcp_pool.excluded_ips if dhcp_pool and dhcp_pool.excluded_ips else '' }}">
|
|
||||||
<div class="flex gap-4 mt-4">
|
|
||||||
<button type="submit" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">Save DHCP Pool</button>
|
|
||||||
{% if dhcp_pool %}
|
|
||||||
<button type="submit" name="remove" value="1" class="bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-2 rounded-lg">Remove DHCP Pool</button>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% if dhcp_pool %}
|
|
||||||
<div class="mt-8 bg-gray-200 dark:bg-zinc-800 p-4 rounded-lg">
|
|
||||||
<h2 class="text-xl font-bold mb-2">Current DHCP Pool</h2>
|
|
||||||
<div>Start: <span class="font-mono">{{ dhcp_pool.start_ip }}</span></div>
|
|
||||||
<div>End: <span class="font-mono">{{ dhcp_pool.end_ip }}</span></div>
|
|
||||||
{% if dhcp_pool.excluded_ips %}
|
|
||||||
<div>Excluded: <span class="font-mono break-words inline-block max-w-full whitespace-normal">{{ dhcp_pool.excluded_ips }}</span></div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Enable Two-Factor Authentication - {{ NAME }} IPAM</title>
|
|
||||||
<link rel="icon" type="image/png" href="{{ LOGO_PNG }}">
|
|
||||||
<link href="/static/css/output.css" rel="stylesheet">
|
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-300 text-gray-900 dark:bg-zinc-900 dark:text-gray-100 min-h-screen flex flex-col">
|
|
||||||
{% include 'header.html' %}
|
|
||||||
<div class="flex-1 flex items-center justify-center mx-4">
|
|
||||||
<div class="container py-8 max-w-2xl pt-20">
|
|
||||||
<div class="bg-gray-200 dark:bg-zinc-800 rounded-2xl shadow-lg p-8">
|
|
||||||
<div class="mb-4">
|
|
||||||
<a href="/account" class="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200">
|
|
||||||
<i class="fas fa-arrow-left mr-2"></i>Back to Account Settings
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<h1 class="text-3xl font-bold mb-6 text-center">
|
|
||||||
<i class="fas fa-shield-alt mr-2"></i>
|
|
||||||
Enable Two-Factor Authentication
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{% if step == 'generate' %}
|
|
||||||
<div class="space-y-4">
|
|
||||||
<p class="text-center text-gray-700 dark:text-gray-300">
|
|
||||||
Two-factor authentication adds an extra layer of security to your account.
|
|
||||||
</p>
|
|
||||||
<p class="text-center text-gray-600 dark:text-gray-400 text-sm">
|
|
||||||
You'll need an authenticator app like Google Authenticator, Authy, or Microsoft Authenticator.
|
|
||||||
</p>
|
|
||||||
<form method="POST" class="mt-6">
|
|
||||||
<input type="hidden" name="action" value="generate">
|
|
||||||
<button type="submit" class="w-full bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
|
|
||||||
<i class="fas fa-qrcode"></i>
|
|
||||||
<span>Generate QR Code</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% elif step == 'verify' %}
|
|
||||||
<div class="space-y-6">
|
|
||||||
<div class="text-center">
|
|
||||||
<p class="mb-4 text-gray-700 dark:text-gray-300">
|
|
||||||
Scan this QR code with your authenticator app:
|
|
||||||
</p>
|
|
||||||
<div class="flex justify-center mb-4">
|
|
||||||
<img src="data:image/png;base64,{{ qr_code }}" alt="QR Code" class="border-4 border-gray-400 dark:border-zinc-600 rounded-lg p-2 bg-white">
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
|
||||||
Or enter this secret manually:
|
|
||||||
</p>
|
|
||||||
<div class="bg-gray-300 dark:bg-zinc-900 p-3 rounded-lg font-mono text-sm break-all text-center">
|
|
||||||
{{ secret }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="POST" class="space-y-4">
|
|
||||||
<input type="hidden" name="action" value="verify">
|
|
||||||
<div>
|
|
||||||
<label for="code" class="block text-sm font-medium mb-2">Enter the 6-digit code from your app:</label>
|
|
||||||
<input type="text" name="code" id="code" maxlength="6" pattern="[0-9]{6}"
|
|
||||||
class="w-full p-3 rounded-lg bg-gray-300 dark:bg-zinc-900 border border-gray-600 text-center text-2xl font-mono tracking-widest"
|
|
||||||
placeholder="000000" required autofocus>
|
|
||||||
</div>
|
|
||||||
{% if error %}
|
|
||||||
<div class="bg-red-200 dark:bg-red-900 text-red-800 dark:text-red-200 p-3 rounded-lg">
|
|
||||||
<i class="fas fa-exclamation-circle mr-2"></i>{{ error }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<button type="submit" class="w-full bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
|
|
||||||
<i class="fas fa-check"></i>
|
|
||||||
<span>Verify & Enable 2FA</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% elif step == 'backup_codes' %}
|
|
||||||
<div class="space-y-6">
|
|
||||||
<div class="bg-yellow-200 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 p-4 rounded-lg">
|
|
||||||
<p class="font-semibold mb-2">
|
|
||||||
<i class="fas fa-exclamation-triangle mr-2"></i>
|
|
||||||
Important: Save Your Backup Codes
|
|
||||||
</p>
|
|
||||||
<p class="text-sm">
|
|
||||||
These codes can be used to access your account if you lose your authenticator device.
|
|
||||||
Store them in a safe place. Each code can only be used once.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-gray-300 dark:bg-zinc-900 p-6 rounded-lg">
|
|
||||||
<h2 class="text-xl font-bold mb-4 text-center">Your Backup Codes</h2>
|
|
||||||
<div class="grid grid-cols-2 gap-4 font-mono text-center">
|
|
||||||
{% for code_pair in backup_codes %}
|
|
||||||
<div class="p-3 bg-gray-200 dark:bg-zinc-800 rounded border border-gray-400 dark:border-zinc-600">
|
|
||||||
{{ code_pair }}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<button onclick="window.print()" class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center">
|
|
||||||
<i class="fas fa-print"></i>
|
|
||||||
<span>Print Codes</span>
|
|
||||||
</button>
|
|
||||||
<a href="/account" class="flex-1 bg-gray-300 hover:bg-gray-400 dark:bg-zinc-700 dark:hover:bg-zinc-600 hover:cursor-pointer px-4 py-3 rounded-lg flex items-center gap-2 justify-center text-center">
|
|
||||||
<i class="fas fa-check"></i>
|
|
||||||
<span>Continue</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
// Auto-focus code input and move cursor on input
|
|
||||||
const codeInput = document.getElementById('code');
|
|
||||||
if (codeInput) {
|
|
||||||
codeInput.addEventListener('input', function(e) {
|
|
||||||
this.value = this.value.replace(/[^0-9]/g, '');
|
|
||||||
if (this.value.length === 6) {
|
|
||||||
this.form.submit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
<header class="bg-zinc-800 shadow-md py-3 px-6 flex items-center justify-between relative">
|
|
||||||
<div class="flex items-center space-x-3 flex-shrink-0">
|
|
||||||
<a href="/" class="flex items-center space-x-3">
|
|
||||||
<img src="{{ LOGO_PNG }}" alt="Logo" class="h-8 rounded">
|
|
||||||
<span class="text-2xl font-bold text-white whitespace-nowrap">{{ NAME }} IPAM</span>
|
|
||||||
</a>
|
|
||||||
<a href="https://git.jdbnet.co.uk/jamie/ipam/releases" target="_blank" rel="noopener noreferrer" class="text-sm font-normal text-gray-300 hover:text-gray-100 -ml-1 mt-3">{{ VERSION }}</a>
|
|
||||||
</div>
|
|
||||||
<nav class="hidden lg:flex items-center space-x-6 flex-shrink-0" id="main-nav">
|
|
||||||
{% if current_user_name %}
|
|
||||||
<button type="button" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2 hover:cursor-pointer" id="search-modal-open" aria-label="Open search modal">
|
|
||||||
<i class="fas fa-search"></i>
|
|
||||||
<span>Search</span>
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
{% if has_permission('view_index') %}
|
|
||||||
<a href="/" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
|
||||||
<i class="fas fa-home"></i>
|
|
||||||
<span>Home</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if has_permission('view_devices') %}
|
|
||||||
<a href="/devices" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
|
||||||
<i class="fas fa-server"></i>
|
|
||||||
<span>Devices</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if has_permission('view_racks') %}
|
|
||||||
<a href="/racks" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
|
||||||
<i class="fas fa-th"></i>
|
|
||||||
<span>Racks</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if has_permission('view_admin') %}
|
|
||||||
<a href="/admin" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
|
||||||
<i class="fas fa-cog"></i>
|
|
||||||
<span>Admin</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if current_user_name %}
|
|
||||||
<a href="/account" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
|
||||||
<i class="fas fa-user-cog"></i>
|
|
||||||
<span>Account</span>
|
|
||||||
</a>
|
|
||||||
<a href="/logout" class="text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
|
||||||
<i class="fas fa-sign-out-alt"></i>
|
|
||||||
<span>Logout</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</nav>
|
|
||||||
<div class="lg:hidden flex items-center gap-3 flex-shrink-0">
|
|
||||||
{% if current_user_name %}
|
|
||||||
<button class="flex items-center text-gray-200 hover:cursor-pointer focus:outline-none" id="search-modal-open-mobile" aria-label="Open search modal">
|
|
||||||
<i class="fas fa-search text-xl"></i>
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
<button class="flex items-center text-gray-200 hover:cursor-pointer focus:outline-none" id="nav-toggle" aria-label="Open navigation menu">
|
|
||||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="lg:hidden fixed top-13 left-0 right-0 bg-zinc-800 shadow-lg z-50 w-full hidden flex-col py-2" id="mobile-nav">
|
|
||||||
{% if has_permission('view_index') %}
|
|
||||||
<a href="/" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
|
||||||
<i class="fas fa-home"></i>
|
|
||||||
<span>Home</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if has_permission('view_devices') %}
|
|
||||||
<a href="/devices" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
|
||||||
<i class="fas fa-server"></i>
|
|
||||||
<span>Devices</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if has_permission('view_racks') %}
|
|
||||||
<a href="/racks" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
|
||||||
<i class="fas fa-th"></i>
|
|
||||||
<span>Racks</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if has_permission('view_admin') %}
|
|
||||||
<a href="/admin" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
|
||||||
<i class="fas fa-cog"></i>
|
|
||||||
<span>Admin</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if current_user_name %}
|
|
||||||
<a href="/account" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
|
||||||
<i class="fas fa-user-cog"></i>
|
|
||||||
<span>Account</span>
|
|
||||||
</a>
|
|
||||||
<a href="/logout" class="block px-6 py-2 text-gray-200 hover:text-gray-400 font-medium flex items-center gap-2">
|
|
||||||
<i class="fas fa-sign-out-alt"></i>
|
|
||||||
<span>Logout</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if current_user_name %}
|
|
||||||
<div id="search-modal" class="hidden fixed inset-0 z-50 items-center justify-center p-4">
|
|
||||||
<div id="search-modal-backdrop" class="absolute inset-0 bg-black/60"></div>
|
|
||||||
<div class="relative w-full max-w-2xl rounded-lg bg-zinc-800 border border-zinc-700 shadow-xl">
|
|
||||||
<div class="flex items-center justify-between px-4 py-3 border-b border-zinc-700">
|
|
||||||
<h3 class="text-white font-semibold text-lg">Search</h3>
|
|
||||||
<button type="button" id="search-modal-close" class="text-gray-300 hover:text-white hover:cursor-pointer" aria-label="Close search modal">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form action="/search" method="GET" class="p-4">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<input type="text" name="q" id="search-modal-input" placeholder="Search..."
|
|
||||||
class="bg-zinc-700 text-white placeholder-gray-400 px-4 py-2 rounded-md text-base focus:outline-none focus:ring-2 focus:ring-gray-500 w-full"
|
|
||||||
value="{{ request.args.get('q', '') }}">
|
|
||||||
<button type="submit" class="text-gray-200 hover:text-gray-400 hover:cursor-pointer flex-shrink-0" aria-label="Submit search">
|
|
||||||
<i class="fas fa-search"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<script src="/static/js/header.min.js"></script>
|
|
||||||
</header>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user