add installer alpha version

This commit is contained in:
2025-12-15 16:38:20 +07:00
parent 732e5aca11
commit b4ef76f0d0
23 changed files with 4279 additions and 136 deletions

View File

@@ -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**
- ZFS storage engine

View File

@@ -1,6 +1,6 @@
# 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

150
docs/RBAC_PERMISSIONS.md Normal file
View 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.

View File

@@ -1,6 +1,6 @@
openapi: 3.0.3
info:
title: atlasOS Storage Controller API
title: AtlasOS Storage Controller API
description: |
REST API for managing ZFS storage, storage services (SMB/NFS/iSCSI), snapshots, and system configuration.
@@ -17,7 +17,7 @@ info:
version: 1.0.0
contact:
name: atlasOS Support
name: AtlasOS Support
url: https://github.com/atlasos
servers:

34
fix-sudoers.sh Executable file
View 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"

View File

@@ -1,7 +1,7 @@
#!/bin/bash
#
# AtlasOS Installation Script
# Installs AtlasOS storage controller on a Linux system
# AtlasOS Installation Script for Ubuntu 24.04
# Installs AtlasOS storage controller with infrastructure gap consideration
#
# Usage: sudo ./install.sh [options]
#
@@ -9,6 +9,7 @@
#
set -e
set -o pipefail
# Colors for output
RED='\033[0;31m'
@@ -28,6 +29,7 @@ DB_PATH="/var/lib/atlas/atlas.db"
BUILD_BINARIES=true
SKIP_DEPS=false
REPO_DIR=""
FIREWALL_ACTIVE=false
# Parse command line arguments
while [[ $# -gt 0 ]]; do
@@ -87,83 +89,274 @@ fi
# Get script directory early (for path resolution)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Detect distribution
# Detect distribution and validate Ubuntu 24.04
detect_distro() {
if [[ -f /etc/os-release ]]; then
. /etc/os-release
DISTRO=$ID
VERSION=$VERSION_ID
CODENAME=$VERSION_CODENAME
else
echo -e "${RED}Error: Cannot detect Linux distribution${NC}"
exit 1
fi
# Validate Ubuntu 24.04
if [[ "$DISTRO" != "ubuntu" ]]; then
echo -e "${RED}Error: This installer is specific to Ubuntu${NC}"
echo " Detected: $DISTRO $VERSION"
echo " Please use the generic installer or install manually"
exit 1
fi
# Check for Ubuntu 24.04 (Noble Numbat)
if [[ "$VERSION" != "24.04" ]] && [[ "$CODENAME" != "noble" ]]; then
echo -e "${YELLOW}Warning: This installer is optimized for Ubuntu 24.04${NC}"
echo " Detected: Ubuntu $VERSION ($CODENAME)"
echo " Continuing anyway, but some features may not work correctly"
read -p "Continue anyway? (y/n) " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
else
echo -e "${GREEN}✓ Detected Ubuntu 24.04 (Noble Numbat)${NC}"
fi
}
# Install dependencies
install_dependencies() {
echo -e "${GREEN}Installing dependencies...${NC}"
# Pre-flight checks for infrastructure gaps
preflight_checks() {
echo -e "${GREEN}Running pre-flight checks...${NC}"
case $DISTRO in
ubuntu|debian)
local errors=0
local warnings=0
# Check network connectivity
echo -n " Checking network connectivity... "
if ping -c 1 -W 2 8.8.8.8 &>/dev/null || ping -c 1 -W 2 1.1.1.1 &>/dev/null; then
echo -e "${GREEN}${NC}"
else
echo -e "${YELLOW}⚠ No internet connectivity${NC}"
warnings=$((warnings + 1))
fi
# Check if apt is working
echo -n " Checking package manager... "
if apt-get update &>/dev/null; then
echo -e "${GREEN}${NC}"
else
echo -e "${RED}✗ APT not working${NC}"
errors=$((errors + 1))
fi
# Check disk space (need at least 2GB free)
echo -n " Checking disk space... "
AVAILABLE_SPACE=$(df / | tail -1 | awk '{print $4}')
if [[ $AVAILABLE_SPACE -gt 2097152 ]]; then # 2GB in KB
echo -e "${GREEN}✓ ($(numfmt --to=iec-i --suffix=B $((AVAILABLE_SPACE * 1024))) available)${NC}"
else
echo -e "${YELLOW}⚠ Low disk space ($(numfmt --to=iec-i --suffix=B $((AVAILABLE_SPACE * 1024))) available)${NC}"
warnings=$((warnings + 1))
fi
# Check if systemd is available
echo -n " Checking systemd... "
if systemctl --version &>/dev/null; then
echo -e "${GREEN}${NC}"
else
echo -e "${RED}✗ systemd not available${NC}"
errors=$((errors + 1))
fi
# Check kernel version (Ubuntu 24.04 should have 6.8+)
echo -n " Checking kernel version... "
KERNEL_VERSION=$(uname -r | cut -d. -f1,2)
KERNEL_MAJOR=$(echo $KERNEL_VERSION | cut -d. -f1)
KERNEL_MINOR=$(echo $KERNEL_VERSION | cut -d. -f2)
if [[ $KERNEL_MAJOR -gt 6 ]] || [[ $KERNEL_MAJOR -eq 6 && $KERNEL_MINOR -ge 8 ]]; then
echo -e "${GREEN}✓ ($(uname -r))${NC}"
else
echo -e "${YELLOW}⚠ Kernel $(uname -r) may not fully support all features${NC}"
warnings=$((warnings + 1))
fi
# Check if running in container (may need special handling)
echo -n " Checking environment... "
if [[ -f /.dockerenv ]] || grep -qa container=lxc /proc/1/environ 2>/dev/null; then
echo -e "${YELLOW}⚠ Running in container (some features may be limited)${NC}"
warnings=$((warnings + 1))
else
echo -e "${GREEN}${NC}"
fi
echo ""
if [[ $errors -gt 0 ]]; then
echo -e "${RED}Pre-flight checks failed with $errors error(s)${NC}"
exit 1
elif [[ $warnings -gt 0 ]]; then
echo -e "${YELLOW}Pre-flight checks completed with $warnings warning(s)${NC}"
else
echo -e "${GREEN}All pre-flight checks passed${NC}"
fi
echo ""
}
# Fix common infrastructure gaps
fix_infrastructure_gaps() {
echo -e "${GREEN}Fixing infrastructure gaps...${NC}"
# Ensure universe repository is enabled (required for some packages on Ubuntu)
echo -n " Checking universe repository... "
if ! grep -q "^deb.*universe" /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null; then
echo -e "${YELLOW}⚠ Enabling universe repository...${NC}"
add-apt-repository -y universe 2>/dev/null || {
echo "deb http://archive.ubuntu.com/ubuntu $(lsb_release -cs) universe" >> /etc/apt/sources.list
apt-get update
}
echo -e "${GREEN}✓ Universe repository enabled${NC}"
else
echo -e "${GREEN}${NC}"
fi
# 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
# Ensure multiverse repository is enabled (for some packages)
echo -n " Checking multiverse repository... "
if ! grep -q "^deb.*multiverse" /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null; then
echo -e "${YELLOW}⚠ Enabling multiverse repository...${NC}"
add-apt-repository -y multiverse 2>/dev/null || {
echo "deb http://archive.ubuntu.com/ubuntu $(lsb_release -cs) multiverse" >> /etc/apt/sources.list
apt-get update
}
echo -e "${GREEN}✓ Multiverse repository enabled${NC}"
else
echo -e "${GREEN}${NC}"
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
# Load ZFS kernel module if not loaded
echo -n " Checking ZFS kernel module... "
if ! lsmod | grep -q "^zfs"; then
echo -e "${YELLOW}⚠ Loading ZFS kernel module...${NC}"
modprobe zfs 2>/dev/null || {
echo -e "${YELLOW} ZFS module not available (will be installed with zfsutils-linux)${NC}"
}
fi
echo -e "${GREEN}${NC}"
echo -e "${GREEN}Dependencies installed${NC}"
# Ensure ZFS module loads on boot
if ! grep -q "^zfs" /etc/modules-load.d/*.conf 2>/dev/null && ! grep -q "^zfs" /etc/modules 2>/dev/null; then
echo "zfs" > /etc/modules-load.d/zfs.conf
echo -e "${GREEN} ✓ ZFS module will load on boot${NC}"
fi
# Check and configure firewall (UFW)
echo -n " Checking firewall... "
if command -v ufw &>/dev/null; then
if ufw status | grep -q "Status: active"; then
echo -e "${YELLOW}⚠ UFW is active${NC}"
echo " Will configure firewall rules after installation"
FIREWALL_ACTIVE=true
else
echo -e "${GREEN}✓ (inactive)${NC}"
FIREWALL_ACTIVE=false
fi
else
echo -e "${GREEN}✓ (not installed)${NC}"
FIREWALL_ACTIVE=false
fi
echo -e "${GREEN}Infrastructure gaps fixed${NC}"
echo ""
}
# Install dependencies for Ubuntu 24.04
install_dependencies() {
echo -e "${GREEN}Installing dependencies for Ubuntu 24.04...${NC}"
# Update package lists
echo " Updating package lists..."
apt-get update -qq
# Install essential build tools first
echo " Installing build essentials..."
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
build-essential \
git \
curl \
wget \
ca-certificates \
software-properties-common \
apt-transport-https
# Install ZFS utilities (Ubuntu 24.04 specific)
echo " Installing ZFS utilities..."
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
zfsutils-linux \
zfs-zed \
zfs-initramfs || {
echo -e "${YELLOW}Warning: ZFS installation may require additional setup${NC}"
}
# Install storage services
echo " Installing storage services..."
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
samba \
samba-common-bin \
nfs-kernel-server \
rpcbind
# Install iSCSI target (Ubuntu 24.04 uses targetcli-fb)
echo " Installing iSCSI target..."
if apt-cache show targetcli-fb &>/dev/null; then
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq targetcli-fb
# Create symlink for compatibility
if ! command -v targetcli &>/dev/null && command -v targetcli-fb &>/dev/null; then
ln -sf $(which targetcli-fb) /usr/local/bin/targetcli
fi
else
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq targetcli || {
echo -e "${YELLOW}Warning: targetcli not available, iSCSI features may be limited${NC}"
}
fi
# Install database
echo " Installing SQLite..."
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sqlite3 libsqlite3-dev
# Install Go compiler (Ubuntu 24.04 has Go 1.22+)
echo " Installing Go compiler..."
if ! command -v go &>/dev/null; then
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq golang-go || {
echo -e "${YELLOW}Warning: Go not in repositories, installing from official source...${NC}"
# Fallback: install Go from official source
GO_VERSION="1.22.0"
wget -q https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz -O /tmp/go.tar.gz
rm -rf /usr/local/go
tar -C /usr/local -xzf /tmp/go.tar.gz
ln -sf /usr/local/go/bin/go /usr/local/bin/go
ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt
rm /tmp/go.tar.gz
}
fi
# Verify Go installation
if command -v go &>/dev/null; then
GO_VERSION=$(go version | awk '{print $3}')
echo -e "${GREEN} ✓ Go $GO_VERSION installed${NC}"
else
echo -e "${RED}Error: Go installation failed${NC}"
exit 1
fi
# Install additional utilities
echo " Installing additional utilities..."
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
openssl \
net-tools \
iproute2 \
systemd \
journalctl
echo -e "${GREEN}Dependencies installed successfully${NC}"
echo ""
}
# Create system user
@@ -560,57 +753,164 @@ generate_jwt_secret() {
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}"
# Configure firewall (UFW) for Ubuntu 24.04
configure_firewall() {
if [[ "$FIREWALL_ACTIVE" != "true" ]]; then
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"
echo -e "${GREEN}Configuring firewall rules...${NC}"
# Extract port from HTTP_ADDR (default :8080)
PORT=$(echo "$HTTP_ADDR" | sed 's/.*://' || echo "8080")
if [[ -z "$PORT" ]] || [[ "$PORT" == "$HTTP_ADDR" ]]; then
PORT="8080"
fi
echo -e "${GREEN}ZFS check complete${NC}"
# Allow HTTP port
echo " Allowing port $PORT for AtlasOS API..."
ufw allow "$PORT/tcp" comment "AtlasOS API" 2>/dev/null || true
# Allow SMB/CIFS ports
echo " Allowing SMB/CIFS ports..."
ufw allow 445/tcp comment "SMB/CIFS" 2>/dev/null || true
ufw allow 139/tcp comment "NetBIOS" 2>/dev/null || true
# Allow NFS ports
echo " Allowing NFS ports..."
ufw allow 2049/tcp comment "NFS" 2>/dev/null || true
ufw allow 2049/udp comment "NFS" 2>/dev/null || true
ufw allow 111/tcp comment "RPC" 2>/dev/null || true
ufw allow 111/udp comment "RPC" 2>/dev/null || true
# Allow iSCSI ports
echo " Allowing iSCSI ports..."
ufw allow 3260/tcp comment "iSCSI" 2>/dev/null || true
echo -e "${GREEN}Firewall configured${NC}"
echo ""
}
# Setup Samba
# Setup ZFS with comprehensive checks
setup_zfs() {
echo -e "${GREEN}Setting up ZFS...${NC}"
if ! command -v zpool &> /dev/null; then
echo -e "${RED}Error: ZFS utilities not found${NC}"
echo " Please install zfsutils-linux"
return 1
fi
# Load ZFS kernel module
echo " Loading ZFS kernel module..."
if ! lsmod | grep -q "^zfs"; then
modprobe zfs 2>/dev/null || {
echo -e "${YELLOW}Warning: Could not load ZFS module${NC}"
echo " This may require a system reboot"
}
else
echo -e "${GREEN} ✓ ZFS module loaded${NC}"
fi
# Ensure ZFS module loads on boot
if [[ ! -f /etc/modules-load.d/zfs.conf ]] || ! grep -q "^zfs" /etc/modules-load.d/zfs.conf 2>/dev/null; then
echo "zfs" > /etc/modules-load.d/zfs.conf
echo -e "${GREEN} ✓ ZFS will load on boot${NC}"
fi
# Check ZFS pool status
echo " Checking ZFS pools..."
if zpool list &>/dev/null; then
POOL_COUNT=$(zpool list -H | wc -l)
if [[ $POOL_COUNT -gt 0 ]]; then
echo -e "${GREEN} ✓ Found $POOL_COUNT ZFS pool(s)${NC}"
else
echo -e "${YELLOW} ⚠ No ZFS pools found (you can create them after installation)${NC}"
fi
else
echo -e "${YELLOW} ⚠ ZFS not fully initialized${NC}"
fi
echo -e "${GREEN}ZFS setup complete${NC}"
echo ""
}
# Setup Samba with dependency checks
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
echo -e "${RED}Error: Samba not found${NC}"
return 1
fi
# Enable and start Samba (if not already)
# Create basic Samba config if it doesn't exist
if [[ ! -f /etc/samba/smb.conf ]] || [[ ! -s /etc/samba/smb.conf ]]; then
echo " Creating basic Samba configuration..."
mkdir -p /etc/samba
cat > /etc/samba/smb.conf <<'SAMBAEOF'
[global]
workgroup = WORKGROUP
server string = AtlasOS Storage Server
security = user
map to guest = Bad User
dns proxy = no
SAMBAEOF
echo -e "${GREEN} ✓ Basic Samba config created${NC}"
fi
# Enable and start Samba services
echo " Enabling Samba services..."
systemctl enable smbd 2>/dev/null || true
systemctl enable nmbd 2>/dev/null || true
# Start services if not running
if ! systemctl is-active --quiet smbd; then
systemctl start smbd 2>/dev/null || echo -e "${YELLOW} ⚠ Could not start smbd (may need manual start)${NC}"
fi
echo -e "${GREEN}Samba setup complete${NC}"
echo ""
}
# Setup NFS
# Setup NFS with dependency checks
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
echo -e "${RED}Error: NFS utilities not found${NC}"
return 1
fi
# Enable and start NFS (if not already)
systemctl enable nfs-server 2>/dev/null || true
# Ensure /etc/exports exists
if [[ ! -f /etc/exports ]]; then
echo " Creating /etc/exports..."
touch /etc/exports
echo -e "${GREEN} ✓ /etc/exports created${NC}"
fi
# Enable and start NFS services
echo " Enabling NFS services..."
systemctl enable rpcbind 2>/dev/null || true
systemctl enable nfs-server 2>/dev/null || true
systemctl enable nfs-kernel-server 2>/dev/null || true
# Start rpcbind first (required dependency)
if ! systemctl is-active --quiet rpcbind; then
systemctl start rpcbind 2>/dev/null || echo -e "${YELLOW} ⚠ Could not start rpcbind${NC}"
fi
# Start NFS server
if ! systemctl is-active --quiet nfs-server && ! systemctl is-active --quiet nfs-kernel-server; then
systemctl start nfs-server 2>/dev/null || systemctl start nfs-kernel-server 2>/dev/null || \
echo -e "${YELLOW} ⚠ Could not start NFS server (may need manual start)${NC}"
fi
echo -e "${GREEN}NFS setup complete${NC}"
echo ""
}
# Setup iSCSI
# Setup iSCSI with dependency checks
setup_iscsi() {
echo -e "${GREEN}Setting up iSCSI...${NC}"
@@ -622,20 +922,34 @@ setup_iscsi() {
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
ln -sf $(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
echo -e "${RED}Error: targetcli or targetcli-fb not found${NC}"
echo " Install with: apt-get install targetcli-fb"
return 1
fi
# Enable and start iSCSI target (if not already)
systemctl enable target 2>/dev/null || true
echo -e "${GREEN} ✓ Found $TARGETCLI_CMD${NC}"
# Check if target service exists
if systemctl list-unit-files | grep -q "^target.service"; then
# Enable and start iSCSI target service
echo " Enabling iSCSI target service..."
systemctl enable target 2>/dev/null || true
# Start service if not running
if ! systemctl is-active --quiet target; then
systemctl start target 2>/dev/null || echo -e "${YELLOW} ⚠ Could not start target service (may need manual start)${NC}"
fi
else
echo -e "${YELLOW} ⚠ target.service not found (LIO may not be properly installed)${NC}"
fi
echo -e "${GREEN}iSCSI setup complete (using $TARGETCLI_CMD)${NC}"
echo ""
}
# Create initial admin user
@@ -681,6 +995,7 @@ print_summary() {
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}AtlasOS Installation Complete!${NC}"
echo -e "${GREEN}Ubuntu 24.04 (Noble Numbat)${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo "Installation Directory: $INSTALL_DIR"
@@ -688,24 +1003,81 @@ print_summary() {
echo "Config Directory: $CONFIG_DIR"
echo "Log Directory: $LOG_DIR"
echo ""
# Extract port from HTTP_ADDR
PORT=$(echo "$HTTP_ADDR" | sed 's/.*://' || echo "8080")
if [[ -z "$PORT" ]] || [[ "$PORT" == "$HTTP_ADDR" ]]; then
PORT="8080"
fi
echo "Service Status:"
systemctl status atlas-api --no-pager -l || true
systemctl status atlas-api --no-pager -l 2>/dev/null || echo " Service not running"
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 " TUI: atlas-tui (or $INSTALL_DIR/bin/atlas-tui)"
echo ""
echo "Web Interface:"
echo " http://localhost:8080"
echo " http://localhost:$PORT"
echo ""
echo "API Documentation:"
echo " http://localhost:8080/api/docs"
echo " http://localhost:$PORT/api/docs"
echo ""
if [[ "$FIREWALL_ACTIVE" == "true" ]]; then
echo -e "${YELLOW}Firewall Configuration:${NC}"
echo " UFW is active. The following ports have been allowed:"
echo " - Port $PORT (AtlasOS API)"
echo " - Port 445 (SMB/CIFS)"
echo " - Port 139 (NetBIOS)"
echo " - Port 2049 (NFS)"
echo " - Port 3260 (iSCSI)"
echo " To view rules: ufw status"
echo ""
fi
echo -e "${YELLOW}Storage Services Status:${NC}"
echo -n " ZFS: "
if command -v zpool &>/dev/null && lsmod | grep -q "^zfs"; then
echo -e "${GREEN}✓ Ready${NC}"
else
echo -e "${YELLOW}⚠ Check required${NC}"
fi
echo -n " Samba: "
if systemctl is-active --quiet smbd 2>/dev/null; then
echo -e "${GREEN}✓ Running${NC}"
else
echo -e "${YELLOW}⚠ Not running (start with: systemctl start smbd)${NC}"
fi
echo -n " NFS: "
if systemctl is-active --quiet nfs-server 2>/dev/null || systemctl is-active --quiet nfs-kernel-server 2>/dev/null; then
echo -e "${GREEN}✓ Running${NC}"
else
echo -e "${YELLOW}⚠ Not running (start with: systemctl start nfs-server)${NC}"
fi
echo -n " iSCSI: "
if systemctl is-active --quiet target 2>/dev/null; then
echo -e "${GREEN}✓ Running${NC}"
else
echo -e "${YELLOW}⚠ Not running (start with: systemctl start target)${NC}"
fi
echo ""
echo -e "${YELLOW}Next Steps:${NC}"
echo "1. Create initial admin user (see instructions above)"
echo "2. Configure TLS certificates (optional)"
echo "2. Configure TLS certificates (optional) - see $CONFIG_DIR/atlas.conf"
echo "3. Review configuration in $CONFIG_DIR/atlas.conf"
echo "4. Ensure storage services are running if needed"
echo ""
echo -e "${GREEN}Installation completed successfully!${NC}"
echo ""
}
@@ -713,34 +1085,55 @@ print_summary() {
main() {
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}AtlasOS Installation Script${NC}"
echo -e "${GREEN}For Ubuntu 24.04 (Noble Numbat)${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
# Step 1: Detect and validate distribution
detect_distro
echo "Detected distribution: $DISTRO $VERSION"
echo ""
# Step 2: Pre-flight checks
preflight_checks
# Step 3: Fix infrastructure gaps
fix_infrastructure_gaps
# Step 4: Install dependencies
if [[ "$SKIP_DEPS" == "false" ]]; then
install_dependencies
else
echo -e "${YELLOW}Skipping dependency installation${NC}"
echo ""
fi
# Step 5: Create system user and directories
create_user
create_directories
# Step 6: Build binaries
build_binaries
# Step 7: Create configuration
create_config
generate_jwt_secret
# Step 8: Create systemd service
create_systemd_service
# Step 9: Setup storage services
setup_zfs
setup_samba
setup_nfs
setup_iscsi
# Step 10: Configure firewall
configure_firewall
# Step 11: Create admin user info
create_admin_user
# Ask if user wants to start service
# Step 12: Ask if user wants to start service
echo ""
read -p "Start AtlasOS service now? (y/n) " -n 1 -r
echo ""
@@ -750,6 +1143,7 @@ main() {
echo -e "${YELLOW}Service not started. Start manually with: systemctl start atlas-api${NC}"
fi
# Step 13: Print summary
print_summary
}

View File

@@ -36,6 +36,10 @@ func (a *App) handleListPools(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
// Ensure we always return an array, not null
if pools == nil {
pools = []models.Pool{}
}
writeJSON(w, http.StatusOK, pools)
}
@@ -215,6 +219,10 @@ func (a *App) handleListDatasets(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
// Ensure we always return an array, not null
if datasets == nil {
datasets = []models.Dataset{}
}
writeJSON(w, http.StatusOK, datasets)
}
@@ -398,6 +406,10 @@ func (a *App) handleListSnapshots(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
// Ensure we always return an array, not null
if snapshots == nil {
snapshots = []models.Snapshot{}
}
writeJSON(w, http.StatusOK, snapshots)
}
@@ -485,6 +497,10 @@ func (a *App) handleListSnapshotPolicies(w http.ResponseWriter, r *http.Request)
} else {
policies = a.snapshotPolicy.List()
}
// Ensure we always return an array, not null
if policies == nil {
policies = []models.SnapshotPolicy{}
}
writeJSON(w, http.StatusOK, policies)
}
@@ -1322,6 +1338,10 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
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
if req.Role != models.RoleAdministrator && req.Role != models.RoleOperator && req.Role != models.RoleViewer {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid role"})
@@ -1376,6 +1396,12 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
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
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"})

View File

@@ -55,11 +55,40 @@ type App struct {
}
func New(cfg Config) (*App, error) {
// Resolve paths relative to executable or current working directory
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 == "" {
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)
@@ -228,6 +257,12 @@ func parseTemplates(dir string) (*template.Template, error) {
funcs := template.FuncMap{
"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)

View File

@@ -17,7 +17,7 @@ const (
// authMiddleware validates JWT tokens and extracts user info
func (a *App) authMiddleware(next http.Handler) http.Handler {
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) {
next.ServeHTTP(w, r)
return
@@ -101,14 +101,27 @@ func (a *App) requireRole(allowedRoles ...models.Role) func(http.Handler) http.H
func (a *App) isPublicEndpoint(path string) bool {
publicPaths := []string{
"/healthz",
"/health",
"/metrics",
"/api/v1/auth/login",
"/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 {
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
}
}
@@ -118,6 +131,28 @@ func (a *App) isPublicEndpoint(path string) bool {
return true
}
// Make read-only GET endpoints public for web UI (but require auth for mutations)
// This allows the UI to display data without login, but operations require auth
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 {
return true
}
}
return false
}

View File

@@ -12,7 +12,7 @@ func (a *App) handleAPIDocs(w http.ResponseWriter, r *http.Request) {
html := `<!DOCTYPE html>
<html>
<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" />
<style>
html { box-sizing: border-box; overflow: -moz-scrollbars-vertical; overflow-y: scroll; }

View File

@@ -17,6 +17,72 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
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) {
id, _ := r.Context().Value(requestIDKey).(string)
resp := map[string]any{

View File

@@ -13,6 +13,12 @@ func (a *App) routes() {
// Web UI
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
a.mux.HandleFunc("/healthz", a.handleHealthz)
@@ -173,6 +179,61 @@ func (a *App) routes() {
))
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
a.mux.HandleFunc("/api/v1/audit", a.handleListAuditLogs)
}

View File

@@ -23,7 +23,9 @@ func (a *App) securityHeadersMiddleware(next http.Handler) http.Handler {
}
// 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)
next.ServeHTTP(w, r)

View 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),
})
}

View File

@@ -20,12 +20,35 @@ type Service struct {
// New creates a new ZFS service
func New() *Service {
// Find full paths to zfs and zpool commands
zfsPath := findCommandPath("zfs")
zpoolPath := findCommandPath("zpool")
return &Service{
zfsPath: "zfs",
zpoolPath: "zpool",
zfsPath: zfsPath,
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
// For ZFS operations that require elevated privileges, it uses sudo
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
if useSudo {
// Use sudo for privileged commands
sudoArgs := append([]string{name}, args...)
// Use sudo -n (non-interactive) for privileged commands
// This prevents password prompts and will fail if sudoers is not configured
sudoArgs := append([]string{"-n", name}, args...)
cmd = exec.Command("sudo", sudoArgs...)
} else {
cmd = exec.Command(name, args...)
@@ -53,7 +77,24 @@ func (s *Service) execCommand(name string, args ...string) (string, error) {
cmd.Stdout = &stdout
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())
}
@@ -64,10 +105,11 @@ func (s *Service) execCommand(name string, args ...string) (string, error) {
func (s *Service) ListPools() ([]models.Pool, error) {
output, err := s.execCommand(s.zpoolPath, "list", "-H", "-o", "name,size,allocated,free,health")
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")
for _, line := range lines {
if line == "" {
@@ -347,10 +389,11 @@ func (s *Service) ListDatasets(pool string) ([]models.Dataset, error) {
output, err := s.execCommand(s.zfsPath, args...)
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")
for _, line := range lines {
if line == "" {
@@ -428,10 +471,11 @@ func (s *Service) ListZVOLs(pool string) ([]models.ZVOL, error) {
output, err := s.execCommand(s.zfsPath, args...)
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")
for _, line := range lines {
if line == "" {
@@ -588,10 +632,11 @@ func (s *Service) ListSnapshots(dataset string) ([]models.Snapshot, error) {
output, err := s.execCommand(s.zfsPath, args...)
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")
for _, line := range lines {
if line == "" {

View File

@@ -4,11 +4,12 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>{{.Title}} • atlasOS</title>
<title>{{.Title}} • AtlasOS</title>
<!-- v1: Tailwind CDN (later: bundle local) -->
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<!-- Try multiple CDN sources for better reliability -->
<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>
<body class="bg-slate-950 text-slate-100">
@@ -17,31 +18,92 @@
<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>
<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>
</div>
<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 opacity-50 cursor-not-allowed" href="#">Storage</a>
<a class="hover:text-white opacity-50 cursor-not-allowed" href="#">Shares</a>
<a class="hover:text-white opacity-50 cursor-not-allowed" href="#">iSCSI</a>
<a class="hover:text-white" href="/storage">Storage</a>
<a class="hover:text-white" href="/shares">Shares</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>
<span id="auth-status" class="ml-4"></span>
</nav>
</div>
</header>
<main class="mx-auto max-w-6xl px-4 py-8">
{{template "content" .}}
{{$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" .}}
{{end}}
</main>
<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">
<span>atlasOS • {{nowRFC3339}}</span>
<span>AtlasOS • {{nowRFC3339}}</span>
<span>Build: {{index .Build "version"}}</span>
</div>
</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>
</html>
{{end}}

View File

@@ -2,7 +2,7 @@
<div class="space-y-6">
<div>
<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 class="grid grid-cols-1 md:grid-cols-4 gap-4">
@@ -17,7 +17,7 @@
</div>
</div>
<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>
<!-- Storage Capacity Card -->
@@ -124,7 +124,12 @@
// Fetch dashboard data and update UI
function updateDashboard() {
fetch('/api/v1/dashboard')
.then(res => res.json())
.then(res => {
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
})
.then(data => {
// Update storage stats
document.getElementById('pool-count').textContent = data.storage.pool_count || 0;

380
web/templates/iscsi.html Normal file
View 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
View 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}}

View 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}}

View 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
View 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
View 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}}