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:
1 parent
467bd3cb4f
commit
5eca9ca215
8 files changed
+181
-37
No files matched your search
@@ -244,7 +244,7 @@ func (a *Agent) runSSH(parent context.Context, sid string, t proto.SSHTarget) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
log.Printf("ssh %s: %s@%s up (host key %s)", sid, t.User, addr, fp)
|
log.Printf("ssh %s: %s@%s up (host key %s)", sid, t.User, addr, fp)
|
||||||
status(proto.SSHOK, t.User+"@"+addr+" · "+fp)
|
status(proto.SSHOK, t.User+"@"+addr)
|
||||||
a.bridgeStream(parent, sid, sh, sh.Resize)
|
a.bridgeStream(parent, sid, sh, sh.Resize)
|
||||||
log.Printf("ssh %s: ended", sid)
|
log.Printf("ssh %s: ended", sid)
|
||||||
}
|
}
|
||||||
+116
-23
@@ -3,31 +3,40 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// doUpdate downloads the latest agent binary for this platform, atomically
|
// doUpdate downloads the latest agent binary for this platform, replaces the
|
||||||
// replaces the running executable, and schedules a service restart. It never
|
// running executable and restarts the service. It runs as root when it can:
|
||||||
// uninstalls - only updates.
|
// the agent's own user if that is root, otherwise sudo when it needs no
|
||||||
|
// password. It never uninstalls - only updates.
|
||||||
func (a *Agent) doUpdate() proto.TaskResult {
|
func (a *Agent) doUpdate() proto.TaskResult {
|
||||||
exe, err := os.Executable()
|
exe, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return proto.TaskResult{Error: "не удалось определить путь бинарника: " + err.Error()}
|
return proto.TaskResult{Error: "не удалось определить путь бинарника: " + err.Error()}
|
||||||
}
|
}
|
||||||
exe, _ = filepath.EvalSymlinks(exe)
|
exe, _ = filepath.EvalSymlinks(exe)
|
||||||
|
sudo := ""
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
sudo = sudoPath()
|
||||||
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/download/agent-%s-%s", a.cfg.Host, runtime.GOOS, runtime.GOARCH)
|
url := fmt.Sprintf("%s/download/agent-%s-%s", a.cfg.Host, runtime.GOOS, runtime.GOARCH)
|
||||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||||
resp, err := a.http.Do(req)
|
resp, err := a.stream.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return proto.TaskResult{Error: "скачивание: " + err.Error()}
|
return proto.TaskResult{Error: "скачивание: " + err.Error()}
|
||||||
}
|
}
|
||||||
@@ -36,41 +45,125 @@ func (a *Agent) doUpdate() proto.TaskResult {
|
|||||||
return proto.TaskResult{Error: "скачивание: сервер вернул " + resp.Status}
|
return proto.TaskResult{Error: "скачивание: сервер вернул " + resp.Status}
|
||||||
}
|
}
|
||||||
|
|
||||||
tmp := exe + ".new"
|
// The new binary lands next to the old one, or, when root's hand is
|
||||||
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
|
// needed to put it there, in a temporary folder first.
|
||||||
if err != nil {
|
dir := filepath.Dir(exe)
|
||||||
return proto.TaskResult{Error: "создание файла: " + err.Error()}
|
if sudo != "" {
|
||||||
|
dir = ""
|
||||||
}
|
}
|
||||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
f, err := os.CreateTemp(dir, ".lpagent-new-*")
|
||||||
f.Close()
|
if err != nil {
|
||||||
os.Remove(tmp)
|
return proto.TaskResult{Error: "создание файла: " + err.Error() + userNote()}
|
||||||
|
}
|
||||||
|
tmp := f.Name()
|
||||||
|
defer os.Remove(tmp)
|
||||||
|
n, err := io.Copy(f, io.LimitReader(resp.Body, 256<<20))
|
||||||
|
if cerr := f.Close(); err == nil {
|
||||||
|
err = cerr
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
return proto.TaskResult{Error: "запись: " + err.Error()}
|
return proto.TaskResult{Error: "запись: " + err.Error()}
|
||||||
}
|
}
|
||||||
f.Close()
|
if err := checkBinary(tmp, n); err != nil {
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
if err := os.Chmod(tmp, 0o755); err != nil {
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
// Atomic replace: renaming over a running executable is allowed on Linux;
|
how := "от " + currentUser()
|
||||||
// the live process keeps the old inode until it restarts.
|
if sudo != "" {
|
||||||
if err := os.Rename(tmp, exe); err != nil {
|
// install copies it in place under a new inode, as a rename would.
|
||||||
os.Remove(tmp)
|
if out, err := exec.Command(sudo, "-n", "install", "-m", "0755", tmp, exe).CombinedOutput(); err != nil {
|
||||||
return proto.TaskResult{Error: "замена бинарника: " + err.Error()}
|
return proto.TaskResult{Error: "замена бинарника через sudo: " + strings.TrimSpace(string(out)+" "+err.Error())}
|
||||||
|
}
|
||||||
|
how = "через sudo"
|
||||||
|
} else if err := os.Rename(tmp, exe); err != nil {
|
||||||
|
// Renaming over a running executable is allowed; the live process
|
||||||
|
// keeps the old inode until it restarts.
|
||||||
|
return proto.TaskResult{Error: "замена бинарника: " + err.Error() + userNote()}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restart shortly, after the result has been reported.
|
// Restart shortly, after the result has been reported.
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(1500 * time.Millisecond)
|
time.Sleep(1500 * time.Millisecond)
|
||||||
restartSelf()
|
restartSelf(exe, sudo)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
data, _ := json.Marshal(map[string]string{"status": "обновлено, перезапуск"})
|
data, _ := json.Marshal(map[string]string{"status": "обновлено " + how + ", перезапуск"})
|
||||||
return proto.TaskResult{OK: true, Data: data}
|
return proto.TaskResult{OK: true, Data: data}
|
||||||
}
|
}
|
||||||
|
|
||||||
// restartSelf restarts the agent so the new binary takes effect.
|
// sudoPath is sudo when it lets this user run commands without a password.
|
||||||
func restartSelf() {
|
func sudoPath() string {
|
||||||
|
p, err := exec.LookPath("sudo")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if exec.Command(p, "-n", "true").Run() != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func userNote() string {
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return " (агент запущен от " + currentUser() + ", без root и sudo без пароля - обновите установщиком)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkBinary makes sure what came is a program for this system, not an
|
||||||
|
// error page: a binary of a sensible size with the right header.
|
||||||
|
func checkBinary(path string, size int64) error {
|
||||||
|
if size < 1<<20 {
|
||||||
|
return fmt.Errorf("скачанный файл слишком мал (%d байт) - не похоже на агента", size)
|
||||||
|
}
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
var magic [4]byte
|
||||||
|
if _, err := io.ReadFull(f, magic[:]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ok := false
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
ok = string(magic[:]) == "\x7fELF"
|
||||||
|
case "darwin":
|
||||||
|
ok = magic == [4]byte{0xcf, 0xfa, 0xed, 0xfe} || magic == [4]byte{0xca, 0xfe, 0xba, 0xbe}
|
||||||
|
default:
|
||||||
|
ok = true
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return errors.New("скачанный файл - не программа для " + runtime.GOOS)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// restartSelf starts the new binary. Under systemd the service is restarted
|
||||||
|
// (queued, so it goes on after this process is stopped); otherwise the
|
||||||
|
// process replaces itself with the new binary.
|
||||||
|
func restartSelf(exe, sudo string) {
|
||||||
|
if os.Getenv("INVOCATION_ID") != "" {
|
||||||
if path, err := exec.LookPath("systemctl"); err == nil {
|
if path, err := exec.LookPath("systemctl"); err == nil {
|
||||||
_ = exec.Command(path, "restart", "lpagent").Start()
|
args := []string{"--no-block", "restart", "lpagent"}
|
||||||
|
cmd := exec.Command(path, args...)
|
||||||
|
if sudo != "" {
|
||||||
|
cmd = exec.Command(sudo, append([]string{"-n", path}, args...)...)
|
||||||
|
}
|
||||||
|
if cmd.Run() == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// No systemd (e.g. launchd/manual run): exit so the supervisor restarts us.
|
}
|
||||||
|
// systemd brings a service that stopped back up (Restart=always).
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
CloseTerminals()
|
||||||
|
if err := syscall.Exec(exe, os.Args, os.Environ()); err != nil {
|
||||||
|
log.Printf("update: restart: %v", err)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -211,6 +211,8 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
|
|||||||
"ServerVersion": Version,
|
"ServerVersion": Version,
|
||||||
"Outdated": a.Version != "" && a.Version != Version,
|
"Outdated": a.Version != "" && a.Version != Version,
|
||||||
"Updated": r.URL.Query().Get("updated") == "1",
|
"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"),
|
"UserOK": r.URL.Query().Get("uok"),
|
||||||
"UserErr": r.URL.Query().Get("uerr"),
|
"UserErr": r.URL.Query().Get("uerr"),
|
||||||
"Transport": firstNonEmpty(a.Transport, proto.TransportAuto),
|
"Transport": firstNonEmpty(a.Transport, proto.TransportAuto),
|
||||||
@@ -366,17 +368,28 @@ func (s *Server) handleAgentUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
back := func(q url.Values) { http.Redirect(w, r, "/agents/"+id+"/edit?"+q.Encode(), http.StatusSeeOther) }
|
||||||
if !s.online(a) {
|
if !s.online(a) {
|
||||||
http.Redirect(w, r, "/agents/"+id+"/edit?updated=0", http.StatusSeeOther)
|
back(url.Values{"uperr": {"машина не в сети"}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
taskID, ch := s.hub.CreateTask(id, proto.TaskSysUpdate, nil)
|
if admin := s.currentAdmin(r); admin != nil {
|
||||||
defer s.hub.DiscardTask(taskID)
|
s.db.Audit(admin.ID, id, "update:"+a.Version+" -> "+Version)
|
||||||
select {
|
}
|
||||||
case <-ch:
|
// The download can take a while on a slow link; under a proxy's timeout.
|
||||||
case <-time.After(30 * time.Second):
|
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) {
|
func (s *Server) handleAgentRename(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -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="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="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">' +
|
' <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>' +
|
' </form>' +
|
||||||
' <label class="acct-pingrace"><span>На этом устройстве не спрашивать после ввода</span><select>' +
|
' <label class="acct-pingrace"><span>На этом устройстве не спрашивать после ввода</span><select>' +
|
||||||
' <option value="0">каждый раз</option><option value="5">5 минут</option><option value="15">15 минут</option>' +
|
' <option value="0">каждый раз</option><option value="5">5 минут</option><option value="15">15 минут</option>' +
|
||||||
|
|||||||
@@ -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.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.el.classList.toggle('on', t === active);
|
||||||
t.view.classList.toggle('split', t.panes.length > 1);
|
t.view.classList.toggle('split', t.panes.length > 1);
|
||||||
|
syncFloat();
|
||||||
}
|
}
|
||||||
function activate(t) {
|
function activate(t) {
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
@@ -310,8 +311,13 @@
|
|||||||
statusEl.title = activePane.text;
|
statusEl.title = activePane.text;
|
||||||
statusEl.className = activePane.state === 'ok' ? 'st-ok' : activePane.state === 'err' ? 'st-err' : 'muted';
|
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 "+".
|
// One tab: a plain terminal, the strip folded into the corner "+".
|
||||||
function updateMode() {
|
function updateMode() {
|
||||||
|
syncFloat();
|
||||||
const multi = tabs.length > 1;
|
const multi = tabs.length > 1;
|
||||||
if (document.body.classList.contains('multi') === multi) return;
|
if (document.body.classList.contains('multi') === multi) return;
|
||||||
document.body.classList.toggle('multi', multi);
|
document.body.classList.toggle('multi', multi);
|
||||||
|
|||||||
@@ -94,7 +94,8 @@
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Версия клиента</h2>
|
<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">
|
<p class="muted">
|
||||||
Клиент: <code>{{if .Agent.Version}}{{.Agent.Version}}{{else}}неизвестно{{end}}</code>
|
Клиент: <code>{{if .Agent.Version}}{{.Agent.Version}}{{else}}неизвестно{{end}}</code>
|
||||||
· Сервер: <code>{{.ServerVersion}}</code>
|
· Сервер: <code>{{.ServerVersion}}</code>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
}
|
}
|
||||||
/* A button with an icon before its text. */
|
/* A button with an icon before its text. */
|
||||||
.fm-bar .btn.ico { gap:6px; }
|
.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);
|
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;
|
background:#0c0f13; border:1px solid var(--border); border-radius:8px; padding:8px 12px;
|
||||||
flex:1; min-width:200px; height:34px; box-sizing:border-box; }
|
flex:1; min-width:200px; height:34px; box-sizing:border-box; }
|
||||||
@@ -58,7 +59,9 @@
|
|||||||
table.fm td, table.fm th { padding:7px 8px; }
|
table.fm td, table.fm th { padding:7px 8px; }
|
||||||
.fm .nm { cursor:pointer; }
|
.fm .nm { cursor:pointer; }
|
||||||
.fm .nm:hover { color:var(--accent); }
|
.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; }
|
.sz { color:var(--muted); font-variant-numeric:tabular-nums; }
|
||||||
.act { display:flex; gap:6px; justify-content:flex-end; }
|
.act { display:flex; gap:6px; justify-content:flex-end; }
|
||||||
/* Sortable headers: a click sorts by the column, a second one turns it round. */
|
/* 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; }
|
table.fm td, table.fm th { padding:7px 4px; }
|
||||||
.ic { margin-right:4px; }
|
.ic { margin-right:4px; }
|
||||||
.fm-bar .fm-pathbox { flex:1 1 100%; order:-1; }
|
.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);
|
dialog { background:var(--panel); color:var(--text); border:1px solid var(--border);
|
||||||
border-radius:12px; padding:0; max-width:900px; width:92vw; }
|
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="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>
|
<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>
|
<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>
|
<span id="selinfo" class="muted" style="font-size:13px"></span>
|
||||||
<button class="btn ghost small" id="zipsel" disabled>В zip</button>
|
<button class="btn ghost small" id="zipsel" disabled>В zip</button>
|
||||||
<button class="btn danger small" id="delsel" disabled>Удалить выбранное</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_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_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>';
|
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 cwd = '/';
|
||||||
let entries = [];
|
let entries = [];
|
||||||
const selected = new Set();
|
const selected = new Set();
|
||||||
@@ -564,7 +593,7 @@
|
|||||||
const dlHref = '/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/download?path=' + encodeURIComponent(join(cwd, e.name));
|
const dlHref = '/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/download?path=' + encodeURIComponent(join(cwd, e.name));
|
||||||
tr.innerHTML =
|
tr.innerHTML =
|
||||||
'<td><input type="checkbox" class="ck" data-n="'+encodeURIComponent(e.name)+'"></td>' +
|
'<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 c-sz">'+(e.is_dir?'':fmtSize(e.size))+'</td>' +
|
||||||
'<td class="sz mt">'+fmtTime(e.mtime)+'</td>' +
|
'<td class="sz mt">'+fmtTime(e.mtime)+'</td>' +
|
||||||
'<td><div class="act">' +
|
'<td><div class="act">' +
|
||||||
|
|||||||
@@ -82,7 +82,9 @@
|
|||||||
body.multi .area { margin-top:0; border-top-left-radius:0; }
|
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;
|
.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);
|
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); }
|
.float-add:hover, .float-add:focus-visible { opacity:1; background:var(--panel); }
|
||||||
body.multi .float-add { opacity:0; pointer-events:none; }
|
body.multi .float-add { opacity:0; pointer-events:none; }
|
||||||
.tabview { position:absolute; inset:8px; opacity:0; visibility:hidden; transform:translateY(4px) scale(.995);
|
.tabview { position:absolute; inset:8px; opacity:0; visibility:hidden; transform:translateY(4px) scale(.995);
|
||||||
|
|||||||
Reference in new issue
Block a user