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:
js 2026-02-16 13:08:28 +00:00
parent da71d91294
commit 9a0a6b501a
45 changed files with 424 additions and 195 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"}

View File

@ -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

View File

@ -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

View File

@ -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"}

View File

@ -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

View File

@ -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"}

View File

@ -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

View File

@ -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

View File

@ -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"}

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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>