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

1074 lines
67 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`:
```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`**
```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`:
```go
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**
```bash
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`:
```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:
```go
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:
```html
{{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`:
```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`:
```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:
```go
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`:
```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:
```js
// 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:
```js
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**
```bash
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:
```js
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**
```bash
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**
```bash
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):
```go
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`:
```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`**
```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**
```bash
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:
```go
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`).
```bash
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:
```js
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 элементов → <label>:<dir>», 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`:
```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**
```bash
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`**
```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`:
```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**
```bash
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**
```bash
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
```