Compare commits
4 Commits
732e5aca11
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c70777181 | |||
| e1a66dc7df | |||
| 1c53988cbd | |||
| b4ef76f0d0 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,3 +16,6 @@ pluto-api
|
|||||||
|
|
||||||
# Runtime
|
# Runtime
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Temporary vdevs
|
||||||
|
data/vdevs/
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -1,6 +1,6 @@
|
|||||||
# atlasOS
|
# AtlasOS
|
||||||
|
|
||||||
atlasOS is an appliance-style storage controller build by Adastra
|
AtlasOS is an appliance-style storage controller build by Adastra
|
||||||
|
|
||||||
**v1 Focus**
|
**v1 Focus**
|
||||||
- ZFS storage engine
|
- ZFS storage engine
|
||||||
@@ -11,3 +11,22 @@ atlasOS is an appliance-style storage controller build by Adastra
|
|||||||
- Prometheus metrics
|
- Prometheus metrics
|
||||||
|
|
||||||
> This repository contains the management plane and appliance tooling.
|
> This repository contains the management plane and appliance tooling.
|
||||||
|
|
||||||
|
## Quick Installation
|
||||||
|
|
||||||
|
### Standard Installation (with internet)
|
||||||
|
```bash
|
||||||
|
sudo ./installer/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Airgap Installation (offline)
|
||||||
|
```bash
|
||||||
|
# Step 1: Download bundle (on internet-connected system)
|
||||||
|
sudo ./installer/bundle-downloader.sh ./atlas-bundle
|
||||||
|
|
||||||
|
# Step 2: Transfer bundle to airgap system
|
||||||
|
# Step 3: Install on airgap system
|
||||||
|
sudo ./installer/install.sh --offline-bundle /path/to/atlas-bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
See `installer/README.md` and `docs/INSTALLATION.md` for detailed instructions.
|
||||||
|
|||||||
174
docs/AIRGAP_INSTALLATION.md
Normal file
174
docs/AIRGAP_INSTALLATION.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# Airgap Installation Guide for AtlasOS
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
AtlasOS installer supports airgap (offline) installation for data centers without internet access. All required packages and dependencies are bundled into a single directory that can be transferred to the airgap system.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Step 1: Download Bundle (On System with Internet)
|
||||||
|
|
||||||
|
On a system with internet access and Ubuntu 24.04:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone <repository-url>
|
||||||
|
cd atlas
|
||||||
|
|
||||||
|
# Run bundle downloader (requires root)
|
||||||
|
sudo ./installer/bundle-downloader.sh ./atlas-bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
This will create a directory `./atlas-bundle` containing:
|
||||||
|
- All required .deb packages (~100-200 packages)
|
||||||
|
- All dependencies
|
||||||
|
- Go binary (fallback)
|
||||||
|
- Manifest and README files
|
||||||
|
|
||||||
|
**Estimated bundle size:** 500MB - 1GB
|
||||||
|
|
||||||
|
### Step 2: Transfer Bundle to Airgap System
|
||||||
|
|
||||||
|
Transfer the entire bundle directory to your airgap system using:
|
||||||
|
- USB drive
|
||||||
|
- Internal network (if available)
|
||||||
|
- Physical media
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Example: Copy to USB drive
|
||||||
|
cp -r ./atlas-bundle /media/usb/
|
||||||
|
|
||||||
|
# On airgap system: Copy from USB
|
||||||
|
cp -r /media/usb/atlas-bundle /tmp/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Install on Airgap System
|
||||||
|
|
||||||
|
On the airgap system (Ubuntu 24.04):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Navigate to bundle directory
|
||||||
|
cd /tmp/atlas-bundle
|
||||||
|
|
||||||
|
# Run installer with offline bundle
|
||||||
|
cd /path/to/atlas
|
||||||
|
sudo ./installer/install.sh --offline-bundle /tmp/atlas-bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bundle Contents
|
||||||
|
|
||||||
|
The bundle includes:
|
||||||
|
|
||||||
|
### Main Packages
|
||||||
|
- **Build Tools**: build-essential, git, curl, wget
|
||||||
|
- **ZFS**: zfsutils-linux, zfs-zed, zfs-initramfs
|
||||||
|
- **Storage Services**: samba, samba-common-bin, nfs-kernel-server, rpcbind
|
||||||
|
- **iSCSI**: targetcli-fb
|
||||||
|
- **Database**: sqlite3, libsqlite3-dev
|
||||||
|
- **Go Compiler**: golang-go
|
||||||
|
- **Utilities**: openssl, net-tools, iproute2
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
All transitive dependencies are automatically included.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Before transferring, verify the bundle:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Count .deb files (should be 100-200)
|
||||||
|
find ./atlas-bundle -name "*.deb" | wc -l
|
||||||
|
|
||||||
|
# Check manifest
|
||||||
|
cat ./atlas-bundle/MANIFEST.txt
|
||||||
|
|
||||||
|
# Check total size
|
||||||
|
du -sh ./atlas-bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Missing Dependencies
|
||||||
|
|
||||||
|
If installation fails with dependency errors:
|
||||||
|
|
||||||
|
1. Ensure all .deb files are present in bundle
|
||||||
|
2. Check that bundle was created on Ubuntu 24.04
|
||||||
|
3. Verify system architecture matches (amd64/arm64)
|
||||||
|
|
||||||
|
### Go Installation Issues
|
||||||
|
|
||||||
|
If Go is not found after installation:
|
||||||
|
|
||||||
|
1. Check if `golang-go` package is installed: `dpkg -l | grep golang-go`
|
||||||
|
2. If missing, the bundle includes `go.tar.gz` as fallback
|
||||||
|
3. Installer will automatically extract it if needed
|
||||||
|
|
||||||
|
### Package Conflicts
|
||||||
|
|
||||||
|
If you encounter package conflicts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Fix broken packages
|
||||||
|
sudo apt-get install -f -y
|
||||||
|
|
||||||
|
# Or manually install specific packages
|
||||||
|
sudo dpkg -i /path/to/bundle/*.deb
|
||||||
|
sudo apt-get install -f -y
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bundle Maintenance
|
||||||
|
|
||||||
|
### Updating Bundle
|
||||||
|
|
||||||
|
To update the bundle with newer packages:
|
||||||
|
|
||||||
|
1. Run `./installer/bundle-downloader.sh` again on internet-connected system
|
||||||
|
2. This will download latest versions
|
||||||
|
3. Transfer new bundle to airgap system
|
||||||
|
|
||||||
|
### Bundle Size Optimization
|
||||||
|
|
||||||
|
To reduce bundle size (optional):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Remove unnecessary packages (be careful!)
|
||||||
|
# Only remove if you're certain they're not needed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- Verify bundle integrity before transferring
|
||||||
|
- Use secure transfer methods (encrypted USB, secure network)
|
||||||
|
- Keep bundle in secure location on airgap system
|
||||||
|
- Verify package signatures if possible
|
||||||
|
|
||||||
|
## Advanced Usage
|
||||||
|
|
||||||
|
### Custom Bundle Location
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download to custom location
|
||||||
|
sudo ./installer/bundle-downloader.sh /opt/atlas-bundles/ubuntu24.04
|
||||||
|
|
||||||
|
# Install from custom location
|
||||||
|
sudo ./installer/install.sh --offline-bundle /opt/atlas-bundles/ubuntu24.04
|
||||||
|
```
|
||||||
|
|
||||||
|
### Partial Bundle (if some packages already installed)
|
||||||
|
|
||||||
|
If some packages are already installed on airgap system:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Installer will skip already-installed packages
|
||||||
|
# Missing packages will be installed from bundle
|
||||||
|
sudo ./installer/install.sh --offline-bundle /path/to/bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues with airgap installation:
|
||||||
|
1. Check installation logs
|
||||||
|
2. Verify bundle completeness
|
||||||
|
3. Ensure Ubuntu 24.04 compatibility
|
||||||
|
4. Review MANIFEST.txt for package list
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Background Job System
|
# Background Job System
|
||||||
|
|
||||||
The atlasOS API includes a background job system that automatically executes snapshot policies and manages long-running operations.
|
The AtlasOS API includes a background job system that automatically executes snapshot policies and manages long-running operations.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ The easiest way to install AtlasOS is using the provided installer script:
|
|||||||
cd /path/to/atlas
|
cd /path/to/atlas
|
||||||
|
|
||||||
# Run installer (requires root)
|
# Run installer (requires root)
|
||||||
sudo ./install.sh
|
sudo ./installer/install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer will:
|
The installer will:
|
||||||
@@ -50,22 +50,22 @@ The installer will:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Custom installation directory
|
# Custom installation directory
|
||||||
sudo ./install.sh --install-dir /opt/custom-atlas
|
sudo ./installer/install.sh --install-dir /opt/custom-atlas
|
||||||
|
|
||||||
# Custom data directory
|
# Custom data directory
|
||||||
sudo ./install.sh --data-dir /mnt/atlas-data
|
sudo ./installer/install.sh --data-dir /mnt/atlas-data
|
||||||
|
|
||||||
# Skip dependency installation (if already installed)
|
# Skip dependency installation (if already installed)
|
||||||
sudo ./install.sh --skip-deps
|
sudo ./installer/install.sh --skip-deps
|
||||||
|
|
||||||
# Skip building binaries (use pre-built)
|
# Skip building binaries (use pre-built)
|
||||||
sudo ./install.sh --skip-build
|
sudo ./installer/install.sh --skip-build
|
||||||
|
|
||||||
# Custom HTTP address
|
# Custom HTTP address
|
||||||
sudo ./install.sh --http-addr :8443
|
sudo ./installer/install.sh --http-addr :8443
|
||||||
|
|
||||||
# Show help
|
# Show help
|
||||||
sudo ./install.sh --help
|
sudo ./installer/install.sh --help
|
||||||
```
|
```
|
||||||
|
|
||||||
## Manual Installation
|
## Manual Installation
|
||||||
|
|||||||
150
docs/RBAC_PERMISSIONS.md
Normal file
150
docs/RBAC_PERMISSIONS.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# Role-Based Access Control (RBAC) - Current Implementation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
AtlasOS implements a three-tier role-based access control system with the following roles:
|
||||||
|
|
||||||
|
1. **Administrator** (`administrator`) - Full system control
|
||||||
|
2. **Operator** (`operator`) - Storage and service operations
|
||||||
|
3. **Viewer** (`viewer`) - Read-only access
|
||||||
|
|
||||||
|
## Current Implementation Status
|
||||||
|
|
||||||
|
### ✅ Fully Implemented (Administrator-Only)
|
||||||
|
|
||||||
|
These operations **require Administrator role**:
|
||||||
|
|
||||||
|
- **User Management**: Create, update, delete users, list users
|
||||||
|
- **Service Management**: Start, stop, restart, reload services, view service logs
|
||||||
|
- **Maintenance Mode**: Enable/disable maintenance mode
|
||||||
|
|
||||||
|
### ⚠️ Partially Implemented (Authentication Required, No Role Check)
|
||||||
|
|
||||||
|
These operations **require authentication** but **don't check specific roles** (any authenticated user can perform them):
|
||||||
|
|
||||||
|
- **ZFS Operations**: Create/delete pools, datasets, ZVOLs, import/export pools, scrub operations
|
||||||
|
- **Snapshot Management**: Create/delete snapshots, create/delete snapshot policies
|
||||||
|
- **Storage Services**: Create/update/delete SMB shares, NFS exports, iSCSI targets
|
||||||
|
- **Backup & Restore**: Create backups, restore backups
|
||||||
|
|
||||||
|
### ✅ Public (No Authentication Required)
|
||||||
|
|
||||||
|
These endpoints are **publicly accessible**:
|
||||||
|
|
||||||
|
- **Read-Only Operations**: List pools, datasets, ZVOLs, shares, exports, targets, snapshots
|
||||||
|
- **Dashboard Data**: System statistics and health information
|
||||||
|
- **Web UI Pages**: All HTML pages (authentication required for mutations via API)
|
||||||
|
|
||||||
|
## Role Definitions
|
||||||
|
|
||||||
|
### Administrator (`administrator`)
|
||||||
|
- **Full system access**
|
||||||
|
- Can manage users (create, update, delete)
|
||||||
|
- Can manage services (start, stop, restart, reload)
|
||||||
|
- Can enable/disable maintenance mode
|
||||||
|
- Can perform all storage operations
|
||||||
|
- Can view audit logs
|
||||||
|
|
||||||
|
### Operator (`operator`)
|
||||||
|
- **Storage and service operations** (intended)
|
||||||
|
- Currently: Same as authenticated user (can perform storage operations)
|
||||||
|
- Should be able to: Create/manage pools, datasets, shares, snapshots
|
||||||
|
- Should NOT be able to: Manage users, manage services, maintenance mode
|
||||||
|
|
||||||
|
### Viewer (`viewer`)
|
||||||
|
- **Read-only access** (intended)
|
||||||
|
- Currently: Can view all public data
|
||||||
|
- Should be able to: View all system information
|
||||||
|
- Should NOT be able to: Perform any mutations (create, update, delete)
|
||||||
|
|
||||||
|
## Current Permission Matrix
|
||||||
|
|
||||||
|
| Operation | Administrator | Operator | Viewer | Unauthenticated |
|
||||||
|
|-----------|--------------|----------|--------|-----------------|
|
||||||
|
| **User Management** |
|
||||||
|
| List users | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
| Create user | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
| Update user | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
| Delete user | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
| **Service Management** |
|
||||||
|
| View service status | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
| Start/stop/restart service | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
| View service logs | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
| **Storage Operations** |
|
||||||
|
| List pools/datasets/ZVOLs | ✅ | ✅ | ✅ | ✅ (public) |
|
||||||
|
| Create pool/dataset/ZVOL | ✅ | ✅* | ❌ | ❌ |
|
||||||
|
| Delete pool/dataset/ZVOL | ✅ | ✅* | ❌ | ❌ |
|
||||||
|
| Import/export pool | ✅ | ✅* | ❌ | ❌ |
|
||||||
|
| **Share Management** |
|
||||||
|
| List shares/exports/targets | ✅ | ✅ | ✅ | ✅ (public) |
|
||||||
|
| Create share/export/target | ✅ | ✅* | ❌ | ❌ |
|
||||||
|
| Update share/export/target | ✅ | ✅* | ❌ | ❌ |
|
||||||
|
| Delete share/export/target | ✅ | ✅* | ❌ | ❌ |
|
||||||
|
| **Snapshot Management** |
|
||||||
|
| List snapshots/policies | ✅ | ✅ | ✅ | ✅ (public) |
|
||||||
|
| Create snapshot/policy | ✅ | ✅* | ❌ | ❌ |
|
||||||
|
| Delete snapshot/policy | ✅ | ✅* | ❌ | ❌ |
|
||||||
|
| **Maintenance Mode** |
|
||||||
|
| View status | ✅ | ✅ | ✅ | ✅ (public) |
|
||||||
|
| Enable/disable | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
|
||||||
|
*Currently works but not explicitly restricted - any authenticated user can perform these operations
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Role Checking
|
||||||
|
|
||||||
|
Roles are checked using the `requireRole()` middleware:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Example: Administrator-only endpoint
|
||||||
|
a.mux.HandleFunc("/api/v1/users", methodHandler(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) { a.handleListUsers(w, r) },
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
adminRole := models.RoleAdministrator
|
||||||
|
a.requireRole(adminRole)(http.HandlerFunc(a.handleCreateUser)).ServeHTTP(w, r)
|
||||||
|
},
|
||||||
|
nil, nil, nil,
|
||||||
|
))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Roles Support
|
||||||
|
|
||||||
|
The `requireRole()` function accepts multiple roles:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Allow both Administrator and Operator
|
||||||
|
a.requireRole(models.RoleAdministrator, models.RoleOperator)(handler)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Current Limitations
|
||||||
|
|
||||||
|
1. **No Operator/Viewer Differentiation**: Most storage operations don't check roles - they only require authentication
|
||||||
|
2. **Hardcoded Role Checks**: Role permissions are defined in route handlers, not in a centralized permission matrix
|
||||||
|
3. **No Granular Permissions**: Can't assign specific permissions (e.g., "can create pools but not delete them")
|
||||||
|
|
||||||
|
## Future Improvements
|
||||||
|
|
||||||
|
To properly implement Operator and Viewer roles:
|
||||||
|
|
||||||
|
1. **Add Role Checks to Storage Operations**:
|
||||||
|
- Allow Operator and Administrator for create/update/delete operations
|
||||||
|
- Restrict Viewer to read-only (GET requests only)
|
||||||
|
|
||||||
|
2. **Centralize Permission Matrix**:
|
||||||
|
- Create a permission configuration file or database table
|
||||||
|
- Map operations to required roles
|
||||||
|
|
||||||
|
3. **Granular Permissions** (Future):
|
||||||
|
- Allow custom permission sets
|
||||||
|
- Support resource-level permissions (e.g., "can manage pool X but not pool Y")
|
||||||
|
|
||||||
|
## Testing Roles
|
||||||
|
|
||||||
|
To test different roles:
|
||||||
|
|
||||||
|
1. Create users with different roles via the Management page
|
||||||
|
2. Login as each user
|
||||||
|
3. Attempt operations and verify permissions
|
||||||
|
|
||||||
|
**Note**: Currently, most operations work for any authenticated user. Only user management, service management, and maintenance mode are properly restricted to Administrators.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: atlasOS Storage Controller API
|
title: AtlasOS Storage Controller API
|
||||||
description: |
|
description: |
|
||||||
REST API for managing ZFS storage, storage services (SMB/NFS/iSCSI), snapshots, and system configuration.
|
REST API for managing ZFS storage, storage services (SMB/NFS/iSCSI), snapshots, and system configuration.
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ info:
|
|||||||
|
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
contact:
|
contact:
|
||||||
name: atlasOS Support
|
name: AtlasOS Support
|
||||||
url: https://github.com/atlasos
|
url: https://github.com/atlasos
|
||||||
|
|
||||||
servers:
|
servers:
|
||||||
|
|||||||
34
fix-sudoers.sh
Executable file
34
fix-sudoers.sh
Executable file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Quick fix script to add current user to ZFS sudoers for development
|
||||||
|
# Usage: sudo ./fix-sudoers.sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
CURRENT_USER=$(whoami)
|
||||||
|
SUDOERS_FILE="/etc/sudoers.d/atlas-zfs"
|
||||||
|
|
||||||
|
echo "Adding $CURRENT_USER to ZFS sudoers for development..."
|
||||||
|
|
||||||
|
# Check if sudoers file exists
|
||||||
|
if [ ! -f "$SUDOERS_FILE" ]; then
|
||||||
|
echo "Creating sudoers file..."
|
||||||
|
cat > "$SUDOERS_FILE" <<EOF
|
||||||
|
# Allow ZFS commands without password for development
|
||||||
|
# This file is auto-generated - modify with caution
|
||||||
|
EOF
|
||||||
|
chmod 440 "$SUDOERS_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if user is already in the file
|
||||||
|
if grep -q "^$CURRENT_USER" "$SUDOERS_FILE"; then
|
||||||
|
echo "User $CURRENT_USER already has ZFS sudoers access"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add user to sudoers file
|
||||||
|
cat >> "$SUDOERS_FILE" <<EOF
|
||||||
|
$CURRENT_USER ALL=(ALL) NOPASSWD: /usr/sbin/zpool, /usr/bin/zpool, /sbin/zpool, /usr/sbin/zfs, /usr/bin/zfs, /sbin/zfs
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "Added $CURRENT_USER to ZFS sudoers"
|
||||||
|
echo "You can now run atlas-api without sudo password prompts"
|
||||||
757
install.sh
757
install.sh
@@ -1,757 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
#
|
|
||||||
# AtlasOS Installation Script
|
|
||||||
# Installs AtlasOS storage controller on a Linux system
|
|
||||||
#
|
|
||||||
# Usage: sudo ./install.sh [options]
|
|
||||||
#
|
|
||||||
# Note: Run this script from the atlas repository root directory
|
|
||||||
#
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Colors for output
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
# Default values
|
|
||||||
INSTALL_DIR="/opt/atlas"
|
|
||||||
DATA_DIR="/var/lib/atlas"
|
|
||||||
CONFIG_DIR="/etc/atlas"
|
|
||||||
SERVICE_USER="atlas"
|
|
||||||
LOG_DIR="/var/log/atlas"
|
|
||||||
BACKUP_DIR="/var/lib/atlas/backups"
|
|
||||||
HTTP_ADDR=":8080"
|
|
||||||
DB_PATH="/var/lib/atlas/atlas.db"
|
|
||||||
BUILD_BINARIES=true
|
|
||||||
SKIP_DEPS=false
|
|
||||||
REPO_DIR=""
|
|
||||||
|
|
||||||
# Parse command line arguments
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case $1 in
|
|
||||||
--install-dir)
|
|
||||||
INSTALL_DIR="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--data-dir)
|
|
||||||
DATA_DIR="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--skip-deps)
|
|
||||||
SKIP_DEPS=true
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--skip-build)
|
|
||||||
BUILD_BINARIES=false
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--http-addr)
|
|
||||||
HTTP_ADDR="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--repo-dir)
|
|
||||||
REPO_DIR="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
-h|--help)
|
|
||||||
echo "AtlasOS Installation Script"
|
|
||||||
echo ""
|
|
||||||
echo "Usage: sudo ./install.sh [options]"
|
|
||||||
echo ""
|
|
||||||
echo "Options:"
|
|
||||||
echo " --install-dir DIR Installation directory (default: /opt/atlas)"
|
|
||||||
echo " --data-dir DIR Data directory (default: /var/lib/atlas)"
|
|
||||||
echo " --skip-deps Skip dependency installation"
|
|
||||||
echo " --skip-build Skip building binaries (use existing)"
|
|
||||||
echo " --http-addr ADDR HTTP address (default: :8080)"
|
|
||||||
echo " --repo-dir DIR Repository directory (if not in current dir)"
|
|
||||||
echo " -h, --help Show this help message"
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Unknown option: $1"
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# Check if running as root
|
|
||||||
if [[ $EUID -ne 0 ]]; then
|
|
||||||
echo -e "${RED}Error: This script must be run as root (use sudo)${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Get script directory early (for path resolution)
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
# Detect distribution
|
|
||||||
detect_distro() {
|
|
||||||
if [[ -f /etc/os-release ]]; then
|
|
||||||
. /etc/os-release
|
|
||||||
DISTRO=$ID
|
|
||||||
VERSION=$VERSION_ID
|
|
||||||
else
|
|
||||||
echo -e "${RED}Error: Cannot detect Linux distribution${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
install_dependencies() {
|
|
||||||
echo -e "${GREEN}Installing dependencies...${NC}"
|
|
||||||
|
|
||||||
case $DISTRO in
|
|
||||||
ubuntu|debian)
|
|
||||||
apt-get update
|
|
||||||
|
|
||||||
# Try targetcli-fb first (common on newer Ubuntu/Debian), fallback to targetcli
|
|
||||||
TARGETCLI_PKG="targetcli-fb"
|
|
||||||
if ! apt-cache show targetcli-fb &>/dev/null; then
|
|
||||||
TARGETCLI_PKG="targetcli"
|
|
||||||
fi
|
|
||||||
|
|
||||||
apt-get install -y \
|
|
||||||
zfsutils-linux \
|
|
||||||
samba \
|
|
||||||
nfs-kernel-server \
|
|
||||||
$TARGETCLI_PKG \
|
|
||||||
sqlite3 \
|
|
||||||
golang-go \
|
|
||||||
git \
|
|
||||||
build-essential \
|
|
||||||
curl
|
|
||||||
;;
|
|
||||||
fedora|rhel|centos)
|
|
||||||
if command -v dnf &> /dev/null; then
|
|
||||||
dnf install -y \
|
|
||||||
zfs \
|
|
||||||
samba \
|
|
||||||
nfs-utils \
|
|
||||||
targetcli \
|
|
||||||
sqlite \
|
|
||||||
golang \
|
|
||||||
git \
|
|
||||||
gcc \
|
|
||||||
make \
|
|
||||||
curl
|
|
||||||
else
|
|
||||||
yum install -y \
|
|
||||||
zfs \
|
|
||||||
samba \
|
|
||||||
nfs-utils \
|
|
||||||
targetcli \
|
|
||||||
sqlite \
|
|
||||||
golang \
|
|
||||||
git \
|
|
||||||
gcc \
|
|
||||||
make \
|
|
||||||
curl
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo -e "${YELLOW}Warning: Unknown distribution. Please install dependencies manually:${NC}"
|
|
||||||
echo " - ZFS utilities"
|
|
||||||
echo " - Samba (SMB/CIFS)"
|
|
||||||
echo " - NFS server"
|
|
||||||
echo " - targetcli or targetcli-fb (iSCSI)"
|
|
||||||
echo " - SQLite"
|
|
||||||
echo " - Go compiler"
|
|
||||||
echo " - Build tools"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
echo -e "${GREEN}Dependencies installed${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create system user
|
|
||||||
create_user() {
|
|
||||||
echo -e "${GREEN}Creating system user...${NC}"
|
|
||||||
|
|
||||||
if ! id "$SERVICE_USER" &>/dev/null; then
|
|
||||||
useradd -r -s /bin/false -d "$DATA_DIR" "$SERVICE_USER"
|
|
||||||
echo -e "${GREEN}User $SERVICE_USER created${NC}"
|
|
||||||
else
|
|
||||||
echo -e "${YELLOW}User $SERVICE_USER already exists${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Add user to disk group for block device access (required for ZFS)
|
|
||||||
if getent group disk > /dev/null 2>&1; then
|
|
||||||
usermod -a -G disk "$SERVICE_USER"
|
|
||||||
echo -e "${GREEN}Added $SERVICE_USER to disk group${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create sudoers configuration for ZFS commands
|
|
||||||
echo -e "${GREEN}Configuring sudo for ZFS operations...${NC}"
|
|
||||||
cat > /etc/sudoers.d/atlas-zfs <<EOF
|
|
||||||
# Allow atlas user to run ZFS commands without password
|
|
||||||
# This is required for ZFS pool operations
|
|
||||||
$SERVICE_USER ALL=(ALL) NOPASSWD: /usr/sbin/zpool, /usr/bin/zpool, /sbin/zpool, /usr/sbin/zfs, /usr/bin/zfs, /sbin/zfs
|
|
||||||
EOF
|
|
||||||
chmod 440 /etc/sudoers.d/atlas-zfs
|
|
||||||
echo -e "${GREEN}Sudo configuration created${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create directories
|
|
||||||
create_directories() {
|
|
||||||
echo -e "${GREEN}Creating directories...${NC}"
|
|
||||||
|
|
||||||
mkdir -p "$INSTALL_DIR/bin"
|
|
||||||
mkdir -p "$DATA_DIR"
|
|
||||||
mkdir -p "$CONFIG_DIR"
|
|
||||||
mkdir -p "$LOG_DIR"
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
mkdir -p "$CONFIG_DIR/tls"
|
|
||||||
|
|
||||||
# Set ownership
|
|
||||||
chown -R "$SERVICE_USER:$SERVICE_USER" "$DATA_DIR"
|
|
||||||
chown -R "$SERVICE_USER:$SERVICE_USER" "$LOG_DIR"
|
|
||||||
chown -R "$SERVICE_USER:$SERVICE_USER" "$BACKUP_DIR"
|
|
||||||
chown -R "$SERVICE_USER:$SERVICE_USER" "$CONFIG_DIR"
|
|
||||||
|
|
||||||
# Set permissions
|
|
||||||
chmod 755 "$INSTALL_DIR"
|
|
||||||
chmod 755 "$INSTALL_DIR/bin"
|
|
||||||
chmod 700 "$DATA_DIR"
|
|
||||||
chmod 700 "$CONFIG_DIR"
|
|
||||||
chmod 750 "$LOG_DIR"
|
|
||||||
chmod 750 "$BACKUP_DIR"
|
|
||||||
|
|
||||||
echo -e "${GREEN}Directories created${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Build binaries
|
|
||||||
build_binaries() {
|
|
||||||
if [[ "$BUILD_BINARIES" == "false" ]]; then
|
|
||||||
echo -e "${YELLOW}Skipping binary build${NC}"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo -e "${GREEN}Building binaries...${NC}"
|
|
||||||
|
|
||||||
# Check if we're in the right directory
|
|
||||||
# Look for go.mod and internal/ directory (indicates we're in the repo root)
|
|
||||||
BUILD_DIR=""
|
|
||||||
|
|
||||||
# Function to check if directory is valid repo root
|
|
||||||
is_repo_root() {
|
|
||||||
local dir="$1"
|
|
||||||
[[ -f "$dir/go.mod" ]] && [[ -d "$dir/internal" ]]
|
|
||||||
}
|
|
||||||
|
|
||||||
# Try REPO_DIR first
|
|
||||||
if [[ -n "$REPO_DIR" ]] && is_repo_root "$REPO_DIR"; then
|
|
||||||
BUILD_DIR="$(cd "$REPO_DIR" && pwd)"
|
|
||||||
# Try current directory
|
|
||||||
elif is_repo_root "."; then
|
|
||||||
BUILD_DIR="$(pwd)"
|
|
||||||
# Try script directory
|
|
||||||
elif is_repo_root "$SCRIPT_DIR"; then
|
|
||||||
BUILD_DIR="$SCRIPT_DIR"
|
|
||||||
# Try parent of script directory
|
|
||||||
elif is_repo_root "$SCRIPT_DIR/.."; then
|
|
||||||
BUILD_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# If still not found, show error
|
|
||||||
if [[ -z "$BUILD_DIR" ]]; then
|
|
||||||
echo -e "${RED}Error: Cannot find atlas repository root${NC}"
|
|
||||||
echo " Current directory: $(pwd)"
|
|
||||||
echo " Script directory: $SCRIPT_DIR"
|
|
||||||
echo ""
|
|
||||||
echo " Looking for directory with:"
|
|
||||||
echo " - go.mod file"
|
|
||||||
echo " - internal/ directory"
|
|
||||||
echo ""
|
|
||||||
echo " Checking current directory:"
|
|
||||||
[[ -f "./go.mod" ]] && echo " ✓ Found go.mod" || echo " ✗ No go.mod"
|
|
||||||
[[ -d "./internal" ]] && echo " ✓ Found internal/" || echo " ✗ No internal/"
|
|
||||||
echo ""
|
|
||||||
echo " Please run installer from the atlas repository root"
|
|
||||||
echo " Or specify path with: --repo-dir /path/to/atlas"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "$BUILD_DIR"
|
|
||||||
echo "Building from: $(pwd)"
|
|
||||||
|
|
||||||
# Create cmd directory structure if it doesn't exist
|
|
||||||
if [[ ! -f "./cmd/atlas-api/main.go" ]]; then
|
|
||||||
echo -e "${YELLOW}cmd/ directory not found, creating...${NC}"
|
|
||||||
mkdir -p ./cmd/atlas-api
|
|
||||||
mkdir -p ./cmd/atlas-tui
|
|
||||||
|
|
||||||
# Create atlas-api/main.go
|
|
||||||
cat > ./cmd/atlas-api/main.go <<'MAINGO'
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/tls"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/httpapp"
|
|
||||||
tlscfg "gitea.avt.data-center.id/othman.suseno/atlas/internal/tls"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
addr := env("ATLAS_HTTP_ADDR", ":8080")
|
|
||||||
dbPath := env("ATLAS_DB_PATH", "data/atlas.db")
|
|
||||||
|
|
||||||
app, err := httpapp.New(httpapp.Config{
|
|
||||||
Addr: addr,
|
|
||||||
TemplatesDir: "web/templates",
|
|
||||||
StaticDir: "web/static",
|
|
||||||
DatabasePath: dbPath,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("init app: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tlsConfig := tlscfg.LoadConfig()
|
|
||||||
if err := tlsConfig.Validate(); err != nil {
|
|
||||||
log.Fatalf("TLS configuration error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var tlsServerConfig interface{}
|
|
||||||
if tlsConfig.Enabled {
|
|
||||||
var err error
|
|
||||||
tlsServerConfig, err = tlsConfig.BuildTLSConfig()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("TLS configuration error: %v", err)
|
|
||||||
}
|
|
||||||
log.Printf("[atlas-api] TLS enabled (cert: %s, key: %s)", tlsConfig.CertFile, tlsConfig.KeyFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
srv := &http.Server{
|
|
||||||
Addr: addr,
|
|
||||||
Handler: app.Router(),
|
|
||||||
ReadHeaderTimeout: 5 * time.Second,
|
|
||||||
WriteTimeout: 30 * time.Second,
|
|
||||||
IdleTimeout: 120 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
if tlsServerConfig != nil {
|
|
||||||
if cfg, ok := tlsServerConfig.(*tls.Config); ok {
|
|
||||||
srv.TLSConfig = cfg
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
log.Printf("[atlas-api] listening on %s", addr)
|
|
||||||
var err error
|
|
||||||
if tlsConfig.Enabled {
|
|
||||||
err = srv.ListenAndServeTLS("", "")
|
|
||||||
} else {
|
|
||||||
err = srv.ListenAndServe()
|
|
||||||
}
|
|
||||||
if err != nil && err != http.ErrServerClosed {
|
|
||||||
log.Fatalf("listen: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
stop := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(stop, os.Interrupt, os.Kill)
|
|
||||||
<-stop
|
|
||||||
log.Printf("[atlas-api] shutdown requested")
|
|
||||||
|
|
||||||
app.StopScheduler()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
if err := srv.Shutdown(ctx); err != nil {
|
|
||||||
log.Printf("[atlas-api] shutdown error: %v", err)
|
|
||||||
} else {
|
|
||||||
log.Printf("[atlas-api] shutdown complete")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func env(key, def string) string {
|
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
MAINGO
|
|
||||||
|
|
||||||
# Create atlas-tui/main.go
|
|
||||||
cat > ./cmd/atlas-tui/main.go <<'MAINGO'
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/tui"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
defaultAPIURL = "http://localhost:8080"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
apiURL := os.Getenv("ATLAS_API_URL")
|
|
||||||
if apiURL == "" {
|
|
||||||
apiURL = defaultAPIURL
|
|
||||||
}
|
|
||||||
|
|
||||||
client := tui.NewAPIClient(apiURL)
|
|
||||||
app := tui.NewApp(client)
|
|
||||||
|
|
||||||
sigChan := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
|
||||||
go func() {
|
|
||||||
<-sigChan
|
|
||||||
app.Cleanup()
|
|
||||||
os.Exit(0)
|
|
||||||
}()
|
|
||||||
|
|
||||||
if err := app.Run(); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MAINGO
|
|
||||||
|
|
||||||
echo -e "${GREEN}Created cmd/ directory structure${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Verify cmd files exist now
|
|
||||||
if [[ ! -f "./cmd/atlas-api/main.go" ]]; then
|
|
||||||
echo -e "${RED}Error: Failed to create cmd/atlas-api/main.go${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ ! -f "./cmd/atlas-tui/main.go" ]]; then
|
|
||||||
echo -e "${RED}Error: Failed to create cmd/atlas-tui/main.go${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Build binaries
|
|
||||||
echo "Building atlas-api..."
|
|
||||||
if ! go build -o "$INSTALL_DIR/bin/atlas-api" ./cmd/atlas-api; then
|
|
||||||
echo -e "${RED}Error: Failed to build atlas-api${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Building atlas-tui..."
|
|
||||||
if ! go build -o "$INSTALL_DIR/bin/atlas-tui" ./cmd/atlas-tui; then
|
|
||||||
echo -e "${RED}Error: Failed to build atlas-tui${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Set permissions
|
|
||||||
chown root:root "$INSTALL_DIR/bin/atlas-api"
|
|
||||||
chown root:root "$INSTALL_DIR/bin/atlas-tui"
|
|
||||||
chmod 755 "$INSTALL_DIR/bin/atlas-api"
|
|
||||||
chmod 755 "$INSTALL_DIR/bin/atlas-tui"
|
|
||||||
|
|
||||||
# Create symlinks in /usr/local/bin for global access
|
|
||||||
echo -e "${GREEN}Creating symlinks for global access...${NC}"
|
|
||||||
ln -sf "$INSTALL_DIR/bin/atlas-api" /usr/local/bin/atlas-api
|
|
||||||
ln -sf "$INSTALL_DIR/bin/atlas-tui" /usr/local/bin/atlas-tui
|
|
||||||
chmod 755 /usr/local/bin/atlas-api
|
|
||||||
chmod 755 /usr/local/bin/atlas-tui
|
|
||||||
|
|
||||||
echo -e "${GREEN}Binaries built and installed successfully${NC}"
|
|
||||||
echo " Binaries available at: $INSTALL_DIR/bin/"
|
|
||||||
echo " Global commands: atlas-api, atlas-tui"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create systemd service
|
|
||||||
create_systemd_service() {
|
|
||||||
echo -e "${GREEN}Creating systemd service...${NC}"
|
|
||||||
|
|
||||||
cat > /etc/systemd/system/atlas-api.service <<EOF
|
|
||||||
[Unit]
|
|
||||||
Description=AtlasOS Storage Controller API
|
|
||||||
After=network.target zfs.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=$SERVICE_USER
|
|
||||||
Group=$SERVICE_USER
|
|
||||||
WorkingDirectory=$INSTALL_DIR
|
|
||||||
ExecStart=$INSTALL_DIR/bin/atlas-api
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
SyslogIdentifier=atlas-api
|
|
||||||
|
|
||||||
# Environment variables
|
|
||||||
Environment="ATLAS_HTTP_ADDR=$HTTP_ADDR"
|
|
||||||
Environment="ATLAS_DB_PATH=$DB_PATH"
|
|
||||||
Environment="ATLAS_BACKUP_DIR=$BACKUP_DIR"
|
|
||||||
Environment="ATLAS_LOG_LEVEL=INFO"
|
|
||||||
Environment="ATLAS_LOG_FORMAT=json"
|
|
||||||
|
|
||||||
# Security
|
|
||||||
# Note: NoNewPrivileges is set to false to allow sudo for ZFS operations
|
|
||||||
# This is necessary for ZFS pool management
|
|
||||||
NoNewPrivileges=false
|
|
||||||
PrivateTmp=true
|
|
||||||
ProtectSystem=strict
|
|
||||||
ProtectHome=true
|
|
||||||
ReadWritePaths=$DATA_DIR $LOG_DIR $BACKUP_DIR $CONFIG_DIR
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
|
|
||||||
systemctl daemon-reload
|
|
||||||
echo -e "${GREEN}Systemd service created${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create configuration file
|
|
||||||
create_config() {
|
|
||||||
echo -e "${GREEN}Creating configuration...${NC}"
|
|
||||||
|
|
||||||
cat > "$CONFIG_DIR/atlas.conf" <<EOF
|
|
||||||
# AtlasOS Configuration
|
|
||||||
# This file is sourced by the systemd service
|
|
||||||
|
|
||||||
# HTTP Server
|
|
||||||
ATLAS_HTTP_ADDR=$HTTP_ADDR
|
|
||||||
|
|
||||||
# Database
|
|
||||||
ATLAS_DB_PATH=$DB_PATH
|
|
||||||
|
|
||||||
# Backup Directory
|
|
||||||
ATLAS_BACKUP_DIR=$BACKUP_DIR
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
ATLAS_LOG_LEVEL=INFO
|
|
||||||
ATLAS_LOG_FORMAT=json
|
|
||||||
|
|
||||||
# TLS (optional - uncomment to enable)
|
|
||||||
# ATLAS_TLS_ENABLED=true
|
|
||||||
# ATLAS_TLS_CERT=$CONFIG_DIR/tls/cert.pem
|
|
||||||
# ATLAS_TLS_KEY=$CONFIG_DIR/tls/key.pem
|
|
||||||
|
|
||||||
# JWT Secret (generate with: openssl rand -hex 32)
|
|
||||||
# ATLAS_JWT_SECRET=your-secret-here
|
|
||||||
EOF
|
|
||||||
|
|
||||||
chown "$SERVICE_USER:$SERVICE_USER" "$CONFIG_DIR/atlas.conf"
|
|
||||||
chmod 600 "$CONFIG_DIR/atlas.conf"
|
|
||||||
|
|
||||||
echo -e "${GREEN}Configuration created${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Generate JWT secret
|
|
||||||
generate_jwt_secret() {
|
|
||||||
echo -e "${GREEN}Generating JWT secret...${NC}"
|
|
||||||
|
|
||||||
if command -v openssl &> /dev/null; then
|
|
||||||
JWT_SECRET=$(openssl rand -hex 32)
|
|
||||||
echo "ATLAS_JWT_SECRET=$JWT_SECRET" >> "$CONFIG_DIR/atlas.conf"
|
|
||||||
echo -e "${GREEN}JWT secret generated${NC}"
|
|
||||||
else
|
|
||||||
echo -e "${YELLOW}Warning: openssl not found. Please set ATLAS_JWT_SECRET manually${NC}"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Setup ZFS (if needed)
|
|
||||||
setup_zfs() {
|
|
||||||
echo -e "${GREEN}Checking ZFS...${NC}"
|
|
||||||
|
|
||||||
if ! command -v zpool &> /dev/null; then
|
|
||||||
echo -e "${YELLOW}Warning: ZFS not found. Please install ZFS utilities${NC}"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if ZFS module is loaded
|
|
||||||
if ! lsmod | grep -q zfs; then
|
|
||||||
echo -e "${YELLOW}Warning: ZFS kernel module not loaded${NC}"
|
|
||||||
echo " Run: modprobe zfs"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo -e "${GREEN}ZFS check complete${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Setup Samba
|
|
||||||
setup_samba() {
|
|
||||||
echo -e "${GREEN}Setting up Samba...${NC}"
|
|
||||||
|
|
||||||
if ! command -v smbd &> /dev/null; then
|
|
||||||
echo -e "${YELLOW}Warning: Samba not found${NC}"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Enable and start Samba (if not already)
|
|
||||||
systemctl enable smbd 2>/dev/null || true
|
|
||||||
systemctl enable nmbd 2>/dev/null || true
|
|
||||||
|
|
||||||
echo -e "${GREEN}Samba setup complete${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Setup NFS
|
|
||||||
setup_nfs() {
|
|
||||||
echo -e "${GREEN}Setting up NFS...${NC}"
|
|
||||||
|
|
||||||
if ! command -v exportfs &> /dev/null; then
|
|
||||||
echo -e "${YELLOW}Warning: NFS not found${NC}"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Enable and start NFS (if not already)
|
|
||||||
systemctl enable nfs-server 2>/dev/null || true
|
|
||||||
systemctl enable rpcbind 2>/dev/null || true
|
|
||||||
|
|
||||||
echo -e "${GREEN}NFS setup complete${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Setup iSCSI
|
|
||||||
setup_iscsi() {
|
|
||||||
echo -e "${GREEN}Setting up iSCSI...${NC}"
|
|
||||||
|
|
||||||
# Check for targetcli or targetcli-fb
|
|
||||||
TARGETCLI_CMD=""
|
|
||||||
if command -v targetcli &> /dev/null; then
|
|
||||||
TARGETCLI_CMD="targetcli"
|
|
||||||
elif command -v targetcli-fb &> /dev/null; then
|
|
||||||
TARGETCLI_CMD="targetcli-fb"
|
|
||||||
# Create symlink if targetcli doesn't exist
|
|
||||||
if ! command -v targetcli &> /dev/null; then
|
|
||||||
ln -s $(which targetcli-fb) /usr/local/bin/targetcli 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "$TARGETCLI_CMD" ]]; then
|
|
||||||
echo -e "${YELLOW}Warning: targetcli or targetcli-fb not found${NC}"
|
|
||||||
echo " Install with: apt-get install targetcli-fb (Ubuntu/Debian)"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Enable and start iSCSI target (if not already)
|
|
||||||
systemctl enable target 2>/dev/null || true
|
|
||||||
|
|
||||||
echo -e "${GREEN}iSCSI setup complete (using $TARGETCLI_CMD)${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create initial admin user
|
|
||||||
create_admin_user() {
|
|
||||||
echo -e "${GREEN}Creating initial admin user...${NC}"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}Please set up the initial admin user:${NC}"
|
|
||||||
echo " Username: admin"
|
|
||||||
echo " Password: (you will be prompted)"
|
|
||||||
echo ""
|
|
||||||
echo "After starting the service, you can create the admin user via:"
|
|
||||||
echo " curl -X POST http://localhost:8080/api/v1/users \\"
|
|
||||||
echo " -H 'Content-Type: application/json' \\"
|
|
||||||
echo " -d '{\"username\":\"admin\",\"password\":\"your-password\",\"role\":\"administrator\"}'"
|
|
||||||
echo ""
|
|
||||||
echo "Or use the TUI:"
|
|
||||||
echo " $INSTALL_DIR/bin/atlas-tui"
|
|
||||||
echo ""
|
|
||||||
}
|
|
||||||
|
|
||||||
# Start service
|
|
||||||
start_service() {
|
|
||||||
echo -e "${GREEN}Starting AtlasOS service...${NC}"
|
|
||||||
|
|
||||||
systemctl enable atlas-api
|
|
||||||
systemctl start atlas-api
|
|
||||||
|
|
||||||
# Wait a moment for service to start
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
if systemctl is-active --quiet atlas-api; then
|
|
||||||
echo -e "${GREEN}AtlasOS service started successfully${NC}"
|
|
||||||
else
|
|
||||||
echo -e "${RED}Error: Service failed to start${NC}"
|
|
||||||
echo "Check logs with: journalctl -u atlas-api -n 50"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Print summary
|
|
||||||
print_summary() {
|
|
||||||
echo ""
|
|
||||||
echo -e "${GREEN}========================================${NC}"
|
|
||||||
echo -e "${GREEN}AtlasOS Installation Complete!${NC}"
|
|
||||||
echo -e "${GREEN}========================================${NC}"
|
|
||||||
echo ""
|
|
||||||
echo "Installation Directory: $INSTALL_DIR"
|
|
||||||
echo "Data Directory: $DATA_DIR"
|
|
||||||
echo "Config Directory: $CONFIG_DIR"
|
|
||||||
echo "Log Directory: $LOG_DIR"
|
|
||||||
echo ""
|
|
||||||
echo "Service Status:"
|
|
||||||
systemctl status atlas-api --no-pager -l || true
|
|
||||||
echo ""
|
|
||||||
echo "Useful Commands:"
|
|
||||||
echo " Service: systemctl {start|stop|restart|status} atlas-api"
|
|
||||||
echo " Logs: journalctl -u atlas-api -f"
|
|
||||||
echo " TUI: $INSTALL_DIR/bin/atlas-tui"
|
|
||||||
echo ""
|
|
||||||
echo "Web Interface:"
|
|
||||||
echo " http://localhost:8080"
|
|
||||||
echo ""
|
|
||||||
echo "API Documentation:"
|
|
||||||
echo " http://localhost:8080/api/docs"
|
|
||||||
echo ""
|
|
||||||
echo -e "${YELLOW}Next Steps:${NC}"
|
|
||||||
echo "1. Create initial admin user (see instructions above)"
|
|
||||||
echo "2. Configure TLS certificates (optional)"
|
|
||||||
echo "3. Review configuration in $CONFIG_DIR/atlas.conf"
|
|
||||||
echo ""
|
|
||||||
}
|
|
||||||
|
|
||||||
# Main installation
|
|
||||||
main() {
|
|
||||||
echo -e "${GREEN}========================================${NC}"
|
|
||||||
echo -e "${GREEN}AtlasOS Installation Script${NC}"
|
|
||||||
echo -e "${GREEN}========================================${NC}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
detect_distro
|
|
||||||
echo "Detected distribution: $DISTRO $VERSION"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
if [[ "$SKIP_DEPS" == "false" ]]; then
|
|
||||||
install_dependencies
|
|
||||||
else
|
|
||||||
echo -e "${YELLOW}Skipping dependency installation${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
create_user
|
|
||||||
create_directories
|
|
||||||
build_binaries
|
|
||||||
create_config
|
|
||||||
generate_jwt_secret
|
|
||||||
create_systemd_service
|
|
||||||
|
|
||||||
setup_zfs
|
|
||||||
setup_samba
|
|
||||||
setup_nfs
|
|
||||||
setup_iscsi
|
|
||||||
|
|
||||||
create_admin_user
|
|
||||||
|
|
||||||
# Ask if user wants to start service
|
|
||||||
echo ""
|
|
||||||
read -p "Start AtlasOS service now? (y/n) " -n 1 -r
|
|
||||||
echo ""
|
|
||||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
|
||||||
start_service
|
|
||||||
else
|
|
||||||
echo -e "${YELLOW}Service not started. Start manually with: systemctl start atlas-api${NC}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
print_summary
|
|
||||||
}
|
|
||||||
|
|
||||||
# Run main
|
|
||||||
main
|
|
||||||
51
installer/README.md
Normal file
51
installer/README.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# AtlasOS Installer
|
||||||
|
|
||||||
|
This directory contains installation scripts for AtlasOS on Ubuntu 24.04.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- **`install.sh`** - Main installation script
|
||||||
|
- **`bundle-downloader.sh`** - Downloads all packages for airgap installation
|
||||||
|
- **`README.md`** - This file
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Standard Installation (with internet)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From repository root
|
||||||
|
sudo ./installer/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Airgap Installation (offline)
|
||||||
|
|
||||||
|
**Step 1: Download bundle (on internet-connected system)**
|
||||||
|
```bash
|
||||||
|
sudo ./installer/bundle-downloader.sh ./atlas-bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Transfer bundle to airgap system**
|
||||||
|
|
||||||
|
**Step 3: Install on airgap system**
|
||||||
|
```bash
|
||||||
|
sudo ./installer/install.sh --offline-bundle /path/to/atlas-bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
See help for all options:
|
||||||
|
```bash
|
||||||
|
sudo ./installer/install.sh --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- **Installation Guide**: `../docs/INSTALLATION.md`
|
||||||
|
- **Airgap Installation**: `../docs/AIRGAP_INSTALLATION.md`
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Ubuntu 24.04 (Noble Numbat)
|
||||||
|
- Root/sudo access
|
||||||
|
- Internet connection (for standard installation)
|
||||||
|
- Or offline bundle (for airgap installation)
|
||||||
223
installer/bundle-downloader.sh
Executable file
223
installer/bundle-downloader.sh
Executable file
@@ -0,0 +1,223 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# AtlasOS Bundle Downloader for Ubuntu 24.04
|
||||||
|
# Downloads all required packages and dependencies for airgap installation
|
||||||
|
#
|
||||||
|
# Usage: sudo ./installer/bundle-downloader.sh [output-dir]
|
||||||
|
# or: sudo ./bundle-downloader.sh [output-dir] (if run from installer/ directory)
|
||||||
|
#
|
||||||
|
# This script must be run on a system with internet access and Ubuntu 24.04
|
||||||
|
# The downloaded packages can then be transferred to airgap systems
|
||||||
|
#
|
||||||
|
# Example:
|
||||||
|
# sudo ./installer/bundle-downloader.sh ./atlas-bundle
|
||||||
|
# # Transfer ./atlas-bundle to airgap system
|
||||||
|
# # On airgap: sudo ./installer/install.sh --offline-bundle ./atlas-bundle
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
set -o pipefail
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Default output directory
|
||||||
|
OUTPUT_DIR="${1:-./atlas-bundle-ubuntu24.04}"
|
||||||
|
OUTPUT_DIR=$(realpath "$OUTPUT_DIR")
|
||||||
|
|
||||||
|
echo -e "${GREEN}========================================${NC}"
|
||||||
|
echo -e "${GREEN}AtlasOS Bundle Downloader${NC}"
|
||||||
|
echo -e "${GREEN}For Ubuntu 24.04 (Noble Numbat)${NC}"
|
||||||
|
echo -e "${GREEN}========================================${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if running on Ubuntu 24.04
|
||||||
|
if [[ ! -f /etc/os-release ]]; then
|
||||||
|
echo -e "${RED}Error: Cannot detect Linux distribution${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
. /etc/os-release
|
||||||
|
|
||||||
|
if [[ "$ID" != "ubuntu" ]] || [[ "$VERSION_ID" != "24.04" ]]; then
|
||||||
|
echo -e "${YELLOW}Warning: This script is designed for Ubuntu 24.04${NC}"
|
||||||
|
echo " Detected: $ID $VERSION_ID"
|
||||||
|
read -p "Continue anyway? (y/n) " -n 1 -r
|
||||||
|
echo ""
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if running as root (needed for apt operations)
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo -e "${RED}Error: This script must be run as root (use sudo)${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create output directory
|
||||||
|
echo "Creating output directory: $OUTPUT_DIR"
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
|
# Update package lists
|
||||||
|
echo "Updating package lists..."
|
||||||
|
apt-get update -qq
|
||||||
|
|
||||||
|
# List of packages to download (all dependencies will be included)
|
||||||
|
PACKAGES=(
|
||||||
|
# Build essentials
|
||||||
|
"build-essential"
|
||||||
|
"git"
|
||||||
|
"curl"
|
||||||
|
"wget"
|
||||||
|
"ca-certificates"
|
||||||
|
"software-properties-common"
|
||||||
|
"apt-transport-https"
|
||||||
|
|
||||||
|
# ZFS utilities
|
||||||
|
"zfsutils-linux"
|
||||||
|
"zfs-zed"
|
||||||
|
"zfs-initramfs"
|
||||||
|
|
||||||
|
# Storage services
|
||||||
|
"samba"
|
||||||
|
"samba-common-bin"
|
||||||
|
"nfs-kernel-server"
|
||||||
|
"rpcbind"
|
||||||
|
|
||||||
|
# iSCSI target
|
||||||
|
"targetcli-fb"
|
||||||
|
|
||||||
|
# Database
|
||||||
|
"sqlite3"
|
||||||
|
"libsqlite3-dev"
|
||||||
|
|
||||||
|
# Go compiler
|
||||||
|
"golang-go"
|
||||||
|
|
||||||
|
# Additional utilities
|
||||||
|
"openssl"
|
||||||
|
"net-tools"
|
||||||
|
"iproute2"
|
||||||
|
)
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}Downloading packages and all dependencies...${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Download all packages with dependencies
|
||||||
|
cd "$OUTPUT_DIR"
|
||||||
|
|
||||||
|
# Use apt-get download to get all packages including dependencies
|
||||||
|
for pkg in "${PACKAGES[@]}"; do
|
||||||
|
echo -n " Downloading $pkg... "
|
||||||
|
if apt-get download "$pkg" 2>/dev/null; then
|
||||||
|
echo -e "${GREEN}✓${NC}"
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}⚠ (may not be available)${NC}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Download all dependencies recursively
|
||||||
|
echo ""
|
||||||
|
echo "Downloading all dependencies..."
|
||||||
|
apt-get download $(apt-cache depends --recurse --no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances "${PACKAGES[@]}" | grep "^\w" | sort -u) 2>/dev/null || {
|
||||||
|
echo -e "${YELLOW}Warning: Some dependencies may have been skipped${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Download Go binary (if golang-go package is not sufficient)
|
||||||
|
echo ""
|
||||||
|
echo "Downloading Go binary (fallback)..."
|
||||||
|
GO_VERSION="1.22.0"
|
||||||
|
if ! wget -q "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" -O "$OUTPUT_DIR/go.tar.gz" 2>/dev/null; then
|
||||||
|
echo -e "${YELLOW}Warning: Could not download Go binary${NC}"
|
||||||
|
echo " You may need to download it manually from https://go.dev/dl/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create manifest file
|
||||||
|
echo ""
|
||||||
|
echo "Creating manifest..."
|
||||||
|
cat > "$OUTPUT_DIR/MANIFEST.txt" <<EOF
|
||||||
|
AtlasOS Bundle for Ubuntu 24.04 (Noble Numbat)
|
||||||
|
Generated: $(date -u +"%Y-%m-%d %H:%M:%S UTC")
|
||||||
|
Packages: ${#PACKAGES[@]} main packages + dependencies
|
||||||
|
|
||||||
|
Main Packages:
|
||||||
|
$(printf '%s\n' "${PACKAGES[@]}")
|
||||||
|
|
||||||
|
Total .deb files: $(find "$OUTPUT_DIR" -name "*.deb" | wc -l)
|
||||||
|
|
||||||
|
Installation Instructions:
|
||||||
|
1. Transfer this entire directory to your airgap system
|
||||||
|
2. Run: sudo ./installer/install.sh --offline-bundle "$OUTPUT_DIR"
|
||||||
|
|
||||||
|
Note: Ensure all .deb files are present before transferring
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Create README
|
||||||
|
cat > "$OUTPUT_DIR/README.md" <<'EOF'
|
||||||
|
# AtlasOS Offline Bundle for Ubuntu 24.04
|
||||||
|
|
||||||
|
This bundle contains all required packages and dependencies for installing AtlasOS on an airgap (offline) Ubuntu 24.04 system.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- All required .deb packages with dependencies
|
||||||
|
- Go binary (fallback, if needed)
|
||||||
|
- Installation manifest
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Transfer this entire directory to your airgap system
|
||||||
|
2. On the airgap system, run:
|
||||||
|
```bash
|
||||||
|
sudo ./installer/install.sh --offline-bundle /path/to/this/directory
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bundle Size
|
||||||
|
|
||||||
|
The bundle typically contains:
|
||||||
|
- ~100-200 .deb packages (including dependencies)
|
||||||
|
- Total size: ~500MB - 1GB (depending on architecture)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Before transferring, verify the bundle:
|
||||||
|
```bash
|
||||||
|
# Count .deb files
|
||||||
|
find . -name "*.deb" | wc -l
|
||||||
|
|
||||||
|
# Check manifest
|
||||||
|
cat MANIFEST.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
If installation fails:
|
||||||
|
1. Check that all .deb files are present
|
||||||
|
2. Verify you're on Ubuntu 24.04
|
||||||
|
3. Check disk space (need at least 2GB free)
|
||||||
|
4. Review installation logs
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}========================================${NC}"
|
||||||
|
echo -e "${GREEN}Bundle Download Complete!${NC}"
|
||||||
|
echo -e "${GREEN}========================================${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Output directory: $OUTPUT_DIR"
|
||||||
|
echo "Total .deb files: $(find "$OUTPUT_DIR" -name "*.deb" | wc -l)"
|
||||||
|
echo "Total size: $(du -sh "$OUTPUT_DIR" | cut -f1)"
|
||||||
|
echo ""
|
||||||
|
echo "Manifest: $OUTPUT_DIR/MANIFEST.txt"
|
||||||
|
echo "README: $OUTPUT_DIR/README.md"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Next Steps:${NC}"
|
||||||
|
echo "1. Transfer this directory to your airgap system"
|
||||||
|
echo "2. On airgap system, run:"
|
||||||
|
echo " sudo ./installer/install.sh --offline-bundle \"$OUTPUT_DIR\""
|
||||||
|
echo ""
|
||||||
1281
installer/install.sh
Executable file
1281
installer/install.sh
Executable file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
|||||||
package httpapp
|
package httpapp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
@@ -36,6 +38,10 @@ func (a *App) handleListPools(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Ensure we always return an array, not null
|
||||||
|
if pools == nil {
|
||||||
|
pools = []models.Pool{}
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, pools)
|
writeJSON(w, http.StatusOK, pools)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +78,12 @@ func (a *App) handleCreatePool(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invalidate cache for GET /api/v1/pools
|
||||||
|
keyToInvalidate := "GET:/api/v1/pools"
|
||||||
|
hash := sha256.Sum256([]byte(keyToInvalidate))
|
||||||
|
cacheKey := hex.EncodeToString(hash[:])
|
||||||
|
a.cache.Invalidate(cacheKey)
|
||||||
|
|
||||||
pool, err := a.zfs.GetPool(req.Name)
|
pool, err := a.zfs.GetPool(req.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusCreated, map[string]string{"message": "pool created", "name": req.Name})
|
writeJSON(w, http.StatusCreated, map[string]string{"message": "pool created", "name": req.Name})
|
||||||
@@ -110,6 +122,12 @@ func (a *App) handleDeletePool(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invalidate cache for GET /api/v1/pools
|
||||||
|
keyToInvalidate := "GET:/api/v1/pools"
|
||||||
|
hash := sha256.Sum256([]byte(keyToInvalidate))
|
||||||
|
cacheKey := hex.EncodeToString(hash[:])
|
||||||
|
a.cache.Invalidate(cacheKey)
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"message": "pool destroyed", "name": name})
|
writeJSON(w, http.StatusOK, map[string]string{"message": "pool destroyed", "name": name})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +233,10 @@ func (a *App) handleListDatasets(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Ensure we always return an array, not null
|
||||||
|
if datasets == nil {
|
||||||
|
datasets = []models.Dataset{}
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, datasets)
|
writeJSON(w, http.StatusOK, datasets)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,6 +420,10 @@ func (a *App) handleListSnapshots(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Ensure we always return an array, not null
|
||||||
|
if snapshots == nil {
|
||||||
|
snapshots = []models.Snapshot{}
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, snapshots)
|
writeJSON(w, http.StatusOK, snapshots)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,6 +511,10 @@ func (a *App) handleListSnapshotPolicies(w http.ResponseWriter, r *http.Request)
|
|||||||
} else {
|
} else {
|
||||||
policies = a.snapshotPolicy.List()
|
policies = a.snapshotPolicy.List()
|
||||||
}
|
}
|
||||||
|
// Ensure we always return an array, not null
|
||||||
|
if policies == nil {
|
||||||
|
policies = []models.SnapshotPolicy{}
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, policies)
|
writeJSON(w, http.StatusOK, policies)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1322,6 +1352,10 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
req.Role = models.RoleViewer // Default role
|
req.Role = models.RoleViewer // Default role
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize role to lowercase for comparison
|
||||||
|
roleStr := strings.ToLower(string(req.Role))
|
||||||
|
req.Role = models.Role(roleStr)
|
||||||
|
|
||||||
// Validate role
|
// Validate role
|
||||||
if req.Role != models.RoleAdministrator && req.Role != models.RoleOperator && req.Role != models.RoleViewer {
|
if req.Role != models.RoleAdministrator && req.Role != models.RoleOperator && req.Role != models.RoleViewer {
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid role"})
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid role"})
|
||||||
@@ -1376,6 +1410,12 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize role to lowercase if provided
|
||||||
|
if req.Role != "" {
|
||||||
|
roleStr := strings.ToLower(string(req.Role))
|
||||||
|
req.Role = models.Role(roleStr)
|
||||||
|
}
|
||||||
|
|
||||||
// Validate role if provided
|
// Validate role if provided
|
||||||
if req.Role != "" && req.Role != models.RoleAdministrator && req.Role != models.RoleOperator && req.Role != models.RoleViewer {
|
if req.Role != "" && req.Role != models.RoleAdministrator && req.Role != models.RoleOperator && req.Role != models.RoleViewer {
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid role"})
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid role"})
|
||||||
|
|||||||
@@ -52,14 +52,44 @@ type App struct {
|
|||||||
backupService *backup.Service
|
backupService *backup.Service
|
||||||
maintenanceService *maintenance.Service
|
maintenanceService *maintenance.Service
|
||||||
tlsConfig *tls.Config
|
tlsConfig *tls.Config
|
||||||
|
cache *ResponseCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg Config) (*App, error) {
|
func New(cfg Config) (*App, error) {
|
||||||
|
// Resolve paths relative to executable or current working directory
|
||||||
if cfg.TemplatesDir == "" {
|
if cfg.TemplatesDir == "" {
|
||||||
cfg.TemplatesDir = "web/templates"
|
// Try multiple locations for templates
|
||||||
|
possiblePaths := []string{
|
||||||
|
"web/templates",
|
||||||
|
"./web/templates",
|
||||||
|
"/opt/atlas/web/templates",
|
||||||
|
}
|
||||||
|
for _, path := range possiblePaths {
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
cfg.TemplatesDir = path
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.TemplatesDir == "" {
|
||||||
|
cfg.TemplatesDir = "web/templates" // Default fallback
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if cfg.StaticDir == "" {
|
if cfg.StaticDir == "" {
|
||||||
cfg.StaticDir = "web/static"
|
// Try multiple locations for static files
|
||||||
|
possiblePaths := []string{
|
||||||
|
"web/static",
|
||||||
|
"./web/static",
|
||||||
|
"/opt/atlas/web/static",
|
||||||
|
}
|
||||||
|
for _, path := range possiblePaths {
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
cfg.StaticDir = path
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.StaticDir == "" {
|
||||||
|
cfg.StaticDir = "web/static" // Default fallback
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tmpl, err := parseTemplates(cfg.TemplatesDir)
|
tmpl, err := parseTemplates(cfg.TemplatesDir)
|
||||||
@@ -146,6 +176,7 @@ func New(cfg Config) (*App, error) {
|
|||||||
backupService: backupService,
|
backupService: backupService,
|
||||||
maintenanceService: maintenanceService,
|
maintenanceService: maintenanceService,
|
||||||
tlsConfig: tlsConfig,
|
tlsConfig: tlsConfig,
|
||||||
|
cache: NewResponseCache(5 * time.Minute),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start snapshot scheduler (runs every 15 minutes)
|
// Start snapshot scheduler (runs every 15 minutes)
|
||||||
@@ -228,6 +259,12 @@ func parseTemplates(dir string) (*template.Template, error) {
|
|||||||
|
|
||||||
funcs := template.FuncMap{
|
funcs := template.FuncMap{
|
||||||
"nowRFC3339": func() string { return time.Now().Format(time.RFC3339) },
|
"nowRFC3339": func() string { return time.Now().Format(time.RFC3339) },
|
||||||
|
"getContentTemplate": func(data map[string]any) string {
|
||||||
|
if ct, ok := data["ContentTemplate"].(string); ok && ct != "" {
|
||||||
|
return ct
|
||||||
|
}
|
||||||
|
return "content"
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
t := template.New("root").Funcs(funcs)
|
t := template.New("root").Funcs(funcs)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func (a *App) auditMiddleware(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Skip audit for public endpoints
|
// Skip audit for public endpoints
|
||||||
if a.isPublicEndpoint(r.URL.Path) {
|
if a.isPublicEndpoint(r.URL.Path, r.Method) {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ const (
|
|||||||
// authMiddleware validates JWT tokens and extracts user info
|
// authMiddleware validates JWT tokens and extracts user info
|
||||||
func (a *App) authMiddleware(next http.Handler) http.Handler {
|
func (a *App) authMiddleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Skip auth for public endpoints
|
// Skip auth for public endpoints (includes web UI pages and read-only GET endpoints)
|
||||||
if a.isPublicEndpoint(r.URL.Path) {
|
if a.isPublicEndpoint(r.URL.Path, r.Method) {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -98,26 +98,66 @@ func (a *App) requireRole(allowedRoles ...models.Role) func(http.Handler) http.H
|
|||||||
}
|
}
|
||||||
|
|
||||||
// isPublicEndpoint checks if an endpoint is public (no auth required)
|
// isPublicEndpoint checks if an endpoint is public (no auth required)
|
||||||
func (a *App) isPublicEndpoint(path string) bool {
|
// It validates both path and HTTP method to prevent unauthenticated mutations
|
||||||
|
func (a *App) isPublicEndpoint(path, method string) bool {
|
||||||
|
// Always public paths (any method)
|
||||||
publicPaths := []string{
|
publicPaths := []string{
|
||||||
"/healthz",
|
"/healthz",
|
||||||
|
"/health",
|
||||||
"/metrics",
|
"/metrics",
|
||||||
"/api/v1/auth/login",
|
"/api/v1/auth/login",
|
||||||
"/api/v1/auth/logout",
|
"/api/v1/auth/logout",
|
||||||
"/", // Dashboard (can be made protected later)
|
"/", // Dashboard
|
||||||
|
"/login", // Login page
|
||||||
|
"/storage", // Storage management page
|
||||||
|
"/shares", // Shares page
|
||||||
|
"/iscsi", // iSCSI page
|
||||||
|
"/protection", // Data Protection page
|
||||||
|
"/management", // System Management page
|
||||||
|
"/api/docs", // API documentation
|
||||||
|
"/api/openapi.yaml", // OpenAPI spec
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, publicPath := range publicPaths {
|
for _, publicPath := range publicPaths {
|
||||||
if path == publicPath || strings.HasPrefix(path, publicPath+"/") {
|
if path == publicPath {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Also allow paths that start with public paths (for sub-pages)
|
||||||
|
if strings.HasPrefix(path, publicPath+"/") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static files are public
|
// Static files are public (any method)
|
||||||
if strings.HasPrefix(path, "/static/") {
|
if strings.HasPrefix(path, "/static/") {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read-only GET endpoints are public for web UI (but require auth for mutations)
|
||||||
|
// SECURITY: Only GET requests are allowed without authentication
|
||||||
|
// POST, PUT, DELETE, PATCH require authentication
|
||||||
|
publicReadOnlyPaths := []string{
|
||||||
|
"/api/v1/dashboard", // Dashboard data
|
||||||
|
"/api/v1/disks", // List disks
|
||||||
|
"/api/v1/pools", // List pools (GET only)
|
||||||
|
"/api/v1/pools/available", // List available pools
|
||||||
|
"/api/v1/datasets", // List datasets (GET only)
|
||||||
|
"/api/v1/zvols", // List ZVOLs (GET only)
|
||||||
|
"/api/v1/shares/smb", // List SMB shares (GET only)
|
||||||
|
"/api/v1/exports/nfs", // List NFS exports (GET only)
|
||||||
|
"/api/v1/iscsi/targets", // List iSCSI targets (GET only)
|
||||||
|
"/api/v1/snapshots", // List snapshots (GET only)
|
||||||
|
"/api/v1/snapshot-policies", // List snapshot policies (GET only)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, publicPath := range publicReadOnlyPaths {
|
||||||
|
if path == publicPath {
|
||||||
|
// Only allow GET requests without authentication
|
||||||
|
// All mutation methods (POST, PUT, DELETE, PATCH) require authentication
|
||||||
|
return method == http.MethodGet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,9 +126,6 @@ func generateETag(content []byte) string {
|
|||||||
|
|
||||||
// cacheMiddleware provides response caching for GET requests
|
// cacheMiddleware provides response caching for GET requests
|
||||||
func (a *App) cacheMiddleware(next http.Handler) http.Handler {
|
func (a *App) cacheMiddleware(next http.Handler) http.Handler {
|
||||||
// Default TTL: 5 minutes for GET requests
|
|
||||||
cache := NewResponseCache(5 * time.Minute)
|
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Only cache GET requests
|
// Only cache GET requests
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
@@ -137,7 +134,7 @@ func (a *App) cacheMiddleware(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Skip caching for authenticated endpoints that may have user-specific data
|
// Skip caching for authenticated endpoints that may have user-specific data
|
||||||
if !a.isPublicEndpoint(r.URL.Path) {
|
if !a.isPublicEndpoint(r.URL.Path, r.Method) {
|
||||||
// Check if user is authenticated - if so, skip caching
|
// Check if user is authenticated - if so, skip caching
|
||||||
// In production, you might want per-user caching by including user ID in cache key
|
// In production, you might want per-user caching by including user ID in cache key
|
||||||
if _, ok := getUserFromContext(r); ok {
|
if _, ok := getUserFromContext(r); ok {
|
||||||
@@ -154,7 +151,7 @@ func (a *App) cacheMiddleware(next http.Handler) http.Handler {
|
|||||||
|
|
||||||
// Check cache
|
// Check cache
|
||||||
cacheKey := generateCacheKey(r)
|
cacheKey := generateCacheKey(r)
|
||||||
if entry, found := cache.Get(cacheKey); found {
|
if entry, found := a.cache.Get(cacheKey); found {
|
||||||
// Check If-None-Match header for ETag validation
|
// Check If-None-Match header for ETag validation
|
||||||
ifNoneMatch := r.Header.Get("If-None-Match")
|
ifNoneMatch := r.Header.Get("If-None-Match")
|
||||||
if ifNoneMatch == entry.ETag {
|
if ifNoneMatch == entry.ETag {
|
||||||
@@ -202,7 +199,7 @@ func (a *App) cacheMiddleware(next http.Handler) http.Handler {
|
|||||||
ETag: etag,
|
ETag: etag,
|
||||||
}
|
}
|
||||||
|
|
||||||
cache.Set(cacheKey, entry)
|
a.cache.Set(cacheKey, entry)
|
||||||
|
|
||||||
// Add cache headers
|
// Add cache headers
|
||||||
w.Header().Set("ETag", etag)
|
w.Header().Set("ETag", etag)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ func (a *App) handleAPIDocs(w http.ResponseWriter, r *http.Request) {
|
|||||||
html := `<!DOCTYPE html>
|
html := `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>atlasOS API Documentation</title>
|
<title>AtlasOS API Documentation</title>
|
||||||
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5.10.3/swagger-ui.css" />
|
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5.10.3/swagger-ui.css" />
|
||||||
<style>
|
<style>
|
||||||
html { box-sizing: border-box; overflow: -moz-scrollbars-vertical; overflow-y: scroll; }
|
html { box-sizing: border-box; overflow: -moz-scrollbars-vertical; overflow-y: scroll; }
|
||||||
|
|||||||
@@ -17,6 +17,72 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
a.render(w, "dashboard.html", data)
|
a.render(w, "dashboard.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) handleStorage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := map[string]any{
|
||||||
|
"Title": "Storage Management",
|
||||||
|
"Build": map[string]string{
|
||||||
|
"version": "v0.1.0-dev",
|
||||||
|
},
|
||||||
|
"ContentTemplate": "storage-content",
|
||||||
|
}
|
||||||
|
a.render(w, "storage.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleShares(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := map[string]any{
|
||||||
|
"Title": "Storage Shares",
|
||||||
|
"Build": map[string]string{
|
||||||
|
"version": "v0.1.0-dev",
|
||||||
|
},
|
||||||
|
"ContentTemplate": "shares-content",
|
||||||
|
}
|
||||||
|
a.render(w, "shares.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleISCSI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := map[string]any{
|
||||||
|
"Title": "iSCSI Targets",
|
||||||
|
"Build": map[string]string{
|
||||||
|
"version": "v0.1.0-dev",
|
||||||
|
},
|
||||||
|
"ContentTemplate": "iscsi-content",
|
||||||
|
}
|
||||||
|
a.render(w, "iscsi.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleProtection(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := map[string]any{
|
||||||
|
"Title": "Data Protection",
|
||||||
|
"Build": map[string]string{
|
||||||
|
"version": "v0.1.0-dev",
|
||||||
|
},
|
||||||
|
"ContentTemplate": "protection-content",
|
||||||
|
}
|
||||||
|
a.render(w, "protection.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleManagement(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := map[string]any{
|
||||||
|
"Title": "System Management",
|
||||||
|
"Build": map[string]string{
|
||||||
|
"version": "v0.1.0-dev",
|
||||||
|
},
|
||||||
|
"ContentTemplate": "management-content",
|
||||||
|
}
|
||||||
|
a.render(w, "management.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := map[string]any{
|
||||||
|
"Title": "Login",
|
||||||
|
"Build": map[string]string{
|
||||||
|
"version": "v0.1.0-dev",
|
||||||
|
},
|
||||||
|
"ContentTemplate": "login-content",
|
||||||
|
}
|
||||||
|
a.render(w, "login.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
||||||
id, _ := r.Context().Value(requestIDKey).(string)
|
id, _ := r.Context().Value(requestIDKey).(string)
|
||||||
resp := map[string]any{
|
resp := map[string]any{
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
func (a *App) httpsEnforcementMiddleware(next http.Handler) http.Handler {
|
func (a *App) httpsEnforcementMiddleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Skip HTTPS enforcement for health checks and localhost
|
// Skip HTTPS enforcement for health checks and localhost
|
||||||
if a.isPublicEndpoint(r.URL.Path) || isLocalhost(r) {
|
if a.isPublicEndpoint(r.URL.Path, r.Method) || isLocalhost(r) {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ func isLocalhost(r *http.Request) bool {
|
|||||||
func (a *App) requireHTTPSMiddleware(next http.Handler) http.Handler {
|
func (a *App) requireHTTPSMiddleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Skip for health checks
|
// Skip for health checks
|
||||||
if a.isPublicEndpoint(r.URL.Path) {
|
if a.isPublicEndpoint(r.URL.Path, r.Method) {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func (a *App) maintenanceMiddleware(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.isPublicEndpoint(r.URL.Path) {
|
if a.isPublicEndpoint(r.URL.Path, r.Method) {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ func (a *App) rateLimitMiddleware(next http.Handler) http.Handler {
|
|||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// Skip rate limiting for public endpoints
|
// Skip rate limiting for public endpoints
|
||||||
if a.isPublicEndpoint(r.URL.Path) {
|
if a.isPublicEndpoint(r.URL.Path, r.Method) {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ func (a *App) routes() {
|
|||||||
|
|
||||||
// Web UI
|
// Web UI
|
||||||
a.mux.HandleFunc("/", a.handleDashboard)
|
a.mux.HandleFunc("/", a.handleDashboard)
|
||||||
|
a.mux.HandleFunc("/login", a.handleLoginPage)
|
||||||
|
a.mux.HandleFunc("/storage", a.handleStorage)
|
||||||
|
a.mux.HandleFunc("/shares", a.handleShares)
|
||||||
|
a.mux.HandleFunc("/iscsi", a.handleISCSI)
|
||||||
|
a.mux.HandleFunc("/protection", a.handleProtection)
|
||||||
|
a.mux.HandleFunc("/management", a.handleManagement)
|
||||||
|
|
||||||
// Health & metrics
|
// Health & metrics
|
||||||
a.mux.HandleFunc("/healthz", a.handleHealthz)
|
a.mux.HandleFunc("/healthz", a.handleHealthz)
|
||||||
@@ -173,6 +179,61 @@ func (a *App) routes() {
|
|||||||
))
|
))
|
||||||
a.mux.HandleFunc("/api/v1/users/", a.handleUserOpsWithAuth)
|
a.mux.HandleFunc("/api/v1/users/", a.handleUserOpsWithAuth)
|
||||||
|
|
||||||
|
// Service Management (requires authentication, admin-only)
|
||||||
|
a.mux.HandleFunc("/api/v1/services", methodHandler(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
adminRole := models.RoleAdministrator
|
||||||
|
a.requireRole(adminRole)(http.HandlerFunc(a.handleListServices)).ServeHTTP(w, r)
|
||||||
|
},
|
||||||
|
nil, nil, nil, nil,
|
||||||
|
))
|
||||||
|
a.mux.HandleFunc("/api/v1/services/status", methodHandler(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
adminRole := models.RoleAdministrator
|
||||||
|
a.requireRole(adminRole)(http.HandlerFunc(a.handleServiceStatus)).ServeHTTP(w, r)
|
||||||
|
},
|
||||||
|
nil, nil, nil, nil,
|
||||||
|
))
|
||||||
|
a.mux.HandleFunc("/api/v1/services/start", methodHandler(
|
||||||
|
nil,
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
adminRole := models.RoleAdministrator
|
||||||
|
a.requireRole(adminRole)(http.HandlerFunc(a.handleServiceStart)).ServeHTTP(w, r)
|
||||||
|
},
|
||||||
|
nil, nil, nil,
|
||||||
|
))
|
||||||
|
a.mux.HandleFunc("/api/v1/services/stop", methodHandler(
|
||||||
|
nil,
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
adminRole := models.RoleAdministrator
|
||||||
|
a.requireRole(adminRole)(http.HandlerFunc(a.handleServiceStop)).ServeHTTP(w, r)
|
||||||
|
},
|
||||||
|
nil, nil, nil,
|
||||||
|
))
|
||||||
|
a.mux.HandleFunc("/api/v1/services/restart", methodHandler(
|
||||||
|
nil,
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
adminRole := models.RoleAdministrator
|
||||||
|
a.requireRole(adminRole)(http.HandlerFunc(a.handleServiceRestart)).ServeHTTP(w, r)
|
||||||
|
},
|
||||||
|
nil, nil, nil,
|
||||||
|
))
|
||||||
|
a.mux.HandleFunc("/api/v1/services/reload", methodHandler(
|
||||||
|
nil,
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
adminRole := models.RoleAdministrator
|
||||||
|
a.requireRole(adminRole)(http.HandlerFunc(a.handleServiceReload)).ServeHTTP(w, r)
|
||||||
|
},
|
||||||
|
nil, nil, nil,
|
||||||
|
))
|
||||||
|
a.mux.HandleFunc("/api/v1/services/logs", methodHandler(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
adminRole := models.RoleAdministrator
|
||||||
|
a.requireRole(adminRole)(http.HandlerFunc(a.handleServiceLogs)).ServeHTTP(w, r)
|
||||||
|
},
|
||||||
|
nil, nil, nil, nil,
|
||||||
|
))
|
||||||
|
|
||||||
// Audit Logs
|
// Audit Logs
|
||||||
a.mux.HandleFunc("/api/v1/audit", a.handleListAuditLogs)
|
a.mux.HandleFunc("/api/v1/audit", a.handleListAuditLogs)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ func (a *App) securityHeadersMiddleware(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Content Security Policy (CSP)
|
// Content Security Policy (CSP)
|
||||||
csp := "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data:; font-src 'self' https://cdn.jsdelivr.net; connect-src 'self';"
|
// Allow Tailwind CDN, unpkg (for htmx), and jsdelivr for external resources
|
||||||
|
// Note: Tailwind CDN needs connect-src to fetch config and make network requests
|
||||||
|
csp := "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://unpkg.com https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://unpkg.com https://cdn.jsdelivr.net; img-src 'self' data:; font-src 'self' https://cdn.tailwindcss.com https://unpkg.com https://cdn.jsdelivr.net; connect-src 'self' https://cdn.tailwindcss.com https://unpkg.com https://cdn.jsdelivr.net;"
|
||||||
w.Header().Set("Content-Security-Policy", csp)
|
w.Header().Set("Content-Security-Policy", csp)
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
@@ -96,7 +98,7 @@ func (a *App) validateContentTypeMiddleware(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Skip for public endpoints
|
// Skip for public endpoints
|
||||||
if a.isPublicEndpoint(r.URL.Path) {
|
if a.isPublicEndpoint(r.URL.Path, r.Method) {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
249
internal/httpapp/service_handlers.go
Normal file
249
internal/httpapp/service_handlers.go
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
package httpapp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ManagedService represents a service with its status
|
||||||
|
type ManagedService struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Output string `json:"output,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// getServiceName extracts service name from query parameter or defaults to atlas-api
|
||||||
|
func getServiceName(r *http.Request) string {
|
||||||
|
serviceName := r.URL.Query().Get("service")
|
||||||
|
if serviceName == "" {
|
||||||
|
return "atlas-api"
|
||||||
|
}
|
||||||
|
return serviceName
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAllServices returns list of all managed services
|
||||||
|
func getAllServices() []ManagedService {
|
||||||
|
services := []ManagedService{
|
||||||
|
{Name: "atlas-api", DisplayName: "AtlasOS API"},
|
||||||
|
{Name: "smbd", DisplayName: "SMB/CIFS (Samba)"},
|
||||||
|
{Name: "nfs-server", DisplayName: "NFS Server"},
|
||||||
|
{Name: "target", DisplayName: "iSCSI Target"},
|
||||||
|
}
|
||||||
|
return services
|
||||||
|
}
|
||||||
|
|
||||||
|
// getServiceStatus returns the status of a specific service
|
||||||
|
func getServiceStatus(serviceName string) (string, string, error) {
|
||||||
|
cmd := exec.Command("systemctl", "status", serviceName, "--no-pager", "-l")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// systemctl status returns non-zero exit code even when service is running
|
||||||
|
// Check if output contains "active (running)" to determine actual status
|
||||||
|
if strings.Contains(outputStr, "active (running)") {
|
||||||
|
return "running", outputStr, nil
|
||||||
|
}
|
||||||
|
if strings.Contains(outputStr, "inactive (dead)") {
|
||||||
|
return "stopped", outputStr, nil
|
||||||
|
}
|
||||||
|
if strings.Contains(outputStr, "failed") {
|
||||||
|
return "failed", outputStr, nil
|
||||||
|
}
|
||||||
|
// Service might not exist
|
||||||
|
if strings.Contains(outputStr, "could not be found") || strings.Contains(outputStr, "not found") {
|
||||||
|
return "not-found", outputStr, nil
|
||||||
|
}
|
||||||
|
return "unknown", outputStr, err
|
||||||
|
}
|
||||||
|
|
||||||
|
status := "unknown"
|
||||||
|
if strings.Contains(outputStr, "active (running)") {
|
||||||
|
status = "running"
|
||||||
|
} else if strings.Contains(outputStr, "inactive (dead)") {
|
||||||
|
status = "stopped"
|
||||||
|
} else if strings.Contains(outputStr, "failed") {
|
||||||
|
status = "failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
return status, outputStr, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListServices returns the status of all services
|
||||||
|
func (a *App) handleListServices(w http.ResponseWriter, r *http.Request) {
|
||||||
|
allServices := getAllServices()
|
||||||
|
servicesStatus := make([]ManagedService, 0, len(allServices))
|
||||||
|
|
||||||
|
for _, svc := range allServices {
|
||||||
|
status, output, err := getServiceStatus(svc.Name)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("error getting status for %s: %v", svc.Name, err)
|
||||||
|
status = "error"
|
||||||
|
}
|
||||||
|
servicesStatus = append(servicesStatus, ManagedService{
|
||||||
|
Name: svc.Name,
|
||||||
|
DisplayName: svc.DisplayName,
|
||||||
|
Status: status,
|
||||||
|
Output: output,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"services": servicesStatus,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleServiceStatus returns the status of a specific service
|
||||||
|
func (a *App) handleServiceStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
serviceName := getServiceName(r)
|
||||||
|
status, output, err := getServiceStatus(serviceName)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||||
|
"error": "failed to get service status",
|
||||||
|
"details": output,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"service": serviceName,
|
||||||
|
"status": status,
|
||||||
|
"output": output,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleServiceStart starts a service
|
||||||
|
func (a *App) handleServiceStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
serviceName := getServiceName(r)
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
// Special handling for SMB - use smbcontrol for reload, but systemctl for start/stop
|
||||||
|
if serviceName == "smbd" {
|
||||||
|
cmd = exec.Command("systemctl", "start", "smbd")
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("systemctl", "start", serviceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("service start error for %s: %v", serviceName, err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||||
|
"error": "failed to start service",
|
||||||
|
"service": serviceName,
|
||||||
|
"details": string(output),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "service started successfully",
|
||||||
|
"service": serviceName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleServiceStop stops a service
|
||||||
|
func (a *App) handleServiceStop(w http.ResponseWriter, r *http.Request) {
|
||||||
|
serviceName := getServiceName(r)
|
||||||
|
cmd := exec.Command("systemctl", "stop", serviceName)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("service stop error for %s: %v", serviceName, err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||||
|
"error": "failed to stop service",
|
||||||
|
"service": serviceName,
|
||||||
|
"details": string(output),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "service stopped successfully",
|
||||||
|
"service": serviceName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleServiceRestart restarts a service
|
||||||
|
func (a *App) handleServiceRestart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
serviceName := getServiceName(r)
|
||||||
|
cmd := exec.Command("systemctl", "restart", serviceName)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("service restart error for %s: %v", serviceName, err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||||
|
"error": "failed to restart service",
|
||||||
|
"service": serviceName,
|
||||||
|
"details": string(output),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "service restarted successfully",
|
||||||
|
"service": serviceName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleServiceReload reloads a service
|
||||||
|
func (a *App) handleServiceReload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
serviceName := getServiceName(r)
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
// Special handling for SMB - use smbcontrol for reload
|
||||||
|
if serviceName == "smbd" {
|
||||||
|
cmd = exec.Command("smbcontrol", "all", "reload-config")
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command("systemctl", "reload", serviceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("service reload error for %s: %v", serviceName, err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||||
|
"error": "failed to reload service",
|
||||||
|
"service": serviceName,
|
||||||
|
"details": string(output),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "service reloaded successfully",
|
||||||
|
"service": serviceName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleServiceLogs returns the logs of a service
|
||||||
|
func (a *App) handleServiceLogs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
serviceName := getServiceName(r)
|
||||||
|
|
||||||
|
// Get number of lines from query parameter (default: 50)
|
||||||
|
linesStr := r.URL.Query().Get("lines")
|
||||||
|
lines := "50"
|
||||||
|
if linesStr != "" {
|
||||||
|
if n, err := strconv.Atoi(linesStr); err == nil && n > 0 && n <= 1000 {
|
||||||
|
lines = linesStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("journalctl", "-u", serviceName, "-n", lines, "--no-pager")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("service logs error for %s: %v", serviceName, err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{
|
||||||
|
"error": "failed to get service logs",
|
||||||
|
"service": serviceName,
|
||||||
|
"details": string(output),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"service": serviceName,
|
||||||
|
"logs": string(output),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -20,12 +20,35 @@ type Service struct {
|
|||||||
|
|
||||||
// New creates a new ZFS service
|
// New creates a new ZFS service
|
||||||
func New() *Service {
|
func New() *Service {
|
||||||
|
// Find full paths to zfs and zpool commands
|
||||||
|
zfsPath := findCommandPath("zfs")
|
||||||
|
zpoolPath := findCommandPath("zpool")
|
||||||
|
|
||||||
return &Service{
|
return &Service{
|
||||||
zfsPath: "zfs",
|
zfsPath: zfsPath,
|
||||||
zpoolPath: "zpool",
|
zpoolPath: zpoolPath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// findCommandPath finds the full path to a command
|
||||||
|
func findCommandPath(cmd string) string {
|
||||||
|
// Try which first
|
||||||
|
if output, err := exec.Command("which", cmd).Output(); err == nil {
|
||||||
|
path := strings.TrimSpace(string(output))
|
||||||
|
if path != "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try LookPath
|
||||||
|
if path, err := exec.LookPath(cmd); err == nil {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to command name (will use PATH)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
// execCommand executes a shell command and returns output
|
// execCommand executes a shell command and returns output
|
||||||
// For ZFS operations that require elevated privileges, it uses sudo
|
// For ZFS operations that require elevated privileges, it uses sudo
|
||||||
func (s *Service) execCommand(name string, args ...string) (string, error) {
|
func (s *Service) execCommand(name string, args ...string) (string, error) {
|
||||||
@@ -42,8 +65,9 @@ func (s *Service) execCommand(name string, args ...string) (string, error) {
|
|||||||
|
|
||||||
var cmd *exec.Cmd
|
var cmd *exec.Cmd
|
||||||
if useSudo {
|
if useSudo {
|
||||||
// Use sudo for privileged commands
|
// Use sudo -n (non-interactive) for privileged commands
|
||||||
sudoArgs := append([]string{name}, args...)
|
// This prevents password prompts and will fail if sudoers is not configured
|
||||||
|
sudoArgs := append([]string{"-n", name}, args...)
|
||||||
cmd = exec.Command("sudo", sudoArgs...)
|
cmd = exec.Command("sudo", sudoArgs...)
|
||||||
} else {
|
} else {
|
||||||
cmd = exec.Command(name, args...)
|
cmd = exec.Command(name, args...)
|
||||||
@@ -53,7 +77,24 @@ func (s *Service) execCommand(name string, args ...string) (string, error) {
|
|||||||
cmd.Stdout = &stdout
|
cmd.Stdout = &stdout
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
err := cmd.Run()
|
||||||
|
if err != nil && useSudo {
|
||||||
|
// If sudo failed, try running the command directly
|
||||||
|
// (user might already have permissions or be root)
|
||||||
|
directCmd := exec.Command(name, args...)
|
||||||
|
var directStdout, directStderr bytes.Buffer
|
||||||
|
directCmd.Stdout = &directStdout
|
||||||
|
directCmd.Stderr = &directStderr
|
||||||
|
|
||||||
|
if directErr := directCmd.Run(); directErr == nil {
|
||||||
|
// Direct execution succeeded, return that result
|
||||||
|
return strings.TrimSpace(directStdout.String()), nil
|
||||||
|
}
|
||||||
|
// Both sudo and direct failed, return the original sudo error
|
||||||
|
return "", fmt.Errorf("%s: %v: %s", name, err, stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
return "", fmt.Errorf("%s: %v: %s", name, err, stderr.String())
|
return "", fmt.Errorf("%s: %v: %s", name, err, stderr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +105,11 @@ func (s *Service) execCommand(name string, args ...string) (string, error) {
|
|||||||
func (s *Service) ListPools() ([]models.Pool, error) {
|
func (s *Service) ListPools() ([]models.Pool, error) {
|
||||||
output, err := s.execCommand(s.zpoolPath, "list", "-H", "-o", "name,size,allocated,free,health")
|
output, err := s.execCommand(s.zpoolPath, "list", "-H", "-o", "name,size,allocated,free,health")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
// Return empty slice instead of nil to ensure JSON encodes as [] not null
|
||||||
|
return []models.Pool{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var pools []models.Pool
|
pools := []models.Pool{}
|
||||||
lines := strings.Split(output, "\n")
|
lines := strings.Split(output, "\n")
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
if line == "" {
|
if line == "" {
|
||||||
@@ -347,10 +389,11 @@ func (s *Service) ListDatasets(pool string) ([]models.Dataset, error) {
|
|||||||
|
|
||||||
output, err := s.execCommand(s.zfsPath, args...)
|
output, err := s.execCommand(s.zfsPath, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
// Return empty slice instead of nil to ensure JSON encodes as [] not null
|
||||||
|
return []models.Dataset{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var datasets []models.Dataset
|
datasets := []models.Dataset{}
|
||||||
lines := strings.Split(output, "\n")
|
lines := strings.Split(output, "\n")
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
if line == "" {
|
if line == "" {
|
||||||
@@ -428,10 +471,11 @@ func (s *Service) ListZVOLs(pool string) ([]models.ZVOL, error) {
|
|||||||
|
|
||||||
output, err := s.execCommand(s.zfsPath, args...)
|
output, err := s.execCommand(s.zfsPath, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
// Return empty slice instead of nil to ensure JSON encodes as [] not null
|
||||||
|
return []models.ZVOL{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var zvols []models.ZVOL
|
zvols := []models.ZVOL{}
|
||||||
lines := strings.Split(output, "\n")
|
lines := strings.Split(output, "\n")
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
if line == "" {
|
if line == "" {
|
||||||
@@ -588,10 +632,11 @@ func (s *Service) ListSnapshots(dataset string) ([]models.Snapshot, error) {
|
|||||||
|
|
||||||
output, err := s.execCommand(s.zfsPath, args...)
|
output, err := s.execCommand(s.zfsPath, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
// Return empty slice instead of nil to ensure JSON encodes as [] not null
|
||||||
|
return []models.Snapshot{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var snapshots []models.Snapshot
|
snapshots := []models.Snapshot{}
|
||||||
lines := strings.Split(output, "\n")
|
lines := strings.Split(output, "\n")
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
if line == "" {
|
if line == "" {
|
||||||
|
|||||||
@@ -4,11 +4,12 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>{{.Title}} • atlasOS</title>
|
<title>{{.Title}} • AtlasOS</title>
|
||||||
|
|
||||||
<!-- v1: Tailwind CDN (later: bundle local) -->
|
<!-- v1: Tailwind CDN (later: bundle local) -->
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<!-- Try multiple CDN sources for better reliability -->
|
||||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
<script src="https://cdn.tailwindcss.com" onerror="this.onerror=null;this.src='https://unpkg.com/@tailwindcss/browser@4/dist/tailwind.min.js'"></script>
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js" onerror="this.onerror=null;this.src='https://cdn.jsdelivr.net/npm/htmx.org@1.9.12/dist/htmx.min.js'"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="bg-slate-950 text-slate-100">
|
<body class="bg-slate-950 text-slate-100">
|
||||||
@@ -17,31 +18,92 @@
|
|||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="h-9 w-9 rounded-lg bg-slate-800 flex items-center justify-center font-bold">A</div>
|
<div class="h-9 w-9 rounded-lg bg-slate-800 flex items-center justify-center font-bold">A</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold leading-tight">atlasOS</div>
|
<div class="font-semibold leading-tight">AtlasOS</div>
|
||||||
<div class="text-xs text-slate-400 leading-tight">Storage Controller v1</div>
|
<div class="text-xs text-slate-400 leading-tight">Storage Controller v1</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="text-sm text-slate-300 flex items-center gap-4">
|
<nav class="text-sm text-slate-300 flex items-center gap-4">
|
||||||
<a class="hover:text-white" href="/">Dashboard</a>
|
<a class="hover:text-white" href="/">Dashboard</a>
|
||||||
<a class="hover:text-white opacity-50 cursor-not-allowed" href="#">Storage</a>
|
<a class="hover:text-white" href="/storage">Storage</a>
|
||||||
<a class="hover:text-white opacity-50 cursor-not-allowed" href="#">Shares</a>
|
<a class="hover:text-white" href="/shares">Shares</a>
|
||||||
<a class="hover:text-white opacity-50 cursor-not-allowed" href="#">iSCSI</a>
|
<a class="hover:text-white" href="/iscsi">iSCSI</a>
|
||||||
|
<a class="hover:text-white" href="/protection">Data Protection</a>
|
||||||
|
<a class="hover:text-white" href="/management">Management</a>
|
||||||
<a class="hover:text-white opacity-50 cursor-not-allowed" href="#">Monitoring</a>
|
<a class="hover:text-white opacity-50 cursor-not-allowed" href="#">Monitoring</a>
|
||||||
|
<span id="auth-status" class="ml-4"></span>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="mx-auto max-w-6xl px-4 py-8">
|
<main class="mx-auto max-w-6xl px-4 py-8">
|
||||||
|
{{$ct := getContentTemplate .}}
|
||||||
|
{{if eq $ct "storage-content"}}
|
||||||
|
{{template "storage-content" .}}
|
||||||
|
{{else if eq $ct "shares-content"}}
|
||||||
|
{{template "shares-content" .}}
|
||||||
|
{{else if eq $ct "iscsi-content"}}
|
||||||
|
{{template "iscsi-content" .}}
|
||||||
|
{{else if eq $ct "protection-content"}}
|
||||||
|
{{template "protection-content" .}}
|
||||||
|
{{else if eq $ct "management-content"}}
|
||||||
|
{{template "management-content" .}}
|
||||||
|
{{else if eq $ct "login-content"}}
|
||||||
|
{{template "login-content" .}}
|
||||||
|
{{else}}
|
||||||
{{template "content" .}}
|
{{template "content" .}}
|
||||||
|
{{end}}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="mx-auto max-w-6xl px-4 pb-10 text-xs text-slate-500">
|
<footer class="mx-auto max-w-6xl px-4 pb-10 text-xs text-slate-500">
|
||||||
<div class="border-t border-slate-800 pt-4 flex items-center justify-between">
|
<div class="border-t border-slate-800 pt-4 flex items-center justify-between">
|
||||||
<span>atlasOS • {{nowRFC3339}}</span>
|
<span>AtlasOS • {{nowRFC3339}}</span>
|
||||||
<span>Build: {{index .Build "version"}}</span>
|
<span>Build: {{index .Build "version"}}</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
<script>
|
||||||
|
// Update auth status in navigation
|
||||||
|
function updateAuthStatus() {
|
||||||
|
const authStatusEl = document.getElementById('auth-status');
|
||||||
|
if (!authStatusEl) return;
|
||||||
|
|
||||||
|
const token = localStorage.getItem('atlas_token');
|
||||||
|
const userStr = localStorage.getItem('atlas_user');
|
||||||
|
|
||||||
|
if (token && userStr) {
|
||||||
|
try {
|
||||||
|
const user = JSON.parse(userStr);
|
||||||
|
authStatusEl.innerHTML = `
|
||||||
|
<span class="text-slate-400">${user.username || 'User'}</span>
|
||||||
|
<button onclick="handleLogout()" class="ml-2 px-2 py-1 text-xs bg-slate-700 hover:bg-slate-600 text-white rounded">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
} catch {
|
||||||
|
authStatusEl.innerHTML = `
|
||||||
|
<a href="/login" class="px-2 py-1 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded">Login</a>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
authStatusEl.innerHTML = `
|
||||||
|
<a href="/login" class="px-2 py-1 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded">Login</a>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
localStorage.removeItem('atlas_token');
|
||||||
|
localStorage.removeItem('atlas_user');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update on page load
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', updateAuthStatus);
|
||||||
|
} else {
|
||||||
|
updateAuthStatus();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-white mb-2">Dashboard</h1>
|
<h1 class="text-3xl font-bold text-white mb-2">Dashboard</h1>
|
||||||
<p class="text-slate-400">Welcome to atlasOS Storage Controller</p>
|
<p class="text-slate-400">Welcome to AtlasOS Storage Controller</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-2xl font-bold text-white mb-1" id="pool-count">-</p>
|
<p class="text-2xl font-bold text-white mb-1" id="pool-count">-</p>
|
||||||
<p class="text-sm text-slate-400">ZFS Pools</p>
|
<p class="text-sm text-slate-400">Storage Pools</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Storage Capacity Card -->
|
<!-- Storage Capacity Card -->
|
||||||
@@ -124,7 +124,12 @@
|
|||||||
// Fetch dashboard data and update UI
|
// Fetch dashboard data and update UI
|
||||||
function updateDashboard() {
|
function updateDashboard() {
|
||||||
fetch('/api/v1/dashboard')
|
fetch('/api/v1/dashboard')
|
||||||
.then(res => res.json())
|
.then(res => {
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
.then(data => {
|
.then(data => {
|
||||||
// Update storage stats
|
// Update storage stats
|
||||||
document.getElementById('pool-count').textContent = data.storage.pool_count || 0;
|
document.getElementById('pool-count').textContent = data.storage.pool_count || 0;
|
||||||
|
|||||||
380
web/templates/iscsi.html
Normal file
380
web/templates/iscsi.html
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
{{define "iscsi-content"}}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-white mb-2">iSCSI Targets</h1>
|
||||||
|
<p class="text-slate-400">Manage iSCSI targets and LUNs</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="showCreateISCSIModal()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium">
|
||||||
|
Create Target
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||||
|
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-white">iSCSI Targets</h2>
|
||||||
|
<button onclick="loadISCSITargets()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div id="iscsi-targets-list" class="p-4">
|
||||||
|
<p class="text-slate-400 text-sm">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create iSCSI Target Modal -->
|
||||||
|
<div id="create-iscsi-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 class="text-xl font-semibold text-white mb-4">Create iSCSI Target</h3>
|
||||||
|
<form id="create-iscsi-form" onsubmit="createISCSITarget(event)" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">IQN</label>
|
||||||
|
<input type="text" name="iqn" placeholder="iqn.2024-12.com.atlas:target1" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
<p class="text-xs text-slate-400 mt-1">iSCSI Qualified Name</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button type="button" onclick="closeModal('create-iscsi-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add LUN Modal -->
|
||||||
|
<div id="add-lun-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 class="text-xl font-semibold text-white mb-4">Add LUN to Target</h3>
|
||||||
|
<form id="add-lun-form" onsubmit="addLUN(event)" class="space-y-4">
|
||||||
|
<input type="hidden" name="target_id" id="lun-target-id">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Select Storage Volume</label>
|
||||||
|
<select name="zvol" id="lun-zvol-select" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
<option value="">Loading volumes...</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-slate-400 mt-1">Or enter manually below</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Or Enter Volume Name Manually</label>
|
||||||
|
<input type="text" name="zvol-manual" id="lun-zvol-manual" placeholder="pool/zvol" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button type="button" onclick="closeModal('add-lun-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Add LUN
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function getAuthHeaders() {
|
||||||
|
const token = localStorage.getItem('atlas_token');
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (!bytes || bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadISCSITargets() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/iscsi/targets', { headers: getAuthHeaders() });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||||
|
document.getElementById('iscsi-targets-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.error || 'Failed to load iSCSI targets'}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targets = await res.json();
|
||||||
|
const listEl = document.getElementById('iscsi-targets-list');
|
||||||
|
|
||||||
|
if (!Array.isArray(targets)) {
|
||||||
|
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targets.length === 0) {
|
||||||
|
listEl.innerHTML = '<p class="text-slate-400 text-sm">No iSCSI targets found</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = targets.map(target => `
|
||||||
|
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<h3 class="text-lg font-semibold text-white">${target.iqn}</h3>
|
||||||
|
${target.enabled ? '<span class="px-2 py-1 rounded text-xs font-medium bg-green-900 text-green-300">Enabled</span>' : '<span class="px-2 py-1 rounded text-xs font-medium bg-slate-700 text-slate-300">Disabled</span>'}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-slate-400 space-y-1 mb-3">
|
||||||
|
<p>LUNs: ${target.luns ? target.luns.length : 0}</p>
|
||||||
|
${target.initiators && target.initiators.length > 0 ? `<p>Initiators: ${target.initiators.join(', ')}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
${target.luns && target.luns.length > 0 ? `
|
||||||
|
<div class="bg-slate-900 rounded p-3 mt-2">
|
||||||
|
<h4 class="text-sm font-medium text-slate-300 mb-2">LUNs (${target.luns.length}):</h4>
|
||||||
|
<div class="space-y-2">
|
||||||
|
${target.luns.map(lun => `
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-slate-400">LUN ${lun.id}:</span>
|
||||||
|
<span class="text-slate-300 font-mono">${lun.zvol}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-slate-300">${formatBytes(lun.size)}</span>
|
||||||
|
<button onclick="removeLUN('${target.id}', ${lun.id})" class="ml-2 px-2 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-xs" title="Remove LUN">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : '<p class="text-sm text-slate-500 mt-2">No LUNs attached. Click "Add LUN" to bind a volume.</p>'}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 ml-4">
|
||||||
|
<button onclick="showAddLUNModal('${target.id}')" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Add LUN
|
||||||
|
</button>
|
||||||
|
<button onclick="showConnectionInstructions('${target.id}')" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||||
|
Connection Info
|
||||||
|
</button>
|
||||||
|
<button onclick="deleteISCSITarget('${target.id}')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('iscsi-targets-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreateISCSIModal() {
|
||||||
|
document.getElementById('create-iscsi-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showAddLUNModal(targetId) {
|
||||||
|
document.getElementById('lun-target-id').value = targetId;
|
||||||
|
document.getElementById('add-lun-modal').classList.remove('hidden');
|
||||||
|
|
||||||
|
// Load available storage volumes
|
||||||
|
await loadZVOLsForLUN();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadZVOLsForLUN() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/zvols', { headers: getAuthHeaders() });
|
||||||
|
const selectEl = document.getElementById('lun-zvol-select');
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
selectEl.innerHTML = '<option value="">Error loading volumes</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zvols = await res.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(zvols)) {
|
||||||
|
selectEl.innerHTML = '<option value="">No volumes available</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zvols.length === 0) {
|
||||||
|
selectEl.innerHTML = '<option value="">No volumes found. Create a storage volume first.</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear and populate dropdown
|
||||||
|
selectEl.innerHTML = '<option value="">Select a volume...</option>';
|
||||||
|
zvols.forEach(zvol => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = zvol.name;
|
||||||
|
option.textContent = `${zvol.name} (${formatBytes(zvol.size)})`;
|
||||||
|
selectEl.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update manual input when dropdown changes
|
||||||
|
selectEl.addEventListener('change', function() {
|
||||||
|
if (this.value) {
|
||||||
|
document.getElementById('lun-zvol-manual').value = this.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update dropdown when manual input changes
|
||||||
|
document.getElementById('lun-zvol-manual').addEventListener('input', function() {
|
||||||
|
if (this.value && !selectEl.value) {
|
||||||
|
// Allow manual entry even if not in dropdown
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading volumes:', err);
|
||||||
|
document.getElementById('lun-zvol-select').innerHTML = '<option value="">Error loading volumes</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showConnectionInstructions(targetId) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/iscsi/targets/${targetId}/connection`, { headers: getAuthHeaders() });
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const instructions = `
|
||||||
|
Connection Instructions for ${data.target?.iqn || 'target'}:
|
||||||
|
|
||||||
|
Portal: ${data.portal || 'N/A'}
|
||||||
|
|
||||||
|
Linux:
|
||||||
|
iscsiadm -m discovery -t st -p ${data.portal || '127.0.0.1'}
|
||||||
|
iscsiadm -m node -T ${data.target?.iqn || ''} -p ${data.portal || '127.0.0.1'} --login
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
Use iSCSI Initiator from Control Panel
|
||||||
|
Add target: ${data.portal || '127.0.0.1'}:${data.port || 3260}
|
||||||
|
Target: ${data.target?.iqn || ''}
|
||||||
|
|
||||||
|
macOS:
|
||||||
|
Use System Preferences > Network > iSCSI
|
||||||
|
`;
|
||||||
|
|
||||||
|
alert(instructions);
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(modalId) {
|
||||||
|
document.getElementById(modalId).classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createISCSITarget(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/iscsi/targets', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
iqn: formData.get('iqn')
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
closeModal('create-iscsi-modal');
|
||||||
|
e.target.reset();
|
||||||
|
loadISCSITargets();
|
||||||
|
alert('iSCSI target created successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to create iSCSI target'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addLUN(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const targetId = formData.get('target_id');
|
||||||
|
|
||||||
|
// Get volume from either dropdown or manual input
|
||||||
|
const zvolSelect = document.getElementById('lun-zvol-select').value;
|
||||||
|
const zvolManual = document.getElementById('lun-zvol-manual').value.trim();
|
||||||
|
const zvol = zvolSelect || zvolManual;
|
||||||
|
|
||||||
|
if (!zvol) {
|
||||||
|
alert('Please select or enter a volume name');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/iscsi/targets/${targetId}/luns`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
zvol: zvol
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
closeModal('add-lun-modal');
|
||||||
|
e.target.reset();
|
||||||
|
document.getElementById('lun-zvol-select').innerHTML = '<option value="">Loading volumes...</option>';
|
||||||
|
document.getElementById('lun-zvol-manual').value = '';
|
||||||
|
loadISCSITargets();
|
||||||
|
alert('LUN added successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to add LUN'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeLUN(targetId, lunId) {
|
||||||
|
if (!confirm(`Are you sure you want to remove LUN ${lunId} from this target?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/iscsi/targets/${targetId}/luns/remove`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
lun_id: lunId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
loadISCSITargets();
|
||||||
|
alert('LUN removed successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to remove LUN'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteISCSITarget(id) {
|
||||||
|
if (!confirm('Are you sure you want to delete this iSCSI target? All LUNs will be removed.')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/iscsi/targets/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
loadISCSITargets();
|
||||||
|
alert('iSCSI target deleted successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to delete iSCSI target'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load initial data
|
||||||
|
loadISCSITargets();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "iscsi.html"}}
|
||||||
|
{{template "base" .}}
|
||||||
|
{{end}}
|
||||||
81
web/templates/login.html
Normal file
81
web/templates/login.html
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
{{define "login-content"}}
|
||||||
|
<div class="min-h-screen flex items-center justify-center bg-slate-950">
|
||||||
|
<div class="max-w-md w-full mx-4">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 p-8 shadow-xl">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<div class="h-16 w-16 rounded-lg bg-slate-700 flex items-center justify-center font-bold text-2xl mx-auto mb-4">A</div>
|
||||||
|
<h1 class="text-3xl font-bold text-white mb-2">AtlasOS</h1>
|
||||||
|
<p class="text-slate-400">Storage Controller</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="login-form" onsubmit="handleLogin(event)" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-2">Username</label>
|
||||||
|
<input type="text" name="username" id="username" required autofocus class="w-full px-4 py-3 bg-slate-900 border border-slate-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-600" placeholder="Enter your username">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-2">Password</label>
|
||||||
|
<input type="password" name="password" id="password" required class="w-full px-4 py-3 bg-slate-900 border border-slate-700 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-600" placeholder="Enter your password">
|
||||||
|
</div>
|
||||||
|
<div id="login-error" class="hidden text-red-400 text-sm"></div>
|
||||||
|
<button type="submit" class="w-full px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-6 text-center text-sm text-slate-400">
|
||||||
|
<p>Default credentials: <span class="font-mono text-slate-300">admin / admin</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
async function handleLogin(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const errorEl = document.getElementById('login-error');
|
||||||
|
|
||||||
|
errorEl.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: formData.get('username'),
|
||||||
|
password: formData.get('password')
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
|
||||||
|
if (res.ok && data.token) {
|
||||||
|
// Store token in localStorage
|
||||||
|
localStorage.setItem('atlas_token', data.token);
|
||||||
|
|
||||||
|
// Store user info if available
|
||||||
|
if (data.user) {
|
||||||
|
localStorage.setItem('atlas_user', JSON.stringify(data.user));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to dashboard or return URL
|
||||||
|
const returnUrl = new URLSearchParams(window.location.search).get('return') || '/';
|
||||||
|
window.location.href = returnUrl;
|
||||||
|
} else {
|
||||||
|
const errorMsg = (data && data.error) ? data.error : 'Login failed';
|
||||||
|
errorEl.textContent = errorMsg;
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = `Error: ${err.message}`;
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "login.html"}}
|
||||||
|
{{template "base" .}}
|
||||||
|
{{end}}
|
||||||
710
web/templates/management.html
Normal file
710
web/templates/management.html
Normal file
@@ -0,0 +1,710 @@
|
|||||||
|
{{define "management-content"}}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-white mb-2">System Management</h1>
|
||||||
|
<p class="text-slate-400">Manage services and users</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="border-b border-slate-800">
|
||||||
|
<nav class="flex gap-4">
|
||||||
|
<button onclick="switchTab('services')" id="tab-services" class="tab-button px-4 py-2 border-b-2 border-blue-600 text-blue-400 font-medium">
|
||||||
|
Services
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('users')" id="tab-users" class="tab-button px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-300">
|
||||||
|
Users
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Services Tab -->
|
||||||
|
<div id="content-services" class="tab-content">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||||
|
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-white">Service Management</h2>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="loadServiceStatus()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 space-y-4">
|
||||||
|
<!-- Services List -->
|
||||||
|
<div id="services-list" class="space-y-4">
|
||||||
|
<p class="text-slate-400 text-sm">Loading services...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Users Tab -->
|
||||||
|
<div id="content-users" class="tab-content hidden">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||||
|
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-white">User Management</h2>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="showCreateUserModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Create User
|
||||||
|
</button>
|
||||||
|
<button onclick="loadUsers()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="users-list" class="p-4">
|
||||||
|
<p class="text-slate-400 text-sm">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Service Logs Modal -->
|
||||||
|
<div id="service-logs-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-xl font-semibold text-white" id="service-logs-title">Service Logs</h3>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="number" id="logs-lines" value="50" min="10" max="1000" class="w-20 px-2 py-1 bg-slate-900 border border-slate-700 rounded text-white text-sm">
|
||||||
|
<button onclick="loadServiceLogs()" class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">Refresh</button>
|
||||||
|
<button onclick="closeModal('service-logs-modal')" class="px-3 py-1 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="service-logs-content" class="flex-1 overflow-y-auto bg-slate-900 rounded p-4 text-sm text-slate-300 font-mono whitespace-pre-wrap">
|
||||||
|
Loading logs...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create User Modal -->
|
||||||
|
<div id="create-user-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 class="text-xl font-semibold text-white mb-4">Create User</h3>
|
||||||
|
<form id="create-user-form" onsubmit="createUser(event)" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Username *</label>
|
||||||
|
<input type="text" name="username" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Email</label>
|
||||||
|
<input type="email" name="email" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Password *</label>
|
||||||
|
<input type="password" name="password" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Role *</label>
|
||||||
|
<select name="role" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
<option value="viewer">Viewer</option>
|
||||||
|
<option value="operator">Operator</option>
|
||||||
|
<option value="administrator">Administrator</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button type="button" onclick="closeModal('create-user-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit User Modal -->
|
||||||
|
<div id="edit-user-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 class="text-xl font-semibold text-white mb-4">Edit User</h3>
|
||||||
|
<form id="edit-user-form" onsubmit="updateUser(event)" class="space-y-4">
|
||||||
|
<input type="hidden" id="edit-user-id" name="id">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Username</label>
|
||||||
|
<input type="text" id="edit-username" disabled class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-slate-500 text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Email</label>
|
||||||
|
<input type="email" id="edit-email" name="email" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Role</label>
|
||||||
|
<select id="edit-role" name="role" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
<option value="viewer">Viewer</option>
|
||||||
|
<option value="operator">Operator</option>
|
||||||
|
<option value="administrator">Administrator</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-300">
|
||||||
|
<input type="checkbox" id="edit-active" name="active" class="rounded bg-slate-900 border-slate-700">
|
||||||
|
<span>Active</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button type="button" onclick="closeModal('edit-user-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Change Password Modal -->
|
||||||
|
<div id="change-password-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 class="text-xl font-semibold text-white mb-4">Change Password</h3>
|
||||||
|
<form id="change-password-form" onsubmit="changePassword(event)" class="space-y-4">
|
||||||
|
<input type="hidden" id="change-password-user-id" name="id">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Current Password *</label>
|
||||||
|
<input type="password" name="old_password" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">New Password *</label>
|
||||||
|
<input type="password" name="new_password" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Confirm New Password *</label>
|
||||||
|
<input type="password" name="confirm_password" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button type="button" onclick="closeModal('change-password-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Change Password
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function getAuthHeaders() {
|
||||||
|
const token = localStorage.getItem('atlas_token');
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTab(tab) {
|
||||||
|
// Hide all tabs
|
||||||
|
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
||||||
|
document.querySelectorAll('.tab-button').forEach(el => {
|
||||||
|
el.classList.remove('border-blue-600', 'text-blue-400', 'font-medium');
|
||||||
|
el.classList.add('border-transparent', 'text-slate-400');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show selected tab
|
||||||
|
document.getElementById(`content-${tab}`).classList.remove('hidden');
|
||||||
|
document.getElementById(`tab-${tab}`).classList.remove('border-transparent', 'text-slate-400');
|
||||||
|
document.getElementById(`tab-${tab}`).classList.add('border-blue-600', 'text-blue-400', 'font-medium');
|
||||||
|
|
||||||
|
// Load data for the tab
|
||||||
|
if (tab === 'services') loadServiceStatus();
|
||||||
|
else if (tab === 'users') loadUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(modalId) {
|
||||||
|
document.getElementById(modalId).classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Service Management =====
|
||||||
|
|
||||||
|
let currentServiceName = null;
|
||||||
|
|
||||||
|
async function loadServiceStatus() {
|
||||||
|
const token = localStorage.getItem('atlas_token');
|
||||||
|
const listEl = document.getElementById('services-list');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
listEl.innerHTML = `
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<p class="text-slate-400 mb-2">Authentication required</p>
|
||||||
|
<a href="/login?return=/management" class="text-blue-400 hover:text-blue-300">Click here to login</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = '<p class="text-slate-400 text-sm">Loading services...</p>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/services', { headers: getAuthHeaders() });
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) {
|
||||||
|
localStorage.removeItem('atlas_token');
|
||||||
|
localStorage.removeItem('atlas_user');
|
||||||
|
listEl.innerHTML = `
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<p class="text-slate-400 mb-2">Session expired. Please login again.</p>
|
||||||
|
<a href="/login?return=/management" class="text-blue-400 hover:text-blue-300">Click here to login</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const errorMsg = (data && data.error) ? data.error : `HTTP ${res.status}`;
|
||||||
|
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${errorMsg}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const services = (data && data.services) ? data.services : [];
|
||||||
|
|
||||||
|
if (services.length === 0) {
|
||||||
|
listEl.innerHTML = '<p class="text-slate-400 text-sm">No services found</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render all services
|
||||||
|
listEl.innerHTML = services.map(svc => {
|
||||||
|
const statusBadge = getStatusBadge(svc.status);
|
||||||
|
const statusColor = getStatusColor(svc.status);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="bg-slate-900 rounded-lg border border-slate-700 p-4">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-md font-semibold text-white">${escapeHtml(svc.display_name)}</h3>
|
||||||
|
<div class="px-3 py-1 rounded text-xs font-medium ${statusColor}">
|
||||||
|
${statusBadge}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-slate-500 font-mono mb-4 max-h-24 overflow-y-auto whitespace-pre-wrap">
|
||||||
|
${escapeHtml((svc.output || '').split('\n').slice(-5).join('\n')) || 'No status information'}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 flex-wrap">
|
||||||
|
<button onclick="serviceAction('${svc.name}', 'start')" class="px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white rounded text-xs">
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
<button onclick="serviceAction('${svc.name}', 'stop')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-xs">
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
<button onclick="serviceAction('${svc.name}', 'restart')" class="px-3 py-1.5 bg-yellow-600 hover:bg-yellow-700 text-white rounded text-xs">
|
||||||
|
Restart
|
||||||
|
</button>
|
||||||
|
<button onclick="serviceAction('${svc.name}', 'reload')" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-xs">
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
<button onclick="showServiceLogs('${svc.name}', '${escapeHtml(svc.display_name)}')" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-xs">
|
||||||
|
View Logs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
} catch (err) {
|
||||||
|
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadge(status) {
|
||||||
|
switch(status) {
|
||||||
|
case 'running': return 'Running';
|
||||||
|
case 'stopped': return 'Stopped';
|
||||||
|
case 'failed': return 'Failed';
|
||||||
|
case 'not-found': return 'Not Found';
|
||||||
|
default: return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(status) {
|
||||||
|
switch(status) {
|
||||||
|
case 'running': return 'bg-green-900 text-green-300';
|
||||||
|
case 'stopped': return 'bg-red-900 text-red-300';
|
||||||
|
case 'failed': return 'bg-red-900 text-red-300';
|
||||||
|
case 'not-found': return 'bg-slate-700 text-slate-300';
|
||||||
|
default: return 'bg-slate-700 text-slate-300';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serviceAction(serviceName, action) {
|
||||||
|
const token = localStorage.getItem('atlas_token');
|
||||||
|
if (!token) {
|
||||||
|
alert('Please login first');
|
||||||
|
window.location.href = '/login?return=/management';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`Are you sure you want to ${action} ${serviceName}?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/services/${action}?service=${encodeURIComponent(serviceName)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
alert(`${serviceName} ${action}ed successfully`);
|
||||||
|
loadServiceStatus();
|
||||||
|
} else {
|
||||||
|
const errorMsg = (data && data.error) ? data.error : `Failed to ${action} service`;
|
||||||
|
alert(`Error: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showServiceLogs(serviceName, displayName) {
|
||||||
|
const token = localStorage.getItem('atlas_token');
|
||||||
|
if (!token) {
|
||||||
|
alert('Please login first');
|
||||||
|
window.location.href = '/login?return=/management';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentServiceName = serviceName;
|
||||||
|
document.getElementById('service-logs-title').textContent = `${displayName} Logs`;
|
||||||
|
document.getElementById('service-logs-modal').classList.remove('hidden');
|
||||||
|
loadServiceLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadServiceLogs() {
|
||||||
|
const token = localStorage.getItem('atlas_token');
|
||||||
|
if (!token) {
|
||||||
|
document.getElementById('service-logs-content').textContent = 'Authentication required';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentServiceName) {
|
||||||
|
document.getElementById('service-logs-content').textContent = 'No service selected';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = document.getElementById('logs-lines').value || '50';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/services/logs?service=${encodeURIComponent(currentServiceName)}&lines=${lines}`, {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorMsg = (data && data.error) ? data.error : `HTTP ${res.status}`;
|
||||||
|
document.getElementById('service-logs-content').textContent = `Error: ${errorMsg}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('service-logs-content').textContent = data.logs || 'No logs available';
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('service-logs-content').textContent = `Error: ${err.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== User Management =====
|
||||||
|
|
||||||
|
async function loadUsers(forceRefresh = false) {
|
||||||
|
const token = localStorage.getItem('atlas_token');
|
||||||
|
const listEl = document.getElementById('users-list');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
listEl.innerHTML = `
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<p class="text-slate-400 mb-2">Authentication required to view users</p>
|
||||||
|
<a href="/login?return=/management" class="text-blue-400 hover:text-blue-300">Click here to login</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
listEl.innerHTML = '<p class="text-slate-400 text-sm">Loading users...</p>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Add cache busting parameter if force refresh
|
||||||
|
const url = forceRefresh ? `/api/v1/users?_t=${Date.now()}` : '/api/v1/users';
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
cache: forceRefresh ? 'no-cache' : 'default'
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawText = await res.text();
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(rawText);
|
||||||
|
} catch (parseErr) {
|
||||||
|
console.error('Failed to parse JSON response:', parseErr, 'Raw response:', rawText);
|
||||||
|
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid JSON response from server</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) {
|
||||||
|
localStorage.removeItem('atlas_token');
|
||||||
|
localStorage.removeItem('atlas_user');
|
||||||
|
listEl.innerHTML = `
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<p class="text-slate-400 mb-2">Session expired. Please login again.</p>
|
||||||
|
<a href="/login?return=/management" class="text-blue-400 hover:text-blue-300">Click here to login</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const errorMsg = (data && data.error) ? data.error : `HTTP ${res.status}`;
|
||||||
|
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${errorMsg}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || !Array.isArray(data)) {
|
||||||
|
console.error('Invalid response format:', data);
|
||||||
|
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format (expected array)</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Loaded ${data.length} users:`, data);
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
listEl.innerHTML = '<p class="text-slate-400 text-sm">No users found</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort users by ID to ensure consistent ordering
|
||||||
|
const sortedUsers = data.sort((a, b) => {
|
||||||
|
const idA = (a.id || '').toLowerCase();
|
||||||
|
const idB = (b.id || '').toLowerCase();
|
||||||
|
return idA.localeCompare(idB);
|
||||||
|
});
|
||||||
|
|
||||||
|
listEl.innerHTML = sortedUsers.map(user => {
|
||||||
|
// Escape HTML to prevent XSS
|
||||||
|
const escapeHtml = (text) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
};
|
||||||
|
|
||||||
|
const username = escapeHtml(user.username || 'N/A');
|
||||||
|
const email = user.email ? escapeHtml(user.email) : '';
|
||||||
|
const userId = escapeHtml(user.id || 'N/A');
|
||||||
|
const role = escapeHtml(user.role || 'N/A');
|
||||||
|
const active = user.active !== false;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<h3 class="text-lg font-semibold text-white">${username}</h3>
|
||||||
|
${active ? '<span class="px-2 py-1 rounded text-xs font-medium bg-green-900 text-green-300">Active</span>' : '<span class="px-2 py-1 rounded text-xs font-medium bg-slate-700 text-slate-300">Inactive</span>'}
|
||||||
|
<span class="px-2 py-1 rounded text-xs font-medium bg-blue-900 text-blue-300">${role}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-slate-400 space-y-1">
|
||||||
|
${email ? `<p>Email: <span class="text-slate-300">${email}</span></p>` : ''}
|
||||||
|
<p>ID: <span class="text-slate-300 font-mono text-xs">${userId}</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 ml-4">
|
||||||
|
<button onclick="showEditUserModal('${userId}')" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button onclick="showChangePasswordModal('${userId}', '${username}')" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Change Password
|
||||||
|
</button>
|
||||||
|
<button onclick="deleteUser('${userId}', '${username}')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('users-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreateUserModal() {
|
||||||
|
document.getElementById('create-user-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: formData.get('username'),
|
||||||
|
email: formData.get('email') || '',
|
||||||
|
password: formData.get('password'),
|
||||||
|
role: formData.get('role')
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawText = await res.text();
|
||||||
|
let data = null;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(rawText);
|
||||||
|
} catch (parseErr) {
|
||||||
|
console.error('Failed to parse create user response:', parseErr, 'Raw:', rawText);
|
||||||
|
alert('Error: Invalid response from server');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Create user response:', res.status, data);
|
||||||
|
|
||||||
|
if (res.ok || res.status === 201) {
|
||||||
|
console.log('User created successfully, refreshing list...');
|
||||||
|
closeModal('create-user-modal');
|
||||||
|
e.target.reset();
|
||||||
|
// Force reload users list - add cache busting
|
||||||
|
await loadUsers(true);
|
||||||
|
alert('User created successfully');
|
||||||
|
} else {
|
||||||
|
const errorMsg = (data && data.error) ? data.error : 'Failed to create user';
|
||||||
|
console.error('Create user failed:', errorMsg);
|
||||||
|
alert(`Error: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showEditUserModal(userId) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/users/${userId}`, { headers: getAuthHeaders() });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to load user'}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await res.json();
|
||||||
|
document.getElementById('edit-user-id').value = user.id;
|
||||||
|
document.getElementById('edit-username').value = user.username || '';
|
||||||
|
document.getElementById('edit-email').value = user.email || '';
|
||||||
|
document.getElementById('edit-role').value = user.role || 'Viewer';
|
||||||
|
document.getElementById('edit-active').checked = user.active !== false;
|
||||||
|
|
||||||
|
document.getElementById('edit-user-modal').classList.remove('hidden');
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateUser(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const userId = formData.get('id');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/users/${userId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: formData.get('email') || '',
|
||||||
|
role: formData.get('role'),
|
||||||
|
active: formData.get('active') === 'on'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
closeModal('edit-user-modal');
|
||||||
|
await loadUsers(true);
|
||||||
|
alert('User updated successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to update user'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(userId, username) {
|
||||||
|
if (!confirm(`Are you sure you want to delete user "${username}"? This action cannot be undone.`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/users/${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
await loadUsers(true);
|
||||||
|
alert('User deleted successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to delete user'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showChangePasswordModal(userId, username) {
|
||||||
|
document.getElementById('change-password-user-id').value = userId;
|
||||||
|
document.getElementById('change-password-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePassword(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const userId = document.getElementById('change-password-user-id').value;
|
||||||
|
const newPassword = formData.get('new_password');
|
||||||
|
const confirmPassword = formData.get('confirm_password');
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
alert('New passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/users/${userId}/password`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
old_password: formData.get('old_password'),
|
||||||
|
new_password: newPassword
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
closeModal('change-password-modal');
|
||||||
|
e.target.reset();
|
||||||
|
alert('Password changed successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to change password'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load initial data when page loads
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadServiceStatus();
|
||||||
|
// Load users if on users tab
|
||||||
|
const usersTab = document.getElementById('tab-users');
|
||||||
|
if (usersTab && usersTab.classList.contains('border-blue-600')) {
|
||||||
|
loadUsers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
loadServiceStatus();
|
||||||
|
// Load users if on users tab
|
||||||
|
const usersTab = document.getElementById('tab-users');
|
||||||
|
if (usersTab && usersTab.classList.contains('border-blue-600')) {
|
||||||
|
loadUsers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "management.html"}}
|
||||||
|
{{template "base" .}}
|
||||||
|
{{end}}
|
||||||
704
web/templates/protection.html
Normal file
704
web/templates/protection.html
Normal file
@@ -0,0 +1,704 @@
|
|||||||
|
{{define "protection-content"}}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-white mb-2">Data Protection</h1>
|
||||||
|
<p class="text-slate-400">Manage snapshots, scheduling, and replication</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="border-b border-slate-800">
|
||||||
|
<nav class="flex gap-4">
|
||||||
|
<button onclick="switchTab('snapshots')" id="tab-snapshots" class="tab-button px-4 py-2 border-b-2 border-blue-600 text-blue-400 font-medium">
|
||||||
|
Snapshots
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('policies')" id="tab-policies" class="tab-button px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-300">
|
||||||
|
Snapshot Policies
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('volume-snapshots')" id="tab-volume-snapshots" class="tab-button px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-300">
|
||||||
|
Volume Snapshots
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('replication')" id="tab-replication" class="tab-button px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-300">
|
||||||
|
Replication
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Snapshots Tab -->
|
||||||
|
<div id="content-snapshots" class="tab-content">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||||
|
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-white">Snapshots</h2>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="showCreateSnapshotModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Create Snapshot
|
||||||
|
</button>
|
||||||
|
<button onclick="loadSnapshots()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="snapshots-list" class="p-4">
|
||||||
|
<p class="text-slate-400 text-sm">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Snapshot Policies Tab -->
|
||||||
|
<div id="content-policies" class="tab-content hidden">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||||
|
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-white">Snapshot Policies</h2>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="showCreatePolicyModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Create Policy
|
||||||
|
</button>
|
||||||
|
<button onclick="loadPolicies()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="policies-list" class="p-4">
|
||||||
|
<p class="text-slate-400 text-sm">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Volume Snapshots Tab -->
|
||||||
|
<div id="content-volume-snapshots" class="tab-content hidden">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||||
|
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-white">Volume Snapshots</h2>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="showCreateVolumeSnapshotModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Create Volume Snapshot
|
||||||
|
</button>
|
||||||
|
<button onclick="loadVolumeSnapshots()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="volume-snapshots-list" class="p-4">
|
||||||
|
<p class="text-slate-400 text-sm">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Replication Tab -->
|
||||||
|
<div id="content-replication" class="tab-content hidden">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||||
|
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-white">Replication</h2>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="showCreateReplicationModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Create Replication Task
|
||||||
|
</button>
|
||||||
|
<button onclick="loadReplications()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="replications-list" class="p-4">
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<p class="text-slate-400 text-sm mb-2">Replication feature coming soon</p>
|
||||||
|
<p class="text-slate-500 text-xs">This feature will allow you to replicate snapshots to remote systems</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Snapshot Modal -->
|
||||||
|
<div id="create-snapshot-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 class="text-xl font-semibold text-white mb-4">Create Snapshot</h3>
|
||||||
|
<form id="create-snapshot-form" onsubmit="createSnapshot(event)" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Dataset</label>
|
||||||
|
<select name="dataset" id="snapshot-dataset-select" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
<option value="">Loading datasets...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Snapshot Name</label>
|
||||||
|
<input type="text" name="name" placeholder="snapshot-2024-12-15" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-300">
|
||||||
|
<input type="checkbox" name="recursive" class="rounded bg-slate-900 border-slate-700">
|
||||||
|
<span>Recursive (include child datasets)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button type="button" onclick="closeModal('create-snapshot-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Snapshot Policy Modal -->
|
||||||
|
<div id="create-policy-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<h3 class="text-xl font-semibold text-white mb-4">Create Snapshot Policy</h3>
|
||||||
|
<form id="create-policy-form" onsubmit="createPolicy(event)" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Dataset</label>
|
||||||
|
<select name="dataset" id="policy-dataset-select" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
<option value="">Loading datasets...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Frequent (15min)</label>
|
||||||
|
<input type="number" name="frequent" min="0" value="0" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Hourly</label>
|
||||||
|
<input type="number" name="hourly" min="0" value="0" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Daily</label>
|
||||||
|
<input type="number" name="daily" min="0" value="0" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Weekly</label>
|
||||||
|
<input type="number" name="weekly" min="0" value="0" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Monthly</label>
|
||||||
|
<input type="number" name="monthly" min="0" value="0" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Yearly</label>
|
||||||
|
<input type="number" name="yearly" min="0" value="0" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-300">
|
||||||
|
<input type="checkbox" name="autosnap" checked class="rounded bg-slate-900 border-slate-700">
|
||||||
|
<span>Enable automatic snapshots</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-300">
|
||||||
|
<input type="checkbox" name="autoprune" checked class="rounded bg-slate-900 border-slate-700">
|
||||||
|
<span>Enable automatic pruning</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button type="button" onclick="closeModal('create-policy-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Volume Snapshot Modal -->
|
||||||
|
<div id="create-volume-snapshot-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 class="text-xl font-semibold text-white mb-4">Create Volume Snapshot</h3>
|
||||||
|
<form id="create-volume-snapshot-form" onsubmit="createVolumeSnapshot(event)" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Volume</label>
|
||||||
|
<select name="volume" id="volume-snapshot-select" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
<option value="">Loading volumes...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Snapshot Name</label>
|
||||||
|
<input type="text" name="name" placeholder="volume-snapshot-2024-12-15" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button type="button" onclick="closeModal('create-volume-snapshot-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function getAuthHeaders() {
|
||||||
|
const token = localStorage.getItem('atlas_token');
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (!bytes || bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
if (!dateStr) return 'N/A';
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTab(tab) {
|
||||||
|
// Hide all tabs
|
||||||
|
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
||||||
|
document.querySelectorAll('.tab-button').forEach(el => {
|
||||||
|
el.classList.remove('border-blue-600', 'text-blue-400', 'font-medium');
|
||||||
|
el.classList.add('border-transparent', 'text-slate-400');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show selected tab
|
||||||
|
document.getElementById(`content-${tab}`).classList.remove('hidden');
|
||||||
|
document.getElementById(`tab-${tab}`).classList.remove('border-transparent', 'text-slate-400');
|
||||||
|
document.getElementById(`tab-${tab}`).classList.add('border-blue-600', 'text-blue-400', 'font-medium');
|
||||||
|
|
||||||
|
// Load data for the tab
|
||||||
|
if (tab === 'snapshots') loadSnapshots();
|
||||||
|
else if (tab === 'policies') loadPolicies();
|
||||||
|
else if (tab === 'volume-snapshots') loadVolumeSnapshots();
|
||||||
|
else if (tab === 'replication') loadReplications();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(modalId) {
|
||||||
|
document.getElementById(modalId).classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot Management
|
||||||
|
async function loadSnapshots() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/snapshots', { headers: getAuthHeaders() });
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
const listEl = document.getElementById('snapshots-list');
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorMsg = (data && data.error) ? data.error : `HTTP ${res.status}`;
|
||||||
|
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${errorMsg}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || !Array.isArray(data)) {
|
||||||
|
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
listEl.innerHTML = '<p class="text-slate-400 text-sm">No snapshots found</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = data.map(snap => `
|
||||||
|
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<h3 class="text-lg font-semibold text-white font-mono">${snap.name}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-slate-400 space-y-1">
|
||||||
|
<p>Dataset: <span class="text-slate-300">${snap.dataset}</span></p>
|
||||||
|
<p>Size: <span class="text-slate-300">${formatBytes(snap.size || 0)}</span></p>
|
||||||
|
<p>Created: <span class="text-slate-300">${formatDate(snap.created_at)}</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 ml-4">
|
||||||
|
<button onclick="deleteSnapshot('${snap.name}')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('snapshots-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showCreateSnapshotModal() {
|
||||||
|
await loadDatasetsForSnapshot();
|
||||||
|
document.getElementById('create-snapshot-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDatasetsForSnapshot() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/datasets', { headers: getAuthHeaders() });
|
||||||
|
const selectEl = document.getElementById('snapshot-dataset-select');
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
selectEl.innerHTML = '<option value="">Error loading datasets</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const datasets = await res.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(datasets) || datasets.length === 0) {
|
||||||
|
selectEl.innerHTML = '<option value="">No datasets found</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectEl.innerHTML = '<option value="">Select a dataset...</option>';
|
||||||
|
datasets.forEach(ds => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = ds.name;
|
||||||
|
option.textContent = `${ds.name} (${ds.type})`;
|
||||||
|
selectEl.appendChild(option);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading datasets:', err);
|
||||||
|
document.getElementById('snapshot-dataset-select').innerHTML = '<option value="">Error loading datasets</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSnapshot(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/snapshots', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
dataset: formData.get('dataset'),
|
||||||
|
name: formData.get('name'),
|
||||||
|
recursive: formData.get('recursive') === 'on'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
closeModal('create-snapshot-modal');
|
||||||
|
e.target.reset();
|
||||||
|
loadSnapshots();
|
||||||
|
alert('Snapshot created successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to create snapshot'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSnapshot(name) {
|
||||||
|
if (!confirm(`Are you sure you want to delete snapshot "${name}"?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/snapshots/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
loadSnapshots();
|
||||||
|
alert('Snapshot deleted successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to delete snapshot'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot Policy Management
|
||||||
|
async function loadPolicies() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/snapshot-policies', { headers: getAuthHeaders() });
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
const listEl = document.getElementById('policies-list');
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorMsg = (data && data.error) ? data.error : `HTTP ${res.status}`;
|
||||||
|
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${errorMsg}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || !Array.isArray(data)) {
|
||||||
|
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
listEl.innerHTML = '<p class="text-slate-400 text-sm">No snapshot policies found. Create a policy to enable automatic snapshots.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = data.map(policy => `
|
||||||
|
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<h3 class="text-lg font-semibold text-white">${policy.dataset}</h3>
|
||||||
|
${policy.autosnap ? '<span class="px-2 py-1 rounded text-xs font-medium bg-green-900 text-green-300">Auto-snap Enabled</span>' : '<span class="px-2 py-1 rounded text-xs font-medium bg-slate-700 text-slate-300">Auto-snap Disabled</span>'}
|
||||||
|
${policy.autoprune ? '<span class="px-2 py-1 rounded text-xs font-medium bg-blue-900 text-blue-300">Auto-prune Enabled</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-slate-400 space-y-1">
|
||||||
|
<p>Retention:
|
||||||
|
${policy.frequent > 0 ? `<span class="text-slate-300">${policy.frequent} frequent</span>` : ''}
|
||||||
|
${policy.hourly > 0 ? `<span class="text-slate-300">${policy.hourly} hourly</span>` : ''}
|
||||||
|
${policy.daily > 0 ? `<span class="text-slate-300">${policy.daily} daily</span>` : ''}
|
||||||
|
${policy.weekly > 0 ? `<span class="text-slate-300">${policy.weekly} weekly</span>` : ''}
|
||||||
|
${policy.monthly > 0 ? `<span class="text-slate-300">${policy.monthly} monthly</span>` : ''}
|
||||||
|
${policy.yearly > 0 ? `<span class="text-slate-300">${policy.yearly} yearly</span>` : ''}
|
||||||
|
${!policy.frequent && !policy.hourly && !policy.daily && !policy.weekly && !policy.monthly && !policy.yearly ? '<span class="text-slate-500">No retention configured</span>' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 ml-4">
|
||||||
|
<button onclick="editPolicy('${policy.dataset}')" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button onclick="deletePolicy('${policy.dataset}')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('policies-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showCreatePolicyModal() {
|
||||||
|
await loadDatasetsForPolicy();
|
||||||
|
document.getElementById('create-policy-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDatasetsForPolicy() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/datasets', { headers: getAuthHeaders() });
|
||||||
|
const selectEl = document.getElementById('policy-dataset-select');
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
selectEl.innerHTML = '<option value="">Error loading datasets</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const datasets = await res.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(datasets) || datasets.length === 0) {
|
||||||
|
selectEl.innerHTML = '<option value="">No datasets found</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectEl.innerHTML = '<option value="">Select a dataset...</option>';
|
||||||
|
datasets.forEach(ds => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = ds.name;
|
||||||
|
option.textContent = `${ds.name} (${ds.type})`;
|
||||||
|
selectEl.appendChild(option);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading datasets:', err);
|
||||||
|
document.getElementById('policy-dataset-select').innerHTML = '<option value="">Error loading datasets</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPolicy(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/snapshot-policies', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
dataset: formData.get('dataset'),
|
||||||
|
frequent: parseInt(formData.get('frequent')) || 0,
|
||||||
|
hourly: parseInt(formData.get('hourly')) || 0,
|
||||||
|
daily: parseInt(formData.get('daily')) || 0,
|
||||||
|
weekly: parseInt(formData.get('weekly')) || 0,
|
||||||
|
monthly: parseInt(formData.get('monthly')) || 0,
|
||||||
|
yearly: parseInt(formData.get('yearly')) || 0,
|
||||||
|
autosnap: formData.get('autosnap') === 'on',
|
||||||
|
autoprune: formData.get('autoprune') === 'on'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
closeModal('create-policy-modal');
|
||||||
|
e.target.reset();
|
||||||
|
loadPolicies();
|
||||||
|
alert('Snapshot policy created successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to create snapshot policy'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editPolicy(dataset) {
|
||||||
|
// Load policy and populate form (similar to create but with PUT)
|
||||||
|
alert('Edit policy feature - coming soon');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePolicy(dataset) {
|
||||||
|
if (!confirm(`Are you sure you want to delete snapshot policy for "${dataset}"?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/snapshot-policies/${encodeURIComponent(dataset)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
loadPolicies();
|
||||||
|
alert('Snapshot policy deleted successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to delete snapshot policy'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volume Snapshots (same as regular snapshots but filtered for volumes)
|
||||||
|
async function loadVolumeSnapshots() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/snapshots', { headers: getAuthHeaders() });
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
const listEl = document.getElementById('volume-snapshots-list');
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorMsg = (data && data.error) ? data.error : `HTTP ${res.status}`;
|
||||||
|
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${errorMsg}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || !Array.isArray(data)) {
|
||||||
|
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter for volume snapshots (ZVOLs)
|
||||||
|
const volumeSnapshots = data.filter(snap => {
|
||||||
|
// Check if dataset is a volume (starts with pool/ and might be a zvol)
|
||||||
|
return snap.dataset && snap.dataset.includes('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (volumeSnapshots.length === 0) {
|
||||||
|
listEl.innerHTML = '<p class="text-slate-400 text-sm">No volume snapshots found</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = volumeSnapshots.map(snap => `
|
||||||
|
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<h3 class="text-lg font-semibold text-white font-mono">${snap.name}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-slate-400 space-y-1">
|
||||||
|
<p>Volume: <span class="text-slate-300">${snap.dataset}</span></p>
|
||||||
|
<p>Size: <span class="text-slate-300">${formatBytes(snap.size || 0)}</span></p>
|
||||||
|
<p>Created: <span class="text-slate-300">${formatDate(snap.created_at)}</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 ml-4">
|
||||||
|
<button onclick="deleteSnapshot('${snap.name}')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('volume-snapshots-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showCreateVolumeSnapshotModal() {
|
||||||
|
await loadVolumesForSnapshot();
|
||||||
|
document.getElementById('create-volume-snapshot-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVolumesForSnapshot() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/zvols', { headers: getAuthHeaders() });
|
||||||
|
const selectEl = document.getElementById('volume-snapshot-select');
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
selectEl.innerHTML = '<option value="">Error loading volumes</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const volumes = await res.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(volumes) || volumes.length === 0) {
|
||||||
|
selectEl.innerHTML = '<option value="">No volumes found</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectEl.innerHTML = '<option value="">Select a volume...</option>';
|
||||||
|
volumes.forEach(vol => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = vol.name;
|
||||||
|
option.textContent = `${vol.name} (${formatBytes(vol.size)})`;
|
||||||
|
selectEl.appendChild(option);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading volumes:', err);
|
||||||
|
document.getElementById('volume-snapshot-select').innerHTML = '<option value="">Error loading volumes</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createVolumeSnapshot(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/snapshots', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
dataset: formData.get('volume'),
|
||||||
|
name: formData.get('name'),
|
||||||
|
recursive: false
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
closeModal('create-volume-snapshot-modal');
|
||||||
|
e.target.reset();
|
||||||
|
loadVolumeSnapshots();
|
||||||
|
alert('Volume snapshot created successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to create volume snapshot'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replication (placeholder)
|
||||||
|
async function loadReplications() {
|
||||||
|
// Placeholder - replication not yet implemented
|
||||||
|
document.getElementById('replications-list').innerHTML = `
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<p class="text-slate-400 text-sm mb-2">Replication feature coming soon</p>
|
||||||
|
<p class="text-slate-500 text-xs">This feature will allow you to replicate snapshots to remote systems</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreateReplicationModal() {
|
||||||
|
alert('Replication feature coming soon');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load initial data
|
||||||
|
loadSnapshots();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "protection.html"}}
|
||||||
|
{{template "base" .}}
|
||||||
|
{{end}}
|
||||||
379
web/templates/shares.html
Normal file
379
web/templates/shares.html
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
{{define "shares-content"}}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-white mb-2">Storage Shares</h1>
|
||||||
|
<p class="text-slate-400">Manage SMB and NFS shares</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="border-b border-slate-800">
|
||||||
|
<nav class="flex gap-4">
|
||||||
|
<button onclick="switchTab('smb')" id="tab-smb" class="tab-button px-4 py-2 border-b-2 border-blue-600 text-blue-400 font-medium">
|
||||||
|
SMB Shares
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('nfs')" id="tab-nfs" class="tab-button px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-300">
|
||||||
|
NFS Exports
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SMB Shares Tab -->
|
||||||
|
<div id="content-smb" class="tab-content">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||||
|
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-white">SMB/CIFS Shares</h2>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="showCreateSMBModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Create Share
|
||||||
|
</button>
|
||||||
|
<button onclick="loadSMBShares()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="smb-shares-list" class="p-4">
|
||||||
|
<p class="text-slate-400 text-sm">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NFS Exports Tab -->
|
||||||
|
<div id="content-nfs" class="tab-content hidden">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||||
|
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-white">NFS Exports</h2>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="showCreateNFSModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Create Export
|
||||||
|
</button>
|
||||||
|
<button onclick="loadNFSExports()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="nfs-exports-list" class="p-4">
|
||||||
|
<p class="text-slate-400 text-sm">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create SMB Share Modal -->
|
||||||
|
<div id="create-smb-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 class="text-xl font-semibold text-white mb-4">Create SMB Share</h3>
|
||||||
|
<form id="create-smb-form" onsubmit="createSMBShare(event)" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Share Name</label>
|
||||||
|
<input type="text" name="name" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Dataset</label>
|
||||||
|
<input type="text" name="dataset" placeholder="pool/dataset" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Description (optional)</label>
|
||||||
|
<input type="text" name="description" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" name="readonly" id="smb-readonly" class="w-4 h-4 text-blue-600 bg-slate-900 border-slate-700 rounded">
|
||||||
|
<label for="smb-readonly" class="text-sm text-slate-300">Read-only</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" name="guest_ok" id="smb-guest" class="w-4 h-4 text-blue-600 bg-slate-900 border-slate-700 rounded">
|
||||||
|
<label for="smb-guest" class="text-sm text-slate-300">Allow guest access</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button type="button" onclick="closeModal('create-smb-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create NFS Export Modal -->
|
||||||
|
<div id="create-nfs-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 class="text-xl font-semibold text-white mb-4">Create NFS Export</h3>
|
||||||
|
<form id="create-nfs-form" onsubmit="createNFSExport(event)" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Dataset</label>
|
||||||
|
<input type="text" name="dataset" placeholder="pool/dataset" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Clients (comma-separated)</label>
|
||||||
|
<input type="text" name="clients" placeholder="192.168.1.0/24,*" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
<p class="text-xs text-slate-400 mt-1">Leave empty or use * for all clients</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" name="readonly" id="nfs-readonly" class="w-4 h-4 text-blue-600 bg-slate-900 border-slate-700 rounded">
|
||||||
|
<label for="nfs-readonly" class="text-sm text-slate-300">Read-only</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" name="root_squash" id="nfs-rootsquash" class="w-4 h-4 text-blue-600 bg-slate-900 border-slate-700 rounded" checked>
|
||||||
|
<label for="nfs-rootsquash" class="text-sm text-slate-300">Root squash</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button type="button" onclick="closeModal('create-nfs-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentTab = 'smb';
|
||||||
|
|
||||||
|
function switchTab(tab) {
|
||||||
|
currentTab = tab;
|
||||||
|
document.querySelectorAll('.tab-button').forEach(btn => {
|
||||||
|
btn.classList.remove('border-blue-600', 'text-blue-400');
|
||||||
|
btn.classList.add('border-transparent', 'text-slate-400');
|
||||||
|
});
|
||||||
|
document.getElementById(`tab-${tab}`).classList.remove('border-transparent', 'text-slate-400');
|
||||||
|
document.getElementById(`tab-${tab}`).classList.add('border-blue-600', 'text-blue-400');
|
||||||
|
|
||||||
|
document.querySelectorAll('.tab-content').forEach(content => {
|
||||||
|
content.classList.add('hidden');
|
||||||
|
});
|
||||||
|
document.getElementById(`content-${tab}`).classList.remove('hidden');
|
||||||
|
|
||||||
|
if (tab === 'smb') loadSMBShares();
|
||||||
|
else if (tab === 'nfs') loadNFSExports();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthHeaders() {
|
||||||
|
const token = localStorage.getItem('atlas_token');
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSMBShares() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/shares/smb', { headers: getAuthHeaders() });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||||
|
document.getElementById('smb-shares-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.error || 'Failed to load SMB shares'}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const shares = await res.json();
|
||||||
|
const listEl = document.getElementById('smb-shares-list');
|
||||||
|
|
||||||
|
if (!Array.isArray(shares)) {
|
||||||
|
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shares.length === 0) {
|
||||||
|
listEl.innerHTML = '<p class="text-slate-400 text-sm">No SMB shares found</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = shares.map(share => `
|
||||||
|
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<h3 class="text-lg font-semibold text-white">${share.name}</h3>
|
||||||
|
${share.enabled ? '<span class="px-2 py-1 rounded text-xs font-medium bg-green-900 text-green-300">Enabled</span>' : '<span class="px-2 py-1 rounded text-xs font-medium bg-slate-700 text-slate-300">Disabled</span>'}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-slate-400 space-y-1">
|
||||||
|
<p>Path: ${share.path || 'N/A'}</p>
|
||||||
|
<p>Dataset: ${share.dataset || 'N/A'}</p>
|
||||||
|
${share.description ? `<p>Description: ${share.description}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="deleteSMBShare('${share.id}')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('smb-shares-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNFSExports() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/exports/nfs', { headers: getAuthHeaders() });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||||
|
document.getElementById('nfs-exports-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.error || 'Failed to load NFS exports'}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const exports = await res.json();
|
||||||
|
const listEl = document.getElementById('nfs-exports-list');
|
||||||
|
|
||||||
|
if (!Array.isArray(exports)) {
|
||||||
|
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exports.length === 0) {
|
||||||
|
listEl.innerHTML = '<p class="text-slate-400 text-sm">No NFS exports found</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = exports.map(exp => `
|
||||||
|
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<h3 class="text-lg font-semibold text-white">${exp.path || 'N/A'}</h3>
|
||||||
|
${exp.enabled ? '<span class="px-2 py-1 rounded text-xs font-medium bg-green-900 text-green-300">Enabled</span>' : '<span class="px-2 py-1 rounded text-xs font-medium bg-slate-700 text-slate-300">Disabled</span>'}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-slate-400 space-y-1">
|
||||||
|
<p>Dataset: ${exp.dataset || 'N/A'}</p>
|
||||||
|
<p>Clients: ${exp.clients && exp.clients.length > 0 ? exp.clients.join(', ') : '*'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="deleteNFSExport('${exp.id}')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('nfs-exports-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreateSMBModal() {
|
||||||
|
document.getElementById('create-smb-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreateNFSModal() {
|
||||||
|
document.getElementById('create-nfs-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(modalId) {
|
||||||
|
document.getElementById(modalId).classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSMBShare(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const data = {
|
||||||
|
name: formData.get('name'),
|
||||||
|
dataset: formData.get('dataset'),
|
||||||
|
read_only: formData.get('readonly') === 'on',
|
||||||
|
guest_ok: formData.get('guest_ok') === 'on'
|
||||||
|
};
|
||||||
|
if (formData.get('description')) data.description = formData.get('description');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/shares/smb', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
closeModal('create-smb-modal');
|
||||||
|
e.target.reset();
|
||||||
|
loadSMBShares();
|
||||||
|
alert('SMB share created successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to create SMB share'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNFSExport(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const clients = formData.get('clients') ? formData.get('clients').split(',').map(c => c.trim()).filter(c => c) : ['*'];
|
||||||
|
const data = {
|
||||||
|
dataset: formData.get('dataset'),
|
||||||
|
clients: clients,
|
||||||
|
read_only: formData.get('readonly') === 'on',
|
||||||
|
root_squash: formData.get('root_squash') === 'on'
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/exports/nfs', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
closeModal('create-nfs-modal');
|
||||||
|
e.target.reset();
|
||||||
|
loadNFSExports();
|
||||||
|
alert('NFS export created successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to create NFS export'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSMBShare(id) {
|
||||||
|
if (!confirm('Are you sure you want to delete this SMB share?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/shares/smb/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
loadSMBShares();
|
||||||
|
alert('SMB share deleted successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to delete SMB share'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteNFSExport(id) {
|
||||||
|
if (!confirm('Are you sure you want to delete this NFS export?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/exports/nfs/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
loadNFSExports();
|
||||||
|
alert('NFS export deleted successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to delete NFS export'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load initial data
|
||||||
|
loadSMBShares();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "shares.html"}}
|
||||||
|
{{template "base" .}}
|
||||||
|
{{end}}
|
||||||
725
web/templates/storage.html
Normal file
725
web/templates/storage.html
Normal file
@@ -0,0 +1,725 @@
|
|||||||
|
{{define "storage-content"}}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-white mb-2">Storage Management</h1>
|
||||||
|
<p class="text-slate-400">Manage storage pools, datasets, and volumes</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="showCreatePoolModal()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium">
|
||||||
|
Create Pool
|
||||||
|
</button>
|
||||||
|
<button onclick="showImportPoolModal()" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg text-sm font-medium">
|
||||||
|
Import Pool
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="border-b border-slate-800">
|
||||||
|
<nav class="flex gap-4">
|
||||||
|
<button onclick="switchTab('pools')" id="tab-pools" class="tab-button px-4 py-2 border-b-2 border-blue-600 text-blue-400 font-medium">
|
||||||
|
Pools
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('datasets')" id="tab-datasets" class="tab-button px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-300">
|
||||||
|
Datasets
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('zvols')" id="tab-zvols" class="tab-button px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-300">
|
||||||
|
Volumes
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('disks')" id="tab-disks" class="tab-button px-4 py-2 border-b-2 border-transparent text-slate-400 hover:text-slate-300">
|
||||||
|
Disks
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pools Tab -->
|
||||||
|
<div id="content-pools" class="tab-content">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||||
|
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-white">Storage Pools</h2>
|
||||||
|
<button onclick="loadPools()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div id="pools-list" class="p-4">
|
||||||
|
<p class="text-slate-400 text-sm">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Datasets Tab -->
|
||||||
|
<div id="content-datasets" class="tab-content hidden">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||||
|
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-white">Datasets</h2>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="showCreateDatasetModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Create Dataset
|
||||||
|
</button>
|
||||||
|
<button onclick="loadDatasets()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="datasets-list" class="p-4">
|
||||||
|
<p class="text-slate-400 text-sm">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Storage Volumes Tab -->
|
||||||
|
<div id="content-zvols" class="tab-content hidden">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||||
|
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-white">Storage Volumes</h2>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="showCreateZVOLModal()" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Create Volume
|
||||||
|
</button>
|
||||||
|
<button onclick="loadZVOLs()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="zvols-list" class="p-4">
|
||||||
|
<p class="text-slate-400 text-sm">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Disks Tab -->
|
||||||
|
<div id="content-disks" class="tab-content hidden">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||||
|
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-white">Available Disks</h2>
|
||||||
|
<button onclick="loadDisks()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div id="disks-list" class="p-4">
|
||||||
|
<p class="text-slate-400 text-sm">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Pool Modal -->
|
||||||
|
<div id="create-pool-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 class="text-xl font-semibold text-white mb-4">Create Storage Pool</h3>
|
||||||
|
<form id="create-pool-form" onsubmit="createPool(event)" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Pool Name</label>
|
||||||
|
<input type="text" name="name" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">VDEVs (comma-separated)</label>
|
||||||
|
<input type="text" name="vdevs" placeholder="/dev/sdb,/dev/sdc" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
<p class="text-xs text-slate-400 mt-1">Enter device paths separated by commas</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button type="button" onclick="closeModal('create-pool-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Import Pool Modal -->
|
||||||
|
<div id="import-pool-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 class="text-xl font-semibold text-white mb-4">Import Storage Pool</h3>
|
||||||
|
<form id="import-pool-form" onsubmit="importPool(event)" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Pool Name</label>
|
||||||
|
<select name="name" id="import-pool-select" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
<option value="">Loading available pools...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" name="readonly" id="import-readonly" class="w-4 h-4 text-blue-600 bg-slate-900 border-slate-700 rounded">
|
||||||
|
<label for="import-readonly" class="text-sm text-slate-300">Import as read-only</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button type="button" onclick="closeModal('import-pool-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Dataset Modal -->
|
||||||
|
<div id="create-dataset-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 class="text-xl font-semibold text-white mb-4">Create Dataset</h3>
|
||||||
|
<form id="create-dataset-form" onsubmit="createDataset(event)" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Dataset Name</label>
|
||||||
|
<input type="text" name="name" placeholder="pool/dataset" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Quota (optional)</label>
|
||||||
|
<input type="text" name="quota" placeholder="10G" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Compression (optional)</label>
|
||||||
|
<select name="compression" class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
<option value="">None</option>
|
||||||
|
<option value="lz4">lz4</option>
|
||||||
|
<option value="zstd">zstd</option>
|
||||||
|
<option value="gzip">gzip</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button type="button" onclick="closeModal('create-dataset-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Storage Volume Modal -->
|
||||||
|
<div id="create-zvol-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||||
|
<h3 class="text-xl font-semibold text-white mb-4">Create Storage Volume</h3>
|
||||||
|
<form id="create-zvol-form" onsubmit="createZVOL(event)" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Volume Name</label>
|
||||||
|
<input type="text" name="name" placeholder="pool/zvol" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-300 mb-1">Size</label>
|
||||||
|
<input type="text" name="size" placeholder="10G" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<button type="button" onclick="closeModal('create-zvol-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentTab = 'pools';
|
||||||
|
|
||||||
|
function switchTab(tab) {
|
||||||
|
currentTab = tab;
|
||||||
|
// Update tab buttons
|
||||||
|
document.querySelectorAll('.tab-button').forEach(btn => {
|
||||||
|
btn.classList.remove('border-blue-600', 'text-blue-400');
|
||||||
|
btn.classList.add('border-transparent', 'text-slate-400');
|
||||||
|
});
|
||||||
|
document.getElementById(`tab-${tab}`).classList.remove('border-transparent', 'text-slate-400');
|
||||||
|
document.getElementById(`tab-${tab}`).classList.add('border-blue-600', 'text-blue-400');
|
||||||
|
|
||||||
|
// Update content
|
||||||
|
document.querySelectorAll('.tab-content').forEach(content => {
|
||||||
|
content.classList.add('hidden');
|
||||||
|
});
|
||||||
|
document.getElementById(`content-${tab}`).classList.remove('hidden');
|
||||||
|
|
||||||
|
// Load data for the tab
|
||||||
|
if (tab === 'pools') loadPools();
|
||||||
|
else if (tab === 'datasets') loadDatasets();
|
||||||
|
else if (tab === 'zvols') loadZVOLs();
|
||||||
|
else if (tab === 'disks') loadDisks();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (!bytes || bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthHeaders() {
|
||||||
|
const token = localStorage.getItem('atlas_token');
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
// Only add Authorization header if token exists (for mutations)
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPools() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/pools', { headers: getAuthHeaders() });
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
const listEl = document.getElementById('pools-list');
|
||||||
|
|
||||||
|
// Handle HTTP errors
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorMsg = (data && data.error) ? data.error : `HTTP ${res.status}: Failed to load pools`;
|
||||||
|
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${errorMsg}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle invalid response format
|
||||||
|
if (!data) {
|
||||||
|
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response (no data)</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if data is an array
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
// Log the actual response for debugging
|
||||||
|
console.error('Invalid response format:', data);
|
||||||
|
const errorMsg = (data.error) ? data.error : 'Invalid response format: expected array';
|
||||||
|
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${errorMsg}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pools = data;
|
||||||
|
|
||||||
|
if (pools.length === 0) {
|
||||||
|
listEl.innerHTML = '<p class="text-slate-400 text-sm">No pools found. Create a pool to get started.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = pools.map(pool => `
|
||||||
|
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<h3 class="text-lg font-semibold text-white">${pool.name}</h3>
|
||||||
|
<span class="px-2 py-1 rounded text-xs font-medium ${
|
||||||
|
pool.health === 'ONLINE' ? 'bg-green-900 text-green-300' :
|
||||||
|
pool.health === 'DEGRADED' ? 'bg-yellow-900 text-yellow-300' :
|
||||||
|
'bg-red-900 text-red-300'
|
||||||
|
}">${pool.health}</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="text-slate-400">Size:</span>
|
||||||
|
<span class="text-white ml-2">${formatBytes(pool.size)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-slate-400">Used:</span>
|
||||||
|
<span class="text-white ml-2">${formatBytes(pool.allocated)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-slate-400">Free:</span>
|
||||||
|
<span class="text-white ml-2">${formatBytes(pool.free)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="startScrub('${pool.name}')" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||||
|
Scrub
|
||||||
|
</button>
|
||||||
|
<button onclick="exportPool('${pool.name}')" class="px-3 py-1.5 bg-yellow-600 hover:bg-yellow-700 text-white rounded text-sm">
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
<button onclick="deletePool('${pool.name}')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('pools-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDatasets() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/datasets', { headers: getAuthHeaders() });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||||
|
document.getElementById('datasets-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.error || 'Failed to load datasets'}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const datasets = await res.json();
|
||||||
|
const listEl = document.getElementById('datasets-list');
|
||||||
|
|
||||||
|
if (!Array.isArray(datasets)) {
|
||||||
|
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (datasets.length === 0) {
|
||||||
|
listEl.innerHTML = '<p class="text-slate-400 text-sm">No datasets found</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = datasets.map(ds => `
|
||||||
|
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-1">${ds.name}</h3>
|
||||||
|
${ds.mountpoint ? `<p class="text-sm text-slate-400">${ds.mountpoint}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
<button onclick="deleteDataset('${ds.name}')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('datasets-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadZVOLs() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/zvols', { headers: getAuthHeaders() });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||||
|
document.getElementById('zvols-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.error || 'Failed to load volumes'}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const zvols = await res.json();
|
||||||
|
const listEl = document.getElementById('zvols-list');
|
||||||
|
|
||||||
|
if (!Array.isArray(zvols)) {
|
||||||
|
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zvols.length === 0) {
|
||||||
|
listEl.innerHTML = '<p class="text-slate-400 text-sm">No volumes found</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = zvols.map(zvol => `
|
||||||
|
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-1">${zvol.name}</h3>
|
||||||
|
<p class="text-sm text-slate-400">Size: ${formatBytes(zvol.size)}</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="deleteZVOL('${zvol.name}')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('zvols-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDisks() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/disks', { headers: getAuthHeaders() });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||||
|
document.getElementById('disks-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.error || 'Failed to load disks'}</p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const disks = await res.json();
|
||||||
|
const listEl = document.getElementById('disks-list');
|
||||||
|
|
||||||
|
if (!Array.isArray(disks)) {
|
||||||
|
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disks.length === 0) {
|
||||||
|
listEl.innerHTML = '<p class="text-slate-400 text-sm">No disks found</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = disks.map(disk => `
|
||||||
|
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-lg font-semibold text-white mb-1">${disk.name}</h3>
|
||||||
|
<div class="text-sm text-slate-400 space-y-1">
|
||||||
|
${disk.size ? `<p>Size: ${disk.size}</p>` : ''}
|
||||||
|
${disk.model ? `<p>Model: ${disk.model}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('disks-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreatePoolModal() {
|
||||||
|
document.getElementById('create-pool-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showImportPoolModal() {
|
||||||
|
document.getElementById('import-pool-modal').classList.remove('hidden');
|
||||||
|
loadAvailablePools();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreateDatasetModal() {
|
||||||
|
document.getElementById('create-dataset-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreateZVOLModal() {
|
||||||
|
document.getElementById('create-zvol-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(modalId) {
|
||||||
|
document.getElementById(modalId).classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAvailablePools() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/pools/available', { headers: getAuthHeaders() });
|
||||||
|
const data = await res.json();
|
||||||
|
const select = document.getElementById('import-pool-select');
|
||||||
|
select.innerHTML = '<option value="">Select a pool...</option>';
|
||||||
|
if (data.pools && data.pools.length > 0) {
|
||||||
|
data.pools.forEach(pool => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = pool;
|
||||||
|
option.textContent = pool;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
select.innerHTML = '<option value="">No pools available for import</option>';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading available pools:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createPool(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const vdevs = formData.get('vdevs').split(',').map(v => v.trim()).filter(v => v);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/pools', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: formData.get('name'),
|
||||||
|
vdevs: vdevs
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
closeModal('create-pool-modal');
|
||||||
|
e.target.reset();
|
||||||
|
loadPools();
|
||||||
|
alert('Pool created successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to create pool'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importPool(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const options = {};
|
||||||
|
if (formData.get('readonly')) {
|
||||||
|
options.readonly = 'on';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/pools/import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: formData.get('name'),
|
||||||
|
options: options
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
closeModal('import-pool-modal');
|
||||||
|
e.target.reset();
|
||||||
|
loadPools();
|
||||||
|
alert('Pool imported successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to import pool'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDataset(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const data = { name: formData.get('name') };
|
||||||
|
if (formData.get('quota')) data.quota = formData.get('quota');
|
||||||
|
if (formData.get('compression')) data.compression = formData.get('compression');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/datasets', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
closeModal('create-dataset-modal');
|
||||||
|
e.target.reset();
|
||||||
|
loadDatasets();
|
||||||
|
alert('Dataset created successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to create dataset'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createZVOL(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/zvols', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: formData.get('name'),
|
||||||
|
size: formData.get('size')
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
closeModal('create-zvol-modal');
|
||||||
|
e.target.reset();
|
||||||
|
loadZVOLs();
|
||||||
|
alert('Storage volume created successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to create storage volume'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePool(name) {
|
||||||
|
if (!confirm(`Are you sure you want to delete pool "${name}"? This will destroy all data!`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/pools/${name}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
loadPools();
|
||||||
|
alert('Pool deleted successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to delete pool'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDataset(name) {
|
||||||
|
if (!confirm(`Are you sure you want to delete dataset "${name}"?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/datasets/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
loadDatasets();
|
||||||
|
alert('Dataset deleted successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to delete dataset'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteZVOL(name) {
|
||||||
|
if (!confirm(`Are you sure you want to delete storage volume "${name}"?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/zvols/${encodeURIComponent(name)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
loadZVOLs();
|
||||||
|
alert('Storage volume deleted successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to delete storage volume'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startScrub(name) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/pools/${name}/scrub`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
alert('Scrub started successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to start scrub'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportPool(name) {
|
||||||
|
if (!confirm(`Are you sure you want to export pool "${name}"?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/pools/${name}/export`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getAuthHeaders(),
|
||||||
|
body: JSON.stringify({ force: false })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
loadPools();
|
||||||
|
alert('Pool exported successfully');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert(`Error: ${err.error || 'Failed to export pool'}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert(`Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load initial data
|
||||||
|
loadPools();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "storage.html"}}
|
||||||
|
{{template "base" .}}
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user