feat: Admin Panel for Incus container management
This commit is contained in:
parent
fc64cd4a1f
commit
d6b2b6da23
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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)}",
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||||
|
|
@ -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"}
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -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
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
/* prettier-ignore */
|
||||||
|
// @ts-nocheck
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
// Generated by unplugin-auto-import
|
||||||
|
// biome-ignore lint: disable
|
||||||
|
export {}
|
||||||
|
declare global {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
@import 'frappe-ui/style.css';
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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": "Переключить тему"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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')
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
`
|
||||||
|
|
@ -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',
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
@ -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',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -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: [],
|
||||||
|
}
|
||||||
|
|
@ -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'),
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||||
Loading…
Reference in New Issue