feat: password-based doas install, proper dark colors, delete container

- Distrobuilder install now prompts for doas password instead of
  requiring nopass config. Password is piped to doas via stdin.
  Manual install hint shown instead of doas.conf rules.
- Restore original colored backgrounds (green/yellow/blue/red) for
  light mode and add proper dark: variants (dark:bg-*-950/30) so
  both themes look good
- Add delete container button with confirmation dialog that requires
  typing the container name to confirm
- Fix snapshot default name to include seconds to avoid duplicates
- Fix certificate modal textarea to use theme-aware colors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
js 2026-02-09 15:04:44 +00:00
parent c42b978df9
commit f324f04ee9
46 changed files with 190 additions and 105 deletions

View File

@ -366,33 +366,41 @@ def get_setup_status():
@frappe.whitelist() @frappe.whitelist()
def run_setup(doas_password=None): def run_setup(doas_password=None):
"""Run setup commands to install distrobuilder.""" """Run setup commands to install distrobuilder using doas with password."""
_check_admin_permission() _check_admin_permission()
logs = [] logs = []
logs.append("=== Starting Distrobuilder Setup ===") logs.append("=== Starting Distrobuilder Setup ===")
if not doas_password:
return {"success": False, "error": "Password is required", "logs": ["Error: Password is required"]}
commands = [ commands = [
(["doas", "-n", "apk", "add", "distrobuilder"], "Installing distrobuilder"), (["doas", "apk", "add", "distrobuilder"], "Installing distrobuilder"),
] ]
for cmd, description in commands: for cmd, description in commands:
logs.append(f"\n{description}...") logs.append(f"\n{description}...")
try: try:
result = subprocess.run( proc = subprocess.Popen(
cmd, cmd,
capture_output=True, stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True, text=True,
)
stdout, stderr = proc.communicate(
input=doas_password + "\n",
timeout=120, timeout=120,
) )
if result.stdout: if stdout:
logs.append(result.stdout.strip()) logs.append(stdout.strip())
if result.returncode != 0: if proc.returncode != 0:
error = result.stderr or "Unknown error" error = stderr or "Unknown error"
if "already" in error.lower() or "exists" in error.lower(): if "already" in error.lower() or "exists" in error.lower():
logs.append(f" (already done)") logs.append(" (already done)")
else: else:
logs.append(f"Failed: {error}") logs.append(f"Failed: {error}")
return {"success": False, "error": error, "logs": logs} return {"success": False, "error": error, "logs": logs}
@ -400,6 +408,7 @@ def run_setup(doas_password=None):
logs.append(f"{description} - Done") logs.append(f"{description} - Done")
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
proc.kill()
logs.append(f"Timeout: {description}") logs.append(f"Timeout: {description}")
return {"success": False, "error": f"Timeout during: {description}", "logs": logs} return {"success": False, "error": f"Timeout during: {description}", "logs": logs}
except Exception as e: 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-ce79e179.js";const n={class:"rounded-lg border p-4 shadow-sm"},c={class:"flex items-center justify-between mb-4"},d={class:"text-base font-medium text-ink-gray-9"},l={class:"h-[200px]"},p={__name:"ChartCard",props:{title:{type:String,required:!0}},setup(o){return(t,_)=>(a(),r("div",n,[e("div",c,[e("h3",d,i(o.title),1),s(t.$slots,"actions")]),e("div",l,[s(t.$slots,"default")])]))}};export{p as _}; import{o as a,c as r,b as e,t as i,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-ccfa432b.js.map //# sourceMappingURL=ChartCard-55f3f2ee.js.map

View File

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

View File

@ -1,2 +1,2 @@
import{_ as g}from"./LayoutHeader-1ab0c734.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-ce79e179.js";const F={class:"flex flex-col h-full"},B={class:"flex-1 overflow-auto p-5"},L={class:"rounded-lg border overflow-hidden"},N={class:"min-w-full divide-y"},U={class:"bg-surface-gray-2"},j={class:"px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase"},q={class:"px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase"},A={class:"px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase"},D={class:"px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase"},E={class:"px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase"},I={class:"px-6 py-3 text-right text-xs font-medium text-ink-gray-5 uppercase"},R={class:"divide-y"},S={class:"px-6 py-4"},z={class:"flex items-center gap-3"},G={class:"w-8 h-8 rounded-full bg-surface-gray-2 flex items-center justify-center"},Q={class:"text-sm font-medium text-ink-gray-5"},T={class:"text-sm font-medium text-ink-gray-9"},H={class:"px-6 py-4 text-sm text-ink-gray-7"},J={class:"px-6 py-4 text-sm text-ink-gray-7"},K={class:"px-6 py-4 text-sm font-medium text-ink-gray-9"},M={class:"px-6 py-4"},O={class:"px-6 py-4 text-sm text-ink-gray-7"},P={class:"px-6 py-4 text-right"},et={__name:"Customers",setup(W){const l=x(""),u=x(v),_=k(()=>{if(!l.value)return u.value;const e=l.value.toLowerCase();return u.value.filter(o=>o.name.toLowerCase().includes(e)||o.email.toLowerCase().includes(e))});function f(e){return new Intl.NumberFormat("ru-RU",{style:"currency",currency:"RUB",minimumFractionDigits:0}).format(e)}function y(){console.log("Exporting customers...")}function h(e){console.log("Viewing customer:",e.name)}return(e,o)=>(c(),m("div",F,[n(g,{title:e.$t("payments.customers")},{right:r(()=>[n(i(C),{modelValue:l.value,"onUpdate:modelValue":o[0]||(o[0]=a=>l.value=a),type:"text",placeholder:e.$t("common.search"),class:"w-64"},{prefix:r(()=>[n(i(d),{name:"search",class:"h-4 w-4 text-ink-gray-4"})]),_:1},8,["modelValue","placeholder"]),n(i(p),{variant:"subtle",onClick:y},{prefix:r(()=>[n(i(d),{name:"download",class:"h-4 w-4"})]),default:r(()=>[b(" "+s(e.$t("common.export")),1)]),_:1})]),_:1},8,["title"]),t("div",B,[t("div",L,[t("table",N,[t("thead",U,[t("tr",null,[t("th",j,s(e.$t("payments.customer.name")),1),t("th",q,s(e.$t("payments.customer.email")),1),t("th",A,s(e.$t("payments.connections")),1),t("th",D,s(e.$t("payments.customer.amount")),1),t("th",E,s(e.$t("payments.customer.status")),1),o[1]||(o[1]=t("th",{class:"px-6 py-3 text-left text-xs font-medium text-ink-gray-5 uppercase"},"Since",-1)),t("th",I,s(e.$t("common.actions")),1)])]),t("tbody",R,[(c(!0),m(w,null,$(_.value,a=>(c(),m("tr",{key:a.id},[t("td",S,[t("div",z,[t("div",G,[t("span",Q,s(a.name.charAt(0)),1)]),t("span",T,s(a.name),1)])]),t("td",H,s(a.email),1),t("td",J,s(a.connections),1),t("td",K,s(f(a.monthlyAmount))+"/mo ",1),t("td",M,[n(i(V),{variant:a.status==="active"?"success":"info",label:a.status},null,8,["variant","label"])]),t("td",O,s(a.since),1),t("td",P,[n(i(p),{variant:"ghost",size:"sm",onClick:X=>h(a)},{default:r(()=>[n(i(d),{name:"eye",class:"h-4 w-4"})]),_:1},8,["onClick"])])]))),128))])])])])]))}};export{et as default}; import{_ as g}from"./LayoutHeader-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-a9617540.js.map //# sourceMappingURL=Customers-4d8c7660.js.map

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-ce79e179.js";const m={class:"flex flex-col items-center justify-center py-12 text-center"},u={class:"mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-surface-gray-2"},f={class:"mb-2 text-lg font-medium text-ink-gray-9"},y={key:0,class:"mb-4 max-w-sm text-sm text-ink-gray-5"},p={__name:"EmptyState",props:{icon:{type:String,default:"inbox"},title:{type:String,required:!0},description:{type:String,default:""}},setup(e){return(i,x)=>(t(),s("div",m,[n("div",u,[r(c(o),{name:e.icon,class:"h-8 w-8 text-ink-gray-4"},null,8,["name"])]),n("h3",f,a(e.title),1),e.description?(t(),s("p",y,a(e.description),1)):l("",!0),d(i.$slots,"action")]))}};export{p as _}; import{o as t,c as s,b as n,d as r,e as c,F as o,t as a,i as l,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-0c9a3a58.js.map //# sourceMappingURL=EmptyState-54c60605.js.map

View File

@ -1 +1 @@
{"version":3,"file":"EmptyState-0c9a3a58.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-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"}

View File

@ -1,2 +1,2 @@
import{o as a,c as r,b as e,M as s,t as i}from"./index-ce79e179.js";const c={class:"flex items-center justify-between border-b bg-surface-white px-5 py-2.5"},l={class:"flex items-center gap-2"},n={class:"text-xl font-semibold text-ink-gray-9"},d={class:"flex items-center gap-2"},f={__name:"LayoutHeader",props:{title:{type:String,default:""}},setup(o){return(t,_)=>(a(),r("header",c,[e("div",l,[s(t.$slots,"left",{},()=>[e("h1",n,i(o.title),1)])]),e("div",d,[s(t.$slots,"right")])]))}};export{f as _}; import{o as a,c as r,b as e,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-1ab0c734.js.map //# sourceMappingURL=LayoutHeader-cd508225.js.map

View File

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

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-ce79e179.js";const C={class:"min-h-screen flex items-center justify-center bg-surface-gray-2 py-12 px-4 sm:px-6 lg:px-8"},U={class:"max-w-md w-full space-y-8"},D={class:"text-center"},P={class:"mt-2 text-sm text-ink-gray-5"},R={class:"space-y-4"},S={for:"email",class:"block text-sm font-medium text-ink-gray-7"},E={for:"password",class:"block text-sm font-medium text-ink-gray-7"},T={class:"flex items-center justify-between"},z={class:"flex items-center"},F={for:"remember-me",class:"ml-2 block text-sm text-ink-gray-7"},G={class:"text-sm"},H={href:"#",class:"font-medium text-blue-600 hover:text-blue-500"},K={__name:"Login",setup(I){const h=b(),v=_(),d=o(""),u=o(""),c=o(!1),m=o(!1),i=o("");async function g(){m.value=!0,i.value="";try{await q("login",{usr:d.value,pwd:u.value}),await A.init();const s=v.query.redirect||"/dashboard";h.push(s)}catch(s){i.value=s.message||"Login failed"}finally{m.value=!1}}return(s,t)=>(f(),y("div",C,[e("div",U,[e("div",D,[r(L,{class:"mx-auto h-16 w-16"}),t[3]||(t[3]=e("h2",{class:"mt-6 text-3xl font-bold text-ink-gray-9"}," Admin Panel ",-1)),e("p",P,a(s.$t("auth.login")),1)]),e("form",{class:"mt-8 space-y-6",onSubmit:x(g,["prevent"])},[e("div",R,[e("div",null,[e("label",S,a(s.$t("auth.email")),1),r(n(p),{id:"email",modelValue:d.value,"onUpdate:modelValue":t[0]||(t[0]=l=>d.value=l),type:"email",required:"",class:"mt-1",placeholder:s.$t("auth.email")},null,8,["modelValue","placeholder"])]),e("div",null,[e("label",E,a(s.$t("auth.password")),1),r(n(p),{id:"password",modelValue:u.value,"onUpdate:modelValue":t[1]||(t[1]=l=>u.value=l),type:"password",required:"",class:"mt-1",placeholder:s.$t("auth.password")},null,8,["modelValue","placeholder"])])]),e("div",T,[e("div",z,[w(e("input",{id:"remember-me","onUpdate:modelValue":t[2]||(t[2]=l=>c.value=l),type:"checkbox",class:"h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"},null,512),[[k,c.value]]),e("label",F,a(s.$t("auth.rememberMe")),1)]),e("div",G,[e("a",H,a(s.$t("auth.forgotPassword")),1)])]),e("div",null,[r(n(N),{type:"submit",variant:"solid",class:"w-full",loading:m.value},{default:V(()=>[M(a(s.$t("auth.loginButton")),1)]),_:1},8,["loading"])]),i.value?(f(),$(n(j),{key:0,message:i.value},null,8,["message"])):B("",!0)],32)])]))}};export{K as default}; import{u as b,a as _,o as f,c as y,b as e,d as r,t as a,w as x,e as n,f as w,v as k,g as V,h as $,i as B,r as o,A as L,_ as p,j as M,k as N,l as j,m as q,s as A}from"./index-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-8759ab20.js.map //# sourceMappingURL=Login-9697bc9f.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-ce79e179.js";const x={class:"min-h-screen flex items-center justify-center bg-surface-gray-2 py-12 px-4"},u={class:"text-center"},g={__name:"NotFound",setup(f){return(s,t)=>(o(),a("div",x,[e("div",u,[t[2]||(t[2]=e("h1",{class:"text-9xl font-bold text-ink-gray-2"},"404",-1)),t[3]||(t[3]=e("h2",{class:"mt-4 text-2xl font-semibold text-ink-gray-9"}," Page Not Found ",-1)),t[4]||(t[4]=e("p",{class:"mt-2 text-ink-gray-5"}," The page you're looking for doesn't exist. ",-1)),r(i(l),{variant:"solid",class:"mt-6",onClick:t[0]||(t[0]=m=>s.$router.push("/dashboard"))},{default:n(()=>[...t[1]||(t[1]=[d(" Go to Dashboard ",-1)])]),_:1})])]))}};export{g as default}; import{o,c as a,b as e,d as r,g as n,e as i,j as d,k as l}from"./index-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-29d88554.js.map //# sourceMappingURL=NotFound-eaa6081d.js.map

View File

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

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-ce79e179.js";const p={class:"rounded-lg border p-4 shadow-sm"},v={class:"flex items-center justify-between"},h={class:"flex items-center gap-3"},w={class:"text-sm text-ink-gray-5"},N={class:"text-2xl font-semibold text-ink-gray-9"},k={class:"text-sm font-medium"},S={key:0,class:"mt-2 text-xs text-ink-gray-5"},F={__name:"StatCard",props:{label:{type:String,required:!0},value:{type:[Number,String],required:!0},iconName:{type:String,default:"activity"},color:{type:String,default:"blue",validator:e=>["blue","green","red","yellow","purple","gray"].includes(e)},trend:{type:Number,default:void 0},subtitle:{type:String,default:""},format:{type:String,default:"number",validator:e=>["number","currency","percent"].includes(e)}},setup(e){const n=e,a={blue:{bg:"bg-blue-100",icon:"text-blue-600"},green:{bg:"bg-green-100",icon:"text-green-600"},red:{bg:"bg-red-100",icon:"text-red-600"},yellow:{bg:"bg-orange-100",icon:"text-orange-600"},purple:{bg:"bg-purple-100",icon:"text-purple-600"},gray:{bg:"bg-surface-gray-2",icon:"text-ink-gray-5"}},b=s(()=>{var t;return((t=a[n.color])==null?void 0:t.bg)||a.blue.bg}),f=s(()=>{var t;return((t=a[n.color])==null?void 0:t.icon)||a.blue.icon}),y=s(()=>n.trend>0?"text-green-600":n.trend<0?"text-red-600":"text-ink-gray-5"),x=s(()=>{const t=typeof n.value=="number"?n.value:parseFloat(n.value);if(isNaN(t))return n.value;switch(n.format){case"currency":return new Intl.NumberFormat("ru-RU",{style:"currency",currency:"RUB",minimumFractionDigits:0}).format(t);case"percent":return`${t}%`;default:return new Intl.NumberFormat("ru-RU").format(t)}});return(t,C)=>(c(),u("div",p,[r("div",v,[r("div",h,[r("div",{class:i(["flex h-10 w-10 items-center justify-center rounded-lg",b.value])},[o(d(g),{name:e.iconName,class:i(["h-5 w-5",f.value])},null,8,["name","class"])],2),r("div",null,[r("p",w,l(e.label),1),r("p",N,l(x.value),1)])]),e.trend!==void 0?(c(),u("div",{key:0,class:i(["flex items-center gap-1",y.value])},[o(d(g),{name:e.trend>0?"trending-up":"trending-down",class:"h-4 w-4"},null,8,["name"]),r("span",k,l(Math.abs(e.trend))+"%",1)],2)):m("",!0)]),e.subtitle?(c(),u("div",S,l(e.subtitle),1)):m("",!0)]))}};export{F as _}; import{n as s,o as c,c as u,b as r,E as i,d as o,e as d,F as g,t as l,i as m}from"./index-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-159fe0b3.js.map //# sourceMappingURL=StatCard-edb6f5e9.js.map

View File

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

View File

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

View File

@ -38,13 +38,13 @@
<div v-else> <div v-else>
<!-- Certificate exists --> <!-- Certificate exists -->
<div v-if="certStatus?.has_certificate" class="space-y-4"> <div v-if="certStatus?.has_certificate" class="space-y-4">
<div class="flex items-center gap-3 p-3 rounded-lg bg-surface-gray-2"> <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"> <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" /> <FeatherIcon name="shield" class="h-5 w-5 text-green-600 dark:text-green-400" />
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="text-sm font-medium text-ink-gray-9">Certificate configured</p> <p class="text-sm font-medium text-green-800 dark:text-green-300">Certificate configured</p>
<p class="text-xs text-ink-gray-5 font-mono">{{ certStatus.cert_path }}</p> <p class="text-xs text-green-600 dark:text-green-500 font-mono">{{ certStatus.cert_path }}</p>
</div> </div>
<Button variant="subtle" size="sm" @click="showCertModal = true"> <Button variant="subtle" size="sm" @click="showCertModal = true">
<template #prefix> <template #prefix>
@ -54,25 +54,25 @@
</Button> </Button>
</div> </div>
<div class="bg-surface-gray-2 rounded-lg p-4"> <div class="bg-blue-50 dark:bg-blue-950/30 rounded-lg p-4">
<p class="text-sm text-ink-gray-8 font-medium mb-2">How to trust this certificate on Incus servers:</p> <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-ink-gray-6 space-y-1 list-decimal list-inside"> <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>Click "View" to copy the certificate content</li>
<li>Save it as <code class="bg-surface-gray-3 px-1 rounded">admin-panel.crt</code> on your Incus server</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-surface-gray-3 px-1 rounded">incus config trust add-certificate admin-panel.crt</code></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> </ol>
</div> </div>
</div> </div>
<!-- No certificate --> <!-- No certificate -->
<div v-else class="space-y-4"> <div v-else class="space-y-4">
<div class="flex items-center gap-3 p-3 rounded-lg bg-surface-gray-2"> <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"> <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" /> <FeatherIcon name="alert-circle" class="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="text-sm font-medium text-ink-gray-9">No certificate</p> <p class="text-sm font-medium text-yellow-800 dark:text-yellow-300">No certificate</p>
<p class="text-xs text-ink-gray-5">Generate a client certificate to connect to Incus servers</p> <p class="text-xs text-yellow-600 dark:text-yellow-500">Generate a client certificate to connect to Incus servers</p>
</div> </div>
<Button variant="solid" @click="generateCertificate" :loading="generating"> <Button variant="solid" @click="generateCertificate" :loading="generating">
<template #prefix> <template #prefix>
@ -112,10 +112,12 @@
<Spinner class="h-6 w-6" /> <Spinner class="h-6 w-6" />
</div> </div>
<div v-else class="flex items-center gap-3 p-3 rounded-lg bg-surface-gray-2"> <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 <div
class="w-10 h-10 rounded-full flex items-center justify-center" class="w-10 h-10 rounded-full flex items-center justify-center"
:class="setupStatus?.distrobuilder_installed ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'" :class="setupStatus?.distrobuilder_installed ? 'bg-green-100 dark:bg-green-900/50' : 'bg-red-100 dark:bg-red-900/50'"
> >
<FeatherIcon <FeatherIcon
:name="setupStatus?.distrobuilder_installed ? 'check-circle' : 'alert-circle'" :name="setupStatus?.distrobuilder_installed ? 'check-circle' : 'alert-circle'"
@ -124,10 +126,14 @@
/> />
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="text-sm font-medium text-ink-gray-9"> <p class="text-sm font-medium"
:class="setupStatus?.distrobuilder_installed ? 'text-green-800 dark:text-green-300' : 'text-red-800 dark:text-red-300'"
>
{{ setupStatus?.distrobuilder_installed ? $t('servers.setup.distrobuilderInstalled') : $t('servers.setup.distrobuilderNotInstalled') }} {{ setupStatus?.distrobuilder_installed ? $t('servers.setup.distrobuilderInstalled') : $t('servers.setup.distrobuilderNotInstalled') }}
</p> </p>
<p class="text-xs text-ink-gray-5"> <p class="text-xs"
:class="setupStatus?.distrobuilder_installed ? 'text-green-600 dark:text-green-500' : 'text-red-600 dark:text-red-500'"
>
{{ setupStatus?.distrobuilder_version || $t('servers.setup.distrobuilderDesc') }} {{ setupStatus?.distrobuilder_version || $t('servers.setup.distrobuilderDesc') }}
</p> </p>
</div> </div>
@ -159,8 +165,8 @@
> >
<div class="flex items-start justify-between mb-3"> <div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center"> <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" /> <FeatherIcon name="cloud" class="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div> </div>
<div> <div>
<h4 class="text-sm font-medium text-ink-gray-9">{{ server.name }}</h4> <h4 class="text-sm font-medium text-ink-gray-9">{{ server.name }}</h4>
@ -240,14 +246,21 @@
<p class="text-sm text-ink-gray-7"> <p class="text-sm text-ink-gray-7">
{{ $t('servers.setup.installDescription') }} {{ $t('servers.setup.installDescription') }}
</p> </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"> <div class="bg-surface-gray-2 rounded-lg p-3">
<p class="text-xs text-ink-gray-7"> <p class="text-xs text-ink-gray-5">
{{ $t('servers.setup.doasNote') }} 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> </p>
<code class="text-xs text-ink-gray-8 block mt-1 bg-surface-gray-3 p-1 rounded whitespace-pre-line">permit nopass frappe as root cmd apk
permit nopass frappe as root cmd distrobuilder
permit nopass frappe as root cmd rm</code>
</div> </div>
<div v-if="installResult" class="mt-4"> <div v-if="installResult" class="mt-4">
<label class="block text-sm font-medium text-ink-gray-7 mb-2">{{ $t('common.output') }}</label> <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 class="bg-gray-900 text-gray-100 p-4 rounded-lg text-xs font-mono max-h-64 overflow-y-auto">
@ -256,7 +269,7 @@ permit nopass frappe as root cmd rm</code>
:key="index" :key="index"
:class="{ :class="{
'text-green-400': log.includes('Done'), 'text-green-400': log.includes('Done'),
'text-red-400': log.includes('Failed'), 'text-red-400': log.includes('Failed') || log.includes('Error'),
'text-yellow-400': log.includes('==='), 'text-yellow-400': log.includes('==='),
}" }"
> >
@ -276,6 +289,7 @@ permit nopass frappe as root cmd rm</code>
variant="solid" variant="solid"
@click="runInstall" @click="runInstall"
:loading="installing" :loading="installing"
:disabled="!doasPassword"
> >
<template #prefix> <template #prefix>
<FeatherIcon name="download" class="h-4 w-4" /> <FeatherIcon name="download" class="h-4 w-4" />
@ -326,7 +340,7 @@ permit nopass frappe as root cmd rm</code>
</label> </label>
</div> </div>
<div v-if="addServerError" class="text-sm text-red-500 bg-red-50 p-2 rounded"> <div v-if="addServerError" class="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 p-2 rounded">
{{ addServerError }} {{ addServerError }}
</div> </div>
</div> </div>
@ -380,7 +394,7 @@ permit nopass frappe as root cmd rm</code>
<textarea <textarea
:value="certContent.certificate" :value="certContent.certificate"
readonly readonly
class="w-full h-48 rounded-lg border border-gray-300 px-3 py-2 text-xs font-mono bg-gray-50" class="w-full h-48 rounded-lg border px-3 py-2 text-xs font-mono bg-surface-gray-2 text-ink-gray-8 border-outline-gray-2"
></textarea> ></textarea>
<Button <Button
variant="subtle" variant="subtle"
@ -395,9 +409,9 @@ permit nopass frappe as root cmd rm</code>
</Button> </Button>
</div> </div>
<div class="bg-blue-50 rounded-lg p-4"> <div class="bg-blue-50 dark:bg-blue-950/30 rounded-lg p-4">
<p class="text-sm text-blue-800 font-medium mb-2">On your Incus server:</p> <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 block bg-blue-100 p-2 rounded"> <code class="text-sm text-blue-700 dark:text-blue-400 block bg-blue-100 dark:bg-blue-900/50 p-2 rounded">
incus config trust add-certificate admin-panel.crt incus config trust add-certificate admin-panel.crt
</code> </code>
</div> </div>
@ -428,6 +442,7 @@ const serverToRemove = ref(null)
const installing = ref(false) const installing = ref(false)
const installResult = ref(null) const installResult = ref(null)
const doasPassword = ref('')
const addingServer = ref(false) const addingServer = ref(false)
const removingServer = ref(false) const removingServer = ref(false)
@ -570,7 +585,7 @@ async function runInstall() {
try { try {
const result = await call('admin_panel.api.servers.run_setup', { const result = await call('admin_panel.api.servers.run_setup', {
doas_password: '', doas_password: doasPassword.value,
}) })
installResult.value = result installResult.value = result
@ -590,6 +605,7 @@ async function runInstall() {
function closeInstallModal() { function closeInstallModal() {
showInstallModal.value = false showInstallModal.value = false
installResult.value = null installResult.value = null
doasPassword.value = ''
} }
async function testServerConnection(serverName) { async function testServerConnection(serverName) {
@ -658,7 +674,6 @@ async function confirmRemove() {
}) })
if (result.success) { if (result.success) {
console.log(result.message)
showRemoveModal.value = false showRemoveModal.value = false
serverToRemove.value = null serverToRemove.value = null
serversRes.reload() serversRes.reload()
@ -679,9 +694,7 @@ async function updateServerRole(serverName, roleField, value) {
const result = await call('admin_panel.api.servers.update_server', params) const result = await call('admin_panel.api.servers.update_server', params)
if (result.success) { if (!result.success) {
console.log(result.message)
} else {
console.error(result.error || 'Failed to update server') console.error(result.error || 'Failed to update server')
serversRes.reload() serversRes.reload()
} }

View File

@ -67,6 +67,12 @@
</template> </template>
Update Update
</Button> </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"> <Button variant="solid" @click="openSite">
<template #prefix> <template #prefix>
<FeatherIcon name="external-link" class="h-4 w-4" /> <FeatherIcon name="external-link" class="h-4 w-4" />
@ -514,18 +520,53 @@
</Button> </Button>
</template> </template>
</Dialog> </Dialog>
<!-- Delete Container Modal -->
<Dialog v-model="showDeleteContainerModal" :options="{ title: 'Delete Container' }">
<template #body-content>
<div class="space-y-4">
<div class="flex items-center gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-950/30">
<FeatherIcon name="alert-triangle" class="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0" />
<p class="text-sm text-red-800 dark:text-red-300">
This action is irreversible. The container <strong>{{ site?.name }}</strong> and all its data will be permanently deleted.
</p>
</div>
<FormControl
v-model="deleteConfirmName"
label="Type the container name to confirm"
type="text"
:placeholder="site?.name"
/>
</div>
</template>
<template #actions>
<Button variant="subtle" @click="showDeleteContainerModal = false; deleteConfirmName = ''">
Cancel
</Button>
<Button
variant="solid"
theme="red"
@click="deleteContainer"
:loading="actionLoading === 'delete-container'"
:disabled="deleteConfirmName !== site?.name"
>
Delete Container
</Button>
</template>
</Dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { createResource, call } from 'frappe-ui' import { createResource, call } from 'frappe-ui'
import { Button, Badge, Spinner, FormControl, Dialog, FeatherIcon } from 'frappe-ui' import { Button, Badge, Spinner, FormControl, Dialog, FeatherIcon } from 'frappe-ui'
import LayoutHeader from '@/components/Common/LayoutHeader.vue' import LayoutHeader from '@/components/Common/LayoutHeader.vue'
import { siteActivity } from '@/mocks/sites' import { siteActivity } from '@/mocks/sites'
const route = useRoute() const route = useRoute()
const router = useRouter()
const activeTab = ref('overview') const activeTab = ref('overview')
// Get server from query param (for remote containers) // Get server from query param (for remote containers)
@ -539,6 +580,10 @@ const snapshotCreateError = ref('')
const logsLoaded = ref(false) const logsLoaded = ref(false)
const snapshotsLoaded = ref(false) const snapshotsLoaded = ref(false)
// Delete container state
const showDeleteContainerModal = ref(false)
const deleteConfirmName = ref('')
// Update/Rebuild state // Update/Rebuild state
const showUpdateModal = ref(false) const showUpdateModal = ref(false)
const updateImage = ref('') const updateImage = ref('')
@ -668,7 +713,25 @@ function generateDefaultSnapshotName() {
const d = String(now.getDate()).padStart(2, '0') const d = String(now.getDate()).padStart(2, '0')
const h = String(now.getHours()).padStart(2, '0') const h = String(now.getHours()).padStart(2, '0')
const min = String(now.getMinutes()).padStart(2, '0') const min = String(now.getMinutes()).padStart(2, '0')
return `snap-${y}${m}${d}-${h}${min}` const sec = String(now.getSeconds()).padStart(2, '0')
return `snap-${y}${m}${d}-${h}${min}${sec}`
}
async function deleteContainer() {
actionLoading.value = 'delete-container'
try {
await call('admin_panel.api.sites.delete_container', {
container_name: route.params.siteId,
target_server: targetServer.value,
})
showDeleteContainerModal.value = false
deleteConfirmName.value = ''
router.push({ name: 'Sites' })
} catch (e) {
console.error('Failed to delete container:', e)
} finally {
actionLoading.value = ''
}
} }
async function startSite() { async function startSite() {