fix: semantic dark mode for Servers, doas PTY for install, container-not-found detection

- Servers page now uses frappe-ui semantic tokens (bg-surface-gray-*, text-ink-gray-*)
  matching Dashboard style instead of explicit bg-green-50/dark:bg-green-950 etc.
- doas install uses Python pty module to create a real pseudo-terminal,
  fixing "doas: a tty is required" error
- SiteDetail detects externally deleted containers and shows "Container Not Found"
  screen with Retry/Back buttons instead of silently failing
- Action errors (start/stop/restart/delete) now show dismissible error banner

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
js 2026-02-09 15:22:44 +00:00
parent f324f04ee9
commit 4f6c07b14b
47 changed files with 313 additions and 222 deletions

View File

@ -1,6 +1,7 @@
import json
import os
import subprocess
import time
import frappe
import requests
@ -364,6 +365,71 @@ def get_setup_status():
return status
def _run_doas_command(cmd, doas_password, timeout=120):
"""Run a command with doas using a pseudo-terminal for password input."""
import pty
import select
master_fd, slave_fd = pty.openpty()
try:
proc = subprocess.Popen(
cmd,
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
close_fds=True,
)
os.close(slave_fd)
slave_fd = -1
# Wait for doas password prompt
output = b""
deadline = time.time() + timeout
while time.time() < deadline:
ready, _, _ = select.select([master_fd], [], [], 1)
if ready:
try:
chunk = os.read(master_fd, 4096)
if not chunk:
break
output += chunk
# doas prompts with "doas (user@host) password:" or similar
if b"password:" in output.lower() or b"password" in output.lower():
os.write(master_fd, (doas_password + "\n").encode())
break
except OSError:
break
# Read remaining output
while time.time() < deadline:
ready, _, _ = select.select([master_fd], [], [], 2)
if ready:
try:
chunk = os.read(master_fd, 4096)
if not chunk:
break
output += chunk
except OSError:
break
else:
# No more data
if proc.poll() is not None:
break
proc.wait(timeout=max(1, int(deadline - time.time())))
return proc.returncode, output.decode("utf-8", errors="replace")
except subprocess.TimeoutExpired:
proc.kill()
raise
finally:
if slave_fd >= 0:
os.close(slave_fd)
os.close(master_fd)
@frappe.whitelist()
def run_setup(doas_password=None):
"""Run setup commands to install distrobuilder using doas with password."""
@ -382,33 +448,31 @@ def run_setup(doas_password=None):
for cmd, description in commands:
logs.append(f"\n{description}...")
try:
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
stdout, stderr = proc.communicate(
input=doas_password + "\n",
timeout=120,
)
returncode, output = _run_doas_command(cmd, doas_password)
if stdout:
logs.append(stdout.strip())
# Filter out the password prompt from output
clean_lines = []
for line in output.split("\n"):
stripped = line.strip()
if not stripped:
continue
if "password:" in stripped.lower():
continue
clean_lines.append(stripped)
if clean_lines:
logs.append("\n".join(clean_lines))
if proc.returncode != 0:
error = stderr or "Unknown error"
if returncode != 0:
error = "\n".join(clean_lines) or "Unknown error"
if "already" in error.lower() or "exists" in error.lower():
logs.append(" (already done)")
else:
logs.append(f"Failed: {error}")
logs.append(f"Failed (exit code {returncode})")
return {"success": False, "error": error, "logs": logs}
else:
logs.append(f"{description} - Done")
except subprocess.TimeoutExpired:
proc.kill()
logs.append(f"Timeout: {description}")
return {"success": False, "error": f"Timeout during: {description}", "logs": logs}
except Exception as e:

View File

@ -1,2 +1,2 @@
import{o as a,c as r,b as e,t as i,M as s}from"./index-3d9a708e.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-55f3f2ee.js.map
import{o as a,c as r,b as e,t as i,L as s}from"./index-e9fa3fd2.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-4c1e5cae.js.map

View File

@ -1 +1 @@
{"version":3,"file":"ChartCard-55f3f2ee.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-4c1e5cae.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-cd508225.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-3d9a708e.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-4d8c7660.js.map
import{_ as g}from"./LayoutHeader-f55d9654.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-e9fa3fd2.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-9285933a.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,M as d}from"./index-3d9a708e.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-54c60605.js.map
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,L as d}from"./index-e9fa3fd2.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-ff6fbcb9.js.map

View File

@ -1 +1 @@
{"version":3,"file":"EmptyState-54c60605.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-ff6fbcb9.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,M as s,t as i}from"./index-3d9a708e.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-cd508225.js.map
import{o as a,c as r,b as e,L as s,t as i}from"./index-e9fa3fd2.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-f55d9654.js.map

View File

@ -1 +1 @@
{"version":3,"file":"LayoutHeader-cd508225.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-f55d9654.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-3d9a708e.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-9697bc9f.js.map
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-e9fa3fd2.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-8e452bf9.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-3d9a708e.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-eaa6081d.js.map
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-e9fa3fd2.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-22257ff1.js.map

View File

@ -1 +1 @@
{"version":3,"file":"NotFound-eaa6081d.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-22257ff1.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

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-3d9a708e.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-edb6f5e9.js.map
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-e9fa3fd2.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-6f3d08a8.js.map

View File

@ -15,8 +15,8 @@
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
})();
</script>
<script type="module" crossorigin src="/assets/admin_panel/frontend/assets/index-3d9a708e.js"></script>
<link rel="stylesheet" href="/assets/admin_panel/frontend/assets/index-984848c2.css">
<script type="module" crossorigin src="/assets/admin_panel/frontend/assets/index-e9fa3fd2.js"></script>
<link rel="stylesheet" href="/assets/admin_panel/frontend/assets/index-43b47371.css">
</head>
<body class="h-full">
<div id="app" class="h-full"></div>

View File

@ -15,8 +15,8 @@
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
})();
</script>
<script type="module" crossorigin src="/assets/admin_panel/frontend/assets/index-3d9a708e.js"></script>
<link rel="stylesheet" href="/assets/admin_panel/frontend/assets/index-984848c2.css">
<script type="module" crossorigin src="/assets/admin_panel/frontend/assets/index-e9fa3fd2.js"></script>
<link rel="stylesheet" href="/assets/admin_panel/frontend/assets/index-43b47371.css">
</head>
<body class="h-full">
<div id="app" class="h-full"></div>

View File

@ -19,83 +19,74 @@
<div class="flex-1 overflow-auto p-5 space-y-6">
<!-- Section 1: Certificate Setup -->
<div class="rounded-lg border p-5">
<div class="rounded-lg border p-5 shadow-sm">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-base font-medium text-ink-gray-9">
Panel Certificate
</h3>
<p class="text-sm text-ink-gray-5 mt-1">
Client certificate used to authenticate with Incus servers
</p>
</div>
<h3 class="text-lg font-semibold text-ink-gray-9">
Panel Certificate
</h3>
<Button v-if="certStatus?.has_certificate" variant="subtle" size="sm" @click="showCertModal = true">
<template #prefix>
<FeatherIcon name="eye" class="h-4 w-4" />
</template>
View Certificate
</Button>
</div>
<div v-if="certStatusRes.loading" class="flex justify-center py-4">
<Spinner class="h-6 w-6" />
</div>
<div v-else>
<!-- Certificate exists -->
<div v-if="certStatus?.has_certificate" class="space-y-4">
<div class="flex items-center gap-3 p-3 rounded-lg bg-green-50 dark:bg-green-950/30">
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-green-100 dark:bg-green-900/50">
<FeatherIcon name="shield" class="h-5 w-5 text-green-600 dark:text-green-400" />
<div v-else class="space-y-4">
<!-- Certificate status row -->
<div class="flex items-center justify-between py-3 border-b">
<div class="flex items-center gap-3">
<div
class="w-2.5 h-2.5 rounded-full"
:class="certStatus?.has_certificate ? 'bg-green-500' : 'bg-orange-400'"
></div>
<div>
<p class="text-sm font-medium text-ink-gray-9">
{{ certStatus?.has_certificate ? 'Certificate configured' : 'No certificate' }}
</p>
<p v-if="certStatus?.has_certificate" class="text-xs text-ink-gray-5 font-mono">
{{ certStatus.cert_path }}
</p>
<p v-else class="text-xs text-ink-gray-5">
Generate a client certificate to connect to Incus servers
</p>
</div>
<div class="flex-1">
<p class="text-sm font-medium text-green-800 dark:text-green-300">Certificate configured</p>
<p class="text-xs text-green-600 dark:text-green-500 font-mono">{{ certStatus.cert_path }}</p>
</div>
<Button variant="subtle" size="sm" @click="showCertModal = true">
<template #prefix>
<FeatherIcon name="eye" class="h-4 w-4" />
</template>
View
</Button>
</div>
<div class="bg-blue-50 dark:bg-blue-950/30 rounded-lg p-4">
<p class="text-sm text-blue-800 dark:text-blue-300 font-medium mb-2">How to trust this certificate on Incus servers:</p>
<ol class="text-sm text-blue-700 dark:text-blue-400 space-y-1 list-decimal list-inside">
<li>Click "View" to copy the certificate content</li>
<li>Save it as <code class="bg-blue-100 dark:bg-blue-900/50 px-1 rounded">admin-panel.crt</code> on your Incus server</li>
<li>Run: <code class="bg-blue-100 dark:bg-blue-900/50 px-1 rounded">incus config trust add-certificate admin-panel.crt</code></li>
</ol>
</div>
<Button
v-if="!certStatus?.has_certificate"
variant="solid"
@click="generateCertificate"
:loading="generating"
>
<template #prefix>
<FeatherIcon name="key" class="h-4 w-4" />
</template>
Generate
</Button>
</div>
<!-- No certificate -->
<div v-else class="space-y-4">
<div class="flex items-center gap-3 p-3 rounded-lg bg-yellow-50 dark:bg-yellow-950/30">
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-yellow-100 dark:bg-yellow-900/50">
<FeatherIcon name="alert-circle" class="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
</div>
<div class="flex-1">
<p class="text-sm font-medium text-yellow-800 dark:text-yellow-300">No certificate</p>
<p class="text-xs text-yellow-600 dark:text-yellow-500">Generate a client certificate to connect to Incus servers</p>
</div>
<Button variant="solid" @click="generateCertificate" :loading="generating">
<template #prefix>
<FeatherIcon name="key" class="h-4 w-4" />
</template>
Generate Certificate
</Button>
</div>
<!-- Instructions (only if cert exists) -->
<div v-if="certStatus?.has_certificate" class="rounded-lg bg-surface-gray-1 p-4">
<p class="text-sm font-medium text-ink-gray-7 mb-2">How to trust this certificate on Incus servers:</p>
<ol class="text-sm text-ink-gray-5 space-y-1 list-decimal list-inside">
<li>Click "View Certificate" to copy the certificate content</li>
<li>Save it as <code class="bg-surface-gray-3 px-1 rounded text-ink-gray-7">admin-panel.crt</code> on your Incus server</li>
<li>Run: <code class="bg-surface-gray-3 px-1 rounded text-ink-gray-7">incus config trust add-certificate admin-panel.crt</code></li>
</ol>
</div>
</div>
</div>
<!-- Section 2: Distrobuilder Setup -->
<div class="rounded-lg border p-5">
<div class="rounded-lg border p-5 shadow-sm">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-base font-medium text-ink-gray-9">
{{ $t('servers.setup.title') }}
</h3>
<p class="text-sm text-ink-gray-5 mt-1">
{{ $t('servers.setup.description') }}
</p>
</div>
<h3 class="text-lg font-semibold text-ink-gray-9">
{{ $t('servers.setup.title') }}
</h3>
<Button
variant="solid"
@click="showInstallModal = true"
@ -112,28 +103,16 @@
<Spinner class="h-6 w-6" />
</div>
<div v-else class="flex items-center gap-3 p-3 rounded-lg"
:class="setupStatus?.distrobuilder_installed ? 'bg-green-50 dark:bg-green-950/30' : 'bg-red-50 dark:bg-red-950/30'"
>
<div v-else class="flex items-center gap-3 py-3 border-b">
<div
class="w-10 h-10 rounded-full flex items-center justify-center"
:class="setupStatus?.distrobuilder_installed ? 'bg-green-100 dark:bg-green-900/50' : 'bg-red-100 dark:bg-red-900/50'"
>
<FeatherIcon
:name="setupStatus?.distrobuilder_installed ? 'check-circle' : 'alert-circle'"
class="h-5 w-5"
:class="setupStatus?.distrobuilder_installed ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'"
/>
</div>
class="w-2.5 h-2.5 rounded-full"
:class="setupStatus?.distrobuilder_installed ? 'bg-green-500' : 'bg-red-500'"
></div>
<div class="flex-1">
<p class="text-sm font-medium"
:class="setupStatus?.distrobuilder_installed ? 'text-green-800 dark:text-green-300' : 'text-red-800 dark:text-red-300'"
>
<p class="text-sm font-medium text-ink-gray-9">
{{ setupStatus?.distrobuilder_installed ? $t('servers.setup.distrobuilderInstalled') : $t('servers.setup.distrobuilderNotInstalled') }}
</p>
<p class="text-xs"
:class="setupStatus?.distrobuilder_installed ? 'text-green-600 dark:text-green-500' : 'text-red-600 dark:text-red-500'"
>
<p class="text-xs text-ink-gray-5">
{{ setupStatus?.distrobuilder_version || $t('servers.setup.distrobuilderDesc') }}
</p>
</div>
@ -141,16 +120,11 @@
</div>
<!-- Section 3: Remote Servers -->
<div class="rounded-lg border p-5">
<div class="rounded-lg border p-5 shadow-sm">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-base font-medium text-ink-gray-9">
{{ $t('servers.remoteServers') }}
</h3>
<p class="text-sm text-ink-gray-5 mt-1">
{{ $t('servers.roles.title') }}
</p>
</div>
<h3 class="text-lg font-semibold text-ink-gray-9">
{{ $t('servers.remoteServers') }}
</h3>
</div>
<div v-if="serversRes.loading" class="flex justify-center py-8">
@ -165,8 +139,8 @@
>
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/50 flex items-center justify-center">
<FeatherIcon name="cloud" class="h-5 w-5 text-blue-600 dark:text-blue-400" />
<div class="w-10 h-10 rounded-lg bg-surface-gray-2 flex items-center justify-center">
<FeatherIcon name="cloud" class="h-5 w-5 text-ink-gray-5" />
</div>
<div>
<h4 class="text-sm font-medium text-ink-gray-9">{{ server.name }}</h4>
@ -409,9 +383,9 @@
</Button>
</div>
<div class="bg-blue-50 dark:bg-blue-950/30 rounded-lg p-4">
<p class="text-sm text-blue-800 dark:text-blue-300 font-medium mb-2">On your Incus server:</p>
<code class="text-sm text-blue-700 dark:text-blue-400 block bg-blue-100 dark:bg-blue-900/50 p-2 rounded">
<div class="rounded-lg bg-surface-gray-1 p-4">
<p class="text-sm text-ink-gray-7 font-medium mb-2">On your Incus server:</p>
<code class="text-sm text-ink-gray-7 block bg-surface-gray-3 p-2 rounded font-mono">
incus config trust add-certificate admin-panel.crt
</code>
</div>

View File

@ -19,66 +19,68 @@
</p>
</div>
<Badge
v-if="site"
v-if="site && !containerError"
:variant="getStatusBadge(site.status)"
:label="site.status"
/>
</div>
</template>
<template #right>
<Button
v-if="site?.status !== 'Running'"
variant="subtle"
@click="startSite"
:loading="actionLoading === 'start'"
>
<template #prefix>
<FeatherIcon name="play" class="h-4 w-4" />
</template>
{{ $t('sites.actions.start') }}
</Button>
<Button
v-if="site?.status === 'Running'"
variant="subtle"
@click="stopSite"
:loading="actionLoading === 'stop'"
>
<template #prefix>
<FeatherIcon name="square" class="h-4 w-4" />
</template>
{{ $t('sites.actions.stop') }}
</Button>
<Button
variant="subtle"
@click="restartSite"
:loading="actionLoading === 'restart'"
>
<template #prefix>
<FeatherIcon name="rotate-cw" class="h-4 w-4" />
</template>
{{ $t('sites.actions.restart') }}
</Button>
<Button
variant="subtle"
@click="openUpdateModal"
>
<template #prefix>
<FeatherIcon name="download" class="h-4 w-4" />
</template>
Update
</Button>
<Button variant="subtle" theme="red" @click="showDeleteContainerModal = true">
<template #prefix>
<FeatherIcon name="trash-2" class="h-4 w-4" />
</template>
{{ $t('common.delete') }}
</Button>
<Button variant="solid" @click="openSite">
<template #prefix>
<FeatherIcon name="external-link" class="h-4 w-4" />
</template>
{{ $t('sites.actions.openSite') }}
</Button>
<template v-if="!containerError">
<Button
v-if="site?.status !== 'Running'"
variant="subtle"
@click="startSite"
:loading="actionLoading === 'start'"
>
<template #prefix>
<FeatherIcon name="play" class="h-4 w-4" />
</template>
{{ $t('sites.actions.start') }}
</Button>
<Button
v-if="site?.status === 'Running'"
variant="subtle"
@click="stopSite"
:loading="actionLoading === 'stop'"
>
<template #prefix>
<FeatherIcon name="square" class="h-4 w-4" />
</template>
{{ $t('sites.actions.stop') }}
</Button>
<Button
variant="subtle"
@click="restartSite"
:loading="actionLoading === 'restart'"
>
<template #prefix>
<FeatherIcon name="rotate-cw" class="h-4 w-4" />
</template>
{{ $t('sites.actions.restart') }}
</Button>
<Button
variant="subtle"
@click="openUpdateModal"
>
<template #prefix>
<FeatherIcon name="download" class="h-4 w-4" />
</template>
Update
</Button>
<Button variant="subtle" theme="red" @click="showDeleteContainerModal = true">
<template #prefix>
<FeatherIcon name="trash-2" class="h-4 w-4" />
</template>
{{ $t('common.delete') }}
</Button>
<Button variant="solid" @click="openSite">
<template #prefix>
<FeatherIcon name="external-link" class="h-4 w-4" />
</template>
{{ $t('sites.actions.openSite') }}
</Button>
</template>
</template>
</LayoutHeader>
@ -115,8 +117,44 @@
<Button variant="subtle" @click="detail.reload()">Retry</Button>
</div>
<!-- Container not found / API error in response -->
<div v-else-if="containerError" class="flex flex-col items-center justify-center h-64 gap-4">
<div class="flex items-center justify-center w-16 h-16 rounded-full bg-red-50 dark:bg-red-950/30">
<FeatherIcon name="alert-triangle" class="h-8 w-8 text-red-500 dark:text-red-400" />
</div>
<div class="text-center">
<h3 class="text-lg font-semibold text-ink-gray-9 mb-1">Container Not Found</h3>
<p class="text-sm text-ink-gray-5 max-w-md">
The container <strong>{{ route.params.siteId }}</strong> does not exist on the server.
It may have been deleted externally.
</p>
</div>
<div class="flex gap-3">
<Button variant="subtle" @click="detail.reload()">
<template #prefix>
<FeatherIcon name="refresh-cw" class="h-4 w-4" />
</template>
Retry
</Button>
<Button variant="solid" @click="$router.push({ name: 'Sites' })">
<template #prefix>
<FeatherIcon name="arrow-left" class="h-4 w-4" />
</template>
Back to Sites
</Button>
</div>
</div>
<!-- Tab content -->
<template v-else>
<!-- Action error banner -->
<div v-if="actionError" class="mb-4 flex items-center gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800">
<FeatherIcon name="x-circle" class="h-5 w-5 text-red-500 flex-shrink-0" />
<p class="text-sm text-red-700 dark:text-red-300 flex-1">{{ actionError }}</p>
<Button variant="ghost" size="sm" @click="actionError = ''">
<FeatherIcon name="x" class="h-4 w-4" />
</Button>
</div>
<!-- Overview Tab -->
<div v-show="activeTab === 'overview'">
<div class="grid gap-6 lg:grid-cols-2">
@ -583,6 +621,7 @@ const snapshotsLoaded = ref(false)
// Delete container state
const showDeleteContainerModal = ref(false)
const deleteConfirmName = ref('')
const actionError = ref('')
// Update/Rebuild state
const showUpdateModal = ref(false)
@ -635,6 +674,7 @@ const images = createResource({
})
const site = computed(() => detail.data)
const containerError = computed(() => site.value?.error || null)
const imageOptions = computed(() => {
const list = images.data || []
@ -719,6 +759,7 @@ function generateDefaultSnapshotName() {
async function deleteContainer() {
actionLoading.value = 'delete-container'
actionError.value = ''
try {
await call('admin_panel.api.sites.delete_container', {
container_name: route.params.siteId,
@ -729,6 +770,9 @@ async function deleteContainer() {
router.push({ name: 'Sites' })
} catch (e) {
console.error('Failed to delete container:', e)
showDeleteContainerModal.value = false
actionError.value = e.messages?.[0] || e.message || 'Failed to delete container'
detail.reload()
} finally {
actionLoading.value = ''
}
@ -736,6 +780,7 @@ async function deleteContainer() {
async function startSite() {
actionLoading.value = 'start'
actionError.value = ''
try {
await call('admin_panel.api.sites.start_container', {
container_name: route.params.siteId,
@ -744,6 +789,8 @@ async function startSite() {
detail.reload()
} catch (e) {
console.error('Failed to start container:', e)
actionError.value = e.messages?.[0] || e.message || 'Failed to start container'
detail.reload()
} finally {
actionLoading.value = ''
}
@ -751,6 +798,7 @@ async function startSite() {
async function stopSite() {
actionLoading.value = 'stop'
actionError.value = ''
try {
await call('admin_panel.api.sites.stop_container', {
container_name: route.params.siteId,
@ -759,6 +807,8 @@ async function stopSite() {
detail.reload()
} catch (e) {
console.error('Failed to stop container:', e)
actionError.value = e.messages?.[0] || e.message || 'Failed to stop container'
detail.reload()
} finally {
actionLoading.value = ''
}
@ -766,6 +816,7 @@ async function stopSite() {
async function restartSite() {
actionLoading.value = 'restart'
actionError.value = ''
try {
await call('admin_panel.api.sites.restart_container', {
container_name: route.params.siteId,
@ -774,6 +825,8 @@ async function restartSite() {
detail.reload()
} catch (e) {
console.error('Failed to restart container:', e)
actionError.value = e.messages?.[0] || e.message || 'Failed to restart container'
detail.reload()
} finally {
actionLoading.value = ''
}