fix: restore colored icons, move distrobuilder to Apps, fix doas pty.fork

- Restored colored icon circles (green shield, orange alert) for certificate
  section in Servers while keeping semantic tokens for backgrounds
- Moved distrobuilder setup section from Servers to Apps tab where it
  logically belongs
- Fixed doas TTY error by using pty.fork() instead of pty.openpty() —
  pty.fork() properly sets up a controlling terminal that doas requires

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
js 2026-02-10 15:07:36 +00:00
parent 4f6c07b14b
commit a86fecaf2a
46 changed files with 319 additions and 312 deletions

View File

@ -366,27 +366,23 @@ def get_setup_status():
def _run_doas_command(cmd, doas_password, timeout=120):
"""Run a command with doas using a pseudo-terminal for password input."""
"""Run a command with doas using pty.fork() for a real controlling terminal."""
import pty
import select
master_fd, slave_fd = pty.openpty()
pid, master_fd = pty.fork()
if pid == 0:
# Child process — exec the command
os.execvp(cmd[0], cmd)
os._exit(1)
# Parent process
output = b""
password_sent = False
deadline = time.time() + timeout
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:
@ -395,38 +391,46 @@ def _run_doas_command(cmd, doas_password, timeout=120):
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():
if not password_sent and b"password" in output.lower():
time.sleep(0.1)
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
password_sent = True
except OSError:
break
else:
# No more data
if proc.poll() is not None:
break
# Check if child exited
result = os.waitpid(pid, os.WNOHANG)
if result[0] != 0:
# Drain remaining output
while True:
r, _, _ = select.select([master_fd], [], [], 0.5)
if r:
try:
d = os.read(master_fd, 4096)
if d:
output += d
else:
break
except OSError:
break
else:
break
rc = os.WEXITSTATUS(result[1]) if os.WIFEXITED(result[1]) else 1
return rc, output.decode("utf-8", errors="replace")
proc.wait(timeout=max(1, int(deadline - time.time())))
return proc.returncode, output.decode("utf-8", errors="replace")
# Final wait
_, status = os.waitpid(pid, 0)
rc = os.WEXITSTATUS(status) if os.WIFEXITED(status) else 1
return rc, output.decode("utf-8", errors="replace")
except subprocess.TimeoutExpired:
proc.kill()
except Exception:
try:
os.kill(pid, 9)
os.waitpid(pid, 0)
except Exception:
pass
raise
finally:
if slave_fd >= 0:
os.close(slave_fd)
os.close(master_fd)

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{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
import{o as a,c as r,b as e,t as i,M as s}from"./index-1d2d8107.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-809e459c.js.map

View File

@ -1 +1 @@
{"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"}
{"version":3,"file":"ChartCard-809e459c.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-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
import{_ as g}from"./LayoutHeader-da9a4100.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-1d2d8107.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-57fa7b9e.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,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
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-1d2d8107.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-1aed7a26.js.map

View File

@ -1 +1 @@
{"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"}
{"version":3,"file":"EmptyState-1aed7a26.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,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
import{o as a,c as r,b as e,M as s,t as i}from"./index-1d2d8107.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-da9a4100.js.map

View File

@ -1 +1 @@
{"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"}
{"version":3,"file":"LayoutHeader-da9a4100.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-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
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-1d2d8107.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-70c4bc3a.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-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
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-1d2d8107.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-84c84485.js.map

View File

@ -1 +1 @@
{"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"}
{"version":3,"file":"NotFound-84c84485.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

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-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
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-1d2d8107.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-561c461f.js.map

View File

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

View File

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

View File

@ -17,7 +17,51 @@
</template>
</LayoutHeader>
<div class="flex-1 overflow-auto p-5">
<div class="flex-1 overflow-auto p-5 space-y-6">
<!-- Distrobuilder Setup -->
<div class="rounded-lg border p-5 shadow-sm">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-ink-gray-9">
{{ $t('servers.setup.title') }}
</h3>
<Button
variant="solid"
@click="showInstallModal = true"
:disabled="setupStatus?.distrobuilder_installed"
>
<template #prefix>
<FeatherIcon name="download" class="h-4 w-4" />
</template>
{{ $t('servers.setup.install') }}
</Button>
</div>
<div v-if="setupStatusRes.loading" class="flex justify-center py-4">
<Spinner class="h-6 w-6" />
</div>
<div v-else class="flex items-center gap-3 py-3">
<div
class="w-10 h-10 rounded-full flex items-center justify-center"
:class="setupStatus?.distrobuilder_installed ? 'bg-green-100 text-green-600' : 'bg-red-100 text-red-600'"
>
<FeatherIcon
:name="setupStatus?.distrobuilder_installed ? 'check-circle' : 'alert-circle'"
class="h-5 w-5"
/>
</div>
<div class="flex-1">
<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 text-ink-gray-5">
{{ setupStatus?.distrobuilder_version || $t('servers.setup.distrobuilderDesc') }}
</p>
</div>
</div>
</div>
<!-- App Templates -->
<div v-if="apps.loading" class="flex items-center justify-center h-64">
<Spinner class="h-8 w-8" />
</div>
@ -152,6 +196,67 @@
</Button>
</template>
</Dialog>
<!-- Install Distrobuilder Modal -->
<Dialog v-model="showInstallModal" :options="{ title: $t('servers.setup.title') }">
<template #body-content>
<div class="space-y-4">
<p class="text-sm text-ink-gray-7">
{{ $t('servers.setup.installDescription') }}
</p>
<FormControl
v-if="!installResult"
v-model="doasPassword"
label="Password (doas)"
type="password"
placeholder="Enter your doas password"
/>
<div class="bg-surface-gray-2 rounded-lg p-3">
<p class="text-xs text-ink-gray-5">
For manual install, run in terminal: <code class="bg-surface-gray-3 px-1 rounded text-ink-gray-7">doas apk add distrobuilder</code>
</p>
</div>
<div v-if="installResult" class="mt-4">
<label class="block text-sm font-medium text-ink-gray-7 mb-2">{{ $t('common.output') }}</label>
<div class="bg-gray-900 text-gray-100 p-4 rounded-lg text-xs font-mono max-h-64 overflow-y-auto">
<div
v-for="(log, index) in installResult.logs"
:key="index"
:class="{
'text-green-400': log.includes('Done'),
'text-red-400': log.includes('Failed') || log.includes('Error'),
'text-yellow-400': log.includes('==='),
}"
>
{{ log }}
</div>
</div>
</div>
</div>
</template>
<template #actions>
<div class="flex w-full items-center justify-end gap-2">
<Button variant="subtle" @click="closeInstallModal">
{{ installResult ? $t('common.close') : $t('common.cancel') }}
</Button>
<Button
v-if="!installResult"
variant="solid"
@click="runInstall"
:loading="installing"
:disabled="!doasPassword"
>
<template #prefix>
<FeatherIcon name="download" class="h-4 w-4" />
</template>
{{ $t('servers.setup.install') }}
</Button>
</div>
</template>
</Dialog>
</div>
</template>
@ -166,9 +271,13 @@ const router = useRouter()
const showCreateModal = ref(false)
const showDeleteModal = ref(false)
const showInstallModal = ref(false)
const creating = ref(false)
const deleting = ref(false)
const installing = ref(false)
const appToDelete = ref(null)
const installResult = ref(null)
const doasPassword = ref('')
const defaultYamlTemplate = `image:
distribution: "alpinelinux"
@ -224,7 +333,13 @@ const apps = createResource({
auto: true,
})
const setupStatusRes = createResource({
url: 'admin_panel.api.servers.get_setup_status',
auto: true,
})
const appsList = computed(() => apps.data || [])
const setupStatus = computed(() => setupStatusRes.data)
function refreshData() {
apps.reload()
@ -308,4 +423,33 @@ async function confirmDelete() {
deleting.value = false
}
}
async function runInstall() {
installing.value = true
installResult.value = null
try {
const result = await call('admin_panel.api.servers.run_setup', {
doas_password: doasPassword.value,
})
installResult.value = result
if (result.success) {
setupStatusRes.reload()
}
} catch (error) {
installResult.value = {
success: false,
logs: [`Error: ${error.message}`],
}
} finally {
installing.value = false
}
}
function closeInstallModal() {
showInstallModal.value = false
installResult.value = null
doasPassword.value = ''
}
</script>

View File

@ -37,89 +37,50 @@
</div>
<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>
<!-- Certificate exists -->
<div v-if="certStatus?.has_certificate">
<div class="flex items-center gap-3 py-3 border-b">
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-green-100 text-green-600">
<FeatherIcon name="shield" class="h-5 w-5" />
</div>
<div class="flex-1">
<p class="text-sm font-medium text-ink-gray-9">Certificate configured</p>
<p class="text-xs text-ink-gray-5 font-mono">{{ certStatus.cert_path }}</p>
</div>
</div>
<Button
v-if="!certStatus?.has_certificate"
variant="solid"
@click="generateCertificate"
:loading="generating"
>
<div class="rounded-lg bg-surface-gray-1 p-4 mt-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>
<!-- No certificate -->
<div v-else class="flex items-center justify-between py-3 border-b">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full flex items-center justify-center bg-orange-100 text-orange-600">
<FeatherIcon name="alert-circle" class="h-5 w-5" />
</div>
<div>
<p class="text-sm font-medium text-ink-gray-9">No certificate</p>
<p class="text-xs text-ink-gray-5">Generate a client certificate to connect to Incus servers</p>
</div>
</div>
<Button variant="solid" @click="generateCertificate" :loading="generating">
<template #prefix>
<FeatherIcon name="key" class="h-4 w-4" />
</template>
Generate
</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 shadow-sm">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-ink-gray-9">
{{ $t('servers.setup.title') }}
</h3>
<Button
variant="solid"
@click="showInstallModal = true"
:disabled="setupStatus?.distrobuilder_installed"
>
<template #prefix>
<FeatherIcon name="download" class="h-4 w-4" />
</template>
{{ $t('servers.setup.install') }}
</Button>
</div>
<div v-if="setupStatusRes.loading" class="flex justify-center py-4">
<Spinner class="h-6 w-6" />
</div>
<div v-else class="flex items-center gap-3 py-3 border-b">
<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 text-ink-gray-9">
{{ setupStatus?.distrobuilder_installed ? $t('servers.setup.distrobuilderInstalled') : $t('servers.setup.distrobuilderNotInstalled') }}
</p>
<p class="text-xs text-ink-gray-5">
{{ setupStatus?.distrobuilder_version || $t('servers.setup.distrobuilderDesc') }}
</p>
</div>
</div>
</div>
<!-- Section 3: Remote Servers -->
<!-- Section 2: Remote Servers -->
<div class="rounded-lg border p-5 shadow-sm">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-ink-gray-9">
@ -213,67 +174,6 @@
</div>
</div>
<!-- Install Modal -->
<Dialog v-model="showInstallModal" :options="{ title: $t('servers.setup.title') }">
<template #body-content>
<div class="space-y-4">
<p class="text-sm text-ink-gray-7">
{{ $t('servers.setup.installDescription') }}
</p>
<FormControl
v-if="!installResult"
v-model="doasPassword"
label="Password (doas)"
type="password"
placeholder="Enter your doas password"
/>
<div class="bg-surface-gray-2 rounded-lg p-3">
<p class="text-xs text-ink-gray-5">
For manual install, run in terminal: <code class="bg-surface-gray-3 px-1 rounded text-ink-gray-7">doas apk add distrobuilder</code>
</p>
</div>
<div v-if="installResult" class="mt-4">
<label class="block text-sm font-medium text-ink-gray-7 mb-2">{{ $t('common.output') }}</label>
<div class="bg-gray-900 text-gray-100 p-4 rounded-lg text-xs font-mono max-h-64 overflow-y-auto">
<div
v-for="(log, index) in installResult.logs"
:key="index"
:class="{
'text-green-400': log.includes('Done'),
'text-red-400': log.includes('Failed') || log.includes('Error'),
'text-yellow-400': log.includes('==='),
}"
>
{{ log }}
</div>
</div>
</div>
</div>
</template>
<template #actions>
<div class="flex w-full items-center justify-end gap-2">
<Button variant="subtle" @click="closeInstallModal">
{{ installResult ? $t('common.close') : $t('common.cancel') }}
</Button>
<Button
v-if="!installResult"
variant="solid"
@click="runInstall"
:loading="installing"
:disabled="!doasPassword"
>
<template #prefix>
<FeatherIcon name="download" class="h-4 w-4" />
</template>
{{ $t('servers.setup.install') }}
</Button>
</div>
</template>
</Dialog>
<!-- Add Server Modal -->
<Dialog v-model="showAddServerModal" :options="{ title: $t('servers.addServer') }">
<template #body-content>
@ -408,16 +308,11 @@ import LayoutHeader from '@/components/Common/LayoutHeader.vue'
import EmptyState from '@/components/Common/EmptyState.vue'
const refreshing = ref(false)
const showInstallModal = ref(false)
const showAddServerModal = ref(false)
const showRemoveModal = ref(false)
const showCertModal = ref(false)
const serverToRemove = ref(null)
const installing = ref(false)
const installResult = ref(null)
const doasPassword = ref('')
const addingServer = ref(false)
const removingServer = ref(false)
const generating = ref(false)
@ -438,11 +333,6 @@ const certStatusRes = createResource({
auto: true,
})
const setupStatusRes = createResource({
url: 'admin_panel.api.servers.get_setup_status',
auto: true,
})
const serversRes = createResource({
url: 'admin_panel.api.servers.get_servers',
auto: true,
@ -453,7 +343,6 @@ const certContentRes = createResource({
})
const certStatus = computed(() => certStatusRes.data)
const setupStatus = computed(() => setupStatusRes.data)
const serversList = computed(() => serversRes.data || [])
const certContent = computed(() => certContentRes.data)
@ -468,7 +357,6 @@ function refreshData() {
refreshing.value = true
Promise.all([
certStatusRes.reload(),
setupStatusRes.reload(),
serversRes.reload(),
]).finally(() => {
refreshing.value = false
@ -553,35 +441,6 @@ function copyCertificate() {
}
}
async function runInstall() {
installing.value = true
installResult.value = null
try {
const result = await call('admin_panel.api.servers.run_setup', {
doas_password: doasPassword.value,
})
installResult.value = result
if (result.success) {
setupStatusRes.reload()
}
} catch (error) {
installResult.value = {
success: false,
logs: [`Error: ${error.message}`],
}
} finally {
installing.value = false
}
}
function closeInstallModal() {
showInstallModal.value = false
installResult.value = null
doasPassword.value = ''
}
async function testServerConnection(serverName) {
try {
const result = await call('admin_panel.api.servers.test_connection', {