Skip to content

Commit f3c67a0

Browse files
Merge pull request #26 from frappe/setup-wizard
feat(admin): Support bench.toml configuration from bench start
2 parents b840d0b + ead42cd commit f3c67a0

20 files changed

Lines changed: 1006 additions & 131 deletions

File tree

admin/backend/app.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .views.database import database_bp
1212
from .views.logs import logs_bp
1313
from .views.processes import processes_bp
14+
from .views.setup import setup_bp
1415
from .views.settings import settings_bp
1516
from .views.sites import sites_bp
1617
from .views.tasks import tasks_bp
@@ -23,6 +24,17 @@
2324
_OPEN_PATHS = {"/api/status", "/api/login", "/api/logout"}
2425

2526

27+
def _wizard_status(bench_root: Path) -> dict:
28+
import tomllib
29+
name = bench_root.name
30+
try:
31+
with open(bench_root / "bench.toml", "rb") as f:
32+
name = tomllib.load(f).get("bench", {}).get("name", name)
33+
except Exception:
34+
pass
35+
return {"wizard": True, "name": name, "enabled": True, "authenticated": True}
36+
37+
2638
def create_app(bench_root: Path) -> Flask:
2739
app = Flask(__name__, static_folder=str(_STATIC_DIR), static_url_path="/static")
2840
app.config["BENCH_ROOT"] = bench_root
@@ -48,6 +60,8 @@ def _check_password(config: BenchConfig):
4860
def _guard():
4961
if not request.path.startswith("/api") or request.path in _OPEN_PATHS:
5062
return None
63+
if request.path.startswith("/api/setup/"):
64+
return None
5165
try:
5266
config = _load_config()
5367
return _check_enabled(config) or _check_password(config)
@@ -56,19 +70,22 @@ def _guard():
5670

5771
@app.route("/api/status")
5872
def api_status():
73+
initialized = (bench_root / "env" / "bin" / "python").exists()
5974
try:
6075
config = BenchConfig.from_file(bench_root / "bench.toml")
61-
if not config.admin.password:
62-
return jsonify({"enabled": False, "error": "No admin password configured in bench.toml"}), 503
63-
return jsonify(
64-
{
65-
"enabled": config.admin.enabled,
66-
"name": config.name,
67-
"authenticated": bool(session.get("authenticated")),
68-
}
69-
)
7076
except Exception as exc:
7177
return jsonify({"enabled": False, "error": str(exc)}), 503
78+
# Show wizard when bench was never initialized, or when init was
79+
# interrupted before an admin password was saved.
80+
if not initialized or not config.admin.password:
81+
return jsonify(_wizard_status(bench_root))
82+
return jsonify(
83+
{
84+
"enabled": config.admin.enabled,
85+
"name": config.name,
86+
"authenticated": bool(session.get("authenticated")),
87+
}
88+
)
7289

7390
@app.route("/api/login", methods=["POST"])
7491
def api_login():
@@ -89,6 +106,7 @@ def api_logout():
89106
session.clear()
90107
return jsonify({"ok": True})
91108

109+
app.register_blueprint(setup_bp, url_prefix="/api/setup")
92110
app.register_blueprint(dashboard_bp, url_prefix="/api")
93111
app.register_blueprint(apps_bp, url_prefix="/api/apps")
94112
app.register_blueprint(sites_bp, url_prefix="/api/sites")
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
from pathlib import Path
5+
6+
from bench_cli.commands.init import InitCommand
7+
from bench_cli.config.bench_config import BenchConfig
8+
from bench_cli.core.bench import Bench
9+
10+
11+
def main() -> None:
12+
parser = argparse.ArgumentParser(description="Run bench init as a task")
13+
parser.add_argument("bench_root")
14+
parser.add_argument("--sudo-password", default="")
15+
args = parser.parse_args()
16+
17+
bench_root = Path(args.bench_root)
18+
config = BenchConfig.from_file(bench_root / "bench.toml")
19+
bench = Bench(config, bench_root)
20+
InitCommand(bench, sudo_password=args.sudo_password).run()
21+
22+
23+
if __name__ == "__main__":
24+
main()

admin/backend/tasks/manager/task_runner.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"setup-production": [],
3434
"setup-letsencrypt": [],
3535
"new-site-from-backup": ["name", "db_file"],
36+
"bench-init": [],
3637
"update-cli": [],
3738
}
3839

@@ -158,6 +159,11 @@ def _build_argv(self, command: str, args: dict) -> list[str]:
158159
return [sys.executable, "-m", "admin.backend.tasks.jobs.setup_production_task", str(self._bench_root)]
159160
if command == "setup-letsencrypt":
160161
return [sys.executable, "-m", "admin.backend.tasks.jobs.setup_letsencrypt_task", str(self._bench_root)]
162+
if command == "bench-init":
163+
argv = [sys.executable, "-m", "admin.backend.tasks.jobs.init_task", str(self._bench_root)]
164+
if args.get("sudo_password"):
165+
argv += ["--sudo-password", args["sudo_password"]]
166+
return argv
161167
if command == "new-site-from-backup":
162168
argv = [sys.executable, "-m", "admin.backend.tasks.jobs.new_site_from_backup_task", str(self._bench_root), args["name"], args["db_file"]]
163169
if args.get("admin_password"):

admin/backend/views/setup.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
5+
from flask import Blueprint, Response, current_app, jsonify, request, stream_with_context
6+
7+
from admin.backend.tasks.manager.task_reader import TaskReader
8+
from admin.backend.tasks.manager.task_runner import TaskRunner
9+
from bench_cli.config.bench_toml_builder import BenchTomlBuilder
10+
11+
setup_bp = Blueprint("setup", __name__)
12+
13+
14+
@setup_bp.route("/config")
15+
def get_config():
16+
bench_root = Path(current_app.config["BENCH_ROOT"])
17+
return jsonify(_read_defaults(bench_root))
18+
19+
20+
@setup_bp.route("/save", methods=["POST"])
21+
def save_config():
22+
bench_root = Path(current_app.config["BENCH_ROOT"])
23+
data = request.get_json(silent=True) or {}
24+
25+
error = _validate(data)
26+
if error:
27+
return jsonify({"ok": False, "error": error}), 400
28+
29+
settings = {**data, "admin_enabled": True}
30+
content = BenchTomlBuilder(_current_name(bench_root), settings).render()
31+
(bench_root / "bench.toml").write_text(content)
32+
return jsonify({"ok": True})
33+
34+
35+
def _validate(data: dict) -> str | None:
36+
for field in ("mariadb_password", "admin_password"):
37+
if not data.get(field):
38+
return f"{field} is required"
39+
if data.get("volume_enabled"):
40+
for field in ("volume_pool", "volume_device"):
41+
if not data.get(field):
42+
return f"{field} is required when volume management is enabled"
43+
return None
44+
45+
46+
@setup_bp.route("/init", methods=["POST"])
47+
def start_init():
48+
bench_root = Path(current_app.config["BENCH_ROOT"])
49+
data = request.get_json(silent=True) or {}
50+
args = {}
51+
if data.get("sudo_password"):
52+
args["sudo_password"] = data["sudo_password"]
53+
try:
54+
task_id = TaskRunner(bench_root).run("bench-init", args)
55+
return jsonify({"ok": True, "task_id": task_id})
56+
except Exception as exc:
57+
return jsonify({"ok": False, "error": str(exc)}), 500
58+
59+
60+
@setup_bp.route("/new-site", methods=["POST"])
61+
def start_new_site():
62+
bench_root = Path(current_app.config["BENCH_ROOT"])
63+
data = request.get_json(silent=True) or {}
64+
if not data.get("name"):
65+
return jsonify({"ok": False, "error": "Site name is required"}), 400
66+
67+
args = {"name": data["name"]}
68+
if data.get("admin_password"):
69+
args["admin_password"] = data["admin_password"]
70+
try:
71+
task_id = TaskRunner(bench_root).run("new-site", args)
72+
return jsonify({"ok": True, "task_id": task_id})
73+
except Exception as exc:
74+
return jsonify({"ok": False, "error": str(exc)}), 500
75+
76+
77+
@setup_bp.route("/stream/<task_id>")
78+
def stream_task(task_id: str):
79+
bench_root = Path(current_app.config["BENCH_ROOT"])
80+
reader = TaskReader(bench_root)
81+
82+
def generate():
83+
for line in reader.stream_output(task_id):
84+
if line.startswith("__DONE__:"):
85+
yield f"event: done\ndata: {line[9:]}\n\n"
86+
else:
87+
yield f"data: {line}\n\n"
88+
89+
return Response(stream_with_context(generate()), mimetype="text/event-stream")
90+
91+
92+
def _read_defaults(bench_root: Path) -> dict:
93+
from bench_cli.platform import is_linux
94+
from admin.backend.tasks.manager.task_reader import TaskReader
95+
96+
result = {"bench_name": bench_root.name, "is_linux": is_linux(), **BenchTomlBuilder.DEFAULTS}
97+
toml_path = bench_root / "bench.toml"
98+
if toml_path.exists():
99+
try:
100+
result.update(BenchTomlBuilder.read_settings(toml_path))
101+
if not result.get("bench_name"):
102+
result["bench_name"] = bench_root.name
103+
except Exception:
104+
pass
105+
106+
try:
107+
tasks = TaskReader(bench_root).list_tasks()
108+
running = next((t for t in tasks if t.command == "bench-init" and t.status == "running"), None)
109+
result["running_init_task_id"] = running.task_id if running else None
110+
except Exception:
111+
result["running_init_task_id"] = None
112+
113+
return result
114+
115+
116+
def _current_name(bench_root: Path) -> str:
117+
toml_path = bench_root / "bench.toml"
118+
if not toml_path.exists():
119+
return bench_root.name
120+
try:
121+
return BenchTomlBuilder.read_settings(toml_path).get("bench_name") or bench_root.name
122+
except Exception:
123+
return bench_root.name

admin/frontend/components.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export {}
88
/* prettier-ignore */
99
declare module 'vue' {
1010
export interface GlobalComponents {
11+
AdvancedSetup: typeof import('./src/components/AdvancedSetup.vue')['default']
1112
AppLayout: typeof import('./src/components/AppLayout.vue')['default']
1213
AppSidebar: typeof import('./src/components/AppSidebar.vue')['default']
1314
FilePickerField: typeof import('./src/components/FilePickerField.vue')['default']

admin/frontend/src/App.vue

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,26 @@
22
import { ref, onMounted } from 'vue'
33
import AppLayout from './components/AppLayout.vue'
44
import Login from './pages/Login.vue'
5+
import Setup from './pages/Setup.vue'
56
import { Alert, useTheme } from 'frappe-ui'
67
78
const { initializeTheme } = useTheme()
89
10+
const loading = ref(true)
911
const adminEnabled = ref(true)
1012
const adminError = ref('')
11-
const authenticated = ref(true)
13+
const authenticated = ref(false)
1214
const benchName = ref('')
15+
const wizardMode = ref(false)
1316
1417
async function loadStatus() {
1518
const response = await fetch('/api/status')
1619
const data = await response.json()
20+
wizardMode.value = data.wizard === true
1721
adminEnabled.value = data.enabled !== false
1822
authenticated.value = data.authenticated !== false
1923
benchName.value = data.name ?? ''
20-
if (!adminEnabled.value) {
24+
if (!wizardMode.value && !adminEnabled.value) {
2125
adminError.value = data.error || 'Admin is disabled in bench.toml'
2226
}
2327
}
@@ -28,12 +32,22 @@ onMounted(async () => {
2832
await loadStatus()
2933
} catch {
3034
adminError.value = 'Could not reach the bench admin server.'
35+
} finally {
36+
loading.value = false
3137
}
3238
})
39+
40+
function onSetupDone() {
41+
// Bench is now initialized and admin requires a password.
42+
// Reload so the fresh status routes the user through the login page.
43+
window.location.reload()
44+
}
3345
</script>
3446
3547
<template>
36-
<div v-if="adminError" class="flex h-screen items-center justify-center p-8">
48+
<div v-if="loading" class="flex h-screen items-center justify-center bg-surface-gray-2" />
49+
<Setup v-else-if="wizardMode" @done="onSetupDone" />
50+
<div v-else-if="adminError" class="flex h-screen items-center justify-center p-8">
3751
<Alert theme="red" title="Admin Unavailable" :description="adminError" />
3852
</div>
3953
<Login v-else-if="!authenticated" :bench-name="benchName" @authenticated="authenticated = true" />
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<script setup>
2+
import { ref } from 'vue'
3+
import { TextInput, FormControl, FormLabel } from 'frappe-ui'
4+
import LucideChevronRight from '~icons/lucide/chevron-right'
5+
6+
const form = defineModel({ type: Object, required: true })
7+
const open = ref(false)
8+
9+
const generalFields = [
10+
{ key: 'app_repo', label: 'Frappe repository' },
11+
{ key: 'http_port', label: 'HTTP port', type: 'number' },
12+
{ key: 'socketio_port', label: 'Socket.IO port', type: 'number' },
13+
{ key: 'redis_port', label: 'Redis port', type: 'number' },
14+
]
15+
16+
const workerFields = [
17+
{ key: 'workers_default', label: 'Default' },
18+
{ key: 'workers_short', label: 'Short' },
19+
{ key: 'workers_long', label: 'Long' },
20+
]
21+
</script>
22+
23+
<template>
24+
<div class="rounded-lg border border-outline-gray-2">
25+
<button
26+
type="button"
27+
class="flex w-full items-center gap-1.5 px-3 py-2 text-sm font-medium text-ink-gray-6"
28+
@click="open = !open"
29+
>
30+
<LucideChevronRight class="h-4 w-4 transition-transform" :class="{ 'rotate-90': open }" />
31+
Advanced
32+
</button>
33+
34+
<div v-if="open" class="flex flex-col gap-4 border-t border-outline-gray-2 p-3">
35+
<FormControl
36+
v-for="field in generalFields"
37+
:key="field.key"
38+
:label="field.label"
39+
:type="field.type || 'text'"
40+
v-model="form[field.key]"
41+
/>
42+
43+
<div class="space-y-1.5">
44+
<FormLabel label="Workers" />
45+
<div class="grid grid-cols-3 gap-2">
46+
<div v-for="field in workerFields" :key="field.key" class="space-y-1">
47+
<FormLabel :label="field.label" />
48+
<TextInput v-model="form[field.key]" type="number" />
49+
</div>
50+
</div>
51+
</div>
52+
</div>
53+
</div>
54+
</template>

admin/frontend/src/index.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,11 @@ html {
66
.dialog-overlay {
77
z-index: 50;
88
}
9+
10+
/* @tailwindcss/forms sets appearance:none on all inputs, which strips the system font Safari
11+
uses to render password masking bullets — they appear as boxes without this override.
12+
We are doing to for safari compatibility!
13+
*/
14+
input[type='password'] {
15+
font-family: -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
16+
}

0 commit comments

Comments
 (0)