1074 lines
67 KiB
Markdown
1074 lines
67 KiB
Markdown
# 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
|
||
```
|