feat: the workspace at /workspace; Настройки show the assistant's model as set, key included, and PostgreSQL's fields only when chosen, its two buttons on one line; pages come in softly

This commit is contained in:
redtea committed 2026-10-16 09:30:19 +03:00
1 parent 352b016384
commit 6db78399ac
10 files changed
+45 -21

No files matched your search

+2 -6
View File
@@ -518,12 +518,8 @@ func (s *Server) handleSetAI(w http.ResponseWriter, r *http.Request) {
_ = s.db.SetConfig(cfgAIType, typ)
_ = s.db.SetConfig(cfgAIURL, strings.TrimSpace(r.FormValue("ai_url")))
_ = s.db.SetConfig(cfgAIModel, strings.TrimSpace(r.FormValue("ai_model")))
if k := strings.TrimSpace(r.FormValue("ai_key")); k != "" {
_ = s.db.SetConfig(cfgAIKey, k)
}
if r.FormValue("ai_key_clear") == "1" {
_ = s.db.SetConfig(cfgAIKey, "")
}
// The field shows the key there; what it holds is the key (empty: none).
_ = s.db.SetConfig(cfgAIKey, strings.TrimSpace(r.FormValue("ai_key")))
http.Redirect(w, r, "/settings#ai", http.StatusSeeOther)
}
+11 -1
View File
@@ -57,7 +57,7 @@ func TestFragmentHasNoChrome(t *testing.T) {
func TestShellRoutes(t *testing.T) {
s, cookie := newTestServer(t)
for _, p := range []string{"/", "/terminal", "/settings", "/agents/x1/files", "/agents/x1/terminal"} {
for _, p := range []string{"/", "/workspace", "/terminal", "/settings", "/agents/x1/files", "/agents/x1/terminal"} {
if w := get(t, s, cookie, p); !strings.Contains(w.Body.String(), `id="app"`) {
t.Errorf("%s: not the shell (code %d)", p, w.Code)
}
@@ -105,3 +105,13 @@ func TestRenameStaysOnEdit(t *testing.T) {
t.Fatalf("rename: %d to %q", w.Code, loc)
}
}
// Настройки show the assistant's model as it is set, key included.
func TestSettingsShowAI(t *testing.T) {
s, c := newTestServer(t)
_ = s.db.SetConfig(cfgAIKey, "key-123")
body := get(t, s, c, "/settings?frag=1").Body.String()
if !strings.Contains(body, `value="key-123"`) || !strings.Contains(body, `value="`+DefaultAIURL+`"`) {
t.Fatal("the AI card is empty")
}
}
+1 -1
View File
@@ -208,7 +208,6 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
"IsAdmin": s.currentAdmin(r).IsAdmin(),
"CSRF": csrfToken(r),
"ServerVersion": Version,
"AI": s.aiConfig(),
"Outdated": a.Version != "" && a.Version != Version,
"Updated": r.URL.Query().Get("updated") == "1",
"UpdateMsg": r.URL.Query().Get("umsg"),
@@ -444,6 +443,7 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
"InstallCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret),
"InstallWget": fmt.Sprintf("wget -qO- %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret),
"ServerVersion": Version,
"AI": s.aiConfig(),
"DBNow": dbNow,
"DBChosen": dbChosen,
"UfwInstalled": ufwInstalled,
+2 -1
View File
@@ -227,7 +227,8 @@ func (s *Server) Handler() http.Handler {
// Panel (admin-only).
mux.Handle("GET /{$}", s.requireAdmin(s.handleDashboard))
mux.Handle("GET /terminal", s.requireAdmin(s.handleShell))
mux.Handle("GET /workspace", s.requireAdmin(s.handleShell))
mux.Handle("GET /terminal", s.requireAdmin(s.handleShell)) // the old address
mux.Handle("GET /agents/{id}/terminal", s.requireAdmin(s.handleShell))
mux.Handle("GET /agents/{id}/{kind}", s.requireAdmin(s.handleShell)) // files
mux.Handle("GET /agents/{id}/forwards", s.requireAdmin(s.handleForwardsPage))
+1 -1
View File
@@ -225,7 +225,7 @@
if (act && act.term && !act.ssh && act.agent.id === m.id) return act;
let t = ws.panes().find(p => p.term && !p.ssh && p.agent.id === m.id);
if (!t) {
if (document.querySelector('main#app').dataset.view !== 'terminal') App.go('/terminal');
if (document.querySelector('main#app').dataset.view !== 'terminal') App.go('/workspace');
ws.open('term', {id: m.id, name: m.name, host: m.hostname, user: m.user});
t = ws.panes().find(p => p.term && !p.ssh && p.agent.id === m.id);
for (let i = 0; i < 40 && t && t.state !== 'ok'; i++) await sleep(250);
+14
View File
@@ -904,3 +904,17 @@ body.ai-dragging .ai-fab, body.ai-dragging .ai-box { transition:none; }
.ai-form-in textarea { min-height:40px; }
.ai-send { min-height:40px; max-height:40px; min-width:40px; max-width:40px; line-height:1; border-radius:10px; }
.ai-form-in textarea { border-radius:10px; }
/* Настройки: the key with its eye; PostgreSQL's fields only when chosen. */
.ai-form .keybox { display:flex; gap:6px; align-items:center; }
.ai-form .keybox input { flex:1; }
.ai-form .keybox .btn.sq { width:38px; height:38px; flex:none; }
.db-form:not(.pg) .pg-fields { display:none; }
.db-form .pg-fields { display:grid; gap:10px; }
.db-form .frow2 { display:flex; gap:10px; }
.db-form .frow2 label:first-child { flex:1; }
.db-form .frow2 .port { width:120px; }
.db-actions { margin-top:12px; }
.db-actions form { margin:0; }
/* Moving between Машины, WorkSpace and Настройки: the page comes in softly. */
main#app > .view:not([hidden]) { animation:view-in .22s ease both; }
@keyframes view-in { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:none; } }
+3 -1
View File
@@ -7,7 +7,7 @@
// unmounted - its timers, requests and listeners dropped - when it goes.
(function () {
'use strict';
const views = {'/': 'machines', '/terminal': 'terminal', '/settings': 'settings'};
const views = {'/': 'machines', '/workspace': 'terminal', '/terminal': 'terminal', '/settings': 'settings'};
const modules = {};
const mounted = new Map(); // area -> {mod, scope}
const $ = s => document.querySelector(s);
@@ -120,6 +120,8 @@
// start mounts what the server drew, once every script has registered.
function start() {
// The old address of the workspace shows as the new one.
if (location.pathname === '/terminal') { history.replaceState({}, '', '/workspace' + location.search); shown = '/workspace' + location.search; }
document.querySelectorAll('main#app > .view').forEach(mount);
show({dashboard: 'machines', settings: 'settings'}[$('main#app').dataset.view] || 'terminal');
// /agents/{id}/terminal or /files: that pane, at once.
+1 -1
View File
@@ -754,7 +754,7 @@
// open opens a pane of kind on agent in a new tab and shows the view.
function open(kind, agent, opts) {
const t = openPane(kind, agent, opts);
if (location.pathname !== '/terminal' && window.App) App.go('/terminal');
if (location.pathname !== '/workspace' && window.App) App.go('/workspace');
return t;
}
const in_ = (el, x, y) => { const r = el.getBoundingClientRect(); return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom; };
+1 -1
View File
@@ -9,7 +9,7 @@
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · <span id="brandtitle">Удалённый доступ</span></div>
<nav>
<a href="/" data-nav="machines">Машины</a>
<a href="/terminal" data-nav="terminal">WorkSpace</a>
<a href="/workspace" data-nav="terminal">WorkSpace</a>
{{if .IsAdmin}}<a href="/settings" data-nav="settings">Настройки</a>{{end}}
<span id="status" class="muted"></span>
<button type="button" class="btn ghost icon acct-btn" id="acct" title="Аккаунт" aria-label="Аккаунт"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg></button>
+9 -8
View File
@@ -121,7 +121,7 @@
<p class="muted">Сейчас: <b>{{if eq .DBNow "postgres"}}PostgreSQL{{else}}SQLite{{end}}</b>.
{{if ne .DBNow .DBChosen.Type}}<span class="err" style="display:inline">Выбрана {{if eq .DBChosen.Type "postgres"}}PostgreSQL{{else}}SQLite{{end}} - перезапустите сервер, чтобы перейти.</span>{{end}}
При первом запуске на пустой PostgreSQL всё копируется из SQLite-файла (он остаётся как есть).</p>
<form method="post" action="/settings/db" class="ai-form db-form{{if eq .DBChosen.Type "postgres"}} pg{{end}}">
<form method="post" action="/settings/db" id="dbform" class="ai-form db-form{{if eq .DBChosen.Type "postgres"}} pg{{end}}">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Хранить в<select name="db_type" onchange="this.form.classList.toggle('pg', this.value === 'postgres')">
<option value="sqlite"{{if ne .DBChosen.Type "postgres"}} selected{{end}}>SQLite (файл рядом с сервером)</option>
@@ -141,19 +141,20 @@
<option value="verify-full"{{if eq $m "verify-full"}} selected{{end}}>verify-full</option>
</select></label>
</div>
<button type="submit">Проверить и сохранить</button>
</form>
<form method="post" action="/settings/db/restart" data-confirm="Перезапустить сервер? Терминалы и передачи прервутся, страница переподключится сама." data-danger style="margin-top:10px">
<div class="row db-actions">
<button type="submit" form="dbform">Проверить и сохранить</button>
<form method="post" action="/settings/db/restart" data-confirm="Перезапустить сервер? Терминалы и передачи прервутся, страница переподключится сама." data-danger>
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button type="submit" class="btn ghost">Перезапустить сервер</button>
</form>
</div>
</div>
<div class="card" id="ai">
<h2>ИИ-ассистент</h2>
<p class="muted">Чат в правом нижнем углу панели: видит вкладки и активный терминал, предлагает команды, которые
пользователь выполняет одним нажатием. Каждый включает его себе сам в профиле. Ключ хранится на сервере
и в браузер не уходит.</p>
пользователь выполняет одним нажатием. Каждый включает его себе сам в профиле. Ключ хранится на сервере: его видят только администраторы здесь, в чат он не попадает.</p>
<form method="post" action="/settings/ai" class="ai-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Тип API<select name="ai_type">
@@ -163,9 +164,9 @@
</select></label>
<label>Адрес<input type="url" name="ai_url" value="{{.AI.URL}}" placeholder="https://api.openai.com/v1"></label>
<label>Модель<input type="text" name="ai_model" value="{{.AI.Model}}" placeholder="для OpenAI и Anthropic"></label>
<label>Ключ<input type="password" name="ai_key" placeholder="{{if .AI.Key}}задан - пусто, чтобы оставить{{else}}не задан{{end}}" autocomplete="off"></label>
{{if .AI.Key}}<label class="row" style="gap:8px"><input type="checkbox" name="ai_key_clear" value="1" style="width:auto"> убрать ключ</label>{{end}}
<button type="submit">Сохранить</button>
<label>Ключ<span class="keybox"><input type="password" name="ai_key" value="{{.AI.Key}}" placeholder="не задан" autocomplete="off" spellcheck="false">
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
<div><button type="submit">Сохранить</button></div>
</form>
</div>