feat: Admin Panel for Incus container management

This commit is contained in:
js 2026-02-05 22:17:42 +00:00
parent fc64cd4a1f
commit d6b2b6da23
99 changed files with 11894 additions and 0 deletions

5
.gitignore vendored
View File

@ -53,3 +53,8 @@ jspm_packages/
# Aider AI Chat # Aider AI Chat
.aider* .aider*
# Admin Panel
certs/
templates/servers.json
templates/distrobuilder/apps_meta.json

View File

619
admin_panel/api/apps.py Normal file
View File

@ -0,0 +1,619 @@
import json
import os
import shutil
import subprocess
import tempfile
import frappe
import requests
import urllib3
import yaml
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
TEMPLATES_DIR = "/home/frappe/frappe-bench/apps/admin_panel/templates/distrobuilder"
META_FILE = os.path.join(TEMPLATES_DIR, "apps_meta.json")
SERVERS_FILE = "/home/frappe/frappe-bench/apps/admin_panel/templates/servers.json"
CERTS_DIR = "/home/frappe/frappe-bench/apps/admin_panel/certs"
CLIENT_CERT = os.path.join(CERTS_DIR, "client.crt")
CLIENT_KEY = os.path.join(CERTS_DIR, "client.key")
def _check_admin_permission():
"""Check if user has admin permission (System Manager or Administrator)."""
if frappe.session.user == "Guest":
frappe.throw("Please login to access this feature", frappe.AuthenticationError)
if frappe.session.user == "Administrator":
return
roles = frappe.get_roles()
if "System Manager" not in roles and "Administrator" not in roles:
frappe.throw(
"You need System Manager or Administrator role to access this feature",
frappe.PermissionError,
)
def _ensure_templates_dir():
"""Ensure templates directory exists."""
if not os.path.exists(TEMPLATES_DIR):
os.makedirs(TEMPLATES_DIR, exist_ok=True)
def _load_meta():
"""Load apps metadata from JSON file."""
_ensure_templates_dir()
if os.path.exists(META_FILE):
with open(META_FILE, "r") as f:
return json.load(f)
return {}
def _save_meta(meta):
"""Save apps metadata to JSON file."""
_ensure_templates_dir()
with open(META_FILE, "w") as f:
json.dump(meta, f, indent=2)
def _load_servers():
"""Load servers config from JSON file."""
if os.path.exists(SERVERS_FILE):
with open(SERVERS_FILE, "r") as f:
return json.load(f)
return {}
def _get_image_server():
"""Get the first configured image server URL."""
servers = _load_servers()
for name, config in servers.items():
if config.get("is_image_server"):
return {"name": name, "url": config.get("url", "")}
# Fallback: return the first server with a URL
for name, config in servers.items():
if config.get("url"):
return {"name": name, "url": config.get("url", "")}
return None
def _incus_api(server_url, method, path, data=None, timeout=30):
"""Make REST API request to an Incus server using panel certificate."""
if not os.path.exists(CLIENT_CERT) or not os.path.exists(CLIENT_KEY):
raise Exception("Panel certificate not configured. Generate one in Servers tab.")
url = f"{server_url}{path}"
r = requests.request(
method,
url,
headers={"Content-Type": "application/json"},
cert=(CLIENT_CERT, CLIENT_KEY),
verify=False,
json=data,
timeout=timeout,
)
r.raise_for_status()
return r.json()
def _get_server_url(server_name):
"""Get URL for a specific server."""
servers = _load_servers()
config = servers.get(server_name, {})
return config.get("url", "")
def _get_all_images(server_url):
"""Get all images from a server (single request)."""
try:
resp = _incus_api(server_url, "GET", "/1.0/images?recursion=1")
return resp.get("metadata", [])
except Exception:
return []
def _filter_images_by_prefix(all_images, prefix):
"""Filter images that match the given alias prefix."""
images = []
for img in all_images:
aliases = img.get("aliases", [])
for alias in aliases:
alias_name = alias.get("name", "")
if alias_name.startswith(prefix):
version = alias_name[len(prefix):].lstrip(":")
images.append(
{
"alias": alias_name,
"version": version or "latest",
"fingerprint": img.get("fingerprint", ""),
"created_at": img.get("created_at", ""),
"size": img.get("size", 0),
"architecture": img.get("architecture", ""),
}
)
return images
def _format_size(size):
"""Format byte size to human readable string."""
if size <= 0:
return "-"
if size >= 1024 * 1024 * 1024:
return f"{size / (1024 * 1024 * 1024):.1f} GB"
elif size >= 1024 * 1024:
return f"{size / (1024 * 1024):.1f} MB"
else:
return f"{size / 1024:.1f} KB"
@frappe.whitelist()
def get_apps():
"""Get list of all app templates."""
_check_admin_permission()
_ensure_templates_dir()
meta = _load_meta()
# Get list of .yaml files in templates directory
yaml_files = [f for f in os.listdir(TEMPLATES_DIR) if f.endswith(".yaml")]
# Fetch all images ONCE from image server
all_images = []
image_server = _get_image_server()
if image_server and image_server.get("url"):
all_images = _get_all_images(image_server["url"])
apps = []
for yaml_file in yaml_files:
app_name = yaml_file.replace(".yaml", "")
app_meta = meta.get(app_name, {})
# Filter versions from cached images
versions = _filter_images_by_prefix(all_images, app_name)
versions_count = len(versions)
latest_version = "-"
total_size = 0
if versions:
versions.sort(key=lambda x: x.get("created_at", ""), reverse=True)
latest_version = versions[0].get("version", "latest")
total_size = versions[0].get("size", 0)
apps.append(
{
"id": app_name,
"name": app_name,
"description": app_meta.get("description", f"Distrobuilder template: {app_name}"),
"status": app_meta.get("status", "active"),
"versions_count": versions_count,
"latest_version": latest_version,
"currentVersion": latest_version,
"size": _format_size(total_size),
"created": app_meta.get("created", ""),
"lastUpdated": versions[0].get("created_at", "")[:10] if versions else app_meta.get("created", ""),
"sitesCount": 0,
}
)
return apps
@frappe.whitelist()
def get_app_detail(app_name):
"""Get app details including YAML config."""
_check_admin_permission()
yaml_path = os.path.join(TEMPLATES_DIR, f"{app_name}.yaml")
if not os.path.exists(yaml_path):
frappe.throw(f"App template '{app_name}' not found")
with open(yaml_path, "r") as f:
yaml_content = f.read()
meta = _load_meta()
app_meta = meta.get(app_name, {})
# Get versions from image server
image_server = _get_image_server()
all_images = []
if image_server and image_server.get("url"):
all_images = _get_all_images(image_server["url"])
versions = _filter_images_by_prefix(all_images, app_name)
versions.sort(key=lambda x: x.get("created_at", ""), reverse=True)
formatted_versions = []
for i, v in enumerate(versions):
formatted_versions.append(
{
"version": v.get("version", "latest"),
"alias": v.get("alias", ""),
"fingerprint": v.get("fingerprint", "")[:12],
"released": v.get("created_at", "")[:10],
"size": _format_size(v.get("size", 0)),
"status": "current" if i == 0 else "active",
"sitesCount": 0,
"changelog": "",
}
)
latest_version = formatted_versions[0].get("version") if formatted_versions else "-"
size = formatted_versions[0].get("size") if formatted_versions else "-"
return {
"id": app_name,
"name": app_name,
"description": app_meta.get("description", f"Distrobuilder template: {app_name}"),
"status": app_meta.get("status", "active"),
"yaml_content": yaml_content,
"versions": formatted_versions,
"currentVersion": latest_version,
"size": size,
"sitesCount": 0,
"lastUpdated": formatted_versions[0].get("released") if formatted_versions else app_meta.get("created", ""),
}
@frappe.whitelist()
def save_app_config(app_name, yaml_content):
"""Save YAML configuration for an app."""
_check_admin_permission()
yaml_path = os.path.join(TEMPLATES_DIR, f"{app_name}.yaml")
if not os.path.exists(yaml_path):
frappe.throw(f"App template '{app_name}' not found")
try:
yaml.safe_load(yaml_content)
except yaml.YAMLError as e:
frappe.throw(f"Invalid YAML: {str(e)}")
with open(yaml_path, "w") as f:
f.write(yaml_content)
return {"success": True, "message": f"Configuration for '{app_name}' saved successfully"}
@frappe.whitelist()
def create_app(app_name, description, yaml_content):
"""Create a new app template."""
_check_admin_permission()
_ensure_templates_dir()
if not app_name or not all(c.isalnum() or c in "-_" for c in app_name):
frappe.throw("App name must be alphanumeric (dashes and underscores allowed)")
yaml_path = os.path.join(TEMPLATES_DIR, f"{app_name}.yaml")
if os.path.exists(yaml_path):
frappe.throw(f"App template '{app_name}' already exists")
try:
yaml.safe_load(yaml_content)
except yaml.YAMLError as e:
frappe.throw(f"Invalid YAML: {str(e)}")
with open(yaml_path, "w") as f:
f.write(yaml_content)
meta = _load_meta()
meta[app_name] = {
"name": app_name,
"description": description or f"Distrobuilder template: {app_name}",
"status": "active",
"created": frappe.utils.today(),
}
_save_meta(meta)
return {"success": True, "message": f"App template '{app_name}' created successfully"}
@frappe.whitelist()
def delete_app(app_name):
"""Delete an app template."""
_check_admin_permission()
yaml_path = os.path.join(TEMPLATES_DIR, f"{app_name}.yaml")
if not os.path.exists(yaml_path):
frappe.throw(f"App template '{app_name}' not found")
os.remove(yaml_path)
meta = _load_meta()
if app_name in meta:
del meta[app_name]
_save_meta(meta)
return {"success": True, "message": f"App template '{app_name}' deleted successfully"}
@frappe.whitelist()
def build_image(app_name, version, target_server=None, release="edge"):
"""Build image using distrobuilder and import to Incus via REST API."""
_check_admin_permission()
logs = []
template_path = os.path.join(TEMPLATES_DIR, f"{app_name}.yaml")
if not os.path.exists(template_path):
return {"success": False, "error": f"Template '{app_name}' not found", "logs": []}
# Determine target server
if not target_server:
image_server = _get_image_server()
if image_server:
target_server = image_server["name"]
else:
return {"success": False, "error": "No image server configured", "logs": []}
server_url = _get_server_url(target_server)
if not server_url:
return {"success": False, "error": f"Server '{target_server}' URL not found", "logs": []}
if not os.path.exists(CLIENT_CERT) or not os.path.exists(CLIENT_KEY):
return {"success": False, "error": "Panel certificate not configured", "logs": []}
build_dir = tempfile.mkdtemp(prefix=f"build-{app_name}-")
logs.append("=== Build Configuration ===")
logs.append(f"App: {app_name}")
logs.append(f"Version: {version or 'latest'}")
logs.append(f"Release: {release}")
logs.append(f"Target Server: {target_server}")
logs.append(f"Build directory: {build_dir}")
try:
build_template = os.path.join(build_dir, "template.yaml")
shutil.copy(template_path, build_template)
logs.append("Template copied to build directory")
logs.append("")
logs.append("=== Running distrobuilder ===")
logs.append("This may take several minutes...")
result = subprocess.run(
[
"doas",
"distrobuilder",
"build-incus",
"-o", "image.architecture=amd64",
"-o", f"image.release={release}",
"template.yaml",
],
cwd=build_dir,
capture_output=True,
text=True,
timeout=600,
)
if result.stdout:
for line in result.stdout.strip().split("\n"):
if line.strip():
logs.append(line.strip())
if result.stderr:
for line in result.stderr.strip().split("\n"):
if line.strip():
logs.append(line.strip())
if result.returncode != 0:
error_msg = result.stderr or "Unknown error"
logs.append(f"Build failed")
return {"success": False, "error": error_msg, "logs": logs}
logs.append("Build completed successfully")
# Check for output files
incus_tar = os.path.join(build_dir, "incus.tar.xz")
rootfs_squashfs = os.path.join(build_dir, "rootfs.squashfs")
if not os.path.exists(incus_tar):
logs.append("Output file not found: incus.tar.xz")
return {"success": False, "error": "Build output not found", "logs": logs}
# Import to Incus via REST API
logs.append("")
logs.append(f"=== Importing to {target_server} via API ===")
alias = f"{app_name}:{version}" if version else app_name
# Prepare multipart upload
if os.path.exists(rootfs_squashfs):
logs.append("Importing split image (metadata + rootfs)")
# For split images, use multipart upload
with open(incus_tar, "rb") as meta_file, open(rootfs_squashfs, "rb") as rootfs_file:
headers = {
"X-Incus-public": "false",
}
# Incus expects multipart with metadata.tar.xz and rootfs.squashfs
files = {
"metadata": ("incus.tar.xz", meta_file, "application/octet-stream"),
"rootfs": ("rootfs.squashfs", rootfs_file, "application/octet-stream"),
}
r = requests.post(
f"{server_url}/1.0/images",
headers=headers,
files=files,
cert=(CLIENT_CERT, CLIENT_KEY),
verify=False,
timeout=300,
)
else:
logs.append("Importing unified image")
with open(incus_tar, "rb") as f:
headers = {
"Content-Type": "application/octet-stream",
"X-Incus-public": "false",
}
r = requests.post(
f"{server_url}/1.0/images",
headers=headers,
data=f,
cert=(CLIENT_CERT, CLIENT_KEY),
verify=False,
timeout=300,
)
if r.status_code not in (200, 202):
error_msg = r.text
logs.append(f"Import failed: {error_msg}")
return {"success": False, "error": error_msg, "logs": logs}
resp = r.json()
# Wait for async operation if needed
if resp.get("type") == "async":
op_url = resp.get("operation", "")
if op_url:
logs.append("Waiting for import operation...")
wait_r = requests.get(
f"{server_url}{op_url}/wait",
cert=(CLIENT_CERT, CLIENT_KEY),
verify=False,
timeout=300,
)
wait_resp = wait_r.json()
if wait_resp.get("metadata", {}).get("status") != "Success":
error = wait_resp.get("metadata", {}).get("err", "Unknown error")
logs.append(f"Import operation failed: {error}")
return {"success": False, "error": error, "logs": logs}
# Get fingerprint from response to set alias
fingerprint = ""
op_metadata = resp.get("metadata", {})
if resp.get("type") == "async":
# For async, get fingerprint from operation metadata
if op_url:
op_r = requests.get(
f"{server_url}{op_url}",
cert=(CLIENT_CERT, CLIENT_KEY),
verify=False,
timeout=30,
)
op_resp = op_r.json()
op_meta = op_resp.get("metadata", {}).get("metadata", {})
fingerprint = op_meta.get("fingerprint", "")
else:
fingerprint = op_metadata.get("fingerprint", "")
# Set alias for the image
if fingerprint:
try:
_incus_api(server_url, "POST", "/1.0/images/aliases", {
"name": alias,
"target": fingerprint,
})
logs.append(f"Alias '{alias}' set for image {fingerprint[:12]}")
except Exception as e:
# Alias might already exist, try to update
try:
_incus_api(server_url, "PUT", f"/1.0/images/aliases/{alias}", {
"target": fingerprint,
})
logs.append(f"Alias '{alias}' updated for image {fingerprint[:12]}")
except Exception:
logs.append(f"Warning: Could not set alias '{alias}': {str(e)}")
logs.append(f"Image imported as '{alias}'")
logs.append("")
logs.append("=== Build completed successfully! ===")
return {
"success": True,
"message": f"Image '{alias}' built and imported successfully",
"logs": logs,
"alias": alias,
}
except subprocess.TimeoutExpired:
logs.append("Build timed out (exceeded 10 minutes)")
return {"success": False, "error": "Build timed out", "logs": logs}
except Exception as e:
logs.append(f"Error: {str(e)}")
frappe.log_error(f"Build failed for {app_name}: {str(e)}", "Image Build Error")
return {"success": False, "error": str(e), "logs": logs}
finally:
try:
subprocess.run(["doas", "rm", "-rf", build_dir], timeout=10, capture_output=True)
except Exception:
shutil.rmtree(build_dir, ignore_errors=True)
@frappe.whitelist()
def get_app_versions(app_name):
"""Get versions of an app from Incus images."""
_check_admin_permission()
image_server = _get_image_server()
if not image_server or not image_server.get("url"):
return []
all_images = _get_all_images(image_server["url"])
versions = _filter_images_by_prefix(all_images, app_name)
versions.sort(key=lambda x: x.get("created_at", ""), reverse=True)
formatted = []
for v in versions:
formatted.append(
{
"version": v.get("version", "latest"),
"alias": v.get("alias", ""),
"fingerprint": v.get("fingerprint", ""),
"created_at": v.get("created_at", ""),
"size": _format_size(v.get("size", 0)),
}
)
return formatted
@frappe.whitelist()
def delete_image(fingerprint, target_server=None):
"""Delete an image from Incus."""
_check_admin_permission()
if not target_server:
image_server = _get_image_server()
if image_server:
target_server = image_server["name"]
else:
return {"success": False, "error": "No image server configured"}
server_url = _get_server_url(target_server)
if not server_url:
return {"success": False, "error": f"Server '{target_server}' URL not found"}
try:
_incus_api(server_url, "DELETE", f"/1.0/images/{fingerprint}")
return {"success": True, "message": "Image deleted successfully"}
except Exception as e:
return {"success": False, "error": str(e)}
@frappe.whitelist()
def update_app_meta(app_name, description=None, status=None):
"""Update app metadata."""
_check_admin_permission()
yaml_path = os.path.join(TEMPLATES_DIR, f"{app_name}.yaml")
if not os.path.exists(yaml_path):
frappe.throw(f"App template '{app_name}' not found")
meta = _load_meta()
if app_name not in meta:
meta[app_name] = {
"name": app_name,
"created": frappe.utils.today(),
}
if description is not None:
meta[app_name]["description"] = description
if status is not None:
meta[app_name]["status"] = status
_save_meta(meta)
return {"success": True, "message": f"Metadata for '{app_name}' updated successfully"}

401
admin_panel/api/servers.py Normal file
View File

@ -0,0 +1,401 @@
import json
import os
import subprocess
import frappe
import requests
SERVERS_FILE = "/home/frappe/frappe-bench/apps/admin_panel/templates/servers.json"
CERTS_DIR = "/home/frappe/frappe-bench/apps/admin_panel/certs"
CLIENT_CERT = os.path.join(CERTS_DIR, "client.crt")
CLIENT_KEY = os.path.join(CERTS_DIR, "client.key")
def _check_admin_permission():
"""Check if user has admin permission."""
if frappe.session.user == "Guest":
frappe.throw("Please login to access this feature", frappe.AuthenticationError)
if frappe.session.user == "Administrator":
return
roles = frappe.get_roles()
if "System Manager" not in roles and "Administrator" not in roles:
frappe.throw(
"You need System Manager or Administrator role to access this feature",
frappe.PermissionError,
)
def _ensure_certs_dir():
"""Ensure certificates directory exists."""
if not os.path.exists(CERTS_DIR):
os.makedirs(CERTS_DIR, mode=0o700, exist_ok=True)
def _load_servers():
"""Load servers config from JSON file."""
if os.path.exists(SERVERS_FILE):
with open(SERVERS_FILE, "r") as f:
return json.load(f)
return {}
def _save_servers(servers):
"""Save servers config to JSON file."""
os.makedirs(os.path.dirname(SERVERS_FILE), exist_ok=True)
with open(SERVERS_FILE, "w") as f:
json.dump(servers, f, indent=2)
def _has_client_certificate():
"""Check if client certificate exists."""
return os.path.exists(CLIENT_CERT) and os.path.exists(CLIENT_KEY)
def _check_server_status(url):
"""Check if server is reachable via REST API using client certificate."""
if not _has_client_certificate():
return "no_cert"
try:
r = requests.get(
f"{url}/1.0",
cert=(CLIENT_CERT, CLIENT_KEY),
verify=False,
timeout=10,
)
return "online" if r.status_code == 200 else "offline"
except requests.exceptions.Timeout:
return "timeout"
except requests.exceptions.ConnectionError:
return "offline"
except Exception:
return "error"
@frappe.whitelist()
def get_certificate_status():
"""Get status of client certificate."""
_check_admin_permission()
has_cert = _has_client_certificate()
result = {
"has_certificate": has_cert,
"cert_path": CLIENT_CERT,
"key_path": CLIENT_KEY,
}
if has_cert:
# Get certificate info
try:
import subprocess
res = subprocess.run(
["openssl", "x509", "-in", CLIENT_CERT, "-noout", "-subject", "-dates"],
capture_output=True,
text=True,
timeout=5,
)
if res.returncode == 0:
result["cert_info"] = res.stdout.strip()
except Exception:
pass
return result
@frappe.whitelist()
def generate_client_certificate():
"""Generate a new client certificate for the admin panel."""
_check_admin_permission()
_ensure_certs_dir()
try:
# Generate EC private key
key_result = subprocess.run(
["openssl", "ecparam", "-genkey", "-name", "secp384r1", "-out", CLIENT_KEY],
capture_output=True,
text=True,
timeout=30,
)
if key_result.returncode != 0:
return {"success": False, "error": f"Failed to generate key: {key_result.stderr}"}
os.chmod(CLIENT_KEY, 0o600)
# Generate self-signed certificate (valid for 10 years)
cert_result = subprocess.run(
[
"openssl", "req", "-new", "-x509",
"-key", CLIENT_KEY,
"-out", CLIENT_CERT,
"-days", "3650",
"-subj", "/CN=admin-panel/O=Frappe Admin Panel",
],
capture_output=True,
text=True,
timeout=30,
)
if cert_result.returncode != 0:
return {"success": False, "error": f"Failed to generate certificate: {cert_result.stderr}"}
os.chmod(CLIENT_CERT, 0o644)
return {
"success": True,
"message": "Certificate generated successfully",
"cert_path": CLIENT_CERT,
}
except subprocess.TimeoutExpired:
return {"success": False, "error": "Timeout generating certificate"}
except Exception as e:
return {"success": False, "error": str(e)}
@frappe.whitelist()
def get_client_certificate():
"""Get the client certificate content for adding to Incus servers."""
_check_admin_permission()
if not _has_client_certificate():
return {"success": False, "error": "No client certificate. Please generate one first."}
try:
with open(CLIENT_CERT, "r") as f:
cert_content = f.read()
return {
"success": True,
"certificate": cert_content,
"instructions": "Add this certificate to your Incus server trust store:\n\n"
"1. Copy the certificate content and save as 'admin-panel.crt'\n"
"2. On the Incus server run: incus config trust add-certificate admin-panel.crt",
}
except Exception as e:
return {"success": False, "error": str(e)}
@frappe.whitelist()
def get_servers():
"""Get list of all configured servers."""
_check_admin_permission()
server_config = _load_servers()
servers = []
for name, config in server_config.items():
url = config.get("url", "")
# Check server status via REST API
status = _check_server_status(url) if url else "no_url"
servers.append({
"name": name,
"url": url,
"is_image_server": config.get("is_image_server", False),
"is_container_server": config.get("is_container_server", False),
"status": status,
})
return servers
@frappe.whitelist()
def add_server(name, url, is_image_server=False, is_container_server=False):
"""Add a new Incus server (uses panel's client certificate for auth)."""
_check_admin_permission()
# Validate name
if not name or not all(c.isalnum() or c in "-_" for c in name):
frappe.throw("Server name must be alphanumeric (dashes and underscores allowed)")
if name in ("local", "images"):
frappe.throw("Cannot use reserved names: local, images")
# Check if already exists
server_config = _load_servers()
if name in server_config:
frappe.throw(f"Server '{name}' already exists")
# Validate URL format
if not url or not url.startswith("https://"):
frappe.throw("Server URL must start with https://")
# Test connection if certificate exists
if _has_client_certificate():
status = _check_server_status(url)
if status == "offline":
# Don't block, just warn
frappe.msgprint(
f"Warning: Could not connect to server. Make sure to add the panel's certificate to the server's trust store.",
indicator="orange",
)
# Save server config
server_config[name] = {
"url": url,
"is_image_server": bool(is_image_server),
"is_container_server": bool(is_container_server),
}
_save_servers(server_config)
return {"success": True, "message": f"Server '{name}' added successfully"}
@frappe.whitelist()
def update_server(name, is_image_server=None, is_container_server=None):
"""Update server roles."""
_check_admin_permission()
server_config = _load_servers()
if name not in server_config:
frappe.throw(f"Server '{name}' not found")
if is_image_server is not None:
server_config[name]["is_image_server"] = bool(is_image_server)
if is_container_server is not None:
server_config[name]["is_container_server"] = bool(is_container_server)
_save_servers(server_config)
return {"success": True, "message": f"Server '{name}' updated successfully"}
@frappe.whitelist()
def remove_server(name):
"""Remove a server from configuration."""
_check_admin_permission()
server_config = _load_servers()
if name not in server_config:
frappe.throw(f"Server '{name}' not found")
del server_config[name]
_save_servers(server_config)
return {"success": True, "message": f"Server '{name}' removed successfully"}
@frappe.whitelist()
def test_connection(name):
"""Test connection to a server."""
_check_admin_permission()
server_config = _load_servers()
if name not in server_config:
return {"success": False, "error": f"Server '{name}' not found"}
url = server_config[name].get("url", "")
if not url:
return {"success": False, "error": "Server URL not configured"}
if not _has_client_certificate():
return {"success": False, "error": "No client certificate. Please generate one first."}
status = _check_server_status(url)
if status == "online":
return {"success": True, "status": "online", "message": "Connection successful"}
elif status == "timeout":
return {"success": False, "status": "timeout", "message": "Connection timeout"}
elif status == "no_cert":
return {"success": False, "status": "no_cert", "message": "Certificate not trusted by server"}
else:
return {"success": False, "status": "offline", "message": "Connection failed. Check URL and certificate trust."}
@frappe.whitelist()
def get_image_servers():
"""Get list of servers marked as image servers."""
_check_admin_permission()
servers = get_servers()
return [s for s in servers if s.get("is_image_server")]
@frappe.whitelist()
def get_container_servers():
"""Get list of servers marked as container servers."""
_check_admin_permission()
servers = get_servers()
return [s for s in servers if s.get("is_container_server")]
@frappe.whitelist()
def get_setup_status():
"""Check if distrobuilder is installed and certificate exists."""
_check_admin_permission()
status = {
"distrobuilder_installed": False,
"certificate_exists": _has_client_certificate(),
}
# Check distrobuilder
try:
result = subprocess.run(
["which", "distrobuilder"],
capture_output=True,
text=True,
timeout=5,
)
status["distrobuilder_installed"] = result.returncode == 0
except Exception:
pass
return status
@frappe.whitelist()
def run_setup(doas_password):
"""Run setup commands to install distrobuilder."""
_check_admin_permission()
if not doas_password:
return {"success": False, "error": "Password is required"}
logs = []
logs.append("=== Starting Distrobuilder Setup ===")
commands = [
(["doas", "-S", "apk", "add", "distrobuilder"], "Installing distrobuilder"),
]
for cmd, description in commands:
logs.append(f"\n{description}...")
try:
result = subprocess.run(
cmd,
input=f"{doas_password}\n",
capture_output=True,
text=True,
timeout=120,
)
if result.stdout:
logs.append(result.stdout.strip())
if result.returncode != 0:
error = result.stderr or "Unknown error"
if "already" in error.lower() or "exists" in error.lower():
logs.append(f" (already done)")
else:
logs.append(f"Failed: {error}")
return {"success": False, "error": error, "logs": logs}
else:
logs.append(f"{description} - Done")
except subprocess.TimeoutExpired:
logs.append(f"Timeout: {description}")
return {"success": False, "error": f"Timeout during: {description}", "logs": logs}
except Exception as e:
logs.append(f"Error: {str(e)}")
return {"success": False, "error": str(e), "logs": logs}
logs.append("\n=== Setup completed successfully! ===")
return {"success": True, "message": "Setup completed successfully", "logs": logs}

849
admin_panel/api/sites.py Normal file
View File

@ -0,0 +1,849 @@
import json
import os
import time
import frappe
import requests
import urllib3
# Suppress SSL warnings
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
SERVERS_FILE = "/home/frappe/frappe-bench/apps/admin_panel/templates/servers.json"
CERTS_DIR = "/home/frappe/frappe-bench/apps/admin_panel/certs"
CLIENT_CERT = os.path.join(CERTS_DIR, "client.crt")
CLIENT_KEY = os.path.join(CERTS_DIR, "client.key")
DEFAULT_VOLUMES = [
{"name": "mysql", "source_path": "/var/lib/mysql", "device_name": "mysql"},
{"name": "frontend", "source_path": "/home/frappe/frappe-bench/sites/frontend", "device_name": "frontend"},
]
def _check_admin_permission():
"""Check if user has admin permission."""
if frappe.session.user == "Guest":
frappe.throw("Please login to access this feature", frappe.AuthenticationError)
if frappe.session.user == "Administrator":
return
roles = frappe.get_roles()
if "System Manager" not in roles and "Administrator" not in roles:
frappe.throw("You need System Manager or Administrator role", frappe.PermissionError)
def _load_servers():
"""Load servers config from JSON file."""
if os.path.exists(SERVERS_FILE):
with open(SERVERS_FILE, "r") as f:
return json.load(f)
return {}
def _get_server_config(server_name):
"""Get server URL and client certificate paths."""
servers = _load_servers()
server = servers.get(server_name, {})
url = server.get("url", "")
return {
"url": url,
"cert": CLIENT_CERT if os.path.exists(CLIENT_CERT) else None,
"key": CLIENT_KEY if os.path.exists(CLIENT_KEY) else None,
}
def _incus_api(server_name, method, path, data=None, timeout=30):
"""Make REST API request to a specific Incus server."""
config = _get_server_config(server_name)
if not config["url"]:
raise Exception(f"Server '{server_name}' URL not found")
if not config["cert"] or not config["key"]:
raise Exception("Panel certificate not configured. Please generate a certificate in Servers tab.")
url = f"{config['url']}{path}"
try:
r = requests.request(
method,
url,
headers={"Content-Type": "application/json"},
cert=(config["cert"], config["key"]),
verify=False,
json=data,
timeout=timeout,
)
r.raise_for_status()
return r.json()
except requests.exceptions.ConnectionError:
raise Exception(f"Cannot connect to server '{server_name}' at {config['url']}")
except requests.exceptions.Timeout:
raise Exception(f"Connection to '{server_name}' timed out")
except requests.exceptions.HTTPError as e:
raise Exception(f"Server '{server_name}' error: {e.response.status_code}")
def _wait_for_operation(server_name, operation_url, timeout=60):
"""Wait for an Incus async operation to complete."""
if not operation_url:
return
wait_path = f"{operation_url}/wait"
try:
result = _incus_api(server_name, "GET", f"{wait_path}?timeout={timeout}")
return result
except Exception:
return None
def _extract_ip(state_data):
"""Extract IPv4 address from Incus state network data."""
network = state_data.get("metadata", {}).get("network", {})
for iface_name, iface in network.items():
if iface_name == "lo":
continue
for addr in iface.get("addresses", []):
if addr.get("family") == "inet" and addr.get("scope") == "global":
return addr.get("address", "")
return ""
def _get_cloud_containers():
"""Get data from Cloud Container doctype if available."""
try:
containers = frappe.get_all(
"Cloud Container",
fields=["name", "container_name", "image", "owner", "ip_address", "status"],
)
return {c.get("container_name", c.get("name")): c for c in containers}
except Exception:
return {}
@frappe.whitelist()
def get_images(target_server=None):
"""Get available images from a specific Incus server."""
_check_admin_permission()
if not target_server:
return {"error": "Server name is required"}
try:
resp = _incus_api(target_server, "GET", "/1.0/images?recursion=1")
except Exception as e:
return {"error": str(e)}
images = []
for img in resp.get("metadata", []):
aliases = img.get("aliases", [])
props = img.get("properties", {})
fingerprint = img.get("fingerprint", "")
alias = aliases[0]["name"] if aliases else ""
description = props.get("description", "") or alias or fingerprint[:12]
images.append({
"alias": alias,
"description": description,
"os": props.get("os", ""),
"architecture": img.get("architecture", ""),
"size": img.get("size", 0),
"fingerprint": fingerprint,
"created_at": img.get("created_at", ""),
})
return images
@frappe.whitelist()
def get_all_containers(target_server=None):
"""Get list of all containers from a specific Incus server."""
_check_admin_permission()
if not target_server:
return {"error": "Server name is required"}
try:
resp = _incus_api(target_server, "GET", "/1.0/instances?recursion=1")
except Exception as e:
return {"error": str(e)}
cloud_data = _get_cloud_containers()
containers = []
for inst in resp.get("metadata", []):
name = inst.get("name", "")
status = inst.get("status", "Unknown")
config = inst.get("config", {})
created_at = inst.get("created_at", "")
architecture = inst.get("architecture", "")
container_type = inst.get("type", "container")
# Get IP from state if running
ip_address = ""
state = inst.get("state", {})
if state and status == "Running":
network = state.get("network", {})
for iface_name, iface in network.items():
if iface_name == "lo":
continue
for addr in iface.get("addresses", []):
if addr.get("family") == "inet" and addr.get("scope") == "global":
ip_address = addr.get("address", "")
break
if ip_address:
break
image = config.get("image.description", config.get("image.os", ""))
cloud = cloud_data.get(name, {})
containers.append({
"name": name,
"container_name": name,
"status": status,
"ip_address": ip_address or cloud.get("ip_address", ""),
"image": image or cloud.get("image", ""),
"owner": cloud.get("owner", ""),
"created": created_at[:10] if created_at else "",
"architecture": architecture,
"type": container_type,
"server": target_server,
})
return containers
@frappe.whitelist()
def get_container_detail(container_name, target_server=None):
"""Get detailed container info including resource usage."""
_check_admin_permission()
if not target_server:
return {"error": "Server name is required"}
try:
detail = _incus_api(target_server, "GET", f"/1.0/instances/{container_name}")
except Exception as e:
return {"error": str(e), "name": container_name, "status": "Error"}
meta = detail.get("metadata", {})
config = meta.get("config", {})
status = meta.get("status", "Unknown")
created_at = meta.get("created_at", "")
result = {
"name": container_name,
"container_name": container_name,
"status": status,
"ip_address": "",
"image": config.get("image.description", config.get("image.os", "")),
"owner": "",
"created": created_at[:10] if created_at else "",
"architecture": meta.get("architecture", ""),
"type": meta.get("type", "container"),
"profiles": meta.get("profiles", []),
"cpu_usage": 0,
"ram_usage": 0,
"disk_usage": 0,
"memory_usage_bytes": 0,
"memory_total_bytes": 0,
"disk_usage_bytes": 0,
"disk_total_bytes": 0,
"network_rx": 0,
"network_tx": 0,
"connections": 0,
"uptime": "-",
"server": target_server,
}
# Enrich with Cloud Container data
cloud_data = _get_cloud_containers()
cloud = cloud_data.get(container_name, {})
if cloud:
result["owner"] = cloud.get("owner", "")
if not result["image"]:
result["image"] = cloud.get("image", "")
# Get state if running
if status == "Running":
try:
state = _incus_api(target_server, "GET", f"/1.0/instances/{container_name}/state")
state_meta = state.get("metadata", {})
result["ip_address"] = _extract_ip(state)
if not result["ip_address"] and cloud:
result["ip_address"] = cloud.get("ip_address", "")
# CPU
cpu_data = state_meta.get("cpu", {})
cpu_usage_ns = cpu_data.get("usage", 0)
cpu_seconds = cpu_usage_ns / 1_000_000_000
result["cpu_usage"] = min(round(cpu_seconds % 100, 1), 100)
# Memory
mem_data = state_meta.get("memory", {})
mem_usage = mem_data.get("usage", 0)
mem_total = mem_data.get("total", 0)
result["memory_usage_bytes"] = mem_usage
result["memory_total_bytes"] = mem_total
if mem_total > 0:
result["ram_usage"] = round((mem_usage / mem_total) * 100, 1)
# Disk
disk_data = state_meta.get("disk", {})
root_disk = disk_data.get("root", {})
disk_usage = root_disk.get("usage", 0)
disk_total = root_disk.get("total", 0)
result["disk_usage_bytes"] = disk_usage
result["disk_total_bytes"] = disk_total
if disk_total > 0:
result["disk_usage"] = round((disk_usage / disk_total) * 100, 1)
# Network
network = state_meta.get("network", {})
for iface_name, iface in network.items():
if iface_name == "lo":
continue
counters = iface.get("counters", {})
result["network_rx"] += counters.get("bytes_received", 0)
result["network_tx"] += counters.get("bytes_sent", 0)
result["connections"] = state_meta.get("processes", 0)
except Exception:
pass
else:
if cloud:
result["ip_address"] = cloud.get("ip_address", "")
return result
@frappe.whitelist()
def start_container(container_name, target_server=None):
"""Start a container."""
_check_admin_permission()
if not target_server:
frappe.throw("Server name is required")
resp = _incus_api(target_server, "PUT", f"/1.0/instances/{container_name}/state", {
"action": "start",
"timeout": 30,
})
op = resp.get("operation")
if op:
_wait_for_operation(target_server, op)
return {"status": "ok", "message": f"Container {container_name} started"}
@frappe.whitelist()
def stop_container(container_name, target_server=None):
"""Stop a container."""
_check_admin_permission()
if not target_server:
frappe.throw("Server name is required")
resp = _incus_api(target_server, "PUT", f"/1.0/instances/{container_name}/state", {
"action": "stop",
"timeout": 30,
})
op = resp.get("operation")
if op:
_wait_for_operation(target_server, op)
return {"status": "ok", "message": f"Container {container_name} stopped"}
@frappe.whitelist()
def restart_container(container_name, target_server=None):
"""Restart a container."""
_check_admin_permission()
if not target_server:
frappe.throw("Server name is required")
resp = _incus_api(target_server, "PUT", f"/1.0/instances/{container_name}/state", {
"action": "restart",
"timeout": 30,
})
op = resp.get("operation")
if op:
_wait_for_operation(target_server, op)
return {"status": "ok", "message": f"Container {container_name} restarted"}
@frappe.whitelist()
def create_container(container_name, image="alpine", container_type="container", target_server=None):
"""Create a new container."""
_check_admin_permission()
if not target_server:
frappe.throw("Server name is required")
payload = {
"name": container_name,
"type": container_type,
"source": {
"type": "image",
"alias": image,
},
}
resp = _incus_api(target_server, "POST", "/1.0/instances", payload)
op = resp.get("operation")
if op:
_wait_for_operation(target_server, op, timeout=120)
# Auto-start the container
try:
start_resp = _incus_api(target_server, "PUT", f"/1.0/instances/{container_name}/state", {
"action": "start",
"timeout": 30,
})
op = start_resp.get("operation")
if op:
_wait_for_operation(target_server, op)
except Exception:
pass
return {"status": "ok", "message": f"Container {container_name} created on {target_server}"}
@frappe.whitelist()
def delete_container(container_name, target_server=None):
"""Delete a container."""
_check_admin_permission()
if not target_server:
frappe.throw("Server name is required")
# Stop first if running
try:
detail = _incus_api(target_server, "GET", f"/1.0/instances/{container_name}")
if detail.get("metadata", {}).get("status") == "Running":
stop_resp = _incus_api(target_server, "PUT", f"/1.0/instances/{container_name}/state", {
"action": "stop",
"timeout": 30,
"force": True,
})
op = stop_resp.get("operation")
if op:
_wait_for_operation(target_server, op)
except Exception:
pass
resp = _incus_api(target_server, "DELETE", f"/1.0/instances/{container_name}")
op = resp.get("operation")
if op:
_wait_for_operation(target_server, op)
return {"status": "ok", "message": f"Container {container_name} deleted"}
@frappe.whitelist()
def get_container_logs(container_name, target_server=None):
"""Get container logs."""
_check_admin_permission()
if not target_server:
return {"error": "Server name is required"}
config = _get_server_config(target_server)
try:
resp = _incus_api(target_server, "GET", f"/1.0/instances/{container_name}/logs")
log_urls = resp.get("metadata", [])
logs = []
for log_url in log_urls:
log_name = log_url.rsplit("/", 1)[-1]
if not log_name.endswith(".log"):
continue
try:
r = requests.get(
f"{config['url']}/1.0/instances/{container_name}/logs/{log_name}",
cert=(config["cert"], config["key"]),
verify=False,
timeout=30,
)
r.raise_for_status()
content = r.text
for line in content.strip().split("\n")[-100:]:
if not line.strip():
continue
level = "info"
if "error" in line.lower() or "fail" in line.lower():
level = "error"
elif "warn" in line.lower():
level = "warning"
logs.append({
"source": log_name,
"level": level,
"message": line.strip(),
"timestamp": "",
})
except Exception:
continue
return logs
except Exception as e:
return [{"source": "system", "level": "error", "message": str(e), "timestamp": ""}]
@frappe.whitelist()
def get_snapshots(container_name, target_server=None):
"""Get list of snapshots for a container."""
_check_admin_permission()
if not target_server:
return []
try:
resp = _incus_api(target_server, "GET", f"/1.0/instances/{container_name}/snapshots?recursion=1")
snapshots = []
for snap in resp.get("metadata", []):
snapshots.append({
"name": snap.get("name", ""),
"created_at": snap.get("created_at", ""),
"stateful": snap.get("stateful", False),
"size": snap.get("size", 0),
})
snapshots.sort(key=lambda s: s["created_at"], reverse=True)
return snapshots
except Exception:
return []
@frappe.whitelist()
def create_snapshot(container_name, snapshot_name, stateful=False, target_server=None):
"""Create a snapshot of a container."""
_check_admin_permission()
if not target_server:
frappe.throw("Server name is required")
if isinstance(stateful, str):
stateful = stateful.lower() in ("true", "1")
resp = _incus_api(target_server, "POST", f"/1.0/instances/{container_name}/snapshots", {
"name": snapshot_name,
"stateful": stateful,
})
op = resp.get("operation")
if op:
_wait_for_operation(target_server, op, timeout=120)
return {"status": "ok", "message": f"Snapshot {snapshot_name} created"}
@frappe.whitelist()
def restore_snapshot(container_name, snapshot_name, target_server=None):
"""Restore a container from a snapshot."""
_check_admin_permission()
if not target_server:
frappe.throw("Server name is required")
resp = _incus_api(target_server, "PUT", f"/1.0/instances/{container_name}", {
"restore": snapshot_name,
})
op = resp.get("operation")
if op:
_wait_for_operation(target_server, op, timeout=120)
return {"status": "ok", "message": f"Container restored from snapshot {snapshot_name}"}
@frappe.whitelist()
def delete_snapshot(container_name, snapshot_name, target_server=None):
"""Delete a snapshot."""
_check_admin_permission()
if not target_server:
frappe.throw("Server name is required")
resp = _incus_api(target_server, "DELETE", f"/1.0/instances/{container_name}/snapshots/{snapshot_name}")
op = resp.get("operation")
if op:
_wait_for_operation(target_server, op)
return {"status": "ok", "message": f"Snapshot {snapshot_name} deleted"}
@frappe.whitelist()
def rebuild_container(container_name, image, target_server=None):
"""Rebuild a container with a new image."""
_check_admin_permission()
if not target_server:
frappe.throw("Server name is required")
logs = []
logs.append(f"=== Rebuild Container ===")
logs.append(f"Container: {container_name}")
logs.append(f"New image: {image}")
logs.append(f"Server: {target_server}")
try:
detail = _incus_api(target_server, "GET", f"/1.0/instances/{container_name}")
meta = detail.get("metadata", {})
was_running = meta.get("status") == "Running"
logs.append(f"Current status: {meta.get('status')}")
if was_running:
logs.append("Stopping container...")
resp = _incus_api(target_server, "PUT", f"/1.0/instances/{container_name}/state", {
"action": "stop",
"timeout": 30,
"force": True,
})
op = resp.get("operation")
if op:
_wait_for_operation(target_server, op)
logs.append("✓ Container stopped")
logs.append(f"Rebuilding with image '{image}'...")
resp = _incus_api(target_server, "POST", f"/1.0/instances/{container_name}/rebuild", {
"source": {
"type": "image",
"alias": image,
},
})
op = resp.get("operation")
if op:
_wait_for_operation(target_server, op, timeout=300)
logs.append("✓ Container rebuilt")
if was_running:
logs.append("Starting container...")
resp = _incus_api(target_server, "PUT", f"/1.0/instances/{container_name}/state", {
"action": "start",
"timeout": 30,
})
op = resp.get("operation")
if op:
_wait_for_operation(target_server, op)
logs.append("✓ Container started")
logs.append("\n=== Rebuild completed successfully! ===")
return {
"success": True,
"container_name": container_name,
"image": image,
"logs": logs,
"message": f"Container '{container_name}' rebuilt with image '{image}'",
}
except Exception as e:
logs.append(f"✗ Error: {str(e)}")
return {
"success": False,
"error": str(e),
"logs": logs,
"message": f"Rebuild failed: {str(e)}",
}
@frappe.whitelist()
def deploy_container_full(container_name, image="erp", pool_name="default", volumes_config=None, target_server=None):
"""Full container deploy with volumes."""
_check_admin_permission()
if not target_server:
frappe.throw("Server name is required")
if isinstance(volumes_config, str):
volumes_config = json.loads(volumes_config)
if not volumes_config:
volumes_config = DEFAULT_VOLUMES
logs = []
current_step = 0
tmp_mount_path = "/mnt/tmp"
volumes_info = []
for vol_config in volumes_config:
volumes_info.append({
"volume_name": f"{container_name}-{vol_config['name']}",
"source_path": vol_config["source_path"],
"device_name": vol_config["device_name"],
"temp_device_name": f"tmp_{vol_config['name']}",
})
logs.append(f"=== Deploy Configuration ===")
logs.append(f"Container: {container_name}")
logs.append(f"Image: {image}")
logs.append(f"Server: {target_server}")
logs.append(f"Volumes: {len(volumes_info)}")
try:
# Step 1: Create container
current_step = 1
logs.append("Step 1: Creating container...")
payload = {
"name": container_name,
"type": "container",
"source": {"type": "image", "alias": image},
}
resp = _incus_api(target_server, "POST", "/1.0/instances", payload)
op = resp.get("operation")
if op:
_wait_for_operation(target_server, op, timeout=120)
logs.append(f"✓ Container '{container_name}' created")
# Step 2: Start container
current_step = 2
logs.append("Step 2: Starting container...")
resp = _incus_api(target_server, "PUT", f"/1.0/instances/{container_name}/state", {
"action": "start", "timeout": 30,
})
op = resp.get("operation")
if op:
_wait_for_operation(target_server, op)
time.sleep(3)
logs.append(f"✓ Container '{container_name}' started")
# Step 3: Create volumes
current_step = 3
logs.append(f"Step 3: Creating {len(volumes_info)} volume(s)...")
for vol_info in volumes_info:
try:
_incus_api(target_server, "POST", f"/1.0/storage-pools/{pool_name}/volumes", {
"name": vol_info["volume_name"],
"type": "custom",
"config": {},
})
logs.append(f" ✓ Volume '{vol_info['volume_name']}' created")
except Exception as e:
if "409" in str(e):
logs.append(f" - Volume '{vol_info['volume_name']}' already exists")
else:
raise
# Step 4: Temporarily mount volumes
current_step = 4
logs.append(f"Step 4: Mounting volumes temporarily...")
for i, vol_info in enumerate(volumes_info):
temp_path = f"{tmp_mount_path}{i+1}"
vol_info["temp_path"] = temp_path
temp_device_config = {
"type": "disk",
"pool": pool_name,
"source": vol_info["volume_name"],
"path": temp_path,
}
resp = _incus_api(target_server, "PATCH", f"/1.0/instances/{container_name}", {
"devices": {vol_info["temp_device_name"]: temp_device_config},
})
op = resp.get("operation")
if op:
_wait_for_operation(target_server, op)
logs.append(f" ✓ Mounted at '{temp_path}'")
time.sleep(2)
# Step 5: Copy data
current_step = 5
logs.append(f"Step 5: Copying data to volumes...")
for vol_info in volumes_info:
exec_payload = {
"command": ["cp", "-a", f"{vol_info['source_path']}/.", f"{vol_info['temp_path']}/"],
"wait-for-websocket": False,
"interactive": False,
}
try:
resp = _incus_api(target_server, "POST", f"/1.0/instances/{container_name}/exec", exec_payload)
op = resp.get("operation")
if op:
_wait_for_operation(target_server, op, timeout=120)
logs.append(f" ✓ Copied {vol_info['source_path']}")
except Exception as e:
logs.append(f" - Warning copying {vol_info['source_path']}: {str(e)}")
# Step 6: Unmount temporary volumes
current_step = 6
logs.append(f"Step 6: Unmounting temporary volumes...")
current = _incus_api(target_server, "GET", f"/1.0/instances/{container_name}")
current_meta = current.get("metadata", {})
devices = current_meta.get("devices", {})
for vol_info in volumes_info:
devices.pop(vol_info["temp_device_name"], None)
resp = _incus_api(target_server, "PUT", f"/1.0/instances/{container_name}", {
"devices": devices,
"config": current_meta.get("config", {}),
"profiles": current_meta.get("profiles", []),
})
op = resp.get("operation")
if op:
_wait_for_operation(target_server, op)
logs.append(f"✓ Temporary mounts removed")
# Step 7: Stop container
current_step = 7
logs.append("Step 7: Stopping container...")
resp = _incus_api(target_server, "PUT", f"/1.0/instances/{container_name}/state", {
"action": "stop", "timeout": 30,
})
op = resp.get("operation")
if op:
_wait_for_operation(target_server, op)
logs.append(f"✓ Container stopped")
# Step 8: Mount volumes permanently
current_step = 8
logs.append(f"Step 8: Mounting volumes permanently...")
for vol_info in volumes_info:
permanent_device_config = {
"type": "disk",
"pool": pool_name,
"source": vol_info["volume_name"],
"path": vol_info["source_path"],
}
resp = _incus_api(target_server, "PATCH", f"/1.0/instances/{container_name}", {
"devices": {vol_info["device_name"]: permanent_device_config},
})
op = resp.get("operation")
if op:
_wait_for_operation(target_server, op)
logs.append(f"{vol_info['volume_name']}{vol_info['source_path']}")
# Step 9: Start container
current_step = 9
logs.append("Step 9: Starting container (final)...")
resp = _incus_api(target_server, "PUT", f"/1.0/instances/{container_name}/state", {
"action": "start", "timeout": 30,
})
op = resp.get("operation")
if op:
_wait_for_operation(target_server, op)
logs.append(f"✓ Container '{container_name}' started")
logs.append("\n=== Deploy completed successfully! ===")
return {
"success": True,
"container_name": container_name,
"volumes": [v["volume_name"] for v in volumes_info],
"logs": logs,
"message": f"Container '{container_name}' deployed successfully",
}
except Exception as e:
logs.append(f"✗ Error at step {current_step}: {str(e)}")
return {
"success": False,
"step": current_step,
"error": str(e),
"logs": logs,
"message": f"Deploy failed at step {current_step}: {str(e)}",
}

View File

@ -5,6 +5,13 @@ app_description = "Admin Panel"
app_email = "ibo131205@gmail.com" app_email = "ibo131205@gmail.com"
app_license = "mit" app_license = "mit"
# Website Route Rules for SPA
# ------------------
website_route_rules = [
{"from_route": "/admin/<path:app_path>", "to_route": "admin"},
]
# Apps # Apps
# ------------------ # ------------------

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
import{o as a,c as r,b as e,t as i,M as s}from"./index-96d8d3bd.js";const n={class:"rounded-lg border p-4 shadow-sm"},c={class:"flex items-center justify-between mb-4"},d={class:"text-base font-medium text-ink-gray-9"},l={class:"h-[200px]"},p={__name:"ChartCard",props:{title:{type:String,required:!0}},setup(o){return(t,_)=>(a(),r("div",n,[e("div",c,[e("h3",d,i(o.title),1),s(t.$slots,"actions")]),e("div",l,[s(t.$slots,"default")])]))}};export{p as _};
//# sourceMappingURL=ChartCard-02dda296.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"ChartCard-02dda296.js","sources":["../../../../frontend/src/components/Common/ChartCard.vue"],"sourcesContent":["<template>\n <div class=\"rounded-lg border p-4 shadow-sm\">\n <div class=\"flex items-center justify-between mb-4\">\n <h3 class=\"text-base font-medium text-ink-gray-9\">\n {{ title }}\n </h3>\n <slot name=\"actions\" />\n </div>\n <div class=\"h-[200px]\">\n <slot />\n </div>\n </div>\n</template>\n\n<script setup>\ndefineProps({\n title: {\n type: String,\n required: true,\n },\n})\n</script>\n"],"names":["_openBlock","_createElementBlock","_hoisted_1","_createElementVNode","_hoisted_2","_hoisted_3","_toDisplayString","__props","_renderSlot","_ctx","_hoisted_4"],"mappings":"sUACEA,EAAA,EAAAC,EAUM,MAVNC,EAUM,CATJC,EAKM,MALNC,EAKM,CAJJD,EAEK,KAFLE,EAEKC,EADAC,EAAK,KAAA,EAAA,CAAA,EAEVC,EAAuBC,EAAA,OAAA,SAAA,IAEzBN,EAEM,MAFNO,EAEM,CADJF,EAAQC,EAAA,OAAA,SAAA"}

View File

@ -0,0 +1,2 @@
import{_ as g}from"./LayoutHeader-a0707290.js";import{c as v}from"./payments-4b7e79ca.js";import{n as k,o as c,c as m,d as n,g as r,b as t,t as s,p as w,q as $,e as i,_ as C,F as d,k as p,j as b,G as V,r as x}from"./index-96d8d3bd.js";const F={class:"flex flex-col h-full"},B={class:"flex-1 overflow-auto p-5"},L={class:"rounded-lg border overflow-hidden"},N={class:"min-w-full divide-y"},U={class:"bg-surface-gray-2"},j={class:"px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase"},q={class:"px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase"},A={class:"px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase"},D={class:"px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase"},E={class:"px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase"},I={class:"px-6 py-3 text-right text-xs font-medium text-ink-gray-5 uppercase"},R={class:"divide-y"},S={class:"px-6 py-4"},z={class:"flex items-center gap-3"},G={class:"w-8 h-8 rounded-full bg-surface-gray-2 flex items-center justify-center"},Q={class:"text-sm font-medium text-ink-gray-5"},T={class:"text-sm font-medium text-ink-gray-9"},H={class:"px-6 py-4 text-sm text-ink-gray-7"},J={class:"px-6 py-4 text-sm text-ink-gray-7"},K={class:"px-6 py-4 text-sm font-medium text-ink-gray-9"},M={class:"px-6 py-4"},O={class:"px-6 py-4 text-sm text-ink-gray-7"},P={class:"px-6 py-4 text-right"},et={__name:"Customers",setup(W){const l=x(""),u=x(v),_=k(()=>{if(!l.value)return u.value;const e=l.value.toLowerCase();return u.value.filter(o=>o.name.toLowerCase().includes(e)||o.email.toLowerCase().includes(e))});function f(e){return new Intl.NumberFormat("ru-RU",{style:"currency",currency:"RUB",minimumFractionDigits:0}).format(e)}function y(){console.log("Exporting customers...")}function h(e){console.log("Viewing customer:",e.name)}return(e,o)=>(c(),m("div",F,[n(g,{title:e.$t("payments.customers")},{right:r(()=>[n(i(C),{modelValue:l.value,"onUpdate:modelValue":o[0]||(o[0]=a=>l.value=a),type:"text",placeholder:e.$t("common.search"),class:"w-64"},{prefix:r(()=>[n(i(d),{name:"search",class:"h-4 w-4 text-ink-gray-4"})]),_:1},8,["modelValue","placeholder"]),n(i(p),{variant:"subtle",onClick:y},{prefix:r(()=>[n(i(d),{name:"download",class:"h-4 w-4"})]),default:r(()=>[b(" "+s(e.$t("common.export")),1)]),_:1})]),_:1},8,["title"]),t("div",B,[t("div",L,[t("table",N,[t("thead",U,[t("tr",null,[t("th",j,s(e.$t("payments.customer.name")),1),t("th",q,s(e.$t("payments.customer.email")),1),t("th",A,s(e.$t("payments.connections")),1),t("th",D,s(e.$t("payments.customer.amount")),1),t("th",E,s(e.$t("payments.customer.status")),1),o[1]||(o[1]=t("th",{class:"px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase"},"Since",-1)),t("th",I,s(e.$t("common.actions")),1)])]),t("tbody",R,[(c(!0),m(w,null,$(_.value,a=>(c(),m("tr",{key:a.id},[t("td",S,[t("div",z,[t("div",G,[t("span",Q,s(a.name.charAt(0)),1)]),t("span",T,s(a.name),1)])]),t("td",H,s(a.email),1),t("td",J,s(a.connections),1),t("td",K,s(f(a.monthlyAmount))+"/mo ",1),t("td",M,[n(i(V),{variant:a.status==="active"?"success":"info",label:a.status},null,8,["variant","label"])]),t("td",O,s(a.since),1),t("td",P,[n(i(p),{variant:"ghost",size:"sm",onClick:X=>h(a)},{default:r(()=>[n(i(d),{name:"eye",class:"h-4 w-4"})]),_:1},8,["onClick"])])]))),128))])])])])]))}};export{et as default};
//# sourceMappingURL=Customers-6537bebb.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
@keyframes dropdown-in-f44ca474{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}@keyframes dropdown-out-f44ca474{0%{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(.95)}}.dropdown-content[data-state=open]{animation:dropdown-in-f44ca474 .1s ease-out}.dropdown-content[data-state=closed]{animation:dropdown-out-f44ca474 75ms ease-in}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
import{o as t,c as s,b as n,d as r,e as c,F as o,t as a,i as l,M as d}from"./index-96d8d3bd.js";const m={class:"flex flex-col items-center justify-center py-12 text-center"},u={class:"mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-surface-gray-2"},f={class:"mb-2 text-lg font-medium text-ink-gray-9"},y={key:0,class:"mb-4 max-w-sm text-sm text-ink-gray-5"},p={__name:"EmptyState",props:{icon:{type:String,default:"inbox"},title:{type:String,required:!0},description:{type:String,default:""}},setup(e){return(i,x)=>(t(),s("div",m,[n("div",u,[r(c(o),{name:e.icon,class:"h-8 w-8 text-ink-gray-4"},null,8,["name"])]),n("h3",f,a(e.title),1),e.description?(t(),s("p",y,a(e.description),1)):l("",!0),d(i.$slots,"action")]))}};export{p as _};
//# sourceMappingURL=EmptyState-060c0be5.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"EmptyState-060c0be5.js","sources":["../../../../frontend/src/components/Common/EmptyState.vue"],"sourcesContent":["<template>\n <div class=\"flex flex-col items-center justify-center py-12 text-center\">\n <div\n class=\"mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-surface-gray-2\"\n >\n <FeatherIcon :name=\"icon\" class=\"h-8 w-8 text-ink-gray-4\" />\n </div>\n <h3 class=\"mb-2 text-lg font-medium text-ink-gray-9\">\n {{ title }}\n </h3>\n <p v-if=\"description\" class=\"mb-4 max-w-sm text-sm text-ink-gray-5\">\n {{ description }}\n </p>\n <slot name=\"action\" />\n </div>\n</template>\n\n<script setup>\nimport { FeatherIcon } from 'frappe-ui'\n\ndefineProps({\n icon: {\n type: String,\n default: 'inbox',\n },\n title: {\n type: String,\n required: true,\n },\n description: {\n type: String,\n default: '',\n },\n})\n</script>\n"],"names":["_openBlock","_createElementBlock","_hoisted_1","_createElementVNode","_hoisted_2","_createVNode","_unref","FeatherIcon","__props","_hoisted_3","_toDisplayString","_hoisted_4","_renderSlot","_ctx"],"mappings":"ohBACEA,EAAA,EAAAC,EAaM,MAbNC,EAaM,CAZJC,EAIM,MAJNC,EAIM,CADJC,EAA4DC,EAAAC,CAAA,EAAA,CAA9C,KAAMC,EAAI,KAAE,MAAM,8CAElCL,EAEK,KAFLM,EAEKC,EADAF,EAAK,KAAA,EAAA,CAAA,EAEDA,EAAW,iBAApBP,EAEI,IAFJU,EAEID,EADCF,EAAW,WAAA,EAAA,CAAA,YAEhBI,EAAsBC,EAAA,OAAA,QAAA"}

View File

@ -0,0 +1,2 @@
import{o as a,c as r,b as e,M as s,t as i}from"./index-96d8d3bd.js";const c={class:"flex items-center justify-between border-b bg-surface-white px-5 py-2.5"},l={class:"flex items-center gap-2"},n={class:"text-xl font-semibold text-ink-gray-9"},d={class:"flex items-center gap-2"},f={__name:"LayoutHeader",props:{title:{type:String,default:""}},setup(o){return(t,_)=>(a(),r("header",c,[e("div",l,[s(t.$slots,"left",{},()=>[e("h1",n,i(o.title),1)])]),e("div",d,[s(t.$slots,"right")])]))}};export{f as _};
//# sourceMappingURL=LayoutHeader-a0707290.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"LayoutHeader-a0707290.js","sources":["../../../../frontend/src/components/Common/LayoutHeader.vue"],"sourcesContent":["<template>\n <header class=\"flex items-center justify-between border-b bg-surface-white px-5 py-2.5\">\n <div class=\"flex items-center gap-2\">\n <slot name=\"left\">\n <h1 class=\"text-xl font-semibold text-ink-gray-9\">\n {{ title }}\n </h1>\n </slot>\n </div>\n <div class=\"flex items-center gap-2\">\n <slot name=\"right\" />\n </div>\n </header>\n</template>\n\n<script setup>\ndefineProps({\n title: {\n type: String,\n default: '',\n },\n})\n</script>\n"],"names":["_openBlock","_createElementBlock","_hoisted_1","_createElementVNode","_hoisted_2","_renderSlot","_ctx","_hoisted_3","_toDisplayString","__props","_hoisted_4"],"mappings":"+WACEA,EAAA,EAAAC,EAWS,SAXTC,EAWS,CAVPC,EAMM,MANNC,EAMM,CALJC,EAIOC,mBAJP,IAIO,CAHLH,EAEK,KAFLI,EAEKC,EADAC,EAAK,KAAA,EAAA,CAAA,MAIdN,EAEM,MAFNO,EAEM,CADJL,EAAqBC,EAAA,OAAA,OAAA"}

View File

@ -0,0 +1,2 @@
import{u as b,a as _,o as f,c as y,b as e,d as r,t as a,w as x,e as n,f as w,v as k,g as V,h as $,i as B,r as o,A as L,_ as p,j as M,k as N,l as j,m as q,s as A}from"./index-96d8d3bd.js";const C={class:"min-h-screen flex items-center justify-center bg-surface-gray-2 py-12 px-4 sm:px-6 lg:px-8"},U={class:"max-w-md w-full space-y-8"},D={class:"text-center"},P={class:"mt-2 text-sm text-ink-gray-5"},R={class:"space-y-4"},S={for:"email",class:"block text-sm font-medium text-ink-gray-7"},E={for:"password",class:"block text-sm font-medium text-ink-gray-7"},T={class:"flex items-center justify-between"},z={class:"flex items-center"},F={for:"remember-me",class:"ml-2 block text-sm text-ink-gray-7"},G={class:"text-sm"},H={href:"#",class:"font-medium text-blue-600 hover:text-blue-500"},K={__name:"Login",setup(I){const h=b(),v=_(),d=o(""),u=o(""),c=o(!1),m=o(!1),i=o("");async function g(){m.value=!0,i.value="";try{await q("login",{usr:d.value,pwd:u.value}),await A.init();const s=v.query.redirect||"/dashboard";h.push(s)}catch(s){i.value=s.message||"Login failed"}finally{m.value=!1}}return(s,t)=>(f(),y("div",C,[e("div",U,[e("div",D,[r(L,{class:"mx-auto h-16 w-16"}),t[3]||(t[3]=e("h2",{class:"mt-6 text-3xl font-bold text-ink-gray-9"}," Admin Panel ",-1)),e("p",P,a(s.$t("auth.login")),1)]),e("form",{class:"mt-8 space-y-6",onSubmit:x(g,["prevent"])},[e("div",R,[e("div",null,[e("label",S,a(s.$t("auth.email")),1),r(n(p),{id:"email",modelValue:d.value,"onUpdate:modelValue":t[0]||(t[0]=l=>d.value=l),type:"email",required:"",class:"mt-1",placeholder:s.$t("auth.email")},null,8,["modelValue","placeholder"])]),e("div",null,[e("label",E,a(s.$t("auth.password")),1),r(n(p),{id:"password",modelValue:u.value,"onUpdate:modelValue":t[1]||(t[1]=l=>u.value=l),type:"password",required:"",class:"mt-1",placeholder:s.$t("auth.password")},null,8,["modelValue","placeholder"])])]),e("div",T,[e("div",z,[w(e("input",{id:"remember-me","onUpdate:modelValue":t[2]||(t[2]=l=>c.value=l),type:"checkbox",class:"h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"},null,512),[[k,c.value]]),e("label",F,a(s.$t("auth.rememberMe")),1)]),e("div",G,[e("a",H,a(s.$t("auth.forgotPassword")),1)])]),e("div",null,[r(n(N),{type:"submit",variant:"solid",class:"w-full",loading:m.value},{default:V(()=>[M(a(s.$t("auth.loginButton")),1)]),_:1},8,["loading"])]),i.value?(f(),$(n(j),{key:0,message:i.value},null,8,["message"])):B("",!0)],32)])]))}};export{K as default};
//# sourceMappingURL=Login-5fde8085.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
import{o,c as a,b as e,d as r,g as n,e as i,j as d,k as l}from"./index-96d8d3bd.js";const x={class:"min-h-screen flex items-center justify-center bg-surface-gray-2 py-12 px-4"},u={class:"text-center"},g={__name:"NotFound",setup(f){return(s,t)=>(o(),a("div",x,[e("div",u,[t[2]||(t[2]=e("h1",{class:"text-9xl font-bold text-ink-gray-2"},"404",-1)),t[3]||(t[3]=e("h2",{class:"mt-4 text-2xl font-semibold text-ink-gray-9"}," Page Not Found ",-1)),t[4]||(t[4]=e("p",{class:"mt-2 text-ink-gray-5"}," The page you're looking for doesn't exist. ",-1)),r(i(l),{variant:"solid",class:"mt-6",onClick:t[0]||(t[0]=m=>s.$router.push("/dashboard"))},{default:n(()=>[...t[1]||(t[1]=[d(" Go to Dashboard ",-1)])]),_:1})])]))}};export{g as default};
//# sourceMappingURL=NotFound-c6f2d210.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"NotFound-c6f2d210.js","sources":["../../../../frontend/src/pages/admin/NotFound.vue"],"sourcesContent":["<template>\n <div class=\"min-h-screen flex items-center justify-center bg-surface-gray-2 py-12 px-4\">\n <div class=\"text-center\">\n <h1 class=\"text-9xl font-bold text-ink-gray-2\">404</h1>\n <h2 class=\"mt-4 text-2xl font-semibold text-ink-gray-9\">\n Page Not Found\n </h2>\n <p class=\"mt-2 text-ink-gray-5\">\n The page you're looking for doesn't exist.\n </p>\n <Button\n variant=\"solid\"\n class=\"mt-6\"\n @click=\"$router.push('/dashboard')\"\n >\n Go to Dashboard\n </Button>\n </div>\n </div>\n</template>\n\n<script setup>\nimport { Button } from 'frappe-ui'\n</script>\n"],"names":["_openBlock","_createElementBlock","_hoisted_1","_createElementVNode","_hoisted_2","_cache","_createVNode","_unref","Button","$event","$router"],"mappings":"qPACEA,EAAA,EAAAC,EAiBM,MAjBNC,EAiBM,CAhBJC,EAeM,MAfNC,EAeM,CAdJC,EAAA,CAAA,IAAAA,EAAA,CAAA,EAAAF,EAAuD,KAAnD,CAAA,MAAM,oCAAoC,EAAC,MAAG,EAAA,GAClDE,EAAA,CAAA,IAAAA,EAAA,CAAA,EAAAF,EAEK,KAFD,CAAA,MAAM,6CAA6C,EAAC,mBAExD,EAAA,GACAE,EAAA,CAAA,IAAAA,EAAA,CAAA,EAAAF,EAEI,IAFD,CAAA,MAAM,sBAAsB,EAAC,+CAEhC,EAAA,GACAG,EAMSC,EAAAC,CAAA,EAAA,CALP,QAAQ,QACR,MAAM,OACL,QAAKH,EAAA,CAAA,IAAAA,EAAA,CAAA,EAAAI,GAAEC,EAAO,QAAC,KAAI,YAAA,eACrB,IAED,CAAA,GAAAL,EAAA,CAAA,IAAAA,EAAA,CAAA,EAAA,GAFC,oBAED,EAAA"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
import{n as s,o as c,c as u,b as r,E as i,d as o,e as d,F as g,t as l,i as m}from"./index-96d8d3bd.js";const p={class:"rounded-lg border p-4 shadow-sm"},v={class:"flex items-center justify-between"},h={class:"flex items-center gap-3"},w={class:"text-sm text-ink-gray-5"},N={class:"text-2xl font-semibold text-ink-gray-9"},k={class:"text-sm font-medium"},S={key:0,class:"mt-2 text-xs text-ink-gray-5"},F={__name:"StatCard",props:{label:{type:String,required:!0},value:{type:[Number,String],required:!0},iconName:{type:String,default:"activity"},color:{type:String,default:"blue",validator:e=>["blue","green","red","yellow","purple","gray"].includes(e)},trend:{type:Number,default:void 0},subtitle:{type:String,default:""},format:{type:String,default:"number",validator:e=>["number","currency","percent"].includes(e)}},setup(e){const n=e,a={blue:{bg:"bg-blue-100",icon:"text-blue-600"},green:{bg:"bg-green-100",icon:"text-green-600"},red:{bg:"bg-red-100",icon:"text-red-600"},yellow:{bg:"bg-orange-100",icon:"text-orange-600"},purple:{bg:"bg-purple-100",icon:"text-purple-600"},gray:{bg:"bg-surface-gray-2",icon:"text-ink-gray-5"}},b=s(()=>{var t;return((t=a[n.color])==null?void 0:t.bg)||a.blue.bg}),f=s(()=>{var t;return((t=a[n.color])==null?void 0:t.icon)||a.blue.icon}),y=s(()=>n.trend>0?"text-green-600":n.trend<0?"text-red-600":"text-ink-gray-5"),x=s(()=>{const t=typeof n.value=="number"?n.value:parseFloat(n.value);if(isNaN(t))return n.value;switch(n.format){case"currency":return new Intl.NumberFormat("ru-RU",{style:"currency",currency:"RUB",minimumFractionDigits:0}).format(t);case"percent":return`${t}%`;default:return new Intl.NumberFormat("ru-RU").format(t)}});return(t,C)=>(c(),u("div",p,[r("div",v,[r("div",h,[r("div",{class:i(["flex h-10 w-10 items-center justify-center rounded-lg",b.value])},[o(d(g),{name:e.iconName,class:i(["h-5 w-5",f.value])},null,8,["name","class"])],2),r("div",null,[r("p",w,l(e.label),1),r("p",N,l(x.value),1)])]),e.trend!==void 0?(c(),u("div",{key:0,class:i(["flex items-center gap-1",y.value])},[o(d(g),{name:e.trend>0?"trending-up":"trending-down",class:"h-4 w-4"},null,8,["name"]),r("span",k,l(Math.abs(e.trend))+"%",1)],2)):m("",!0)]),e.subtitle?(c(),u("div",S,l(e.subtitle),1)):m("",!0)]))}};export{F as _};
//# sourceMappingURL=StatCard-a71614ae.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
const a={totalRevenue:125e4,activeSubscriptions:42,frozenAccounts:3,avgRevenuePerUser:29762,revenueGrowth:12.5,subscriptionGrowth:8.3},s={labels:["Jan","Feb","Mar","Apr","May","Jun"],datasets:[{name:"Revenue",values:[15e4,18e4,2e5,22e4,25e4,25e4]}]},e=[{id:"cust-001",name:"Acme Corporation",email:"billing@acme.com",connections:85,status:"active",monthlyAmount:42500,lastPayment:"2024-03-01",since:"2023-06-15"},{id:"cust-002",name:"TechStart LLC",email:"admin@techstart.io",connections:23,status:"active",monthlyAmount:11500,lastPayment:"2024-03-05",since:"2023-09-20"},{id:"cust-003",name:"Global Retail",email:"it@globalretail.com",connections:150,status:"active",monthlyAmount:75e3,lastPayment:"2024-03-01",since:"2022-11-01"},{id:"cust-004",name:"SmallBiz Inc",email:"owner@smallbiz.co",connections:5,status:"frozen",monthlyAmount:2500,lastPayment:"2024-01-15",since:"2023-12-01",frozenReason:"Payment overdue 60 days"},{id:"cust-005",name:"Enterprise Solutions",email:"finance@enterprise.com",connections:200,status:"active",monthlyAmount:1e5,lastPayment:"2024-03-10",since:"2022-03-15"},{id:"cust-006",name:"Startup Labs",email:"hello@startuplabs.dev",connections:12,status:"frozen",monthlyAmount:6e3,lastPayment:"2024-02-01",since:"2024-01-10",frozenReason:"Payment declined"}],n=e.filter(t=>t.status==="frozen"),o=[{id:"pay-001",customerId:"cust-001",customerName:"Acme Corporation",amount:42500,date:"2024-03-01",status:"completed",method:"bank_transfer"},{id:"pay-002",customerId:"cust-003",customerName:"Global Retail",amount:75e3,date:"2024-03-01",status:"completed",method:"bank_transfer"},{id:"pay-003",customerId:"cust-002",customerName:"TechStart LLC",amount:11500,date:"2024-03-05",status:"completed",method:"card"},{id:"pay-004",customerId:"cust-005",customerName:"Enterprise Solutions",amount:1e5,date:"2024-03-10",status:"completed",method:"bank_transfer"},{id:"pay-005",customerId:"cust-006",customerName:"Startup Labs",amount:6e3,date:"2024-03-01",status:"failed",method:"card"}];export{o as a,e as c,n as f,a as p,s as r};
//# sourceMappingURL=payments-4b7e79ca.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"payments-4b7e79ca.js","sources":["../../../../frontend/src/mocks/payments.js"],"sourcesContent":["// Mock data for Payments\n\nexport const paymentStats = {\n totalRevenue: 1250000,\n activeSubscriptions: 42,\n frozenAccounts: 3,\n avgRevenuePerUser: 29762,\n revenueGrowth: 12.5,\n subscriptionGrowth: 8.3,\n}\n\nexport const revenueData = {\n labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],\n datasets: [\n {\n name: 'Revenue',\n values: [150000, 180000, 200000, 220000, 250000, 250000],\n },\n ],\n}\n\nexport const connectionsByPlan = {\n labels: ['1-10', '11-50', '51-100', '100+'],\n datasets: [\n {\n name: 'Customers',\n values: [15, 18, 7, 2],\n },\n ],\n}\n\nexport const customers = [\n {\n id: 'cust-001',\n name: 'Acme Corporation',\n email: 'billing@acme.com',\n connections: 85,\n status: 'active',\n monthlyAmount: 42500,\n lastPayment: '2024-03-01',\n since: '2023-06-15',\n },\n {\n id: 'cust-002',\n name: 'TechStart LLC',\n email: 'admin@techstart.io',\n connections: 23,\n status: 'active',\n monthlyAmount: 11500,\n lastPayment: '2024-03-05',\n since: '2023-09-20',\n },\n {\n id: 'cust-003',\n name: 'Global Retail',\n email: 'it@globalretail.com',\n connections: 150,\n status: 'active',\n monthlyAmount: 75000,\n lastPayment: '2024-03-01',\n since: '2022-11-01',\n },\n {\n id: 'cust-004',\n name: 'SmallBiz Inc',\n email: 'owner@smallbiz.co',\n connections: 5,\n status: 'frozen',\n monthlyAmount: 2500,\n lastPayment: '2024-01-15',\n since: '2023-12-01',\n frozenReason: 'Payment overdue 60 days',\n },\n {\n id: 'cust-005',\n name: 'Enterprise Solutions',\n email: 'finance@enterprise.com',\n connections: 200,\n status: 'active',\n monthlyAmount: 100000,\n lastPayment: '2024-03-10',\n since: '2022-03-15',\n },\n {\n id: 'cust-006',\n name: 'Startup Labs',\n email: 'hello@startuplabs.dev',\n connections: 12,\n status: 'frozen',\n monthlyAmount: 6000,\n lastPayment: '2024-02-01',\n since: '2024-01-10',\n frozenReason: 'Payment declined',\n },\n]\n\nexport const frozenAccounts = customers.filter(c => c.status === 'frozen')\n\nexport const paymentHistory = [\n {\n id: 'pay-001',\n customerId: 'cust-001',\n customerName: 'Acme Corporation',\n amount: 42500,\n date: '2024-03-01',\n status: 'completed',\n method: 'bank_transfer',\n },\n {\n id: 'pay-002',\n customerId: 'cust-003',\n customerName: 'Global Retail',\n amount: 75000,\n date: '2024-03-01',\n status: 'completed',\n method: 'bank_transfer',\n },\n {\n id: 'pay-003',\n customerId: 'cust-002',\n customerName: 'TechStart LLC',\n amount: 11500,\n date: '2024-03-05',\n status: 'completed',\n method: 'card',\n },\n {\n id: 'pay-004',\n customerId: 'cust-005',\n customerName: 'Enterprise Solutions',\n amount: 100000,\n date: '2024-03-10',\n status: 'completed',\n method: 'bank_transfer',\n },\n {\n id: 'pay-005',\n customerId: 'cust-006',\n customerName: 'Startup Labs',\n amount: 6000,\n date: '2024-03-01',\n status: 'failed',\n method: 'card',\n },\n]\n"],"names":["paymentStats","revenueData","customers","frozenAccounts","c","paymentHistory"],"mappings":"AAEY,MAACA,EAAe,CAC1B,aAAc,MACd,oBAAqB,GACrB,eAAgB,EAChB,kBAAmB,MACnB,cAAe,KACf,mBAAoB,GACtB,EAEaC,EAAc,CACzB,OAAQ,CAAC,MAAO,MAAO,MAAO,MAAO,MAAO,KAAK,EACjD,SAAU,CACR,CACE,KAAM,UACN,OAAQ,CAAC,KAAQ,KAAQ,IAAQ,KAAQ,KAAQ,IAAM,CACxD,CACF,CACH,EAYaC,EAAY,CACvB,CACE,GAAI,WACJ,KAAM,mBACN,MAAO,mBACP,YAAa,GACb,OAAQ,SACR,cAAe,MACf,YAAa,aACb,MAAO,YACR,EACD,CACE,GAAI,WACJ,KAAM,gBACN,MAAO,qBACP,YAAa,GACb,OAAQ,SACR,cAAe,MACf,YAAa,aACb,MAAO,YACR,EACD,CACE,GAAI,WACJ,KAAM,gBACN,MAAO,sBACP,YAAa,IACb,OAAQ,SACR,cAAe,KACf,YAAa,aACb,MAAO,YACR,EACD,CACE,GAAI,WACJ,KAAM,eACN,MAAO,oBACP,YAAa,EACb,OAAQ,SACR,cAAe,KACf,YAAa,aACb,MAAO,aACP,aAAc,yBACf,EACD,CACE,GAAI,WACJ,KAAM,uBACN,MAAO,yBACP,YAAa,IACb,OAAQ,SACR,cAAe,IACf,YAAa,aACb,MAAO,YACR,EACD,CACE,GAAI,WACJ,KAAM,eACN,MAAO,wBACP,YAAa,GACb,OAAQ,SACR,cAAe,IACf,YAAa,aACb,MAAO,aACP,aAAc,kBACf,CACH,EAEaC,EAAiBD,EAAU,OAAOE,GAAKA,EAAE,SAAW,QAAQ,EAE5DC,EAAiB,CAC5B,CACE,GAAI,UACJ,WAAY,WACZ,aAAc,mBACd,OAAQ,MACR,KAAM,aACN,OAAQ,YACR,OAAQ,eACT,EACD,CACE,GAAI,UACJ,WAAY,WACZ,aAAc,gBACd,OAAQ,KACR,KAAM,aACN,OAAQ,YACR,OAAQ,eACT,EACD,CACE,GAAI,UACJ,WAAY,WACZ,aAAc,gBACd,OAAQ,MACR,KAAM,aACN,OAAQ,YACR,OAAQ,MACT,EACD,CACE,GAAI,UACJ,WAAY,WACZ,aAAc,uBACd,OAAQ,IACR,KAAM,aACN,OAAQ,YACR,OAAQ,eACT,EACD,CACE,GAAI,UACJ,WAAY,WACZ,aAAc,eACd,OAAQ,IACR,KAAM,aACN,OAAQ,SACR,OAAQ,MACT,CACH"}

View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html class="h-full" lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover maximum-scale=1.0, user-scalable=no"
/>
<title>Admin Panel</title>
<script type="module" crossorigin src="/assets/admin_panel/frontend/assets/index-96d8d3bd.js"></script>
<link rel="stylesheet" href="/assets/admin_panel/frontend/assets/index-be38b6df.css">
</head>
<body class="h-full">
<div id="app" class="h-full"></div>
<script>
{% for key in boot %}
window["{{ key }}"] = {{ boot[key] | tojson }};
{% endfor %}
</script>
</body>
</html>

View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html class="h-full" lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover maximum-scale=1.0, user-scalable=no"
/>
<title>Admin Panel</title>
<script type="module" crossorigin src="/assets/admin_panel/frontend/assets/index-96d8d3bd.js"></script>
<link rel="stylesheet" href="/assets/admin_panel/frontend/assets/index-be38b6df.css">
</head>
<body class="h-full">
<div id="app" class="h-full"></div>
<script>
{% for key in boot %}
window["{{ key }}"] = {{ boot[key] | tojson }};
{% endfor %}
</script>
</body>
</html>

24
admin_panel/www/admin.py Normal file
View File

@ -0,0 +1,24 @@
import frappe
no_cache = 1
def get_context(context):
csrf_token = frappe.sessions.get_csrf_token()
frappe.db.commit()
context.csrf_token = csrf_token
context.boot = get_boot()
@frappe.whitelist(allow_guest=True)
def get_context_for_dev():
if not frappe.conf.developer_mode:
frappe.throw("This method is only available in developer mode")
return get_boot()
def get_boot():
return {
"csrf_token": frappe.sessions.get_csrf_token(),
"user": frappe.session.user,
}

10
frontend/auto-imports.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
}

27
frontend/components.d.ts vendored Normal file
View File

@ -0,0 +1,27 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AdminHeader: typeof import('./src/components/Layouts/AdminHeader.vue')['default']
AdminLayout: typeof import('./src/components/Layouts/AdminLayout.vue')['default']
AdminLogo: typeof import('./src/components/Icons/AdminLogo.vue')['default']
AdminSidebar: typeof import('./src/components/Layouts/AdminSidebar.vue')['default']
ChartCard: typeof import('./src/components/Common/ChartCard.vue')['default']
DashboardStatCard: typeof import('./src/components/Dashboard/DashboardStatCard.vue')['default']
EmptyState: typeof import('./src/components/Common/EmptyState.vue')['default']
LanguageToggle: typeof import('./src/components/Common/LanguageToggle.vue')['default']
LayoutHeader: typeof import('./src/components/Common/LayoutHeader.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SidebarLink: typeof import('./src/components/Common/SidebarLink.vue')['default']
StatCard: typeof import('./src/components/Common/StatCard.vue')['default']
StatusBadge: typeof import('./src/components/Sites/StatusBadge.vue')['default']
ThemeToggle: typeof import('./src/components/Common/ThemeToggle.vue')['default']
}
}

15
frontend/index.html Normal file
View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html class="h-full" lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover maximum-scale=1.0, user-scalable=no"
/>
<title>Admin Panel</title>
</head>
<body class="h-full">
<div id="app" class="h-full"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

27
frontend/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "admin-panel-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build --base=/assets/admin_panel/frontend/ && yarn copy-html-entry",
"copy-html-entry": "cp ../admin_panel/public/frontend/index.html ../admin_panel/www/admin.html",
"serve": "vite preview"
},
"dependencies": {
"@vitejs/plugin-vue": "^4.2.3",
"@vueuse/core": "^10.7.0",
"frappe-ui": "0.1.248",
"lucide-vue-next": "0.344.0",
"vite": "^4.4.9",
"vue": "^3.5.13",
"vue-i18n": "^9.8.0",
"vue-router": "^4.2.2"
},
"devDependencies": {
"autoprefixer": "^10.4.14",
"postcss": "^8.4.5",
"tailwindcss": "^3.4.15"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

48
frontend/src/App.vue Normal file
View File

@ -0,0 +1,48 @@
<template>
<FrappeUIProvider>
<!-- Loading state while checking auth -->
<div v-if="loading" class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
<div class="text-center">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-4 border-gray-300 border-t-blue-600"></div>
<p class="mt-2 text-gray-500 dark:text-gray-400">{{ $t('common.loading') }}...</p>
</div>
</div>
<!-- Auth pages without sidebar -->
<template v-else-if="isAuthPage">
<router-view />
</template>
<!-- Main app with sidebar -->
<template v-else>
<AdminLayout>
<router-view />
</AdminLayout>
</template>
</FrappeUIProvider>
</template>
<script setup>
import { computed, onMounted, ref, watchEffect } from 'vue'
import { useRoute } from 'vue-router'
import { FrappeUIProvider } from 'frappe-ui'
import AdminLayout from '@/components/Layouts/AdminLayout.vue'
import { session } from '@/session'
import { useTheme } from '@/composables/useTheme'
const route = useRoute()
const loading = ref(true)
// Initialize theme
const { initTheme } = useTheme()
const isAuthPage = computed(() => {
return ['Login'].includes(route.name)
})
onMounted(async () => {
initTheme()
await session.init()
loading.value = false
})
</script>

View File

@ -0,0 +1,22 @@
<template>
<div class="rounded-lg border p-4 shadow-sm">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-medium text-ink-gray-9">
{{ title }}
</h3>
<slot name="actions" />
</div>
<div class="h-[200px]">
<slot />
</div>
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
required: true,
},
})
</script>

View File

@ -0,0 +1,35 @@
<template>
<div class="flex flex-col items-center justify-center py-12 text-center">
<div
class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-surface-gray-2"
>
<FeatherIcon :name="icon" class="h-8 w-8 text-ink-gray-4" />
</div>
<h3 class="mb-2 text-lg font-medium text-ink-gray-9">
{{ title }}
</h3>
<p v-if="description" class="mb-4 max-w-sm text-sm text-ink-gray-5">
{{ description }}
</p>
<slot name="action" />
</div>
</template>
<script setup>
import { FeatherIcon } from 'frappe-ui'
defineProps({
icon: {
type: String,
default: 'inbox',
},
title: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
})
</script>

View File

@ -0,0 +1,57 @@
<template>
<button
class="flex h-7 cursor-pointer items-center rounded text-ink-gray-7 duration-300 ease-in-out focus:outline-none hover:bg-surface-gray-2"
@click="toggleLocale"
>
<div
class="flex w-full items-center justify-between duration-300 ease-in-out"
:class="isCollapsed ? 'ml-[3px] p-1' : 'px-2 py-1'"
>
<div class="flex items-center truncate">
<Tooltip :text="currentLocaleLabel" placement="right" :disabled="!isCollapsed">
<span class="grid flex-shrink-0 place-items-center">
<FeatherIcon name="globe" class="h-4 w-4 text-ink-gray-7" />
</span>
</Tooltip>
<span
class="flex-1 flex-shrink-0 truncate text-sm duration-300 ease-in-out"
:class="
isCollapsed
? 'ml-0 w-0 overflow-hidden opacity-0'
: 'ml-2 w-auto opacity-100'
"
>
{{ currentLocaleLabel }}
</span>
</div>
</div>
</button>
</template>
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Tooltip, FeatherIcon } from 'frappe-ui'
defineProps({
isCollapsed: {
type: Boolean,
default: false,
},
})
const { locale } = useI18n()
const locales = {
en: 'English',
ru: 'Русский',
}
const currentLocaleLabel = computed(() => locales[locale.value] || locale.value)
function toggleLocale() {
const newLocale = locale.value === 'ru' ? 'en' : 'ru'
locale.value = newLocale
localStorage.setItem('admin-panel-locale', newLocale)
}
</script>

View File

@ -0,0 +1,23 @@
<template>
<header class="flex items-center justify-between border-b bg-surface-white px-5 py-2.5">
<div class="flex items-center gap-2">
<slot name="left">
<h1 class="text-xl font-semibold text-ink-gray-9">
{{ title }}
</h1>
</slot>
</div>
<div class="flex items-center gap-2">
<slot name="right" />
</div>
</header>
</template>
<script setup>
defineProps({
title: {
type: String,
default: '',
},
})
</script>

View File

@ -0,0 +1,83 @@
<template>
<button
class="flex h-7 cursor-pointer items-center rounded text-ink-gray-7 duration-300 ease-in-out focus:outline-none focus:transition-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-outline-gray-3"
:class="isActive ? 'bg-surface-selected shadow-sm' : 'hover:bg-surface-gray-2'"
@click="handleClick"
>
<div
class="flex w-full items-center justify-between duration-300 ease-in-out"
:class="isCollapsed ? 'ml-[3px] p-1' : 'px-2 py-1'"
>
<div class="flex items-center truncate">
<Tooltip :text="label" placement="right" :disabled="!isCollapsed">
<slot name="icon">
<span class="grid flex-shrink-0 place-items-center">
<FeatherIcon
v-if="typeof icon === 'string'"
:name="icon"
class="size-4 text-ink-gray-7"
/>
<component v-else :is="icon" class="size-4 text-ink-gray-7" />
</span>
</slot>
</Tooltip>
<span
class="flex-1 flex-shrink-0 truncate text-sm duration-300 ease-in-out"
:class="
isCollapsed
? 'ml-0 w-0 overflow-hidden opacity-0'
: 'ml-2 w-auto opacity-100'
"
>
{{ label }}
</span>
</div>
<slot name="right" />
</div>
</button>
</template>
<script setup>
import { Tooltip, FeatherIcon } from 'frappe-ui'
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
const props = defineProps({
icon: {
type: [Object, String, Function],
},
label: {
type: String,
default: '',
},
to: {
type: [Object, String],
default: '',
},
isCollapsed: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['click'])
function handleClick() {
if (!props.to) {
emit('click')
return
}
if (typeof props.to === 'object') {
router.push(props.to)
} else {
router.push({ name: props.to })
}
}
const isActive = computed(() => {
return route.name === props.to
})
</script>

View File

@ -0,0 +1,101 @@
<template>
<div class="rounded-lg border p-4 shadow-sm">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg"
:class="iconBgClass"
>
<FeatherIcon :name="iconName" class="h-5 w-5" :class="iconClass" />
</div>
<div>
<p class="text-sm text-ink-gray-5">{{ label }}</p>
<p class="text-2xl font-semibold text-ink-gray-9">
{{ formattedValue }}
</p>
</div>
</div>
<div v-if="trend !== undefined" class="flex items-center gap-1" :class="trendClass">
<FeatherIcon :name="trend > 0 ? 'trending-up' : 'trending-down'" class="h-4 w-4" />
<span class="text-sm font-medium">{{ Math.abs(trend) }}%</span>
</div>
</div>
<div v-if="subtitle" class="mt-2 text-xs text-ink-gray-5">
{{ subtitle }}
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { FeatherIcon } from 'frappe-ui'
const props = defineProps({
label: {
type: String,
required: true,
},
value: {
type: [Number, String],
required: true,
},
iconName: {
type: String,
default: 'activity',
},
color: {
type: String,
default: 'blue',
validator: (v) => ['blue', 'green', 'red', 'yellow', 'purple', 'gray'].includes(v),
},
trend: {
type: Number,
default: undefined,
},
subtitle: {
type: String,
default: '',
},
format: {
type: String,
default: 'number',
validator: (v) => ['number', 'currency', 'percent'].includes(v),
},
})
const colorClasses = {
blue: { bg: 'bg-blue-100', icon: 'text-blue-600' },
green: { bg: 'bg-green-100', icon: 'text-green-600' },
red: { bg: 'bg-red-100', icon: 'text-red-600' },
yellow: { bg: 'bg-orange-100', icon: 'text-orange-600' },
purple: { bg: 'bg-purple-100', icon: 'text-purple-600' },
gray: { bg: 'bg-surface-gray-2', icon: 'text-ink-gray-5' },
}
const iconBgClass = computed(() => colorClasses[props.color]?.bg || colorClasses.blue.bg)
const iconClass = computed(() => colorClasses[props.color]?.icon || colorClasses.blue.icon)
const trendClass = computed(() => {
if (props.trend > 0) return 'text-green-600'
if (props.trend < 0) return 'text-red-600'
return 'text-ink-gray-5'
})
const formattedValue = computed(() => {
const num = typeof props.value === 'number' ? props.value : parseFloat(props.value)
if (isNaN(num)) return props.value
switch (props.format) {
case 'currency':
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0,
}).format(num)
case 'percent':
return `${num}%`
default:
return new Intl.NumberFormat('ru-RU').format(num)
}
})
</script>

View File

@ -0,0 +1,43 @@
<template>
<button
class="flex h-7 cursor-pointer items-center rounded text-ink-gray-7 duration-300 ease-in-out focus:outline-none hover:bg-surface-gray-2"
@click="toggleTheme"
>
<div
class="flex w-full items-center justify-between duration-300 ease-in-out"
:class="isCollapsed ? 'ml-[3px] p-1' : 'px-2 py-1'"
>
<div class="flex items-center truncate">
<Tooltip :text="$t('theme.toggle')" placement="right" :disabled="!isCollapsed">
<span class="grid flex-shrink-0 place-items-center">
<FeatherIcon :name="isDark ? 'sun' : 'moon'" class="h-4 w-4 text-ink-gray-7" />
</span>
</Tooltip>
<span
class="flex-1 flex-shrink-0 truncate text-sm duration-300 ease-in-out"
:class="
isCollapsed
? 'ml-0 w-0 overflow-hidden opacity-0'
: 'ml-2 w-auto opacity-100'
"
>
{{ isDark ? $t('theme.light') : $t('theme.dark') }}
</span>
</div>
</div>
</button>
</template>
<script setup>
import { Tooltip, FeatherIcon } from 'frappe-ui'
import { useTheme } from '@/composables/useTheme'
defineProps({
isCollapsed: {
type: Boolean,
default: false,
},
})
const { isDark, toggleTheme } = useTheme()
</script>

View File

@ -0,0 +1,50 @@
<template>
<div class="bg-white dark:bg-gray-800 rounded-xl border dark:border-gray-700 p-5 flex flex-col items-end text-right">
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">{{ label }}</p>
<div class="flex items-baseline gap-2">
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100">
{{ formattedValue }}
</p>
<span
v-if="trend !== undefined"
class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-semibold"
:class="trendClass"
>
+{{ trend }}%
</span>
</div>
<!-- Mini progress bar -->
<div class="w-full mt-3 bg-gray-100 dark:bg-gray-700 rounded-full h-1.5">
<div
class="h-1.5 rounded-full transition-all duration-500"
:class="barColor"
:style="{ width: `${Math.min(trend * 3, 100)}%` }"
></div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
label: { type: String, required: true },
value: { type: [Number, String], required: true },
trend: { type: Number, default: undefined },
color: { type: String, default: 'blue' },
})
const formattedValue = computed(() => {
const num = typeof props.value === 'number' ? props.value : parseFloat(props.value)
if (isNaN(num)) return props.value
return new Intl.NumberFormat('en-US').format(num)
})
const trendClass = computed(() => {
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
})
const barColor = computed(() => {
return props.color === 'green' ? 'bg-green-500' : 'bg-blue-500'
})
</script>

View File

@ -0,0 +1,30 @@
<template>
<svg
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="text-blue-600 dark:text-blue-400"
>
<path
d="M12 2L2 7L12 12L22 7L12 2Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M2 17L12 22L22 17"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M2 12L12 17L22 12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>

View File

@ -0,0 +1,23 @@
<template>
<header class="flex items-center justify-between border-b bg-surface-white dark:bg-gray-800 dark:border-gray-700 px-5 py-2.5">
<div class="flex items-center gap-2">
<slot name="left">
<h1 class="text-xl font-semibold text-ink-gray-9 dark:text-gray-100">
{{ title }}
</h1>
</slot>
</div>
<div class="flex items-center gap-2">
<slot name="right" />
</div>
</header>
</template>
<script setup>
defineProps({
title: {
type: String,
default: '',
},
})
</script>

View File

@ -0,0 +1,14 @@
<template>
<div class="flex h-screen w-screen">
<div class="h-full border-r bg-surface-menu-bar">
<AdminSidebar />
</div>
<div class="flex flex-1 flex-col h-full overflow-auto bg-surface-white">
<slot />
</div>
</div>
</template>
<script setup>
import AdminSidebar from '@/components/Layouts/AdminSidebar.vue'
</script>

View File

@ -0,0 +1,179 @@
<template>
<div
class="relative flex h-full flex-col justify-between transition-all duration-300 ease-in-out"
:class="isSidebarCollapsed ? 'w-12' : 'w-[220px]'"
>
<!-- Logo -->
<div class="p-2">
<div
class="flex items-center gap-2 px-2 py-1.5 transition-all duration-300 ease-in-out"
:class="isSidebarCollapsed ? 'justify-center' : ''"
>
<AdminLogo class="h-6 w-6 flex-shrink-0" />
<span
v-if="!isSidebarCollapsed"
class="text-lg font-semibold text-ink-gray-9"
>
Admin Panel
</span>
</div>
</div>
<!-- Navigation -->
<div class="flex-1 overflow-y-auto">
<!-- Overview -->
<div class="mb-1 px-2">
<div
v-if="!isSidebarCollapsed"
class="px-2 py-1 text-[10px] font-medium uppercase tracking-wider text-ink-gray-4"
>
{{ $t('nav.overview') }}
</div>
<nav class="flex flex-col gap-0.5">
<SidebarLink
icon="bar-chart-2"
:label="$t('nav.dashboard')"
to="Dashboard"
:isCollapsed="isSidebarCollapsed"
/>
</nav>
</div>
<!-- Infrastructure -->
<div class="mb-1 px-2">
<div
v-if="!isSidebarCollapsed"
class="px-2 py-1 text-[10px] font-medium uppercase tracking-wider text-ink-gray-4"
>
{{ $t('nav.infrastructure') }}
</div>
<nav class="flex flex-col gap-0.5">
<SidebarLink
icon="server"
:label="$t('nav.sites')"
to="Sites"
:isCollapsed="isSidebarCollapsed"
/>
<SidebarLink
icon="package"
:label="$t('nav.apps')"
to="Apps"
:isCollapsed="isSidebarCollapsed"
/>
<SidebarLink
icon="cloud"
:label="$t('nav.servers')"
to="Servers"
:isCollapsed="isSidebarCollapsed"
/>
</nav>
</div>
<!-- Business -->
<div class="mb-1 px-2">
<div
v-if="!isSidebarCollapsed"
class="px-2 py-1 text-[10px] font-medium uppercase tracking-wider text-ink-gray-4"
>
{{ $t('nav.business') }}
</div>
<nav class="flex flex-col gap-0.5">
<SidebarLink
icon="credit-card"
:label="$t('nav.payments')"
to="Payments"
:isCollapsed="isSidebarCollapsed"
/>
</nav>
</div>
<!-- System -->
<div class="mb-1 px-2">
<div
v-if="!isSidebarCollapsed"
class="px-2 py-1 text-[10px] font-medium uppercase tracking-wider text-ink-gray-4"
>
{{ $t('nav.system') }}
</div>
<nav class="flex flex-col gap-0.5">
<SidebarLink
icon="file-text"
:label="$t('nav.logs')"
to="Logs"
:isCollapsed="isSidebarCollapsed"
/>
<SidebarLink
icon="settings"
:label="$t('nav.settings')"
to="Settings"
:isCollapsed="isSidebarCollapsed"
/>
</nav>
</div>
</div>
<!-- Footer -->
<div class="m-2 flex flex-col gap-1">
<!-- User info -->
<div
v-if="!isSidebarCollapsed && session.user"
class="flex items-center gap-2 px-2 py-1.5 mb-2 text-sm text-ink-gray-7 truncate"
>
<FeatherIcon name="user" class="h-4 w-4 flex-shrink-0" />
<span class="truncate">{{ session.user }}</span>
</div>
<!-- Theme Toggle -->
<ThemeToggle :isCollapsed="isSidebarCollapsed" />
<!-- Language Toggle -->
<LanguageToggle :isCollapsed="isSidebarCollapsed" />
<!-- Logout button -->
<SidebarLink
:label="$t('auth.logout')"
:isCollapsed="isSidebarCollapsed"
@click="handleLogout"
>
<template #icon>
<span class="grid h-4 w-4 flex-shrink-0 place-items-center">
<FeatherIcon name="log-out" class="h-4 w-4 text-ink-gray-7" />
</span>
</template>
</SidebarLink>
<!-- Collapse button -->
<SidebarLink
:label="isSidebarCollapsed ? $t('nav.expand') : $t('nav.collapse')"
:isCollapsed="isSidebarCollapsed"
@click="isSidebarCollapsed = !isSidebarCollapsed"
>
<template #icon>
<span class="grid h-4 w-4 flex-shrink-0 place-items-center">
<FeatherIcon
name="chevrons-left"
class="h-4 w-4 text-ink-gray-7 duration-300 ease-in-out"
:class="{ '[transform:rotateY(180deg)]': isSidebarCollapsed }"
/>
</span>
</template>
</SidebarLink>
</div>
</div>
</template>
<script setup>
import { useStorage } from '@vueuse/core'
import { FeatherIcon } from 'frappe-ui'
import SidebarLink from '@/components/Common/SidebarLink.vue'
import ThemeToggle from '@/components/Common/ThemeToggle.vue'
import LanguageToggle from '@/components/Common/LanguageToggle.vue'
import AdminLogo from '@/components/Icons/AdminLogo.vue'
import { session } from '@/session'
function handleLogout() {
session.logout()
}
const isSidebarCollapsed = useStorage('admin-sidebar-collapsed', false)
</script>

View File

@ -0,0 +1,57 @@
<template>
<span
class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="badgeClass"
>
<span class="w-1.5 h-1.5 rounded-full" :class="dotClass"></span>
{{ label }}
</span>
</template>
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps({
status: {
type: String,
required: true,
validator: (v) => ['running', 'stopped', 'starting', 'stopping', 'error'].includes(v),
},
})
const { t } = useI18n()
const statusConfig = {
running: {
badge: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
dot: 'bg-green-500',
label: 'sites.status.running',
},
stopped: {
badge: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
dot: 'bg-gray-500',
label: 'sites.status.stopped',
},
starting: {
badge: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400',
dot: 'bg-blue-500 animate-pulse',
label: 'sites.status.starting',
},
stopping: {
badge: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
dot: 'bg-yellow-500 animate-pulse',
label: 'sites.status.stopping',
},
error: {
badge: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
dot: 'bg-red-500',
label: 'sites.status.error',
},
}
const config = computed(() => statusConfig[props.status] || statusConfig.stopped)
const badgeClass = computed(() => config.value.badge)
const dotClass = computed(() => config.value.dot)
const label = computed(() => t(config.value.label))
</script>

View File

@ -0,0 +1,52 @@
import { ref } from 'vue'
const notifications = ref([])
export function useNotifications() {
function addNotification(notification) {
const id = Date.now()
notifications.value.push({
id,
...notification,
createdAt: new Date(),
read: false,
})
return id
}
function removeNotification(id) {
const index = notifications.value.findIndex(n => n.id === id)
if (index > -1) {
notifications.value.splice(index, 1)
}
}
function markAsRead(id) {
const notification = notifications.value.find(n => n.id === id)
if (notification) {
notification.read = true
}
}
function markAllAsRead() {
notifications.value.forEach(n => {
n.read = true
})
}
function clearAll() {
notifications.value = []
}
const unreadCount = () => notifications.value.filter(n => !n.read).length
return {
notifications,
addNotification,
removeNotification,
markAsRead,
markAllAsRead,
clearAll,
unreadCount,
}
}

View File

@ -0,0 +1,45 @@
import { ref, watch } from 'vue'
const isDark = ref(false)
export function useTheme() {
function initTheme() {
const stored = localStorage.getItem('admin-panel-theme')
if (stored) {
isDark.value = stored === 'dark'
} else {
// Check system preference
isDark.value = window.matchMedia('(prefers-color-scheme: dark)').matches
}
applyTheme()
}
function applyTheme() {
if (isDark.value) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
function toggleTheme() {
isDark.value = !isDark.value
localStorage.setItem('admin-panel-theme', isDark.value ? 'dark' : 'light')
applyTheme()
}
function setTheme(dark) {
isDark.value = dark
localStorage.setItem('admin-panel-theme', dark ? 'dark' : 'light')
applyTheme()
}
watch(isDark, applyTheme)
return {
isDark,
initTheme,
toggleTheme,
setTheme,
}
}

5
frontend/src/index.css Normal file
View File

@ -0,0 +1,5 @@
@import 'frappe-ui/style.css';
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -0,0 +1,250 @@
{
"common": {
"loading": "Loading",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"create": "Create",
"search": "Search",
"filter": "Filter",
"export": "Export",
"refresh": "Refresh",
"actions": "Actions",
"status": "Status",
"name": "Name",
"date": "Date",
"all": "All",
"yes": "Yes",
"no": "No",
"confirm": "Confirm",
"back": "Back",
"next": "Next",
"previous": "Previous",
"close": "Close",
"open": "Open"
},
"auth": {
"login": "Login",
"logout": "Logout",
"email": "Email",
"password": "Password",
"rememberMe": "Remember me",
"forgotPassword": "Forgot password?",
"loginButton": "Sign in"
},
"nav": {
"overview": "Overview",
"dashboard": "Dashboard",
"infrastructure": "Infrastructure",
"sites": "Sites",
"apps": "Apps",
"servers": "Servers",
"business": "Business",
"payments": "Payments",
"system": "System",
"logs": "Logs",
"settings": "Settings",
"collapse": "Collapse",
"expand": "Expand"
},
"dashboard": {
"title": "Dashboard",
"trafficOverview": "Traffic Overview",
"activeSessions": "Active Sessions",
"cpuUsage": "CPU Usage",
"ramUsage": "RAM Usage",
"diskUsage": "Disk Usage",
"networkUsage": "Network Usage",
"systemInfo": "System Information",
"osVersion": "OS Version",
"incusVersion": "Incus Version",
"kernelVersion": "Kernel Version",
"imageVersion": "Image Version",
"distrobuilderVersion": "Distrobuilder Version",
"totalSites": "Total Sites",
"activeSites": "Active Sites",
"stoppedSites": "Stopped Sites",
"totalUsers": "Total Users"
},
"sites": {
"title": "Sites",
"newSite": "New Container",
"siteList": "Container List",
"siteName": "Name",
"siteStatus": "Status",
"siteIP": "IP Address",
"siteImage": "Image",
"siteOwner": "Owner",
"siteCreated": "Created",
"noSites": "No containers yet",
"createFirst": "Create your first container to get started",
"targetServer": "Target Server",
"targetServerDesc": "Select which server to create the container on",
"detail": {
"overview": "Overview",
"activity": "Activity",
"logs": "Logs",
"backups": "Backups",
"uptime": "Uptime",
"resources": "Resources",
"actions": "Actions"
},
"actions": {
"start": "Start",
"stop": "Stop",
"restart": "Restart",
"backup": "Backup",
"restore": "Restore",
"delete": "Delete",
"openSite": "Open Site"
},
"status": {
"running": "Running",
"stopped": "Stopped",
"starting": "Starting",
"stopping": "Stopping",
"error": "Error"
}
},
"apps": {
"title": "Apps",
"imageList": "Image List",
"imageName": "Name",
"imageVersion": "Version",
"imageStatus": "Status",
"sitesCount": "Sites Count",
"createVersion": "Build New Version",
"createApp": "Create App",
"updateImage": "Update Image",
"configuration": "Configuration",
"versions": "Versions",
"noApps": "No app templates found",
"buildImage": "Build Image",
"deleteApp": "Delete App"
},
"payments": {
"title": "Payments",
"overview": "Overview",
"customers": "Customers",
"frozen": "Frozen Accounts",
"reports": "Reports",
"totalRevenue": "Total Revenue",
"activeSubscriptions": "Active Subscriptions",
"frozenAccounts": "Frozen Accounts",
"revenueTrend": "Revenue Trend",
"connections": "Connections",
"customer": {
"name": "Name",
"email": "Email",
"plan": "Plan",
"status": "Status",
"amount": "Amount",
"lastPayment": "Last Payment"
}
},
"logs": {
"title": "Logs",
"containerLogs": "Container Logs",
"systemLogs": "System Logs",
"webServerLogs": "Web Server Logs",
"appsLogs": "Apps Logs",
"adminPanelLogs": "Admin Panel Logs",
"billingLogs": "Billing Logs",
"usersLogs": "Users Logs",
"sitesLogs": "Sites Logs",
"level": {
"all": "All Levels",
"info": "Info",
"warning": "Warning",
"error": "Error",
"debug": "Debug"
},
"noLogs": "No logs found",
"filterByLevel": "Filter by level",
"filterByDate": "Filter by date"
},
"settings": {
"title": "Settings",
"adminUsers": "Admin Users",
"security": "Security",
"system": "System",
"notifications": "Notifications",
"admin": {
"addUser": "Add Admin User",
"editUser": "Edit User",
"deleteUser": "Delete User",
"role": "Role",
"lastLogin": "Last Login"
},
"security": {
"changePassword": "Change Password",
"twoFactorAuth": "Two-Factor Authentication",
"activeSessions": "Active Sessions",
"sessionTimeout": "Session Timeout"
},
"system": {
"siteName": "Site Name",
"language": "Language",
"timezone": "Timezone",
"dateFormat": "Date Format"
},
"notification": {
"emailNotifications": "Email Notifications",
"pushNotifications": "Push Notifications",
"newUser": "New User Registration",
"accountFrozen": "Account Frozen",
"containerError": "Container Error",
"payment": "Payment Received"
}
},
"servers": {
"title": "Servers",
"setup": "Setup",
"configuration": "Configuration",
"remoteServers": "Remote Servers",
"addServer": "Add Server",
"removeServer": "Remove Server",
"testConnection": "Test Connection",
"serverName": "Server Name",
"serverUrl": "Server URL",
"serverToken": "Server Token",
"serverStatus": "Status",
"noServers": "No remote servers configured",
"addFirst": "Add your first Incus remote server",
"status": {
"online": "Online",
"offline": "Offline",
"unknown": "Unknown"
},
"roles": {
"title": "Server Roles",
"imageServer": "Image Server",
"containerServer": "Container Server"
},
"setup": {
"title": "Software Setup",
"description": "Install required software for container management",
"incusInstalled": "Incus Installed",
"distrobuilderInstalled": "Distrobuilder Installed",
"serviceRunning": "Service Running",
"install": "Install",
"installDescription": "This will install incus, incus-client, and distrobuilder packages",
"doasPassword": "Doas Password",
"doasPasswordDesc": "Required for privileged operations"
},
"localIncus": {
"title": "Local Incus",
"description": "Local Incus server information",
"version": "Version",
"defaultRemote": "Default Remote",
"storagePools": "Storage Pools",
"networks": "Networks"
}
},
"theme": {
"light": "Light",
"dark": "Dark",
"toggle": "Toggle Theme"
}
}

View File

@ -0,0 +1,250 @@
{
"common": {
"loading": "Загрузка",
"save": "Сохранить",
"cancel": "Отмена",
"delete": "Удалить",
"edit": "Редактировать",
"create": "Создать",
"search": "Поиск",
"filter": "Фильтр",
"export": "Экспорт",
"refresh": "Обновить",
"actions": "Действия",
"status": "Статус",
"name": "Имя",
"date": "Дата",
"all": "Все",
"yes": "Да",
"no": "Нет",
"confirm": "Подтвердить",
"back": "Назад",
"next": "Далее",
"previous": "Назад",
"close": "Закрыть",
"open": "Открыть"
},
"auth": {
"login": "Вход",
"logout": "Выход",
"email": "Email",
"password": "Пароль",
"rememberMe": "Запомнить меня",
"forgotPassword": "Забыли пароль?",
"loginButton": "Войти"
},
"nav": {
"overview": "Обзор",
"dashboard": "Панель управления",
"infrastructure": "Инфраструктура",
"sites": "Сайты",
"apps": "Приложения",
"servers": "Серверы",
"business": "Бизнес",
"payments": "Платежи",
"system": "Система",
"logs": "Логи",
"settings": "Настройки",
"collapse": "Свернуть",
"expand": "Развернуть"
},
"dashboard": {
"title": "Панель управления",
"trafficOverview": "Обзор трафика",
"activeSessions": "Активные сессии",
"cpuUsage": "Использование CPU",
"ramUsage": "Использование RAM",
"diskUsage": "Использование диска",
"networkUsage": "Сетевой трафик",
"systemInfo": "Информация о системе",
"osVersion": "Версия ОС",
"incusVersion": "Версия Incus",
"kernelVersion": "Версия ядра",
"imageVersion": "Версия образа",
"distrobuilderVersion": "Версия Distrobuilder",
"totalSites": "Всего сайтов",
"activeSites": "Активных сайтов",
"stoppedSites": "Остановленных",
"totalUsers": "Всего пользователей"
},
"sites": {
"title": "Сайты",
"newSite": "Новый контейнер",
"siteList": "Список контейнеров",
"siteName": "Название",
"siteStatus": "Статус",
"siteIP": "IP адрес",
"siteImage": "Образ",
"siteOwner": "Владелец",
"siteCreated": "Создан",
"noSites": "Контейнеров пока нет",
"createFirst": "Создайте первый контейнер для начала работы",
"targetServer": "Целевой сервер",
"targetServerDesc": "Выберите сервер для создания контейнера",
"detail": {
"overview": "Обзор",
"activity": "Активность",
"logs": "Логи",
"backups": "Резервные копии",
"uptime": "Время работы",
"resources": "Ресурсы",
"actions": "Действия"
},
"actions": {
"start": "Запустить",
"stop": "Остановить",
"restart": "Перезапустить",
"backup": "Создать бэкап",
"restore": "Восстановить",
"delete": "Удалить",
"openSite": "Открыть сайт"
},
"status": {
"running": "Работает",
"stopped": "Остановлен",
"starting": "Запускается",
"stopping": "Останавливается",
"error": "Ошибка"
}
},
"apps": {
"title": "Приложения",
"imageList": "Список образов",
"imageName": "Название",
"imageVersion": "Версия",
"imageStatus": "Статус",
"sitesCount": "Кол-во сайтов",
"createVersion": "Собрать новую версию",
"createApp": "Создать приложение",
"updateImage": "Обновить образ",
"configuration": "Конфигурация",
"versions": "Версии",
"noApps": "Шаблонов приложений не найдено",
"buildImage": "Собрать образ",
"deleteApp": "Удалить приложение"
},
"payments": {
"title": "Платежи",
"overview": "Обзор",
"customers": "Клиенты",
"frozen": "Замороженные",
"reports": "Отчеты",
"totalRevenue": "Общий доход",
"activeSubscriptions": "Активных подписок",
"frozenAccounts": "Замороженных",
"revenueTrend": "Динамика дохода",
"connections": "Подключений",
"customer": {
"name": "Имя",
"email": "Email",
"plan": "Тариф",
"status": "Статус",
"amount": "Сумма",
"lastPayment": "Последний платеж"
}
},
"logs": {
"title": "Логи",
"containerLogs": "Логи контейнеров",
"systemLogs": "Системные логи",
"webServerLogs": "Логи веб-сервера",
"appsLogs": "Логи приложений",
"adminPanelLogs": "Логи админ-панели",
"billingLogs": "Логи биллинга",
"usersLogs": "Логи пользователей",
"sitesLogs": "Логи сайтов",
"level": {
"all": "Все уровни",
"info": "Информация",
"warning": "Предупреждение",
"error": "Ошибка",
"debug": "Отладка"
},
"noLogs": "Логи не найдены",
"filterByLevel": "Фильтр по уровню",
"filterByDate": "Фильтр по дате"
},
"settings": {
"title": "Настройки",
"adminUsers": "Администраторы",
"security": "Безопасность",
"system": "Система",
"notifications": "Уведомления",
"admin": {
"addUser": "Добавить администратора",
"editUser": "Редактировать",
"deleteUser": "Удалить",
"role": "Роль",
"lastLogin": "Последний вход"
},
"security": {
"changePassword": "Изменить пароль",
"twoFactorAuth": "Двухфакторная аутентификация",
"activeSessions": "Активные сессии",
"sessionTimeout": "Таймаут сессии"
},
"system": {
"siteName": "Название сайта",
"language": "Язык",
"timezone": "Часовой пояс",
"dateFormat": "Формат даты"
},
"notification": {
"emailNotifications": "Email уведомления",
"pushNotifications": "Push уведомления",
"newUser": "Регистрация пользователя",
"accountFrozen": "Заморозка аккаунта",
"containerError": "Ошибка контейнера",
"payment": "Получение платежа"
}
},
"servers": {
"title": "Серверы",
"setup": "Установка",
"configuration": "Конфигурация",
"remoteServers": "Удалённые серверы",
"addServer": "Добавить сервер",
"removeServer": "Удалить сервер",
"testConnection": "Проверить соединение",
"serverName": "Имя сервера",
"serverUrl": "URL сервера",
"serverToken": "Токен сервера",
"serverStatus": "Статус",
"noServers": "Удалённые серверы не настроены",
"addFirst": "Добавьте первый удалённый сервер Incus",
"status": {
"online": "Онлайн",
"offline": "Офлайн",
"unknown": "Неизвестно"
},
"roles": {
"title": "Роли сервера",
"imageServer": "Сервер образов",
"containerServer": "Сервер контейнеров"
},
"setup": {
"title": "Установка ПО",
"description": "Установка необходимого ПО для управления контейнерами",
"incusInstalled": "Incus установлен",
"distrobuilderInstalled": "Distrobuilder установлен",
"serviceRunning": "Сервис запущен",
"install": "Установить",
"installDescription": "Будут установлены пакеты incus, incus-client и distrobuilder",
"doasPassword": "Пароль doas",
"doasPasswordDesc": "Требуется для привилегированных операций"
},
"localIncus": {
"title": "Локальный Incus",
"description": "Информация о локальном сервере Incus",
"version": "Версия",
"defaultRemote": "Remote по умолчанию",
"storagePools": "Пулы хранения",
"networks": "Сети"
}
},
"theme": {
"light": "Светлая",
"dark": "Темная",
"toggle": "Переключить тему"
}
}

88
frontend/src/main.js Normal file
View File

@ -0,0 +1,88 @@
import './index.css'
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
import router from './router'
import App from './App.vue'
import {
FrappeUI,
Button,
Input,
TextInput,
FormControl,
ErrorMessage,
Dialog,
Alert,
Badge,
setConfig,
frappeRequest,
FeatherIcon,
Tooltip,
Spinner,
TabButtons,
Tabs,
} from 'frappe-ui'
// Import locales
import en from './locales/en.json'
import ru from './locales/ru.json'
// Setup i18n
const i18n = createI18n({
legacy: false,
locale: localStorage.getItem('admin-panel-locale') || 'ru',
fallbackLocale: 'en',
messages: {
en,
ru,
},
})
const globalComponents = {
Button,
TextInput,
Input,
FormControl,
ErrorMessage,
Dialog,
Alert,
Badge,
FeatherIcon,
Tooltip,
Spinner,
TabButtons,
Tabs,
}
const app = createApp(App)
setConfig('resourceFetcher', frappeRequest)
app.use(FrappeUI, {
socketio: false,
})
app.use(router)
app.use(i18n)
for (const key in globalComponents) {
app.component(key, globalComponents[key])
}
if (import.meta.env.DEV) {
frappeRequest({
url: '/api/method/admin_panel.www.admin.get_context_for_dev',
})
.then((values) => {
for (const key in values) {
window[key] = values[key]
}
})
.catch((e) => {
console.warn('Failed to fetch dev context:', e)
})
.finally(() => {
app.mount('#app')
})
} else {
app.mount('#app')
}

120
frontend/src/mocks/apps.js Normal file
View File

@ -0,0 +1,120 @@
// Mock data for Apps (Images)
export const apps = [
{
id: 'app-001',
name: 'frappe-base',
description: 'Base Frappe Framework image with ERPNext',
currentVersion: 'v3.2.1',
status: 'active',
sitesCount: 35,
lastUpdated: '2024-03-14',
size: '4.2 GB',
},
{
id: 'app-002',
name: 'frappe-hrms',
description: 'Frappe Framework with HRMS module',
currentVersion: 'v2.1.0',
status: 'active',
sitesCount: 12,
lastUpdated: '2024-03-10',
size: '4.5 GB',
},
{
id: 'app-003',
name: 'frappe-crm',
description: 'Frappe Framework with CRM module',
currentVersion: 'v1.5.2',
status: 'active',
sitesCount: 8,
lastUpdated: '2024-03-05',
size: '4.1 GB',
},
{
id: 'app-004',
name: 'frappe-legacy',
description: 'Legacy Frappe v13 for old projects',
currentVersion: 'v1.0.5',
status: 'deprecated',
sitesCount: 2,
lastUpdated: '2024-01-15',
size: '3.8 GB',
},
]
export const appVersions = [
{
version: 'v3.2.1',
released: '2024-03-14',
changelog: 'Security patches and bug fixes',
status: 'current',
sitesCount: 30,
},
{
version: 'v3.2.0',
released: '2024-03-01',
changelog: 'New features: Multi-currency support, improved reports',
status: 'active',
sitesCount: 5,
},
{
version: 'v3.1.0',
released: '2024-02-15',
changelog: 'Performance improvements',
status: 'active',
sitesCount: 3,
},
{
version: 'v3.0.0',
released: '2024-01-10',
changelog: 'Major release with new UI',
status: 'deprecated',
sitesCount: 0,
},
]
export const appConfig = `image:
name: frappe-base
distribution: debian
release: bookworm
architecture: amd64
source:
downloader: debootstrap
same_as: bookworm
url: http://deb.debian.org/debian
targets:
lxc:
create_message: |
Frappe Framework Container Ready
config:
- type: all
content: |
lxc.include = LXC_TEMPLATE_CONFIG/debian.common.conf
packages:
manager: apt
update: true
cleanup: true
sets:
- packages:
- curl
- git
- python3
- python3-pip
- nodejs
- npm
- mariadb-server
- redis-server
- nginx
actions:
- trigger: post-packages
action: |-
#!/bin/bash
set -eux
pip3 install frappe-bench
bench init --frappe-branch version-15 frappe-bench
`

View File

@ -0,0 +1,157 @@
// Mock data for Dashboard
export const trafficData = {
labels: ['14', '15', '16', '17', '18', '19', '20'],
datasets: [
{
name: 'Background jobs',
values: [1200, 800, 1500, 2000, 1800, 900, 2200],
color: '#e5e7eb',
},
{
name: 'Requests',
values: [2000, 1500, 3500, 4500, 3200, 1200, 3800],
color: '#93c5fd',
},
{
name: 'Avg. Session',
values: [800, 600, 1200, 1600, 1100, 500, 1400],
color: '#3b82f6',
},
],
}
export const sessionsData = {
labels: ['Active', 'Idle', 'New'],
datasets: [
{
name: 'Sessions',
values: [65, 25, 10],
},
],
}
export const cpuData = {
labels: ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00', '24:00'],
datasets: [
{
name: 'CPU %',
values: [25, 30, 55, 70, 65, 45, 30],
},
],
}
export const ramData = {
labels: ['Used', 'Free', 'Cached'],
datasets: [
{
name: 'RAM',
values: [45, 30, 25],
},
],
}
export const diskData = {
labels: ['Used', 'Free'],
datasets: [
{
name: 'Disk',
values: [62, 38],
},
],
}
export const networkData = {
labels: ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00', '24:00'],
datasets: [
{
name: 'Download',
values: [100, 120, 250, 400, 350, 280, 200],
},
{
name: 'Upload',
values: [50, 60, 120, 180, 160, 140, 100],
},
],
}
export const systemInfo = {
osVersion: 'Alpine Linux 3.19',
incusVersion: 'Incus 6.0 LTS',
kernelVersion: '6.6.30-0-lts',
imageVersion: 'v3.2.1',
distrobuilderVersion: '3.0',
}
export const statsOverview = {
totalSites: 47,
activeSites: 42,
stoppedSites: 5,
totalUsers: 156,
}
export const dashboardStats = {
sessions: { value: 744, trend: 24, label: 'Sessions' },
conversionRate: { value: 278, trend: 12, label: 'Conversion rate' },
pageViews: { value: 1924, trend: 10, label: 'Page Views' },
}
export const planInfo = {
current: {
name: 'Basic',
price: 399,
currency: 'RUB',
remaining: '84 days remaining',
},
next: {
name: 'Essential',
price: 999,
currency: 'RUB',
period: '1 Year plan',
},
}
export const osInfo = {
vendor: 'Alpine Linux',
version: 'Alpine Linux 3.19 (x86_64)',
architecture: '64-bit',
language: 'English',
servicesAndSoftware: 'Incus Container Manager',
isISOImage: true,
}
export const cpuDailyUsage = {
percent: 72.84,
cycleResets: '6:84 hours',
status: 'Good',
}
export const siteInfo = {
creator: {
name: 'Admin',
email: 'admin@jeycloud.com',
},
createdOn: 'Dec 22, 2024',
lastDeployed: 'Jan 14, 2025',
}
export const installedApps = [
{
name: 'Appveyor',
version: '1.0 Oldest',
lastUpdate: '19 July 2024, 10:30 GMT +5:30',
plans: [
{ name: 'Basic', price: 99 },
{ name: 'Standard', price: 149 },
{ name: 'Premium', price: 390 },
],
},
]
export const serverInfo = {
ip: '118.24.67.82',
location: 'Moscow, Russia',
cpu: 'CPU / 8GB Memory',
storage: '100 GB Storage',
os: 'Alpine Linux 3.19',
}

192
frontend/src/mocks/logs.js Normal file
View File

@ -0,0 +1,192 @@
// Mock data for Logs
export const logTypes = [
{ id: 'container', label: 'logs.containerLogs', icon: 'server' },
{ id: 'system', label: 'logs.systemLogs', icon: 'cpu' },
{ id: 'webserver', label: 'logs.webServerLogs', icon: 'globe' },
{ id: 'apps', label: 'logs.appsLogs', icon: 'package' },
{ id: 'admin', label: 'logs.adminPanelLogs', icon: 'shield' },
{ id: 'billing', label: 'logs.billingLogs', icon: 'credit-card' },
{ id: 'users', label: 'logs.usersLogs', icon: 'users' },
{ id: 'sites', label: 'logs.sitesLogs', icon: 'layers' },
]
export const containerLogs = [
{
timestamp: '2024-03-15 14:32:15',
level: 'info',
container: 'prod-app-001',
message: 'Health check passed',
},
{
timestamp: '2024-03-15 14:31:00',
level: 'info',
container: 'staging-app-001',
message: 'New connection from 192.168.1.100',
},
{
timestamp: '2024-03-15 14:30:45',
level: 'warning',
container: 'prod-app-001',
message: 'High memory usage detected: 85%',
},
{
timestamp: '2024-03-15 14:29:20',
level: 'error',
container: 'analytics-001',
message: 'Container failed to start: OCI runtime error',
},
{
timestamp: '2024-03-15 14:28:00',
level: 'info',
container: 'client-demo-001',
message: 'Container started successfully',
},
]
export const systemLogs = [
{
timestamp: '2024-03-15 14:35:00',
level: 'info',
source: 'incusd',
message: 'Cluster heartbeat successful',
},
{
timestamp: '2024-03-15 14:34:30',
level: 'info',
source: 'kernel',
message: 'Memory reclaim completed, freed 512MB',
},
{
timestamp: '2024-03-15 14:33:15',
level: 'warning',
source: 'storage',
message: 'Pool usage at 75%, consider expansion',
},
{
timestamp: '2024-03-15 14:32:00',
level: 'info',
source: 'network',
message: 'Bridge br0 link up',
},
]
export const webServerLogs = [
{
timestamp: '2024-03-15 14:35:22',
level: 'info',
message: 'GET /api/v1/sites 200 45ms',
},
{
timestamp: '2024-03-15 14:35:20',
level: 'info',
message: 'POST /api/v1/sites/site-001/start 200 1250ms',
},
{
timestamp: '2024-03-15 14:35:15',
level: 'warning',
message: 'Rate limit approached for IP 192.168.1.50',
},
{
timestamp: '2024-03-15 14:35:10',
level: 'error',
message: 'POST /api/v1/sites/site-005/start 500 Internal Server Error',
},
]
export const adminPanelLogs = [
{
timestamp: '2024-03-15 14:30:00',
level: 'info',
user: 'admin@example.com',
action: 'LOGIN',
message: 'User logged in successfully',
},
{
timestamp: '2024-03-15 14:25:00',
level: 'info',
user: 'admin@example.com',
action: 'START_CONTAINER',
message: 'Started container client-demo-001',
},
{
timestamp: '2024-03-15 14:20:00',
level: 'warning',
user: 'developer@example.com',
action: 'DELETE_ATTEMPT',
message: 'Attempted to delete production container (blocked)',
},
]
export const billingLogs = [
{
timestamp: '2024-03-15 10:00:00',
level: 'info',
customer: 'Acme Corporation',
action: 'PAYMENT_RECEIVED',
amount: 42500,
},
{
timestamp: '2024-03-15 09:30:00',
level: 'warning',
customer: 'Startup Labs',
action: 'PAYMENT_FAILED',
message: 'Card declined',
},
{
timestamp: '2024-03-14 15:00:00',
level: 'info',
customer: 'SmallBiz Inc',
action: 'ACCOUNT_FROZEN',
message: 'Auto-frozen due to 60 days overdue',
},
]
export const usersLogs = [
{
timestamp: '2024-03-15 14:30:00',
level: 'info',
user: 'admin@example.com',
action: 'LOGIN',
ip: '192.168.1.100',
},
{
timestamp: '2024-03-15 12:15:00',
level: 'info',
user: 'developer@example.com',
action: 'PASSWORD_CHANGE',
ip: '192.168.1.105',
},
{
timestamp: '2024-03-15 10:00:00',
level: 'warning',
user: 'unknown@hacker.com',
action: 'LOGIN_FAILED',
ip: '203.0.113.50',
message: 'Invalid credentials (5 attempts)',
},
]
export const sitesLogs = [
{
timestamp: '2024-03-15 14:28:00',
level: 'info',
site: 'client-demo-001',
action: 'START',
user: 'admin@example.com',
},
{
timestamp: '2024-03-15 14:00:00',
level: 'info',
site: 'prod-app-001',
action: 'BACKUP_CREATED',
user: 'system',
},
{
timestamp: '2024-03-15 13:45:00',
level: 'info',
site: 'staging-app-001',
action: 'CONFIG_UPDATE',
user: 'developer@example.com',
},
]

View File

@ -0,0 +1,145 @@
// Mock data for Payments
export const paymentStats = {
totalRevenue: 1250000,
activeSubscriptions: 42,
frozenAccounts: 3,
avgRevenuePerUser: 29762,
revenueGrowth: 12.5,
subscriptionGrowth: 8.3,
}
export const revenueData = {
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
datasets: [
{
name: 'Revenue',
values: [150000, 180000, 200000, 220000, 250000, 250000],
},
],
}
export const connectionsByPlan = {
labels: ['1-10', '11-50', '51-100', '100+'],
datasets: [
{
name: 'Customers',
values: [15, 18, 7, 2],
},
],
}
export const customers = [
{
id: 'cust-001',
name: 'Acme Corporation',
email: 'billing@acme.com',
connections: 85,
status: 'active',
monthlyAmount: 42500,
lastPayment: '2024-03-01',
since: '2023-06-15',
},
{
id: 'cust-002',
name: 'TechStart LLC',
email: 'admin@techstart.io',
connections: 23,
status: 'active',
monthlyAmount: 11500,
lastPayment: '2024-03-05',
since: '2023-09-20',
},
{
id: 'cust-003',
name: 'Global Retail',
email: 'it@globalretail.com',
connections: 150,
status: 'active',
monthlyAmount: 75000,
lastPayment: '2024-03-01',
since: '2022-11-01',
},
{
id: 'cust-004',
name: 'SmallBiz Inc',
email: 'owner@smallbiz.co',
connections: 5,
status: 'frozen',
monthlyAmount: 2500,
lastPayment: '2024-01-15',
since: '2023-12-01',
frozenReason: 'Payment overdue 60 days',
},
{
id: 'cust-005',
name: 'Enterprise Solutions',
email: 'finance@enterprise.com',
connections: 200,
status: 'active',
monthlyAmount: 100000,
lastPayment: '2024-03-10',
since: '2022-03-15',
},
{
id: 'cust-006',
name: 'Startup Labs',
email: 'hello@startuplabs.dev',
connections: 12,
status: 'frozen',
monthlyAmount: 6000,
lastPayment: '2024-02-01',
since: '2024-01-10',
frozenReason: 'Payment declined',
},
]
export const frozenAccounts = customers.filter(c => c.status === 'frozen')
export const paymentHistory = [
{
id: 'pay-001',
customerId: 'cust-001',
customerName: 'Acme Corporation',
amount: 42500,
date: '2024-03-01',
status: 'completed',
method: 'bank_transfer',
},
{
id: 'pay-002',
customerId: 'cust-003',
customerName: 'Global Retail',
amount: 75000,
date: '2024-03-01',
status: 'completed',
method: 'bank_transfer',
},
{
id: 'pay-003',
customerId: 'cust-002',
customerName: 'TechStart LLC',
amount: 11500,
date: '2024-03-05',
status: 'completed',
method: 'card',
},
{
id: 'pay-004',
customerId: 'cust-005',
customerName: 'Enterprise Solutions',
amount: 100000,
date: '2024-03-10',
status: 'completed',
method: 'bank_transfer',
},
{
id: 'pay-005',
customerId: 'cust-006',
customerName: 'Startup Labs',
amount: 6000,
date: '2024-03-01',
status: 'failed',
method: 'card',
},
]

195
frontend/src/mocks/sites.js Normal file
View File

@ -0,0 +1,195 @@
// Mock data for Sites
export const sites = [
{
id: 'site-001',
name: 'production-app',
container_name: 'prod-app-001',
status: 'running',
ip_address: '10.100.1.10',
image: 'frappe-base:v3.2.1',
owner: 'admin@example.com',
created: '2024-01-15',
uptime: '45d 12h 30m',
cpu_usage: 35,
ram_usage: 62,
disk_usage: 45,
connections: 127,
},
{
id: 'site-002',
name: 'staging-app',
container_name: 'staging-app-001',
status: 'running',
ip_address: '10.100.1.11',
image: 'frappe-base:v3.2.1',
owner: 'developer@example.com',
created: '2024-02-20',
uptime: '12d 5h 15m',
cpu_usage: 15,
ram_usage: 38,
disk_usage: 22,
connections: 23,
},
{
id: 'site-003',
name: 'test-environment',
container_name: 'test-env-001',
status: 'stopped',
ip_address: '10.100.1.12',
image: 'frappe-base:v3.1.0',
owner: 'qa@example.com',
created: '2024-03-01',
uptime: '0d 0h 0m',
cpu_usage: 0,
ram_usage: 0,
disk_usage: 18,
connections: 0,
},
{
id: 'site-004',
name: 'client-demo',
container_name: 'client-demo-001',
status: 'running',
ip_address: '10.100.1.13',
image: 'frappe-base:v3.2.1',
owner: 'sales@example.com',
created: '2024-03-10',
uptime: '5d 8h 45m',
cpu_usage: 8,
ram_usage: 25,
disk_usage: 12,
connections: 5,
},
{
id: 'site-005',
name: 'analytics-service',
container_name: 'analytics-001',
status: 'error',
ip_address: '10.100.1.14',
image: 'frappe-base:v3.2.0',
owner: 'admin@example.com',
created: '2024-01-20',
uptime: '0d 0h 0m',
cpu_usage: 0,
ram_usage: 0,
disk_usage: 55,
connections: 0,
},
]
export const siteActivity = [
{
id: 1,
type: 'start',
message: 'Container started',
timestamp: '2024-03-15 10:30:00',
user: 'admin@example.com',
},
{
id: 2,
type: 'backup',
message: 'Backup created successfully',
timestamp: '2024-03-15 02:00:00',
user: 'system',
},
{
id: 3,
type: 'update',
message: 'Image updated to v3.2.1',
timestamp: '2024-03-14 15:45:00',
user: 'admin@example.com',
},
{
id: 4,
type: 'restart',
message: 'Container restarted',
timestamp: '2024-03-14 15:40:00',
user: 'admin@example.com',
},
{
id: 5,
type: 'config',
message: 'Configuration updated',
timestamp: '2024-03-13 09:20:00',
user: 'developer@example.com',
},
]
export const siteBackups = [
{
id: 'backup-001',
name: 'auto-backup-20240315',
size: '2.4 GB',
created: '2024-03-15 02:00:00',
type: 'automatic',
status: 'completed',
},
{
id: 'backup-002',
name: 'auto-backup-20240314',
size: '2.3 GB',
created: '2024-03-14 02:00:00',
type: 'automatic',
status: 'completed',
},
{
id: 'backup-003',
name: 'manual-backup-pre-update',
size: '2.3 GB',
created: '2024-03-14 15:30:00',
type: 'manual',
status: 'completed',
},
{
id: 'backup-004',
name: 'auto-backup-20240313',
size: '2.2 GB',
created: '2024-03-13 02:00:00',
type: 'automatic',
status: 'completed',
},
]
export const siteLogs = [
{
timestamp: '2024-03-15 10:35:22',
level: 'info',
message: 'Worker process started with PID 1234',
},
{
timestamp: '2024-03-15 10:35:20',
level: 'info',
message: 'Gunicorn server started on 0.0.0.0:8000',
},
{
timestamp: '2024-03-15 10:35:15',
level: 'info',
message: 'Database connection established',
},
{
timestamp: '2024-03-15 10:35:10',
level: 'warning',
message: 'Redis connection pool size set to default (10)',
},
{
timestamp: '2024-03-15 10:35:05',
level: 'info',
message: 'Starting Frappe application...',
},
{
timestamp: '2024-03-15 10:30:00',
level: 'info',
message: 'Container started successfully',
},
{
timestamp: '2024-03-14 23:45:12',
level: 'error',
message: 'Background job failed: ImportError in module scheduler',
},
{
timestamp: '2024-03-14 23:40:00',
level: 'info',
message: 'Scheduled backup completed',
},
]

View File

@ -0,0 +1,500 @@
<template>
<div class="flex flex-col h-full">
<LayoutHeader>
<template #left>
<div class="flex items-center gap-3">
<Button variant="ghost" @click="$router.push({ name: 'Apps' })">
<FeatherIcon name="arrow-left" class="h-4 w-4" />
</Button>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center">
<FeatherIcon name="package" class="h-5 w-5 text-blue-600" />
</div>
<div>
<h1 class="text-xl font-semibold text-ink-gray-9">
{{ app?.name || route.params.appId }}
</h1>
<p class="text-sm text-ink-gray-5">
{{ app?.currentVersion || '-' }}
</p>
</div>
</div>
</div>
</template>
<template #right>
<Button variant="subtle" @click="refreshData" :loading="appDetail.loading">
<template #prefix>
<FeatherIcon name="refresh-cw" class="h-4 w-4" />
</template>
Refresh
</Button>
<Button variant="solid" @click="showBuildModal = true">
<template #prefix>
<FeatherIcon name="play" class="h-4 w-4" />
</template>
Build Image
</Button>
</template>
</LayoutHeader>
<div class="flex-1 overflow-auto">
<!-- Loading State -->
<div v-if="appDetail.loading" class="flex items-center justify-center h-64">
<Spinner class="h-8 w-8" />
</div>
<template v-else>
<!-- Tabs -->
<div class="border-b px-5">
<nav class="flex gap-2">
<button
v-for="tab in tabs"
:key="tab.id"
class="px-4 py-2 text-sm font-medium transition-colors"
:class="
activeTab === tab.id
? 'border-b-2 border-ink-gray-9 text-ink-gray-9'
: 'text-ink-gray-5 hover:text-ink-gray-7'
"
@click="activeTab = tab.id"
>
{{ tab.label }}
</button>
</nav>
</div>
<div class="p-5">
<!-- Overview Tab -->
<div v-if="activeTab === 'overview'" class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label="Versions"
:value="app?.versions?.length || 0"
iconName="tag"
color="blue"
/>
<StatCard
label="Latest Version"
:value="app?.currentVersion || '-'"
iconName="git-branch"
color="green"
/>
<StatCard
label="Size"
:value="app?.size || '-'"
iconName="hard-drive"
color="purple"
/>
<StatCard
label="Status"
:value="app?.status || '-'"
iconName="activity"
color="gray"
/>
</div>
<div class="rounded-lg border p-5">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-medium text-ink-gray-9">
Description
</h3>
<Button variant="subtle" size="sm" @click="showEditDescModal = true">
<template #prefix>
<FeatherIcon name="edit-2" class="h-4 w-4" />
</template>
Edit
</Button>
</div>
<p class="text-sm text-ink-gray-5">
{{ app?.description || 'No description available' }}
</p>
</div>
</div>
<!-- Versions Tab -->
<div v-else-if="activeTab === 'versions'">
<div v-if="app?.versions?.length" class="rounded-lg border overflow-hidden">
<table class="min-w-full divide-y">
<thead class="bg-surface-gray-2">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Version</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Alias</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Fingerprint</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Created</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Size</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Actions</th>
</tr>
</thead>
<tbody class="divide-y">
<tr v-for="version in app.versions" :key="version.version">
<td class="px-6 py-4">
<span class="text-sm font-medium text-ink-gray-9">{{ version.version }}</span>
</td>
<td class="px-6 py-4 text-sm text-ink-gray-7 font-mono">{{ version.alias }}</td>
<td class="px-6 py-4 text-sm text-ink-gray-7 font-mono">{{ version.fingerprint }}</td>
<td class="px-6 py-4 text-sm text-ink-gray-7">{{ version.released }}</td>
<td class="px-6 py-4 text-sm text-ink-gray-7">{{ version.size }}</td>
<td class="px-6 py-4">
<Badge
:variant="{ current: 'success', active: 'info', deprecated: 'warning' }[version.status] || 'subtle'"
:label="version.status"
/>
</td>
<td class="px-6 py-4">
<Button
variant="ghost"
size="sm"
@click="deleteVersion(version)"
:loading="deletingVersion === version.fingerprint"
>
<FeatherIcon name="trash-2" class="h-4 w-4 text-red-500" />
</Button>
</td>
</tr>
</tbody>
</table>
</div>
<EmptyState
v-else
icon="tag"
title="No versions"
description="Build your first image version using the Build Image button."
/>
</div>
<!-- Configuration Tab -->
<div v-else-if="activeTab === 'configuration'">
<div class="rounded-lg border overflow-hidden">
<div class="flex items-center justify-between p-4 border-b">
<h3 class="text-base font-medium text-ink-gray-9">
Distrobuilder Configuration
</h3>
<div class="flex gap-2">
<Button
v-if="!editConfig"
variant="subtle"
size="sm"
@click="startEditing"
>
<template #prefix>
<FeatherIcon name="edit-2" class="h-4 w-4" />
</template>
Edit
</Button>
<template v-else>
<Button variant="subtle" size="sm" @click="cancelEditing">
Cancel
</Button>
<Button
variant="solid"
size="sm"
@click="saveConfig"
:loading="savingConfig"
>
Save
</Button>
</template>
</div>
</div>
<div class="p-4">
<pre
v-if="!editConfig"
class="bg-gray-900 text-gray-100 p-4 rounded-lg text-sm overflow-x-auto font-mono max-h-[600px] overflow-y-auto"
>{{ app?.yaml_content || '# No configuration available' }}</pre>
<textarea
v-else
v-model="configContent"
class="w-full h-[600px] bg-gray-900 text-gray-100 p-4 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
></textarea>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- Build Image Modal -->
<Dialog v-model="showBuildModal" :options="{ title: 'Build Image' }">
<template #body-content>
<div class="space-y-4">
<FormControl
v-model="buildVersion"
label="Version Tag"
type="text"
placeholder="v1.0.0"
:description="'Leave empty for unversioned build'"
/>
<FormControl
v-model="buildRelease"
label="Release"
type="text"
placeholder="edge"
description="Distribution release (e.g. edge, 3.21, 3.23, 24.04)"
/>
<FormControl
v-model="targetServer"
label="Target Server"
type="select"
:options="targetServerOptions"
description="Select the image server where the built image will be imported"
/>
<div v-if="buildResult" class="mt-4">
<label class="block text-sm font-medium text-ink-gray-7 mb-2">Build Output</label>
<div class="bg-gray-900 text-gray-100 p-4 rounded-lg text-xs font-mono max-h-64 overflow-y-auto">
<div
v-for="(log, index) in buildResult.logs"
:key="index"
:class="{
'text-green-400': log.includes('✓'),
'text-red-400': log.includes('✗') || log.includes('Error'),
'text-yellow-400': log.includes('==='),
}"
>
{{ log }}
</div>
</div>
</div>
</div>
</template>
<template #actions>
<Button variant="subtle" @click="closeBuildModal">
{{ buildResult ? 'Close' : $t('common.cancel') }}
</Button>
<Button
v-if="!buildResult"
variant="solid"
@click="buildImage"
:loading="building"
>
<template #prefix>
<FeatherIcon name="play" class="h-4 w-4" />
</template>
Start Build
</Button>
</template>
</Dialog>
<!-- Edit Description Modal -->
<Dialog v-model="showEditDescModal" :options="{ title: 'Edit Description' }">
<template #body-content>
<FormControl
v-model="editDescription"
label="Description"
type="textarea"
placeholder="Describe this app template..."
/>
</template>
<template #actions>
<Button variant="subtle" @click="showEditDescModal = false">
{{ $t('common.cancel') }}
</Button>
<Button variant="solid" @click="saveDescription" :loading="savingDesc">
Save
</Button>
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { createResource, call, Button, Badge, FormControl, Dialog, Spinner, FeatherIcon } from 'frappe-ui'
import LayoutHeader from '@/components/Common/LayoutHeader.vue'
import StatCard from '@/components/Common/StatCard.vue'
import EmptyState from '@/components/Common/EmptyState.vue'
const route = useRoute()
const activeTab = ref('overview')
const editConfig = ref(false)
const configContent = ref('')
const savingConfig = ref(false)
const showBuildModal = ref(false)
const buildVersion = ref('')
const buildRelease = ref('edge')
const targetServer = ref('')
const building = ref(false)
const buildResult = ref(null)
const showEditDescModal = ref(false)
const editDescription = ref('')
const savingDesc = ref(false)
const deletingVersion = ref(null)
const tabs = [
{ id: 'overview', label: 'Overview' },
{ id: 'versions', label: 'Versions' },
{ id: 'configuration', label: 'Configuration' },
]
const appDetail = createResource({
url: 'admin_panel.api.apps.get_app_detail',
params: { app_name: route.params.appId },
auto: true,
})
const imageServers = createResource({
url: 'admin_panel.api.servers.get_image_servers',
auto: true,
})
const app = computed(() => appDetail.data)
const imageServersList = computed(() => imageServers.data || [])
const targetServerOptions = computed(() => {
const servers = imageServersList.value
return servers.map(s => ({
label: `${s.name}${s.status === 'online' ? '' : ' (' + s.status + ')'}`,
value: s.name,
}))
})
// Auto-select first image server
watch(imageServersList, (servers) => {
if (servers.length && !targetServer.value) {
targetServer.value = servers[0].name
}
}, { immediate: true })
watch(() => app.value?.description, (newDesc) => {
editDescription.value = newDesc || ''
})
function refreshData() {
appDetail.reload()
}
function startEditing() {
configContent.value = app.value?.yaml_content || ''
editConfig.value = true
}
function cancelEditing() {
editConfig.value = false
configContent.value = ''
}
async function saveConfig() {
savingConfig.value = true
try {
const result = await call('admin_panel.api.apps.save_app_config', {
app_name: route.params.appId,
yaml_content: configContent.value,
})
if (result.success) {
console.log(result.message)
editConfig.value = false
appDetail.reload()
} else {
console.error(result.error || 'Failed to save configuration')
}
} catch (error) {
console.error(error.message || 'Failed to save configuration')
} finally {
savingConfig.value = false
}
}
async function buildImage() {
building.value = true
buildResult.value = null
try {
const result = await call('admin_panel.api.apps.build_image', {
app_name: route.params.appId,
version: buildVersion.value,
target_server: targetServer.value,
release: buildRelease.value || 'edge',
})
buildResult.value = result
if (result.success) {
console.log(result.message)
appDetail.reload()
} else {
console.error(result.error || 'Build failed')
}
} catch (error) {
buildResult.value = {
success: false,
error: error.message,
logs: [`Error: ${error.message}`],
}
console.error(error.message || 'Build failed')
} finally {
building.value = false
}
}
function closeBuildModal() {
showBuildModal.value = false
buildResult.value = null
buildVersion.value = ''
buildRelease.value = 'edge'
targetServer.value = imageServersList.value.length ? imageServersList.value[0].name : ''
}
async function saveDescription() {
savingDesc.value = true
try {
const result = await call('admin_panel.api.apps.update_app_meta', {
app_name: route.params.appId,
description: editDescription.value,
})
if (result.success) {
console.log(result.message)
showEditDescModal.value = false
appDetail.reload()
} else {
console.error(result.error || 'Failed to update description')
}
} catch (error) {
console.error(error.message || 'Failed to update description')
} finally {
savingDesc.value = false
}
}
async function deleteVersion(version) {
if (!version.fingerprint || version.fingerprint === '-') {
console.error('Cannot delete: invalid fingerprint')
return
}
// Get full fingerprint from versions API
deletingVersion.value = version.fingerprint
try {
// First get the full fingerprint
const versions = await call('admin_panel.api.apps.get_app_versions', {
app_name: route.params.appId,
})
const fullVersion = versions.find(v => v.fingerprint.startsWith(version.fingerprint))
if (!fullVersion) {
console.error('Version not found')
return
}
const result = await call('admin_panel.api.apps.delete_image', {
fingerprint: fullVersion.fingerprint,
})
if (result.success) {
console.log('Image deleted successfully')
appDetail.reload()
} else {
console.error(result.error || 'Failed to delete image')
}
} catch (error) {
console.error(error.message || 'Failed to delete image')
} finally {
deletingVersion.value = null
}
}
</script>

View File

@ -0,0 +1,311 @@
<template>
<div class="flex flex-col h-full">
<LayoutHeader :title="$t('apps.title')">
<template #right>
<Button variant="subtle" @click="refreshData" :loading="apps.loading">
<template #prefix>
<FeatherIcon name="refresh-cw" class="h-4 w-4" />
</template>
{{ $t('common.refresh') }}
</Button>
<Button variant="solid" @click="showCreateModal = true">
<template #prefix>
<FeatherIcon name="plus" class="h-4 w-4" />
</template>
{{ $t('apps.createApp') || 'Create App' }}
</Button>
</template>
</LayoutHeader>
<div class="flex-1 overflow-auto p-5">
<div v-if="apps.loading" class="flex items-center justify-center h-64">
<Spinner class="h-8 w-8" />
</div>
<div v-else-if="appsList.length" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div
v-for="app in appsList"
:key="app.id"
class="rounded-lg border p-5 hover:shadow-md transition-shadow cursor-pointer"
@click="goToApp(app.id)"
>
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-lg bg-blue-100 flex items-center justify-center">
<FeatherIcon name="package" class="h-6 w-6 text-blue-600" />
</div>
<div>
<h3 class="text-base font-medium text-ink-gray-9">
{{ app.name }}
</h3>
<Badge
class="mt-1"
:variant="app.status === 'active' ? 'success' : 'warning'"
:label="app.status"
/>
</div>
</div>
<Dropdown
:options="getAppActions(app)"
@click.stop
>
<Button variant="ghost" size="sm">
<FeatherIcon name="more-vertical" class="h-4 w-4" />
</Button>
</Dropdown>
</div>
<p class="text-sm text-ink-gray-5 mb-4 line-clamp-2">
{{ app.description }}
</p>
<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-ink-gray-4 text-xs">{{ $t('apps.imageVersion') }}</span>
<p class="font-medium text-ink-gray-9">{{ app.currentVersion || '-' }}</p>
</div>
<div>
<span class="text-ink-gray-4 text-xs">Versions</span>
<p class="font-medium text-ink-gray-9">{{ app.versions_count || 0 }}</p>
</div>
</div>
<div class="mt-4 pt-4 border-t flex justify-between items-center text-xs text-ink-gray-4">
<span>{{ app.size || '-' }}</span>
<span>Updated: {{ app.lastUpdated || '-' }}</span>
</div>
</div>
</div>
<EmptyState
v-else
icon="package"
:title="$t('apps.noApps')"
description="No app templates found. Create one to get started."
>
<template #action>
<Button variant="solid" @click="showCreateModal = true">
{{ $t('apps.createApp') || 'Create App' }}
</Button>
</template>
</EmptyState>
</div>
<!-- Create App Modal -->
<Dialog v-model="showCreateModal" :options="{ title: 'Create App Template' }">
<template #body-content>
<div class="space-y-4">
<FormControl
v-model="newApp.name"
label="App Name"
type="text"
placeholder="my-app"
:description="'Alphanumeric characters, dashes and underscores only'"
/>
<FormControl
v-model="newApp.description"
label="Description"
type="textarea"
placeholder="Description of this app template..."
/>
<div>
<label class="block text-sm font-medium text-ink-gray-7 mb-1">
YAML Configuration
</label>
<textarea
v-model="newApp.yaml_content"
class="w-full h-64 bg-gray-900 text-gray-100 p-4 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="# Distrobuilder YAML configuration..."
></textarea>
</div>
</div>
</template>
<template #actions>
<Button variant="subtle" @click="showCreateModal = false">
{{ $t('common.cancel') }}
</Button>
<Button
variant="solid"
@click="createApp"
:loading="creating"
:disabled="!newApp.name"
>
{{ $t('common.create') }}
</Button>
</template>
</Dialog>
<!-- Delete Confirmation Modal -->
<Dialog v-model="showDeleteModal" :options="{ title: 'Delete App Template' }">
<template #body-content>
<p class="text-sm text-ink-gray-7">
Are you sure you want to delete <strong>{{ appToDelete?.name }}</strong>?
This will only delete the template file, not the built images.
</p>
</template>
<template #actions>
<Button variant="subtle" @click="showDeleteModal = false">
{{ $t('common.cancel') }}
</Button>
<Button variant="solid" theme="red" @click="confirmDelete" :loading="deleting">
Delete
</Button>
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { createResource, call, Button, Badge, FormControl, Dialog, Spinner, FeatherIcon, Dropdown } from 'frappe-ui'
import LayoutHeader from '@/components/Common/LayoutHeader.vue'
import EmptyState from '@/components/Common/EmptyState.vue'
const router = useRouter()
const showCreateModal = ref(false)
const showDeleteModal = ref(false)
const creating = ref(false)
const deleting = ref(false)
const appToDelete = ref(null)
const defaultYamlTemplate = `image:
distribution: "alpinelinux"
source:
downloader: alpinelinux-http
same_as: 3.12
url: https://mirror.csclub.uwaterloo.ca/alpine/
targets:
incus:
vm:
filesystem: ext4
files:
- path: /etc/hostname
generator: hostname
- path: /etc/hosts
generator: hosts
- path: /etc/network/interfaces
generator: dump
content: |-
auto eth0
iface eth0 inet dhcp
packages:
manager: apk
update: true
cleanup: true
sets:
- packages:
- alpine-base
action: install
actions:
- trigger: post-packages
action: |-
#!/bin/sh
set -eux
rm -f /var/cache/apk/*
`
const newApp = ref({
name: '',
description: '',
yaml_content: defaultYamlTemplate,
})
const apps = createResource({
url: 'admin_panel.api.apps.get_apps',
auto: true,
})
const appsList = computed(() => apps.data || [])
function refreshData() {
apps.reload()
}
function goToApp(appId) {
router.push({ name: 'AppDetail', params: { appId } })
}
function getAppActions(app) {
return [
{
label: 'View Details',
icon: 'eye',
onClick: () => goToApp(app.id),
},
{
label: 'Delete',
icon: 'trash-2',
onClick: () => {
appToDelete.value = app
showDeleteModal.value = true
},
},
]
}
async function createApp() {
if (!newApp.value.name) {
console.error('App name is required')
return
}
creating.value = true
try {
const result = await call('admin_panel.api.apps.create_app', {
app_name: newApp.value.name,
description: newApp.value.description,
yaml_content: newApp.value.yaml_content,
})
if (result.success) {
console.log(result.message)
showCreateModal.value = false
newApp.value = {
name: '',
description: '',
yaml_content: defaultYamlTemplate,
}
apps.reload()
} else {
console.error(result.error || 'Failed to create app')
}
} catch (error) {
console.error(error.message || 'Failed to create app')
} finally {
creating.value = false
}
}
async function confirmDelete() {
if (!appToDelete.value) return
deleting.value = true
try {
const result = await call('admin_panel.api.apps.delete_app', {
app_name: appToDelete.value.id,
})
if (result.success) {
console.log(result.message)
showDeleteModal.value = false
appToDelete.value = null
apps.reload()
} else {
console.error(result.error || 'Failed to delete app')
}
} catch (error) {
console.error(error.message || 'Failed to delete app')
} finally {
deleting.value = false
}
}
</script>

View File

@ -0,0 +1,110 @@
<template>
<div class="flex flex-col h-full">
<LayoutHeader :title="$t('payments.customers')">
<template #right>
<TextInput
v-model="searchQuery"
type="text"
:placeholder="$t('common.search')"
class="w-64"
>
<template #prefix>
<FeatherIcon name="search" class="h-4 w-4 text-ink-gray-4" />
</template>
</TextInput>
<Button variant="subtle" @click="exportCustomers">
<template #prefix>
<FeatherIcon name="download" class="h-4 w-4" />
</template>
{{ $t('common.export') }}
</Button>
</template>
</LayoutHeader>
<div class="flex-1 overflow-auto p-5">
<div class="rounded-lg border overflow-hidden">
<table class="min-w-full divide-y">
<thead class="bg-surface-gray-2">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">{{ $t('payments.customer.name') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">{{ $t('payments.customer.email') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">{{ $t('payments.connections') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">{{ $t('payments.customer.amount') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">{{ $t('payments.customer.status') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Since</th>
<th class="px-6 py-3 text-right text-xs font-medium text-ink-gray-5 uppercase">{{ $t('common.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y">
<tr v-for="customer in filteredCustomers" :key="customer.id">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-surface-gray-2 flex items-center justify-center">
<span class="text-sm font-medium text-ink-gray-5">
{{ customer.name.charAt(0) }}
</span>
</div>
<span class="text-sm font-medium text-ink-gray-9">{{ customer.name }}</span>
</div>
</td>
<td class="px-6 py-4 text-sm text-ink-gray-7">{{ customer.email }}</td>
<td class="px-6 py-4 text-sm text-ink-gray-7">{{ customer.connections }}</td>
<td class="px-6 py-4 text-sm font-medium text-ink-gray-9">
{{ formatCurrency(customer.monthlyAmount) }}/mo
</td>
<td class="px-6 py-4">
<Badge
:variant="customer.status === 'active' ? 'success' : 'info'"
:label="customer.status"
/>
</td>
<td class="px-6 py-4 text-sm text-ink-gray-7">{{ customer.since }}</td>
<td class="px-6 py-4 text-right">
<Button variant="ghost" size="sm" @click="viewCustomer(customer)">
<FeatherIcon name="eye" class="h-4 w-4" />
</Button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Button, Badge, TextInput, FeatherIcon } from 'frappe-ui'
import LayoutHeader from '@/components/Common/LayoutHeader.vue'
import { customers as mockCustomers } from '@/mocks/payments'
const searchQuery = ref('')
const customers = ref(mockCustomers)
const filteredCustomers = computed(() => {
if (!searchQuery.value) return customers.value
const query = searchQuery.value.toLowerCase()
return customers.value.filter(
c =>
c.name.toLowerCase().includes(query) ||
c.email.toLowerCase().includes(query)
)
})
function formatCurrency(value) {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0,
}).format(value)
}
function exportCustomers() {
console.log('Exporting customers...')
}
function viewCustomer(customer) {
console.log('Viewing customer:', customer.name)
}
</script>

View File

@ -0,0 +1,321 @@
<template>
<div class="flex flex-col h-full">
<LayoutHeader :title="$t('dashboard.title')">
<template #right>
<Button variant="subtle" @click="refreshData">
<template #prefix>
<FeatherIcon name="refresh-cw" class="h-4 w-4" />
</template>
{{ $t('common.refresh') }}
</Button>
</template>
</LayoutHeader>
<div class="flex-1 overflow-auto p-5">
<!-- Stats Overview -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-6">
<StatCard
:label="$t('dashboard.totalSites')"
:value="stats.totalSites"
iconName="server"
color="blue"
:trend="5"
/>
<StatCard
:label="$t('dashboard.activeSites')"
:value="stats.activeSites"
iconName="activity"
color="green"
:trend="3"
/>
<StatCard
:label="$t('dashboard.stoppedSites')"
:value="stats.stoppedSites"
iconName="pause-circle"
color="yellow"
/>
<StatCard
:label="$t('dashboard.totalUsers')"
:value="stats.totalUsers"
iconName="users"
color="purple"
:trend="8"
/>
</div>
<!-- Charts Row 1 -->
<div class="grid gap-4 lg:grid-cols-2 mb-6">
<ChartCard :title="$t('dashboard.trafficOverview')">
<div class="h-full flex items-end">
<div class="w-full">
<div class="relative h-40">
<div class="absolute inset-0 flex items-end justify-between gap-1.5">
<div
v-for="(value, idx) in trafficData.datasets[0].values"
:key="idx"
class="flex-1 bg-blue-500 rounded-t transition-all"
:style="{ height: `${(value / 5000) * 100}%` }"
></div>
</div>
</div>
<div class="flex justify-between text-xs text-ink-gray-4 mt-2">
<span v-for="label in trafficData.labels" :key="label">{{ label }}</span>
</div>
</div>
</div>
</ChartCard>
<ChartCard :title="$t('dashboard.activeSessions')">
<div class="h-full flex items-center justify-center">
<div class="flex items-center gap-8">
<div class="relative w-32 h-32">
<svg class="w-full h-full transform -rotate-90">
<circle
cx="64" cy="64" r="56"
fill="none"
stroke-width="12"
class="stroke-surface-gray-2"
/>
<circle
cx="64" cy="64" r="56"
fill="none"
stroke-width="12"
stroke-dasharray="351.86"
:stroke-dashoffset="351.86 * (1 - 0.65)"
class="stroke-blue-500"
/>
</svg>
<div class="absolute inset-0 flex items-center justify-center">
<span class="text-2xl font-bold text-ink-gray-9">65%</span>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-blue-500"></div>
<span class="text-sm text-ink-gray-7">Active: 65%</span>
</div>
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-orange-400"></div>
<span class="text-sm text-ink-gray-7">Idle: 25%</span>
</div>
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-green-500"></div>
<span class="text-sm text-ink-gray-7">New: 10%</span>
</div>
</div>
</div>
</div>
</ChartCard>
</div>
<!-- Charts Row 2 -->
<div class="grid gap-4 lg:grid-cols-3 mb-6">
<ChartCard :title="$t('dashboard.cpuUsage')">
<div class="h-full flex items-center justify-center">
<div class="w-full">
<div class="relative h-24">
<svg class="w-full h-full" viewBox="0 0 300 100" preserveAspectRatio="none">
<polyline
fill="none"
stroke-width="2"
class="stroke-green-500"
:points="cpuPoints"
/>
</svg>
</div>
<div class="text-center mt-2">
<span class="text-2xl font-bold text-ink-gray-9">45%</span>
<span class="text-sm text-ink-gray-5 ml-1">avg</span>
</div>
</div>
</div>
</ChartCard>
<ChartCard :title="$t('dashboard.ramUsage')">
<div class="h-full flex items-center justify-center">
<div class="flex items-center gap-6">
<div class="relative w-24 h-24">
<svg class="w-full h-full transform -rotate-90">
<circle
cx="48" cy="48" r="40"
fill="none"
stroke-width="10"
class="stroke-surface-gray-2"
/>
<circle
cx="48" cy="48" r="40"
fill="none"
stroke-width="10"
stroke-dasharray="251.33"
:stroke-dashoffset="251.33 * (1 - 0.45)"
class="stroke-purple-500"
/>
</svg>
<div class="absolute inset-0 flex items-center justify-center">
<span class="text-lg font-bold text-ink-gray-9">45%</span>
</div>
</div>
<div class="space-y-1 text-sm text-ink-gray-7">
<div>Used: 7.2 GB</div>
<div>Free: 4.8 GB</div>
<div>Cached: 4.0 GB</div>
</div>
</div>
</div>
</ChartCard>
<ChartCard :title="$t('dashboard.diskUsage')">
<div class="h-full flex items-center justify-center">
<div class="flex items-center gap-6">
<div class="relative w-24 h-24">
<svg class="w-full h-full transform -rotate-90">
<circle
cx="48" cy="48" r="40"
fill="none"
stroke-width="10"
class="stroke-surface-gray-2"
/>
<circle
cx="48" cy="48" r="40"
fill="none"
stroke-width="10"
stroke-dasharray="251.33"
:stroke-dashoffset="251.33 * (1 - 0.62)"
class="stroke-orange-500"
/>
</svg>
<div class="absolute inset-0 flex items-center justify-center">
<span class="text-lg font-bold text-ink-gray-9">62%</span>
</div>
</div>
<div class="space-y-1 text-sm text-ink-gray-7">
<div>Used: 310 GB</div>
<div>Free: 190 GB</div>
<div>Total: 500 GB</div>
</div>
</div>
</div>
</ChartCard>
</div>
<!-- Network + System Info -->
<div class="grid gap-4 lg:grid-cols-2 mb-6">
<ChartCard :title="$t('dashboard.networkUsage')">
<div class="h-full flex items-center justify-center">
<div class="w-full">
<div class="relative h-24">
<svg class="w-full h-full" viewBox="0 0 300 100" preserveAspectRatio="none">
<polyline
fill="none"
stroke-width="2"
class="stroke-blue-500"
:points="downloadPoints"
/>
<polyline
fill="none"
stroke-width="2"
class="stroke-green-500"
:points="uploadPoints"
/>
</svg>
</div>
<div class="flex justify-center gap-6 mt-2">
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-blue-500"></div>
<span class="text-sm text-ink-gray-7">Download</span>
</div>
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-green-500"></div>
<span class="text-sm text-ink-gray-7">Upload</span>
</div>
</div>
</div>
</div>
</ChartCard>
<!-- System Info -->
<div class="rounded-lg border p-5 shadow-sm">
<h3 class="text-lg font-semibold text-ink-gray-9 mb-4">
{{ $t('dashboard.systemInfo') }}
</h3>
<div class="space-y-3">
<div class="flex justify-between items-center py-2 border-b">
<span class="text-sm text-ink-gray-5">{{ $t('dashboard.osVersion') }}</span>
<span class="text-sm font-medium text-ink-gray-9">{{ systemInfo.osVersion }}</span>
</div>
<div class="flex justify-between items-center py-2 border-b">
<span class="text-sm text-ink-gray-5">{{ $t('dashboard.incusVersion') }}</span>
<span class="text-sm font-medium text-ink-gray-9">{{ systemInfo.incusVersion }}</span>
</div>
<div class="flex justify-between items-center py-2 border-b">
<span class="text-sm text-ink-gray-5">{{ $t('dashboard.kernelVersion') }}</span>
<span class="text-sm font-medium text-ink-gray-9">{{ systemInfo.kernelVersion }}</span>
</div>
<div class="flex justify-between items-center py-2 border-b">
<span class="text-sm text-ink-gray-5">{{ $t('dashboard.imageVersion') }}</span>
<span class="text-sm font-medium text-ink-gray-9">{{ systemInfo.imageVersion }}</span>
</div>
<div class="flex justify-between items-center py-2">
<span class="text-sm text-ink-gray-5">{{ $t('dashboard.distrobuilderVersion') }}</span>
<span class="text-sm font-medium text-ink-gray-9">{{ systemInfo.distrobuilderVersion }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { Button, FeatherIcon } from 'frappe-ui'
import LayoutHeader from '@/components/Common/LayoutHeader.vue'
import StatCard from '@/components/Common/StatCard.vue'
import ChartCard from '@/components/Common/ChartCard.vue'
import {
trafficData,
cpuData,
networkData,
systemInfo,
statsOverview,
} from '@/mocks/dashboard'
const stats = ref(statsOverview)
function refreshData() {
console.log('Refreshing data...')
}
const cpuPoints = computed(() => {
const values = cpuData.datasets[0].values
const max = 100
const width = 300
const height = 100
const step = width / (values.length - 1)
return values
.map((v, i) => `${i * step},${height - (v / max) * height}`)
.join(' ')
})
const downloadPoints = computed(() => {
const values = networkData.datasets[0].values
const max = 400
const width = 300
const height = 100
const step = width / (values.length - 1)
return values
.map((v, i) => `${i * step},${height - (v / max) * height}`)
.join(' ')
})
const uploadPoints = computed(() => {
const values = networkData.datasets[1].values
const max = 400
const width = 300
const height = 100
const step = width / (values.length - 1)
return values
.map((v, i) => `${i * step},${height - (v / max) * height}`)
.join(' ')
})
</script>

View File

@ -0,0 +1,118 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-surface-gray-2 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<div class="text-center">
<AdminLogo class="mx-auto h-16 w-16" />
<h2 class="mt-6 text-3xl font-bold text-ink-gray-9">
Admin Panel
</h2>
<p class="mt-2 text-sm text-ink-gray-5">
{{ $t('auth.login') }}
</p>
</div>
<form class="mt-8 space-y-6" @submit.prevent="handleLogin">
<div class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-ink-gray-7">
{{ $t('auth.email') }}
</label>
<TextInput
id="email"
v-model="email"
type="email"
required
class="mt-1"
:placeholder="$t('auth.email')"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-ink-gray-7">
{{ $t('auth.password') }}
</label>
<TextInput
id="password"
v-model="password"
type="password"
required
class="mt-1"
:placeholder="$t('auth.password')"
/>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<input
id="remember-me"
v-model="rememberMe"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label for="remember-me" class="ml-2 block text-sm text-ink-gray-7">
{{ $t('auth.rememberMe') }}
</label>
</div>
<div class="text-sm">
<a href="#" class="font-medium text-blue-600 hover:text-blue-500">
{{ $t('auth.forgotPassword') }}
</a>
</div>
</div>
<div>
<Button
type="submit"
variant="solid"
class="w-full"
:loading="loading"
>
{{ $t('auth.loginButton') }}
</Button>
</div>
<ErrorMessage v-if="error" :message="error" />
</form>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { Button, TextInput, ErrorMessage, call } from 'frappe-ui'
import AdminLogo from '@/components/Icons/AdminLogo.vue'
import { session } from '@/session'
const router = useRouter()
const route = useRoute()
const email = ref('')
const password = ref('')
const rememberMe = ref(false)
const loading = ref(false)
const error = ref('')
async function handleLogin() {
loading.value = true
error.value = ''
try {
await call('login', {
usr: email.value,
pwd: password.value,
})
await session.init()
const redirect = route.query.redirect || '/dashboard'
router.push(redirect)
} catch (e) {
error.value = e.message || 'Login failed'
} finally {
loading.value = false
}
}
</script>

View File

@ -0,0 +1,190 @@
<template>
<div class="flex flex-col h-full">
<LayoutHeader :title="$t('logs.title')">
<template #right>
<FormControl
v-model="levelFilter"
type="select"
:options="levelOptions"
class="w-32"
/>
<TextInput
v-model="searchQuery"
type="text"
:placeholder="$t('common.search')"
class="w-64"
>
<template #prefix>
<FeatherIcon name="search" class="h-4 w-4 text-ink-gray-4" />
</template>
</TextInput>
<Button variant="subtle" @click="refreshLogs">
<template #prefix>
<FeatherIcon name="refresh-cw" class="h-4 w-4" />
</template>
{{ $t('common.refresh') }}
</Button>
<Button variant="subtle" @click="exportLogs">
<template #prefix>
<FeatherIcon name="download" class="h-4 w-4" />
</template>
{{ $t('common.export') }}
</Button>
</template>
</LayoutHeader>
<div class="flex flex-1 overflow-hidden">
<!-- Sidebar -->
<div class="w-56 border-r bg-surface-gray-2 overflow-y-auto">
<nav class="p-2">
<button
v-for="logType in logTypes"
:key="logType.id"
class="w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm text-left transition-colors"
:class="
activeLogType === logType.id
? 'bg-surface-white shadow-sm text-ink-gray-9'
: 'text-ink-gray-5 hover:bg-surface-white hover:text-ink-gray-7'
"
@click="activeLogType = logType.id"
>
<FeatherIcon :name="logType.icon" class="h-4 w-4" />
{{ $t(logType.label) }}
</button>
</nav>
</div>
<!-- Content -->
<div class="flex-1 overflow-auto p-5">
<div v-if="filteredLogs.length" class="bg-gray-900 rounded-lg overflow-hidden">
<div class="p-4 font-mono text-sm overflow-x-auto max-h-[calc(100vh-200px)]">
<div
v-for="(log, idx) in filteredLogs"
:key="idx"
class="flex gap-4 py-1 hover:bg-gray-800 px-2 rounded"
>
<span class="text-gray-500 flex-shrink-0 w-40">{{ log.timestamp }}</span>
<span
class="flex-shrink-0 w-20 uppercase"
:class="{
'text-green-400': log.level === 'info',
'text-yellow-400': log.level === 'warning',
'text-red-400': log.level === 'error',
'text-gray-400': log.level === 'debug',
}"
>
[{{ log.level }}]
</span>
<span v-if="log.container" class="text-blue-400 flex-shrink-0 w-32">
{{ log.container }}
</span>
<span v-if="log.source" class="text-purple-400 flex-shrink-0 w-24">
{{ log.source }}
</span>
<span v-if="log.user" class="text-cyan-400 flex-shrink-0 w-40">
{{ log.user }}
</span>
<span v-if="log.action" class="text-orange-400 flex-shrink-0 w-28">
{{ log.action }}
</span>
<span class="text-gray-300 flex-1">{{ log.message }}</span>
<span v-if="log.amount" class="text-green-400">
{{ formatCurrency(log.amount) }}
</span>
<span v-if="log.ip" class="text-gray-500">
{{ log.ip }}
</span>
</div>
</div>
</div>
<EmptyState
v-else
icon="file-text"
:title="$t('logs.noLogs')"
description="No log entries match your filters"
/>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Button, TextInput, FormControl, FeatherIcon } from 'frappe-ui'
import LayoutHeader from '@/components/Common/LayoutHeader.vue'
import EmptyState from '@/components/Common/EmptyState.vue'
import {
logTypes,
containerLogs,
systemLogs,
webServerLogs,
adminPanelLogs,
billingLogs,
usersLogs,
sitesLogs,
} from '@/mocks/logs'
const activeLogType = ref('container')
const levelFilter = ref('all')
const searchQuery = ref('')
const levelOptions = [
{ label: 'All Levels', value: 'all' },
{ label: 'Info', value: 'info' },
{ label: 'Warning', value: 'warning' },
{ label: 'Error', value: 'error' },
{ label: 'Debug', value: 'debug' },
]
const logsMap = {
container: containerLogs,
system: systemLogs,
webserver: webServerLogs,
apps: containerLogs,
admin: adminPanelLogs,
billing: billingLogs,
users: usersLogs,
sites: sitesLogs,
}
const currentLogs = computed(() => {
return logsMap[activeLogType.value] || []
})
const filteredLogs = computed(() => {
let logs = currentLogs.value
if (levelFilter.value !== 'all') {
logs = logs.filter(l => l.level === levelFilter.value)
}
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
logs = logs.filter(l =>
l.message?.toLowerCase().includes(query) ||
l.container?.toLowerCase().includes(query) ||
l.source?.toLowerCase().includes(query) ||
l.user?.toLowerCase().includes(query)
)
}
return logs
})
function refreshLogs() {
console.log('Refreshing logs...')
}
function exportLogs() {
console.log('Exporting logs...')
}
function formatCurrency(value) {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0,
}).format(value)
}
</script>

View File

@ -0,0 +1,24 @@
<template>
<div class="min-h-screen flex items-center justify-center bg-surface-gray-2 py-12 px-4">
<div class="text-center">
<h1 class="text-9xl font-bold text-ink-gray-2">404</h1>
<h2 class="mt-4 text-2xl font-semibold text-ink-gray-9">
Page Not Found
</h2>
<p class="mt-2 text-ink-gray-5">
The page you're looking for doesn't exist.
</p>
<Button
variant="solid"
class="mt-6"
@click="$router.push('/dashboard')"
>
Go to Dashboard
</Button>
</div>
</div>
</template>
<script setup>
import { Button } from 'frappe-ui'
</script>

View File

@ -0,0 +1,299 @@
<template>
<div class="flex flex-col h-full">
<LayoutHeader :title="$t('payments.title')">
<template #right>
<Button variant="subtle" @click="exportReport">
<template #prefix>
<FeatherIcon name="download" class="h-4 w-4" />
</template>
{{ $t('common.export') }}
</Button>
</template>
</LayoutHeader>
<div class="flex-1 overflow-auto">
<!-- Tabs -->
<div class="border-b px-5">
<nav class="flex gap-2">
<button
v-for="tab in tabs"
:key="tab.id"
class="px-4 py-2 text-sm font-medium transition-colors"
:class="
activeTab === tab.id
? 'border-b-2 border-ink-gray-9 text-ink-gray-9'
: 'text-ink-gray-5 hover:text-ink-gray-7'
"
@click="activeTab = tab.id"
>
{{ $t(tab.label) }}
</button>
</nav>
</div>
<div class="p-5">
<!-- Overview Tab -->
<div v-if="activeTab === 'overview'" class="space-y-6">
<!-- Stats -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
:label="$t('payments.totalRevenue')"
:value="stats.totalRevenue"
iconName="dollar-sign"
color="green"
format="currency"
:trend="stats.revenueGrowth"
/>
<StatCard
:label="$t('payments.activeSubscriptions')"
:value="stats.activeSubscriptions"
iconName="users"
color="blue"
:trend="stats.subscriptionGrowth"
/>
<StatCard
:label="$t('payments.frozenAccounts')"
:value="stats.frozenAccounts"
iconName="alert-circle"
color="red"
/>
<StatCard
label="Avg Revenue/User"
:value="stats.avgRevenuePerUser"
iconName="trending-up"
color="purple"
format="currency"
/>
</div>
<!-- Revenue Chart -->
<ChartCard :title="$t('payments.revenueTrend')">
<div class="h-full flex items-center justify-center">
<div class="w-full">
<div class="flex justify-between text-sm text-ink-gray-4 mb-2">
<span v-for="label in revenueData.labels" :key="label">{{ label }}</span>
</div>
<div class="relative h-32">
<div class="absolute inset-0 flex items-end justify-between gap-2">
<div
v-for="(value, idx) in revenueData.datasets[0].values"
:key="idx"
class="flex-1 bg-green-500 rounded-t transition-all"
:style="{ height: `${(value / 250000) * 100}%` }"
></div>
</div>
</div>
</div>
</div>
</ChartCard>
<!-- Recent Payments -->
<div class="rounded-lg border overflow-hidden">
<div class="p-4 border-b">
<h3 class="text-base font-medium text-ink-gray-9">
Recent Payments
</h3>
</div>
<table class="min-w-full divide-y">
<thead class="bg-surface-gray-2">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Customer</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Amount</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Method</th>
</tr>
</thead>
<tbody class="divide-y">
<tr v-for="payment in paymentHistory" :key="payment.id">
<td class="px-6 py-4 text-sm text-ink-gray-9">{{ payment.customerName }}</td>
<td class="px-6 py-4 text-sm font-medium text-ink-gray-9">
{{ formatCurrency(payment.amount) }}
</td>
<td class="px-6 py-4 text-sm text-ink-gray-7">{{ payment.date }}</td>
<td class="px-6 py-4">
<Badge
:variant="payment.status === 'completed' ? 'success' : 'error'"
:label="payment.status"
/>
</td>
<td class="px-6 py-4 text-sm text-ink-gray-7">{{ payment.method }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Customers Tab -->
<div v-else-if="activeTab === 'customers'">
<div class="rounded-lg border overflow-hidden">
<table class="min-w-full divide-y">
<thead class="bg-surface-gray-2">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">{{ $t('payments.customer.name') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">{{ $t('payments.customer.email') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">{{ $t('payments.connections') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">{{ $t('payments.customer.amount') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">{{ $t('payments.customer.status') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">{{ $t('payments.customer.lastPayment') }}</th>
</tr>
</thead>
<tbody class="divide-y">
<tr v-for="customer in customers" :key="customer.id">
<td class="px-6 py-4 text-sm font-medium text-ink-gray-9">{{ customer.name }}</td>
<td class="px-6 py-4 text-sm text-ink-gray-7">{{ customer.email }}</td>
<td class="px-6 py-4 text-sm text-ink-gray-7">{{ customer.connections }}</td>
<td class="px-6 py-4 text-sm font-medium text-ink-gray-9">
{{ formatCurrency(customer.monthlyAmount) }}/mo
</td>
<td class="px-6 py-4">
<Badge
:variant="customer.status === 'active' ? 'success' : 'info'"
:label="customer.status"
/>
</td>
<td class="px-6 py-4 text-sm text-ink-gray-7">{{ customer.lastPayment }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Frozen Tab -->
<div v-else-if="activeTab === 'frozen'">
<div v-if="frozenAccounts.length" class="rounded-lg border overflow-hidden">
<table class="min-w-full divide-y">
<thead class="bg-surface-gray-2">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Customer</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Email</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Amount Due</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Reason</th>
<th class="px-6 py-3 text-right text-xs font-medium text-ink-gray-5 uppercase">Actions</th>
</tr>
</thead>
<tbody class="divide-y">
<tr v-for="account in frozenAccounts" :key="account.id">
<td class="px-6 py-4 text-sm font-medium text-ink-gray-9">{{ account.name }}</td>
<td class="px-6 py-4 text-sm text-ink-gray-7">{{ account.email }}</td>
<td class="px-6 py-4 text-sm font-medium text-red-600">
{{ formatCurrency(account.monthlyAmount) }}
</td>
<td class="px-6 py-4 text-sm text-ink-gray-7">{{ account.frozenReason }}</td>
<td class="px-6 py-4 text-right">
<Button variant="subtle" size="sm" @click="unfreezeAccount(account)">
Unfreeze
</Button>
</td>
</tr>
</tbody>
</table>
</div>
<EmptyState
v-else
icon="credit-card"
title="No frozen accounts"
description="All accounts are in good standing"
/>
</div>
<!-- Reports Tab -->
<div v-else-if="activeTab === 'reports'" class="space-y-4">
<div class="rounded-lg border p-5">
<h3 class="text-base font-medium text-ink-gray-9 mb-4">
Generate Report
</h3>
<div class="grid gap-4 sm:grid-cols-3">
<FormControl
v-model="reportParams.type"
label="Report Type"
type="select"
:options="reportTypes"
/>
<FormControl
v-model="reportParams.startDate"
label="Start Date"
type="date"
/>
<FormControl
v-model="reportParams.endDate"
label="End Date"
type="date"
/>
</div>
<div class="mt-4">
<Button variant="solid" @click="generateReport">
<template #prefix>
<FeatherIcon name="file-text" class="h-4 w-4" />
</template>
Generate Report
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { Button, Badge, FormControl, FeatherIcon } from 'frappe-ui'
import LayoutHeader from '@/components/Common/LayoutHeader.vue'
import StatCard from '@/components/Common/StatCard.vue'
import ChartCard from '@/components/Common/ChartCard.vue'
import EmptyState from '@/components/Common/EmptyState.vue'
import {
paymentStats,
revenueData,
customers as mockCustomers,
frozenAccounts as mockFrozenAccounts,
paymentHistory as mockPaymentHistory,
} from '@/mocks/payments'
const activeTab = ref('overview')
const tabs = [
{ id: 'overview', label: 'payments.overview' },
{ id: 'customers', label: 'payments.customers' },
{ id: 'frozen', label: 'payments.frozen' },
{ id: 'reports', label: 'payments.reports' },
]
const stats = ref(paymentStats)
const customers = ref(mockCustomers)
const frozenAccounts = ref(mockFrozenAccounts)
const paymentHistory = ref(mockPaymentHistory)
const reportParams = ref({
type: 'revenue',
startDate: '',
endDate: '',
})
const reportTypes = [
{ label: 'Revenue Report', value: 'revenue' },
{ label: 'Customer Report', value: 'customers' },
{ label: 'Payment History', value: 'payments' },
]
function formatCurrency(value) {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
minimumFractionDigits: 0,
}).format(value)
}
function exportReport() {
console.log('Exporting report...')
}
function unfreezeAccount(account) {
console.log('Unfreezing account:', account.name)
}
function generateReport() {
console.log('Generating report:', reportParams.value)
}
</script>

View File

@ -0,0 +1,691 @@
<template>
<div class="flex flex-col h-full">
<LayoutHeader :title="$t('servers.title')">
<template #right>
<Button variant="subtle" @click="refreshData" :loading="refreshing">
<template #prefix>
<FeatherIcon name="refresh-cw" class="h-4 w-4" />
</template>
{{ $t('common.refresh') }}
</Button>
<Button variant="solid" @click="showAddServerModal = true" :disabled="!certStatus?.has_certificate">
<template #prefix>
<FeatherIcon name="plus" class="h-4 w-4" />
</template>
{{ $t('servers.addServer') }}
</Button>
</template>
</LayoutHeader>
<div class="flex-1 overflow-auto p-5 space-y-6">
<!-- Section 1: Certificate Setup -->
<div class="rounded-lg border p-5">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-base font-medium text-ink-gray-9">
Panel Certificate
</h3>
<p class="text-sm text-ink-gray-5 mt-1">
Client certificate used to authenticate with Incus servers
</p>
</div>
</div>
<div v-if="certStatusRes.loading" class="flex justify-center py-4">
<Spinner class="h-6 w-6" />
</div>
<div v-else>
<!-- Certificate exists -->
<div v-if="certStatus?.has_certificate" class="space-y-4">
<div class="flex items-center gap-3 p-3 rounded-lg bg-green-50">
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-green-100">
<FeatherIcon name="shield" class="h-5 w-5 text-green-600" />
</div>
<div class="flex-1">
<p class="text-sm font-medium text-green-800">Certificate configured</p>
<p class="text-xs text-green-600 font-mono">{{ certStatus.cert_path }}</p>
</div>
<Button variant="subtle" size="sm" @click="showCertModal = true">
<template #prefix>
<FeatherIcon name="eye" class="h-4 w-4" />
</template>
View
</Button>
</div>
<div class="bg-blue-50 rounded-lg p-4">
<p class="text-sm text-blue-800 font-medium mb-2">How to trust this certificate on Incus servers:</p>
<ol class="text-sm text-blue-700 space-y-1 list-decimal list-inside">
<li>Click "View" to copy the certificate content</li>
<li>Save it as <code class="bg-blue-100 px-1 rounded">admin-panel.crt</code> on your Incus server</li>
<li>Run: <code class="bg-blue-100 px-1 rounded">incus config trust add-certificate admin-panel.crt</code></li>
</ol>
</div>
</div>
<!-- No certificate -->
<div v-else class="space-y-4">
<div class="flex items-center gap-3 p-3 rounded-lg bg-yellow-50">
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-yellow-100">
<FeatherIcon name="alert-circle" class="h-5 w-5 text-yellow-600" />
</div>
<div class="flex-1">
<p class="text-sm font-medium text-yellow-800">No certificate</p>
<p class="text-xs text-yellow-600">Generate a client certificate to connect to Incus servers</p>
</div>
<Button variant="solid" @click="generateCertificate" :loading="generating">
<template #prefix>
<FeatherIcon name="key" class="h-4 w-4" />
</template>
Generate Certificate
</Button>
</div>
</div>
</div>
</div>
<!-- Section 2: Distrobuilder Setup -->
<div class="rounded-lg border p-5">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-base font-medium text-ink-gray-9">
{{ $t('servers.setup.title') }}
</h3>
<p class="text-sm text-ink-gray-5 mt-1">
Tools needed for building container images
</p>
</div>
<Button
variant="solid"
@click="showInstallModal = true"
:disabled="setupStatus?.distrobuilder_installed"
>
<template #prefix>
<FeatherIcon name="download" class="h-4 w-4" />
</template>
{{ $t('servers.setup.install') }}
</Button>
</div>
<div v-if="setupStatusRes.loading" class="flex justify-center py-4">
<Spinner class="h-6 w-6" />
</div>
<div v-else class="flex items-center gap-3 p-3 rounded-lg bg-surface-gray-1">
<div
class="w-10 h-10 rounded-full flex items-center justify-center"
:class="setupStatus?.distrobuilder_installed ? 'bg-green-100' : 'bg-red-100'"
>
<FeatherIcon
:name="setupStatus?.distrobuilder_installed ? 'check' : 'x'"
class="h-5 w-5"
:class="setupStatus?.distrobuilder_installed ? 'text-green-600' : 'text-red-600'"
/>
</div>
<div>
<p class="text-sm font-medium text-ink-gray-9">
{{ $t('servers.setup.distrobuilderInstalled') }}
</p>
<p class="text-xs text-ink-gray-5">For building custom images</p>
</div>
</div>
</div>
<!-- Section 3: Remote Servers -->
<div class="rounded-lg border p-5">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-base font-medium text-ink-gray-9">
{{ $t('servers.remoteServers') }}
</h3>
<p class="text-sm text-ink-gray-5 mt-1">
{{ $t('servers.roles.title') }}
</p>
</div>
</div>
<div v-if="serversRes.loading" class="flex justify-center py-8">
<Spinner class="h-8 w-8" />
</div>
<div v-else-if="serversList.length" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div
v-for="server in serversList"
:key="server.name"
class="rounded-lg border p-4 hover:shadow-md transition-shadow"
>
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center">
<FeatherIcon name="cloud" class="h-5 w-5 text-blue-600" />
</div>
<div>
<h4 class="text-sm font-medium text-ink-gray-9">{{ server.name }}</h4>
<Badge
class="mt-1"
:variant="getStatusVariant(server.status)"
:label="getStatusLabel(server.status)"
/>
</div>
</div>
<Dropdown :options="getServerActions(server)" @click.stop>
<Button variant="ghost" size="sm">
<FeatherIcon name="more-vertical" class="h-4 w-4" />
</Button>
</Dropdown>
</div>
<div class="text-xs text-ink-gray-5 mb-3 truncate" :title="server.url">
{{ server.url }}
</div>
<!-- Roles -->
<div class="space-y-2">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="server.is_image_server"
@change="updateServerRole(server.name, 'is_image_server', $event.target.checked)"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-ink-gray-7">{{ $t('servers.roles.imageServer') }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="server.is_container_server"
@change="updateServerRole(server.name, 'is_container_server', $event.target.checked)"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-ink-gray-7">{{ $t('servers.roles.containerServer') }}</span>
</label>
</div>
</div>
</div>
<EmptyState
v-else
icon="cloud"
:title="$t('servers.noServers')"
:description="certStatus?.has_certificate ? $t('servers.addFirst') : 'Generate a certificate first to add servers'"
>
<template #action>
<Button
v-if="certStatus?.has_certificate"
variant="solid"
@click="showAddServerModal = true"
>
{{ $t('servers.addServer') }}
</Button>
<Button
v-else
variant="solid"
@click="generateCertificate"
:loading="generating"
>
Generate Certificate
</Button>
</template>
</EmptyState>
</div>
</div>
<!-- Install Modal -->
<Dialog v-model="showInstallModal" :options="{ title: $t('servers.setup.title') }">
<template #body-content>
<div class="space-y-4">
<p class="text-sm text-ink-gray-7">
Install distrobuilder for building custom container images.
</p>
<FormControl
v-model="doasPassword"
:label="$t('servers.setup.doasPassword')"
type="password"
placeholder="********"
:description="$t('servers.setup.doasPasswordDesc')"
/>
<div v-if="installResult" class="mt-4">
<label class="block text-sm font-medium text-ink-gray-7 mb-2">Output</label>
<div class="bg-gray-900 text-gray-100 p-4 rounded-lg text-xs font-mono max-h-64 overflow-y-auto">
<div
v-for="(log, index) in installResult.logs"
:key="index"
:class="{
'text-green-400': log.includes('Done'),
'text-red-400': log.includes('Failed'),
'text-yellow-400': log.includes('==='),
}"
>
{{ log }}
</div>
</div>
</div>
</div>
</template>
<template #actions>
<Button variant="subtle" @click="closeInstallModal">
{{ installResult ? $t('common.close') : $t('common.cancel') }}
</Button>
<Button
v-if="!installResult"
variant="solid"
@click="runInstall"
:loading="installing"
:disabled="!doasPassword"
>
<template #prefix>
<FeatherIcon name="download" class="h-4 w-4" />
</template>
{{ $t('servers.setup.install') }}
</Button>
</template>
</Dialog>
<!-- Add Server Modal -->
<Dialog v-model="showAddServerModal" :options="{ title: $t('servers.addServer') }">
<template #body-content>
<div class="space-y-4">
<FormControl
v-model="newServer.name"
:label="$t('servers.serverName')"
type="text"
placeholder="production"
description="Alphanumeric, dashes and underscores only"
/>
<FormControl
v-model="newServer.url"
:label="$t('servers.serverUrl')"
type="text"
placeholder="https://192.168.1.100:8443"
/>
<div class="space-y-2">
<label class="block text-sm font-medium text-ink-gray-7">
{{ $t('servers.roles.title') }}
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
v-model="newServer.is_image_server"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-ink-gray-7">{{ $t('servers.roles.imageServer') }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
v-model="newServer.is_container_server"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-ink-gray-7">{{ $t('servers.roles.containerServer') }}</span>
</label>
</div>
<div v-if="addServerError" class="text-sm text-red-500 bg-red-50 p-2 rounded">
{{ addServerError }}
</div>
</div>
</template>
<template #actions>
<Button variant="subtle" @click="closeAddServerModal">
{{ $t('common.cancel') }}
</Button>
<Button
variant="solid"
@click="addServer"
:loading="addingServer"
:disabled="!newServer.name || !newServer.url"
>
{{ $t('common.create') }}
</Button>
</template>
</Dialog>
<!-- Remove Server Confirmation Modal -->
<Dialog v-model="showRemoveModal" :options="{ title: $t('servers.removeServer') }">
<template #body-content>
<p class="text-sm text-ink-gray-7">
Are you sure you want to remove <strong>{{ serverToRemove?.name }}</strong>?
</p>
</template>
<template #actions>
<Button variant="subtle" @click="showRemoveModal = false">
{{ $t('common.cancel') }}
</Button>
<Button variant="solid" theme="red" @click="confirmRemove" :loading="removingServer">
{{ $t('common.delete') }}
</Button>
</template>
</Dialog>
<!-- Certificate View Modal -->
<Dialog v-model="showCertModal" :options="{ title: 'Panel Certificate', size: 'lg' }">
<template #body-content>
<div class="space-y-4">
<p class="text-sm text-ink-gray-7">
Copy this certificate and add it to your Incus servers' trust store.
</p>
<div v-if="certContentRes.loading" class="flex justify-center py-8">
<Spinner class="h-8 w-8" />
</div>
<div v-else-if="certContent" class="space-y-4">
<div class="relative">
<textarea
:value="certContent.certificate"
readonly
class="w-full h-48 rounded-lg border border-gray-300 px-3 py-2 text-xs font-mono bg-gray-50"
></textarea>
<Button
variant="subtle"
size="sm"
class="absolute top-2 right-2"
@click="copyCertificate"
>
<template #prefix>
<FeatherIcon :name="copied ? 'check' : 'copy'" class="h-4 w-4" />
</template>
{{ copied ? 'Copied!' : 'Copy' }}
</Button>
</div>
<div class="bg-blue-50 rounded-lg p-4">
<p class="text-sm text-blue-800 font-medium mb-2">On your Incus server:</p>
<code class="text-sm text-blue-700 block bg-blue-100 p-2 rounded">
incus config trust add-certificate admin-panel.crt
</code>
</div>
</div>
</div>
</template>
<template #actions>
<Button variant="subtle" @click="showCertModal = false">
{{ $t('common.close') }}
</Button>
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { createResource, call, Button, Badge, FormControl, Dialog, Spinner, FeatherIcon, Dropdown } from 'frappe-ui'
import LayoutHeader from '@/components/Common/LayoutHeader.vue'
import EmptyState from '@/components/Common/EmptyState.vue'
const refreshing = ref(false)
const showInstallModal = ref(false)
const showAddServerModal = ref(false)
const showRemoveModal = ref(false)
const showCertModal = ref(false)
const serverToRemove = ref(null)
const doasPassword = ref('')
const installing = ref(false)
const installResult = ref(null)
const addingServer = ref(false)
const removingServer = ref(false)
const generating = ref(false)
const copied = ref(false)
const addServerError = ref('')
const newServer = ref({
name: '',
url: '',
is_image_server: false,
is_container_server: false,
})
// Resources
const certStatusRes = createResource({
url: 'admin_panel.api.servers.get_certificate_status',
auto: true,
})
const setupStatusRes = createResource({
url: 'admin_panel.api.servers.get_setup_status',
auto: true,
})
const serversRes = createResource({
url: 'admin_panel.api.servers.get_servers',
auto: true,
})
const certContentRes = createResource({
url: 'admin_panel.api.servers.get_client_certificate',
})
const certStatus = computed(() => certStatusRes.data)
const setupStatus = computed(() => setupStatusRes.data)
const serversList = computed(() => serversRes.data || [])
const certContent = computed(() => certContentRes.data)
// Load certificate content when modal opens
watch(showCertModal, (show) => {
if (show && !certContentRes.data) {
certContentRes.fetch()
}
})
function refreshData() {
refreshing.value = true
Promise.all([
certStatusRes.reload(),
setupStatusRes.reload(),
serversRes.reload(),
]).finally(() => {
refreshing.value = false
})
}
function getStatusVariant(status) {
switch (status) {
case 'online':
return 'success'
case 'offline':
case 'error':
return 'error'
case 'timeout':
case 'no_cert':
return 'orange'
default:
return 'subtle'
}
}
function getStatusLabel(status) {
switch (status) {
case 'online':
return 'Online'
case 'offline':
return 'Offline'
case 'timeout':
return 'Timeout'
case 'no_cert':
return 'Not Trusted'
case 'error':
return 'Error'
default:
return 'Unknown'
}
}
function getServerActions(server) {
return [
{
label: 'Test Connection',
icon: 'wifi',
onClick: () => testServerConnection(server.name),
},
{
label: 'Remove',
icon: 'trash-2',
onClick: () => {
serverToRemove.value = server
showRemoveModal.value = true
},
},
]
}
async function generateCertificate() {
generating.value = true
try {
const result = await call('admin_panel.api.servers.generate_client_certificate')
if (result.success) {
certStatusRes.reload()
} else {
console.error(result.error || 'Failed to generate certificate')
}
} catch (error) {
console.error(error.message || 'Failed to generate certificate')
} finally {
generating.value = false
}
}
function copyCertificate() {
if (certContent.value?.certificate) {
navigator.clipboard.writeText(certContent.value.certificate)
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
}
}
async function runInstall() {
installing.value = true
installResult.value = null
try {
const result = await call('admin_panel.api.servers.run_setup', {
doas_password: doasPassword.value,
})
installResult.value = result
if (result.success) {
setupStatusRes.reload()
}
} catch (error) {
installResult.value = {
success: false,
logs: [`Error: ${error.message}`],
}
} finally {
installing.value = false
}
}
function closeInstallModal() {
showInstallModal.value = false
doasPassword.value = ''
installResult.value = null
}
async function testServerConnection(serverName) {
try {
const result = await call('admin_panel.api.servers.test_connection', {
name: serverName,
})
if (result.success) {
console.log(`${serverName}: Connection successful`)
} else {
console.error(`${serverName}: ${result.message}`)
}
serversRes.reload()
} catch (error) {
console.error(error.message || 'Connection test failed')
}
}
function closeAddServerModal() {
showAddServerModal.value = false
addServerError.value = ''
newServer.value = {
name: '',
url: '',
is_image_server: false,
is_container_server: false,
}
}
async function addServer() {
addingServer.value = true
addServerError.value = ''
try {
const result = await call('admin_panel.api.servers.add_server', {
name: newServer.value.name,
url: newServer.value.url,
is_image_server: newServer.value.is_image_server,
is_container_server: newServer.value.is_container_server,
})
if (!result.success) {
addServerError.value = result.error || 'Failed to add server'
return
}
closeAddServerModal()
serversRes.reload()
} catch (error) {
addServerError.value = error.message || error.exc || 'Failed to add server'
} finally {
addingServer.value = false
}
}
async function confirmRemove() {
if (!serverToRemove.value) return
removingServer.value = true
try {
const result = await call('admin_panel.api.servers.remove_server', {
name: serverToRemove.value.name,
})
if (result.success) {
console.log(result.message)
showRemoveModal.value = false
serverToRemove.value = null
serversRes.reload()
} else {
console.error(result.error || 'Failed to remove server')
}
} catch (error) {
console.error(error.message || 'Failed to remove server')
} finally {
removingServer.value = false
}
}
async function updateServerRole(serverName, roleField, value) {
try {
const params = { name: serverName }
params[roleField] = value
const result = await call('admin_panel.api.servers.update_server', params)
if (result.success) {
console.log(result.message)
} else {
console.error(result.error || 'Failed to update server')
serversRes.reload()
}
} catch (error) {
console.error(error.message || 'Failed to update server')
serversRes.reload()
}
}
</script>

View File

@ -0,0 +1,434 @@
<template>
<div class="flex flex-col h-full">
<LayoutHeader :title="$t('settings.title')" />
<div class="flex-1 overflow-auto">
<!-- Tabs -->
<div class="border-b px-5">
<nav class="flex gap-2">
<button
v-for="tab in tabs"
:key="tab.id"
class="px-4 py-2 text-sm font-medium transition-colors"
:class="
activeTab === tab.id
? 'border-b-2 border-ink-gray-9 text-ink-gray-9'
: 'text-ink-gray-5 hover:text-ink-gray-7'
"
@click="activeTab = tab.id"
>
{{ $t(tab.label) }}
</button>
</nav>
</div>
<div class="p-5">
<!-- Admin Users Tab -->
<div v-if="activeTab === 'adminUsers'" class="space-y-4">
<div class="flex justify-between items-center">
<h3 class="text-lg font-medium text-ink-gray-9">
{{ $t('settings.adminUsers') }}
</h3>
<Button variant="solid" @click="showAddUserModal = true">
<template #prefix>
<FeatherIcon name="plus" class="h-4 w-4" />
</template>
{{ $t('settings.admin.addUser') }}
</Button>
</div>
<div class="rounded-lg border overflow-hidden">
<table class="min-w-full divide-y">
<thead class="bg-surface-gray-2">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Email</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">{{ $t('settings.admin.role') }}</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">{{ $t('settings.admin.lastLogin') }}</th>
<th class="px-6 py-3 text-right text-xs font-medium text-ink-gray-5 uppercase">{{ $t('common.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y">
<tr v-for="user in adminUsers" :key="user.id">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center">
<span class="text-sm font-medium text-blue-600">
{{ user.name.charAt(0) }}
</span>
</div>
<span class="text-sm font-medium text-ink-gray-9">{{ user.name }}</span>
</div>
</td>
<td class="px-6 py-4 text-sm text-ink-gray-7">{{ user.email }}</td>
<td class="px-6 py-4">
<Badge
:variant="user.role === 'Super Admin' ? 'warning' : 'info'"
:label="user.role"
/>
</td>
<td class="px-6 py-4 text-sm text-ink-gray-7">{{ user.lastLogin }}</td>
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" @click="editUser(user)">
<FeatherIcon name="edit-2" class="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" @click="deleteUser(user)">
<FeatherIcon name="trash-2" class="h-4 w-4 text-red-500" />
</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Security Tab -->
<div v-else-if="activeTab === 'security'" class="space-y-6">
<div class="rounded-lg border p-5">
<h3 class="text-base font-medium text-ink-gray-9 mb-4">
{{ $t('settings.security.changePassword') }}
</h3>
<div class="space-y-4 max-w-md">
<FormControl
v-model="passwordForm.current"
label="Current Password"
type="password"
/>
<FormControl
v-model="passwordForm.new"
label="New Password"
type="password"
/>
<FormControl
v-model="passwordForm.confirm"
label="Confirm Password"
type="password"
/>
<Button variant="solid" @click="changePassword">
Update Password
</Button>
</div>
</div>
<div class="rounded-lg border p-5">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-base font-medium text-ink-gray-9">
{{ $t('settings.security.twoFactorAuth') }}
</h3>
<p class="text-sm text-ink-gray-5 mt-1">
Add an extra layer of security to your account
</p>
</div>
<Button variant="subtle">
Enable 2FA
</Button>
</div>
</div>
<div class="rounded-lg border p-5">
<h3 class="text-base font-medium text-ink-gray-9 mb-4">
{{ $t('settings.security.activeSessions') }}
</h3>
<div class="space-y-3">
<div
v-for="session in activeSessions"
:key="session.id"
class="flex items-center justify-between py-2 border-b last:border-0"
>
<div class="flex items-center gap-3">
<FeatherIcon name="monitor" class="h-5 w-5 text-ink-gray-4" />
<div>
<p class="text-sm text-ink-gray-9">{{ session.device }}</p>
<p class="text-xs text-ink-gray-4">{{ session.location }} - {{ session.lastActive }}</p>
</div>
</div>
<Button v-if="!session.current" variant="ghost" size="sm">
Revoke
</Button>
<Badge v-else variant="success" label="Current" />
</div>
</div>
</div>
</div>
<!-- System Tab -->
<div v-else-if="activeTab === 'system'" class="space-y-6">
<div class="rounded-lg border p-5">
<h3 class="text-base font-medium text-ink-gray-9 mb-4">
{{ $t('settings.system.language') }}
</h3>
<div class="max-w-md">
<FormControl
v-model="systemSettings.language"
type="select"
:options="languageOptions"
@change="updateLanguage"
/>
</div>
</div>
<div class="rounded-lg border p-5">
<h3 class="text-base font-medium text-ink-gray-9 mb-4">
{{ $t('settings.system.timezone') }}
</h3>
<div class="max-w-md">
<FormControl
v-model="systemSettings.timezone"
type="select"
:options="timezoneOptions"
/>
</div>
</div>
<div class="rounded-lg border p-5">
<h3 class="text-base font-medium text-ink-gray-9 mb-4">
{{ $t('settings.system.dateFormat') }}
</h3>
<div class="max-w-md">
<FormControl
v-model="systemSettings.dateFormat"
type="select"
:options="dateFormatOptions"
/>
</div>
</div>
<div class="flex justify-end">
<Button variant="solid" @click="saveSystemSettings">
{{ $t('common.save') }}
</Button>
</div>
</div>
<!-- Notifications Tab -->
<div v-else-if="activeTab === 'notifications'" class="space-y-6">
<div class="rounded-lg border p-5">
<h3 class="text-base font-medium text-ink-gray-9 mb-4">
{{ $t('settings.notification.emailNotifications') }}
</h3>
<div class="space-y-4">
<label class="flex items-center justify-between">
<span class="text-sm text-ink-gray-7">{{ $t('settings.notification.newUser') }}</span>
<input
v-model="notificationSettings.newUser"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
</label>
<label class="flex items-center justify-between">
<span class="text-sm text-ink-gray-7">{{ $t('settings.notification.accountFrozen') }}</span>
<input
v-model="notificationSettings.accountFrozen"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
</label>
<label class="flex items-center justify-between">
<span class="text-sm text-ink-gray-7">{{ $t('settings.notification.containerError') }}</span>
<input
v-model="notificationSettings.containerError"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
</label>
<label class="flex items-center justify-between">
<span class="text-sm text-ink-gray-7">{{ $t('settings.notification.payment') }}</span>
<input
v-model="notificationSettings.payment"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
</label>
</div>
</div>
<div class="flex justify-end">
<Button variant="solid" @click="saveNotificationSettings">
{{ $t('common.save') }}
</Button>
</div>
</div>
</div>
</div>
<!-- Add User Modal -->
<Dialog v-model="showAddUserModal" :options="{ title: $t('settings.admin.addUser') }">
<template #body-content>
<div class="space-y-4">
<FormControl
v-model="newUser.name"
label="Name"
type="text"
/>
<FormControl
v-model="newUser.email"
label="Email"
type="email"
/>
<FormControl
v-model="newUser.role"
label="Role"
type="select"
:options="roleOptions"
/>
<FormControl
v-model="newUser.password"
label="Password"
type="password"
/>
</div>
</template>
<template #actions>
<Button variant="subtle" @click="showAddUserModal = false">
{{ $t('common.cancel') }}
</Button>
<Button variant="solid" @click="addUser">
{{ $t('common.create') }}
</Button>
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Button, Badge, FormControl, Dialog, FeatherIcon } from 'frappe-ui'
import LayoutHeader from '@/components/Common/LayoutHeader.vue'
const { locale } = useI18n()
const activeTab = ref('adminUsers')
const showAddUserModal = ref(false)
const tabs = [
{ id: 'adminUsers', label: 'settings.adminUsers' },
{ id: 'security', label: 'settings.security' },
{ id: 'system', label: 'settings.system' },
{ id: 'notifications', label: 'settings.notifications' },
]
const adminUsers = ref([
{
id: 1,
name: 'Admin User',
email: 'admin@example.com',
role: 'Super Admin',
lastLogin: '2024-03-15 10:30',
},
{
id: 2,
name: 'Developer',
email: 'dev@example.com',
role: 'Admin',
lastLogin: '2024-03-14 15:45',
},
{
id: 3,
name: 'Support',
email: 'support@example.com',
role: 'Viewer',
lastLogin: '2024-03-13 09:00',
},
])
const newUser = ref({
name: '',
email: '',
role: 'Admin',
password: '',
})
const roleOptions = [
{ label: 'Super Admin', value: 'Super Admin' },
{ label: 'Admin', value: 'Admin' },
{ label: 'Viewer', value: 'Viewer' },
]
const passwordForm = ref({
current: '',
new: '',
confirm: '',
})
const activeSessions = ref([
{
id: 1,
device: 'Chrome on MacOS',
location: 'Moscow, Russia',
lastActive: 'Now',
current: true,
},
{
id: 2,
device: 'Firefox on Windows',
location: 'Saint Petersburg, Russia',
lastActive: '2 hours ago',
current: false,
},
])
const systemSettings = ref({
language: 'ru',
timezone: 'Europe/Moscow',
dateFormat: 'DD.MM.YYYY',
})
const languageOptions = [
{ label: 'English', value: 'en' },
{ label: 'Русский', value: 'ru' },
]
const timezoneOptions = [
{ label: 'Europe/Moscow (UTC+3)', value: 'Europe/Moscow' },
{ label: 'Europe/London (UTC+0)', value: 'Europe/London' },
{ label: 'America/New_York (UTC-5)', value: 'America/New_York' },
]
const dateFormatOptions = [
{ label: 'DD.MM.YYYY', value: 'DD.MM.YYYY' },
{ label: 'MM/DD/YYYY', value: 'MM/DD/YYYY' },
{ label: 'YYYY-MM-DD', value: 'YYYY-MM-DD' },
]
const notificationSettings = ref({
newUser: true,
accountFrozen: true,
containerError: true,
payment: true,
})
function addUser() {
console.log('Adding user:', newUser.value)
showAddUserModal.value = false
newUser.value = { name: '', email: '', role: 'Admin', password: '' }
}
function editUser(user) {
console.log('Editing user:', user)
}
function deleteUser(user) {
console.log('Deleting user:', user)
}
function changePassword() {
console.log('Changing password')
passwordForm.value = { current: '', new: '', confirm: '' }
}
function updateLanguage() {
locale.value = systemSettings.value.language
localStorage.setItem('admin-panel-locale', systemSettings.value.language)
}
function saveSystemSettings() {
console.log('Saving system settings:', systemSettings.value)
}
function saveNotificationSettings() {
console.log('Saving notification settings:', notificationSettings.value)
}
</script>

View File

@ -0,0 +1,840 @@
<template>
<div class="flex flex-col h-full">
<LayoutHeader>
<template #left>
<div class="flex items-center gap-3">
<Button variant="ghost" @click="$router.push({ name: 'Sites' })">
<FeatherIcon name="arrow-left" class="h-4 w-4" />
</Button>
<div v-if="detail.loading && !site">
<div class="h-5 w-40 bg-surface-gray-2 rounded animate-pulse"></div>
<div class="h-4 w-24 bg-surface-gray-2 rounded animate-pulse mt-1"></div>
</div>
<div v-else>
<h1 class="text-xl font-semibold text-ink-gray-9">
{{ site?.name }}
</h1>
<p class="text-sm text-ink-gray-5">
{{ site?.container_name }}
</p>
</div>
<Badge
v-if="site"
:variant="getStatusBadge(site.status)"
:label="site.status"
/>
</div>
</template>
<template #right>
<Button
v-if="site?.status !== 'Running'"
variant="subtle"
@click="startSite"
:loading="actionLoading === 'start'"
>
<template #prefix>
<FeatherIcon name="play" class="h-4 w-4" />
</template>
{{ $t('sites.actions.start') }}
</Button>
<Button
v-if="site?.status === 'Running'"
variant="subtle"
@click="stopSite"
:loading="actionLoading === 'stop'"
>
<template #prefix>
<FeatherIcon name="square" class="h-4 w-4" />
</template>
{{ $t('sites.actions.stop') }}
</Button>
<Button
variant="subtle"
@click="restartSite"
:loading="actionLoading === 'restart'"
>
<template #prefix>
<FeatherIcon name="rotate-cw" class="h-4 w-4" />
</template>
{{ $t('sites.actions.restart') }}
</Button>
<Button
variant="subtle"
@click="openUpdateModal"
>
<template #prefix>
<FeatherIcon name="download" class="h-4 w-4" />
</template>
Update
</Button>
<Button variant="solid" @click="openSite">
<template #prefix>
<FeatherIcon name="external-link" class="h-4 w-4" />
</template>
{{ $t('sites.actions.openSite') }}
</Button>
</template>
</LayoutHeader>
<div class="flex-1 overflow-auto">
<!-- Tabs -->
<div class="border-b px-5">
<nav class="flex gap-2">
<button
v-for="tab in tabs"
:key="tab.id"
class="px-4 py-2 text-sm font-medium transition-colors"
:class="
activeTab === tab.id
? 'border-b-2 border-ink-gray-9 text-ink-gray-9'
: 'text-ink-gray-5 hover:text-ink-gray-7'
"
@click="activeTab = tab.id"
>
{{ $t(tab.label) }}
</button>
</nav>
</div>
<div class="p-5">
<!-- Loading state (only on initial load) -->
<div v-if="detail.loading && !site" class="flex items-center justify-center h-64">
<Spinner class="h-8 w-8" />
</div>
<!-- Error state (only when no data at all) -->
<div v-else-if="detail.error && !site" class="flex flex-col items-center justify-center h-64 gap-3">
<FeatherIcon name="alert-circle" class="h-10 w-10 text-red-500" />
<p class="text-sm text-ink-gray-5">Failed to load container details</p>
<Button variant="subtle" @click="detail.reload()">Retry</Button>
</div>
<!-- Tab content -->
<template v-else>
<!-- Overview Tab -->
<div v-show="activeTab === 'overview'">
<div class="grid gap-6 lg:grid-cols-2">
<!-- Container Information -->
<div class="rounded-lg border p-5">
<h2 class="text-lg font-semibold text-ink-gray-9 mb-4">
Container Information
</h2>
<div class="space-y-3">
<div class="flex justify-between py-2 border-b">
<span class="text-sm text-ink-gray-5">IP Address</span>
<span class="text-sm font-medium font-mono text-ink-gray-9">{{ site?.ip_address || '-' }}</span>
</div>
<div class="flex justify-between py-2 border-b">
<span class="text-sm text-ink-gray-5">Image</span>
<span class="text-sm font-medium text-ink-gray-9">{{ site?.image || '-' }}</span>
</div>
<div class="flex justify-between py-2 border-b">
<span class="text-sm text-ink-gray-5">Architecture</span>
<span class="text-sm font-medium text-ink-gray-9">{{ site?.architecture || '-' }}</span>
</div>
<div class="flex justify-between py-2 border-b">
<span class="text-sm text-ink-gray-5">Type</span>
<span class="text-sm font-medium text-ink-gray-9">{{ site?.type || '-' }}</span>
</div>
<div class="flex justify-between py-2 border-b">
<span class="text-sm text-ink-gray-5">Owner</span>
<span class="text-sm font-medium text-ink-gray-9">{{ site?.owner || '-' }}</span>
</div>
<div class="flex justify-between py-2 border-b">
<span class="text-sm text-ink-gray-5">Created</span>
<span class="text-sm font-medium text-ink-gray-9">{{ site?.created || '-' }}</span>
</div>
<div class="flex justify-between py-2">
<span class="text-sm text-ink-gray-5">Uptime</span>
<span class="text-sm font-medium text-ink-gray-9">{{ site?.uptime || '-' }}</span>
</div>
</div>
</div>
<!-- Resource Usage -->
<div class="rounded-lg border p-5">
<h2 class="text-lg font-semibold text-ink-gray-9 mb-4">
Resource Usage
</h2>
<div v-if="site?.status === 'Running'" class="space-y-4">
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-ink-gray-5">CPU</span>
<span class="text-ink-gray-9 font-medium">{{ site?.cpu_usage || 0 }}%</span>
</div>
<div class="w-full bg-surface-gray-2 rounded-full h-2">
<div
class="bg-blue-500 h-2 rounded-full transition-all"
:style="{ width: `${site?.cpu_usage || 0}%` }"
></div>
</div>
</div>
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-ink-gray-5">RAM</span>
<span class="text-ink-gray-9 font-medium">{{ site?.ram_usage || 0 }}%</span>
</div>
<div class="w-full bg-surface-gray-2 rounded-full h-2">
<div
class="bg-purple-500 h-2 rounded-full transition-all"
:style="{ width: `${site?.ram_usage || 0}%` }"
></div>
</div>
<p class="text-xs text-ink-gray-4 mt-1">
{{ formatBytes(site?.memory_usage_bytes) }} / {{ formatBytes(site?.memory_total_bytes) }}
</p>
</div>
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-ink-gray-5">Disk</span>
<span class="text-ink-gray-9 font-medium">{{ site?.disk_usage || 0 }}%</span>
</div>
<div class="w-full bg-surface-gray-2 rounded-full h-2">
<div
class="bg-orange-500 h-2 rounded-full transition-all"
:style="{ width: `${site?.disk_usage || 0}%` }"
></div>
</div>
<p class="text-xs text-ink-gray-4 mt-1">
{{ formatBytes(site?.disk_usage_bytes) }} / {{ formatBytes(site?.disk_total_bytes) }}
</p>
</div>
<div class="grid grid-cols-2 gap-4 pt-2 border-t text-sm">
<div>
<p class="text-ink-gray-4 text-xs">Network RX</p>
<p class="font-medium text-ink-gray-9">{{ formatBytes(site?.network_rx) }}</p>
</div>
<div>
<p class="text-ink-gray-4 text-xs">Network TX</p>
<p class="font-medium text-ink-gray-9">{{ formatBytes(site?.network_tx) }}</p>
</div>
</div>
<div class="text-sm text-ink-gray-5">
Processes: <span class="font-medium text-ink-gray-9">{{ site?.connections || 0 }}</span>
</div>
</div>
<div v-else class="flex items-center justify-center h-32 text-ink-gray-4 text-sm">
Container is not running
</div>
</div>
</div>
</div>
<!-- Activity Tab -->
<div v-show="activeTab === 'activity'">
<div class="mb-3 text-xs text-ink-gray-4 bg-yellow-50 border border-yellow-200 rounded p-2">
Activity tracking is coming soon. Showing placeholder data.
</div>
<div class="rounded-lg border overflow-hidden">
<div v-if="siteActivity.length" class="divide-y">
<div
v-for="activity in siteActivity"
:key="activity.id"
class="p-4 flex items-start gap-4"
>
<div
class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center"
:class="activityIconClasses[activity.type] || 'bg-surface-gray-2 text-ink-gray-5'"
>
<FeatherIcon :name="activityIcons[activity.type] || 'settings'" class="h-4 w-4" />
</div>
<div class="flex-1 min-w-0">
<p class="text-sm text-ink-gray-9">{{ activity.message }}</p>
<p class="text-xs text-ink-gray-4 mt-1">
{{ activity.timestamp }} - {{ activity.user }}
</p>
</div>
</div>
</div>
<div v-else class="p-8 text-center text-sm text-ink-gray-4">
No activity recorded yet
</div>
</div>
</div>
<!-- Logs Tab -->
<div v-show="activeTab === 'logs'">
<div class="flex justify-end mb-3">
<Button variant="subtle" size="sm" @click="loadLogs" :loading="containerLogs.loading">
<template #prefix>
<FeatherIcon name="refresh-cw" class="h-3 w-3" />
</template>
Refresh Logs
</Button>
</div>
<div v-if="containerLogs.error" class="text-sm text-red-500 bg-red-50 p-2 rounded mb-3">
Error: {{ containerLogs.error }}
</div>
<div class="bg-gray-900 rounded-lg p-4 font-mono text-sm overflow-x-auto min-h-[200px]">
<div v-if="containerLogs.loading && !logEntries.length" class="flex items-center justify-center py-8">
<Spinner class="h-6 w-6 text-gray-400" />
</div>
<div v-else-if="logEntries.length">
<div
v-for="(log, idx) in logEntries"
:key="idx"
class="flex gap-4 py-1"
>
<span
v-if="log.source"
class="text-gray-600 flex-shrink-0 text-xs"
>[{{ log.source }}]</span>
<span
class="flex-shrink-0 w-16"
:class="{
'text-green-400': log.level === 'info',
'text-yellow-400': log.level === 'warning',
'text-red-400': log.level === 'error',
}"
>
[{{ log.level.toUpperCase() }}]
</span>
<span class="text-gray-300">{{ log.message }}</span>
</div>
</div>
<div v-else class="text-gray-500 text-center py-4">No logs available</div>
</div>
</div>
<!-- Snapshots (Backups) Tab -->
<div v-show="activeTab === 'backups'">
<div class="flex justify-between items-center mb-4">
<div class="flex items-center gap-3">
<h3 class="text-lg font-semibold text-ink-gray-9">
Snapshots
</h3>
<Button variant="ghost" size="sm" @click="loadSnapshots" :loading="snapshots.loading">
<FeatherIcon name="refresh-cw" class="h-3 w-3" />
</Button>
</div>
<Button variant="solid" @click="openSnapshotModal">
<template #prefix>
<FeatherIcon name="plus" class="h-4 w-4" />
</template>
Create Snapshot
</Button>
</div>
<!-- Snapshot error -->
<div v-if="snapshotError" class="text-sm text-red-500 bg-red-50 p-2 rounded mb-3">
{{ snapshotError }}
</div>
<div v-if="snapshots.loading && !snapshotList.length" class="flex items-center justify-center h-32">
<Spinner class="h-6 w-6" />
</div>
<div v-else-if="snapshots.error" class="text-sm text-red-500 bg-red-50 p-2 rounded">
Failed to load snapshots: {{ snapshots.error }}
</div>
<div v-else-if="snapshotList.length" class="rounded-lg border overflow-hidden">
<table class="min-w-full divide-y">
<thead class="bg-surface-gray-2">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Created</th>
<th class="px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase">Stateful</th>
<th class="px-6 py-3 text-right text-xs font-medium text-ink-gray-5 uppercase">Actions</th>
</tr>
</thead>
<tbody class="divide-y">
<tr v-for="snap in snapshotList" :key="snap.name">
<td class="px-6 py-4 text-sm text-ink-gray-9">{{ snap.name }}</td>
<td class="px-6 py-4 text-sm text-ink-gray-7">{{ formatDate(snap.created_at) }}</td>
<td class="px-6 py-4">
<Badge
:variant="snap.stateful ? 'info' : 'subtle'"
:label="snap.stateful ? 'stateful' : 'stateless'"
/>
</td>
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" @click="restoreSnapshotAction(snap)" :loading="actionLoading === 'restore-' + snap.name">
<FeatherIcon name="rotate-ccw" class="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" @click="deleteSnapshotAction(snap)" :loading="actionLoading === 'delete-snap-' + snap.name">
<FeatherIcon name="trash-2" class="h-4 w-4 text-red-500" />
</Button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="rounded-lg border p-8 text-center text-sm text-ink-gray-4">
No snapshots yet. Create one to backup this container.
</div>
</div>
</template>
</div>
</div>
<!-- Create Snapshot Modal -->
<Dialog v-model="showSnapshotModal" :options="{ title: 'Create Snapshot' }">
<template #body-content>
<div class="space-y-4">
<FormControl
v-model="snapshotName"
label="Snapshot Name"
type="text"
required
placeholder="e.g., backup-2024-01-15"
/>
<div class="flex items-center gap-2">
<input
id="stateful"
v-model="snapshotStateful"
type="checkbox"
class="h-4 w-4 rounded border-gray-300"
/>
<label for="stateful" class="text-sm text-ink-gray-7">
Stateful (include memory state - container must be running)
</label>
</div>
<div v-if="snapshotCreateError" class="text-sm text-red-500 bg-red-50 p-2 rounded">
{{ snapshotCreateError }}
</div>
</div>
</template>
<template #actions>
<Button variant="subtle" @click="showSnapshotModal = false">
{{ $t('common.cancel') }}
</Button>
<Button variant="solid" @click="createSnapshotAction" :loading="actionLoading === 'create-snap'">
Create
</Button>
</template>
</Dialog>
<!-- Update/Rebuild Modal -->
<Dialog v-model="showUpdateModal" :options="{ title: 'Update Container' }">
<template #body-content>
<div class="space-y-4">
<div class="text-sm text-ink-gray-6 bg-blue-50 border border-blue-200 rounded p-3">
<p class="font-medium text-blue-700 mb-1">What is rebuild?</p>
<p>Rebuild recreates the container from a new image while <strong>preserving attached storage volumes</strong>. Your data will be kept.</p>
</div>
<div class="text-sm">
<span class="text-ink-gray-5">Current image:</span>
<span class="ml-2 font-medium text-ink-gray-8">{{ site?.image || '-' }}</span>
</div>
<div v-if="images.loading" class="flex items-center gap-2 text-sm text-ink-gray-5 py-2">
<Spinner class="h-4 w-4" /> Loading images...
</div>
<FormControl
v-else
v-model="updateImage"
label="New Image"
type="select"
:options="imageOptions"
/>
<div v-if="updateError" class="text-sm text-red-500 bg-red-50 p-2 rounded">
{{ updateError }}
</div>
</div>
</template>
<template #actions>
<Button variant="subtle" @click="showUpdateModal = false">
{{ $t('common.cancel') }}
</Button>
<Button variant="solid" @click="rebuildContainer" :loading="rebuilding">
Update
</Button>
</template>
</Dialog>
<!-- Rebuild Progress/Result Modal -->
<Dialog v-model="showRebuildProgress" :options="{ title: rebuilding ? 'Updating...' : 'Update Result', size: 'lg' }">
<template #body-content>
<div class="space-y-4">
<!-- Progress indicator during rebuild -->
<div v-if="rebuilding" class="flex flex-col items-center gap-4 py-6">
<Spinner class="h-10 w-10 text-blue-500" />
<div class="text-center">
<p class="text-sm font-medium text-ink-gray-8">Rebuilding container...</p>
<p class="text-xs text-ink-gray-5 mt-1">This may take a few minutes</p>
</div>
<div class="w-full bg-surface-gray-2 rounded-full h-2 overflow-hidden">
<div class="h-full bg-blue-500 rounded-full animate-pulse" style="width: 60%"></div>
</div>
</div>
<!-- Result after rebuild completes -->
<template v-else>
<div class="flex items-center gap-3">
<FeatherIcon
v-if="rebuildResult.success"
name="check-circle"
class="h-6 w-6 text-green-600"
/>
<FeatherIcon
v-else
name="x-circle"
class="h-6 w-6 text-red-600"
/>
<span class="text-sm font-medium" :class="rebuildResult.success ? 'text-green-700' : 'text-red-700'">
{{ rebuildResult.message }}
</span>
</div>
<!-- Rebuild logs -->
<div v-if="rebuildResult.logs?.length" class="mt-4">
<p class="text-xs font-medium text-ink-gray-5 uppercase mb-2">Rebuild Logs</p>
<div class="bg-surface-gray-1 rounded-lg p-3 max-h-64 overflow-y-auto font-mono text-xs">
<div
v-for="(log, idx) in rebuildResult.logs"
:key="idx"
class="py-0.5"
:class="{
'text-green-600': log.includes('✓'),
'text-red-600': log.includes('✗') || log.includes('Error'),
'text-ink-gray-6': !log.includes('✓') && !log.includes('✗'),
'font-semibold': log.includes('==='),
}"
>
{{ log }}
</div>
</div>
</div>
</template>
</div>
</template>
<template #actions>
<Button
v-if="!rebuilding"
variant="solid"
@click="showRebuildProgress = false; detail.reload()"
>
Close
</Button>
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { createResource, call } from 'frappe-ui'
import { Button, Badge, Spinner, FormControl, Dialog, FeatherIcon } from 'frappe-ui'
import LayoutHeader from '@/components/Common/LayoutHeader.vue'
import { siteActivity } from '@/mocks/sites'
const route = useRoute()
const activeTab = ref('overview')
// Get server from query param (for remote containers)
const targetServer = computed(() => route.query.server || null)
const actionLoading = ref('')
const showSnapshotModal = ref(false)
const snapshotName = ref('')
const snapshotStateful = ref(false)
const snapshotError = ref('')
const snapshotCreateError = ref('')
const logsLoaded = ref(false)
const snapshotsLoaded = ref(false)
// Update/Rebuild state
const showUpdateModal = ref(false)
const updateImage = ref('')
const updateError = ref('')
const rebuilding = ref(false)
const showRebuildProgress = ref(false)
const rebuildResult = ref({ success: false, message: '', logs: [] })
const tabs = [
{ id: 'overview', label: 'sites.detail.overview' },
{ id: 'activity', label: 'sites.detail.activity' },
{ id: 'logs', label: 'sites.detail.logs' },
{ id: 'backups', label: 'sites.detail.backups' },
]
const detail = createResource({
url: 'admin_panel.api.sites.get_container_detail',
params: {
container_name: route.params.siteId,
target_server: route.query.server || null,
},
auto: true,
})
const containerLogs = createResource({
url: 'admin_panel.api.sites.get_container_logs',
params: {
container_name: route.params.siteId,
target_server: route.query.server || null,
},
auto: false,
})
const snapshots = createResource({
url: 'admin_panel.api.sites.get_snapshots',
params: {
container_name: route.params.siteId,
target_server: route.query.server || null,
},
auto: false,
})
const images = createResource({
url: 'admin_panel.api.sites.get_images',
params: {
target_server: route.query.server || null,
},
auto: false,
})
const site = computed(() => detail.data)
const imageOptions = computed(() => {
const list = images.data || []
return list.map(img => ({
label: img.alias || img.description || img.fingerprint?.slice(0, 12) || 'Unknown',
value: img.alias || img.fingerprint,
}))
})
const logEntries = computed(() => containerLogs.data || [])
const snapshotList = computed(() => snapshots.data || [])
// Lazy load data when tabs are opened
watch(activeTab, (val) => {
if (val === 'logs' && !logsLoaded.value) {
loadLogs()
logsLoaded.value = true
}
if (val === 'backups' && !snapshotsLoaded.value) {
loadSnapshots()
snapshotsLoaded.value = true
}
})
function loadLogs() {
containerLogs.reload()
}
function loadSnapshots() {
snapshotError.value = ''
snapshots.reload()
}
function getStatusBadge(status) {
return { Running: 'success', Stopped: 'warning', Error: 'error', Creating: 'info' }[status] || 'subtle'
}
const activityIcons = {
start: 'power', stop: 'power', restart: 'refresh-cw',
backup: 'database', update: 'refresh-cw', config: 'settings',
}
const activityIconClasses = {
start: 'bg-green-100 text-green-600',
stop: 'bg-red-100 text-red-600',
restart: 'bg-blue-100 text-blue-600',
backup: 'bg-purple-100 text-purple-600',
update: 'bg-orange-100 text-orange-600',
config: 'bg-surface-gray-2 text-ink-gray-5',
}
function formatBytes(bytes) {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let i = 0
let val = bytes
while (val >= 1024 && i < units.length - 1) {
val /= 1024
i++
}
return `${val.toFixed(1)} ${units[i]}`
}
function formatDate(dateStr) {
if (!dateStr) return '-'
try {
const d = new Date(dateStr)
return d.toLocaleString()
} catch {
return dateStr
}
}
function generateDefaultSnapshotName() {
const now = new Date()
const y = now.getFullYear()
const m = String(now.getMonth() + 1).padStart(2, '0')
const d = String(now.getDate()).padStart(2, '0')
const h = String(now.getHours()).padStart(2, '0')
const min = String(now.getMinutes()).padStart(2, '0')
return `snap-${y}${m}${d}-${h}${min}`
}
async function startSite() {
actionLoading.value = 'start'
try {
await call('admin_panel.api.sites.start_container', {
container_name: route.params.siteId,
target_server: targetServer.value,
})
detail.reload()
} catch (e) {
console.error('Failed to start container:', e)
} finally {
actionLoading.value = ''
}
}
async function stopSite() {
actionLoading.value = 'stop'
try {
await call('admin_panel.api.sites.stop_container', {
container_name: route.params.siteId,
target_server: targetServer.value,
})
detail.reload()
} catch (e) {
console.error('Failed to stop container:', e)
} finally {
actionLoading.value = ''
}
}
async function restartSite() {
actionLoading.value = 'restart'
try {
await call('admin_panel.api.sites.restart_container', {
container_name: route.params.siteId,
target_server: targetServer.value,
})
detail.reload()
} catch (e) {
console.error('Failed to restart container:', e)
} finally {
actionLoading.value = ''
}
}
function openSite() {
if (site.value?.ip_address) {
window.open(`http://${site.value.ip_address}`, '_blank')
}
}
function openSnapshotModal() {
snapshotName.value = generateDefaultSnapshotName()
snapshotStateful.value = false
snapshotCreateError.value = ''
showSnapshotModal.value = true
}
async function createSnapshotAction() {
snapshotCreateError.value = ''
if (!snapshotName.value) {
snapshotCreateError.value = 'Snapshot name is required'
return
}
actionLoading.value = 'create-snap'
try {
await call('admin_panel.api.sites.create_snapshot', {
container_name: route.params.siteId,
snapshot_name: snapshotName.value,
stateful: snapshotStateful.value,
})
showSnapshotModal.value = false
snapshotName.value = ''
snapshotStateful.value = false
loadSnapshots()
} catch (e) {
console.error('Failed to create snapshot:', e)
snapshotCreateError.value = e.message || e.exc || 'Failed to create snapshot'
} finally {
actionLoading.value = ''
}
}
async function restoreSnapshotAction(snap) {
if (!confirm(`Restore container from snapshot "${snap.name}"? This will overwrite the current state.`)) return
snapshotError.value = ''
actionLoading.value = 'restore-' + snap.name
try {
await call('admin_panel.api.sites.restore_snapshot', {
container_name: route.params.siteId,
snapshot_name: snap.name,
})
detail.reload()
} catch (e) {
console.error('Failed to restore snapshot:', e)
snapshotError.value = e.message || e.exc || 'Failed to restore snapshot'
} finally {
actionLoading.value = ''
}
}
async function deleteSnapshotAction(snap) {
if (!confirm(`Delete snapshot "${snap.name}"?`)) return
snapshotError.value = ''
actionLoading.value = 'delete-snap-' + snap.name
try {
await call('admin_panel.api.sites.delete_snapshot', {
container_name: route.params.siteId,
snapshot_name: snap.name,
})
loadSnapshots()
} catch (e) {
console.error('Failed to delete snapshot:', e)
snapshotError.value = e.message || e.exc || 'Failed to delete snapshot'
} finally {
actionLoading.value = ''
}
}
function openUpdateModal() {
updateError.value = ''
updateImage.value = ''
showUpdateModal.value = true
// Load images if not loaded
if (!images.data) {
images.reload()
}
}
async function rebuildContainer() {
updateError.value = ''
if (!updateImage.value) {
updateError.value = 'Please select an image'
return
}
showUpdateModal.value = false
rebuilding.value = true
rebuildResult.value = { success: false, message: '', logs: [] }
showRebuildProgress.value = true
try {
const resp = await call('admin_panel.api.sites.rebuild_container', {
container_name: route.params.siteId,
image: updateImage.value,
})
rebuilding.value = false
rebuildResult.value = {
success: resp.success,
message: resp.message,
logs: resp.logs || [],
}
} catch (e) {
console.error('Failed to rebuild container:', e)
rebuilding.value = false
rebuildResult.value = {
success: false,
message: e.message || e.exc || 'Rebuild failed',
logs: [`Error: ${e.message || e.exc}`],
}
}
}
</script>

View File

@ -0,0 +1,560 @@
<template>
<div class="flex flex-col h-full">
<LayoutHeader :title="$t('sites.title')">
<template #right>
<Button variant="subtle" @click="refreshData" :loading="containers.loading">
<template #prefix>
<FeatherIcon name="refresh-cw" class="h-4 w-4" />
</template>
{{ $t('common.refresh') }}
</Button>
<Button variant="solid" @click="openCreateModal">
<template #prefix>
<FeatherIcon name="plus" class="h-4 w-4" />
</template>
{{ $t('sites.newSite') }}
</Button>
</template>
</LayoutHeader>
<div class="flex-1 overflow-auto p-5">
<!-- Filters row -->
<div class="mb-4 flex items-center gap-3 flex-wrap">
<!-- Server selector -->
<div class="flex items-center gap-2">
<FeatherIcon name="cloud" class="h-4 w-4 text-ink-gray-4" />
<FormControl
v-model="selectedServer"
type="select"
:options="availableServerOptions"
class="w-40"
/>
</div>
<div class="h-6 w-px bg-gray-200"></div>
<TextInput
v-model="searchQuery"
type="text"
:placeholder="$t('common.search') + '...'"
class="w-56"
>
<template #prefix>
<FeatherIcon name="search" class="h-4 w-4 text-ink-gray-4" />
</template>
</TextInput>
<div class="flex gap-1">
<button
v-for="f in statusFilters"
:key="f.value"
class="px-3 py-1 rounded text-sm transition-colors"
:class="statusFilter === f.value
? 'bg-surface-selected shadow-sm text-ink-gray-9 font-medium'
: 'text-ink-gray-5 hover:bg-surface-gray-2'"
@click="statusFilter = f.value"
>
{{ f.label }}
</button>
</div>
</div>
<!-- No servers configured -->
<div v-if="availableServers.data && availableServers.data.length === 0" class="flex flex-col items-center justify-center h-64 gap-3">
<FeatherIcon name="cloud-off" class="h-10 w-10 text-ink-gray-4" />
<p class="text-sm text-ink-gray-7 font-medium">No container servers configured</p>
<p class="text-xs text-ink-gray-5">Add a server with "Container Server" role in the Servers tab</p>
<Button variant="solid" @click="$router.push({ name: 'Servers' })">
Go to Servers
</Button>
</div>
<div v-else-if="containers.loading" class="flex items-center justify-center h-64">
<Spinner class="h-8 w-8" />
</div>
<!-- Error state -->
<div v-else-if="containers.error" class="flex flex-col items-center justify-center h-64 gap-3">
<FeatherIcon name="alert-circle" class="h-10 w-10 text-red-500" />
<p class="text-sm text-ink-gray-5">Failed to load containers</p>
<p class="text-xs text-ink-gray-4">{{ containers.error }}</p>
<Button variant="subtle" @click="containers.reload()">Retry</Button>
</div>
<!-- Sites Cards Grid -->
<div v-else-if="filteredSites.length" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div
v-for="site in filteredSites"
:key="site.container_name"
class="rounded-lg border p-4 shadow-sm hover:shadow-md transition-shadow cursor-pointer"
@click="goToSite(site)"
>
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg"
:class="getStatusBg(site.status)"
>
<FeatherIcon
:name="getStatusIcon(site.status)"
class="h-5 w-5"
:class="getStatusColor(site.status)"
/>
</div>
<div>
<p class="font-medium text-ink-gray-9">{{ site.name }}</p>
<p class="text-sm text-ink-gray-5">{{ site.image || site.type }}</p>
</div>
</div>
<Badge
:variant="getStatusBadge(site.status)"
:label="site.status"
/>
</div>
<div class="grid grid-cols-2 gap-2 text-sm mb-2">
<div>
<p class="text-ink-gray-4 text-xs">IP</p>
<p class="font-mono text-ink-gray-7">{{ site.ip_address || '-' }}</p>
</div>
<div>
<p class="text-ink-gray-4 text-xs">{{ $t('sites.siteOwner') }}</p>
<p class="text-ink-gray-7">{{ site.owner || '-' }}</p>
</div>
</div>
<div class="flex items-center justify-between text-xs text-ink-gray-4 pt-2 border-t">
<span>{{ site.created }}</span>
<FeatherIcon name="chevron-right" class="h-4 w-4" />
</div>
</div>
</div>
<EmptyState
v-else
icon="server"
:title="$t('sites.noSites')"
:description="$t('sites.createFirst')"
>
<template #action>
<Button variant="solid" @click="openCreateModal">
{{ $t('sites.newSite') }}
</Button>
</template>
</EmptyState>
</div>
<!-- Create Site Modal -->
<Dialog v-model="showCreateModal" :options="{ title: $t('sites.newSite') }">
<template #body-content>
<div class="space-y-4">
<FormControl
v-model="newSite.name"
:label="$t('sites.siteName')"
type="text"
required
/>
<!-- Image selection -->
<div v-if="images.loading" class="flex items-center gap-2 text-sm text-ink-gray-5 py-2">
<Spinner class="h-4 w-4" /> Loading images...
</div>
<div v-else-if="images.error" class="text-sm text-red-500 py-2">
Failed to load images
<Button variant="link" size="sm" @click="images.reload()">Retry</Button>
</div>
<FormControl
v-else
v-model="newSite.image"
:label="$t('sites.siteImage')"
type="select"
:options="imageOptions"
/>
<FormControl
v-model="newSite.owner"
:label="$t('sites.siteOwner')"
type="text"
/>
<!-- Target Server -->
<FormControl
v-model="newSite.target_server"
:label="$t('sites.targetServer')"
type="select"
:options="serverOptions"
:description="$t('sites.targetServerDesc')"
/>
<!-- Full deploy toggle -->
<div class="flex items-center gap-2 pt-2">
<input
id="fullDeploy"
v-model="newSite.fullDeploy"
type="checkbox"
class="h-4 w-4 rounded border-gray-300"
/>
<label for="fullDeploy" class="text-sm text-ink-gray-7">
Full deploy with volumes (MySQL + Frontend)
</label>
</div>
<!-- Volume config (shown when fullDeploy is checked) -->
<div v-if="newSite.fullDeploy" class="space-y-3 border rounded-lg p-3 bg-surface-gray-1">
<p class="text-xs font-medium text-ink-gray-5 uppercase">Volume Configuration</p>
<FormControl
v-model="newSite.pool_name"
label="Storage Pool"
type="text"
/>
<div
v-for="(vol, idx) in newSite.volumes"
:key="idx"
class="flex items-center gap-2 text-sm"
>
<span class="text-ink-gray-5 w-20">{{ vol.name }}</span>
<span class="text-ink-gray-7 font-mono text-xs flex-1">{{ vol.source_path }}</span>
</div>
</div>
<!-- Error display -->
<div v-if="createError" class="text-sm text-red-500 bg-red-50 p-2 rounded">
{{ createError }}
</div>
</div>
</template>
<template #actions>
<Button variant="subtle" @click="showCreateModal = false">
{{ $t('common.cancel') }}
</Button>
<Button variant="solid" @click="createSite" :loading="creating">
{{ newSite.fullDeploy ? 'Deploy' : $t('common.create') }}
</Button>
</template>
</Dialog>
<!-- Deploy Progress/Result Modal -->
<Dialog v-model="showDeployProgress" :options="{ title: deploying ? 'Deploying...' : 'Deploy Result', size: 'lg' }">
<template #body-content>
<div class="space-y-4">
<!-- Progress indicator during deploy -->
<div v-if="deploying" class="flex flex-col items-center gap-4 py-6">
<Spinner class="h-10 w-10 text-blue-500" />
<div class="text-center">
<p class="text-sm font-medium text-ink-gray-8">Creating container with volumes...</p>
<p class="text-xs text-ink-gray-5 mt-1">This may take a few minutes</p>
</div>
<div class="w-full bg-surface-gray-2 rounded-full h-2 overflow-hidden">
<div class="h-full bg-blue-500 rounded-full animate-pulse" style="width: 60%"></div>
</div>
</div>
<!-- Result after deploy completes -->
<template v-else>
<div class="flex items-center gap-3">
<FeatherIcon
v-if="deployResult.success"
name="check-circle"
class="h-6 w-6 text-green-600"
/>
<FeatherIcon
v-else
name="x-circle"
class="h-6 w-6 text-red-600"
/>
<span class="text-sm font-medium" :class="deployResult.success ? 'text-green-700' : 'text-red-700'">
{{ deployResult.message }}
</span>
</div>
<!-- Deploy logs -->
<div v-if="deployResult.logs?.length" class="mt-4">
<p class="text-xs font-medium text-ink-gray-5 uppercase mb-2">Deploy Logs</p>
<div class="bg-surface-gray-1 rounded-lg p-3 max-h-64 overflow-y-auto font-mono text-xs">
<div
v-for="(log, idx) in deployResult.logs"
:key="idx"
class="py-0.5"
:class="{
'text-green-600': log.includes('✓'),
'text-red-600': log.includes('✗') || log.includes('Error'),
'text-ink-gray-6': !log.includes('✓') && !log.includes('✗'),
'font-semibold': log.includes('==='),
}"
>
{{ log }}
</div>
</div>
</div>
</template>
</div>
</template>
<template #actions>
<Button
v-if="!deploying"
variant="solid"
@click="showDeployProgress = false; containers.reload()"
>
Close
</Button>
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { createResource, call } from 'frappe-ui'
import { Button, Badge, TextInput, FormControl, Dialog, Spinner, FeatherIcon } from 'frappe-ui'
import LayoutHeader from '@/components/Common/LayoutHeader.vue'
import EmptyState from '@/components/Common/EmptyState.vue'
const router = useRouter()
const searchQuery = ref('')
const statusFilter = ref('all')
const selectedServer = ref('')
const showCreateModal = ref(false)
const creating = ref(false)
const createError = ref('')
const showDeployProgress = ref(false)
const deploying = ref(false)
const deployResult = ref({
success: false,
message: '',
logs: [],
})
// Available servers for filter dropdown - use container servers from Servers API
const availableServers = createResource({
url: 'admin_panel.api.servers.get_container_servers',
auto: true,
})
const availableServerOptions = computed(() => {
const servers = availableServers.data || []
if (servers.length === 0) {
return []
}
return servers.map(s => ({
label: `${s.name}${s.status !== 'online' ? ' (offline)' : ''}`,
value: s.name,
}))
})
const containers = createResource({
url: 'admin_panel.api.sites.get_all_containers',
auto: false,
})
const images = createResource({
url: 'admin_panel.api.sites.get_images',
auto: false,
})
// Load containers for a specific server
function loadContainersForServer(serverName) {
if (!serverName) return
containers.fetch({ target_server: serverName })
images.fetch({ target_server: serverName })
}
// Reload when server changes
watch(selectedServer, (newServer) => {
if (newServer) {
loadContainersForServer(newServer)
}
})
// Auto-select first server when available servers are loaded
watch(() => availableServers.data, (servers) => {
if (servers && servers.length > 0 && !selectedServer.value) {
selectedServer.value = servers[0].name
// Load containers for the first server
loadContainersForServer(servers[0].name)
}
}, { immediate: true })
const newSite = ref({
name: '',
image: '',
owner: '',
target_server: '',
fullDeploy: false,
pool_name: 'default',
volumes: [
{ name: 'mysql', source_path: '/var/lib/mysql', device_name: 'mysql' },
{ name: 'frontend', source_path: '/home/frappe/frappe-bench/sites/frontend', device_name: 'frontend' },
],
})
const serverOptions = computed(() => {
const servers = availableServers.data || []
return servers.map(s => ({
label: `${s.name}${s.status !== 'online' ? ' (' + s.status + ')' : ''}`,
value: s.name,
}))
})
// Reload images when target server changes in create modal
watch(() => newSite.value.target_server, (newServer) => {
if (newServer) {
newSite.value.image = ''
images.fetch({ target_server: newServer })
}
})
const imageOptions = computed(() => {
const list = images.data || []
return list.map(img => ({
label: img.alias || img.description || img.fingerprint?.slice(0, 12) || 'Unknown',
value: img.alias || img.fingerprint,
}))
})
const statusFilters = [
{ value: 'all', label: 'All' },
{ value: 'Running', label: 'Running' },
{ value: 'Stopped', label: 'Stopped' },
{ value: 'Error', label: 'Error' },
]
const sites = computed(() => containers.data || [])
const filteredSites = computed(() => {
let result = sites.value
if (statusFilter.value !== 'all') {
result = result.filter(s => s.status === statusFilter.value)
}
if (searchQuery.value) {
const q = searchQuery.value.toLowerCase()
result = result.filter(s =>
s.name.toLowerCase().includes(q) ||
s.container_name?.toLowerCase().includes(q) ||
s.owner?.toLowerCase().includes(q)
)
}
return result
})
function getStatusBg(status) {
return { Running: 'bg-green-100', Stopped: 'bg-orange-100', Error: 'bg-red-100', Creating: 'bg-blue-100' }[status] || 'bg-surface-gray-2'
}
function getStatusIcon(status) {
return { Running: 'play-circle', Stopped: 'pause-circle', Error: 'alert-circle', Creating: 'loader' }[status] || 'server'
}
function getStatusColor(status) {
return { Running: 'text-green-600', Stopped: 'text-orange-600', Error: 'text-red-600', Creating: 'text-blue-600' }[status] || 'text-ink-gray-5'
}
function getStatusBadge(status) {
return { Running: 'success', Stopped: 'warning', Error: 'error', Creating: 'info' }[status] || 'subtle'
}
function refreshData() {
if (selectedServer.value) {
loadContainersForServer(selectedServer.value)
}
}
function goToSite(site) {
router.push({
name: 'SiteDetail',
params: { siteId: site.container_name },
query: { server: site.server || selectedServer.value || '' }
})
}
function openCreateModal() {
createError.value = ''
// Auto-select current server
newSite.value.target_server = selectedServer.value || ''
showCreateModal.value = true
// Load images for the selected server
if (newSite.value.target_server) {
images.fetch({ target_server: newSite.value.target_server })
}
}
async function createSite() {
createError.value = ''
if (!newSite.value.name) {
createError.value = 'Container name is required'
return
}
if (!newSite.value.image) {
createError.value = 'Please select an image'
return
}
creating.value = true
try {
if (newSite.value.fullDeploy) {
// Full deploy with volumes (synchronous - may take time)
showCreateModal.value = false
deploying.value = true
deployResult.value = { success: false, message: '', logs: [] }
showDeployProgress.value = true
const resp = await call('admin_panel.api.sites.deploy_container_full', {
container_name: newSite.value.name,
image: newSite.value.image,
pool_name: newSite.value.pool_name,
volumes_config: JSON.stringify(newSite.value.volumes),
target_server: newSite.value.target_server || null,
})
deploying.value = false
deployResult.value = {
success: resp.success,
message: resp.message,
logs: resp.logs || [],
}
} else {
// Simple create
await call('admin_panel.api.sites.create_container', {
container_name: newSite.value.name,
image: newSite.value.image,
container_type: 'container',
target_server: newSite.value.target_server || null,
})
showCreateModal.value = false
containers.reload()
}
// Reset form
newSite.value = {
name: '',
image: '',
owner: '',
target_server: selectedServer.value || '',
fullDeploy: false,
pool_name: 'default',
volumes: [
{ name: 'mysql', source_path: '/var/lib/mysql', device_name: 'mysql' },
{ name: 'frontend', source_path: '/home/frappe/frappe-bench/sites/frontend', device_name: 'frontend' },
],
}
} catch (e) {
console.error('Failed to create container:', e)
deploying.value = false
if (showDeployProgress.value) {
deployResult.value = {
success: false,
message: e.message || e.exc || 'Deploy failed',
logs: [`Error: ${e.message || e.exc}`],
}
} else {
createError.value = e.message || e.exc || 'Failed to create container'
}
} finally {
creating.value = false
}
}
// Component lifecycle
onMounted(() => {
// Refresh data on mount
})
onUnmounted(() => {
// Cleanup
})
</script>

104
frontend/src/router.js Normal file
View File

@ -0,0 +1,104 @@
import { createRouter, createWebHistory } from 'vue-router'
import { session } from './session'
const routes = [
{
path: '/',
name: 'Home',
redirect: '/dashboard',
},
{
path: '/login',
name: 'Login',
component: () => import('@/pages/admin/Login.vue'),
meta: { public: true },
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/pages/admin/Dashboard.vue'),
},
{
path: '/sites',
name: 'Sites',
component: () => import('@/pages/admin/Sites.vue'),
},
{
path: '/sites/:siteId',
name: 'SiteDetail',
component: () => import('@/pages/admin/SiteDetail.vue'),
props: true,
},
{
path: '/apps',
name: 'Apps',
component: () => import('@/pages/admin/Apps.vue'),
},
{
path: '/apps/:appId',
name: 'AppDetail',
component: () => import('@/pages/admin/AppDetail.vue'),
props: true,
},
{
path: '/servers',
name: 'Servers',
component: () => import('@/pages/admin/Servers.vue'),
},
{
path: '/payments',
name: 'Payments',
component: () => import('@/pages/admin/Payments.vue'),
},
{
path: '/payments/customers',
name: 'Customers',
component: () => import('@/pages/admin/Customers.vue'),
},
{
path: '/logs',
name: 'Logs',
component: () => import('@/pages/admin/Logs.vue'),
},
{
path: '/settings',
name: 'Settings',
component: () => import('@/pages/admin/Settings.vue'),
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/pages/admin/NotFound.vue'),
meta: { public: true },
},
]
const router = createRouter({
history: createWebHistory('/admin'),
routes,
})
// Navigation guard - check authentication
router.beforeEach(async (to, from, next) => {
// Public routes don't need auth
if (to.meta.public) {
next()
return
}
// Check if user is logged in
try {
await session.init()
if (session.isLoggedIn) {
next()
} else {
// Save intended destination for redirect after login
next({ name: 'Login', query: { redirect: to.fullPath } })
}
} catch (error) {
next({ name: 'Login', query: { redirect: to.fullPath } })
}
})
export default router

47
frontend/src/session.js Normal file
View File

@ -0,0 +1,47 @@
import { reactive } from 'vue'
import { call } from 'frappe-ui'
export const session = reactive({
user: null,
isLoggedIn: false,
initialized: false,
loading: true,
async init() {
if (this.initialized) {
return
}
this.loading = true
try {
const data = await call('frappe.auth.get_logged_user')
if (data && data !== 'Guest') {
this.user = data
this.isLoggedIn = true
} else {
this.user = null
this.isLoggedIn = false
}
} catch (error) {
console.error('Session init error:', error)
this.user = null
this.isLoggedIn = false
}
this.initialized = true
this.loading = false
},
async logout() {
try {
await call('logout')
this.user = null
this.isLoggedIn = false
this.initialized = false
window.location.href = '/admin/login'
} catch (error) {
console.error('Logout error:', error)
}
},
})

View File

@ -0,0 +1,18 @@
import frappeUIPreset from 'frappe-ui/tailwind'
export default {
presets: [frappeUIPreset],
content: [
'./index.html',
'./src/**/*.{vue,js,ts,jsx,tsx}',
'./node_modules/frappe-ui/src/**/*.{vue,js,ts,jsx,tsx}',
'../node_modules/frappe-ui/src/**/*.{vue,js,ts,jsx,tsx}',
'./node_modules/frappe-ui/frappe/**/*.{vue,js,ts,jsx,tsx}',
'../node_modules/frappe-ui/frappe/**/*.{vue,js,ts,jsx,tsx}',
],
darkMode: 'class',
theme: {
extend: {},
},
plugins: [],
}

81
frontend/vite.config.js Normal file
View File

@ -0,0 +1,81 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig(async ({ mode }) => {
const isDev = mode === 'development'
const config = {
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
optimizeDeps: {
include: ['feather-icons', 'tailwind.config.js'],
},
server: {
fs: {
allow: [path.resolve(__dirname, '..')],
},
},
}
const frappeui = await importFrappeUIPlugin(isDev, config)
config.plugins.unshift(
frappeui({
frappeProxy: true,
lucideIcons: true,
jinjaBootData: true,
buildConfig: {
indexHtmlPath: '../admin_panel/www/admin.html',
emptyOutDir: true,
sourcemap: true,
},
}),
)
return config
})
async function importFrappeUIPlugin(isDev, config) {
if (isDev) {
try {
const fs = await import('node:fs')
const localVitePluginPath = path.resolve(__dirname, '../frappe-ui/vite')
if (fs.existsSync(localVitePluginPath)) {
const module = await import('../frappe-ui/vite')
console.info('Local frappe-ui vite plugin found, using local plugin')
config.resolve.alias = getAliases(config)
return module.default
} else {
console.warn('Local frappe-ui vite plugin not found, using npm package')
}
} catch (error) {
console.warn(
'Local frappe-ui not found, falling back to npm package:',
error.message,
)
}
}
const module = await import('frappe-ui/vite')
return module.default
}
function getAliases(config) {
return {
...config.resolve.alias,
'frappe-ui/tailwind': path.resolve(
__dirname,
'../frappe-ui/tailwind/preset.js',
),
'frappe-ui/style.css': path.resolve(
__dirname,
'../frappe-ui/src/style.css',
),
'frappe-ui/frappe': path.resolve(__dirname, '../frappe-ui/frappe/index.js'),
'frappe-ui': path.resolve(__dirname, '../frappe-ui/src/index.ts'),
}
}

2330
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,447 @@
image:
distribution: "alpinelinux"
source:
downloader: alpinelinux-http
same_as: 3.12
url: https://mirror.csclub.uwaterloo.ca/alpine/
keys:
# 0482D84022F52DF1C4E7CD43293ACD0907D9495A
- |-
-----BEGIN PGP PUBLIC KEY BLOCK-----
mQINBFSIEDwBEADbib88gv1dBgeEez1TIh6A5lAzRl02JrdtYkDoPr5lQGYv0qKP
lWpd3jgGe8n90krGmT9W2nooRdyZjZ6UPbhYSJ+tub6VuKcrtwROXP2gNNqJA5j3
vkXQ40725CVig7I3YCpzjsKRStwegZAelB8ZyC4zb15J7YvTVkd6qa/uuh8H21X2
h/7IZJz50CMxyz8vkdyP2niIGZ4fPi0cVtsg8l4phbNJ5PwFOLMYl0b5geKMviyR
MxxQ33iNa9X+RcWeR751IQfax6xNcbOrxNRzfzm77fY4KzBezcnqJFnrl/p8qgBq
GHKmrrcjv2MF7dCWHGAPm1/vdPPjUpOcEOH4uGvX7P4w2qQ0WLBTDDO47/BiuY9A
DIwEF1afNXiJke4fmjDYMKA+HrnhocvI48VIX5C5+C5aJOKwN2EOpdXSvmsysTSt
gIc4ffcaYugfAIEn7ZdgcYmTlbIphHmOmOgt89J+6Kf9X6mVRmumI3cZWetf2FEV
fS9v24C2c8NRw3LESoDT0iiWsCHcsixCYqqvjzJBJ0TSEIVCZepOOBp8lfMl4YEZ
BVMzOx558LzbF2eR/XEsr3AX7Ga1jDu2N5WzIOa0YvJl1xcQxc0RZumaMlZ81dV/
uu8G2+HTrJMZK933ov3pbxaZ38/CbCA90SBk5xqVqtTNAHpIkdGj90v2lwARAQAB
iH8EEhYKACcWIQRQQFYXHWwYKA6Ezfe2Zm2RwjfyiQUCWsf0cgWDAeEzgAMFATwA
CgkQtmZtkcI38okvzwD+PFAaXtH+KkuIzYJPH1rlaswCx2ALFYUMR7ptsWNbQQwA
/iaqtZns6UngP85uNyKNLjoxIWK3+WRQ8Cj3+pFBU58EtCVOYXRhbmFlbCBDb3Bh
IDxuY29wYUBhbHBpbmVsaW51eC5vcmc+iH8EEhYKACcWIQRQQFYXHWwYKA6Ezfe2
Zm2RwjfyiQUCWsf0cgWDAeEzgAMFATwACgkQtmZtkcI38okvzwD+PFAaXtH+KkuI
zYJPH1rlaswCx2ALFYUMR7ptsWNbQQwA/iaqtZns6UngP85uNyKNLjoxIWK3+WRQ
8Cj3+pFBU58EiMMEExMKACcWIQT64Bydmt13ap3e1NXkkuZkZCRt2AUCWsf4aAWD
A8JnAAMFAXgACgkQ5JLmZGQkbdiYCwIJAURrcI3SntkkiwrvI8UjTiJ9+ZXRi7HF
nAQCIXVBH5/p6KSXsEVKo2cr3uiJsXEWPQ1f4A4lmKre2e59kX+ABb2nAgkBLxsq
gYTq0sj/SOO4+2rbDE0VzLhIEmiz63igk8CCkrQovKkwJ+cWz14pzGiojvbk85Mr
xOlKlC3ThGE7xJVrgaqJARwEEAECAAYFAln4Pp4ACgkQOktclQn9UtFQ5Qf7BSqj
vNhSbz88SGqL+ICOhJAEyMR8TKVqrm7MKEF4e/5GJ3f55mQxoFGQqHuPy3oxx5Gt
MA5419HgSbUWE7AWoc+B6DoxoFguQv/yKvZnrZh0EiTRFQpTSZkcQUtepppKhAhN
j6iuLMwOTeVnrNsmskrda72P7z/TM6z/DY3DHv5yWgRcQCBIKp8ZmC1oP2aFm0Y0
uF7tAUbPcAtDk/93LgmJDB4x72+ac/rvFtUFLoUBIThm0CdLTfeKl7VRqSYS7GKE
6Ih9wjXnP0Hn1do1K+MYvU8rjW6VhOmujX6K3nq8w6jv9EQ2bru0M7c0ev94ydjq
hY7Wla+3nJS1mAW5/okBHAQQAQgABgUCVIlXRgAKCRBGcA6MYo+hzrNmCACOMe11
SLVty3ptKA7qwkFzA1oXZJnBlDlO8H07cLIReFqxwkaa6tUC7C037B5Y9lEZ6jn0
um9ohZETJrRZOwMGiuqThNgdwLlfOkcziRq5LFy4xexDEYhT4iydaH8E3rs7eQrU
iEx/4gwgPkZ24nO+GMnLZzhdZ/OLQdZt4r0vRQWdtYvKhJYxB+CHsSs8nexJ5TAq
RW4eYJEWMnGCPJpZ3lHcNQy1PacH8wWX4v24Q6CP4ZyY4+PTv/QHXJpYWMFikEVZ
gbQCOL7rh5DiQHUOpFBtDvZ7EtAUWVY1QR3W8gQqOL9g8Y8e1OLzq3v1Jrtj+zC+
XpZJWIRnHNIcelaTiQGcBBABCAAGBQJWOND9AAoJECWNqqKUWUkm25cL/3Rv652V
ZpAvj+TwaY+Wv5Wj5pkY/cstM8212wgVdwLHb+bVVomVXY9Z55biFEFwLPnfG+U+
OYnaDt4Wzd/s7Fl7/VXeg8TftwynH4PyDJ5cnrullJlVXUvD5bfzLlJ+kdFrC1UE
DquuTVPW/UPbM4r5730VEBtYxtwUaZ69LSrOX0z9RemjWvylY47dVMNtJEWQkwzg
6zCV8a6as4itN5xANMZyD4INZE41dWaCPTIAn6nADHHi5fL3IzA4Qt5aRnyXeQk0
GI1WvN48gR6KdtEKyar5f04CpUlfmRIBoYBWBsruIlgsYe9WUpmrVm1YyKgD1iuO
UTscLgrZ3owx6xCN1p1MyfU79iH5nGz/SuBeNsm7G2F/+eHidOqYh+Kh3+nWfZCH
MSsk/4LUvHjMWbeZ07ih2UOcZ6lHIgX4Z+toSDh1HQkBkI8VhDssEFVhjgODsBwK
ezZZA/h9kNKFA9b9KBeYCioR0pMSd2YYvQVUhRU7hh8Aoo6VyA1S8u0TyYkCHAQQ
AQgABgUCVPTSVAAKCRB/lslknLy/URsqD/9I1rO3/qE96a2AWX5j817SOn3w6HZy
9OD312erjBGdAJph6ZRc9GvMmPbMt4s6wh3cIodagqJ+LadocvQTzsWMMANIXTZW
N0I5bjaRuQpZYYBpV6sQhPwdvj6E2RZ4eKeIFOsFc2VtqK2B+lMy4TGpmGCyZ68+
maVO4VYpC7+j0S7G+8HEEmG0RX1erVrSoIyD8h8K49jdlGV6eS/rar/UBpKr6cSG
5ghV9S+XrkleZeJLzzGD8Ff5lq/CmANmhGzAthXwgyweC1ozivg5Co1u8B5Esyi7
/ImZCRlhVBrSK4YFdQ9+lKKsiV1MR+jaW2+KUxEYxmnOc9qfQZ5A9CyPd/YgsQip
poexbMMOMgj8Wnyf+MuK/vYJp8b69R8GT+28iRiRwvzFw5X4XiEl8rqdjqlKLQpk
yKLvff4pw9Oxjm6FfLMnAspsRQ+UEodyqQFklT0vnEqh8Ii9KXRaQ4cInSqsstcV
Cbm/NG++ChpJSnP/CuRfnyzLqZm+1AbM+dZyyoAxd9k1HdAO0jiMx9AXlXHNwEfE
aZ02YFQ5imIWpqZXBBImXjhGLu4/086E/AVFIFnJbj6iaxS1Qc4YE2H9ceo003sf
tRdHuuAFUTRyQQMku6DAOcKUD4eQy5AlVKRjAzxiI80QihqjGuWRabHlAkc0Bhz3
Svo0SLD95s09LYkCHAQQAQoABgUCVL403wAKCRAYhY1yfFeuYZV4D/93MISeIAIp
Y/s1B2pHpxJxfYqR5HiMPq3hGjsUjIKmSXmaAkcnUqpFlrRDmU6Xerjsx16nIEsx
u5JiyLeBfFO/UF92FcEPhbIzFFiBxxinB5nB2KYNl4SPHn2mXFgtrUDh0qHQyl5P
LPYpiGq7WYOIIB6g+abJXTJH4Fsiv9UoVWpvPB+NW5eQXBpSS/a2SjwpfR5f172y
4axPQd66ntDJXliN1R9upRLIlvB/KcbkLHVUvRemvph/30dix51z9hUz1TxDFHBV
OdGODMljjDnSpS/ern1hO9tkelR5Ak/++p4oKwiEJD9PfZTpVruDWykJEZknED+I
vfVd/5zFkBFd6RuJw+qQHsL/bSxFy80K81L9nKxE1ZLoEEEfGonYmQOGtCVpWRng
Z6GyJbD19q9ZBVQOSemp4/6Vh5A3CwldXK0Hq8Mw159hkd5nsGVYmOOiFdnSwVVO
SZaanh7iSHExxZjbhIQIbGblGPuPmFohTV2Sp67BPS+fUxFVG0j24d6CO2+qs5Q0
gT6Uo8GkVD1gcemFe9IzJvCrA2ICPWyi3fmIRI0uucRATqTxlYNslaRqVffxSyDM
tZLWXPIePPevwDQsouuwND4aytTaMRiFqqGiULn8dkOGDmAbSVPNHylewibrKdHC
H91EVhOT3XY2Z2xPkW724RFgaG9ohLoHQYkCIgQSAQoADAUCWJ9UPgWDB4YfgAAK
CRC5rer6U9uYHh5aEACcmUkR25IKDILZZWZ3Y+48wcAPbsQUQZE/+TnCo3D+F+be
S0URApZROQri3QZ+9H3hPHxziv1l0sU/0IOGhFC6I7kSao4nNfOUe3OPZ3wm5o8l
cULZNl2ChCMpbxn5GeQ5+LbyNpZpSjhZf2Xj3FqzacpLTVCg0UOpK+bF2CxRw8z1
7eUL2sLldSsaRFWwDVAFIROS04yvfpGbAch9pKWAtJ2/VsrkTh+1/rl+EyGVynks
TSkkx+pMsS/7HLCtxBiFKOy40FiL5QBOBkk/1g6sp2dvWCYzG7oGJeHr+TaDle0I
+ynSulIDNZb1gsNpZk2wHZ6F7XcdIn9rD/LlHSlHeTnDaFudDw3wEuSRLpf/lQUF
exXYZfOsazpORuz+fdDlpPMjL8XVocUW24XM3AGHdOlXpSdnGpWiaBYDEO7NmboH
yn4hhBHs8ibTuxBBtzG7rq7f7n9EuEr7m7CfukGIZnT6aN6wVqFNa6nWsXOgzOCc
+BPStTnUsBAliT22c5mzkPbrLZA5I3ydB/NrT0G0cWzYo1qESXJY2leVQkqzFIff
CRC+7kKIJp00Uj5fjxhbrTZhJQGOHhlgkpfQL7mAC5rDNqn31yxve7+mgU6BbH7V
nZmt8nqmCV9e7s2cbfbb5A5HWZ4TcHJ2KFKLKlSjyOsuQBFqQHVfDXYBmhFsbYkC
MwQQAQgAHRYhBO22T6L3kGd6kKX9jjbDVqWm6mZ2BQJahiGaAAoJEDbDVqWm6mZ2
pfkP/RFMkUCp4Gtv0g7RvhDkbybb6b2GiAS1bvmfSHYfZ8V4ZuQHYV6BM8g7SItq
FOpsNADnb1LqTOKJFok1YCwjeN/YMNFExOqt/bE8w87rvj0UlxcRdbSM6cB4JxFo
RAFjE23vJ0OZ/NujPLBnsVCD7Gk7wOTswR5nkhZ9usonpU1aaQUjEfmIiH2m0J+G
84GXDqHofE13VeDApqPy3S/E2BxK8Td232bOYiU7s3mQuy1copaCDchXbWO5FaP5
P+bhaUh4AS6a96v6FfYaZqj/0aUWOgwH5deFaU7Fch0C4xFejhvcq0r4YFmmY1T+
R6aZu4VB69iNXeh9mcL9qnci0MxLa2I/VZn0oZ1nVoovaRYXRkMNx+VnEg57JVP3
ap4MEwUZzhooN1RSNdsMnPK0nB2zYodbSvUe6ie5XstGtCRgLlFGzxoXwk9ZMO59
ycVHnzgFA3AHKvKFuIP0YELRBOjvNoy9HLTYB9FY/xPCCf5+T5lzIwqv4qlDZfV/
1rJJKPg5tijd5dXTlaoXtjGAixUYB8oFHJ/bl0fEMxUP1S1pE1t4DvIMNVUChUi0
yL/OOX9RhDkzsVSGyvM53zAk9glvFAtg0pGbNXwu40lCZqvWGGmXLFi8P65KDnKC
XpnUd45sNfwfMH6rGyhSLRqYZLETVsquMyGgsJZUW7KPva5ziQIzBBABCgAdFiEE
UCa0fcAp7B2D76H27PE5p3fKNFQFAl0CRn0ACgkQ7PE5p3fKNFSDyBAAkhvoiSIf
UhbsX82Hj/ESTOdEPPbC0bkzMdzhdYXdO/SteAfd09dpjtNuHkGsXXOGfw3iq7TG
PwdfHgqtZDvu0ckQWqb2YC6CwhzFgl1ytrAKFfvwCmS3ftawSZA7bbE2q8q8+HLf
KSeGFsRIOu2V0FG18WFatwyabxtP2y0lHMHoZDb7LOVBJ7NwQOwZsRKF7aQ7zQrT
p15gFLx3BsD9uzsJN1SgrMRGiPBA3xeuQ4YHVaBlo3IQawR+vfaX6RIIECR6aGO0
o6B539cyFBtp0D0lGbLaZ35xghXB1obpluR75R+AMl+dfWyAxRFK69R1ETtk0BZr
2WQV2mn+/RINToQteeraRSMzkBGQ5r3n5O7QJcucn/EaeLmYDdh+pFAqRKtQQE+l
q2xsH6XJdzmVLBrM7xB6zZNw9d1NORd6np4iTtuVOz0vyn6My0bfoXQf63x9cF5V
HPHXhgvDkcSAPntGt79zunh5StRuX0RBejszYNyt7paFHgqh+pXbPi7dk1OdnEYz
liMOibcqgvyL2gYyOQkYUpMCA1dz98w5uYGlkFrLsVKE6ySed8sRL+nTR3Y5zP0M
Z6DVFLl5WF1fhFA2loxVFB4d0J8M/CX40OYjUceTHIiE3DjBsnSrvrURlf1YvtDf
XX7g/Wdz9CrrC4WO4qn73blHiKtRgo5zewqJAjMEEAEKAB0WIQTU/3wdYJFfOEC/
1Ysr6KOtDiGtnQUCW2QXrwAKCRAr6KOtDiGtna4xD/91HGyZhvQDY7gVYHejcE5w
0gtSwGz/1PTIvFcJgAsBp6iFSWmqgqaz7KDoKC1bR/vR7Wp2apW3CSIqjBhb156H
hX0fps9z6c80FvfcnJorYBpoSOJ0nDwgaQpbBxzCe9ddPgCduGPQUb89WasfyoG5
/f/0SRPxr9jJr/qBEs0U6/bDhGOR+JOj9t/1bap2GbaTxBN5I6UxaE9ICu43C+/w
DUap5co7071b58weVLSLhKEv/L0Tr1otx369MBiTiJJEESsIc3gsUpSIB9H9QFET
Pqx+thxBGvo49GJxGbjIS5CC7jd4QUbTek0lks6o4UXv6Wg/LVQyZDXrSo0phTZY
/j8cnjr8oZC/PO08GdaUUhXgNqp/gMm15bNNANfuNaUSeamIZsI9SVIQOPKGtznk
M61jqu2ZRKFHiMFRSgzbRoqyL8rBLfmGkM83AcC+v6pPOf4s0ph5RZrC0VdeeAxF
wCbfh8kYNTAeqaCOGw4JYfXfyqmDwBizrqYMGDDdxcFUIZ9sd5joZ4zezVdcfL1M
amJiR4+0Debnxc+FGJPoGK/4pF5/BYmGvT/pYTFBmMgRno8fWNJQIPG7F9kTsE1t
r1IxZttFycMlY/w+5eo9CoOUIPJunI5vtM/KP7xLaNz/6ec4lne2QASbS+FvjiCP
SuMC8fi5PcH/ucxaU+Rpb4kCNgQTAQgAIAUCVIgQPAIbAwULCQgHAgYVCAkKCwID
FgIBAh4BAheAAAoJECk6zQkH2Ula3DcQAJhM1/T0GnM85TwJQ3t5tQDdJT+hP9aW
FiwRrOqhodfHFsXY8jlrDuO5wNT/nExkkgGHuJCDiMfZKdksTm3SnmU7mQgXVn0L
v3LkRNdroEEBREZacFUTXejWLk91sme7/GV0cOH99xSLeAkq1tfwMjP76wPrVjaO
228K/whWiHC/JCDT3HHRTqVodeHqzjXfzxG94DvNclyAOnNVZeJnHtiUtIXehBaI
cUucjECTESEv2YPLn098hyVyGkpxvikT+1li0dGWi62gspGIvxFvZpkOPS8UKryj
oI6UVvWjYAwPmqypdbpo6ZZABSNRmdP6gLICpuGoHbyxmYgmag13EVjaJRuT3jlF
KYptX+faRp7qWd0W8+ARfOFPtbi/sndDkeWPfAMVhcqtHais/8xWMEJDFZ5fcltK
ZCZj+6+jyo9G4VpLEF/kY2D6LI98MT2e4kijF+8WWqqJ/wh6R/f15vAmmxnAyE60
uofofMdBRU/zFfyyoHN6vBOJpV3pXsNUbcSuIc1xaVypdFnEIOeH49VqsuOxZZAK
xudHYtLrQgRpZlWUAPJ6hOaGJBS6aYhV89Ulwl6I33/12XTRdlnYFfGgyjmEytVg
z1Ei5qmPqHzpEC+PWKmfTORUfITAG7y+nuAm8VVwpFiekPS4O/QM0t6bzlwifwlA
0LOwd9L9NugWuQINBFSIEDwBEADB7jwL/wdCj/UhANT7F+ft+5OOpUz+5b+1RXe1
Wg9awFDP2cg9n1oKyaa3AjxKXTH7rM3zh4xlUO9PFUPr5Q97xaDmRPioHip8wxvR
c2F1z5sZFsxBKiNevGpnhfy0ITddlRdZquS27pIgCsdsrW9SPKJ7wrzSQ7x9Q/9w
cUo+04VfWz/2xbn3DhxQTBcLcgZlGkeVsfegavuwxG068YbIaEIFKiCxLnVMoWWt
Of066VLwuH/KnNHXmqiRTtcw+oLmcUVmXUliXXBc02ncoN+j+HshTMWtRbewZQyn
SiwBZQhoEdI3zpykZnUShkTRUy0OvGgZXTeMMQbjMHtLZkcPc2MFsA+NwKvRNumL
bsVUdcG981JovuO0B+ADQMzIqIphPTI+UqmK4M0CdCBsCIwbfhzcx72F/pMjLlRS
uNZNlaOqFDlR7kDLt1IC70jzLMsOvXfiMCv667EMLrra7yfEHHWt63mtR71gVXGP
7sqEbDrAku4rUujCuiikymlvIrJJhG9v4UNWQdUGlptrEPF3Jm90GYLcgX2Srgve
U/5gFz09LhE+m06T+Q3RIt8BbPqsP3PRWUjQ2eIXzbktuHdrt0YfC1rf1PN4ngLr
TZ7Tixob7mZtYy2Ldir81xPHC7d1e+f2RTvsRFe6y7jraxylpc4CUM7A6dYDOE8/
VC3mOwARAQABiH8EEhYKACcWIQRQQFYXHWwYKA6Ezfe2Zm2RwjfyiQUCWsf0cgWD
AeEzgAMFATwACgkQtmZtkcI38okvzwD+PFAaXtH+KkuIzYJPH1rlaswCx2ALFYUM
R7ptsWNbQQwA/iaqtZns6UngP85uNyKNLjoxIWK3+WRQ8Cj3+pFBU58EiQIfBBgB
CAAJBQJUiBA8AhsMAAoJECk6zQkH2UlayIAQAK4QnaNyLabhClnMcdtqDMA5vtHZ
l5s6nD5wfMvU3zXKHE6CFz+Ox9flxHp2XU2GSTq3as6yumNT6ZcEL+oahU6MqYG0
E3pJ62fEgg8hCnFOIndq+90x084DUoguEABNteIuZCnejzEJ+12FY7Mb4p92WpUt
seJvFBWpdgvZ46PB6qE7AzkkctJs6KgKl5ngHt1/aWJnvwlMAOkfIRxIF+41IZvw
K1VucGW6AIp8OQZigY1oiME9b8X78IGwtBANTtuBuM0KhCdiC5nDN0b1sLRRVmrE
Kle6pynaQ/BCG6D2vDnJe46A3ObHivAc7CO7VzeGI8NGhwjpWdeX1hz2CoAxUUGk
RX3zgzzW8UDYs2K7t6WxFgyyFKELScipfqNvvJuGlgmDcydVpBEI3vUWgaRzQ3Mn
p+cgu97QhCbL02bjCOF6nOMYC7fMKK3ihOexkXYNnzvHzuNF90fyeWDOkqhu8trs
I1QhxQXjtjZJK/Jzp6PkW59m9N3PTGqTE+JZfIyXbOMcIYaSI8zy1ndmXF/2Rqpr
Jyj4q6HvadVT6JTe9joa/ZUvjl37zj1djfgK+awjm5oZ2G1xoFP6soZNapEbvsNm
wWTjkCHWgn4eQB2592RhhkLJIFmQxT+MWdw1lxUljD7rxI25lJ9sa4faGLI7Dhsv
bcgRRcklNr8mTzJH
=ckew
-----END PGP PUBLIC KEY BLOCK-----
targets:
lxc:
create_message: |
You just created an {{ image.description }} container.
config:
- type: all
before: 5
content: |-
lxc.include = LXC_TEMPLATE_CONFIG/alpine.common.conf
- type: user
before: 5
content: |-
lxc.include = LXC_TEMPLATE_CONFIG/alpine.userns.conf
- type: all
after: 4
content: |-
lxc.include = LXC_TEMPLATE_CONFIG/common.conf
- type: user
after: 4
content: |-
lxc.include = LXC_TEMPLATE_CONFIG/userns.conf
- type: all
content: |-
lxc.arch = {{ image.architecture_personality }}
incus:
vm:
filesystem: ext4
files:
- path: /etc/hostname
generator: hostname
- path: /etc/hosts
generator: hosts
- generator: fstab
types:
- vm
- generator: incus-agent
types:
- vm
- path: /etc/default/grub
generator: dump
pongo: true
content: |-
# Set the recordfail timeout
GRUB_RECORDFAIL_TIMEOUT=0
# Do not wait on grub prompt
GRUB_TIMEOUT=0
# Set the default commandline
GRUB_CMDLINE_LINUX_DEFAULT="console=tty1 console=ttyS0 modules={{ targets.incus.vm.filesystem }} rootfstype={{ targets.incus.vm.filesystem }}"
# Set the grub console type
GRUB_TERMINAL=console
# Disable os-prober
GRUB_DISABLE_OS_PROBER=true
types:
- vm
- path: /etc/network/interfaces
generator: dump
content: |-
auto eth0
iface eth0 inet dhcp
hostname $(hostname)
- path: /etc/inittab
generator: dump
content: |-
# /etc/inittab
::sysinit:/sbin/openrc sysinit
::sysinit:/sbin/openrc boot
::wait:/sbin/openrc default
# Set up a couple of getty's
::respawn:/sbin/getty 38400 console
tty1::respawn:/sbin/getty 38400 tty1
tty2::respawn:/sbin/getty 38400 tty2
tty3::respawn:/sbin/getty 38400 tty3
tty4::respawn:/sbin/getty 38400 tty4
# Stuff to do for the 3-finger salute
::ctrlaltdel:/sbin/reboot
# Stuff to do before rebooting
::shutdown:/sbin/openrc shutdown
- path: /etc/inittab
generator: template
name: inittab
content: |-
# /etc/inittab
::sysinit:/sbin/openrc sysinit
::sysinit:/sbin/openrc boot
::wait:/sbin/openrc default
# Set up a couple of getty's
::respawn:/sbin/getty 38400 console
# Stuff to do for the 3-finger salute
::ctrlaltdel:/sbin/reboot
# Stuff to do before rebooting
::shutdown:/sbin/openrc shutdown
- name: meta-data
generator: cloud-init
variants:
- cloud
- name: network-config
generator: cloud-init
content: |-
version: 1
config:
- type: physical
name: eth0
subnets:
- type: dhcp
control: auto
variants:
- cloud
- name: user-data
generator: cloud-init
variants:
- cloud
- name: vendor-data
generator: cloud-init
variants:
- cloud
packages:
manager: apk
update: true
cleanup: true
sets:
- packages:
- alpine-base
- logrotate
- doas
action: install
- packages:
- cloud-init
- e2fsprogs-extra
action: install
variants:
- cloud
- packages:
- py3-pyserial
- py3-netifaces
action: install
variants:
- cloud
releases:
- 3.20
- 3.21
- 3.22
- 3.23
- edge
- packages:
- acpi
- grub-efi
- linux-virt
action: install
types:
- vm
- packages:
- tiny-cloud-incus
action: install
variants:
- tinycloud
actions:
- trigger: post-unpack
action: |-
#!/bin/sh
set -eux
printf "disable_trigger=1\n" > /etc/update-grub.conf
types:
- vm
- trigger: post-packages
action: |-
#!/bin/sh
set -eux
rm -f /var/cache/apk/*
- trigger: post-packages
action: |-
#!/bin/sh
set -eux
# Rewrite configuration for LXC
sed -i 's/#rc_sys=""/rc_sys="lxc"/' /etc/rc.conf
# Honor fstab by not making the localmount script a noop
sed -i 's/-lxc//' /etc/init.d/localmount
# Enable services
for svc_name in bootmisc syslog devfs; do
ln -s /etc/init.d/${svc_name} /etc/runlevels/boot/${svc_name}
done
for svc_name in networking crond; do
ln -s /etc/init.d/${svc_name} /etc/runlevels/default/${svc_name}
done
types:
- container
- trigger: post-files
action: |-
#!/bin/sh
set -eux
for svc_name in acpid bootmisc hostname hwclock loadkmap modules networking swap sysctl syslog urandom; do
ln -fs /etc/init.d/${svc_name} /etc/runlevels/boot/${svc_name}
done
for svc_name in devfs dmesg hwdrivers mdev; do
ln -fs /etc/init.d/${svc_name} /etc/runlevels/sysinit/${svc_name}
done
mkdir -p /boot/grub
target=/boot/grub/grub.cfg
grub-mkconfig -o "${target}"
sed -i "s#root=[^ ]*#root=${DISTROBUILDER_ROOT_UUID}#g" "${target}"
TARGET="x86_64"
[ "$(uname -m)" = "aarch64" ] && TARGET="arm64"
grub-install --target=${TARGET}-efi --no-nvram --removable
grub-install --target=${TARGET}-efi --no-nvram
# re-enable grub trigger for future grub updates
rm /etc/update-grub.conf
types:
- vm
- trigger: post-files
action: |-
#!/bin/sh
set -eux
# Tweak to prevent cloud-init from getting stuck.
echo "datasource_list: ['NoCloud', 'ConfigDrive', 'LXD', 'None']" > /etc/cloud/cloud.cfg.d/99_lxc.cfg
types:
- vm
variants:
- cloud
- trigger: post-files
action: |-
#!/bin/sh
set -eux
setup-cloud-init
variants:
- cloud
- trigger: post-files
action: |-
#!/bin/sh
set -eux
tiny-cloud --enable
variants:
- tinycloud
mappings:
architecture_map: alpinelinux