fix: agent update runs as root when it can (sudo without a password otherwise), checks the download, restarts reliably and reports its result; one-colour file icons; zip and delete on one line on phones; PIN buttons alike; no key fingerprint in the SSH status; corner plus clears split panes

This commit is contained in:
redtea committed 2026-10-14 23:54:24 +03:00
1 parent 467bd3cb4f
commit 5eca9ca215
8 files changed
+184 -40

No files matched your search

+20 -7
View File
@@ -211,6 +211,8 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
"ServerVersion": Version,
"Outdated": a.Version != "" && a.Version != Version,
"Updated": r.URL.Query().Get("updated") == "1",
"UpdateMsg": r.URL.Query().Get("umsg"),
"UpdateErr": r.URL.Query().Get("uperr"),
"UserOK": r.URL.Query().Get("uok"),
"UserErr": r.URL.Query().Get("uerr"),
"Transport": firstNonEmpty(a.Transport, proto.TransportAuto),
@@ -366,17 +368,28 @@ func (s *Server) handleAgentUpdate(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
back := func(q url.Values) { http.Redirect(w, r, "/agents/"+id+"/edit?"+q.Encode(), http.StatusSeeOther) }
if !s.online(a) {
http.Redirect(w, r, "/agents/"+id+"/edit?updated=0", http.StatusSeeOther)
back(url.Values{"uperr": {"машина не в сети"}})
return
}
taskID, ch := s.hub.CreateTask(id, proto.TaskSysUpdate, nil)
defer s.hub.DiscardTask(taskID)
select {
case <-ch:
case <-time.After(30 * time.Second):
if admin := s.currentAdmin(r); admin != nil {
s.db.Audit(admin.ID, id, "update:"+a.Version+" -> "+Version)
}
// The download can take a while on a slow link; under a proxy's timeout.
res, err := s.waitTask(r.Context(), id, proto.TaskSysUpdate, nil, 80*time.Second)
switch {
case err != nil:
back(url.Values{"uperr": {"агент не ответил: " + err.Error()}})
case !res.OK:
back(url.Values{"uperr": {res.Error}})
default:
var d struct {
Status string `json:"status"`
}
_ = json.Unmarshal(res.Data, &d)
back(url.Values{"updated": {"1"}, "umsg": {d.Status}})
}
http.Redirect(w, r, "/agents/"+id+"/edit?updated=1", http.StatusSeeOther)
}
func (s *Server) handleAgentRename(w http.ResponseWriter, r *http.Request) {
+1 -1
View File
@@ -53,7 +53,7 @@
' <input type="password" name="current" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="текущий PIN" autocomplete="off">' +
' <input type="password" name="pin" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="новый PIN, 4 цифры" autocomplete="off">' +
' <input type="password" name="again" inputmode="numeric" maxlength="4" pattern="[0-9]{4}" placeholder="новый ещё раз" autocomplete="off">' +
' <div class="row"><button type="button" class="btn danger small acct-pindel">Убрать PIN</button><button type="submit" class="acct-pinsave">Сохранить</button></div>' +
' <div class="row"><button type="button" class="btn danger acct-pindel">Убрать PIN</button><button type="submit" class="acct-pinsave">Сохранить</button></div>' +
' </form>' +
' <label class="acct-pingrace"><span>На этом устройстве не спрашивать после ввода</span><select>' +
' <option value="0">каждый раз</option><option value="5">5 минут</option><option value="15">15 минут</option>' +
+6
View File
@@ -287,6 +287,7 @@
t.el.querySelector('.dot').className = 'dot ' + (st.includes('err') ? 'err' : st.includes('wait') ? 'wait' : st.includes('ok') ? 'ok' : 'off');
t.el.classList.toggle('on', t === active);
t.view.classList.toggle('split', t.panes.length > 1);
syncFloat();
}
function activate(t) {
if (!t) return;
@@ -310,8 +311,13 @@
statusEl.title = activePane.text;
statusEl.className = activePane.state === 'ok' ? 'st-ok' : activePane.state === 'err' ? 'st-err' : 'muted';
}
// The corner "+" moves down out of the way of a split tab's title bars.
function syncFloat() {
document.body.classList.toggle('split1', tabs.length === 1 && tabs[0].panes.length > 1);
}
// One tab: a plain terminal, the strip folded into the corner "+".
function updateMode() {
syncFloat();
const multi = tabs.length > 1;
if (document.body.classList.contains('multi') === multi) return;
document.body.classList.toggle('multi', multi);
+2 -1
View File
@@ -94,7 +94,8 @@
<div class="card">
<h2>Версия клиента</h2>
{{if .Updated}}<div class="err" style="background:#1d3a24;border-color:#2b6b3a;color:#b3ffcb">Команда обновления отправлена. Клиент скачает свежий бинарник и перезапустится.</div>{{end}}
{{if .Updated}}<div class="err" style="background:#1d3a24;border-color:#2b6b3a;color:#b3ffcb">Клиент {{if .UpdateMsg}}{{.UpdateMsg}}{{else}}обновлён и перезапускается{{end}}. Через несколько секунд он вернётся с новой версией.</div>{{end}}
{{if .UpdateErr}}<div class="err">Обновление не вышло: {{.UpdateErr}}</div>{{end}}
<p class="muted">
Клиент: <code>{{if .Agent.Version}}{{.Agent.Version}}{{else}}неизвестно{{end}}</code>
· Сервер: <code>{{.ServerVersion}}</code>
+32 -3
View File
@@ -16,6 +16,7 @@
}
/* A button with an icon before its text. */
.fm-bar .btn.ico { gap:6px; }
.fm-bar .spacer { flex:1; }
input.fm-path { font-family:ui-monospace,Menlo,monospace; font-size:13px; color:var(--muted);
background:#0c0f13; border:1px solid var(--border); border-radius:8px; padding:8px 12px;
flex:1; min-width:200px; height:34px; box-sizing:border-box; }
@@ -58,7 +59,9 @@
table.fm td, table.fm th { padding:7px 8px; }
.fm .nm { cursor:pointer; }
.fm .nm:hover { color:var(--accent); }
.ic { display:inline-block; width:18px; text-align:center; margin-right:6px; }
.ic { display:inline-flex; vertical-align:-3px; width:18px; justify-content:center; margin-right:6px; color:var(--muted); }
.ic.dir { color:#7aa7d9; }
.ic.dir svg { fill:rgba(122,167,217,.16); }
.sz { color:var(--muted); font-variant-numeric:tabular-nums; }
.act { display:flex; gap:6px; justify-content:flex-end; }
/* Sortable headers: a click sorts by the column, a second one turns it round. */
@@ -76,6 +79,8 @@
table.fm td, table.fm th { padding:7px 4px; }
.ic { margin-right:4px; }
.fm-bar .fm-pathbox { flex:1 1 100%; order:-1; }
/* The selection's buttons go together on a line of their own. */
.fm-bar .spacer { flex:1 1 100%; height:0; }
}
dialog { background:var(--panel); color:var(--text); border:1px solid var(--border);
border-radius:12px; padding:0; max-width:900px; width:92vw; }
@@ -304,7 +309,7 @@
<button class="btn small ico" id="mkdir"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/><path d="M12 11v6M9 14h6"/></svg>Папка</button>
<button class="btn small ico" id="newfile"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M12 12v6M9 15h6"/></svg>Файл</button>
<label class="btn small ico" style="cursor:pointer"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12M6 10l6 6 6-6M5 21h14"/></svg>Загрузить<input type="file" id="upl" multiple hidden></label>
<span class="spacer" style="flex:1"></span>
<span class="spacer"></span>
<span id="selinfo" class="muted" style="font-size:13px"></span>
<button class="btn ghost small" id="zipsel" disabled>В zip</button>
<button class="btn danger small" id="delsel" disabled>Удалить выбранное</button>
@@ -482,6 +487,30 @@
const ICON_MV = SVG + '<polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg>';
const ICON_UNZIP = SVG + '<rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h2"/><path d="M20 8v11a2 2 0 0 1-2 2h-2"/><path d="m9 15 3-3 3 3"/><path d="M12 12v9"/></svg>';
const ICON_DEL = SVG + '<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
// File icons: one colour, the line style of the buttons; a few kinds by
// the name's ending.
const FI = '<svg class="fi" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">';
const FICONS = {
dir: FI + '<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
file: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>',
text: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></svg>',
code: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M10 12l-2 2.5 2 2.5M14 12l2 2.5-2 2.5"/></svg>',
img: FI + '<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="1.6"/><path d="m21 16-5-5-8 9"/></svg>',
arc: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M11 4v2M11 8v2M11 12v2"/><rect x="9.5" y="15" width="3" height="3" rx=".6"/></svg>',
media:FI + '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m10 9 5 3-5 3z"/></svg>',
link: FI + '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></svg>'
};
function fileIcon(e) {
let k = 'file';
if (e.is_dir) k = 'dir';
else if (/^L/.test(e.mode || '')) k = 'link';
else if (/\.(png|jpe?g|gif|webp|bmp|ico|svg|avif|heic)$/i.test(e.name)) k = 'img';
else if (/\.(zip|tar|gz|tgz|bz2|xz|zst|7z|rar|deb|rpm)$/i.test(e.name)) k = 'arc';
else if (/\.(mp4|m4v|webm|mov|mkv|avi|mp3|wav|ogg|flac|m4a|aac|opus)$/i.test(e.name)) k = 'media';
else if (/\.(js|ts|go|py|rb|rs|c|h|cpp|java|kt|php|sh|bash|zsh|json|ya?ml|toml|html?|css|sql|lua|pl|swift|vue|jsx|tsx|ini|conf|xml)$/i.test(e.name)) k = 'code';
else if (/\.(txt|md|log|csv|rst|cfg|env)$|^(readme|license|makefile|dockerfile)/i.test(e.name)) k = 'text';
return '<span class="ic' + (k === 'dir' ? ' dir' : '') + '">' + FICONS[k] + '</span>';
}
let cwd = '/';
let entries = [];
const selected = new Set();
@@ -564,7 +593,7 @@
const dlHref = '/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/download?path=' + encodeURIComponent(join(cwd, e.name));
tr.innerHTML =
'<td><input type="checkbox" class="ck" data-n="'+encodeURIComponent(e.name)+'"></td>' +
'<td class="nm"><span class="ic">'+(e.is_dir?'📁':'📄')+'</span>'+esc(e.name)+'</td>' +
'<td class="nm">'+fileIcon(e)+esc(e.name)+'</td>' +
'<td class="sz c-sz">'+(e.is_dir?'':fmtSize(e.size))+'</td>' +
'<td class="sz mt">'+fmtTime(e.mtime)+'</td>' +
'<td><div class="act">' +
+3 -1
View File
@@ -82,7 +82,9 @@
body.multi .area { margin-top:0; border-top-left-radius:0; }
.float-add { position:absolute; top:8px; right:10px; z-index:6; width:28px; height:28px; padding:0; display:inline-flex;
align-items:center; justify-content:center; font-size:17px; line-height:1; color:var(--text);
background:transparent; border:1px solid var(--border); opacity:.28; transition:opacity .25s ease, background .25s ease; }
background:transparent; border:1px solid var(--border); opacity:.28; transition:opacity .25s ease, background .25s ease, top .25s ease; }
/* One tab split in panes: below the panes' title bars, clear of their close buttons. */
body.split1 .float-add { top:42px; }
.float-add:hover, .float-add:focus-visible { opacity:1; background:var(--panel); }
body.multi .float-add { opacity:0; pointer-events:none; }
.tabview { position:absolute; inset:8px; opacity:0; visibility:hidden; transform:translateY(4px) scale(.995);