Skip to content

Commit 41840c6

Browse files
rmehtaclaude
andcommitted
feat(settings): settings modal with tabbed layout, production refactor, and CLI update checker
- Move Settings from a page to a Dialog modal accessible from the sidebar header dropdown (CRM-style layout: sidebar nav + content panel) - Refactor ProductionConfig: replace enabled/lightweight booleans with a single process_manager field (none | supervisor | systemd); nginx is now independent; backward-compat parsing for old TOML format - Auto-trigger setup-production or setup-nginx task on settings save instead of a separate Setup tab - Add Updates tab: check for bench-cli git updates, show badge, and run git pull as a task - Add tests for ProductionConfig parsing (new format, legacy format, enabled property, TOML writer output) - Update README: process_manager field, process manager options, Settings row description Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 17a57b2 commit 41840c6

18 files changed

Lines changed: 554 additions & 38 deletions

File tree

README.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ password = "your-admin-password" # required — admin refuses to start without
8080
domain = "admin.example.com" # optional — serve admin over HTTPS via nginx
8181

8282
[production]
83-
lightweight = false # false = supervisor (default), true = systemd --user
84-
nginx = true # run nginx setup as part of bench setup production
83+
process_manager = "supervisor" # none | supervisor | systemd
84+
nginx = true
8585
```
8686

8787
Apps and sites are tracked by the filesystem — no need to list them in `bench.toml`.
@@ -112,8 +112,8 @@ With multiple benches: `bench -b my-bench start`
112112

113113
```toml
114114
[production]
115-
nginx = true # include nginx in bench setup production
116-
# lightweight = true # uncomment to use systemd --user instead of supervisor
115+
process_manager = "supervisor" # none | supervisor | systemd
116+
nginx = true
117117

118118
[nginx]
119119
enabled = true
@@ -133,8 +133,9 @@ bench restart # restart all bench processes (works with both ma
133133
```
134134

135135
**Process managers:**
136-
- **Supervisor** (default) — runs a bench-owned `supervisord` instance, no root needed.
137-
- **Systemd** (`lightweight = true`) — uses `systemctl --user` units; requires `loginctl enable-linger` once.
136+
- **Supervisor** — runs a bench-owned `supervisord` instance, no root needed.
137+
- **Systemd** — uses `systemctl --user` units; requires `loginctl enable-linger` once.
138+
- **None** — development mode; use `bench start` / Procfile runner.
138139

139140
When `admin.domain` is set, `bench setup production` obtains a certificate for that domain and generates an HTTPS nginx proxy block. HTTP redirects to HTTPS automatically.
140141

@@ -153,7 +154,7 @@ The built-in admin UI runs on port 8002 (configurable via `[admin] port`).
153154
| Logs | Tail and search log files with live streaming |
154155
| Tasks | Multi-step task view with collapsible output per step; task history |
155156
| Database | MariaDB process list, slow queries, binary log viewer |
156-
| Settings | Edit bench config (ports, workers, nginx, Let's Encrypt) with validation |
157+
| Settings | Modal dialog (sidebar dropdown) — configure ports, workers, process manager, nginx, and Let's Encrypt; check and apply bench-cli updates |
157158

158159
All forms validate input before submission — site names are checked for valid hostname format, repository URLs for valid git URL format, branch names for legal characters, cron expressions for valid 5-field syntax, and port numbers for the 1–65535 range.
159160

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from __future__ import annotations
2+
3+
import subprocess
4+
import sys
5+
from pathlib import Path
6+
7+
8+
def _cli_root() -> Path:
9+
import bench_cli as _pkg
10+
return Path(_pkg.__file__).parent.parent
11+
12+
13+
if __name__ == "__main__":
14+
# bench_root is passed by the task runner but not needed here
15+
cli_root = _cli_root()
16+
print(f"Updating bench-cli at {cli_root}...")
17+
sys.stdout.flush()
18+
19+
result = subprocess.run(
20+
["git", "-C", str(cli_root), "pull"],
21+
stdout=sys.stdout,
22+
stderr=sys.stderr,
23+
text=True,
24+
)
25+
sys.exit(result.returncode)

admin/backend/tasks/manager/task_runner.py

Lines changed: 3 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+
"update-cli": [],
3637
}
3738

3839

@@ -166,6 +167,8 @@ def _build_argv(self, command: str, args: dict) -> list[str]:
166167
if args.get("private_files"):
167168
argv += ["--private-files", args["private_files"]]
168169
return argv
170+
if command == "update-cli":
171+
return [sys.executable, "-m", "admin.backend.tasks.jobs.update_cli_task", str(self._bench_root)]
169172

170173
raise ValueError(f"Unhandled command: {command!r}")
171174

admin/backend/views/settings.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,7 @@
2727
("workers", "default"),
2828
("workers", "short"),
2929
("workers", "long"),
30-
("production", "enabled"),
31-
("production", "lightweight"),
30+
("production", "process_manager"),
3231
}
3332

3433

@@ -96,8 +95,7 @@ def _config_snapshot(config: BenchConfig) -> dict:
9695
"long": config.workers.long_count,
9796
},
9897
"production": {
99-
"enabled": config.production.enabled,
100-
"lightweight": config.production.lightweight,
98+
"process_manager": config.production.process_manager,
10199
},
102100
}
103101

@@ -147,9 +145,8 @@ def get_settings():
147145
"webroot_path": str(config.letsencrypt.webroot_path),
148146
},
149147
"production": {
150-
"enabled": config.production.enabled,
148+
"process_manager": config.production.process_manager,
151149
"nginx": config.production.nginx,
152-
"lightweight": config.production.lightweight,
153150
},
154151
})
155152

@@ -220,9 +217,12 @@ def update_settings():
220217
production_data = data.get("production", {})
221218
if production_data:
222219
p = config.production
223-
p.enabled = bool(production_data.get("enabled", p.enabled))
220+
if "process_manager" in production_data:
221+
pm = str(production_data["process_manager"])
222+
if pm not in ("none", "supervisor", "systemd"):
223+
return jsonify({"ok": False, "error": "process_manager must be none, supervisor, or systemd"}), 400
224+
p.process_manager = pm
224225
p.nginx = bool(production_data.get("nginx", p.nginx))
225-
p.lightweight = bool(production_data.get("lightweight", p.lightweight))
226226

227227
err = first_error(
228228
validate_port(config.http_port, "HTTP Port"),

admin/backend/views/updates.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
updates_bp = Blueprint("updates", __name__)
99

1010

11+
def _cli_root() -> Path:
12+
import bench_cli as _pkg
13+
return Path(_pkg.__file__).parent.parent
14+
15+
1116
@updates_bp.route("/")
1217
def get_updates():
1318
bench_root = Path(current_app.config["BENCH_ROOT"])
@@ -92,3 +97,30 @@ def _log_subject(path: Path, ref: str) -> str:
9297
text=True,
9398
)
9499
return r.stdout.strip()
100+
101+
102+
@updates_bp.route("/cli")
103+
def get_cli_update():
104+
cli_root = _cli_root()
105+
do_fetch = request.args.get("fetch") == "1"
106+
107+
branch = _current_branch(cli_root)
108+
if do_fetch:
109+
_git_fetch(cli_root, branch)
110+
111+
remote_ref = f"origin/{branch}"
112+
behind = _count(cli_root, f"HEAD..{remote_ref}")
113+
remote_commit = _log_subject(cli_root, remote_ref)
114+
local_commit = _log_subject(cli_root, "HEAD")
115+
116+
fetch_head = cli_root / ".git" / "FETCH_HEAD"
117+
last_fetched = fetch_head.stat().st_mtime if fetch_head.exists() else None
118+
119+
return jsonify({
120+
"branch": branch,
121+
"commits_behind": behind,
122+
"update_available": behind > 0,
123+
"local_commit": local_commit,
124+
"remote_commit": remote_commit,
125+
"last_fetched": last_fetched,
126+
})

admin/frontend/components.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ declare module 'vue' {
1313
FilePickerField: typeof import('./src/components/FilePickerField.vue')['default']
1414
RouterLink: typeof import('vue-router')['RouterLink']
1515
RouterView: typeof import('vue-router')['RouterView']
16+
SettingsModal: typeof import('./src/components/SettingsModal.vue')['default']
1617
TerminalOutput: typeof import('./src/components/TerminalOutput.vue')['default']
1718
}
1819
}

admin/frontend/src/components/AppLayout.vue

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
<script setup>
2-
import { computed } from 'vue'
2+
import { computed, ref } from 'vue'
33
import { RouterView, useRoute } from 'vue-router'
44
import { Breadcrumbs } from 'frappe-ui'
55
import AppSidebar from './AppSidebar.vue'
6+
import SettingsModal from './SettingsModal.vue'
67
78
const emit = defineEmits(['logout'])
89
910
const route = useRoute()
11+
const showSettings = ref(false)
1012
1113
const breadcrumbs = computed(() => {
1214
const { path, params } = route
@@ -35,14 +37,13 @@ const breadcrumbs = computed(() => {
3537
{ label: 'Binary Logs', route: '/database/binlogs' },
3638
{ label: String(params.name) },
3739
]
38-
if (path === '/settings') return [{ label: 'Settings' }]
3940
return [{ label: '' }]
4041
})
4142
</script>
4243

4344
<template>
4445
<div class="flex h-screen overflow-hidden">
45-
<AppSidebar @logout="$emit('logout')" />
46+
<AppSidebar @logout="$emit('logout')" @open-settings="showSettings = true" />
4647
<main class="flex-1 overflow-auto bg-surface-white">
4748
<header class="sticky top-0 z-[10] flex items-center border-b bg-surface-white px-5 py-2.5">
4849
<Breadcrumbs :items="breadcrumbs" />
@@ -52,5 +53,6 @@ const breadcrumbs = computed(() => {
5253
<RouterView />
5354
</div>
5455
</main>
56+
<SettingsModal v-model="showSettings" />
5557
</div>
5658
</template>

admin/frontend/src/components/AppSidebar.vue

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,17 @@ import LucideLogOut from '~icons/lucide/log-out'
1313
import LucidePackage2 from '~icons/lucide/package-2'
1414
import LucideSettings from '~icons/lucide/settings'
1515
16-
const emit = defineEmits(['logout'])
16+
const emit = defineEmits(['logout', 'open-settings'])
1717
1818
const route = useRoute()
1919
2020
const header = {
2121
title: 'Bench',
2222
logo: '/logos/frappe-icon.png',
23+
menuItems: [
24+
{ label: 'Settings', icon: LucideSettings, onClick: () => emit('open-settings') },
25+
{ label: 'Logout', icon: LucideLogOut, onClick: () => logout() },
26+
],
2327
}
2428
2529
const baseNavItems = [
@@ -30,7 +34,6 @@ const baseNavItems = [
3034
{ label: 'Logs', to: '/logs', icon: LucideFileText },
3135
{ label: 'Database', to: '/database', icon: LucideDatabase },
3236
{ label: 'Tasks', to: '/tasks', icon: LucideListTodo },
33-
{ label: 'Settings', to: '/settings', icon: LucideSettings },
3437
]
3538
3639
const snapshotsEnabled = ref(false)
@@ -94,8 +97,6 @@ onUnmounted(() => clearInterval(pollTimer))
9497
</template>
9598
</SidebarItem>
9699
</template>
97-
<template #footer-items>
98-
<SidebarItem label="Logout" :icon="LucideLogOut" @click="logout" />
99-
</template>
100+
<template #footer-items />
100101
</Sidebar>
101102
</template>

0 commit comments

Comments
 (0)