fix: move build_image to background job to prevent gunicorn timeout
The build_image process (distrobuilder + image upload) takes 10+ minutes, exceeding gunicorn worker timeout. Now uses frappe.enqueue() with Redis cache for real-time progress tracking and frontend polling. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
da71d91294
commit
9a0a6b501a
|
|
@ -4,6 +4,7 @@ import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
import requests
|
import requests
|
||||||
|
|
@ -330,19 +331,36 @@ def delete_app(app_name):
|
||||||
return {"success": True, "message": f"App template '{app_name}' deleted successfully"}
|
return {"success": True, "message": f"App template '{app_name}' deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
def _update_build(build_id, **kwargs):
|
||||||
|
"""Update build status in cache."""
|
||||||
|
cache_key = f"admin_panel:build:{build_id}"
|
||||||
|
current = frappe.cache.get_value(cache_key) or {}
|
||||||
|
current.update(kwargs)
|
||||||
|
frappe.cache.set_value(cache_key, current, expires_in_sec=3600)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_build_log(build_id, *lines):
|
||||||
|
"""Append log lines to build status."""
|
||||||
|
cache_key = f"admin_panel:build:{build_id}"
|
||||||
|
current = frappe.cache.get_value(cache_key) or {}
|
||||||
|
logs = current.get("logs", [])
|
||||||
|
logs.extend(lines)
|
||||||
|
current["logs"] = logs
|
||||||
|
frappe.cache.set_value(cache_key, current, expires_in_sec=3600)
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def build_image(app_name, version, target_server=None, release="edge", doas_password=None):
|
def build_image(app_name, version, target_server=None, release="edge", doas_password=None):
|
||||||
"""Build image using distrobuilder and import to Incus via REST API."""
|
"""Start image build in background. Returns build_id for polling status."""
|
||||||
_check_admin_permission()
|
_check_admin_permission()
|
||||||
|
|
||||||
logs = []
|
|
||||||
template_path = os.path.join(TEMPLATES_DIR, f"{app_name}.yaml")
|
template_path = os.path.join(TEMPLATES_DIR, f"{app_name}.yaml")
|
||||||
|
|
||||||
if not os.path.exists(template_path):
|
if not os.path.exists(template_path):
|
||||||
return {"success": False, "error": f"Template '{app_name}' not found", "logs": []}
|
return {"success": False, "error": f"Template '{app_name}' not found"}
|
||||||
|
|
||||||
if not doas_password:
|
if not doas_password:
|
||||||
return {"success": False, "error": "Password is required for building images", "logs": []}
|
return {"success": False, "error": "Password is required for building images"}
|
||||||
|
|
||||||
# Determine target server
|
# Determine target server
|
||||||
if not target_server:
|
if not target_server:
|
||||||
|
|
@ -350,33 +368,81 @@ def build_image(app_name, version, target_server=None, release="edge", doas_pass
|
||||||
if image_server:
|
if image_server:
|
||||||
target_server = image_server["name"]
|
target_server = image_server["name"]
|
||||||
else:
|
else:
|
||||||
return {"success": False, "error": "No image server configured", "logs": []}
|
return {"success": False, "error": "No image server configured"}
|
||||||
|
|
||||||
server_url = _get_server_url(target_server)
|
server_url = _get_server_url(target_server)
|
||||||
if not server_url:
|
if not server_url:
|
||||||
return {"success": False, "error": f"Server '{target_server}' URL not found", "logs": []}
|
return {"success": False, "error": f"Server '{target_server}' URL not found"}
|
||||||
|
|
||||||
if not os.path.exists(CLIENT_CERT) or not os.path.exists(CLIENT_KEY):
|
if not os.path.exists(CLIENT_CERT) or not os.path.exists(CLIENT_KEY):
|
||||||
return {"success": False, "error": "Panel certificate not configured", "logs": []}
|
return {"success": False, "error": "Panel certificate not configured"}
|
||||||
|
|
||||||
|
# Generate build ID
|
||||||
|
build_id = uuid.uuid4().hex[:8]
|
||||||
|
|
||||||
|
# Initialize build status in cache
|
||||||
|
frappe.cache.set_value(f"admin_panel:build:{build_id}", {
|
||||||
|
"status": "queued",
|
||||||
|
"logs": ["Build queued, starting..."],
|
||||||
|
"progress": 0,
|
||||||
|
"step": "queued",
|
||||||
|
}, expires_in_sec=3600)
|
||||||
|
|
||||||
|
# Enqueue background job
|
||||||
|
frappe.enqueue(
|
||||||
|
_build_image_job,
|
||||||
|
queue="long",
|
||||||
|
timeout=900,
|
||||||
|
build_id=build_id,
|
||||||
|
app_name=app_name,
|
||||||
|
version=version,
|
||||||
|
target_server=target_server,
|
||||||
|
server_url=server_url,
|
||||||
|
release=release,
|
||||||
|
doas_password=doas_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"success": True, "build_id": build_id}
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_build_status(build_id):
|
||||||
|
"""Poll build status by build_id."""
|
||||||
|
_check_admin_permission()
|
||||||
|
|
||||||
|
cache_key = f"admin_panel:build:{build_id}"
|
||||||
|
data = frappe.cache.get_value(cache_key)
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return {"status": "not_found", "logs": [], "progress": 0}
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _build_image_job(build_id, app_name, version, target_server, server_url, release, doas_password):
|
||||||
|
"""Background job: build image via distrobuilder and import to Incus."""
|
||||||
build_dir = tempfile.mkdtemp(prefix=f"build-{app_name}-")
|
build_dir = tempfile.mkdtemp(prefix=f"build-{app_name}-")
|
||||||
logs.append("=== Build Configuration ===")
|
template_path = os.path.join(TEMPLATES_DIR, f"{app_name}.yaml")
|
||||||
logs.append(f"App: {app_name}")
|
|
||||||
logs.append(f"Version: {version or 'latest'}")
|
_update_build(build_id, status="running", step="preparing", progress=5)
|
||||||
logs.append(f"Release: {release}")
|
_append_build_log(build_id,
|
||||||
logs.append(f"Target Server: {target_server}")
|
"=== Build Configuration ===",
|
||||||
logs.append(f"Build directory: {build_dir}")
|
f"App: {app_name}",
|
||||||
|
f"Version: {version or 'latest'}",
|
||||||
|
f"Release: {release}",
|
||||||
|
f"Target Server: {target_server}",
|
||||||
|
f"Build directory: {build_dir}",
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
build_template = os.path.join(build_dir, "template.yaml")
|
build_template = os.path.join(build_dir, "template.yaml")
|
||||||
shutil.copy(template_path, build_template)
|
shutil.copy(template_path, build_template)
|
||||||
logs.append("Template copied to build directory")
|
_append_build_log(build_id, "Template copied to build directory")
|
||||||
|
|
||||||
logs.append("")
|
# --- Step 1: Build with distrobuilder ---
|
||||||
logs.append("=== Running distrobuilder ===")
|
_update_build(build_id, step="building", progress=10)
|
||||||
logs.append("This may take several minutes...")
|
_append_build_log(build_id, "", "=== Running distrobuilder ===", "This may take several minutes...")
|
||||||
|
|
||||||
# Use sh -c to cd into build_dir before running distrobuilder
|
|
||||||
shell_cmd = (
|
shell_cmd = (
|
||||||
f"cd {build_dir} && "
|
f"cd {build_dir} && "
|
||||||
f"distrobuilder build-incus "
|
f"distrobuilder build-incus "
|
||||||
|
|
@ -389,41 +455,44 @@ def build_image(app_name, version, target_server=None, release="edge", doas_pass
|
||||||
returncode, output = _run_doas_command(cmd, doas_password, timeout=600)
|
returncode, output = _run_doas_command(cmd, doas_password, timeout=600)
|
||||||
|
|
||||||
# Parse output, skip password prompt lines
|
# Parse output, skip password prompt lines
|
||||||
|
output_lines = []
|
||||||
for line in output.split("\n"):
|
for line in output.split("\n"):
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
if not stripped or "password" in stripped.lower():
|
if not stripped or "password" in stripped.lower():
|
||||||
continue
|
continue
|
||||||
logs.append(stripped)
|
output_lines.append(stripped)
|
||||||
|
|
||||||
|
if output_lines:
|
||||||
|
_append_build_log(build_id, *output_lines)
|
||||||
|
|
||||||
if returncode != 0:
|
if returncode != 0:
|
||||||
logs.append("Build failed")
|
_append_build_log(build_id, "✗ Build failed")
|
||||||
return {"success": False, "error": "Build failed", "logs": logs}
|
_update_build(build_id, status="failed", step="build_failed", progress=100,
|
||||||
|
error="distrobuilder build failed")
|
||||||
|
return
|
||||||
|
|
||||||
logs.append("Build completed successfully")
|
_append_build_log(build_id, "✓ Build completed successfully")
|
||||||
|
_update_build(build_id, progress=60)
|
||||||
|
|
||||||
# Check for output files
|
# Check for output files
|
||||||
incus_tar = os.path.join(build_dir, "incus.tar.xz")
|
incus_tar = os.path.join(build_dir, "incus.tar.xz")
|
||||||
rootfs_squashfs = os.path.join(build_dir, "rootfs.squashfs")
|
rootfs_squashfs = os.path.join(build_dir, "rootfs.squashfs")
|
||||||
|
|
||||||
if not os.path.exists(incus_tar):
|
if not os.path.exists(incus_tar):
|
||||||
logs.append("Output file not found: incus.tar.xz")
|
_append_build_log(build_id, "✗ Output file not found: incus.tar.xz")
|
||||||
return {"success": False, "error": "Build output not found", "logs": logs}
|
_update_build(build_id, status="failed", step="build_failed", progress=100,
|
||||||
|
error="Build output not found")
|
||||||
# Import to Incus via REST API
|
return
|
||||||
logs.append("")
|
|
||||||
logs.append(f"=== Importing to {target_server} via API ===")
|
|
||||||
|
|
||||||
|
# --- Step 2: Import to Incus ---
|
||||||
|
_update_build(build_id, step="importing", progress=65)
|
||||||
alias = f"{app_name}:{version}" if version else app_name
|
alias = f"{app_name}:{version}" if version else app_name
|
||||||
|
_append_build_log(build_id, "", f"=== Importing to {target_server} via API ===")
|
||||||
|
|
||||||
# Prepare multipart upload
|
|
||||||
if os.path.exists(rootfs_squashfs):
|
if os.path.exists(rootfs_squashfs):
|
||||||
logs.append("Importing split image (metadata + rootfs)")
|
_append_build_log(build_id, "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:
|
with open(incus_tar, "rb") as meta_file, open(rootfs_squashfs, "rb") as rootfs_file:
|
||||||
headers = {
|
headers = {"X-Incus-public": "false"}
|
||||||
"X-Incus-public": "false",
|
|
||||||
}
|
|
||||||
# Incus expects multipart with metadata.tar.xz and rootfs.squashfs
|
|
||||||
files = {
|
files = {
|
||||||
"metadata": ("incus.tar.xz", meta_file, "application/octet-stream"),
|
"metadata": ("incus.tar.xz", meta_file, "application/octet-stream"),
|
||||||
"rootfs": ("rootfs.squashfs", rootfs_file, "application/octet-stream"),
|
"rootfs": ("rootfs.squashfs", rootfs_file, "application/octet-stream"),
|
||||||
|
|
@ -437,7 +506,7 @@ def build_image(app_name, version, target_server=None, release="edge", doas_pass
|
||||||
timeout=300,
|
timeout=300,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logs.append("Importing unified image")
|
_append_build_log(build_id, "Importing unified image")
|
||||||
with open(incus_tar, "rb") as f:
|
with open(incus_tar, "rb") as f:
|
||||||
headers = {
|
headers = {
|
||||||
"Content-Type": "application/octet-stream",
|
"Content-Type": "application/octet-stream",
|
||||||
|
|
@ -454,16 +523,20 @@ def build_image(app_name, version, target_server=None, release="edge", doas_pass
|
||||||
|
|
||||||
if r.status_code not in (200, 202):
|
if r.status_code not in (200, 202):
|
||||||
error_msg = r.text
|
error_msg = r.text
|
||||||
logs.append(f"Import failed: {error_msg}")
|
_append_build_log(build_id, f"✗ Import failed: {error_msg}")
|
||||||
return {"success": False, "error": error_msg, "logs": logs}
|
_update_build(build_id, status="failed", step="import_failed", progress=100,
|
||||||
|
error=error_msg)
|
||||||
|
return
|
||||||
|
|
||||||
resp = r.json()
|
resp = r.json()
|
||||||
|
_update_build(build_id, progress=80)
|
||||||
|
|
||||||
# Wait for async operation if needed
|
# Wait for async operation if needed
|
||||||
|
op_url = ""
|
||||||
if resp.get("type") == "async":
|
if resp.get("type") == "async":
|
||||||
op_url = resp.get("operation", "")
|
op_url = resp.get("operation", "")
|
||||||
if op_url:
|
if op_url:
|
||||||
logs.append("Waiting for import operation...")
|
_append_build_log(build_id, "Waiting for import operation...")
|
||||||
wait_r = requests.get(
|
wait_r = requests.get(
|
||||||
f"{server_url}{op_url}/wait",
|
f"{server_url}{op_url}/wait",
|
||||||
cert=(CLIENT_CERT, CLIENT_KEY),
|
cert=(CLIENT_CERT, CLIENT_KEY),
|
||||||
|
|
@ -473,24 +546,26 @@ def build_image(app_name, version, target_server=None, release="edge", doas_pass
|
||||||
wait_resp = wait_r.json()
|
wait_resp = wait_r.json()
|
||||||
if wait_resp.get("metadata", {}).get("status") != "Success":
|
if wait_resp.get("metadata", {}).get("status") != "Success":
|
||||||
error = wait_resp.get("metadata", {}).get("err", "Unknown error")
|
error = wait_resp.get("metadata", {}).get("err", "Unknown error")
|
||||||
logs.append(f"Import operation failed: {error}")
|
_append_build_log(build_id, f"✗ Import operation failed: {error}")
|
||||||
return {"success": False, "error": error, "logs": logs}
|
_update_build(build_id, status="failed", step="import_failed", progress=100,
|
||||||
|
error=error)
|
||||||
|
return
|
||||||
|
|
||||||
|
_update_build(build_id, progress=90, step="setting_alias")
|
||||||
|
|
||||||
# Get fingerprint from response to set alias
|
# Get fingerprint from response to set alias
|
||||||
fingerprint = ""
|
fingerprint = ""
|
||||||
op_metadata = resp.get("metadata", {})
|
op_metadata = resp.get("metadata", {})
|
||||||
if resp.get("type") == "async":
|
if resp.get("type") == "async" and op_url:
|
||||||
# For async, get fingerprint from operation metadata
|
op_r = requests.get(
|
||||||
if op_url:
|
f"{server_url}{op_url}",
|
||||||
op_r = requests.get(
|
cert=(CLIENT_CERT, CLIENT_KEY),
|
||||||
f"{server_url}{op_url}",
|
verify=False,
|
||||||
cert=(CLIENT_CERT, CLIENT_KEY),
|
timeout=30,
|
||||||
verify=False,
|
)
|
||||||
timeout=30,
|
op_resp = op_r.json()
|
||||||
)
|
op_meta = op_resp.get("metadata", {}).get("metadata", {})
|
||||||
op_resp = op_r.json()
|
fingerprint = op_meta.get("fingerprint", "")
|
||||||
op_meta = op_resp.get("metadata", {}).get("metadata", {})
|
|
||||||
fingerprint = op_meta.get("fingerprint", "")
|
|
||||||
else:
|
else:
|
||||||
fingerprint = op_metadata.get("fingerprint", "")
|
fingerprint = op_metadata.get("fingerprint", "")
|
||||||
|
|
||||||
|
|
@ -501,36 +576,34 @@ def build_image(app_name, version, target_server=None, release="edge", doas_pass
|
||||||
"name": alias,
|
"name": alias,
|
||||||
"target": fingerprint,
|
"target": fingerprint,
|
||||||
})
|
})
|
||||||
logs.append(f"Alias '{alias}' set for image {fingerprint[:12]}")
|
_append_build_log(build_id, f"✓ Alias '{alias}' set for image {fingerprint[:12]}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Alias might already exist, try to update
|
|
||||||
try:
|
try:
|
||||||
_incus_api(server_url, "PUT", f"/1.0/images/aliases/{alias}", {
|
_incus_api(server_url, "PUT", f"/1.0/images/aliases/{alias}", {
|
||||||
"target": fingerprint,
|
"target": fingerprint,
|
||||||
})
|
})
|
||||||
logs.append(f"Alias '{alias}' updated for image {fingerprint[:12]}")
|
_append_build_log(build_id, f"✓ Alias '{alias}' updated for image {fingerprint[:12]}")
|
||||||
except Exception:
|
except Exception:
|
||||||
logs.append(f"Warning: Could not set alias '{alias}': {str(e)}")
|
_append_build_log(build_id, f"⚠ Could not set alias '{alias}': {str(e)}")
|
||||||
|
|
||||||
logs.append(f"Image imported as '{alias}'")
|
_append_build_log(build_id,
|
||||||
logs.append("")
|
f"✓ Image imported as '{alias}'",
|
||||||
logs.append("=== Build completed successfully! ===")
|
"",
|
||||||
|
"=== Build completed successfully! ===",
|
||||||
return {
|
)
|
||||||
"success": True,
|
_update_build(build_id, status="completed", step="done", progress=100,
|
||||||
"message": f"Image '{alias}' built and imported successfully",
|
alias=alias, message=f"Image '{alias}' built and imported successfully")
|
||||||
"logs": logs,
|
|
||||||
"alias": alias,
|
|
||||||
}
|
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
logs.append("Build timed out (exceeded 10 minutes)")
|
_append_build_log(build_id, "✗ Build timed out (exceeded 10 minutes)")
|
||||||
return {"success": False, "error": "Build timed out", "logs": logs}
|
_update_build(build_id, status="failed", step="timeout", progress=100,
|
||||||
|
error="Build timed out")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logs.append(f"Error: {str(e)}")
|
_append_build_log(build_id, f"✗ Error: {str(e)}")
|
||||||
|
_update_build(build_id, status="failed", step="error", progress=100,
|
||||||
|
error=str(e))
|
||||||
frappe.log_error(f"Build failed for {app_name}: {str(e)}", "Image Build Error")
|
frappe.log_error(f"Build failed for {app_name}: {str(e)}", "Image Build Error")
|
||||||
return {"success": False, "error": str(e), "logs": logs}
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
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 @@
|
||||||
|
.build-progress-animated[data-v-fca62e4b]{background-image:linear-gradient(-45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:1rem 1rem;animation:build-progress-stripes-fca62e4b .75s linear infinite}@keyframes build-progress-stripes-fca62e4b{0%{background-position:1rem 0}to{background-position:0 0}}
|
||||||
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
|
|
@ -1,2 +1,2 @@
|
||||||
import{o as a,c as r,b as e,t as i,N as s}from"./index-6984cb4d.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 _};
|
import{o as a,c as r,b as e,t as i,P as s}from"./index-5e93ecd0.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-32bd6a37.js.map
|
//# sourceMappingURL=ChartCard-98ba0e20.js.map
|
||||||
|
|
@ -1 +1 @@
|
||||||
{"version":3,"file":"ChartCard-32bd6a37.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"}
|
{"version":3,"file":"ChartCard-98ba0e20.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"}
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
import{_ as g}from"./LayoutHeader-756499bb.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-6984cb4d.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};
|
import{_ as g}from"./LayoutHeader-596b102d.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-5e93ecd0.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-a9befce5.js.map
|
//# sourceMappingURL=Customers-efeef313.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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,2 +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,N as d}from"./index-6984cb4d.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 _};
|
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,P as d}from"./index-5e93ecd0.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-a41cdda0.js.map
|
//# sourceMappingURL=EmptyState-1ecba3e9.js.map
|
||||||
|
|
@ -1 +1 @@
|
||||||
{"version":3,"file":"EmptyState-a41cdda0.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"}
|
{"version":3,"file":"EmptyState-1ecba3e9.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"}
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
import{o as a,c as r,b as e,N as s,t as i}from"./index-6984cb4d.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 _};
|
import{o as a,c as r,b as e,P as s,t as i}from"./index-5e93ecd0.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-756499bb.js.map
|
//# sourceMappingURL=LayoutHeader-596b102d.js.map
|
||||||
|
|
@ -1 +1 @@
|
||||||
{"version":3,"file":"LayoutHeader-756499bb.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"}
|
{"version":3,"file":"LayoutHeader-596b102d.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"}
|
||||||
|
|
@ -1,2 +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-6984cb4d.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};
|
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-5e93ecd0.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-9c5fd274.js.map
|
//# sourceMappingURL=Login-3b5a2007.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
|
|
@ -1,2 +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-6984cb4d.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};
|
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-5e93ecd0.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-b92a28d0.js.map
|
//# sourceMappingURL=NotFound-62e4f85b.js.map
|
||||||
|
|
@ -1 +1 @@
|
||||||
{"version":3,"file":"NotFound-b92a28d0.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"}
|
{"version":3,"file":"NotFound-62e4f85b.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
|
|
@ -1,2 +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-6984cb4d.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 _};
|
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-5e93ecd0.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-140179fb.js.map
|
//# sourceMappingURL=StatCard-9cdf9951.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
|
|
@ -15,8 +15,8 @@
|
||||||
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="/assets/admin_panel/frontend/assets/index-6984cb4d.js"></script>
|
<script type="module" crossorigin src="/assets/admin_panel/frontend/assets/index-5e93ecd0.js"></script>
|
||||||
<link rel="stylesheet" href="/assets/admin_panel/frontend/assets/index-b6e1d020.css">
|
<link rel="stylesheet" href="/assets/admin_panel/frontend/assets/index-a640712b.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="h-full">
|
<body class="h-full">
|
||||||
<div id="app" class="h-full"></div>
|
<div id="app" class="h-full"></div>
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@
|
||||||
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="/assets/admin_panel/frontend/assets/index-6984cb4d.js"></script>
|
<script type="module" crossorigin src="/assets/admin_panel/frontend/assets/index-5e93ecd0.js"></script>
|
||||||
<link rel="stylesheet" href="/assets/admin_panel/frontend/assets/index-b6e1d020.css">
|
<link rel="stylesheet" href="/assets/admin_panel/frontend/assets/index-a640712b.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="h-full">
|
<body class="h-full">
|
||||||
<div id="app" class="h-full"></div>
|
<div id="app" class="h-full"></div>
|
||||||
|
|
|
||||||
|
|
@ -218,61 +218,98 @@
|
||||||
<Dialog v-model="showBuildModal" :options="{ title: 'Build Image' }">
|
<Dialog v-model="showBuildModal" :options="{ title: 'Build Image' }">
|
||||||
<template #body-content>
|
<template #body-content>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<FormControl
|
<!-- Build form (before build starts) -->
|
||||||
v-model="buildVersion"
|
<template v-if="!building && !buildResult">
|
||||||
label="Version Tag"
|
<FormControl
|
||||||
type="text"
|
v-model="buildVersion"
|
||||||
placeholder="v1.0.0"
|
label="Version Tag"
|
||||||
:description="'Leave empty for unversioned build'"
|
type="text"
|
||||||
/>
|
placeholder="v1.0.0"
|
||||||
<FormControl
|
:description="'Leave empty for unversioned build'"
|
||||||
v-model="buildRelease"
|
/>
|
||||||
label="Release"
|
<FormControl
|
||||||
type="text"
|
v-model="buildRelease"
|
||||||
placeholder="edge"
|
label="Release"
|
||||||
description="Distribution release (e.g. edge, 3.21, 3.23, 24.04)"
|
type="text"
|
||||||
/>
|
placeholder="edge"
|
||||||
<FormControl
|
description="Distribution release (e.g. edge, 3.21, 3.23, 24.04)"
|
||||||
v-model="targetServer"
|
/>
|
||||||
label="Target Server"
|
<FormControl
|
||||||
type="select"
|
v-model="targetServer"
|
||||||
:options="targetServerOptions"
|
label="Target Server"
|
||||||
description="Select the image server where the built image will be imported"
|
type="select"
|
||||||
/>
|
:options="targetServerOptions"
|
||||||
<FormControl
|
description="Select the image server where the built image will be imported"
|
||||||
v-if="!buildResult"
|
/>
|
||||||
v-model="doasPassword"
|
<FormControl
|
||||||
label="Password (doas)"
|
v-model="doasPassword"
|
||||||
type="password"
|
label="Password (doas)"
|
||||||
placeholder="Required for distrobuilder"
|
type="password"
|
||||||
/>
|
placeholder="Required for distrobuilder"
|
||||||
<div v-if="buildResult" class="mt-4">
|
/>
|
||||||
<label class="block text-sm font-medium text-ink-gray-7 mb-2">Build Output</label>
|
</template>
|
||||||
<div class="bg-gray-900 text-gray-100 p-4 rounded-lg text-xs font-mono max-h-64 overflow-y-auto">
|
|
||||||
<div
|
<!-- Build progress -->
|
||||||
v-for="(log, index) in buildResult.logs"
|
<template v-if="building || buildResult">
|
||||||
:key="index"
|
<!-- Progress bar -->
|
||||||
:class="{
|
<div class="space-y-2">
|
||||||
'text-green-400': log.includes('✓'),
|
<div class="flex items-center justify-between text-sm">
|
||||||
'text-red-400': log.includes('✗') || log.includes('Error'),
|
<span class="font-medium text-ink-gray-7">
|
||||||
'text-yellow-400': log.includes('==='),
|
{{ buildStepLabel }}
|
||||||
}"
|
</span>
|
||||||
>
|
<span class="text-ink-gray-5">
|
||||||
{{ log }}
|
{{ buildElapsedFormatted }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-surface-gray-3 rounded-full h-2 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-full rounded-full transition-all duration-500"
|
||||||
|
:class="buildResult?.status === 'failed' ? 'bg-red-500' : buildResult?.status === 'completed' ? 'bg-green-500' : 'bg-blue-500 build-progress-animated'"
|
||||||
|
:style="{ width: `${buildProgress}%` }"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<!-- Build logs -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-ink-gray-7 mb-2">Build Output</label>
|
||||||
|
<div
|
||||||
|
ref="buildLogsContainer"
|
||||||
|
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 buildLogs"
|
||||||
|
:key="index"
|
||||||
|
:class="{
|
||||||
|
'text-green-400': log.includes('✓'),
|
||||||
|
'text-red-400': log.includes('✗') || log.includes('Error'),
|
||||||
|
'text-yellow-400': log.includes('⚠'),
|
||||||
|
'text-blue-400': log.includes('==='),
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ log }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Result status -->
|
||||||
|
<div v-if="buildResult" class="flex items-center gap-2 p-3 rounded-lg" :class="buildResult.status === 'completed' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'">
|
||||||
|
<FeatherIcon :name="buildResult.status === 'completed' ? 'check-circle' : 'x-circle'" class="h-5 w-5" />
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
{{ buildResult.status === 'completed' ? (buildResult.message || 'Build completed!') : (buildResult.error || 'Build failed') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<Button variant="subtle" @click="closeBuildModal">
|
<Button variant="subtle" @click="closeBuildModal">
|
||||||
{{ buildResult ? 'Close' : $t('common.cancel') }}
|
{{ building ? 'Hide' : buildResult ? 'Close' : $t('common.cancel') }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
v-if="!buildResult"
|
v-if="!building && !buildResult"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
@click="buildImage"
|
@click="buildImage"
|
||||||
:loading="building"
|
|
||||||
:disabled="!doasPassword"
|
:disabled="!doasPassword"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
|
|
@ -306,7 +343,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { createResource, call, Button, Badge, FormControl, Dialog, Spinner, FeatherIcon } from 'frappe-ui'
|
import { createResource, call, Button, Badge, FormControl, Dialog, Spinner, FeatherIcon } from 'frappe-ui'
|
||||||
import LayoutHeader from '@/components/Common/LayoutHeader.vue'
|
import LayoutHeader from '@/components/Common/LayoutHeader.vue'
|
||||||
|
|
@ -326,6 +363,14 @@ const targetServer = ref('')
|
||||||
const doasPassword = ref('')
|
const doasPassword = ref('')
|
||||||
const building = ref(false)
|
const building = ref(false)
|
||||||
const buildResult = ref(null)
|
const buildResult = ref(null)
|
||||||
|
const buildLogs = ref([])
|
||||||
|
const buildProgress = ref(0)
|
||||||
|
const buildStep = ref('')
|
||||||
|
const buildElapsed = ref(0)
|
||||||
|
const buildLogsContainer = ref(null)
|
||||||
|
|
||||||
|
let buildPollTimer = null
|
||||||
|
let buildElapsedTimer = null
|
||||||
|
|
||||||
const showEditDescModal = ref(false)
|
const showEditDescModal = ref(false)
|
||||||
const editDescription = ref('')
|
const editDescription = ref('')
|
||||||
|
|
@ -333,6 +378,28 @@ const savingDesc = ref(false)
|
||||||
|
|
||||||
const deletingVersion = ref(null)
|
const deletingVersion = ref(null)
|
||||||
|
|
||||||
|
const buildStepLabels = {
|
||||||
|
queued: 'Queued...',
|
||||||
|
preparing: 'Preparing build...',
|
||||||
|
building: 'Building image (distrobuilder)...',
|
||||||
|
importing: 'Importing to Incus...',
|
||||||
|
setting_alias: 'Setting image alias...',
|
||||||
|
done: 'Completed',
|
||||||
|
build_failed: 'Build failed',
|
||||||
|
import_failed: 'Import failed',
|
||||||
|
timeout: 'Build timed out',
|
||||||
|
error: 'Error',
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildStepLabel = computed(() => buildStepLabels[buildStep.value] || buildStep.value || 'Starting...')
|
||||||
|
|
||||||
|
const buildElapsedFormatted = computed(() => {
|
||||||
|
const s = buildElapsed.value
|
||||||
|
const min = Math.floor(s / 60)
|
||||||
|
const sec = s % 60
|
||||||
|
return `${min}:${sec.toString().padStart(2, '0')}`
|
||||||
|
})
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'overview', label: 'Overview' },
|
{ id: 'overview', label: 'Overview' },
|
||||||
{ id: 'versions', label: 'Versions' },
|
{ id: 'versions', label: 'Versions' },
|
||||||
|
|
@ -410,6 +477,15 @@ async function saveConfig() {
|
||||||
async function buildImage() {
|
async function buildImage() {
|
||||||
building.value = true
|
building.value = true
|
||||||
buildResult.value = null
|
buildResult.value = null
|
||||||
|
buildLogs.value = []
|
||||||
|
buildProgress.value = 0
|
||||||
|
buildStep.value = 'queued'
|
||||||
|
buildElapsed.value = 0
|
||||||
|
|
||||||
|
// Start elapsed timer
|
||||||
|
buildElapsedTimer = setInterval(() => {
|
||||||
|
buildElapsed.value++
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await call('admin_panel.api.apps.build_image', {
|
const result = await call('admin_panel.api.apps.build_image', {
|
||||||
|
|
@ -420,35 +496,88 @@ async function buildImage() {
|
||||||
doas_password: doasPassword.value,
|
doas_password: doasPassword.value,
|
||||||
})
|
})
|
||||||
|
|
||||||
buildResult.value = result
|
if (!result.success) {
|
||||||
|
// Validation error (returned before enqueue)
|
||||||
|
stopBuildTimers()
|
||||||
|
building.value = false
|
||||||
|
buildResult.value = { status: 'failed', error: result.error }
|
||||||
|
buildLogs.value = [result.error]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (result.success) {
|
// Start polling for build status
|
||||||
console.log(result.message)
|
const buildId = result.build_id
|
||||||
appDetail.reload()
|
buildPollTimer = setInterval(async () => {
|
||||||
} else {
|
try {
|
||||||
console.error(result.error || 'Build failed')
|
const status = await call('admin_panel.api.apps.get_build_status', {
|
||||||
}
|
build_id: buildId,
|
||||||
|
})
|
||||||
|
|
||||||
|
buildLogs.value = status.logs || []
|
||||||
|
buildProgress.value = status.progress || 0
|
||||||
|
buildStep.value = status.step || ''
|
||||||
|
|
||||||
|
// Auto-scroll logs to bottom
|
||||||
|
await nextTick()
|
||||||
|
if (buildLogsContainer.value) {
|
||||||
|
buildLogsContainer.value.scrollTop = buildLogsContainer.value.scrollHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.status === 'completed' || status.status === 'failed') {
|
||||||
|
stopBuildTimers()
|
||||||
|
building.value = false
|
||||||
|
buildResult.value = status
|
||||||
|
|
||||||
|
if (status.status === 'completed') {
|
||||||
|
appDetail.reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Polling error - don't stop, just log
|
||||||
|
console.warn('Build status poll error:', err)
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
buildResult.value = {
|
stopBuildTimers()
|
||||||
success: false,
|
|
||||||
error: error.message,
|
|
||||||
logs: [`Error: ${error.message}`],
|
|
||||||
}
|
|
||||||
console.error(error.message || 'Build failed')
|
|
||||||
} finally {
|
|
||||||
building.value = false
|
building.value = false
|
||||||
|
buildResult.value = {
|
||||||
|
status: 'failed',
|
||||||
|
error: error.message,
|
||||||
|
}
|
||||||
|
buildLogs.value = [`Error: ${error.message}`]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopBuildTimers() {
|
||||||
|
if (buildPollTimer) {
|
||||||
|
clearInterval(buildPollTimer)
|
||||||
|
buildPollTimer = null
|
||||||
|
}
|
||||||
|
if (buildElapsedTimer) {
|
||||||
|
clearInterval(buildElapsedTimer)
|
||||||
|
buildElapsedTimer = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeBuildModal() {
|
function closeBuildModal() {
|
||||||
showBuildModal.value = false
|
showBuildModal.value = false
|
||||||
buildResult.value = null
|
if (!building.value) {
|
||||||
buildVersion.value = ''
|
buildResult.value = null
|
||||||
buildRelease.value = 'edge'
|
buildLogs.value = []
|
||||||
doasPassword.value = ''
|
buildProgress.value = 0
|
||||||
targetServer.value = imageServersList.value.length ? imageServersList.value[0].name : ''
|
buildStep.value = ''
|
||||||
|
buildElapsed.value = 0
|
||||||
|
buildVersion.value = ''
|
||||||
|
buildRelease.value = 'edge'
|
||||||
|
doasPassword.value = ''
|
||||||
|
targetServer.value = imageServersList.value.length ? imageServersList.value[0].name : ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopBuildTimers()
|
||||||
|
})
|
||||||
|
|
||||||
async function saveDescription() {
|
async function saveDescription() {
|
||||||
savingDesc.value = true
|
savingDesc.value = true
|
||||||
try {
|
try {
|
||||||
|
|
@ -509,3 +638,29 @@ async function deleteVersion(version) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.build-progress-animated {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
-45deg,
|
||||||
|
rgba(255, 255, 255, 0.15) 25%,
|
||||||
|
transparent 25%,
|
||||||
|
transparent 50%,
|
||||||
|
rgba(255, 255, 255, 0.15) 50%,
|
||||||
|
rgba(255, 255, 255, 0.15) 75%,
|
||||||
|
transparent 75%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
background-size: 1rem 1rem;
|
||||||
|
animation: build-progress-stripes 0.75s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes build-progress-stripes {
|
||||||
|
from {
|
||||||
|
background-position: 1rem 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-position: 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue