Files
lpra/docs/superpowers/plans/2026-10-15-workspace.md

67 KiB
Raw Permalink Blame History

LPRA 0.2.0 Workspace Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: One-page panel shell where terminals and file managers (on machines and SSH hosts) live as panes in browser-session tabs that survive moving between Машины, Терминал and Настройки, with drag and drop of files between panes.

Architecture: The server keeps rendering the existing templates, but in a fragment mode (?frag=1) without the page chrome; one shell page (app.html) holds the header and three views, and a small router (app.js) swaps views with history.pushState, loads fragments, intercepts their forms and mounts their scripts. The terminal view is created once and only hidden, so its sessions stay open. The terminal page's code splits into a workspace (tabs, layouts, menu) and pane types: terminal (termpane.js) and files (filepane.js, pulled out of files.html). Files on SSH hosts are done by the agent through shell commands over SSH, as the existing tar transfer does.

Tech Stack: Go 1.26 (server, agent; golang.org/x/crypto/ssh already in go.mod), plain browser JavaScript without a build step (internal/server/web/static/*.js), Go html/template, node (node --test) for the pure JS helpers, Chrome DevTools (MCP) on the local stand for UI checks.

Spec: docs/superpowers/specs/2026-10-15-workspace-design.md

Global Constraints

  • Version 0.2.0 in VERSION.txt (set in the last task).
  • No new Go or JS dependencies. Files over SSH use shell commands over the existing golang.org/x/crypto/ssh client; no SFTP library.
  • No build step for the front end: plain scripts under internal/server/web/static/, served with {{asset "name.js"}} (content-hashed URLs).
  • All UI text in Russian, in the tone of the existing panel; code comments in English, in the style of the surrounding code.
  • Workspace state lives only in the page's memory: one per browser tab, gone on reload, kept across Машины ↔ Терминал ↔ Настройки.
  • beforeunload asks only while a terminal or SSH terminal session is alive.
  • Old URLs /agents/{id}/terminal and /agents/{id}/files keep working (they open the shell with a tab of that kind).
  • Roles: a non-admin sees and opens only machines given to them; «Настройки» is shown to admins only.
  • Commits go straight to main, no attribution lines in commit messages, dist.zip and deploy/nginx.conf.example are never staged; stage files by name.
  • Local stand for checks: scratchpad r/ (./server -addr 127.0.0.1:28491 -db lpra.db -tunnel "" -insecure-cookies, agent HOST=http://127.0.0.1:28491 STATE_PATH=$PWD/state.json TUNNEL_ADDR=off ./agent, admin adm/Passw0rd12345, agent id 75108f8d676069f9, test sshd on 127.0.0.1:28622, user t, password secret). Start the agent with env -u TERM_SESSION_ID -u TERM_PROGRAM HISTFILE=/dev/null, and type unset HISTFILE first in any test shell.

Review Focus

  1. A form inside a fragment that the server answers with a redirect to /login (session expired) — expected: the whole page goes to /login, not a login form inside the settings area. Test: Task 2, step "session expiry".
  2. Paths with spaces, quotes and $ dropped into a terminal or used by SSH file operations — expected: quoted so the shell sees one literal path. Tests: Task 1 (shellQuote), Task 6 (shq round trip).
  3. Two file panes on the same machine in one tab — expected: each keeps its own folder, selection and buttons. Test: Task 5, step "two panes".
  4. Closing a tab with an SSH file pane — expected: the agent closes that SSH connection at once, not after the idle timeout. Test: Task 7, step "close releases".
  5. A machine dialog (Статус) closed while its live graph runs — expected: its timers stop (no requests after closing). Test: Task 9, step "unmount stops timers".

File Structure

file responsibility
internal/server/web/templates/layout.html (new) {{define "head"}}, {{define "topbar"}}, {{define "assets"}} shared by pages
internal/server/web/templates/app.html (new) the shell: header, three views, the machine dialog, the scripts
internal/server/frag.go (new) fragment mode: isFrag(r), renderPage(w, r, name, data)
internal/server/frag_test.go (new) tests of the fragment mode and shell routes
internal/server/web/static/app.js (new) router, fragment loader, form interception, module registry, beforeunload
internal/server/web/static/wsutil.js (new) pure helpers: shellQuote, dropAction, fmtSize; exported for node tests
internal/server/web/static/wsutil.test.js (new) node --test for wsutil.js
internal/server/web/static/workspace.js (new, from terminal.js) tabs, layouts, splits, pane and tab dragging, «+» menu
internal/server/web/static/termpane.js (new, from terminal.js) the Session class (terminal, SSH terminal), clipboard keys
internal/server/web/static/filepane.js (new, from files.html) FilePane class, its data sources (machine, SSH)
internal/server/web/static/fileviews.js (new, from files.html) shared file dialogs: viewer/editor, hex, properties, move/copy, uploads
internal/server/web/static/dnd.js (new) dragging files between panes and into terminals, the drop dialog
internal/server/web/static/machine.js (new) the machine dialog (Порты, Статус, Изменить fragments)
internal/server/web/static/pages.js (new) page modules: machines list, settings copy buttons and install code
internal/agent/sshfs.go (new) SSH file sessions and sshfs.* tasks
internal/agent/sshfs_test.go (new) tests of stat parsing and command building
internal/server/sshfs.go (new) server side of sshfs.*: open, ops, download, upload, audit
internal/proto/proto.go new task names and argument types
removed at the end templates/terminal.html, templates/files.html (their markup moves into app.html and the JS files above), static/terminal.js

Task 1: Pure helpers with node tests

Files:

  • Create: internal/server/web/static/wsutil.js
  • Create: internal/server/web/static/wsutil.test.js

Interfaces:

  • Produces: window.WSUtil = {shellQuote(path: string): string, shellQuoteAll(paths: string[]): string, dropAction(item, target): {kind: 'transfer'|'paste'|'deny'|'none', reason?: string}, fmtSize(n: number): string}. item = {source: {kind: 'agent'|'ssh', id: string, label: string}, paths: string[], dir: string}; target = {kind: 'files'|'folder'|'term', source: {kind, id, label}, dir?: string}. Two sources are «the same place» when kind and id are equal (for SSH, id is "<agentId>|user@host:port").

  • Step 1: Write the failing tests

internal/server/web/static/wsutil.test.js:

'use strict';
const test = require('node:test');
const assert = require('node:assert');
const U = require('./wsutil.js');

test('shellQuote keeps a path one literal word', () => {
  assert.strictEqual(U.shellQuote('/srv/a b'), "'/srv/a b'");
  assert.strictEqual(U.shellQuote("/x/it's"), "'/x/it'\\''s'");
  assert.strictEqual(U.shellQuote('/x/$HOME;rm'), "'/x/$HOME;rm'");
  assert.strictEqual(U.shellQuoteAll(['/a', '/b c']), "'/a' '/b c'");
});

const m1 = {kind: 'agent', id: 'm1', label: 'web'};
const m2 = {kind: 'agent', id: 'm2', label: 'db'};
const s1 = {kind: 'ssh', id: 'm1|root@h:22', label: 'root@h'};
const item = {source: m1, paths: ['/etc/x'], dir: '/etc'};

test('dropAction: files into a file pane is a transfer', () => {
  assert.deepStrictEqual(U.dropAction(item, {kind: 'files', source: m2, dir: '/tmp'}), {kind: 'transfer'});
  assert.deepStrictEqual(U.dropAction(item, {kind: 'folder', source: m1, dir: '/var'}), {kind: 'transfer'});
});
test('dropAction: into its own folder does nothing', () => {
  assert.deepStrictEqual(U.dropAction(item, {kind: 'files', source: m1, dir: '/etc'}), {kind: 'none'});
});
test('dropAction: terminal of the same place pastes, of another is denied', () => {
  assert.deepStrictEqual(U.dropAction(item, {kind: 'term', source: m1}), {kind: 'paste'});
  const d = U.dropAction(item, {kind: 'term', source: m2});
  assert.strictEqual(d.kind, 'deny');
  assert.strictEqual(d.reason, 'Нельзя: файл на web, а терминал на db');
  assert.strictEqual(U.dropAction({source: s1, paths: ['/a'], dir: '/'}, {kind: 'term', source: s1}).kind, 'paste');
});
test('fmtSize', () => {
  assert.strictEqual(U.fmtSize(10), '10 B');
  assert.strictEqual(U.fmtSize(2048), '2.0 K');
});
  • Step 2: Run them to see them fail

Run: cd internal/server/web/static && node --test wsutil.test.js Expected: FAIL, Cannot find module './wsutil.js'.

  • Step 3: Write wsutil.js
// Pure helpers of the workspace: quoting paths for a shell, what a dropped
// file does where it lands, sizes. No DOM here, so node can test them.
(function (root) {
  'use strict';
  function shellQuote(p) { return "'" + String(p).replace(/'/g, "'\\''") + "'"; }
  function shellQuoteAll(ps) { return ps.map(shellQuote).join(' '); }
  const same = (a, b) => a.kind === b.kind && a.id === b.id;
  function dropAction(item, target) {
    if (target.kind === 'term') {
      return same(item.source, target.source) ? {kind: 'paste'}
        : {kind: 'deny', reason: 'Нельзя: файл на ' + item.source.label + ', а терминал на ' + target.source.label};
    }
    if (same(item.source, target.source) && target.dir === item.dir) return {kind: 'none'};
    return {kind: 'transfer'};
  }
  function fmtSize(n) {
    if (n < 1024) return n + ' B';
    if (n < 1048576) return (n / 1024).toFixed(1) + ' K';
    if (n < 1073741824) return (n / 1048576).toFixed(1) + ' M';
    return (n / 1073741824).toFixed(1) + ' G';
  }
  const api = {shellQuote, shellQuoteAll, dropAction, fmtSize};
  if (typeof module !== 'undefined') module.exports = api; else root.WSUtil = api;
})(this);
  • Step 4: Run the tests

Run: cd internal/server/web/static && node --test wsutil.test.js Expected: PASS, 5 tests.

  • Step 5: Keep the test file out of the served assets

staticHandler serves everything under web/static. In internal/server/server.go find staticHandler and make it answer 404 for names ending in .test.js:

if strings.HasSuffix(r.URL.Path, ".test.js") {
	http.NotFound(w, r)
	return
}

Run: go build ./... && go vet ./internal/server Expected: no output.

  • Step 6: Commit
git add internal/server/web/static/wsutil.js internal/server/web/static/wsutil.test.js internal/server/server.go
git commit -m "feat: workspace helpers - quoting paths, what a dropped file does, with node tests"

Task 2: Shell page, fragment mode, router

Files:

  • Create: internal/server/web/templates/layout.html, internal/server/web/templates/app.html, internal/server/frag.go, internal/server/frag_test.go, internal/server/web/static/app.js, internal/server/web/static/pages.js
  • Modify: internal/server/web/templates/dashboard.html, settings.html, edit.html, forwards.html, status.html (use the layout blocks, wrap content in {{define "<name>-body"}}), internal/server/handlers_panel.go (handlers call renderPage), internal/server/server.go (routes /, /terminal, /settings)

Interfaces:

  • Produces (Go): func isFrag(r *http.Request) bool; func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, name string, data map[string]any) — with ?frag=1 executes template name+"-body", otherwise executes app.html with data["View"] = name and the body pre-rendered into data["Body"] template.HTML.

  • Produces (JS): window.App = {go(url: string), load(area: Element, url: string): Promise<void>, register(name: string, mod: {mount(root: Element), unmount?()})}; each fragment root has data-module="<name>"; App.load calls the module's mount(root) after inserting and the previous module's unmount() before replacing.

  • Step 1: Write the failing Go test

internal/server/frag_test.go:

package server

import (
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func TestFragmentHasNoChrome(t *testing.T) {
	s, cookie := newTestServer(t) // helper below
	req := httptest.NewRequest(http.MethodGet, "/settings?frag=1", nil)
	req.AddCookie(cookie)
	w := httptest.NewRecorder()
	s.Handler().ServeHTTP(w, req)
	body := w.Body.String()
	if w.Code != 200 || strings.Contains(body, "<html") || strings.Contains(body, "topbar") {
		t.Fatalf("fragment: code %d, has chrome: %q", w.Code, body[:min(200, len(body))])
	}
	if !strings.Contains(body, `data-module="settings"`) {
		t.Fatalf("fragment has no module root")
	}
}

func TestShellRoutes(t *testing.T) {
	s, cookie := newTestServer(t)
	for _, p := range []string{"/", "/terminal", "/settings", "/agents/x1/files", "/agents/x1/terminal"} {
		req := httptest.NewRequest(http.MethodGet, p, nil)
		req.AddCookie(cookie)
		w := httptest.NewRecorder()
		s.Handler().ServeHTTP(w, req)
		if !strings.Contains(w.Body.String(), `id="app"`) {
			t.Errorf("%s: not the shell (code %d)", p, w.Code)
		}
	}
}

Add the helper in the same file. It opens a temporary database, creates an admin and a session the same way handleSetupSubmit and setSession do; check Options in server.go for the exact field names before writing it:

func newTestServer(t *testing.T) (*Server, *http.Cookie) {
	t.Helper()
	db, err := OpenDB(t.TempDir() + "/t.db")
	if err != nil {
		t.Fatal(err)
	}
	s, err := New(Options{DB: db, DistDir: t.TempDir(), TunnelAddr: ""})
	if err != nil {
		t.Fatal(err)
	}
	hash, _ := hashPassword("Passw0rd12345")
	_ = db.CreateAdmin("adm", hash)
	_ = db.SetConfig(cfgSetupDone, "1")
	a, _ := db.AdminByUsername("adm")
	_ = db.CreateSession("sid-test", a.ID, sessionTTL)
	_, _ = db.sql.Exec(`INSERT INTO agents(id, name, hostname, token_hash, created_at, last_seen) VALUES('x1','t','h','x',0,0)`)
	return s, &http.Cookie{Name: sessionCookie, Value: "sid-test"}
}

If New or Handler are named differently, use the names cmd/server/main.go uses.

  • Step 2: Run it to see it fail

Run: go test ./internal/server -run 'TestFragmentHasNoChrome|TestShellRoutes' -v Expected: FAIL (no id="app", fragment has <html).

  • Step 3: Write layout.html

Move the repeated head and top bar out of the page templates:

{{define "head"}}<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<script src="{{asset "scroll.js"}}" defer></script>{{end}}

{{define "topbar"}}<header class="topbar">
  <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">Терминал</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>
  </nav>
</header>{{end}}
  • Step 4: Turn each page into a body block

For dashboard.html, settings.html, edit.html, forwards.html, status.html:

  1. Cut everything between <body> (after the <header>…</header>) and the page scripts into {{define "<file>-body"}}<div class="page" data-module="<file>">…</div>{{end}} (<file> = dashboard, settings, edit, forwards, status).
  2. Move the page <style> block into the body block (it is scoped by the page's own class names already; keep it as is).
  3. Move each inline <script> of the page into pages.js as a module (Step 6) — leave no <script> in a body block.
  4. Delete the rest of the file (doctype, head, header, closing tags).
  • Step 5: Write app.html and frag.go

app.html:

{{define "app.html"}}<!doctype html>
<html lang="ru">
<head>
<title>LPRA</title>
{{template "head" .}}
<link rel="stylesheet" href="{{asset "xterm.css"}}">
</head>
<body class="app">
{{template "topbar" .}}
<main id="app" data-view="{{.View}}">
  <section class="view" data-view="machines"{{if ne .View "dashboard"}} hidden{{end}}>{{if eq .View "dashboard"}}{{.Body}}{{end}}</section>
  <section class="view view-term" data-view="terminal"{{if ne .View "terminal"}} hidden{{end}}></section>
  <section class="view" data-view="settings"{{if ne .View "settings"}} hidden{{end}}>{{if eq .View "settings"}}{{.Body}}{{end}}</section>
</main>
<script>window.APP = {admin: {{.IsAdmin}}, open: {{.Open}}};</script>
<script src="{{asset "wsutil.js"}}"></script>
<script src="{{asset "app.js"}}"></script>
<script src="{{asset "pages.js"}}"></script>
<script src="{{asset "users.js"}}"></script>
<script src="{{asset "account.js"}}"></script>
</body>
</html>{{end}}

(Task 3 adds the terminal scripts; Task 9 adds the machine dialog.)

internal/server/frag.go:

package server

import (
	"bytes"
	"html/template"
	"log"
	"net/http"
)

// A panel page is either a fragment - its content alone, for the shell to put
// in place without reloading - or the shell with that content already in it.

func isFrag(r *http.Request) bool { return r.URL.Query().Get("frag") == "1" }

// pageView is the shell's view a page belongs to.
var pageView = map[string]string{"dashboard": "dashboard", "settings": "settings"}

func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, name string, data map[string]any) {
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store")
	if data == nil {
		data = map[string]any{}
	}
	me := s.currentAdmin(r)
	data["IsAdmin"] = me.IsAdmin()
	var body bytes.Buffer
	if err := s.tmpl.ExecuteTemplate(&body, name+"-body", data); err != nil {
		log.Printf("template %s: %v", name, err)
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	if isFrag(r) {
		_, _ = w.Write(body.Bytes())
		return
	}
	data["View"] = pageView[name]
	data["Body"] = template.HTML(body.String())
	if err := s.tmpl.ExecuteTemplate(w, "app.html", data); err != nil {
		log.Printf("template app.html: %v", err)
	}
}

// handleShell serves the shell alone: the terminal view, or a page that opens
// a pane at once (Open: {"kind": "term"|"files", "agent": {...}}).
func (s *Server) handleShell(w http.ResponseWriter, r *http.Request) {
	data := map[string]any{"View": "terminal", "IsAdmin": s.currentAdmin(r).IsAdmin()}
	if id := r.PathValue("id"); id != "" {
		a, _ := s.db.GetAgent(id)
		if a == nil {
			http.NotFound(w, r)
			return
		}
		kind := "term"
		if r.PathValue("kind") == "files" {
			kind = "files"
		}
		data["Open"] = map[string]any{"kind": kind, "agent": map[string]string{"id": a.ID, "name": a.Name, "host": a.Hostname, "user": a.TerminalUser()}}
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store")
	if err := s.tmpl.ExecuteTemplate(w, "app.html", data); err != nil {
		log.Printf("template app.html: %v", err)
	}
}

In handlers_panel.go: handleDashboard and handleSettings call s.renderPage(w, r, "dashboard"|"settings", data) instead of s.render; handleAgentEdit, handleForwardsPage, handleStatusPage call s.renderPage(w, r, "edit"|"forwards"|"status", data) (without frag=1 they open the shell on the machines view with the dialog: add data["View"] = "dashboard" and data["Dialog"] = "/agents/" + id + "/<page>" there, and app.html passes {{.Dialog}} in window.APP.dialog). In server.go routes:

mux.Handle("GET /terminal", s.requireAdmin(s.handleShell))
mux.Handle("GET /agents/{id}/{kind}", s.requireAdmin(s.handleShell)) // kind: terminal | files

replacing the two old routes for /agents/{id}/terminal and /agents/{id}/files (remove handleTerminalPage and handleFilesPage). requireAdmin already blocks machines a user may not use for paths starting with /agents/.

  • Step 6: Write app.js and pages.js

internal/server/web/static/app.js:

// The panel is one page: the header and three views (Машины, Терминал,
// Настройки). Moving between them changes the address without a reload, so
// the terminal view - and every session in it - lives as long as the tab.
// Машины and Настройки come from the server as fragments; their forms are
// sent here and the fragment drawn again from the answer.
(function () {
  'use strict';
  const views = {'/': 'machines', '/terminal': 'terminal', '/settings': 'settings'};
  const modules = {};
  const mounted = new Map(); // area -> module
  const $ = s => document.querySelector(s);

  function register(name, mod) { modules[name] = mod; }
  function viewOf(path) { return views[path] || null; }

  async function load(area, url) {
    const u = new URL(url, location.href);
    u.searchParams.set('frag', '1');
    let r;
    try { r = await fetch(u, {credentials: 'same-origin', cache: 'no-store'}); }
    catch (e) { area.innerHTML = failHTML('нет связи с сервером'); bindRetry(area, url); return; }
    if (r.redirected && new URL(r.url).pathname === '/login') { location.href = '/login'; return; }
    if (!r.ok) { area.innerHTML = failHTML('ошибка ' + r.status); bindRetry(area, url); return; }
    put(area, await r.text());
  }
  function put(area, html) {
    const prev = mounted.get(area);
    if (prev && prev.unmount) prev.unmount();
    mounted.delete(area);
    area.innerHTML = html;
    const root = area.querySelector('[data-module]');
    const mod = root && modules[root.dataset.module];
    if (mod) { mod.mount(root); mounted.set(area, mod); }
  }
  const failHTML = msg => '<div class="wrap"><p class="err">' + msg + ' <button type="button" class="btn ghost small" data-retry>Повторить</button></p></div>';
  function bindRetry(area, url) { const b = area.querySelector('[data-retry]'); if (b) b.onclick = () => load(area, url); }

  function show(view) {
    document.querySelectorAll('main#app > .view').forEach(v => { v.hidden = v.dataset.view !== view; });
    document.querySelectorAll('[data-nav]').forEach(a => a.classList.toggle('on', a.dataset.nav === view));
    $('main#app').dataset.view = view;
    document.dispatchEvent(new CustomEvent('app:view', {detail: view}));
  }
  async function go(url, push = true) {
    const u = new URL(url, location.href);
    const view = viewOf(u.pathname);
    if (!view || u.origin !== location.origin) { location.href = url; return; }
    if (push) history.pushState({}, '', u.pathname + u.search + u.hash);
    show(view);
    if (view !== 'terminal') await load(document.querySelector('.view[data-view="' + view + '"]'), u.pathname + u.search);
  }

  // Links inside the app move between views; other links behave as links.
  document.addEventListener('click', ev => {
    const a = ev.target.closest('a[href]');
    if (!a || ev.defaultPrevented || ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || a.target) return;
    const u = new URL(a.href, location.href);
    if (u.origin === location.origin && viewOf(u.pathname)) { ev.preventDefault(); go(u.href); }
  });
  window.addEventListener('popstate', () => go(location.href, false));

  // Forms inside fragments are sent here; the answer's address says what to
  // draw. /logout and forms outside the views go the usual way.
  document.addEventListener('submit', async ev => {
    const f = ev.target;
    const area = f.closest('.view, .frag-area');
    if (!area || ev.defaultPrevented || f.method.toLowerCase() !== 'post' || /\/logout$/.test(f.action)) return;
    ev.preventDefault();
    let r;
    try { r = await fetch(f.action, {method: 'POST', body: new FormData(f), credentials: 'same-origin'}); }
    catch (e) { alert('нет связи с сервером'); return; }
    const to = new URL(r.url);
    if (to.pathname === '/login') { location.href = '/login'; return; }
    if (area.classList.contains('view')) {
      if (viewOf(to.pathname) && viewOf(to.pathname) !== area.dataset.view) { go(to.href); return; }
      history.replaceState({}, '', to.pathname + to.search + to.hash);
    }
    put(area, await (await fetch(withFrag(to), {credentials: 'same-origin', cache: 'no-store'})).text());
    if (to.hash) { const t = area.querySelector(to.hash); if (t) t.scrollIntoView(); }
  });
  function withFrag(u) { const x = new URL(u); x.searchParams.set('frag', '1'); return x; }

  // Leaving the page drops the terminals: ask while one is alive.
  window.addEventListener('beforeunload', ev => {
    if (window.Workspace && window.Workspace.hasLiveSessions()) { ev.preventDefault(); ev.returnValue = ''; }
  });

  // The page the server drew: mount its module once every script has
  // registered (app.html calls start() after the last script).
  function start() {
    document.querySelectorAll('main#app > .view').forEach(v => {
      const root = v.querySelector('[data-module]');
      if (root && modules[root.dataset.module]) { modules[root.dataset.module].mount(root); mounted.set(v, modules[root.dataset.module]); }
    });
    show($('main#app').dataset.view === 'dashboard' ? 'machines' : $('main#app').dataset.view);
  }
  window.App = {go, load, register, put, start};
})();

In app.html add <script>App.start();</script> as the last script.

internal/server/web/static/pages.js — the inline scripts moved out of the page templates as modules, for example the settings copy buttons:

// Scripts of the pages the shell shows as fragments, as modules the router
// mounts and unmounts.
(function () {
  'use strict';
  const CHECK = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
  function copy(text) {
    if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
    const ta = document.createElement('textarea');
    ta.value = text; ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
    document.body.appendChild(ta); ta.select();
    const ok = document.execCommand('copy'); ta.remove();
    return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
  }
  App.register('settings', {
    mount(root) {
      root.querySelectorAll('.copybox .copy').forEach(b => {
        const icon = b.innerHTML;
        b.onclick = () => copy(root.querySelector('#' + b.dataset.copy).textContent.trim()).then(() => {
          b.innerHTML = CHECK; b.classList.add('done');
          setTimeout(() => { b.innerHTML = icon; b.classList.remove('done'); }, 1500);
        }, e => alert('Не скопировалось: ' + e.message));
      });
      if (window.UsersTable) window.UsersTable.mount(root);
    }
  });
})();

Then add one App.register(name, {mount(root), unmount()}) per remaining page, each holding that page's inline script moved verbatim, with these changes only:

  • dashboard (from dashboard.html, the fetch('/api/panel/agents/presence'…) poll and the tooltip code): document.getElementById(x) / document.querySelector(x) → root.querySelector(...); every setInterval/setTimeout id is pushed to this.timers = []; unmount() { this.timers.forEach(clearTimeout); this.timers.forEach(clearInterval); }.

  • forwards, edit (their inline scripts): the same two changes.

  • status (from status.html, including liveTick and the speed test): the same, plus every fetch gets {signal: this.ac.signal} with this.ac = new AbortController() created in mount, and unmount calls this.ac.abort(). The four page templates then contain no <script>; confirm with grep -c '<script' internal/server/web/templates/{dashboard,settings,edit,forwards,status}.html → all 0. users.js: wrap its IIFE body into window.UsersTable = {mount(root) {…}} with root in place of document lookups.

  • Step 7: Run the Go tests

Run: go test ./internal/server -run 'TestFragmentHasNoChrome|TestShellRoutes' -v && go vet ./... Expected: PASS.

  • Step 8: Check in the browser (stand)

Rebuild and restart the stand; open http://127.0.0.1:28491/. In DevTools run:

await App.go('/settings'); document.querySelector('.view[data-view=settings] [data-module=settings]') !== null

Expected: true, address /settings, header «Настройки» highlighted, no full reload (check performance.getEntriesByType('navigation').length === 1). Then submit «Новый код» in the settings (accept the confirm): the code changes, the page is not reloaded, address stays /settings. Press browser «Назад»: machines list shows.

  • Step 9: Session expiry (Review Focus 1)

On the stand delete the browser's session row: sqlite3 lpra.db "delete from admin_sessions"; then in the page submit any settings form. Expected: the whole page lands on /login (not a login form inside the settings view).

  • Step 10: Commit
git add internal/server/frag.go internal/server/frag_test.go internal/server/server.go internal/server/handlers_panel.go internal/server/web/templates/layout.html internal/server/web/templates/app.html internal/server/web/templates/dashboard.html internal/server/web/templates/settings.html internal/server/web/templates/edit.html internal/server/web/templates/forwards.html internal/server/web/templates/status.html internal/server/web/static/app.js internal/server/web/static/pages.js internal/server/web/static/users.js
git commit -m "feat: one-page panel - shell with Машины, Терминал and Настройки, pages as fragments, forms sent in place"

Task 3: Workspace in the terminal view

Files:

  • Create: internal/server/web/static/workspace.js, internal/server/web/static/termpane.js
  • Modify: internal/server/web/templates/app.html (terminal view markup and scripts), internal/server/web/static/app.css (styles moved from terminal.html)
  • Delete: internal/server/web/templates/terminal.html, internal/server/web/static/terminal.js

Interfaces:

  • Consumes: App, WSUtil, window.APP.open.

  • Produces: window.Workspace = {open(kind: 'term'|'ssh'|'files'|'sshfiles', agent, opts?), hasLiveSessions(): boolean, panes(): Pane[], activePane(): Pane|null, renderPane(pane) /* redraws its tab title, dot and the header status */, activate(tab), paneTypes: {}} (paneAt/tabAt are added in Task 7). Pane interface (every pane type implements it): el: HTMLElement, kind: string, agent: {id,name,host,user}, source: {kind:'agent'|'ssh', id, label}, title(): string, state: 'wait'|'ok'|'err'|'off', text: string, fit(), focus(), close(), live(): boolean, dropTarget(x, y): {kind, source, dir?}|null, drop(item, action). Session (termpane) sets source to {kind:'agent', id: agent.id, label: agent.name} or, for SSH, {kind:'ssh', id: agent.id + '|' + user + '@' + host + ':' + port, label: user + '@' + host}. Pane registry: Workspace.paneTypes[kind] = (agent, opts) => Pane.

  • Step 1: Split terminal.js

  1. Copy terminal.js to termpane.js and keep only: bytesToB64, b64ToBytes, mac, copyText, clipKeys, flashStatus, the Session class, who(). Export them: window.TermPane = {Session, who, copyText}; register the types:
Workspace.paneTypes.term = (agent) => new Session(agent);
Workspace.paneTypes.ssh = (agent, opts) => new Session(agent, opts.ssh);

Add to Session: this.kind = ssh ? 'ssh' : 'term'; this.source as in Interfaces; live() { return !!this.sid && !this.ended && !this.closed; }; title() returning what tabTitle used for one pane (ssh.host or agent.name); dropTarget() { return {kind: 'term', source: this.source}; }; drop(item) { this.send(WSUtil.shellQuoteAll(item.paths) + ' '); this.term.focus(); }. 2. Copy terminal.js to workspace.js and remove what went to termpane.js. Replace new Session(agent, ssh) with Workspace.paneTypes[kind](agent, opts); replace tabTitle(t) single-pane name with t.panes[0].title(); replace who(s) in tab tooltips with s.title(). 3. In workspace.js the code no longer runs at page load: wrap it in function start(root) that builds the strip, area, menu inside root (the markup in Step 2), and call start the first time the terminal view is shown (document.addEventListener('app:view', e => e.detail === 'terminal' && ensureStarted())) or when Workspace.open is called. Remove openTerminal(PAGE.agent) at the end; start opens window.APP.open if set. 4. Workspace.open(kind, agent, opts): ensureStarted(); const t = makeTab([Workspace.paneTypes[kind](agent, opts||{})]); activate(t); App.go('/terminal') (App.go does nothing when already there except pushState; skip pushState if location.pathname === '/terminal'). 5. hasLiveSessions(): tabs.some(t => t.panes.some(p => p.live())). 6. The «+» menu items become two submenus («Терминал ›», «Файловый менеджер ›»), each with «здесь (машина)», other online machines, «SSH-хост…». Until Task 5 the files items stay disabled (b.disabled = !Workspace.paneTypes.files). 7. The status line in the header (#status) is now the shell's; showStatus() writes it only while the terminal view is shown, and on app:view the shell clears it for other views.

  • Step 2: Move the terminal markup and styles

From terminal.html move into app.html's <section data-view="terminal">: .tabs, #area with #hint, #floatadd, #empty, #addmenu, the touch keys bar and the copy menu, the SSH dialog markup if any; move its <style> block into app.css under a .view-term prefix where selectors are generic (body.multi → .view-term.multi, body.touch stays on body, body.split1 → .view-term.split1; update the matching classList calls in workspace.js). Move its inline <script> (touch keys, copy menu, TermKeys) into termpane.js. Add the scripts to app.html before app.js: xterm.js, addon-fit.js, workspace.js, termpane.js. Delete terminal.html and terminal.js; the GET /agents/{id}/terminal route already serves the shell (Task 2). The terminal view takes the height below the header: .app main#app { height: calc(100vh - <header height>) } and .view-term { height: 100%; display: flex; flex-direction: column } (take the exact values from the old body { height:100vh; display:flex; flex-direction:column } rules).

  • Step 3: Check in the browser

On the stand: open /, click the terminal button of the machine → view «Терминал», a terminal tab. In it run unset HISTFILE; for i in $(seq 1 40); do echo n$i; sleep 0.5; done. Click «Машины», wait 5 s, click «Терминал». Expected: the output went on (the last line is ≥ n10), no reconnect message. Workspace.hasLiveSessions() is true; reloading the page shows the browser's «Покинуть сайт?». Open /agents/75108f8d676069f9/terminal directly: shell with one terminal tab. Split two terminals by dragging (desktop) and back; open an SSH terminal to 127.0.0.1:28622 (t/secret).

  • Step 4: Commit
git add internal/server/web/static/workspace.js internal/server/web/static/termpane.js internal/server/web/static/app.css internal/server/web/templates/app.html
git rm internal/server/web/templates/terminal.html internal/server/web/static/terminal.js
git commit -m "feat: workspace lives in the terminal view and keeps its sessions across pages; terminal panes split out"

Task 4: File pane for machines

Files:

  • Create: internal/server/web/static/filepane.js, internal/server/web/static/fileviews.js
  • Modify: internal/server/web/templates/app.html, internal/server/web/static/app.css, internal/server/web/static/pages.js (machines list file button), internal/server/web/templates/dashboard.html
  • Delete: internal/server/web/templates/files.html

Interfaces:

  • Consumes: Workspace.paneTypes, WSUtil.fmtSize, DocView, DBEdit, CodeMirror.

  • Produces: class FilePane with the Pane interface plus cwd: string, source, src (data source), reload(), selectedPaths(): string[]; Workspace.paneTypes.files = agent => new FilePane({kind: 'agent', agent}). Data source interface (src): list(path) → Promise<{ok, data:{path, entries:[{name,is_dir,size,mode,mtime}]}, error}>, op(name, args) → Promise<{ok, data, error}> for read|write|mkdir|delete|deltree|move|copy|stat|chmod|zip|unzip|db.*, downloadURL(path, inline): string, upload(file, path, onProgress) → Promise<{ok,error}>, caps: {zip: bool, sqlite: bool}. MachineSource(agent) wraps the existing /api/panel/agents/{id}/fs/{op}, /fs/download, /fs/upload + xfer pieces. FileViews.open(kind, pane, entry) for view|hex|props|move|db; FileViews.uploads(pane, files, dir).

  • Step 1: Extract the shared dialogs

From files.html move into fileviews.js: the viewer/editor (openViewer, viewText, viewDoc, viewHex and helpers), properties dialog, move/copy dialog with the remote part, upload list dialog, toast. Their markup (<dialog id="viewer">, #propdlg, #mvdlg, #updlg, #toast) goes into app.html once. Every function that used the globals agentID, cwd, api, load(cwd) takes the pane instead: api(op, args) → pane.src.op(op, args); fsURL('download', p) → pane.src.downloadURL(p); load(cwd) → pane.reload(); cwd → pane.cwd. Expose window.FileViews = {view, hex, props, move, uploads, toast}.

  • Step 2: Write FilePane

Move the list part of files.html (bar, table, render, sorted, selection, mkdir, newfile, upload input and drop zone, zipsel, delsel, path box with dirprops, fileIcon) into class FilePane in filepane.js:

  • the markup is built in the constructor into this.el (a div.pane.fpane with .pane-head like terminal panes and a .fpane-body with the bar and table); every $('id') becomes this.q('.class') with q(sel) { return this.el.querySelector(sel); } — rename ids to classes (#path → .fp-path, #list → .fp-list, #zipsel → .fp-zip, …);

  • state on the instance: this.cwd, this.entries, this.selected = new Set();

  • reload() = old load(cwd); go(path) = old load(path); after each load: this.text = this.cwd, Workspace.renderPane(this) (redraws tab title and dot);

  • title() → this.agent.name + ' · ' + (base name of cwd or '/'); kind = 'files'; live() → false;

  • narrow panes: a ResizeObserver on this.el toggles this.el.classList.toggle('narrow', width < 520); in app.css .fpane.narrow hides the mtime column and turns row buttons into the «⋯» menu exactly like the old @media (max-width: 640px) rules (copy them under .fpane.narrow).

  • selectedPaths() → selected names joined with cwd.

  • dropTarget(x, y): if the point is over a folder row → {kind: 'folder', source: this.source, dir: <that folder>}, else {kind: 'files', source: this.source, dir: this.cwd} (used in Task 8).

  • Step 3: Wire the menu and the machines list

Workspace.paneTypes.files = agent => new FilePane({kind: 'agent', agent}) at the end of filepane.js; the «Файловый менеджер ›» items in the «+» menu call Workspace.open('files', agent). In dashboard.html the files button becomes <a href="/agents/{{.ID}}/files" data-open="files" data-agent='{{json}}'>; in pages.js the dashboard module handles clicks on [data-open]: ev.preventDefault(); Workspace.open(btn.dataset.open, JSON.parse(btn.dataset.agent)); the terminal button gets data-open="term". Add "json" to the template funcs in server.go (func(v any) template.JS { b, _ := json.Marshal(v); return template.JS(b) }) or render the four attributes data-id, data-name, data-host, data-user and build the object in JS — prefer the four attributes (no new template func). Add codemirror/codemirror.js, docview.js, dbedit.js, fileviews.js, filepane.js to app.html before app.js. Delete files.html.

  • Step 4: Check — two panes (Review Focus 3)

On the stand: open a files tab for the machine, then «+» → «Файловый менеджер › здесь» and drag the new tab onto the first to split. In the left pane go to the scratchpad ship/src, in the right to dbt; tick a row in the left. Expected: each pane shows its own folder and selection; «Удалить выбранное» is active only in the left; the tab title shows both. Then in one pane: open a text file and edit/save, open test.db (SQLite editor), upload a file by dragging from the OS, create a folder, rename (move dialog), properties, delete. All work as on the old page.

  • Step 5: Commit
git add internal/server/web/static/filepane.js internal/server/web/static/fileviews.js internal/server/web/static/app.css internal/server/web/static/pages.js internal/server/web/templates/app.html internal/server/web/templates/dashboard.html
git rm internal/server/web/templates/files.html
git commit -m "feat: file manager as a workspace pane, side by side with terminals"

Task 5: Agent — file sessions over SSH

Files:

  • Create: internal/agent/sshfs.go, internal/agent/sshfs_test.go
  • Modify: internal/proto/proto.go, internal/agent/users.go (dispatch), internal/agent/task.go (stream tasks)

Interfaces:

  • Consumes: (*Agent).sshConnect(ctx, proto.SSHTarget, status func(st, note string)) (*ssh.Client, string, error), shq(string) string, sshRun(*ssh.Client, string) (string, error).
  • Produces (proto):
TaskSSHFSOpen  = "sshfs.open"  // connect; result {id, home}
TaskSSHFSClose = "sshfs.close"
TaskSSHFSOp    = "sshfs.op"    // list, read, write, mkdir, delete, move, copy, stat, chmod
TaskSSHFSSend  = "sshfs.send"  // a file to the browser in pieces (FSXferArgs + Conn)
TaskSSHFSPut   = "sshfs.put"   // a file from the browser in pieces
type SSHFSArgs struct {
	Conn string          `json:"conn,omitempty"`
	SSH  *SSHTarget      `json:"ssh,omitempty"`  // open only
	Op   string          `json:"op,omitempty"`
	Args json.RawMessage `json:"args,omitempty"` // the same shapes as the fs.* tasks: FSPathArgs, FSWriteArgs, FSMoveArgs, FSCopyArgs, FSChmodArgs
}

Agent: type sshfsConn struct{ client *ssh.Client; statFmt string; last time.Time; mu sync.Mutex }; func parseStatLine(line string) (proto.FSEntry, bool); func statCommand(flavour, dir string) string.

  • Step 1: Write the failing tests

internal/agent/sshfs_test.go:

package agent

import "testing"

func TestParseStatLine(t *testing.T) {
	// statCommand prints: type|size|mtime|mode|name
	e, ok := parseStatLine("directory|4096|1760000000|drwxr-xr-x|my dir")
	if !ok || !e.IsDir || e.Name != "my dir" || e.MTime != 1760000000 || e.Mode != "drwxr-xr-x" {
		t.Fatalf("dir: %+v %v", e, ok)
	}
	e, ok = parseStatLine("regular file|12|1760000001|-rw-r--r--|a|b.txt")
	if !ok || e.IsDir || e.Size != 12 || e.Name != "a|b.txt" {
		t.Fatalf("file with | in name: %+v %v", e, ok)
	}
	if _, ok := parseStatLine("garbage"); ok {
		t.Fatal("garbage parsed")
	}
}

func TestStatCommandQuotes(t *testing.T) {
	for _, fl := range []string{"gnu", "bsd"} {
		c := statCommand(fl, "/srv/it's here")
		if !contains(c, `'/srv/it'\''s here'`) {
			t.Errorf("%s: dir not quoted: %s", fl, c)
		}
	}
}

func contains(s, sub string) bool { return len(s) >= len(sub) && (len(sub) == 0 || indexOf(s, sub) >= 0) }
func indexOf(s, sub string) int {
	for i := 0; i+len(sub) <= len(s); i++ {
		if s[i:i+len(sub)] == sub {
			return i
		}
	}
	return -1
}
  • Step 2: Run them to see them fail

Run: go test ./internal/agent -run 'TestParseStatLine|TestStatCommandQuotes' -v Expected: FAIL, undefined: parseStatLine.

  • Step 3: Write sshfs.go
package agent

// Files on an SSH host, for the panel's file manager: the agent keeps one SSH
// connection per pane and runs plain commands there - stat, cat, mv, rm - as
// the tar transfer does. No SFTP needed on the host.

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"strconv"
	"strings"
	"sync"
	"time"

	"golang.org/x/crypto/ssh"

	"github.com/x-grk/longpolling-remote-access/internal/proto"
)

const sshfsIdle = 10 * time.Minute

type sshfsConn struct {
	client  *ssh.Client
	target  proto.SSHTarget // kept in the agent's memory to reconnect once after a drop
	statFmt string // "gnu", "bsd" or "ls"
	home    string
	mu      sync.Mutex
	last    time.Time
}

var sshfsConns sync.Map // id -> *sshfsConn

// statCommand lists dir's entries as "type|size|mtime|mode|name" lines.
func statCommand(flavour, dir string) string {
	d := shq(strings.TrimSuffix(dir, "/") + "/")
	switch flavour {
	case "bsd":
		return "cd " + d + " && for f in .[!.]* ..?* *; do [ -e \"$f\" ] || [ -L \"$f\" ] || continue; stat -f '%HT|%z|%m|%Sp|%N' -- \"$f\"; done"
	case "ls":
		return "cd " + d + " && ls -la"
	}
	return "cd " + d + " && for f in .[!.]* ..?* *; do [ -e \"$f\" ] || [ -L \"$f\" ] || continue; stat -c '%F|%s|%Y|%A|%n' -- \"$f\"; done"
}

// parseStatLine reads one line of statCommand's output.
func parseStatLine(line string) (proto.FSEntry, bool) {
	p := strings.SplitN(line, "|", 5)
	if len(p) != 5 {
		return proto.FSEntry{}, false
	}
	size, err1 := strconv.ParseInt(p[1], 10, 64)
	mt, err2 := strconv.ParseInt(p[2], 10, 64)
	if err1 != nil || err2 != nil {
		return proto.FSEntry{}, false
	}
	t := strings.ToLower(p[0])
	return proto.FSEntry{Name: p[4], IsDir: strings.Contains(t, "directory"), Size: size, MTime: mt, Mode: p[3]}, true
}

Then the rest of the file:

  • (a *Agent) sshfsOpen(ctx, t proto.SSHTarget) (map[string]any, error): client, _, err := a.sshConnect(ctx, t, func(string, string){}); detect the flavour with sshRun(client, "stat -c %F / 2>/dev/null && echo GNU; stat -f %HT / 2>/dev/null && echo BSD; echo $HOME") (output contains GNU → gnu, BSD → bsd, else ls; the last line is home); store under randHex(12) id; start one janitor goroutine (once, sync.Once) that every minute closes connections idle longer than sshfsIdle. Return {"id": id, "home": home}.

  • sshfsGet(id) (*sshfsConn, error) — error "подключение закрыто - откройте файлы по SSH снова" when missing; updates last.

  • When a command fails because the connection is gone (client.NewSession returns an error), sshfsOp reconnects once with c.target (a.sshConnect), replaces c.client and runs the command again; a second failure returns "подключение к хосту потеряно: <error>".

  • sshfsOp(c *sshfsConn, op string, raw json.RawMessage) (any, error) with the commands from the spec table; list parses lines with parseStatLine (for ls flavour, parse ls -la columns: mode, size = field 5, name = after the 8th field, mtime 0); read runs cat -- path with session.StdoutPipe capped at proto.FSReadCap and returns proto.FSReadResult{Content: base64, Truncated}; write sends data to cat > tmp && mv tmp path via session.Stdin; delete → rm -f, deltree → rm -rf (refuse /), mkdir → mkdir -p, move → mv --, copy → cp -a (conflict rules: error → test -e dst && echo EXISTS first; skip → cp -an; replace → rm -rf dst; cp -a), stat → stat of one path into proto.FSStatResult, chmod → chmod/chown -R when recursive. Non-zero exit → error with stderr (first 300 bytes).

  • sshfsClose(id) closes the client and deletes the entry.

  • sshfs.send / sshfs.put: like doFSSend / doFSPut in fsstream.go, but the reader is cat -- path stdout and the writer is cat > tmp stdin of an SSH session; size from stat -c %s / stat -f %z. Dispatch in users.go: case proto.TaskSSHFSOpen, proto.TaskSSHFSClose, proto.TaskSSHFSOp: return a.sshfsTask(env) — dispatchTask has no agent receiver, so add these cases in runTask (task.go) before the default instead, next to TaskFSPut/TaskFSSend.

  • Step 4: Run the tests

Run: go test ./internal/agent -run 'TestParseStatLine|TestStatCommandQuotes' -v && go vet ./internal/agent Expected: PASS.

  • Step 5: Build

Run: go build ./... && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o /dev/null ./cmd/agent Expected: no output. (The commands run against a real host in Task 6, Step 4.)

  • Step 6: Commit
git add internal/agent/sshfs.go internal/agent/sshfs_test.go internal/proto/proto.go internal/agent/task.go
git commit -m "feat: agent keeps SSH file sessions and runs file operations on the host by commands"

Task 6: Server and pane for files over SSH

Files:

  • Create: internal/server/sshfs.go
  • Modify: internal/server/server.go (routes), internal/server/web/static/filepane.js (SSHSource, sshfiles pane type), internal/server/web/static/workspace.js (menu «Файловый менеджер › SSH-хост…»)

Interfaces:

  • Consumes: agent tasks from Task 5; s.waitTask, s.xfers, fsAgent, CanUseAgent, Audit.

  • Produces (HTTP): POST /api/panel/agents/{id}/sshfs/open ({ssh} → {ok, data:{conn, home}}); POST /api/panel/agents/{id}/sshfs/{conn}/close; POST /api/panel/agents/{id}/sshfs/{conn}/{op} (body = the fs.* args shape); GET /api/panel/agents/{id}/sshfs/{conn}/download?path=; POST /api/panel/agents/{id}/sshfs/{conn}/upload?path=&size= (then pieces through the existing PUT /api/panel/fs/xfer/{xid}/{i}). JS: SSHSource(agent, conn, target) with the data source interface of Task 4 (caps: {zip: false, sqlite: false}); Workspace.paneTypes.sshfiles = (agent, opts) => new FilePane({kind: 'ssh', agent, conn: opts.conn, ssh: opts.ssh, home: opts.home}); source.id = agent.id + '|' + user + '@' + host + ':' + port.

  • Step 1: Write the failing Go test

In internal/server/frag_test.go (it has newTestServer), add:

func TestSSHFSOpen(t *testing.T) {
	s, admin := newTestServer(t)
	post := func(c *http.Cookie) *httptest.ResponseRecorder {
		req := httptest.NewRequest(http.MethodPost, "/api/panel/agents/x1/sshfs/open", strings.NewReader(`{"ssh":{"host":"h","user":"u","password":"p"}}`))
		req.AddCookie(c)
		w := httptest.NewRecorder()
		s.Handler().ServeHTTP(w, req)
		return w
	}
	// The machine exists but is offline: the route answers in JSON.
	if w := post(admin); w.Code != 200 || !strings.Contains(w.Body.String(), "офлайн") {
		t.Fatalf("admin: %d %s", w.Code, w.Body.String())
	}
	// A user without the machine does not reach it.
	hash, _ := hashPassword("userpass123")
	id, _ := s.db.CreateUser("bob", hash, RoleUser)
	_ = s.db.CreateSession("sid-bob", id, sessionTTL)
	if w := post(&http.Cookie{Name: sessionCookie, Value: "sid-bob"}); w.Code != http.StatusNotFound {
		t.Fatalf("a user without the machine got %d", w.Code)
	}
}
  • Step 2: Run it to see it fail

Run: go test ./internal/server -run TestSSHFSOpen -v Expected: FAIL at «admin: 404» (no route yet).

  • Step 3: Write internal/server/sshfs.go

Handlers that wrap s.waitTask(r.Context(), agentID, proto.TaskSSHFS…, proto.SSHFSArgs{…}, fsTaskTimeout) and writeJSON the result; download/upload reuse handleFSDownload/handleFSUploadStart logic with proto.TaskSSHFSSend/TaskSSHFSPut and FSXferArgs plus Conn (add Conn string json:"conn,omitempty" to FSXferArgs). Changes (write, mkdir, delete, deltree, move, copy, chmod, upload) go to s.db.Audit(admin.ID, agentID, "sshfs."+op+":"+target+" "+path). Unknown task on an old agent (unknownOp(res)) → {"ok":false,"error":"агент не умеет файлы по SSH - обновите его"}. Routes go under requireAdmin, so the machine access check of requireAdmin applies (path starts with /api/panel/agents/).

  • Step 4: Write the pane and menu, check end to end

SSHSource in filepane.js calls the routes above; FilePane hides «В zip», «Распаковать» and opens .db files as plain downloads when !src.caps.zip / !src.caps.sqlite. Closing the pane calls src.close(). Menu «Файловый менеджер › SSH-хост…» opens the existing SSH dialog (openSSH(here) in workspace.js) with a mode argument; for mode === 'files' the submit calls /sshfs/open and on success Workspace.open('sshfiles', agent, {conn, ssh: {host, port, user}, home}) (secrets are not kept). Stand check with the test sshd (127.0.0.1:28622, t/secret): open, list home, go into the scratchpad, view a text file, edit and save, upload a file, download it, mkdir, rename, chmod, delete. Expected: every operation works; ps on the stand shows one SSH connection per open pane.

  • Step 5: Close releases (Review Focus 4)

With an SSH file pane open, count connections to the test sshd: lsof -nP -iTCP:28622 -sTCP:ESTABLISHED | grep -c agent. Close the tab; count again within 2 s. Expected: the count drops by one.

  • Step 6: Run the tests and commit

Run: go test ./internal/... && go vet ./... Expected: PASS (including TestSSHFSOpen).

git add internal/server/sshfs.go internal/server/server.go internal/server/frag_test.go internal/proto/proto.go internal/server/web/static/filepane.js internal/server/web/static/workspace.js
git commit -m "feat: file manager on SSH hosts through a machine - open from the plus menu"

Task 7: Drag and drop of files

Files:

  • Create: internal/server/web/static/dnd.js
  • Modify: internal/server/web/static/filepane.js (drag start on rows), internal/server/web/static/workspace.js (expose paneAt(x, y) and tab hover activation), internal/server/web/static/fileviews.js (followJob), internal/server/web/templates/app.html (drop dialog markup), internal/server/web/static/app.css, internal/proto/proto.go, internal/agent/ship.go, internal/agent/sshfs.go, internal/agent/sshfs_test.go, internal/server/ship.go

Interfaces:

  • Consumes: WSUtil.dropAction, Pane.dropTarget, Pane.drop, FilePane.selectedPaths, FileViews.move internals (copy within a source; fs/ship between sources, sshfs/{conn}/copy within an SSH host).

  • Produces: window.DnD = {start(ev, pane, paths)}; Workspace.paneAt(x, y) → Pane|null; Workspace.tabAt(x, y) → tab|null.

  • Step 1: Pure logic first

WSUtil.dropAction already decides (Task 1). Add a test for dropping onto the same folder of a different SSH user on the same host, which is a different place:

test('dropAction: other SSH user on the same host is another place', () => {
  const a = {kind: 'ssh', id: 'm1|root@h:22', label: 'root@h'};
  const b = {kind: 'ssh', id: 'm1|bob@h:22', label: 'bob@h'};
  assert.strictEqual(U.dropAction({source: a, paths: ['/x'], dir: '/'}, {kind: 'term', source: b}).kind, 'deny');
});

Run: cd internal/server/web/static && node --test wsutil.test.js → PASS (no code change needed; the test pins the rule).

  • Step 2: Write dnd.js

  • start(ev, pane, paths): on pointerdown on a row (desktop only, not on buttons/checkbox), after 6 px movement create a badge div.dnd-badge (name or «N элементов») following the pointer; on move: const p = Workspace.paneAt(x, y), const tgt = p && p.dropTarget(x, y), const act = tgt ? WSUtil.dropAction(item, tgt) : {kind: 'none'}; highlight the pane (.drop-ok / .drop-deny) and show act.reason in the badge for deny; over a tab header for 500 ms → Workspace.activate(tab).

  • on release: paste → p.drop(item); deny → toast with the reason; transfer → open the drop dialog at the pointer.

  • Drop dialog (<div class="dropdlg"> positioned near the pointer, not a full modal): title «N элементов → :

    », buttons «Переместить», «Копировать», «Отмена», select «если уже есть: сообщить / заменить / оставить». On choice, for each path:

    • same place, machine: src.op('copy', {src, dst: dir + '/' + name, move, conflict});
    • same place, SSH host: src.op('copy', …) (the agent runs cp -a / mv there);
    • different places: POST /api/panel/agents/{fromAgent}/fs/ship with {src, name, dir, conflict, move, to: toAgent, fromConn, toConn} — fromConn / toConn are the SSH panes' connection ids (empty for a machine pane). Show the job's progress in the dialog (the job polling of the move dialog, moved to a shared FileViews.followJob(job, el)), then reload() both panes.
  • Esc cancels a drag.

  • Step 2b: Transfers to and from SSH panes through held connections

The page keeps no SSH secrets, but the agent of each SSH pane holds its connection, so the tar transfer can use it:

  • proto.FSShipArgs gets Conn string json:"conn,omitempty": the side whose files are on an SSH host held by this agent under that id.
  • Agent doShipOut: when args.Conn != "", instead of packTar locally, run tar -cf - -C <dir of src> <base of src> on the held connection (sshfsGet(args.Conn)), copying its stdout to the relay pipe; the entry names are renamed to args.Name by streaming through tar.Reader → tar.Writer with hdr.Name rewritten (first path element → args.Name). Progress counts bytes read. Move → rm -rf -- src on the host after success.
  • Agent doShipIn: when args.Conn != "", instead of the local unpacker, pipe the relay into mkdir -p -- dir && cd -- dir && tar -xf - (+ --skip-old-files / -k / --overwrite as shipSSH picks them) on the held connection; error conflict checks test -e first.
  • Server shipReq gets FromConn, ToConn; ship1 passes Conn: q.FromConn to the out task and Conn: q.ToConn to the in task; when q.To == "" and q.ToConn != "" the target agent is from itself. fsAgent and CanUseAgent checks stay as they are.
  • Go test in internal/agent/sshfs_test.go:
func TestRenameTarEntries(t *testing.T) {
	var src bytes.Buffer
	tw := tar.NewWriter(&src)
	for _, n := range []string{"old/", "old/a.txt", "old/sub/b"} {
		_ = tw.WriteHeader(&tar.Header{Name: n, Mode: 0o644, Typeflag: map[bool]byte{true: tar.TypeDir, false: tar.TypeReg}[strings.HasSuffix(n, "/")]})
	}
	_ = tw.Close()
	var out bytes.Buffer
	if err := renameTar(&out, &src, "new"); err != nil {
		t.Fatal(err)
	}
	tr := tar.NewReader(&out)
	var names []string
	for {
		h, err := tr.Next()
		if err != nil {
			break
		}
		names = append(names, h.Name)
	}
	if strings.Join(names, ",") != "new/,new/a.txt,new/sub/b" {
		t.Fatalf("names: %v", names)
	}
}

with func renameTar(w io.Writer, r io.Reader, name string) error in sshfs.go (copies entries, replacing the first path element with name). Run: go test ./internal/agent -run TestRenameTarEntries -v → FAIL before renameTar, PASS after.

  • Step 3: Check in the browser

SSH pane (test sshd) and a machine pane side by side: drag a folder from the machine into the SSH pane → «Копировать» → it appears on the host (check with ls in an SSH terminal); drag a file from the SSH pane back → «Переместить» → it is gone from the host and present on the machine. Two file panes on the machine side by side; drag slow.bin from left to right → dialog → «Копировать» → file appears on the right. Drag a folder → «Переместить» → gone on the left, present on the right. Drop a file onto a folder row in the same pane → dialog with that folder. Drag onto a terminal pane of the same machine → the quoted path appears in the prompt, no Enter. Open a terminal of another machine (or an SSH terminal) and drop → red highlight and «Нельзя: файл на …, а терминал на …». Path with a space and a quote is pasted quoted.

  • Step 4: Commit
git add internal/server/web/static/dnd.js internal/server/web/static/filepane.js internal/server/web/static/fileviews.js internal/server/web/static/workspace.js internal/server/web/static/wsutil.test.js internal/server/web/static/app.css internal/server/web/templates/app.html internal/proto/proto.go internal/agent/ship.go internal/agent/sshfs.go internal/agent/sshfs_test.go internal/server/ship.go
git commit -m "feat: drag files between file panes (move or copy, SSH hosts too) and into a terminal of the same machine"

Task 8: Machine dialogs

Files:

  • Create: internal/server/web/static/machine.js
  • Modify: internal/server/web/templates/app.html (<dialog id="machinedlg">), internal/server/web/templates/dashboard.html (row buttons data-dialog), internal/server/web/static/workspace.js (pane/tab menu items), internal/server/web/static/pages.js (modules status, forwards, edit with unmount)

Interfaces:

  • Consumes: App.put, App.load, the page modules registered in pages.js.

  • Produces: window.MachineDialog = {open(agentId: string, page: 'forwards'|'status'|'edit', name: string)}.

  • Step 1: Write machine.js

// Порты, Статус and Изменить of a machine in a dialog over the current page:
// the server's pages as fragments, so nothing behind is lost.
(function () {
  'use strict';
  const TITLES = {forwards: 'Порты', status: 'Статус', edit: 'Изменить'};
  const dlg = document.getElementById('machinedlg');
  const area = dlg.querySelector('.frag-area');
  function open(id, page, name) {
    dlg.querySelector('.md-title').textContent = name + ' · ' + TITLES[page];
    area.innerHTML = '<p class="muted" style="padding:16px">загрузка…</p>';
    dlg.showModal();
    App.load(area, '/agents/' + encodeURIComponent(id) + '/' + page);
  }
  dlg.addEventListener('close', () => App.put(area, ''));
  dlg.querySelector('.md-close').onclick = () => dlg.close();
  document.addEventListener('click', ev => {
    const b = ev.target.closest('[data-dialog]');
    if (!b) return;
    ev.preventDefault();
    open(b.dataset.id, b.dataset.dialog, b.dataset.name);
  });
  if (window.APP && window.APP.dialog) {
    const m = /^\/agents\/([^/]+)\/(\w+)/.exec(window.APP.dialog);
    if (m) open(m[1], m[2], m[1]);
  }
  window.MachineDialog = {open};
})();

App.put(area, '') unmounts the fragment's module, so its timers stop. Markup in app.html:

<dialog id="machinedlg" class="machinedlg"><div class="dlg-h"><strong class="md-title" style="flex:1"></strong>
<button type="button" class="btn ghost small icon close x md-close" aria-label="Закрыть">✕</button></div>
<div class="frag-area"></div></dialog>

CSS: .machinedlg { max-width: 1000px; width: 94vw; max-height: 90vh } .machinedlg .frag-area { overflow: auto; max-height: calc(90vh - 56px) } @media (max-width: 640px) { .machinedlg { width: 100vw; max-width: none; height: 100vh; max-height: none; margin: 0; border-radius: 0 } }.

  • Step 2: Unmount stops timers (Review Focus 5)

In pages.js the status module keeps every interval and timeout it starts in this.timers and unmount() clears them all and aborts its fetches (AbortController kept in this.ac). Check: open «Статус» of the machine, wait 3 s, close the dialog, then in DevTools Network filter /stat: after 5 s no new requests. Expected: no requests after closing.

  • Step 3: Check forms in the dialog

«Изменить» → rename the machine → the dialog redraws with the new name, the machines list behind updates on next show; «Порты» → add a rule and delete it; «Изменить» → «Обновить клиента» shows its result in the dialog. A user (bob) cannot open the dialog of a machine he does not have (404 text in the dialog).

  • Step 4: Commit
git add internal/server/web/static/machine.js internal/server/web/static/pages.js internal/server/web/static/workspace.js internal/server/web/templates/app.html internal/server/web/templates/dashboard.html internal/server/web/static/app.css
git commit -m "feat: machine pages Порты, Статус, Изменить as dialogs over the current page"

Task 9: Edge cases, version, README, regression

Files:

  • Modify: internal/server/web/static/filepane.js, internal/server/web/static/workspace.js, VERSION.txt, README.md

  • Step 1: Offline machine and lost access in panes

FilePane: when an op answers машина офлайн, show in the pane «машина офлайн» with «Повторить»; subscribe to the workspace's presence poll (Workspace already polls /api/panel/agents/presence for the menu — make it poll every 15 s while any file pane exists) and reload() when the machine comes back. A 404 from the machine's API → «нет доступа к машине» in the pane. SSH pane: the agent already reconnects once by itself (Task 5); when an op still answers подключение к хосту потеряно or подключение закрыто, the pane shows that text and a «Переподключиться» button that opens the SSH dialog prefilled with host, port and user (the secret is typed again) and replaces the pane's conn. Check: stop the agent on the stand → file pane shows «машина офлайн»; start it → pane reloads within 15 s.

  • Step 2: Old agent

Build an agent from before this work and run it on the stand: git worktree add /tmp/lpra-old 0fa3de7 && (cd /tmp/lpra-old && go build -o $STAND/agent-old ./cmd/agent) && git worktree remove /tmp/lpra-old; stop the stand agent, start agent-old with the same environment. Open «Файловый менеджер › SSH-хост…» and connect. Expected: the dialog shows «агент не умеет файлы по SSH - обновите его». Stop agent-old, start the current agent again.

  • Step 3: Version and README

echo 0.2.0 > VERSION.txt. README: replace the terminal and files paragraphs with: the panel is one page (Машины, Терминал, Настройки); terminals and file managers are tabs that survive moving between pages and are reset on reload (the browser asks first); up to four panes in a tab, terminal or files; file manager on an SSH host through a machine (no SFTP needed, commands over SSH; zip and the SQLite editor only on machines); dragging files between panes (move/copy) and into a terminal of the same machine (the path is pasted); Порты, Статус, Изменить open as dialogs.

  • Step 4: Full regression on the stand

Run every item of the spec's «Проверка» list (1–7) in Chrome desktop and the phone emulation (390x844x2,mobile,touch): PIN on a new tab, passkey login screen, settings forms (users table, host, tunnel, install code), SQLite editor in a file pane, transfer to another machine and by SSH from the move dialog, direct address settings. Plus go test ./... && go vet ./... and cd internal/server/web/static && node --test wsutil.test.js. Expected: all pass; note any failure and fix it in the task that owns the code before committing.

  • Step 5: Commit
git add VERSION.txt README.md internal/server/web/static/filepane.js internal/server/web/static/workspace.js
git commit -m "feat: file panes follow the machine going offline and back; version 0.2.0, README"
git push origin main