diff --git a/internal/server/frag_test.go b/internal/server/frag_test.go index 92e9f33..6c820ed 100644 --- a/internal/server/frag_test.go +++ b/internal/server/frag_test.go @@ -89,3 +89,19 @@ func TestSSHFSOpen(t *testing.T) { t.Fatalf("a user without the machine got %d", w.Code) } } + +// Renaming from the machine dialog keeps it open on «Изменить». +func TestRenameStaysOnEdit(t *testing.T) { + s, c := newTestServer(t) + tok := "tok-test" + var req *http.Request + req = httptest.NewRequest(http.MethodPost, "/agents/x1/rename", strings.NewReader("csrf="+tok+"&name=new")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(c) + req.AddCookie(&http.Cookie{Name: csrfCookie, Value: tok}) + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, req) + if loc := w.Header().Get("Location"); w.Code != http.StatusSeeOther || loc != "/agents/x1/edit" { + t.Fatalf("rename: %d to %q", w.Code, loc) + } +} diff --git a/internal/server/handlers_panel.go b/internal/server/handlers_panel.go index f5a1ec6..b28b51f 100644 --- a/internal/server/handlers_panel.go +++ b/internal/server/handlers_panel.go @@ -402,7 +402,8 @@ func (s *Server) handleAgentRename(w http.ResponseWriter, r *http.Request) { if name != "" { _ = s.db.RenameAgent(id, name) } - http.Redirect(w, r, "/", http.StatusSeeOther) + // Back to «Изменить», with the new name: the machine dialog stays open. + http.Redirect(w, r, "/agents/"+id+"/edit", http.StatusSeeOther) } // --- settings --- diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index 8a089d7..9232277 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -780,3 +780,16 @@ p.mv-a { margin:0; font-size:13px; } body:not(.multi) .fpane.under-plus .fm-bar:first-child { margin-right:40px; } /* The bars' square buttons, as tall as the path box beside them. */ .fm-bar > .btn.sq, .fp-sel .btn.sq { width:34px; height:34px; } + +/* A machine's Порты, Статус, Изменить over the current page. */ +dialog.machinedlg { max-width:1000px; width:94vw; max-height:90vh; padding:0; overflow:hidden; } +/* The dialog's title names the page: the page's own heading goes. */ +.machinedlg .frag-area .wrap > h1:first-child { display:none; } +.machinedlg .frag-area .wrap { padding-top:18px; padding-bottom:18px; } +.machinedlg .frag-area { overflow:auto; max-height:calc(90vh - 58px); } +.mdmenu { min-width:180px; } +.mdmenu .md-head { padding:6px 10px 4px; font-size:12px; } +@media (max-width: 640px) { + dialog.machinedlg { width:100vw; max-width:none; height:100dvh; max-height:none; margin:0; border-radius:0; } + .machinedlg .frag-area { max-height:calc(100dvh - 58px); } +} diff --git a/internal/server/web/static/app.js b/internal/server/web/static/app.js index f560e53..6902afe 100644 --- a/internal/server/web/static/app.js +++ b/internal/server/web/static/app.js @@ -109,6 +109,13 @@ catch (e) { alert('Нет связи с сервером'); return; } if (signedOut(r)) { location.href = '/login'; return; } const to = new URL(r.url); + // From a dialog to a view (a machine deleted): the dialog closes. + if (!area.classList.contains('view') && viewOf(to.pathname)) { + const d = area.closest('dialog'); + if (d) d.close(); + go(to.href); + return; + } if (area.classList.contains('view')) { const v = viewOf(to.pathname); if (v && v !== area.dataset.view) { go(to.href); return; } diff --git a/internal/server/web/static/filepane.js b/internal/server/web/static/filepane.js index deff21b..024bd90 100644 --- a/internal/server/web/static/filepane.js +++ b/internal/server/web/static/filepane.js @@ -270,6 +270,7 @@ const q = s => this.q(s); q('.pane-x').onclick = ev => { ev.stopPropagation(); Workspace.closePane(this); }; q('.pane-head').addEventListener('pointerdown', ev => { if (Workspace.desk && !ev.target.closest('.pane-x')) Workspace.startDrag(ev, 'pane', this); }); + q('.pane-head').addEventListener('contextmenu', ev => { if (window.MachineDialog) MachineDialog.menu(ev, this.agent); }); this.el.addEventListener('pointerdown', () => Workspace.setActivePane(this), true); q('.fp-up').onclick = () => this.go(parent(this.cwd)); q('.fp-refresh').onclick = () => this.reload(); diff --git a/internal/server/web/static/machine.js b/internal/server/web/static/machine.js new file mode 100644 index 0000000..b53f8bd --- /dev/null +++ b/internal/server/web/static/machine.js @@ -0,0 +1,88 @@ +// Порты, Статус and Изменить of a machine in a dialog over the current page: +// the server's pages as fragments, so nothing behind is lost. They open from +// the buttons of a machine's row, and from a menu on a tab's title or a +// pane's title bar (a right click, or a long press on a phone). Closing the +// dialog unmounts the fragment's module: its charts and polling stop. +(function () { + 'use strict'; + const TITLES = {forwards: 'Порты', status: 'Статус', edit: 'Изменить'}; + const dlg = document.getElementById('machinedlg'); + const area = dlg.querySelector('.frag-area'); + const page = /^\/agents\/([^/]+)\/(forwards|status|edit)$/; + let changed = false; + + function open(id, what, name) { + dlg.querySelector('.md-title').textContent = (name || id) + ' · ' + TITLES[what]; + App.put(area, '

загрузка…

'); + if (!dlg.open) (window.FileViews ? FileViews.openModal(dlg) : dlg.showModal()); + App.load(area, '/agents/' + encodeURIComponent(id) + '/' + what); + } + dlg.addEventListener('close', () => { + App.put(area, ''); + // A form sent in the dialog (a new name, say) shows in the list behind. + if (changed && document.querySelector('main#app').dataset.view === 'machines') App.go('/', false); + changed = false; + }); + dlg.querySelector('.md-close').onclick = () => dlg.close(); + // «Изменить» drawn again after a new name: the title follows it. + new MutationObserver(() => { + const h = area.querySelector('[data-module="edit"] .wrap > h1'); + if (h) dlg.querySelector('.md-title').textContent = h.textContent.trim() + ' · ' + TITLES.edit; + }).observe(area, {childList: true}); + dlg.addEventListener('submit', () => { changed = true; }); + // The fragment's own links: another page of a machine stays in the dialog, + // anything else closes it and goes on as usual. + area.addEventListener('click', ev => { + const a = ev.target.closest('a[href]'); + if (!a || ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || a.target || a.hasAttribute('download')) return; + const u = new URL(a.href, location.href), m = page.exec(u.pathname); + if (u.origin !== location.origin) return; + if (m) { ev.preventDefault(); ev.stopPropagation(); App.load(area, u.pathname + u.search); return; } + dlg.close(); + }, true); + document.addEventListener('click', ev => { + const b = ev.target.closest('[data-dialog]'); + if (!b || ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey) return; + ev.preventDefault(); + open(b.dataset.id, b.dataset.dialog, b.dataset.name); + }); + + // menu: Порты, Статус, Изменить of agent, by the pointer. + const menu = document.createElement('div'); + menu.className = 'addmenu mdmenu'; + menu.hidden = true; + document.body.appendChild(menu); + function closeMenu() { menu.hidden = true; document.removeEventListener('pointerdown', outside, true); } + function outside(ev) { if (!menu.contains(ev.target)) closeMenu(); } + function showMenu(ev, agent) { + ev.preventDefault(); + menu.innerHTML = ''; + const head = document.createElement('div'); + head.className = 'muted md-head'; + head.textContent = agent.name; + menu.appendChild(head); + Object.keys(TITLES).forEach(k => { + const b = document.createElement('button'); + b.type = 'button'; b.className = 'item'; + b.innerHTML = ''; + b.querySelector('b').textContent = TITLES[k]; + b.onclick = () => { closeMenu(); open(agent.id, k, agent.name); }; + menu.appendChild(b); + }); + menu.hidden = false; + const m = menu.getBoundingClientRect(); + menu.style.left = Math.max(8, Math.min(innerWidth - m.width - 8, ev.clientX)) + 'px'; + menu.style.top = Math.max(8, Math.min(innerHeight - m.height - 8, ev.clientY)) + 'px'; + setTimeout(() => document.addEventListener('pointerdown', outside, true), 0); + } + document.addEventListener('keydown', ev => { if (ev.key === 'Escape' && !menu.hidden) closeMenu(); }); + + // A machine page asked for by its address: the machines view with it open. + const m = window.APP && window.APP.dialog && page.exec(window.APP.dialog); + if (m) { + const row = document.querySelector('[data-open][data-id="' + CSS.escape(m[1]) + '"]'); + history.replaceState({}, '', '/'); + open(m[1], m[2], row ? row.dataset.name : m[1]); + } + window.MachineDialog = {open, menu: showMenu}; +})(); diff --git a/internal/server/web/static/termpane.js b/internal/server/web/static/termpane.js index 647e0e2..c9038d7 100644 --- a/internal/server/web/static/termpane.js +++ b/internal/server/web/static/termpane.js @@ -297,6 +297,7 @@ window.TermKeys = (function () { this.term.textarea.addEventListener('focus', () => W.setActivePane(this)); this.el.querySelector('.pane-x').onclick = ev => { ev.stopPropagation(); W.closePane(this); }; if (W.desk) this.el.querySelector('.pane-head').addEventListener('pointerdown', ev => W.startDrag(ev, 'pane', this)); + this.el.querySelector('.pane-head').addEventListener('contextmenu', ev => { if (window.MachineDialog) MachineDialog.menu(ev, this.agent); }); this.open(); } setState(state, text) { diff --git a/internal/server/web/static/workspace.js b/internal/server/web/static/workspace.js index 05c83ea..daf4257 100644 --- a/internal/server/web/static/workspace.js +++ b/internal/server/web/static/workspace.js @@ -36,6 +36,11 @@ t.el.addEventListener('click', ev => { if (!ev.target.closest('.tab-x, input') && !dragJustEnded) activate(t); }); t.el.querySelector('.tab-x').onclick = ev => { ev.stopPropagation(); closeTab(t); }; t.el.querySelector('.tab-title').addEventListener('dblclick', ev => { ev.stopPropagation(); rename(t); }); + // Its machine's Порты, Статус, Изменить: a right click on the title. + t.el.addEventListener('contextmenu', ev => { + const p = t.panes.includes(activePane) ? activePane : t.panes[0]; + if (window.MachineDialog && !ev.target.closest('input')) MachineDialog.menu(ev, p.agent); + }); if (desk) t.el.addEventListener('pointerdown', ev => { if (!ev.target.closest('.tab-x, input')) startDrag(ev, 'tab', t); }); const at = index == null ? tabs.length : index; tablist.insertBefore(t.el, tabs[at] ? tabs[at].el : mark); diff --git a/internal/server/web/templates/app.html b/internal/server/web/templates/app.html index d98f14c..a4cc425 100644 --- a/internal/server/web/templates/app.html +++ b/internal/server/web/templates/app.html @@ -189,6 +189,9 @@ +
+
+
@@ -206,6 +209,7 @@ + {{end}} diff --git a/internal/server/web/templates/dashboard.html b/internal/server/web/templates/dashboard.html index fb7d1aa..dabe500 100644 --- a/internal/server/web/templates/dashboard.html +++ b/internal/server/web/templates/dashboard.html @@ -21,13 +21,13 @@ - + - + - + {{if $.IsAdmin}}
Правила {{if .Rules}} - + {{range .Rules}}
ТипХост (порт)→ Клиент (порт)Слушать наМетка
ТипХост (порт)→ Клиент (порт)Слушать наМеткаДействие