99 Commits
Author SHA1 Message Date
redtea fcf032042b release: 0.2.2 2026-10-18 11:09:16 +03:00
redtea 01f02360c4 chore: the GitLab namespace is itzredtea now 2026-10-18 11:03:31 +03:00
redtea 57de7bf967 chore: move to GitLab - server-install.sh takes the latest release and dist/ from gitlab.com/itzRedTea/lpra, README links and screenshots kept in the repo; a guide for moving other projects 2026-10-18 10:44:18 +03:00
redtea 173f46a145 fix: Qwen Omni gets its files right - laid out by the app's own inputs (audio before image there, they were swapped), a video as Gradio 5 takes one ({video, subtitles}); the model is told it has the files, so it answers about them 2026-10-17 15:42:37 +03:00
redtea 03f963340e feat: «Думать» uses a model's own thinking when it has it (OpenAI-compatible reasoning, Anthropic extended thinking) instead of asking it to think in words too; learned per model, the word-thinking prompt kept for models without; without «Думать» such a model is asked not to think 2026-10-17 15:36:35 +03:00
redtea 37ef886503 feat: the model's short name up to 12 characters 2026-10-17 15:33:19 +03:00
redtea 6c1da79b4a feat: the model's short name up to 9 characters, the chat 408px wide so nine of the widest letters fit 2026-10-17 15:07:49 +03:00
redtea 6a17f40042 feat: a short name of up to 7 characters for each model, on the chat's model button, always whole; the message field back to the normal 14px 2026-10-17 14:58:04 +03:00
redtea 351e4ad28d fix: the chat's message field lower - 42px for a line instead of 52, the clip and send to scale, still centred on the last line 2026-10-17 14:47:42 +03:00
redtea 983809714b feat: several models for the assistant, each with its own name, kind, address and key; a model menu in the chat when there are several (or always, from Аккаунт › Вид), the choice kept for the user 2026-10-17 14:41:34 +03:00
redtea ded86e1c3f fix: the panel's input dialog - a dark field as everywhere, with the accent outline when focused 2026-10-16 22:08:30 +03:00
redtea b4bcc05162 docs: a plan for flow control in the direct tunnel, for when one slow connection stalling an agent's others starts to matter 2026-10-16 22:02:59 +03:00
redtea e8d45f2f7b docs: http-lp forwarding goes over the agent's WebSocket, by long polls only without one - said so in the panel, the code and the README 2026-10-16 21:56:24 +03:00
redtea e93a2bbd3a fix: the stop button blue again, as the plane 2026-10-16 21:51:03 +03:00
redtea e0b3999d0c feat: a chat of the assistant is deleted at once, without asking 2026-10-16 21:47:50 +03:00
redtea 77322e8644 chore: update build 2026-10-16 21:44:43 +03:00
redtea 6085b5a094 fix: the stop square fades away as a red square and becomes the plane only once hidden; the square a little bigger 2026-10-16 21:44:09 +03:00
redtea e0cc5a06b7 fix: server-install.sh - an answer «n» about nginx no longer counts as nginx set up, so without nginx the server listens on every address 2026-10-16 21:24:27 +03:00
redtea 667c6177f4 feat: server-install.sh - install or update the server in one command: the latest release's binary, dist/ from main, the systemd service, nginx with a domain and certbot if wanted, a health check 2026-10-16 20:47:26 +03:00
redtea 86f7c96807 feat: rewrite nginx files 2026-10-16 20:18:06 +03:00
redtea cf0b2baf75 fix: gitignore 2026-10-16 20:15:47 +03:00
redtea 2ab2aa1e38 fix: the message field grows only when the word really wraps (measured without a scrollbar taking its width); the stop button red 2026-10-16 20:04:38 +03:00
redtea 087eabf3b4 feat: «Здесь» from a machine's terminal opens the file pane in the terminal's folder (asked of the agent); Аккаунт › Вид can turn «Здесь» back to the home folder. Version 0.2.1 2026-10-16 18:28:15 +03:00
redtea 57c2f1338f feat: «Здесь» from a machine's file pane opens the terminal in that pane's folder (the agent starts the shell there; an older agent, or a folder gone, at home) 2026-10-16 18:17:11 +03:00
redtea 4fb26a9652 feat: the chat's arrow down midway between the answers' right edge and the chat's border, fading in and out 2026-10-16 18:11:21 +03:00
redtea 46f7f743b3 fix: the clip and the send button keep to the last line of the message, as in Telegram 2026-10-16 18:04:14 +03:00
redtea 70729ef63f feat: folders and files the assistant cannot take, dropped from a file pane, go into the message as their paths; a file that fails shows a red note over the field, fading in and out like an error toast 2026-10-16 18:02:43 +03:00
redtea 2d6b3916c8 fix: a clearer chat mark in the empty chat (two bubbles), a bigger send plane 2026-10-16 17:57:25 +03:00
redtea 35ad2d435b feat: the send button as in Telegram - a blue plane with no plate, coming in only when there is something to send; the empty chat shows a chat mark and «Спросите AI помощника» 2026-10-16 17:56:53 +03:00
redtea 37ae830cbf fix: Safari - drags no longer select page text (user-select needs its -webkit- twin there), the drag ghost follows the pointer without a transition 2026-10-16 17:51:27 +03:00
redtea ffe7c138f1 fix: the send button as far from the right edge as from top and bottom 2026-10-16 17:37:04 +03:00
redtea ae8fffe7d8 feat: fetch for the assistant with Поиск on - a page's main text for the model, the server's own network out of reach; shorter words in Настройки and the chat; the + menu's roots a step left. Also files from panes into the chat, docview and drag fixes (by the user) 2026-10-16 17:32:52 +03:00
redtea 84b42c5369 Add multimodal AI chat attachments 2026-10-16 15:46:23 +03:00
redtea 8aba30a093 fix: the machine's dot in the + menu centred like the other icons 2026-10-16 12:03:11 +03:00
redtea 9337bd26b0 feat: Машины - column names centred over their values 2026-10-16 11:58:33 +03:00
redtea 8bbb2b0ccb fix: machines in the + menu line up with the other items 2026-10-16 11:57:49 +03:00
redtea 80ed7d4ffa feat: a new chat is named by the model in two or three words, asked aside; the machine's name gets its room in the chat's header 2026-10-16 11:23:52 +03:00
redtea 6442c39360 fix: the chat's machine name on one line without the dot, cut with …; close buttons always square 2026-10-16 11:17:32 +03:00
redtea d5d57fbf1b feat: Вид - tab icons слева, справа or отключены; the lists one width 2026-10-16 11:13:12 +03:00
redtea 7ae75e910a fix: «Думал N сек.» holds its arrow inside its highlight, a quiet one 2026-10-16 11:06:31 +03:00
redtea 74a22ab57f fix: the assistant is told which search engine it has, results say which one they came from (and why, when DuckDuckGo stood in), the stand-in is logged 2026-10-16 10:59:39 +03:00
redtea 060975a443 test: a search engine out of its limit gives way to DuckDuckGo 2026-10-16 10:53:53 +03:00
redtea fdd0483286 feat: no note under the command in the assistant's question 2026-10-16 10:48:34 +03:00
redtea fcf9417f5a feat: the assistant's chat - the name clear of the close button, typing dots while the model starts, thinking that opens without reflow; asking before a command: «Выполнить команду на [машина]?», the command in mono, a red «Отмена» 2026-10-16 10:48:05 +03:00
redtea 3641a3627c docs: README - the assistant's search engines 2026-10-16 10:38:22 +03:00
redtea 9912255289 feat: the assistant's search engine in Настройки - DuckDuckGo, a SearXNG of one's own, Brave Search or Tavily, with DuckDuckGo behind the chosen one 2026-10-16 10:38:12 +03:00
redtea 9bf30b281e feat: Поиск - with it on the assistant searches DuckDuckGo itself and answers from what it found; without it, it cannot. «Думал N сек.» 2026-10-16 10:32:33 +03:00
redtea fa62f2c5b6 fix: Думать - the model is reminded in the question itself, the label no longer lands in the arrow, and the thinking with its time is kept to show again later 2026-10-16 10:29:44 +03:00
redtea 3b7a6271ac feat: a dragged tab held over another tab's title opens it to be dropped into; let go with nothing done, the dragged tab comes back 2026-10-16 10:21:37 +03:00
redtea 5216aed237 feat: Аккаунт › Вид - tab icons left, right or hidden, terminal font, compact file lists, the time column, animations; kept per user on the server. Uploads show a ring filling as they go 2026-10-16 10:17:07 +03:00
redtea c003b51b4c feat: a small terminal or folder icon before each tab's name says what it holds 2026-10-16 10:12:00 +03:00
redtea 96163ebf86 fix: dragging files selects no text; without a drag the names select as before 2026-10-16 10:10:08 +03:00
redtea bc1736f105 fix: menus open right by their button again - the page's entrance animation no longer holds a transform that moved fixed menus 2026-10-16 10:09:20 +03:00
redtea b23943ff42 feat: the assistant keeps several chats - the current one named in the header, the list under its arrow to open or delete; actions as compact cards; folding parts open smoothly 2026-10-16 10:06:06 +03:00
redtea 6d8f39f2a5 fix: commands on SSH hosts run under sh -c, so hosts whose login shell is csh or tcsh list, zip and send files too 2026-10-16 10:02:10 +03:00
redtea 458d826de6 fix: the question window as wide as its words 2026-10-16 09:54:13 +03:00
redtea 0c44ef006d fix: a long name in a question is cut with … 2026-10-16 09:39:48 +03:00
redtea 187dc2ae97 feat: PostgreSQL as the panel's database, chosen in Настройки, the SQLite data copied over on its first start; tested on a real PostgreSQL 2026-10-16 09:39:06 +03:00
redtea 055020c3bb feat: links to folders open as folders, on machines and SSH hosts, with their own icon; deleting a link leaves what it leads to 2026-10-16 09:34:32 +03:00
redtea 4da1cc4200 feat: the assistant's answers in Markdown; thinking shows «Думаю…», then «Думал N с», opening smoothly by its arrow 2026-10-16 09:33:23 +03:00
redtea 4b7220fffb feat: account dialog - Выйти beside the name, passkeys last after a line, the add button the field's height; a file pane's status says it is connected 2026-10-16 09:32:04 +03:00
redtea 434900a168 feat: long names in questions cut with … and the window as wide as its words; SSH dialog hides «Вход» for a saved host and says «подключение…» quietly; zip and unzip on SSH hosts without them through Python's zipfile 2026-10-16 09:31:21 +03:00
redtea 6db78399ac feat: the workspace at /workspace; Настройки show the assistant's model as set, key included, and PostgreSQL's fields only when chosen, its two buttons on one line; pages come in softly 2026-10-16 09:30:19 +03:00
redtea 352b016384 fix: the editor shows an empty page, not the last file, while a file loads; text in UTF-16 and with BOM; + menu groups open and shut smoothly; the assistant's errors in plain words 2026-10-16 09:28:00 +03:00
redtea b272aed8fd Fix settings handler build 2026-10-15 22:48:03 +03:00
redtea ce7c417884 feat: the assistant's send is a Telegram-like plane, its height pinned to the field's 2026-10-15 22:38:37 +03:00
redtea bfa38e7d18 feat: the + menu without descriptions, narrower 2026-10-15 22:35:30 +03:00
redtea 00617451aa feat: the assistant's talk is kept in the database per user and the model sees a trimmed window of it; clear is a broom; the ball steps aside for the chat and comes back where the chat was 2026-10-15 22:35:10 +03:00
redtea 92a90646a5 fix: address fields styled like the others 2026-10-15 22:32:36 +03:00
redtea 69600133a4 docs: README - the assistant's actions and modes, saved SSH hosts and their key 2026-10-15 22:30:09 +03:00
redtea 3044e650d3 feat: on a computer the assistant's ball and chat can be dragged, the chat opening beside the ball; on a phone a smaller ball further from the edges; the send button the field's height 2026-10-15 22:29:55 +03:00
redtea ed77c8e0c1 style: gofmt 2026-10-15 22:28:59 +03:00
redtea 7d1996dd7d perf: several dragged items cross to another place as one tar stream; older agents get them one by one 2026-10-15 22:28:46 +03:00
redtea 6916af4236 perf: SSH uploads fetch pieces ahead and downloads post them side by side 2026-10-15 22:25:54 +03:00
redtea 8d7489b577 feat: the assistant's Агент and Думать modes as pills; thinking shows folded above the answer 2026-10-15 22:24:29 +03:00
redtea 847bcd68c0 perf: the browser terminal's WebSocket deflates larger messages 2026-10-15 22:22:59 +03:00
redtea a7e43ab700 feat: assistant reads, lists and writes files and runs commands on any of the user's machines; agent mode does the steps itself, asking before each command and write 2026-10-15 22:21:54 +03:00
redtea d9c7c2dd42 feat: the assistant's chat slides open and shut, smaller on a computer, a screen of its own on a phone that «назад» closes; a sparkle button 2026-10-15 22:19:23 +03:00
redtea 94bf292494 feat: saved SSH hosts per user, the way in sealed with AES-GCM under a server key kept beside the database 2026-10-15 22:18:27 +03:00
redtea 1f930e5aea feat: the panel's own confirm and prompt dialogs everywhere; the assistant's field is one line with a square send button 2026-10-15 22:16:02 +03:00
redtea 3f113ce8d6 feat: on a phone the header's links scroll under a pinned profile button; the assistant's close is red 2026-10-15 22:11:18 +03:00
redtea 46948f5ddc feat: AI assistant (MVP) - chat button per user, sees the workspace, streams answers, runs suggested commands in the active terminal; Gradio, OpenAI and Anthropic models set in Настройки 2026-10-15 22:10:47 +03:00
redtea 9572a35ad5 feat: on a phone Вверх, the path and Обновить share one line; the header is one line without the logo 2026-10-15 22:10:46 +03:00
redtea 64ce8f2b72 fix: ended page scopes start no timers; SSH transfers keep their connection from the idle sweep; linked files download whole; only the sent item unpacks on an SSH host, replace and skip as on a machine 2026-10-15 22:05:04 +03:00
redtea e05ff05015 feat: on a phone the selection's zip and delete sit on the line of the blue buttons; moved to the next line they start on the left 2026-10-15 22:01:17 +03:00
redtea 01dc45ecad feat: file panes follow the machine going offline and back, reconnect a lost SSH host; zip and unzip over SSH where the host has them; phone keys only for terminals; version 0.2.0, README 2026-10-15 21:58:09 +03:00
redtea d92696226e feat: machine pages Порты, Статус, Изменить as dialogs over the current page; Действие column in the ports table 2026-10-15 21:49:18 +03:00
redtea 2009796421 feat: the + joins a lone file pane's top bar, beside Обновить and above the delete button, growing in smoothly 2026-10-15 21:44:15 +03:00
redtea f2740c50b4 feat: drag files between file panes (move or copy, SSH hosts too) and into a terminal of the same machine 2026-10-15 21:42:21 +03:00
redtea a30b89f1d8 feat: square icon buttons for up and refresh in file panes; the Терминал view is named WorkSpace 2026-10-15 21:33:14 +03:00
redtea 7e4daeda4c feat: file manager on SSH hosts through a machine - open from the plus menu 2026-10-15 21:32:18 +03:00
redtea 2859e69abe feat: agent keeps SSH file sessions and runs file operations on the host by commands 2026-10-15 21:26:11 +03:00
redtea f5d6bd30e8 feat: file manager as a workspace pane, side by side with terminals; zip and delete of the selection as icons 2026-10-15 21:24:10 +03:00
redtea 012c0af7f9 feat: workspace lives in the terminal view and keeps its sessions across pages; terminal panes split out 2026-10-15 20:10:28 +03:00
redtea 113c63fd1b feat: one-page panel - shell with Машины, Терминал and Настройки, pages as fragments, forms sent in place 2026-10-15 20:05:20 +03:00
redtea 7b9daa524e feat: workspace helpers - quoting paths, what a dropped file does, with node tests 2026-10-15 20:00:26 +03:00
redtea 0ada0899cf docs: 0.2.0 workspace implementation plan 2026-10-15 19:58:32 +03:00
redtea 8e2028e7f9 docs: workspace design approved, version 0.2.0 2026-10-15 19:52:43 +03:00
redtea 698bc68f99 docs: 0.1.3 workspace design - one-page shell, tabs with terminal and file panes (machines and SSH hosts), drag and drop, machine dialogs 2026-10-15 19:51:02 +03:00
94 changed files with 14109 additions and 3021 deletions

No files matched your search

-1
View File
@@ -34,7 +34,6 @@ go.work.sum
bin bin
dist
*.db *.db
.DS_Store .DS_Store
*/.DS_Store */.DS_Store
+73 -8
View File
@@ -12,9 +12,14 @@
``` ```
## Возможности ## Возможности
- **Терминал** - вкладки на нескольких машинах сразу, до четырёх терминалов в одной вкладке - **Одна страница** - Машины, WorkSpace (терминалы и файлы), Настройки; переходы без перезагрузки.
(перетаскиванием, с изменяемыми размерами), переименование вкладок. На телефоне - панель Вкладки WorkSpace живут, пока открыта вкладка браузера: уход на другие страницы их не
клавиш (Esc, Ctrl, Alt, стрелки и т.д.). закрывает, перезагрузка сбрасывает (браузер сначала спросит). «Порты», «Статус» и «Изменить»
машины открываются окном поверх текущей страницы - из списка машин или правым кликом
по заголовку вкладки или панели.
- **WorkSpace** - вкладки на нескольких машинах сразу, до четырёх панелей в одной вкладке -
терминал или файловый менеджер (перетаскиванием, с изменяемыми размерами), переименование
вкладок. На телефоне - панель клавиш (Esc, Ctrl, Alt, стрелки и т.д.).
- **SSH-терминал** - в меню «+»: агент открывает SSH-соединение к другому хосту (по паролю, - **SSH-терминал** - в меню «+»: агент открывает SSH-соединение к другому хосту (по паролю,
ключу - можно загрузить файл - или своими ключами из `~/.ssh`), шелл открывается во вкладке, ключу - можно загрузить файл - или своими ключами из `~/.ssh`), шелл открывается во вкладке,
а шаги подключения видны в статусе. Ключ хоста запоминается при первом входе а шаги подключения видны в статусе. Ключ хоста запоминается при первом входе
@@ -22,10 +27,37 @@
- **Файлы** - просмотр, редактор с подсветкой, hex-редактор, предпросмотр картинок, видео, PDF, - **Файлы** - просмотр, редактор с подсветкой, hex-редактор, предпросмотр картинок, видео, PDF,
баз SQLite с правкой (значение по клику, добавление и удаление строк, вкладка SQL; агент меняет базу через сам SQLite, так что работающая база не ломается); загрузка и скачивание до 512 МБ кусками по 4 МБ (проходит через Cloudflare); баз SQLite с правкой (значение по клику, добавление и удаление строк, вкладка SQL; агент меняет базу через сам SQLite, так что работающая база не ломается); загрузка и скачивание до 512 МБ кусками по 4 МБ (проходит через Cloudflare);
свойства (chmod/chown), zip/unzip. свойства (chmod/chown), zip/unzip.
- **Файлы по SSH** - в меню «+» › «Файловый менеджер» › «SSH-хост…»: машина подключается к хосту
и работает с файлами командами по SSH, SFTP не нужен. Есть всё, кроме редактора SQLite;
zip и распаковка - если на хосте установлены `zip` и `unzip`. Оборвалось подключение -
панель предложит переподключиться.
- **Перетаскивание файлов** - строку (или все отмеченные) в другую файловую панель или в папку
в ней: окно «Переместить / Копировать» у места броска, между машинами и SSH-хостами - с
прогрессом. В терминал той же машины или того же SSH-хоста - вставляется путь в кавычках,
в терминал другой - нельзя, с подсказкой почему. Задержать над заголовком вкладки - она
откроется. Только на компьютере.
- **Перемещение и копирование** - внутри машины или, кнопкой «сеть» в том же окне, **на другую - **Перемещение и копирование** - внутри машины или, кнопкой «сеть» в том же окне, **на другую
машину или на SSH-хост**: папка уходит потоком tar без промежуточных файлов, с прогрессом, машину или на SSH-хост**: папка уходит потоком tar без промежуточных файлов, с прогрессом,
скоростью и отменой. «Переместить» удаляет исходник после успешной отправки. Правила для скоростью и отменой. «Переместить» удаляет исходник после успешной отправки. Правила для
существующего: сообщить / заменить / оставить как есть. существующего: сообщить / заменить / оставить как есть.
- **ИИ-ассистент** - круглая кнопка чата (на компьютере её и окно можно перетаскивать); каждый
включает себе сам в профиле. Видит вкладки, активный терминал или папку и список ваших машин,
отвечает потоком. Действия - кнопками: выполнить команду в терминале нужной машины, прочитать,
показать или записать файл - всё с правами пользователя. Плашка «Агент» - делает шаги сам,
спрашивая перед каждой командой и записью; «Думать» - сначала рассуждает (свёрнуто над
ответом). Модели задаёт администратор в «Настройки › ИИ-ассистент»: их может быть несколько, у каждой своё
название в чате (и короткое, до 12 символов, - для кнопки выбора), тип - Gradio (по умолчанию - прокси к Qwen Omni), OpenAI-совместимый API или Anthropic,
адрес, модель и ключ (хранится на сервере, в браузер не уходит). Если моделей несколько, в чате рядом с
плашками появляется выбор, первая - по умолчанию; выбор запоминается за пользователем. В «Аккаунт › Вид»
можно показывать выбор всегда.
Плашка «Поиск» даёт ассистенту искать в интернете; поисковик выбирается там же: DuckDuckGo
(по умолчанию, без настройки), свой SearXNG (нужен `search.formats: [json]`), Brave Search API
или Tavily (по ключу). Если выбранный не ответил, поиск идёт через DuckDuckGo.
- **Сохранённые SSH-хосты** - в окне SSH-подключения галочка «Сохранить»: хост и вход (пароль или
ключ) хранятся на сервере, у каждого пользователя свои, зашифрованы AES-256-GCM ключом сервера.
Ключ берётся из `LPRA_SECRET_KEY` (32 байта, hex или base64) или из файла `<база>.key` рядом с
базой, который создаётся сам - храните его вместе с бэкапом базы, без него сохранённые входы
не расшифровать.
- **Проброс портов** - `http` через панель, `tcp`/`udp` через прямой туннель. - **Проброс портов** - `http` через панель, `tcp`/`udp` через прямой туннель.
- **Статус** - нагрузка, память, диски, сеть, тест скорости канала до сервера. - **Статус** - нагрузка, память, диски, сеть, тест скорости канала до сервера.
- **Вход** - пароль и/или ключи доступа (passkeys); пароль можно выключить, когда есть ключ. - **Вход** - пароль и/или ключи доступа (passkeys); пароль можно выключить, когда есть ключ.
@@ -42,6 +74,15 @@
Там же галочка «Пароль» включает или выключает вход по паролю (выключить можно, когда у человека есть ключ доступа), Там же галочка «Пароль» включает или выключает вход по паролю (выключить можно, когда у человека есть ключ доступа),
а «Закрепить» не даёт ему поменять это самому. Последнего администратора нельзя удалить или понизить. а «Закрепить» не даёт ему поменять это самому. Последнего администратора нельзя удалить или понизить.
## База данных
По умолчанию - SQLite (файл из `-db`, по умолчанию `lpra.db`). В «Настройки › База данных» можно выбрать
PostgreSQL: хост, порт, база, пользователь, пароль, SSL; кнопка «Проверить и сохранить» подключается и
записывает выбор в `<база>.backend.json` рядом с SQLite-файлом (права 0600), «Перезапустить сервер»
завершает процесс - systemd (`Restart=always`) поднимает его уже на PostgreSQL. При первом запуске на
пустой PostgreSQL все данные копируются из SQLite-файла, сам файл остаётся как был (к нему можно
вернуться тем же выбором). История ИИ-ассистента тоже хранится в базе - у каждого пользователя
последние 200 сообщений.
## Компоненты ## Компоненты
- **Сервер** (`cmd/server`) - панель и точка встречи для агентов. Ставится за nginx (TLS). - **Сервер** (`cmd/server`) - панель и точка встречи для агентов. Ставится за nginx (TLS).
- **Агент** (`cmd/agent`) - бинарь для Linux (amd64/arm64), systemd-сервис от root. - **Агент** (`cmd/agent`) - бинарь для Linux (amd64/arm64), systemd-сервис от root.
@@ -54,6 +95,29 @@
Получаются `bin/lpra-server*` и `dist/agent-linux-{amd64,arm64}`. Получаются `bin/lpra-server*` и `dist/agent-linux-{amd64,arm64}`.
Версия задаётся в одном месте - `VERSION.txt`, она вшивается в сервер и агент. Версия задаётся в одном месте - `VERSION.txt`, она вшивается в сервер и агент.
## Установка сервера одной командой
На Linux-сервере (amd64 или arm64, systemd):
```sh
wget -qO- https://gitlab.com/itzredtea/lpra/-/raw/main/server-install.sh | sudo sh
```
Скрипт `server-install.sh`:
- качает бинарник сервера под архитектуру машины из [последнего релиза](https://gitlab.com/itzredtea/lpra/-/releases/permalink/latest)
и папку `dist/` (бинарники агентов) из ветки `main`, кладёт всё в `/opt/lpra`;
- ставит службу systemd `lpra-server` (порт спросит, по умолчанию `8091`);
- по желанию настраивает nginx: спросит домен, создаст сайт на порт 80 (при необходимости поставит nginx),
затем `certbot --nginx` - если certbot с плагином nginx не установлен, этот шаг пропускается;
- если включён ufw - предложит открыть 80, 443 и туннель `8446` tcp/udp;
- запускает службу, перезагружает nginx и проверяет, что сервер отвечает.
Вопросы задаются в терминале, даже когда скрипт идёт через `| sudo sh`. Без терминала ответы берутся
из переменных: `LPRA_PORT`, `LPRA_NGINX` (y/n), `LPRA_DOMAIN`, `LPRA_EMAIL`, `LPRA_CERTBOT` (y/n), `LPRA_UFW` (y/n):
```sh
wget -qO- https://gitlab.com/itzredtea/lpra/-/raw/main/server-install.sh | sudo LPRA_NGINX=y LPRA_DOMAIN=lpra.example.com sh
```
Повторный запуск той же командой - **обновление**: бинарник и `dist/` заменяются, база (`/opt/lpra/lpra.db`
и её ключ `lpra.db.key`), настройки службы и nginx остаются. После установки откройте `/setup` - мастер
создаст администратора.
## Запуск сервера ## Запуск сервера
```sh ```sh
./bin/lpra-server -addr 127.0.0.1:8091 -dist ./dist -db ./lpra.db ./bin/lpra-server -addr 127.0.0.1:8091 -dist ./dist -db ./lpra.db
@@ -121,7 +185,8 @@ curl -fsSL https://panel.example/install.sh | sudo HOST=https://panel.example EN
- **lp** - только long-polling. - **lp** - только long-polling.
## Проброс портов и прямой туннель ## Проброс портов и прямой туннель
Правила создаются на странице машины «Порты». Режим `http` идёт через адрес панели, Правила создаются на странице машины «Порты». Режим `http` (в панели - http-lp) идёт через
соединение агента с панелью: по WebSocket, а если он недоступен - по long-polling;
`tcp`/`udp` - через прямой туннель: агент держит исходящее соединение к порту туннеля хоста, `tcp`/`udp` - через прямой туннель: агент держит исходящее соединение к порту туннеля хоста,
по нему мультиплексируются все соединения. по нему мультиплексируются все соединения.
@@ -148,10 +213,10 @@ curl -fsSL https://panel.example/install.sh | sudo HOST=https://panel.example EN
- Открытие терминала и SSH, перенос файлов на другие машины, смена пользователя пишутся в `audit`. - Открытие терминала и SSH, перенос файлов на другие машины, смена пользователя пишутся в `audit`.
## Скриншоты ## Скриншоты
<img width="1256" height="709" alt="image" src="https://github.com/user-attachments/assets/5f0b30f1-d5e3-4b62-817e-4041641df6e3" /> <img width="1256" height="709" alt="image" src="docs/screenshots/1.png" />
<img width="1254" height="532" alt="image" src="https://github.com/user-attachments/assets/0207a7f0-2b59-40ee-abd1-d8673059cc24" /> <img width="1254" height="532" alt="image" src="docs/screenshots/2.png" />
<img width="1255" height="526" alt="image" src="https://github.com/user-attachments/assets/4dcbe907-33f5-493e-a56f-01cd8bf0fea1" /> <img width="1255" height="526" alt="image" src="docs/screenshots/3.png" />
<img width="1260" height="654" alt="image" src="https://github.com/user-attachments/assets/fdf59733-8e5d-46e3-b232-235ddfe1be73" /> <img width="1260" height="654" alt="image" src="docs/screenshots/4.png" />
+1 -1
View File
@@ -1 +1 @@
0.1.2 0.2.2
+1 -1
View File
@@ -26,7 +26,7 @@ func main() {
"do not mark cookies Secure (plain-HTTP local testing only)") "do not mark cookies Secure (plain-HTTP local testing only)")
flag.Parse() flag.Parse()
db, err := server.OpenDB(*dbPath) db, err := server.OpenStore(*dbPath)
if err != nil { if err != nil {
log.Fatalf("open db: %v", err) log.Fatalf("open db: %v", err)
} }
+51
View File
@@ -0,0 +1,51 @@
# nginx reverse proxy for the LPRA server.
# TLS is terminated here; the Go server listens on 127.0.0.1:8091.
#
# Put this in /etc/nginx/sites-available/lp_agent and symlink it into
# sites-enabled, then: sudo nginx -t && sudo systemctl reload nginx
#
# Agents connect by WebSocket (from 0.1.0), with HTTP long polling to fall
# back on; the browser uses plain HTTP. The settings that matter:
# - Upgrade / Connection headers -> let the agents' WebSocket through
# - proxy_buffering off -> deliver long-poll responses immediately
# - proxy_read_timeout 300s -> longer than the 25s long-poll hold
#
# NOTE: proxy_pass MUST start with "http://". A bare "proxy_pass 127.0.0.1:8091;"
# causes nginx error: [emerg] invalid URL prefix.
# "Connection: upgrade" for a WebSocket, "close" for everything else.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name example.ru;
client_max_body_size 120m;
location / {
proxy_pass http://127.0.0.1:8091;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Agents' WebSocket.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Long-poll behaviour: no buffering, generous read timeout.
proxy_buffering off;
proxy_cache off;
# Pass uploads on as they arrive: the speed test through the browser
# times them on the client.
proxy_request_buffering off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
@@ -14,14 +14,16 @@
# causes nginx error: [emerg] invalid URL prefix. # causes nginx error: [emerg] invalid URL prefix.
# "Connection: upgrade" for a WebSocket, "close" for everything else. # "Connection: upgrade" for a WebSocket, "close" for everything else.
map $http_upgrade $connection_upgrade { map $http_upgrade $connection_upgrade {
default upgrade; default upgrade;
'' close; '' close;
} }
server { server {
listen 8445 ssl; listen 443 ssl;
server_name REDACTED; server_name example.ru;
ssl_certificate /etc/letsencrypt/live/REDACTED/fullchain.pem; # managed by Certbot ssl_certificate /etc/letsencrypt/live/REDACTED/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/REDACTED/privkey.pem; # managed by Certbot ssl_certificate_key /etc/letsencrypt/live/REDACTED/privkey.pem; # managed by Certbot
@@ -30,7 +32,7 @@ server {
# Files go through the panel in 4 MB pieces; 64m leaves room for a client # Files go through the panel in 4 MB pieces; 64m leaves room for a client
# that predates the pieces, which takes a file of up to 32 MB in one request. # that predates the pieces, which takes a file of up to 32 MB in one request.
client_max_body_size 64m; client_max_body_size 120m;
location / { location / {
proxy_pass http://127.0.0.1:8091; proxy_pass http://127.0.0.1:8091;
Vendored Executable
BIN
View File
Binary file not shown.
Vendored Executable
BIN
View File
Binary file not shown.
+144
View File
@@ -0,0 +1,144 @@
# Перенос проекта с GitHub на GitLab с сохранением истории и дат
Инструкция для локального репозитория, когда GitHub недоступен (аккаунт заблокирован) и
переносить приходится из своей копии. Так был перенесён `lpra`: 195 коммитов, 8 тегов,
3 релиза - хэши и даты совпали.
## Что сохраняется, а что нет
| Что | Сохраняется? |
|---|---|
| Даты коммитов (автора и коммита) | **Да, точно.** Они хранятся внутри коммита; push той же истории не меняет ни одного хэша |
| Ветки и теги | Да, если они есть локально (см. шаг 2) |
| Дата создания проекта | **Нет.** На gitlab.com она всегда - день создания проекта, задним числом не ставится. Начало проекта видно по первому коммиту |
| Релизы | Создаются заново, **с исходной датой** (`--released-at`); файлы релизов - заново собрать или взять свои копии |
| Issues, PR, звёзды, Actions | Нет: при заблокированном GitHub их не выгрузить |
**Нельзя:** `rebase`, `squash`, `filter-branch`, `filter-repo`, `commit --amend` по старым
коммитам, «Import from GitHub» с чужой копии - всё это переписывает коммиты и их даты.
## 0. Один раз: вход в GitLab
```sh
brew install glab
glab auth login --hostname gitlab.com --web --git-protocol https
```
Вход через браузер даёт права и на push, и на релизы. Git берёт пароль у glab - для каждого
репозитория это настраивается в шаге 4 (или один раз для всех:
`git config --global credential.https://gitlab.com.helper '!glab auth git-credential'`).
SSH-ключ тоже подойдёт, но в GitLab у ключа должен быть **Usage type: Authentication & Signing**
(с типом «Signing» по SSH не пускает).
## 1. Пустой проект в GitLab
https://gitlab.com/projects/new → **Create blank project** → имя, владелец `itzredtea`.
**Снять галочку «Initialize repository with a README»** - иначе в проекте появится свой первый
коммит, и историю придётся склеивать.
Или из терминала:
```sh
glab repo create itzredtea/ИМЯ --public # или --private; README не создаётся
```
## 2. Проверить, что всё есть локально
```sh
cd ~/CodeRepos/ИМЯ
git status # незакоммиченное закоммитить или убрать
git branch -a # ветки: свои и remotes/origin/*
git tag -l # теги
```
- Ветки, которых нет локально, но есть `remotes/origin/X`, тоже переносятся (шаг 3).
- **Теги, созданные на GitHub при публикации релиза, могут не быть локально** - если их не
делали `git fetch --tags`. Такой тег создаётся заново на нужном коммите (шаг 5). Коммит
ищется по дате публикации релиза: последний коммит перед ней
(`git log --format='%h %cd %s' --date=iso --until='ДАТА'`).
Снимок до переноса - для сверки:
```sh
git log --branches --remotes=origin --tags --format='%H %aI %cI' | sort > /tmp/before.txt
git show-ref --tags | sort > /tmp/tags-before.txt
```
## 3. Отправить историю
```sh
git remote rename origin github # GitHub остаётся под своим именем
git remote add origin https://gitlab.com/itzredtea/ИМЯ.git
H='!glab auth git-credential'
git -c credential.helper= -c "credential.helper=$H" push origin --all # все локальные ветки
git -c credential.helper= -c "credential.helper=$H" push origin 'refs/remotes/github/*:refs/heads/*' # ветки, что были только на GitHub
git -c credential.helper= -c "credential.helper=$H" push origin --tags
```
Вторая команда push может сказать про `HEAD` - это не ветка, сообщение можно не смотреть.
## 4. Сверить и переключиться
Сверка - по свежей копии из GitLab:
```sh
rm -rf /tmp/check && git clone -q https://gitlab.com/itzredtea/ИМЯ.git /tmp/check
cd /tmp/check && git log --remotes=origin --tags --format='%H %aI %cI' | sort > /tmp/after.txt
git show-ref --tags | sort > /tmp/tags-after.txt
diff /tmp/before.txt /tmp/after.txt && echo "коммиты и даты совпали"
diff /tmp/tags-before.txt /tmp/tags-after.txt && echo "теги совпали"
```
Пустой `diff` - история перенесена один в один. Дальше работать с GitLab:
```sh
cd ~/CodeRepos/ИМЯ
git config --local credential.https://gitlab.com.helper '!glab auth git-credential'
git fetch origin && git branch -u origin/main main
```
## 5. Релизы с исходными датами
Дата - время публикации релиза на GitHub (UTC). Файлы - свои копии или сборка из коммита
релиза во временной папке:
```sh
git worktree add --detach /tmp/rel-1.2.0 1.2.0 # тег или хэш коммита релиза
cd /tmp/rel-1.2.0 && ./build.sh # как проект собирается
glab release create 1.2.0 bin/файл1 bin/файл2 \
-R itzredtea/ИМЯ --name "Release 1.2.0" \
--released-at "2026-10-15T16:34:53Z" \
--notes "Перенесено с GitHub, дата публикации исходная."
# тега нет (его делал GitHub) - добавить: --ref <полный хэш коммита>
git worktree remove /tmp/rel-1.2.0
```
Создавать по порядку, от старых к новым. Последним GitLab считает релиз с самой поздней
датой. Ссылки на файлы:
- конкретный релиз: `https://gitlab.com/itzredtea/ИМЯ/-/releases/ТЕГ/downloads/ФАЙЛ`
- последний: `https://gitlab.com/itzredtea/ИМЯ/-/releases/permalink/latest/downloads/ФАЙЛ`
## 6. Ссылки в самом проекте
Найти и заменить всё, что смотрит на GitHub:
```sh
grep -rn "github.com\|githubusercontent" --exclude-dir=.git .
```
| GitHub | GitLab |
|---|---|
| `https://raw.githubusercontent.com/USER/ИМЯ/main/ПУТЬ` | `https://gitlab.com/itzredtea/ИМЯ/-/raw/main/ПУТЬ` |
| `https://github.com/USER/ИМЯ/releases/latest/download/ФАЙЛ` | `https://gitlab.com/itzredtea/ИМЯ/-/releases/permalink/latest/downloads/ФАЙЛ` |
| `https://api.github.com/repos/USER/ИМЯ/releases/latest` (`tag_name`) | `https://gitlab.com/api/v4/projects/itzredtea%2FИМЯ/releases/permalink/latest` (`tag_name`) |
| `https://api.github.com/repos/USER/ИМЯ/contents/ПАПКА?ref=main` | `https://gitlab.com/api/v4/projects/itzredtea%2FИМЯ/repository/tree?path=ПАПКА&ref=main` (поле `path`) |
| картинки `github.com/user-attachments/...` | скачать, пока открываются, в `docs/` и ссылаться на файлы |
Путь Go-модуля (`module github.com/...` в `go.mod`) можно не трогать: это только имя.
## 7. После переноса
- Описание проекта в GitLab: «Перенесён с GitHub, начат ДД.ММ.ГГГГ» - раз дата создания новая.
- Токены, которые светились в чатах и логах, - отозвать и выпустить новые.
+73
View File
@@ -0,0 +1,73 @@
# План: управление потоком в прямом туннеле (tcp-проброс)
Статус: **отложено** - сделать, если на практике упрёмся. Записано 2026-10-16.
## Что сейчас
Прямой туннель (`:8446`, режим проброса `tcp`) - одно TCP-соединение на агента, по нему
мультиплексируются все проброшенные соединения этого агента кадрами
`type(1) | stream(4) | length(4) | payload` (`internal/tunnel/frame.go`).
Ёмкости хватает с запасом: на агента - одно соединение и одна горутина чтения, на проброшенное
соединение - горутина и буфер 32 КБ; шифрования в туннеле нет. 20 агентов и тысячи соединений -
десятки МБ памяти и почти ноль CPU. Потолок - сеть хоста (1 Гбит/с), через которую весь трафик
идёт дважды (пользователь ↔ хост ↔ агент).
## Проблема: блокировка всего туннеля агента одним соединением
Управления потоком (окон, как в HTTP/2 или yamux) нет, поэтому внутри одного агента медленное
соединение тормозит остальные:
- **Хост** (`internal/server/tunnelserver.go`, `agentTunnel.readLoop`): кадр `TypeData` пишется
пользователю синхронно, `c.Write(f.Payload)`, без дедлайна. Клиент, который медленно читает или
завис, останавливает чтение туннеля - встают все пробросы этого агента, пока клиент не отвалится.
- **Агент** (`internal/agent/tunnel.go`, `tunClient.readLoop`): данные кладутся в канал потока
`in` (256 кусков, до ~8 МБ). Если локальный сервис не читает и канал полон, `readLoop`
блокируется на `s.in <- f.Payload` - встаёт весь туннель агента.
Между агентами влияния нет: у каждого свой `readLoop`.
Когда проявится: через одного агента одновременно большая отдача медленному клиенту и что-то
интерактивное (SSH, веб) - интерактивное подвисает. Худший случай - зависший клиент держит
туннель агента до разрыва TCP.
## Предлагаемое решение
1. **Своя очередь записи у каждого потока** - на хосте и на агенте. `readLoop` только кладёт
кадр в очередь потока и никогда не пишет в сокет сам; пишет отдельная горутина потока.
2. **Окно (кредиты) на поток.** Новый тип кадра `TypeWindow` (stream, uint32 байт): получатель
сообщает, сколько ещё байт готов принять. Отправитель не шлёт `TypeData` сверх окна.
Начальное окно - 256 КБ; получатель возвращает кредит по мере записи в сокет (например, когда
записана половина окна). Так очередь потока ограничена окном, а медленный поток тормозит
только себя.
3. **Дедлайны записи.** Запись в пользовательский или локальный сокет - с `SetWriteDeadline`
(например, 60 с без прогресса). Не успел - поток закрывается (`TypeClose`), туннель живёт.
4. **Справедливость отправки.** Отправка в туннель уже сериализована (`wmu`); кадры данных
ограничить 32 КБ (сейчас так и есть), чтобы большой поток не занимал канал надолго.
## Совместимость со старыми агентами
Рукопожатие `LPRA-TUN1` не меняем: старый агент отвергает другое приветствие, а старый сервер -
ответ не из двух полей. Вместо этого:
- сервер знает версию агента (из БД, `versionAtLeast`) и, если агент новый, сразу после
рукопожатия шлёт кадр `TypeHello` (флаги: `flowControl`);
- агент включает окна, только получив `TypeHello`. Старый сервер его не шлёт - новый агент
работает с ним по-старому. Старому агенту сервер `TypeHello` не шлёт и окна не включает (а
пришедший неизвестный кадр старый агент и так пропустил бы: в его `readLoop` нет `default`).
Новые типы кадров: `TypeHello = 5`, `TypeWindow = 6`.
## Тесты
- Медленный читатель на одном потоке не задерживает другой поток того же агента (замер задержки
эха на втором потоке при забитом первом).
- Зависший клиент закрывается по дедлайну, туннель и остальные потоки живы.
- Новый сервер + старый агент и старый сервер + новый агент - проброс работает, как раньше.
- Пропускная способность одного потока не падает заметно (окно 256 КБ на RTT до 50 мс даёт
~40 Мбит/с на поток; если мало - окно больше или автоподстройка).
## Объём
Сервер: `tunnelserver.go` (~150 строк), агент: `tunnel.go` (~150 строк), `frame.go` (типы), тесты.
Нужна новая версия агента; панель после обновления помечает старых как устаревших.
Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,159 @@
# LPRA 0.2.0 — рабочее пространство (подпроект 1)
Дата: 2026-10-15. Статус: спецификация утверждена.
Подпроект 2 (встроенный ИИ-помощник) — отдельная спецификация, делается после этого.
## Цель
Работа с несколькими машинами в одном месте, без потери открытых терминалов при переходах по панели.
Терминалы, файловые менеджеры (своих машин и по SSH) живут во вкладках одного рабочего пространства;
файлы перетаскиваются между панелями и в терминал.
Критерии готовности:
- переход Машины ↔ Терминал ↔ Настройки не обрывает сессии терминалов и не сбрасывает вкладки;
- перезагрузка страницы сбрасывает пространство (с предупреждением браузера, если есть живые сессии);
- файловый менеджер — тип панели во вкладке, в том числе по SSH;
- перетаскивание файлов: между файловыми панелями (окно «переместить / копировать»), в терминал той же машины (вставка путей), в терминал другой машины — запрет с сообщением.
## Решения, принятые в обсуждении
| вопрос | решение |
|---|---|
| Страницы «Порты», «Статус», «Изменить» | окна поверх текущей страницы |
| Файлы рядом с терминалами в одной вкладке | да: любая из до 4 панелей — терминал, SSH-терминал, файлы, SSH-файлы |
| Перезагрузка при живых сессиях | вопрос браузера «Покинуть сайт?» |
| Архитектура | одностраничная оболочка, остальное — HTML-фрагменты с сервера (вариант A) |
| Файлы по SSH | без SFTP-библиотеки: команды на хосте через SSH, как у существующей передачи tar |
| Состояние | одно на вкладку браузера, в памяти страницы; не сохраняется при перезагрузке |
## 1. Каркас и маршруты
**Оболочка.** Адреса `/`, `/terminal`, `/settings` отдают один шаблон `app.html`: шапка (LPRA, «Машины · Терминал · Настройки» — «Настройки» только администраторам, статус активной панели, аккаунт) и три области `<section data-view="machines|terminal|settings">`.
Старые адреса `/agents/{id}/terminal` и `/agents/{id}/files` отдают ту же оболочку и при загрузке создают вкладку нужного типа для этой машины (закладки не ломаются).
**Роутер** (`static/app.js`, без библиотек):
- перехватывает клики по внутренним ссылкам и `popstate`; меняет видимую область через `history.pushState`, подсвечивает пункт шапки;
- область «Терминал» создаётся один раз и потом только скрывается (`hidden`) — сессии, WebSocket и xterm живут, пока жива вкладка браузера;
- «Машины» и «Настройки» при каждом показе подгружаются заново как фрагмент: `GET /?frag=1`, `GET /settings?frag=1`.
**Фрагменты на сервере.** `render()` получает режим фрагмента (параметр `frag=1`): шаблон отдаёт только содержимое, без `<html>`, шапки и подключений. Общая разметка `head` и `topbar`, сейчас скопированная в каждый шаблон, выносится в `{{define}}`-блоки.
**Формы во фрагментах** (`app.js`, перехват `submit` внутри областей и окон):
1. `onsubmit`-обработчики (`confirm(...)`) срабатывают как обычно; отказ — ничего не происходит;
2. форма отправляется через `fetch` (`redirect: 'follow'`, тело — `FormData`);
3. итоговый адрес ответа определяет, что перерисовать: тот же фрагмент по этому адресу (сохраняются `?err=...`, `?updated=1`, `#users`);
4. формы на `/logout` и ссылки наружу работают обычной загрузкой страницы.
**Скрипты фрагментов.** Встроенные `<script>` во фрагментах не исполняются. Поведение страниц оформляется модулями с `mount(root)` / `unmount()`, которые вызывает роутер: копирование команд и коды установки (настройки), `users.js`, список машин с опросом присутствия.
`pin.js`, `scroll.js`, `account.js` подключаются один раз в оболочке.
**Предупреждение при уходе.** `beforeunload` спрашивает «Покинуть сайт?», только если в рабочем пространстве есть живая сессия терминала или SSH-терминала.
## 2. Рабочее пространство и типы панелей
**Модель.** Вкладка содержит 1–4 панели в существующих раскладках (`1`, `h`, `v`, сетка), с изменением размеров и перетаскиванием панелей и вкладок, как сейчас. Тип панели:
| тип | что это |
|---|---|
| `term` | терминал на машине (как сейчас) |
| `ssh` | SSH-терминал через машину (как сейчас) |
| `files` | файловый менеджер на машине |
| `sshfiles` | файловый менеджер на SSH-хосте через машину |
**Разделение кода** (сейчас всё в `terminal.js` и `files.html`):
- `workspace.js` — вкладки, раскладки, разделение, перетаскивание панелей и вкладок, меню «+». С панелями работает через общий интерфейс: `el`, `kind`, `agent`, `title`, `state` и `text` (точка и строка статуса), `fit()`, `focus()`, `close()`, `acceptsDrop(item)`, `drop(item)`;
- `termpane.js` — нынешний класс `Session` (терминал и SSH-терминал), вынесенный почти без изменений;
- `filepane.js` — файловая панель (часть 3);
- `fileviews.js` — общие окна файлов (просмотр и правка, hex, свойства, перемещение, загрузка, редактор SQLite).
**Меню «+»** (на компьютере и телефоне):
- «Терминал ›» — «здесь (машина)», другие машины в сети, «SSH-хост…»;
- «Файловый менеджер ›» — «здесь (машина)», другие машины в сети, «SSH-хост…».
«SSH-хост…» открывает существующее окно SSH-подключения; тип панели зависит от пункта меню. Последний SSH-хост (без секретов) запоминается, как сейчас.
**Из списка машин:** кнопка терминала → новая вкладка-терминал этой машины и переход в «Терминал»; кнопка файлов → новая вкладка «Файлы». Если пространства ещё нет, оно создаётся.
**Заголовки.** Вкладка файлов — иконка папки и `машина · папка`, путь обрезается слева, обновляется при смене папки. Переименование двойным кликом — как у терминалов. Точка статуса файловой панели — машина в сети / офлайн / ошибка.
**Телефон.** Вкладки и меню те же; разделения и перетаскивания, как и сейчас, только на компьютере.
## 3. Файловая панель
Содержимое нынешней страницы файлов как компонент `FilePane(source)`: строка пути, «Вверх», «Обновить», «Папка / Файл / Загрузить», таблица с отметками, «В zip», «Удалить выбранное», загрузка перетаскиванием из ОС.
Состояние (текущая папка, список, отметки) — у экземпляра; элементы ищутся внутри корня панели, без глобальных `id`. Две файловые панели в одной вкладке не мешают друг другу.
**Общие окна** (`fileviews.js`) — одно на страницу. При открытии окну передаётся панель-источник: машина или SSH, путь, что обновить после действия.
**Источник данных** — один интерфейс (`list`, `read`, `write`, `mkdir`, `delete`, `move`, `copy`, `stat`, `chmod`, `download`, `upload`) с двумя реализациями:
- машина — существующий `/api/panel/agents/{id}/fs/...`;
- SSH-хост — новые задачи агента `sshfs.*` (ниже).
**Узкие панели** (меньше ~520 px, в разделённой вкладке): прячется колонка «Изменён», кнопки строки сворачиваются в меню «⋯» — как сейчас на телефоне.
### Файлы по SSH (`sshfiles`)
Агент держит одно SSH-подключение на панель: `sshConnect` и проверка ключа хоста из SSH-терминала. Подключение открывается при создании панели (окно «SSH-хост…»), получает id, закрывается при закрытии панели или через 10 минут простоя. Секреты передаются агенту один раз, панель их не хранит.
Операции — команды на хосте, пути в одинарных кавычках (`shq`):
| операция | команда |
|---|---|
| список | `stat` по содержимому папки; формат выбирается один раз при подключении: GNU/busybox `stat -c`, BSD/macOS `stat -f`; без `stat` — разбор `ls -la` без точного времени |
| чтение, скачивание | `cat -- 'файл'`, поток через stdout |
| запись, загрузка | `cat > 'файл.tmp' && mv 'файл.tmp' 'файл'`, поток через stdin |
| папка, удаление, переименование | `mkdir -p`, `rm -rf`, `mv` |
| права, владелец | `chmod`, `chown` |
| копирование, перенос внутри хоста | `cp -a`, `mv` |
| перенос между хостом и машинами | существующая передача tar по SSH |
Новые задачи агента: `sshfs.open` (подключиться, вернуть id и домашнюю папку), `sshfs.close`, `sshfs.list`, `sshfs.read`, `sshfs.write`, `sshfs.mkdir`, `sshfs.delete`, `sshfs.move`, `sshfs.copy`, `sshfs.stat`, `sshfs.chmod`, `sshfs.send` (скачивание через существующие куски `xfer`), `sshfs.put` (загрузка через куски `xfer`).
Серверу — те же правила доступа, что у файловых операций машины; изменения пишутся в аудит.
Не входит: zip и распаковка на SSH-хосте, редактор SQLite по SSH — эти кнопки в `sshfiles` скрыты. Новых зависимостей нет.
## 4. Перетаскивание
**Что тащится:** строка файла или папки; если строка отмечена — все отмеченные строки панели. У курсора бейдж (имя или «N элементов»).
Механизм — на `pointer`-событиях внутри страницы, тот же, что уже двигает вкладки и панели; подсказка над целью говорит, что произойдёт.
| цель | результат |
|---|---|
| другая файловая панель (любая машина или SSH-хост) | небольшое окно у места броска: «Переместить / Копировать / Отмена», куда (папка цели), «если уже есть: сообщить ▾». Внутри машины — `fs.copy`; внутри SSH-хоста — `cp`/`mv`; между разными — существующая передача tar с прогрессом в этом же окне |
| папка в списке панели | то же, цель — эта папка |
| терминал той же машины / того же SSH-хоста | пути в одинарных кавычках через пробел вставляются в терминал, без Enter |
| терминал другой машины | цель подсвечивается красным, подсказка «Нельзя: файл на X, терминал на Y» |
| заголовок вкладки | через 0,5 с наведения вкладка активируется, бросить можно в её панель |
| пустое место / Esc | отмена |
Правила конфликтов имён — те же, что в окне «Переместить или копировать». Загрузка из ОС перетаскиванием — в любую файловую панель. На телефоне перетаскивания нет; остаётся окно перемещения с выбором машины или SSH.
## 5. Окна машины
«Порты», «Статус», «Изменить» — нынешние страницы как фрагменты (`?frag=1`) в общем окне `machinedlg` (заголовок «машина · раздел», закрытие; на телефоне — на весь экран).
Открываются из кнопок строки в списке машин и из меню заголовка вкладки или панели (её машина). Формы — перехватом из части 1; результат (например, обновления клиента) показывается в окне.
Живые части (графики и опрос статуса, тест скорости, присутствие) — модули `mount` / `unmount`; при закрытии окна останавливаются.
## Ошибки и крайние случаи
- Машина офлайн: терминал — как сейчас; файловая панель — «машина офлайн» и «повторить», при возвращении машины обновляется сама.
- Нет доступа (роль «пользователь»): чужих машин нет в меню и списке; если доступ отобран при открытой вкладке — панель показывает «нет доступа».
- Сессия истекла (401 или редирект на `/login`): сообщение и переход на вход.
- Агент без `sshfs.*`: пункт «Файлы по SSH» сообщает «обновите агента».
- Фрагмент не загрузился: ошибка и «повторить» в его области, остальное приложение работает.
- SSH-подключение файловой панели оборвалось: следующая операция переподключается один раз; не вышло — панель сообщает и предлагает переподключиться.
## Проверка
На стенде (сервер, агент, тестовый sshd), Chrome на компьютере и в эмуляции телефона:
1. Переходы Машины ↔ Терминал ↔ Настройки при идущем выводе в терминале — вывод не прерывается; «Назад / Вперёд»; перезагрузка — сброс и «Покинуть сайт?».
2. Вкладки с терминалом, файлами, SSH-файлами; разделение, изменение размеров, перетаскивание панелей и вкладок.
3. Файловая панель машины и SSH-хоста: список, просмотр, правка, загрузка, скачивание, создание, удаление, переименование, права.
4. Перетаскивание: в соседнюю панель (своя машина, SSH), в папку, в терминал той же машины (вставка путей), в терминал чужой (запрет).
5. Окна машины: формы, обновление клиента, остановка графиков при закрытии.
6. Роли: обычный пользователь видит в меню и списке только свои машины.
7. Регрессия: PIN, ключи доступа, установщик, редактор SQLite, перенос на другие машины, прямой адрес.
## Версия и документация
Версия 0.2.0. README — разделы о рабочем пространстве, файлах по SSH и перетаскивании.
+10 -5
View File
@@ -5,20 +5,25 @@ go 1.26
require ( require (
github.com/coder/websocket v1.8.12 github.com/coder/websocket v1.8.12
github.com/creack/pty v1.1.21 github.com/creack/pty v1.1.21
golang.org/x/crypto v0.31.0 github.com/jackc/pgx/v5 v5.7.5
golang.org/x/sys v0.28.0 golang.org/x/crypto v0.37.0
golang.org/x/sys v0.32.0
modernc.org/sqlite v1.24.0 modernc.org/sqlite v1.24.0
) )
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect github.com/mattn/go-isatty v0.0.16 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/mod v0.3.0 // indirect golang.org/x/mod v0.17.0 // indirect
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 // indirect golang.org/x/sync v0.13.0 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect golang.org/x/text v0.24.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
lukechampine.com/uint128 v1.2.0 // indirect lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect modernc.org/cc/v3 v3.40.0 // indirect
modernc.org/ccgo/v3 v3.16.13 // indirect modernc.org/ccgo/v3 v3.16.13 // indirect
+36 -30
View File
@@ -2,14 +2,25 @@ github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NA
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
@@ -21,35 +32,30 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 h1:M8tBwCtWD/cZV9DZpFYRUgaymAYAr+aIUTWzDaM3uPs= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
+1 -1
View File
@@ -330,7 +330,7 @@ func (a *Agent) pollOnce(ctx, reqCtx context.Context) error {
func (a *Agent) dispatch(ctx context.Context, msg proto.ControlMessage) { func (a *Agent) dispatch(ctx context.Context, msg proto.ControlMessage) {
switch msg.Type { switch msg.Type {
case proto.CtrlOpenTerminal: case proto.CtrlOpenTerminal:
go a.runTerminal(ctx, msg.SessionID, msg.User) go a.runTerminal(ctx, msg.SessionID, msg.User, msg.Dir)
case proto.CtrlOpenForward: case proto.CtrlOpenForward:
go a.runForward(ctx, msg.SessionID, msg.Port) go a.runForward(ctx, msg.SessionID, msg.Port)
case proto.CtrlTask: case proto.CtrlTask:
+10 -2
View File
@@ -114,13 +114,21 @@ func fsList(path string) (proto.FSListResult, error) {
if err != nil { if err != nil {
continue continue
} }
out = append(out, proto.FSEntry{ ent := proto.FSEntry{
Name: e.Name(), Name: e.Name(),
IsDir: e.IsDir(), IsDir: e.IsDir(),
Size: info.Size(), Size: info.Size(),
Mode: info.Mode().String(), Mode: info.Mode().String(),
MTime: info.ModTime().Unix(), MTime: info.ModTime().Unix(),
}) }
// A link to a folder opens like one.
if info.Mode()&os.ModeSymlink != 0 {
ent.Link = true
if t, err := os.Stat(filepath.Join(abs, e.Name())); err == nil && t.IsDir() {
ent.IsDir = true
}
}
out = append(out, ent)
} }
// Directories first, then alphabetical. // Directories first, then alphabetical.
sort.Slice(out, func(i, j int) bool { sort.Slice(out, func(i, j int) bool {
+46
View File
@@ -289,3 +289,49 @@ func (a *Agent) doFSSend(ctx context.Context, env proto.TaskEnvelope) proto.Task
data, _ := json.Marshal(map[string]any{"path": args.Path, "size": size}) data, _ := json.Marshal(map[string]any{"path": args.Path, "size": size})
return proto.TaskResult{OK: true, Data: data} return proto.TaskResult{OK: true, Data: data}
} }
// ordered fetches pieces 0..n-1 with up to depth in flight and hands them to
// use one by one in their order: a stream that must be written in sequence
// (stdin over SSH) still gets its pieces fetched ahead.
func ordered(ctx context.Context, n, depth int, fetch func(ctx context.Context, i int) ([]byte, error), use func(i int, b []byte) error) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
type got struct {
b []byte
err error
}
slots := make([]chan got, n)
for i := range slots {
slots[i] = make(chan got, 1)
}
sem := make(chan struct{}, depth)
go func() {
for i := 0; i < n; i++ {
select {
case sem <- struct{}{}:
case <-ctx.Done():
return
}
go func(i int) {
b, err := fetch(ctx, i)
slots[i] <- got{b, err}
}(i)
}
}()
for i := 0; i < n; i++ {
var g got
select {
case g = <-slots[i]:
case <-ctx.Done():
return ctx.Err()
}
<-sem
if g.err != nil {
return g.err
}
if err := use(i, g.b); err != nil {
return err
}
}
return nil
}
+45
View File
@@ -0,0 +1,45 @@
package agent
import (
"context"
"math/rand"
"sync/atomic"
"testing"
"time"
)
// Pieces fetched side by side are used in their order, never more than
// depth at once in flight.
func TestOrderedKeepsOrder(t *testing.T) {
var inFlight, most int32
var got []int
err := ordered(context.Background(), 20, 3, func(ctx context.Context, i int) ([]byte, error) {
n := atomic.AddInt32(&inFlight, 1)
for {
m := atomic.LoadInt32(&most)
if n <= m || atomic.CompareAndSwapInt32(&most, m, n) {
break
}
}
time.Sleep(time.Duration(rand.Intn(5)) * time.Millisecond)
atomic.AddInt32(&inFlight, -1)
return []byte{byte(i)}, nil
}, func(i int, b []byte) error {
if int(b[0]) != i {
t.Errorf("piece %d carries %d", i, b[0])
}
got = append(got, i)
return nil
})
if err != nil || len(got) != 20 {
t.Fatalf("got %v, %v", got, err)
}
for i, v := range got {
if v != i {
t.Fatalf("order: %v", got)
}
}
if most > 3 || most < 2 {
t.Fatalf("at most %d in flight", most)
}
}
+8 -4
View File
@@ -16,8 +16,8 @@ import (
// runTerminal spawns a PTY and bridges it to the session. // runTerminal spawns a PTY and bridges it to the session.
// user is the OS user to run the shell as ("" = the agent's own user). // user is the OS user to run the shell as ("" = the agent's own user).
func (a *Agent) runTerminal(parent context.Context, sid, user string) { func (a *Agent) runTerminal(parent context.Context, sid, user, dir string) {
term, err := StartPTY(user) term, err := StartPTY(user, dir)
if err != nil { if err != nil {
log.Printf("term %s: start pty: %v", sid, err) log.Printf("term %s: start pty: %v", sid, err)
// Show the reason in the browser terminal before closing it. // Show the reason in the browser terminal before closing it.
@@ -26,12 +26,15 @@ func (a *Agent) runTerminal(parent context.Context, sid, user string) {
return return
} }
log.Printf("term %s: shell started (user %q)", sid, user) log.Printf("term %s: shell started (user %q)", sid, user)
termsBySID.Store(sid, term)
defer termsBySID.Delete(sid)
a.bridgeStream(parent, sid, term, term.Resize) a.bridgeStream(parent, sid, term, term.Resize)
log.Printf("term %s: ended", sid) log.Printf("term %s: ended", sid)
} }
// runForward dials 127.0.0.1:port on the client and bridges it to the session, // runForward dials 127.0.0.1:port on the client and bridges it to the session,
// giving the host a reverse TCP tunnel over the same long-poll transport. // giving the host a reverse TCP tunnel over the agent's link to the server:
// the WebSocket, or the long polls without one.
func (a *Agent) runForward(parent context.Context, sid string, port int) { func (a *Agent) runForward(parent context.Context, sid string, port int) {
d := net.Dialer{Timeout: 10 * time.Second} d := net.Dialer{Timeout: 10 * time.Second}
conn, err := d.DialContext(parent, "tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port))) conn, err := d.DialContext(parent, "tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
@@ -90,7 +93,8 @@ func (a *Agent) bridgeStream(parent context.Context, sid string, rw io.ReadWrite
} }
} }
// pollIn performs one downstream long-poll and applies the frames. // pollIn takes the next input - pushed on the WebSocket, else by a long
// poll - and applies the frames.
// Returns false when the session is gone and the loop should stop. // Returns false when the session is gone and the loop should stop.
func (a *Agent) pollIn(ctx context.Context, sid string, w io.Writer, onResize func(cols, rows uint16)) bool { func (a *Agent) pollIn(ctx context.Context, sid string, w io.Writer, onResize func(cols, rows uint16)) bool {
// On the WebSocket the input comes pushed; if it drops, by a long poll. // On the WebSocket the input comes pushed; if it drops, by a long poll.
+112 -17
View File
@@ -99,7 +99,24 @@ func treeSize(ctx context.Context, p string) int64 {
// packTar writes src as a tar stream whose entries start at name. Owners are // packTar writes src as a tar stream whose entries start at name. Owners are
// left out: the numbers mean other people on another machine. // left out: the numbers mean other people on another machine.
func packTar(ctx context.Context, w io.Writer, src, name string, m *shipMeter) error { func packTar(ctx context.Context, w io.Writer, src, name string, m *shipMeter) error {
return packTars(ctx, w, []shipItem{{src, name}}, m)
}
// shipItem is one thing sent: where it is and the name it goes under.
type shipItem struct{ src, name string }
// packTars writes the items as one tar stream, each under its name.
func packTars(ctx context.Context, w io.Writer, items []shipItem, m *shipMeter) error {
tw := tar.NewWriter(w) tw := tar.NewWriter(w)
for _, it := range items {
if err := packInto(ctx, tw, it.src, it.name, m); err != nil {
return err
}
}
return tw.Close()
}
func packInto(ctx context.Context, tw *tar.Writer, src, name string, m *shipMeter) error {
err := filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error { err := filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {
return err return err
@@ -152,10 +169,7 @@ func packTar(ctx context.Context, w io.Writer, src, name string, m *shipMeter) e
m.files.Add(1) m.files.Add(1)
return nil return nil
}) })
if err != nil {
return err return err
}
return tw.Close()
} }
type countReader struct { type countReader struct {
@@ -175,11 +189,33 @@ func (a *Agent) doShipOut(parent context.Context, env proto.TaskEnvelope) proto.
if err := json.Unmarshal(env.Args, &args); err != nil { if err := json.Unmarshal(env.Args, &args); err != nil {
return proto.TaskResult{Error: err.Error()} return proto.TaskResult{Error: err.Error()}
} }
if err := shipName(args.Name); err != nil { names := args.ShipNames()
for _, n := range names {
if err := shipName(n); err != nil {
return proto.TaskResult{Error: err.Error()} return proto.TaskResult{Error: err.Error()}
} }
}
if len(args.More) > 0 && args.SSH != nil {
return proto.TaskResult{Error: "по SSH-адресу - по одному"}
}
src := filepath.Clean(args.Src) src := filepath.Clean(args.Src)
if _, err := os.Lstat(src); err != nil { srcs := []string{args.Src}
for _, p := range args.More {
// The others: of the same folder only.
if path.Dir(path.Clean(p)) != path.Dir(path.Clean(args.Src)) {
return proto.TaskResult{Error: "несколько - только из одной папки"}
}
srcs = append(srcs, p)
}
var conn *sshfsConn
if args.Conn != "" {
// The files are on an SSH host this agent holds a connection to.
c, err := sshfsGet(args.Conn)
if err != nil {
return proto.TaskResult{Error: err.Error()}
}
conn, src = c, path.Clean(args.Src)
} else if _, err := os.Lstat(src); err != nil {
return proto.TaskResult{Error: err.Error()} return proto.TaskResult{Error: err.Error()}
} }
ctx, cancel := context.WithCancel(parent) ctx, cancel := context.WithCancel(parent)
@@ -187,14 +223,28 @@ func (a *Agent) doShipOut(parent context.Context, env proto.TaskEnvelope) proto.
m := &shipMeter{} m := &shipMeter{}
m.note.Store("подсчёт") m.note.Store("подсчёт")
go a.reportShip(ctx, args.Job, m, cancel) go a.reportShip(ctx, args.Job, m, cancel)
m.total.Store(treeSize(ctx, src)) var total int64
items := make([]shipItem, len(srcs))
for i, p := range srcs {
if conn != nil {
total += a.remoteSize(conn, path.Clean(p))
} else {
total += treeSize(ctx, filepath.Clean(p))
}
items[i] = shipItem{filepath.Clean(p), names[i]}
}
m.total.Store(total)
pack := func(w io.Writer) error { return packTars(ctx, w, items, m) }
if conn != nil {
pack = func(w io.Writer) error { return a.packRemote(ctx, w, conn, srcs, names, m) }
}
var err error var err error
if args.SSH != nil { if args.SSH != nil {
err = a.shipSSH(ctx, args, src, m) err = a.shipSSH(ctx, args, src, m)
} else { } else {
m.note.Store("") m.note.Store("")
err = a.shipRelay(ctx, args, src, m) err = a.shipRelay(ctx, args, pack)
} }
if err != nil { if err != nil {
if ctx.Err() != nil && parent.Err() == nil { if ctx.Err() != nil && parent.Err() == nil {
@@ -218,14 +268,14 @@ func (a *Agent) relayURL(id string, i int) string {
// shipRelay posts the tar stream in pieces, a few at once; the last carries // shipRelay posts the tar stream in pieces, a few at once; the last carries
// X-Last. // X-Last.
func (a *Agent) shipRelay(ctx context.Context, args proto.FSShipArgs, src string, m *shipMeter) error { func (a *Agent) shipRelay(ctx context.Context, args proto.FSShipArgs, pack func(io.Writer) error) error {
if args.Relay == "" || args.Chunk <= 0 { if args.Relay == "" || args.Chunk <= 0 {
return errors.New("не задана передача") return errors.New("не задана передача")
} }
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
defer cancel() defer cancel()
pr, pw := io.Pipe() pr, pw := io.Pipe()
go func() { pw.CloseWithError(packTar(ctx, pw, src, args.Name, m)) }() go func() { pw.CloseWithError(pack(pw)) }()
defer pr.Close() defer pr.Close()
type piece struct { type piece struct {
@@ -302,16 +352,31 @@ func (a *Agent) doShipIn(ctx context.Context, env proto.TaskEnvelope) proto.Task
if err := json.Unmarshal(env.Args, &args); err != nil { if err := json.Unmarshal(env.Args, &args); err != nil {
return proto.TaskResult{Error: err.Error()} return proto.TaskResult{Error: err.Error()}
} }
if err := shipName(args.Name); err != nil { names := args.ShipNames()
for _, n := range names {
if err := shipName(n); err != nil {
return proto.TaskResult{Error: err.Error()} return proto.TaskResult{Error: err.Error()}
} }
}
dir := filepath.Clean(args.Dir) dir := filepath.Clean(args.Dir)
if _, err := os.Lstat(filepath.Join(dir, args.Name)); err == nil && args.Conflict != "replace" && args.Conflict != "skip" { var conn *sshfsConn
return proto.TaskResult{Error: filepath.Join(dir, args.Name) + " уже существует"} if args.Conn != "" {
// Into an SSH host this agent holds a connection to.
c, err := sshfsGet(args.Conn)
if err != nil {
return proto.TaskResult{Error: err.Error()}
}
conn, dir = c, path.Clean(args.Dir)
} else {
for _, n := range names {
if _, err := os.Lstat(filepath.Join(dir, n)); err == nil && args.Conflict != "replace" && args.Conflict != "skip" {
return proto.TaskResult{Error: filepath.Join(dir, n) + " уже существует"}
}
} }
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(dir, 0o755); err != nil {
return proto.TaskResult{Error: err.Error()} return proto.TaskResult{Error: err.Error()}
} }
}
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
defer cancel() defer cancel()
pr, pw := io.Pipe() pr, pw := io.Pipe()
@@ -339,7 +404,27 @@ func (a *Agent) doShipIn(ctx context.Context, env proto.TaskEnvelope) proto.Task
} }
} }
}() }()
u := &unpacker{dir: dir, name: args.Name, conflict: args.Conflict} if conn != nil {
skipped, err := a.unpackRemote(conn, pr, dir, names, args.Conflict)
if err == nil {
_, _ = io.Copy(io.Discard, pr)
}
pr.CloseWithError(errors.New("распаковка закончена"))
if err != nil {
return proto.TaskResult{Error: err.Error()}
}
res := map[string]int{}
if skipped > 0 {
res["skipped"] = skipped
}
data, _ := json.Marshal(res)
return proto.TaskResult{OK: true, Data: data}
}
allowed := map[string]bool{}
for _, n := range names {
allowed[n] = true
}
u := &unpacker{dir: dir, names: allowed, conflict: args.Conflict}
if os.Geteuid() == 0 { if os.Geteuid() == 0 {
// New things belong to whoever owns the folder they land in. // New things belong to whoever owns the folder they land in.
if info, err := os.Stat(dir); err == nil { if info, err := os.Stat(dir); err == nil {
@@ -370,7 +455,8 @@ func (a *Agent) doShipIn(ctx context.Context, env proto.TaskEnvelope) proto.Task
// the conflict rule for what is there already: folders merge, and files are // the conflict rule for what is there already: folders merge, and files are
// replaced or kept. // replaced or kept.
type unpacker struct { type unpacker struct {
dir, name, conflict string dir, conflict string
names map[string]bool // the top entries the stream may carry
uid, gid int uid, gid int
chown bool chown bool
done, skipped int done, skipped int
@@ -387,7 +473,8 @@ func (u *unpacker) unpack(tr *tar.Reader) error {
return err return err
} }
clean := path.Clean(hdr.Name) clean := path.Clean(hdr.Name)
if clean != u.name && !strings.HasPrefix(clean, u.name+"/") { top, _, _ := strings.Cut(clean, "/")
if !u.names[top] || clean == ".." || strings.HasPrefix(clean, "../") {
return fmt.Errorf("в архиве чужой путь %q", hdr.Name) return fmt.Errorf("в архиве чужой путь %q", hdr.Name)
} }
target := filepath.Join(u.dir, filepath.FromSlash(clean)) target := filepath.Join(u.dir, filepath.FromSlash(clean))
@@ -475,6 +562,14 @@ func (u *unpacker) own(p string) {
// shq quotes s for a POSIX shell. // shq quotes s for a POSIX shell.
func shq(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } func shq(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" }
// posix wraps a command for the host's sh: the user's login shell gets one
// word to hand on, so fish or csh there does not matter.
func posix(cmd string) string {
// csh reads "!" as history even inside single quotes; '\!' is a plain
// "!" for it and for sh alike.
return "sh -c " + strings.ReplaceAll(shq(cmd), "!", `'\!'`)
}
// shipSSH writes the tar stream into tar on the SSH host, in Dir. // shipSSH writes the tar stream into tar on the SSH host, in Dir.
func (a *Agent) shipSSH(ctx context.Context, args proto.FSShipArgs, src string, m *shipMeter) error { func (a *Agent) shipSSH(ctx context.Context, args proto.FSShipArgs, src string, m *shipMeter) error {
client, _, err := a.sshConnect(ctx, *args.SSH, func(_, note string) { m.note.Store(note) }) client, _, err := a.sshConnect(ctx, *args.SSH, func(_, note string) { m.note.Store(note) })
@@ -528,7 +623,7 @@ func (a *Agent) shipSSH(ctx context.Context, args proto.FSShipArgs, src string,
return err return err
} }
cmd := "mkdir -p -- " + shq(dir) + " && cd -- " + shq(dir) + " && tar -xf -" + flags cmd := "mkdir -p -- " + shq(dir) + " && cd -- " + shq(dir) + " && tar -xf -" + flags
if err := sess.Start(cmd); err != nil { if err := sess.Start(posix(cmd)); err != nil {
return err return err
} }
m.note.Store("") m.note.Store("")
@@ -559,7 +654,7 @@ func sshRun(c *ssh.Client, cmd string) (string, error) {
return "", err return "", err
} }
defer s.Close() defer s.Close()
out, err := s.Output(cmd) out, err := s.Output(posix(cmd))
var ee *ssh.ExitError var ee *ssh.ExitError
if errors.As(err, &ee) { if errors.As(err, &ee) {
err = nil // the checks may exit non-zero; what they print is the answer err = nil // the checks may exit non-zero; what they print is the answer
+66
View File
@@ -0,0 +1,66 @@
package agent
import (
"archive/tar"
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// Several items of one folder go as one tar and land side by side; an entry
// of none of them is refused.
func TestShipSeveralInOneTar(t *testing.T) {
src, dst := t.TempDir(), t.TempDir()
_ = os.WriteFile(filepath.Join(src, "a.txt"), []byte("A"), 0o644)
_ = os.MkdirAll(filepath.Join(src, "b"), 0o755)
_ = os.WriteFile(filepath.Join(src, "b", "c.txt"), []byte("C"), 0o644)
var buf bytes.Buffer
items := []shipItem{{filepath.Join(src, "a.txt"), "a.txt"}, {filepath.Join(src, "b"), "b"}}
if err := packTars(context.Background(), &buf, items, &shipMeter{}); err != nil {
t.Fatal(err)
}
u := &unpacker{dir: dst, names: map[string]bool{"a.txt": true, "b": true}}
if err := u.unpack(tar.NewReader(&buf)); err != nil {
t.Fatal(err)
}
if b, _ := os.ReadFile(filepath.Join(dst, "b", "c.txt")); string(b) != "C" {
t.Fatalf("b/c.txt: %q", b)
}
if b, _ := os.ReadFile(filepath.Join(dst, "a.txt")); string(b) != "A" {
t.Fatalf("a.txt: %q", b)
}
buf.Reset()
_ = packTars(context.Background(), &buf, items, &shipMeter{})
u = &unpacker{dir: t.TempDir(), names: map[string]bool{"a.txt": true}}
if err := u.unpack(tar.NewReader(&buf)); err == nil || !strings.Contains(err.Error(), "чужой путь") {
t.Fatalf("b let in: %v", err)
}
}
func TestRenameTarSeveral(t *testing.T) {
var src bytes.Buffer
tw := tar.NewWriter(&src)
for _, n := range []string{"a", "b/x", "c", "._a"} {
_ = tw.WriteHeader(&tar.Header{Name: n, Mode: 0o644, Typeflag: tar.TypeReg})
}
_ = tw.Close()
var out bytes.Buffer
if err := renameTar(&out, &src, map[string]string{"a": "a", "b": "b2"}); err != nil {
t.Fatal(err)
}
tr := tar.NewReader(&out)
var got []string
for {
h, err := tr.Next()
if err != nil {
break
}
got = append(got, h.Name)
}
if strings.Join(got, ",") != "a,b2/x" {
t.Fatalf("entries: %v", got)
}
}
+794
View File
@@ -0,0 +1,794 @@
package agent
// Files on an SSH host, for the panel's file panes: the agent keeps one SSH
// connection per pane and runs plain commands there - stat, cat, mv, rm - as
// the tar transfer does. No SFTP is needed on the host. A connection that
// dropped is opened again once with what it was opened with; one left alone
// for sshfsIdle is closed.
import (
"archive/tar"
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"path"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"golang.org/x/crypto/ssh"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
const sshfsIdle = 10 * time.Minute
type sshfsConn struct {
mu sync.Mutex
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
last time.Time
busy int // transfers under way: the sweep leaves the connection be
}
// hold marks the connection busy for a long transfer; the returned func ends
// it and starts the idle clock again.
func (c *sshfsConn) hold() func() {
c.mu.Lock()
c.busy++
c.mu.Unlock()
return func() {
c.mu.Lock()
c.busy--
c.last = time.Now()
c.mu.Unlock()
}
}
// idle: nothing under way and unused for sshfsIdle by now.
func (c *sshfsConn) idle(now time.Time) bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.busy == 0 && now.Sub(c.last) > sshfsIdle
}
var (
sshfsConns sync.Map // id -> *sshfsConn
sshfsJanitor sync.Once
)
// statCommand lists dir's entries as "type|size|mtime|mode|name" lines.
func statCommand(flavour, dir string) string {
d := shq(strings.TrimSuffix(dir, "/") + "/")
// Each line starts with LD for a link to a folder, so it opens like one.
loop := "cd " + d + " && for f in .[!.]* ..?* *; do [ -e \"$f\" ] || [ -L \"$f\" ] || continue; " +
"t=; [ -L \"$f\" ] && [ -d \"$f\" ] && t=LD; printf '%s|' \"$t\"; "
switch flavour {
case "bsd":
return loop + "stat -f '%HT|%z|%m|%Sp|%N' -- \"$f\"; done"
case "ls":
return "cd " + d + " && LC_ALL=C ls -la"
}
return loop + "stat -c '%F|%s|%Y|%A|%n' -- \"$f\"; done"
}
// parseStatLine reads one line of statCommand's output.
func parseStatLine(line string) (proto.FSEntry, bool) {
// The LD mark of statCommand: empty, or LD for a link to a folder.
mark := ""
if i := strings.IndexByte(line, '|'); i >= 0 && (line[:i] == "" || line[:i] == "LD") {
mark, line = line[:i], line[i+1:]
}
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") || mark == "LD", Size: size, MTime: mt, Mode: p[3],
Link: strings.Contains(t, "link")}, true
}
// parseLsLine reads a line of "ls -la" for a host with no stat: no times.
func parseLsLine(line string) (proto.FSEntry, bool) {
f := strings.Fields(line)
if len(f) < 9 || len(f[0]) < 10 {
return proto.FSEntry{}, false
}
size, err := strconv.ParseInt(f[4], 10, 64)
if err != nil {
return proto.FSEntry{}, false
}
// The name is what follows the eighth field, spaces and all.
rest := line
for i := 0; i < 8; i++ {
rest = strings.TrimLeft(rest, " ")
rest = rest[strings.IndexByte(rest, ' ')+1:]
}
name := strings.TrimLeft(rest, " ")
if f[0][0] == 'l' {
if i := strings.Index(name, " -> "); i >= 0 {
name = name[:i]
}
}
if name == "." || name == ".." {
return proto.FSEntry{}, false
}
return proto.FSEntry{Name: name, IsDir: f[0][0] == 'd', Size: size, Mode: f[0]}, true
}
func randHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// sshfsOpen connects for a file pane and tells the host's home.
func (a *Agent) sshfsOpen(ctx context.Context, t proto.SSHTarget) (any, error) {
client, _, err := a.sshConnect(ctx, t, func(string, string) {})
if err != nil {
return nil, err
}
c := &sshfsConn{client: client, target: t, last: time.Now()}
out, _ := sshRun(client, "stat -c %F / >/dev/null 2>&1 && echo GNU; stat -f %HT / >/dev/null 2>&1 && echo BSD; "+
"command -v zip >/dev/null 2>&1 && echo ZIP; command -v unzip >/dev/null 2>&1 && echo UNZIP; "+
"command -v python3 >/dev/null 2>&1 && echo PY; echo \"$HOME\"")
lines := strings.Split(strings.TrimSpace(out), "\n")
switch {
case strings.Contains(out, "GNU"):
c.statFmt = "gnu"
case strings.Contains(out, "BSD"):
c.statFmt = "bsd"
default:
c.statFmt = "ls"
}
c.home = strings.TrimSpace(lines[len(lines)-1])
if !strings.HasPrefix(c.home, "/") {
c.home = "/"
}
id := randHex(12)
sshfsConns.Store(id, c)
sshfsJanitor.Do(func() { go sshfsSweep() })
// zip and unzip, where the host has them, for the pane's buttons.
return map[string]any{"id": id, "home": c.home,
"zip": hasLine(lines, "ZIP") || hasLine(lines, "PY"), "unzip": hasLine(lines, "UNZIP") || hasLine(lines, "PY")}, nil
}
func hasLine(lines []string, w string) bool {
for _, l := range lines {
if strings.TrimSpace(l) == w {
return true
}
}
return false
}
// sshfsSweep closes connections nobody used for sshfsIdle.
func sshfsSweep() {
for range time.Tick(time.Minute) {
sshfsConns.Range(func(k, v any) bool {
if v.(*sshfsConn).idle(time.Now()) {
sshfsClose(k.(string))
}
return true
})
}
}
func sshfsClose(id string) {
if v, ok := sshfsConns.LoadAndDelete(id); ok {
_ = v.(*sshfsConn).client.Close()
}
}
func sshfsGet(id string) (*sshfsConn, error) {
v, ok := sshfsConns.Load(id)
if !ok {
return nil, errors.New("подключение закрыто - откройте файлы по SSH снова")
}
c := v.(*sshfsConn)
c.mu.Lock()
c.last = time.Now()
c.mu.Unlock()
return c, nil
}
// session opens an SSH session on the connection; a connection that dropped
// is opened again once.
func (a *Agent) sshfsSession(c *sshfsConn) (*ssh.Session, error) {
c.mu.Lock()
client := c.client
c.mu.Unlock()
s, err := client.NewSession()
if err == nil {
return s, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
defer cancel()
nc, _, cerr := a.sshConnect(ctx, c.target, func(string, string) {})
if cerr != nil {
return nil, fmt.Errorf("подключение к хосту потеряно: %v", cerr)
}
c.mu.Lock()
_ = c.client.Close()
c.client = nc
c.mu.Unlock()
return nc.NewSession()
}
// run runs cmd on the host with stdin, returning its output; a failure
// carries what the command said on stderr.
func (a *Agent) sshfsRun(c *sshfsConn, cmd string, stdin io.Reader) ([]byte, error) {
s, err := a.sshfsSession(c)
if err != nil {
return nil, err
}
defer s.Close()
var out, errb bytes.Buffer
s.Stdout = &out
s.Stderr = &limitBuf{b: &errb, max: 4096}
if stdin != nil {
s.Stdin = stdin
}
if err := s.Run(posix(cmd)); err != nil {
msg := strings.TrimSpace(errb.String())
if msg == "" {
msg = err.Error()
}
if len(msg) > 300 {
msg = msg[:300]
}
return out.Bytes(), errors.New(msg)
}
return out.Bytes(), nil
}
// sshfsTask runs one sshfs.* task.
func (a *Agent) sshfsTask(ctx context.Context, env proto.TaskEnvelope) (any, error) {
var args proto.SSHFSArgs
if err := json.Unmarshal(env.Args, &args); err != nil {
return nil, err
}
switch env.Type {
case proto.TaskSSHFSOpen:
if args.SSH == nil {
return nil, errors.New("не указан хост")
}
return a.sshfsOpen(ctx, *args.SSH)
case proto.TaskSSHFSClose:
sshfsClose(args.Conn)
return nil, nil
}
c, err := sshfsGet(args.Conn)
if err != nil {
return nil, err
}
return a.sshfsOp(c, args.Op, args.Args)
}
func (a *Agent) sshfsOp(c *sshfsConn, op string, raw json.RawMessage) (any, error) {
switch op {
case "list":
var p proto.FSPathArgs
_ = json.Unmarshal(raw, &p)
dir := p.Path
if dir == "" || strings.HasPrefix(dir, "~") {
dir = c.home
}
out, err := a.sshfsRun(c, statCommand(c.statFmt, dir), nil)
if err != nil && len(out) == 0 {
return nil, err
}
entries := []proto.FSEntry{}
for _, line := range strings.Split(string(out), "\n") {
var e proto.FSEntry
var ok bool
if c.statFmt == "ls" {
e, ok = parseLsLine(line)
} else {
e, ok = parseStatLine(line)
}
if ok {
entries = append(entries, e)
}
}
return proto.FSListResult{Path: path.Clean(dir), Entries: entries}, nil
case "read":
var p proto.FSPathArgs
_ = json.Unmarshal(raw, &p)
size, _ := a.sshfsSize(c, p.Path)
out, err := a.sshfsRun(c, "head -c "+strconv.Itoa(proto.FSReadCap+1)+" -- "+shq(p.Path), nil)
if err != nil {
return nil, err
}
truncated := len(out) > proto.FSReadCap
if truncated {
out = out[:proto.FSReadCap]
}
return proto.FSReadResult{Path: p.Path, Size: size, Content: base64.StdEncoding.EncodeToString(out),
Truncated: truncated, Binary: !utf8.Valid(out) || bytes.IndexByte(out, 0) >= 0}, nil
case "write":
var p proto.FSWriteArgs
_ = json.Unmarshal(raw, &p)
data, err := base64.StdEncoding.DecodeString(p.Data)
if err != nil {
return nil, err
}
_, err = a.sshfsRun(c, writeCommand(p.Path), bytes.NewReader(data))
return map[string]string{"path": p.Path}, err
case "mkdir":
var p proto.FSPathArgs
_ = json.Unmarshal(raw, &p)
_, err := a.sshfsRun(c, "mkdir -p -- "+shq(p.Path), nil)
return map[string]string{"path": p.Path}, err
case "delete", "deltree":
var p proto.FSPathArgs
_ = json.Unmarshal(raw, &p)
if p.Path == "" || path.Clean(p.Path) == "/" {
return nil, errors.New("отказ: удаление корня запрещено")
}
flag := "-f"
if op == "deltree" {
flag = "-rf"
}
_, err := a.sshfsRun(c, "rm "+flag+" -- "+shq(p.Path), nil)
return map[string]string{"path": p.Path}, err
case "move":
var p proto.FSMoveArgs
_ = json.Unmarshal(raw, &p)
_, err := a.sshfsRun(c, "mv -- "+shq(p.Src)+" "+shq(p.Dst), nil)
return map[string]string{"src": p.Src, "dst": p.Dst}, err
case "copy":
var p proto.FSCopyArgs
_ = json.Unmarshal(raw, &p)
return nil, a.sshfsCopy(c, p)
case "zip":
var p proto.FSZipArgs
_ = json.Unmarshal(raw, &p)
cmd, err := zipCommand(p.Paths, p.Dest)
if err != nil {
return nil, err
}
_, err = a.sshfsRun(c, cmd, nil)
return map[string]string{"dest": p.Dest}, err
case "unzip":
var p proto.FSUnzipArgs
_ = json.Unmarshal(raw, &p)
_, err := a.sshfsRun(c, unzipCommand(p.Path, p.Dest), nil)
return map[string]string{"dest": p.Dest}, err
case "stat":
var p proto.FSPathArgs
_ = json.Unmarshal(raw, &p)
return a.sshfsStat(c, p.Path)
case "chmod":
var p proto.FSChmodArgs
_ = json.Unmarshal(raw, &p)
rec := ""
if p.Recursive {
rec = "-R "
}
var cmds []string
if p.Mode != nil {
cmds = append(cmds, "chmod "+rec+fmt.Sprintf("%o", *p.Mode)+" -- "+shq(p.Path))
}
if p.Owner != "" || p.Group != "" {
who := p.Owner
if p.Group != "" {
who += ":" + p.Group
}
cmds = append(cmds, "chown "+rec+shq(who)+" -- "+shq(p.Path))
}
if len(cmds) == 0 {
return nil, nil
}
_, err := a.sshfsRun(c, strings.Join(cmds, " && "), nil)
return map[string]string{"path": p.Path}, err
}
return nil, errors.New("неизвестная операция")
}
// zipCommand packs paths - items of one folder - into the archive dest, from
// that folder, under their own names. Paths are absolute and names start
// with "./", so none reads as an option.
func zipCommand(paths []string, dest string) (string, error) {
if len(paths) == 0 {
return "", errors.New("не выбраны файлы")
}
dir := path.Dir(path.Clean(paths[0]))
names := make([]string, len(paths))
for i, p := range paths {
p = path.Clean(p)
if path.Dir(p) != dir {
return "", errors.New("в архив - только из одной папки")
}
names[i] = shq("./" + path.Base(p))
}
// zip where the host has it, else Python's zipfile, which most have.
items := strings.Join(names, " ")
return "cd -- " + shq(dir) + " && if command -v zip >/dev/null 2>&1; then zip -r -q -y " + shq(path.Clean(dest)) + " " + items + "; " +
"else python3 -m zipfile -c " + shq(path.Clean(dest)) + " " + items + "; fi", nil
}
// unzipCommand unpacks the archive p into dest (its own folder if empty),
// replacing what is there, as on a machine.
func unzipCommand(p, dest string) string {
if dest == "" {
dest = path.Dir(path.Clean(p))
}
return "if command -v unzip >/dev/null 2>&1; then unzip -o -q " + shq(path.Clean(p)) + " -d " + shq(path.Clean(dest)) + "; " +
"else python3 -m zipfile -e " + shq(path.Clean(p)) + " " + shq(path.Clean(dest)) + "; fi"
}
// writeCommand writes stdin to p through a file beside it, so a broken
// transfer leaves the old file as it was.
func writeCommand(p string) string {
tmp := path.Join(path.Dir(p), "."+path.Base(p)+".lpra-"+randHex(4))
return "cat > " + shq(tmp) + " && mv -f -- " + shq(tmp) + " " + shq(p) + " || { rm -f -- " + shq(tmp) + "; exit 1; }"
}
// sizeCommand tells the size of p - of its target, if a link: what cat
// reads.
func sizeCommand(statFmt, p string) string {
switch statFmt {
case "bsd":
return "stat -L -f %z -- " + shq(p)
case "ls":
return "wc -c < " + shq(p)
}
return "stat -L -c %s -- " + shq(p)
}
func (a *Agent) sshfsSize(c *sshfsConn, p string) (int64, error) {
out, err := a.sshfsRun(c, sizeCommand(c.statFmt, p), nil)
if err != nil {
return 0, err
}
return strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64)
}
func (a *Agent) sshfsStat(c *sshfsConn, p string) (any, error) {
cmd := "stat -c '%F|%s|%Y|%a|%u|%g|%U|%G' -- " + shq(p)
if c.statFmt == "bsd" {
cmd = "stat -f '%HT|%z|%m|%Lp|%u|%g|%Su|%Sg' -- " + shq(p)
}
out, err := a.sshfsRun(c, cmd, nil)
if err != nil {
return nil, err
}
f := strings.Split(strings.TrimSpace(string(out)), "|")
if len(f) != 8 {
return nil, errors.New("не удалось прочитать свойства")
}
size, _ := strconv.ParseInt(f[1], 10, 64)
mt, _ := strconv.ParseInt(f[2], 10, 64)
mode, _ := strconv.ParseUint(f[3], 8, 32)
uid, _ := strconv.Atoi(f[4])
gid, _ := strconv.Atoi(f[5])
return proto.FSStatResult{Path: p, IsDir: strings.Contains(strings.ToLower(f[0]), "directory"), Size: size, MTime: mt,
Mode: uint32(mode), UID: uid, GID: gid, Owner: f[6], Group: f[7], Users: []string{}, Groups: []string{}}, nil
}
// sshfsCopy copies or moves on the host, by the conflict rule for what is
// there already.
func (a *Agent) sshfsCopy(c *sshfsConn, p proto.FSCopyArgs) error {
src, dst := shq(p.Src), shq(p.Dst)
exists, _ := a.sshfsRun(c, "test -e "+dst+" -o -L "+dst+" && echo EXISTS", nil)
there := strings.Contains(string(exists), "EXISTS")
if there {
switch p.Conflict {
case "replace":
if _, err := a.sshfsRun(c, "rm -rf -- "+dst, nil); err != nil {
return err
}
case "skip":
return nil
default:
return fmt.Errorf("%s уже существует", p.Dst)
}
}
cmd := "cp -a -- " + src + " " + dst
if p.Move {
cmd = "mv -- " + src + " " + dst
}
_, err := a.sshfsRun(c, cmd, nil)
return err
}
// doSSHFSSend sends a file of the host to the browser in pieces, as
// doFSSend does with a local one: cat's output, piece after piece.
func (a *Agent) doSSHFSSend(ctx context.Context, env proto.TaskEnvelope) proto.TaskResult {
var args proto.FSXferArgs
if err := json.Unmarshal(env.Args, &args); err != nil || args.Chunk <= 0 {
return proto.TaskResult{Error: "плохой запрос"}
}
c, err := sshfsGet(args.Conn)
if err != nil {
return proto.TaskResult{Error: err.Error()}
}
defer c.hold()()
size, err := a.sshfsSize(c, args.Path)
if err != nil {
return proto.TaskResult{Error: err.Error()}
}
if size > proto.FSFileCap {
return proto.TaskResult{Error: "файл больше 512 МБ"}
}
s, err := a.sshfsSession(c)
if err != nil {
return proto.TaskResult{Error: err.Error()}
}
defer s.Close()
out, _ := s.StdoutPipe()
if err := s.Start(posix("cat -- " + shq(args.Path))); err != nil {
return proto.TaskResult{Error: err.Error()}
}
// Read in order from cat, posted to the server a few at a time.
sizeHdr := strconv.FormatInt(size, 10)
n := pieceCount(size, args.Chunk)
pctx, cancel := context.WithCancel(ctx)
defer cancel()
var (
wg sync.WaitGroup
once sync.Once
first error
)
fail := func(err error) { once.Do(func() { first = err; cancel() }) }
sem := make(chan struct{}, xferWorkers)
for i := 0; i < n && pctx.Err() == nil; i++ {
buf := make([]byte, min(int64(args.Chunk), size-int64(i)*int64(args.Chunk)))
if _, err := io.ReadFull(out, buf); err != nil {
fail(errors.New("файл изменился во время чтения: " + err.Error()))
break
}
select {
case sem <- struct{}{}:
case <-pctx.Done():
}
if pctx.Err() != nil {
break
}
wg.Add(1)
go func(i int, buf []byte) {
defer func() { <-sem; wg.Done() }()
err := a.xferRequest(pctx, func(ctx context.Context) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.xferURL(args.Xfer, i), bytes.NewReader(buf))
if err == nil {
req.Header.Set("X-Size", sizeHdr)
req.Header.Set("Content-Type", "application/octet-stream")
}
return req, err
}, func(*http.Response) error { return nil })
if err != nil {
fail(errors.New("передача прервана: " + err.Error()))
}
}(i, buf)
}
wg.Wait()
if first == nil && ctx.Err() != nil {
first = ctx.Err()
}
if first != nil {
return proto.TaskResult{Error: first.Error()}
}
// All of it is read; the session closes (deferred) without waiting on a
// cat that may have more to say.
data, _ := json.Marshal(map[string]any{"path": args.Path, "size": size})
return proto.TaskResult{OK: true, Data: data}
}
// doSSHFSPut writes a file the browser uploads onto the host, through a file
// beside it.
func (a *Agent) doSSHFSPut(ctx context.Context, env proto.TaskEnvelope) proto.TaskResult {
var args proto.FSXferArgs
if err := json.Unmarshal(env.Args, &args); err != nil || args.Chunk <= 0 || args.Size < 0 || args.Size > proto.FSFileCap {
return proto.TaskResult{Error: "плохой запрос"}
}
c, err := sshfsGet(args.Conn)
if err != nil {
return proto.TaskResult{Error: err.Error()}
}
defer c.hold()()
s, err := a.sshfsSession(c)
if err != nil {
return proto.TaskResult{Error: err.Error()}
}
defer s.Close()
in, _ := s.StdinPipe()
var errb bytes.Buffer
s.Stderr = &limitBuf{b: &errb, max: 4096}
if err := s.Start(posix(writeCommand(args.Path))); err != nil {
return proto.TaskResult{Error: err.Error()}
}
// Pieces come in a few ahead; stdin takes them in order. ack: how many
// are written, for the server to let go.
n := pieceCount(args.Size, args.Chunk)
var written atomic.Int64
err = ordered(ctx, n, xferWorkers, func(ctx context.Context, i int) ([]byte, error) {
want := min(int64(args.Chunk), args.Size-int64(i)*int64(args.Chunk))
var data []byte
err := a.xferRequest(ctx, func(ctx context.Context) (*http.Request, error) {
return http.NewRequestWithContext(ctx, http.MethodGet, a.xferURL(args.Xfer, i)+"?ack="+strconv.FormatInt(written.Load(), 10), nil)
}, func(resp *http.Response) error {
b, err := io.ReadAll(io.LimitReader(resp.Body, int64(args.Chunk)+1))
if err == nil && int64(len(b)) != want {
err = errors.New("кусок не той длины")
}
data = b
return err
})
return data, err
}, func(i int, b []byte) error {
_, err := in.Write(b)
written.Store(int64(i + 1))
return err
})
if err != nil {
_ = s.Close()
return proto.TaskResult{Error: "загрузка прервана: " + err.Error()}
}
_ = in.Close()
if err := s.Wait(); err != nil {
msg := strings.TrimSpace(errb.String())
if msg == "" {
msg = err.Error()
}
return proto.TaskResult{Error: msg}
}
data, _ := json.Marshal(map[string]any{"path": args.Path, "size": args.Size})
return proto.TaskResult{OK: true, Data: data}
}
// renameTar copies a tar stream, giving each top entry named in names - and
// everything under it - its new name: what the host's tar packs under the
// file's own name arrives under the name chosen for it. Other entries (the
// "._" companions a macOS tar adds) are left out.
func renameTar(w io.Writer, r io.Reader, names map[string]string) error {
tr, tw := tar.NewReader(r), tar.NewWriter(w)
for {
h, err := tr.Next()
if err == io.EOF {
return tw.Close()
}
if err != nil {
return err
}
n := strings.TrimPrefix(h.Name, "./")
top, rest := n, ""
if i := strings.IndexByte(n, '/'); i >= 0 {
top, rest = n[:i], n[i:]
}
// Only base and what is under it - nothing that climbs out by "..".
name, ok := names[top]
if !ok || strings.Contains("/"+rest+"/", "/../") {
continue
}
h.Name = name + rest
h.Uid, h.Gid, h.Uname, h.Gname = 0, 0, "", ""
if err := tw.WriteHeader(h); err != nil {
return err
}
if _, err := io.Copy(tw, tr); err != nil {
return err
}
}
}
// packRemote writes src on the connection's host as a tar stream named name,
// counting what goes for the progress.
func (a *Agent) packRemote(ctx context.Context, w io.Writer, c *sshfsConn, srcs, names []string, m *shipMeter) error {
defer c.hold()()
s, err := a.sshfsSession(c)
if err != nil {
return err
}
defer s.Close()
go func() { <-ctx.Done(); s.Close() }()
out, _ := s.StdoutPipe()
var errb bytes.Buffer
s.Stderr = &limitBuf{b: &errb, max: 4096}
// All from one folder: packed from there by their own names.
dir := path.Dir(path.Clean(srcs[0]))
rename := map[string]string{}
cmd := "COPYFILE_DISABLE=1 tar -cf - -C " + shq(dir) + " --"
for i, p := range srcs {
b := path.Base(path.Clean(p))
rename[b] = names[i]
cmd += " " + shq(b)
}
if err := s.Start(posix(cmd)); err != nil {
return err
}
if err := renameTar(w, &countReader{r: out, n: &m.done}, rename); err != nil {
return err
}
if err := s.Wait(); err != nil {
if msg := strings.TrimSpace(errb.String()); msg != "" {
return errors.New("tar на хосте: " + msg)
}
return err
}
return nil
}
// remoteSize is about how many bytes src takes on the host, for progress.
func (a *Agent) remoteSize(c *sshfsConn, src string) int64 {
out, err := a.sshfsRun(c, "du -sk -- "+shq(src), nil)
if err != nil {
return 0
}
f := strings.Fields(string(out))
if len(f) == 0 {
return 0
}
kb, _ := strconv.ParseInt(f[0], 10, 64)
return kb * 1024
}
// unpackRemote puts a tar stream into dir on the connection's host, by the
// conflict rule: tar keeps or replaces what is there, an "error" rule
// refuses when name is there already.
func (a *Agent) unpackRemote(c *sshfsConn, r io.Reader, dir string, names []string, conflict string) (skipped int, err error) {
defer c.hold()()
base := strings.TrimSuffix(dir, "/") + "/"
check := ""
for _, n := range names {
check += "test -e " + shq(base+n) + " -o -L " + shq(base+n) + " && echo " + shq("LPRA_EXISTS "+n) + "; "
}
out, _ := a.sshfsRun(c, check+"true", nil)
keep := map[string]string{}
var put []string
for _, n := range names {
if strings.Contains(string(out), "LPRA_EXISTS "+n+"\n") {
switch conflict {
case "skip":
// Left as it was, as on a machine.
skipped++
continue
case "replace":
default:
return 0, errors.New(base + n + " уже существует")
}
}
keep[n] = n
put = append(put, n)
}
if len(put) == 0 {
return skipped, nil
}
// Only the items sent (and not kept back) reach the host's tar.
pr, pw := io.Pipe()
go func() { pw.CloseWithError(renameTar(pw, r, keep)) }()
defer pr.Close()
_, err = a.sshfsRun(c, unpackCommand(dir, put, conflict), pr)
return skipped, err
}
// unpackCommand unpacks a tar stream of names into dir; "replace" removes
// what is there first, so each thing is replaced whole as on a machine.
func unpackCommand(dir string, names []string, conflict string) string {
cmd := "mkdir -p -- " + shq(dir) + " && cd -- " + shq(dir) + " && tar -xf -"
if conflict == "replace" {
rm := "rm -rf --"
for _, n := range names {
rm += " " + shq(strings.TrimSuffix(dir, "/")+"/"+n)
}
cmd = rm + " && " + cmd
}
return cmd
}
+242
View File
@@ -0,0 +1,242 @@
package agent
import (
"archive/tar"
"bytes"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
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 !strings.Contains(c, `'/srv/it'\''s here/'`) {
t.Errorf("%s: dir not quoted: %s", fl, c)
}
}
}
func TestParseLsLine(t *testing.T) {
e, ok := parseLsLine("-rw-r--r-- 1 root root 1234 Oct 15 10:00 my file.txt")
if !ok || e.Name != "my file.txt" || e.Size != 1234 || e.IsDir {
t.Fatalf("file: %+v %v", e, ok)
}
e, ok = parseLsLine("lrwxrwxrwx 1 root root 7 Oct 15 10:00 link -> target")
if !ok || e.Name != "link" {
t.Fatalf("link: %+v %v", e, ok)
}
if _, ok := parseLsLine("drwxr-xr-x 2 root root 4096 Oct 15 10:00 .."); ok {
t.Fatal(".. listed")
}
if _, ok := parseLsLine("total 8"); ok {
t.Fatal("total parsed")
}
}
func TestRenameTarEntries(t *testing.T) {
var src bytes.Buffer
tw := tar.NewWriter(&src)
for _, n := range []string{"old/", "old/a.txt", "old/sub/b"} {
typ := byte(tar.TypeReg)
if strings.HasSuffix(n, "/") {
typ = tar.TypeDir
}
_ = tw.WriteHeader(&tar.Header{Name: n, Mode: 0o644, Typeflag: typ})
}
_ = tw.Close()
var out bytes.Buffer
if err := renameTar(&out, &src, map[string]string{"old": "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)
}
}
// A macOS host's tar packs "._name" companions with the file's attributes;
// they are not the file and must not land under its name.
func TestRenameTarDropsOtherEntries(t *testing.T) {
var src bytes.Buffer
tw := tar.NewWriter(&src)
for _, e := range []struct{ n, body string }{{"./._r.txt", "attrs"}, {"./r.txt", "data"}} {
_ = tw.WriteHeader(&tar.Header{Name: e.n, Mode: 0o644, Typeflag: tar.TypeReg, Size: int64(len(e.body))})
_, _ = tw.Write([]byte(e.body))
}
_ = tw.Close()
var out bytes.Buffer
if err := renameTar(&out, &src, map[string]string{"r.txt": "new.txt"}); err != nil {
t.Fatal(err)
}
tr := tar.NewReader(&out)
var got []string
for {
h, err := tr.Next()
if err != nil {
break
}
b, _ := io.ReadAll(tr)
got = append(got, h.Name+"="+string(b))
}
if strings.Join(got, ",") != "new.txt=data" {
t.Fatalf("entries: %v", got)
}
}
// zip on an SSH host: from the items' folder, by their own names, quoted.
func TestZipCommand(t *testing.T) {
cmd, err := zipCommand([]string{"/srv/a b", "/srv/it's"}, "/tmp/x.zip")
if err != nil {
t.Fatal(err)
}
want := `cd -- '/srv' && if command -v zip >/dev/null 2>&1; then zip -r -q -y '/tmp/x.zip' './a b' './it'\''s'; ` +
`else python3 -m zipfile -c '/tmp/x.zip' './a b' './it'\''s'; fi`
if cmd != want {
t.Fatalf("got %s\nwant %s", cmd, want)
}
if _, err := zipCommand([]string{"/a/x", "/b/y"}, "/t.zip"); err == nil {
t.Fatal("items of two folders: want an error")
}
if _, err := zipCommand(nil, "/t.zip"); err == nil {
t.Fatal("no items: want an error")
}
if got := unzipCommand("/srv/a.zip", ""); got != `if command -v unzip >/dev/null 2>&1; then unzip -o -q '/srv/a.zip' -d '/srv'; else python3 -m zipfile -e '/srv/a.zip' '/srv'; fi` {
t.Fatalf("unzip: %s", got)
}
}
// A connection busy with a long transfer is not idle, however long ago it
// was opened; once the transfer ends the idle clock starts again.
func TestSweepSparesBusyConn(t *testing.T) {
c := &sshfsConn{last: time.Now().Add(-2 * sshfsIdle)}
release := c.hold()
if c.idle(time.Now()) {
t.Fatal("a busy connection was taken for idle")
}
release()
if c.idle(time.Now()) {
t.Fatal("just released: not idle yet")
}
if !c.idle(time.Now().Add(2 * sshfsIdle)) {
t.Fatal("unused for long: idle")
}
}
// A file's size is its target's when it is a link: what cat will read.
func TestSizeCommandFollowsLinks(t *testing.T) {
for _, f := range []string{"gnu", "bsd"} {
if cmd := sizeCommand(f, "/x"); !strings.Contains(cmd, "stat -L ") {
t.Fatalf("%s: %s", f, cmd)
}
}
}
// Nothing of a tar stream lands outside name: other entries and ".." paths
// are dropped.
func TestRenameTarKeepsInsideName(t *testing.T) {
var src bytes.Buffer
tw := tar.NewWriter(&src)
for _, n := range []string{"d/a", "d/../../etc/x", ".ssh/authorized_keys", "d/b"} {
_ = tw.WriteHeader(&tar.Header{Name: n, Mode: 0o644, Typeflag: tar.TypeReg})
}
_ = tw.Close()
var out bytes.Buffer
if err := renameTar(&out, &src, map[string]string{"d": "d"}); err != nil {
t.Fatal(err)
}
tr := tar.NewReader(&out)
var got []string
for {
h, err := tr.Next()
if err != nil {
break
}
got = append(got, h.Name)
}
if strings.Join(got, ",") != "d/a,d/b" {
t.Fatalf("entries: %v", got)
}
}
// Into an SSH host, "replace" replaces the thing whole, as on a machine.
func TestUnpackCommandReplaces(t *testing.T) {
cmd := unpackCommand("/srv", []string{"d"}, "replace")
if !strings.HasPrefix(cmd, "rm -rf -- '/srv/d' && ") || strings.Contains(cmd, "--overwrite") {
t.Fatalf("replace: %s", cmd)
}
if cmd := unpackCommand("/srv", []string{"d"}, "error"); cmd != "mkdir -p -- '/srv' && cd -- '/srv' && tar -xf -" {
t.Fatalf("error: %s", cmd)
}
}
// A link to a folder lists as a folder (marked as a link), so it opens.
func TestParseStatLineFolderLink(t *testing.T) {
e, ok := parseStatLine("LD|symbolic link|7|1700000000|lrwxrwxrwx|www")
if !ok || !e.IsDir || !e.Link || e.Name != "www" {
t.Fatalf("%+v %v", e, ok)
}
e, ok = parseStatLine("|regular file|5|1700000000|-rw-r--r--|a|b")
if !ok || e.IsDir || e.Name != "a|b" {
t.Fatalf("plain: %+v %v", e, ok)
}
if !strings.Contains(statCommand("gnu", "/x"), `[ -L "$f" ] && [ -d "$f" ]`) {
t.Fatal("the command does not mark folder links")
}
}
func TestListFolderLink(t *testing.T) {
d := t.TempDir()
_ = os.Mkdir(filepath.Join(d, "real"), 0o755)
_ = os.Symlink(filepath.Join(d, "real"), filepath.Join(d, "ln"))
r, err := fsList(d)
if err != nil {
t.Fatal(err)
}
for _, e := range r.Entries {
if e.Name == "ln" && (!e.IsDir || !e.Link) {
t.Fatalf("link: %+v", e)
}
}
}
// Commands go to the host as one argument of sh -c, so the user's own login
// shell (fish, csh) cannot read them its way.
func TestPosixCommand(t *testing.T) {
if got := posix(`if [ -d '/x' ]; then echo ok; fi`); got != `sh -c 'if [ -d '\''/x'\'' ]; then echo ok; fi'` {
t.Fatalf("%s", got)
}
}
// csh expands "!" even inside single quotes: it goes out as '\!'.
func TestPosixCommandBang(t *testing.T) {
if got := posix(`ls .[!.]*`); got != `sh -c 'ls .['\!'.]*'` {
t.Fatalf("%s", got)
}
}
+18
View File
@@ -26,6 +26,12 @@ func (a *Agent) runTask(ctx context.Context, taskID string) {
res = a.doFSPut(ctx, env) res = a.doFSPut(ctx, env)
case proto.TaskFSSend: case proto.TaskFSSend:
res = a.doFSSend(ctx, env) res = a.doFSSend(ctx, env)
case proto.TaskSSHFSSend:
res = a.doSSHFSSend(ctx, env)
case proto.TaskSSHFSPut:
res = a.doSSHFSPut(ctx, env)
case proto.TaskSSHFSOpen, proto.TaskSSHFSClose, proto.TaskSSHFSOp:
res = resultOf(a.sshfsTask(ctx, env))
case proto.TaskFSShipOut: case proto.TaskFSShipOut:
res = a.doShipOut(ctx, env) res = a.doShipOut(ctx, env)
case proto.TaskFSShipIn: case proto.TaskFSShipIn:
@@ -107,3 +113,15 @@ func itoa64(n int64) string {
} }
return string(b[i:]) return string(b[i:])
} }
// resultOf makes a task's result of what it returned.
func resultOf(data any, err error) proto.TaskResult {
if err != nil {
return proto.TaskResult{Error: err.Error()}
}
raw, err := json.Marshal(data)
if err != nil {
return proto.TaskResult{Error: err.Error()}
}
return proto.TaskResult{OK: true, Data: raw}
}
+62
View File
@@ -0,0 +1,62 @@
package agent
import (
"errors"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"sync"
"golang.org/x/sys/unix"
)
// termsBySID are the running terminals by their session, so the panel can
// ask where one of them is.
var termsBySID sync.Map // sid -> *PTY
// Cwd is the folder the terminal is in now: that of the program in front
// (the shell, or what it runs), else of the shell itself.
func (p *PTY) Cwd() (string, error) {
pid := 0
if g, err := unix.IoctlGetInt(int(p.f.Fd()), unix.TIOCGPGRP); err == nil && g > 0 {
pid = g
}
if pid > 0 {
if d, err := procCwd(pid); err == nil {
return d, nil
}
}
return procCwd(p.cmd.Process.Pid)
}
// procCwd is a process's working folder: /proc on Linux, lsof elsewhere.
func procCwd(pid int) (string, error) {
if runtime.GOOS == "linux" {
return os.Readlink("/proc/" + strconv.Itoa(pid) + "/cwd")
}
out, err := exec.Command("lsof", "-a", "-p", strconv.Itoa(pid), "-d", "cwd", "-Fn").Output()
if err != nil {
return "", err
}
for _, l := range strings.Split(string(out), "\n") {
if strings.HasPrefix(l, "n/") {
return l[1:], nil
}
}
return "", errors.New("папка терминала не найдена")
}
// termCwd answers the panel: where the terminal of session sid is now.
func termCwd(sid string) (any, error) {
v, ok := termsBySID.Load(sid)
if !ok {
return nil, errors.New("терминал не найден")
}
d, err := v.(*PTY).Cwd()
if err != nil {
return nil, err
}
return map[string]string{"path": d}, nil
}
+22 -2
View File
@@ -4,6 +4,8 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strings"
"sync" "sync"
"syscall" "syscall"
"time" "time"
@@ -41,8 +43,12 @@ func CloseTerminals() {
// StartPTY launches a login shell attached to a new PTY. With user empty (or // StartPTY launches a login shell attached to a new PTY. With user empty (or
// equal to the agent's own user) it is the agent's shell - root by default, // equal to the agent's own user) it is the agent's shell - root by default,
// since the agent runs as a root systemd service. Any other user is entered via // since the agent runs as a root systemd service. Any other user is entered via
// `su -l`, which only a root agent can do without a password. // `su -l`, which only a root agent can do without a password. dir, when it
func StartPTY(user string) (*PTY, error) { // is a folder, is where the shell starts instead of home.
func StartPTY(user, dir string) (*PTY, error) {
if dir != "" && (!filepath.IsAbs(dir) || strings.ContainsAny(dir, "\x00\r\n") || !isDir(dir)) {
dir = ""
}
var cmd *exec.Cmd var cmd *exec.Cmd
env := append(os.Environ(), "TERM=xterm-256color") env := append(os.Environ(), "TERM=xterm-256color")
if user == "" || user == currentUser() { if user == "" || user == currentUser() {
@@ -54,6 +60,9 @@ func StartPTY(user string) (*PTY, error) {
cmd.Dir = home cmd.Dir = home
env = append(env, "HOME="+home) env = append(env, "HOME="+home)
} }
if dir != "" {
cmd.Dir = dir
}
} else { } else {
if !validUserName(user) { if !validUserName(user) {
return nil, fmt.Errorf("недопустимое имя пользователя %q", user) return nil, fmt.Errorf("недопустимое имя пользователя %q", user)
@@ -69,6 +78,11 @@ func StartPTY(user string) (*PTY, error) {
return nil, fmt.Errorf("su не найден: %w", err) return nil, fmt.Errorf("su не найден: %w", err)
} }
cmd = exec.Command(su, "-l", user) cmd = exec.Command(su, "-l", user)
// su -l always goes home; to start elsewhere, its shell changes
// directory first and becomes the login shell (sh and csh alike).
if dir != "" {
cmd = exec.Command(su, "-l", user, "-c", "cd "+shQuote(dir)+"; exec \"$SHELL\" -l")
}
// su -l goes to the user's home; starting there too keeps the shell // su -l goes to the user's home; starting there too keeps the shell
// out of "/" should it not. // out of "/" should it not.
if home := homeDir(user); isDir(home) { if home := homeDir(user); isDir(home) {
@@ -126,3 +140,9 @@ func (p *PTY) Close() error {
}) })
return err return err
} }
// shQuote puts s in single quotes for sh and csh alike, closing and
// reopening them around each quote inside.
func shQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
+115
View File
@@ -0,0 +1,115 @@
package agent
import (
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// A terminal opened with a folder starts its shell there; a folder that is
// not there leaves it at home.
func TestStartPTYDir(t *testing.T) {
dir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
sub := filepath.Join(dir, "sub dir")
if err := os.Mkdir(sub, 0o755); err != nil {
t.Fatal(err)
}
if got := ptyPwd(t, sub); got != sub {
t.Fatalf("pwd = %q, want %q", got, sub)
}
home, _ := filepath.EvalSymlinks(homeDir(""))
if got := ptyPwd(t, filepath.Join(dir, "missing")); got != home {
t.Fatalf("pwd for a missing folder = %q, want home %q", got, home)
}
}
// ptyPwd opens a terminal at dir and returns where its shell is.
func ptyPwd(t *testing.T, dir string) string {
t.Helper()
t.Setenv("SHELL", "/bin/sh")
p, err := StartPTY("", dir)
if err != nil {
t.Fatal(err)
}
defer p.Close()
if _, err := p.Write([]byte("echo \"@@$(pwd -P)@@\"\n")); err != nil {
t.Fatal(err)
}
var out strings.Builder
buf := make([]byte, 4096)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
n, err := p.Read(buf)
out.Write(buf[:n])
s := out.String()
// The echoed command line has "@@$(pwd" in it; the answer does not.
for _, part := range strings.Split(s, "@@") {
if strings.HasPrefix(part, "/") && strings.Contains(s, part+"@@") {
return part
}
}
if err != nil {
break
}
}
t.Fatalf("no pwd in %q", out.String())
return ""
}
// A terminal tells the folder its shell is in now, after a cd.
func TestPTYCwd(t *testing.T) {
dir, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
sub := filepath.Join(dir, "next one")
if err := os.Mkdir(sub, 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("SHELL", "/bin/sh")
p, err := StartPTY("", dir)
if err != nil {
t.Fatal(err)
}
defer p.Close()
go func() { _, _ = io.Copy(io.Discard, p) }()
if _, err := p.Write([]byte("cd 'next one'\n")); err != nil {
t.Fatal(err)
}
var got string
for i := 0; i < 50; i++ {
if got, err = p.Cwd(); err == nil && got == sub {
return
}
time.Sleep(100 * time.Millisecond)
}
t.Fatalf("Cwd = %q, %v; want %q", got, err, sub)
}
// The panel asks for a terminal's folder by its session.
func TestTermCwdTask(t *testing.T) {
dir, _ := filepath.EvalSymlinks(t.TempDir())
t.Setenv("SHELL", "/bin/sh")
p, err := StartPTY("", dir)
if err != nil {
t.Fatal(err)
}
defer p.Close()
termsBySID.Store("s1", p)
defer termsBySID.Delete("s1")
res, err := dispatchTask(proto.TaskEnvelope{Type: proto.TaskTermCwd, Args: []byte(`{"session":"s1"}`)})
if err != nil || res.(map[string]string)["path"] != dir {
t.Fatalf("term.cwd = %v, %v; want %q", res, err, dir)
}
if _, err := dispatchTask(proto.TaskEnvelope{Type: proto.TaskTermCwd, Args: []byte(`{"session":"nope"}`)}); err == nil {
t.Fatal("an unknown session has a folder")
}
}
+4
View File
@@ -41,6 +41,10 @@ func dispatchTask(env proto.TaskEnvelope) (any, error) {
return nil, addUser(a) return nil, addUser(a)
case proto.TaskDBTables, proto.TaskDBRows, proto.TaskDBSet, proto.TaskDBAdd, proto.TaskDBDel, proto.TaskDBSQL: case proto.TaskDBTables, proto.TaskDBRows, proto.TaskDBSet, proto.TaskDBAdd, proto.TaskDBDel, proto.TaskDBSQL:
return dbTask(env) return dbTask(env)
case proto.TaskTermCwd:
var a proto.TermCwdArgs
_ = json.Unmarshal(env.Args, &a)
return termCwd(a.Session)
} }
return dispatchFS(env) return dispatchFS(env)
} }
+54 -5
View File
@@ -1,15 +1,17 @@
// Package proto describes the wire messages shared by the server and the agent. // Package proto describes the wire messages shared by the server and the agent.
// //
// All agent<->server traffic is plain JSON over HTTP(S) long-polling so it // All agent<->server traffic is plain JSON, on a WebSocket the agent opens or,
// survives NAT and restrictive egress firewalls: the agent only ever makes // where that does not get through, over HTTP(S) long polls, so it survives
// outbound requests. // NAT and restrictive egress firewalls: the agent only ever connects out.
package proto package proto
import "strings"
import "encoding/json" import "encoding/json"
// Control poll message types (server -> agent, returned from /api/agent/poll). // Control poll message types (server -> agent, returned from /api/agent/poll).
const ( const (
// CtrlIdle is returned when the long-poll times out with nothing to do. // CtrlIdle is returned when the control poll times out with nothing to do.
CtrlIdle = "idle" CtrlIdle = "idle"
// CtrlOpenTerminal tells the agent to spawn a PTY for the session id. // CtrlOpenTerminal tells the agent to spawn a PTY for the session id.
CtrlOpenTerminal = "open_terminal" CtrlOpenTerminal = "open_terminal"
@@ -33,6 +35,9 @@ type SSHTarget struct {
Password string `json:"password,omitempty"` Password string `json:"password,omitempty"`
Key string `json:"key,omitempty"` Key string `json:"key,omitempty"`
Passphrase string `json:"passphrase,omitempty"` Passphrase string `json:"passphrase,omitempty"`
// Saved: a host the user saved on the server; it fills the rest there and
// never reaches an agent.
Saved int64 `json:"saved,omitempty"`
} }
// EnrollRequest is sent once by a freshly installed agent. // EnrollRequest is sent once by a freshly installed agent.
@@ -51,13 +56,15 @@ type EnrollResponse struct {
Token string `json:"token"` Token string `json:"token"`
} }
// ControlMessage is the body returned by the control long-poll. // ControlMessage is a command for the agent: on the WebSocket, or the body of
// the control long poll.
type ControlMessage struct { type ControlMessage struct {
Type string `json:"type"` Type string `json:"type"`
SessionID string `json:"session_id,omitempty"` SessionID string `json:"session_id,omitempty"`
Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial
TaskID string `json:"task_id,omitempty"` // CtrlTask TaskID string `json:"task_id,omitempty"` // CtrlTask
User string `json:"user,omitempty"` // CtrlOpenTerminal: OS user for the shell ("" = the agent's own) User string `json:"user,omitempty"` // CtrlOpenTerminal: OS user for the shell ("" = the agent's own)
Dir string `json:"dir,omitempty"` // CtrlOpenTerminal: the folder to start in ("" or missing = home)
SSH *SSHTarget `json:"ssh,omitempty"` // CtrlOpenSSH SSH *SSHTarget `json:"ssh,omitempty"` // CtrlOpenSSH
} }
@@ -167,6 +174,12 @@ const (
TaskDBAdd = "db.add" // add a row TaskDBAdd = "db.add" // add a row
TaskDBDel = "db.del" // delete rows by their keys TaskDBDel = "db.del" // delete rows by their keys
TaskDBSQL = "db.sql" // run a statement typed in the panel TaskDBSQL = "db.sql" // run a statement typed in the panel
TaskTermCwd = "term.cwd" // the folder a terminal is in now (TermCwdArgs); result {path}
TaskSSHFSOpen = "sshfs.open" // connect to an SSH host for a file pane; result {id, home}
TaskSSHFSClose = "sshfs.close" // drop that connection
TaskSSHFSOp = "sshfs.op" // a file operation on the host: list, read, write, mkdir, delete, deltree, move, copy, stat, chmod
TaskSSHFSSend = "sshfs.send" // a file of the host to the browser in pieces (FSXferArgs with Conn)
TaskSSHFSPut = "sshfs.put" // a file from the browser to the host in pieces
TaskFSShipOut = "fs.ship.out" // pack a file or folder and send it to another machine TaskFSShipOut = "fs.ship.out" // pack a file or folder and send it to another machine
TaskFSShipIn = "fs.ship.in" // unpack what another machine sends TaskFSShipIn = "fs.ship.in" // unpack what another machine sends
) )
@@ -186,6 +199,18 @@ type FSXferArgs struct {
Xfer string `json:"xfer"` Xfer string `json:"xfer"`
Size int64 `json:"size"` Size int64 `json:"size"`
Chunk int `json:"chunk"` Chunk int `json:"chunk"`
// Conn is the SSH file connection the file is on (sshfs.send, sshfs.put).
Conn string `json:"conn,omitempty"`
}
// SSHFSArgs are the arguments of the sshfs.* tasks: the connection (Conn), or
// where to connect (SSH, for open); for an operation its name (Op) and its
// arguments (Args) in the shapes of the fs.* tasks.
type SSHFSArgs struct {
Conn string `json:"conn,omitempty"`
SSH *SSHTarget `json:"ssh,omitempty"`
Op string `json:"op,omitempty"`
Args json.RawMessage `json:"args,omitempty"`
} }
// TaskEnvelope is what the agent fetches for a task id. // TaskEnvelope is what the agent fetches for a task id.
@@ -270,6 +295,8 @@ type FSEntry struct {
Size int64 `json:"size"` Size int64 `json:"size"`
Mode string `json:"mode"` Mode string `json:"mode"`
MTime int64 `json:"mtime"` MTime int64 `json:"mtime"`
// Link: a symbolic link (IsDir then says where it leads: a folder).
Link bool `json:"link,omitempty"`
} }
// FSListResult is returned for TaskFSList. // FSListResult is returned for TaskFSList.
@@ -349,6 +376,23 @@ type FSShipArgs struct {
Chunk int `json:"chunk,omitempty"` Chunk int `json:"chunk,omitempty"`
Job string `json:"job,omitempty"` Job string `json:"job,omitempty"`
SSH *SSHTarget `json:"ssh,omitempty"` SSH *SSHTarget `json:"ssh,omitempty"`
// Conn: the files of this side are on the SSH host of this agent's file
// connection (an SSH file pane), not on the machine itself.
Conn string `json:"conn,omitempty"`
// More: other things of Src's folder sent in the same stream under their
// own names (several files dragged at once). Agents before 0.2.0 do not
// know it; the server sends to them one at a time.
More []string `json:"more,omitempty"`
}
// ShipNames is what lands at the other end: Name, then More by their names.
func (a FSShipArgs) ShipNames() []string {
n := []string{a.Name}
for _, p := range a.More {
p = strings.TrimRight(p, "/")
n = append(n, p[strings.LastIndex(p, "/")+1:])
}
return n
} }
// ShipProgress is what a sending machine reports: bytes of files sent out of // ShipProgress is what a sending machine reports: bytes of files sent out of
@@ -359,3 +403,8 @@ type ShipProgress struct {
Files int `json:"files"` Files int `json:"files"`
Note string `json:"note,omitempty"` // what is going on: connecting, signing in Note string `json:"note,omitempty"` // what is going on: connecting, signing in
} }
// TermCwdArgs names the terminal session whose folder the panel asks for.
type TermCwdArgs struct {
Session string `json:"session"`
}
File diff suppressed because it is too large. Load diff
+435
View File
@@ -0,0 +1,435 @@
package server
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
)
func collect(t *testing.T, cfg aiConfig) (string, error) {
t.Helper()
var b strings.Builder
err := aiStream(context.Background(), cfg, "sys", []aiMsg{{Role: "user", Content: "раз"}, {Role: "assistant", Content: "два"}, {Role: "user", Content: "три"}}, func(s string) { b.WriteString(s) })
return b.String(), err
}
// Gradio (the Qwen space behind the proxy): history in, the answer streamed
// as diffs of the chatbot, the key in X-Agent-Key.
func TestAIGradio(t *testing.T) {
var joined map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Agent-Key") != "k" {
http.Error(w, "no key", 403)
return
}
switch r.URL.Path {
case "/config":
fmt.Fprint(w, `{"dependencies":[{"id":3,"api_name":"other"},{"id":0,"api_name":"chat_predict","targets":[[21,"click"]]}]}`)
case "/gradio_api/queue/join":
_ = json.NewDecoder(r.Body).Decode(&joined)
fmt.Fprint(w, `{"event_id":"e"}`)
case "/gradio_api/queue/data":
w.Header().Set("Content-Type", "text/event-stream")
for _, m := range []string{
`{"msg":"process_starts"}`,
`{"msg":"process_generating","output":{"data":[null,null,null,null,[{"role":"user","content":"три"}],{},{}]}}`,
`{"msg":"process_generating","output":{"data":[[],[],[],[],[["add",[3],{"role":"assistant","content":"При"}]],[],[]]}}`,
`{"msg":"process_generating","output":{"data":[[],[],[],[],[["append",[3,"content"],"вет"]],[],[]]}}`,
`{"msg":"process_completed","success":true,"output":{"data":[{},{},{},{},[{"role":"user","content":"три"},{"role":"assistant","content":"Привет!"}],{},{}]}}`,
} {
fmt.Fprintf(w, "data: %s\n\n", m)
}
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
got, err := collect(t, aiConfig{Type: "gradio", URL: srv.URL, Key: "k"})
if err != nil || got != "Привет!" {
t.Fatalf("got %q, %v", got, err)
}
d := joined["data"].([]any)
if joined["fn_index"].(float64) != 0 || d[0] != "три" || d[5] != "sys" || len(d[4].([]any)) != 2 {
t.Fatalf("join: %v", joined)
}
}
func TestAIGradioError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/config":
fmt.Fprint(w, `{"dependencies":[{"id":0,"api_name":"chat_predict"}]}`)
case "/gradio_api/queue/join":
fmt.Fprint(w, `{"event_id":"e"}`)
default:
fmt.Fprint(w, "data: {\"msg\":\"process_completed\",\"success\":false,\"output\":{\"error\":\"GPU занят\"}}\n\n")
}
}))
defer srv.Close()
if _, err := collect(t, aiConfig{Type: "gradio", URL: srv.URL}); err == nil || !strings.Contains(err.Error(), "GPU занят") {
t.Fatalf("err: %v", err)
}
}
func TestAIOpenAI(t *testing.T) {
var body map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" || r.Header.Get("Authorization") != "Bearer k" {
http.Error(w, "bad", 400)
return
}
_ = json.NewDecoder(r.Body).Decode(&body)
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"При\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"вет\"}}]}\n\ndata: [DONE]\n\n")
}))
defer srv.Close()
got, err := collect(t, aiConfig{Type: "openai", URL: srv.URL + "/v1", Key: "k", Model: "m"})
if err != nil || got != "Привет" {
t.Fatalf("got %q, %v", got, err)
}
msgs := body["messages"].([]any)
if body["model"] != "m" || body["stream"] != true || len(msgs) != 4 || msgs[0].(map[string]any)["role"] != "system" {
t.Fatalf("body: %v", body)
}
}
func TestAIOpenAIAttachments(t *testing.T) {
var body map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&body)
fmt.Fprint(w, "data: [DONE]\n\n")
}))
defer srv.Close()
msg := aiMsg{Role: "user", Content: "посмотри", Attachments: []aiAttachment{
{Name: "image.png", MIME: "image/png", Data: "AQID"},
{Name: "voice.wav", MIME: "audio/wav", Data: "BAUG"},
{Name: "clip.mp4", MIME: "video/mp4", Data: "BwgJ"},
}}
if err := aiOpenAI(context.Background(), aiConfig{Type: "openai", URL: srv.URL, Model: "m"}, "sys", []aiMsg{msg}, func(string) {}); err != nil {
t.Fatal(err)
}
messages := body["messages"].([]any)
content := messages[1].(map[string]any)["content"].([]any)
if len(content) != 4 || content[3].(map[string]any)["text"] != "посмотри" {
t.Fatalf("content: %#v", content)
}
if got := content[0].(map[string]any)["image_url"].(map[string]any)["url"].(string); !strings.HasPrefix(got, "data:image/png;base64,") {
t.Fatalf("image: %q", got)
}
if got := content[1].(map[string]any)["input_audio"].(map[string]any)["format"]; got != "wav" {
t.Fatalf("audio: %#v", content[1])
}
video := content[2].(map[string]any)
if video["type"] != "video_url" || video["fps"] != float64(1) || !strings.HasPrefix(video["video_url"].(map[string]any)["url"].(string), "data:video/mp4;base64,") {
t.Fatalf("video: %#v", video)
}
}
func TestAIAnthropic(t *testing.T) {
var body map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/messages" || r.Header.Get("x-api-key") != "k" || r.Header.Get("anthropic-version") == "" {
http.Error(w, "bad", 400)
return
}
_ = json.NewDecoder(r.Body).Decode(&body)
fmt.Fprint(w, "event: message_start\ndata: {\"type\":\"message_start\"}\n\n"+
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"При\"}}\n\n"+
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"вет\"}}\n\n"+
"event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")
}))
defer srv.Close()
got, err := collect(t, aiConfig{Type: "anthropic", URL: srv.URL, Key: "k", Model: "m"})
if err != nil || got != "Привет" {
t.Fatalf("got %q, %v", got, err)
}
if body["system"] != "sys" || len(body["messages"].([]any)) != 3 {
t.Fatalf("body: %v", body)
}
}
// The chat is on by default, and an explicit user preference can still turn it off.
func TestAIChatDefaultOnAndCanBeDisabled(t *testing.T) {
s, c := newTestServer(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"}}]}\n\ndata: [DONE]\n\n")
}))
defer srv.Close()
_ = s.db.SetConfig(cfgAIType, "openai")
_ = s.db.SetConfig(cfgAIURL, srv.URL)
post := func() *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/api/panel/ai/chat", strings.NewReader(`{"message":"hi","context":"x"}`))
req.AddCookie(c)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
return w
}
a, _ := s.db.AdminByUsername("adm")
state := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/panel/ai", nil)
req.AddCookie(c)
s.handleAIState(state, req)
var got struct {
On bool `json:"on"`
}
if err := json.Unmarshal(state.Body.Bytes(), &got); err != nil || !got.On {
t.Fatalf("default state: %s (%v)", state.Body.String(), err)
}
w := post()
b, _ := io.ReadAll(w.Body)
if w.Code != 200 || string(b) != "ok" {
t.Fatalf("on: %d %q", w.Code, b)
}
// The talk is kept: the question and the answer.
cs := s.aiChats(a.ID)
// Named by the question at once (the model may rename it a moment later).
if len(cs) != 1 || cs[0].Title == "" || w.Header().Get("X-Chat-Id") != strconv.FormatInt(cs[0].ID, 10) {
t.Fatalf("chats: %+v %q", cs, w.Header().Get("X-Chat-Id"))
}
if h := s.aiHistoryOf(a.ID, cs[0].ID, 10); len(h) != 2 || h[0].Content != "hi" || h[1].Content != "ok" {
t.Fatalf("history: %+v", h)
}
off := httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/panel/ai/toggle", strings.NewReader(`{"on":false}`))
req.AddCookie(c)
s.handleAIToggle(off, req)
state = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/api/panel/ai", nil)
req.AddCookie(c)
s.handleAIState(state, req)
var after struct {
On bool `json:"on"`
}
if err := json.Unmarshal(state.Body.Bytes(), &after); err != nil || after.On {
t.Fatalf("disabled state: %s (%v)", state.Body.String(), err)
}
if w := post(); w.Code != http.StatusForbidden {
t.Fatalf("explicit off: %d %s", w.Code, w.Body.String())
}
}
func TestAIChatKeepsAttachmentTextOnly(t *testing.T) {
s, c := newTestServer(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var in struct {
Messages []map[string]any `json:"messages"`
}
_ = json.NewDecoder(r.Body).Decode(&in)
content := in.Messages[len(in.Messages)-1]["content"].([]any)
if content[0].(map[string]any)["type"] != "image_url" {
http.Error(w, "image missing", http.StatusBadRequest)
return
}
fmt.Fprint(w, "data: [DONE]\n\n")
}))
defer srv.Close()
_ = s.db.SetConfig(cfgAIType, "openai")
_ = s.db.SetConfig(cfgAIURL, srv.URL)
a, _ := s.db.AdminByUsername("adm")
_ = s.db.SetConfig(aiUserKey(a.ID), "1")
chatID := s.aiNewChat(a.ID)
s.aiAppendTo(a.ID, chatID, "user", "предыдущий вопрос")
s.aiAppendTo(a.ID, chatID, "assistant", "ответ")
body := fmt.Sprintf(`{"message":"посмотри","chat":%d,"attachments":[{"name":"x.png","mime":"image/png","data":"AQID"}]}`, chatID)
req := httptest.NewRequest(http.MethodPost, "/api/panel/ai/chat", strings.NewReader(body))
req.AddCookie(c)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("response: %d %s", w.Code, w.Body.String())
}
chat, _ := strconv.ParseInt(w.Header().Get("X-Chat-Id"), 10, 64)
history := s.aiHistoryOf(a.ID, chat, 10)
if chat != chatID || len(history) != 3 || !strings.Contains(history[2].Content, "x.png") || strings.Contains(history[2].Content, "AQID") {
t.Fatalf("history contains wrong attachment data: %+v", history)
}
}
func TestVersionAtLeast(t *testing.T) {
for _, c := range []struct {
v string
want bool
}{{"0.2.0", true}, {"0.10.1", true}, {"0.1.9", false}, {"", false}, {"dev", false}, {"1.0", true}} {
if got := versionAtLeast(c.v, "0.2.0"); got != c.want {
t.Errorf("%q: %v", c.v, got)
}
}
}
// The window the model sees: newest first within the budget, old results
// and answers cut down, results as the user's words, same roles merged.
func TestAIWindow(t *testing.T) {
big := strings.Repeat("x", 5000)
rows := []aiMsg{
{Role: "user", Content: "старое"},
{Role: "assistant", Content: big},
{Role: "result", Content: big},
{Role: "user", Content: "дальше"},
{Role: "assistant", Content: "ок"},
{Role: "result", Content: "вывод"},
}
w := aiWindow(rows, 100000)
if len(w) != 5 || w[0].Role != "user" || w[len(w)-1].Role != "user" {
t.Fatalf("roles: %+v", w)
}
if len(w[1].Content) > 3200 || len(w[2].Content) > 1700+len("дальше")+40 {
t.Fatalf("old not cut: %d %d", len(w[1].Content), len(w[2].Content))
}
if !strings.Contains(w[4].Content, "вывод") || !strings.HasPrefix(w[4].Content, "Результат") {
t.Fatalf("last result: %q", w[4].Content)
}
// A small budget keeps the newest, starting with the user.
w = aiWindow(rows, 60)
if len(w) == 0 || w[0].Role != "user" || !strings.Contains(w[len(w)-1].Content, "вывод") {
t.Fatalf("small: %+v", w)
}
}
func TestStripThink(t *testing.T) {
if got := stripThink("::думаю::\nхм\n::ответ::\n391"); got != "391" {
t.Fatalf("%q", got)
}
if got := stripThink("просто"); got != "просто" {
t.Fatalf("%q", got)
}
}
// Several talks: each its own messages; a new one is named by its first
// question; deleting one leaves the others.
func TestAIChats(t *testing.T) {
s, _ := newTestServer(t)
a, _ := s.db.AdminByUsername("adm")
one := s.aiNewChat(a.ID)
two := s.aiNewChat(a.ID)
s.aiAppendTo(a.ID, one, "user", "первый вопрос про nginx и его настройки, длинный-длинный")
s.aiAppendTo(a.ID, two, "user", "второй")
if h := s.aiHistoryOf(a.ID, one, 10); len(h) != 1 || h[0].Content[:5] != "перв"[:5] {
t.Fatalf("one: %+v", h)
}
list := s.aiChats(a.ID)
if len(list) != 2 || list[0].ID != two || !strings.HasPrefix(list[1].Title, "первый вопрос") || len([]rune(list[1].Title)) > 41 {
t.Fatalf("list: %+v", list)
}
s.aiDeleteChat(a.ID, one)
if list = s.aiChats(a.ID); len(list) != 1 || len(s.aiHistoryOf(a.ID, one, 10)) != 0 {
t.Fatalf("after delete: %+v", list)
}
// Another user sees none of them and cannot write into them.
if len(s.aiChats(a.ID+1)) != 0 {
t.Fatal("another user sees the talks")
}
s.aiAppendTo(a.ID+1, two, "user", "чужое")
if len(s.aiHistoryOf(a.ID, two, 10)) != 1 {
t.Fatal("another user wrote into the talk")
}
}
// The thinking is kept with the answer (and how long it took), so it shows
// again later; the model never gets it back.
func TestAIThinkingKept(t *testing.T) {
s, c := newTestServer(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fl := w.(http.Flusher)
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"::думаю::\\nхм\\n\"}}]}\n\n")
fl.Flush()
time.Sleep(1100 * time.Millisecond)
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"::ответ::\\n391\"}}]}\n\ndata: [DONE]\n\n")
}))
defer srv.Close()
_ = s.db.SetConfig(cfgAIType, "openai")
_ = s.db.SetConfig(cfgAIURL, srv.URL)
a, _ := s.db.AdminByUsername("adm")
_ = s.db.SetConfig(aiUserKey(a.ID), "1")
req := httptest.NewRequest(http.MethodPost, "/api/panel/ai/chat", strings.NewReader(`{"message":"17*23","think":true}`))
req.AddCookie(c)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
chat, _ := strconv.ParseInt(w.Header().Get("X-Chat-Id"), 10, 64)
h := s.aiHistoryOf(a.ID, chat, 10)
if len(h) != 2 || !strings.Contains(h[1].Content, "::думаю::") || h[1].ThinkMS < 1000 {
t.Fatalf("kept: %+v", h)
}
if win := aiWindow(h, 10000); strings.Contains(win[len(win)-1].Content, "хм") {
t.Fatalf("the model gets the thinking back: %+v", win)
}
}
// A new chat is named by the model: its first question in two or three
// words, asked aside so the answer does not wait.
func TestAIChatTitle(t *testing.T) {
s, c := newTestServer(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var in struct {
Messages []aiMsg `json:"messages"`
}
_ = json.NewDecoder(r.Body).Decode(&in)
reply := "ok"
if strings.Contains(in.Messages[0].Content, "название") {
reply = "«Выбор поисковика.»"
}
b, _ := json.Marshal(reply)
fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":%s}}]}\n\ndata: [DONE]\n\n", b)
}))
defer srv.Close()
_ = s.db.SetConfig(cfgAIType, "openai")
_ = s.db.SetConfig(cfgAIURL, srv.URL)
a, _ := s.db.AdminByUsername("adm")
_ = s.db.SetConfig(aiUserKey(a.ID), "1")
req := httptest.NewRequest(http.MethodPost, "/api/panel/ai/chat", strings.NewReader(`{"message":"какой поисковик ты используешь сейчас"}`))
req.AddCookie(c)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
for i := 0; i < 50; i++ {
if cs := s.aiChats(a.ID); len(cs) == 1 && cs[0].Title == "Выбор поисковика" {
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("title: %+v", s.aiChats(a.ID))
}
// Qwen Omni's files go where its inputs are (audio before image there), and
// a video as Gradio 5 takes one: {video, subtitles}.
func TestAIGradioMediaSlots(t *testing.T) {
var joined map[string]any
n := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/config":
fmt.Fprint(w, `{"components":[{"id":20,"type":"textbox"},{"id":16,"type":"audio"},{"id":17,"type":"image"},{"id":18,"type":"video"},`+
`{"id":13,"type":"chatbot"},{"id":7,"type":"textbox"},{"id":8,"type":"slider"},{"id":9,"type":"slider"},{"id":10,"type":"slider"}],`+
`"dependencies":[{"id":0,"api_name":"chat_predict","targets":[[21,"click"]],"inputs":[20,16,17,18,13,7,8,9,10]}]}`)
case "/gradio_api/upload":
n++
fmt.Fprintf(w, `["/tmp/f%d"]`, n)
case "/gradio_api/queue/join":
_ = json.NewDecoder(r.Body).Decode(&joined)
fmt.Fprint(w, `{"event_id":"e"}`)
case "/gradio_api/queue/data":
fmt.Fprint(w, "data: {\"msg\":\"process_completed\",\"success\":true,\"output\":{\"data\":[{},{},{},{},[{\"role\":\"assistant\",\"content\":\"ok\"}],{},{}]}}\n\n")
}
}))
defer srv.Close()
enc := base64.StdEncoding.EncodeToString([]byte("x"))
msgs := []aiMsg{{Role: "user", Content: "что тут", Attachments: []aiAttachment{
{Name: "a.png", MIME: "image/png", Data: enc},
{Name: "b.mp3", MIME: "audio/mpeg", Data: enc},
{Name: "c.mp4", MIME: "video/mp4", Data: enc},
}}}
if err := aiStream(context.Background(), aiConfig{Type: "gradio", URL: srv.URL}, "sys", msgs, func(string) {}); err != nil {
t.Fatal(err)
}
d := joined["data"].([]any)
path := func(v any) string { m, _ := v.(map[string]any); p, _ := m["path"].(string); return p }
video, _ := d[3].(map[string]any)
if path(d[1]) != "/tmp/f2" || path(d[2]) != "/tmp/f1" || path(video["video"]) != "/tmp/f3" || video["subtitles"] != nil || d[5] != "sys" {
t.Fatalf("data: %v", d)
}
}
+110
View File
@@ -0,0 +1,110 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
// The model set before there could be several is the first of the list.
func TestAIModelsFromLegacy(t *testing.T) {
s, _ := newTestServer(t)
_ = s.db.SetConfig(cfgAIType, "openai")
_ = s.db.SetConfig(cfgAIURL, "https://api.example/v1/")
_ = s.db.SetConfig(cfgAIModel, "gpt-x")
_ = s.db.SetConfig(cfgAIKey, "k1")
ms := s.aiModels()
if len(ms) != 1 || ms[0].Type != "openai" || ms[0].URL != "https://api.example/v1" || ms[0].Model != "gpt-x" || ms[0].Key != "k1" || ms[0].ID == "" {
t.Fatalf("models: %+v", ms)
}
if got := s.aiModel("nope"); got.ID != ms[0].ID {
t.Fatalf("an unknown id is not the first model: %+v", got)
}
}
// postAIModels sends the settings form with the given models.
func postAIModels(t *testing.T, s *Server, c *http.Cookie, models [][7]string) {
t.Helper()
f := url.Values{"csrf": {"tok"}}
for _, m := range models {
f.Add("m_id", m[0])
f.Add("m_name", m[1])
f.Add("m_type", m[2])
f.Add("m_url", m[3])
f.Add("m_model", m[4])
f.Add("m_key", m[5])
f.Add("m_short", m[6])
}
req := httptest.NewRequest(http.MethodPost, "/settings/ai", strings.NewReader(f.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(c)
req.AddCookie(&http.Cookie{Name: csrfCookie, Value: "tok"})
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
if w.Code != http.StatusSeeOther {
t.Fatalf("save: %d %s", w.Code, w.Body)
}
}
// Настройки keep several models with their names; the chat learns the
// names and ids only, never a key.
func TestAIModelsSaveAndList(t *testing.T) {
s, c := newTestServer(t)
postAIModels(t, s, c, [][7]string{
{"", "Qwen", "gradio", "", "", "", ""},
{"", "Claude", "anthropic", "https://api.anthropic.com", "claude-x", "secret-key", "Сонет 4.5 длинно"},
{"", "", "", "", "", "", ""}, // an empty card is dropped
})
ms := s.aiModels()
if len(ms) != 2 || ms[0].Name != "Qwen" || ms[1].Name != "Claude" || ms[1].Key != "secret-key" || ms[0].ID == "" || ms[0].ID == ms[1].ID || ms[1].Short != "Сонет 4.5 дл" {
t.Fatalf("models: %+v", ms)
}
body := get(t, s, c, "/api/panel/ai").Body.String()
if strings.Contains(body, "secret-key") || !strings.Contains(body, `"name":"Claude"`) || !strings.Contains(body, `"short":"Сонет 4.5 дл"`) || !strings.Contains(body, ms[1].ID) {
t.Fatalf("state: %s", body)
}
// Saved again with its id, a model keeps it.
postAIModels(t, s, c, [][7]string{{ms[1].ID, "Claude 2", "anthropic", "https://api.anthropic.com", "claude-x", "secret-key", ""}})
if got := s.aiModels(); len(got) != 1 || got[0].ID != ms[1].ID || got[0].Name != "Claude 2" {
t.Fatalf("after resave: %+v", got)
}
if !strings.Contains(get(t, s, c, "/settings?frag=1").Body.String(), `value="Claude 2"`) {
t.Fatal("the settings page does not show the model's name")
}
}
// The chat asks the model it names.
func TestAIChatUsesChosenModel(t *testing.T) {
s, c := newTestServer(t)
srv := func(word string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":\"%s\"}}]}\n\ndata: [DONE]\n\n", word)
}))
}
one, two := srv("first"), srv("second")
defer one.Close()
defer two.Close()
postAIModels(t, s, c, [][7]string{
{"", "One", "openai", one.URL, "m", "", ""},
{"", "Two", "openai", two.URL, "m", "", ""},
})
ms := s.aiModels()
ask := func(model string) string {
b, _ := json.Marshal(map[string]any{"message": "hi", "context": "x", "model": model})
req := httptest.NewRequest(http.MethodPost, "/api/panel/ai/chat", strings.NewReader(string(b)))
req.AddCookie(c)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
return w.Body.String()
}
if got := ask(ms[1].ID); !strings.Contains(got, "second") {
t.Fatalf("model Two: %q", got)
}
if got := ask(""); !strings.Contains(got, "first") {
t.Fatalf("no model named - the first: %q", got)
}
}
+144
View File
@@ -0,0 +1,144 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// openAIChunk is one streamed delta of an OpenAI-compatible server.
func openAIChunk(field, text string) string {
b, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"delta": map[string]string{field: text}}}})
return "data: " + string(b) + "\n\n"
}
// A model that thinks by itself (llama.cpp, vLLM: reasoning_content) has its
// thinking shown as the chat's own: between ::думаю:: and ::ответ::.
func TestAIOpenAINativeReasoning(t *testing.T) {
var body map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&body)
fmt.Fprint(w, openAIChunk("reasoning_content", "17*23")+openAIChunk("reasoning_content", " = 391")+
openAIChunk("content", "391")+"data: [DONE]\n\n")
}))
defer srv.Close()
got, err := collect(t, aiConfig{Type: "openai", URL: srv.URL, Model: "m", think: "on"})
if err != nil || got != "::думаю::\n17*23 = 391\n::ответ::\n391" {
t.Fatalf("got %q, %v", got, err)
}
kw, _ := body["chat_template_kwargs"].(map[string]any)
if kw["enable_thinking"] != true {
t.Fatalf("thinking not asked for: %v", body)
}
}
// A server that does not know the thinking switch (400 on it) is asked again
// without it.
func TestAIOpenAIRetriesWithoutThinkSwitch(t *testing.T) {
var mu sync.Mutex
var calls []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var b map[string]any
_ = json.NewDecoder(r.Body).Decode(&b)
mu.Lock()
calls = append(calls, b)
mu.Unlock()
if _, ok := b["chat_template_kwargs"]; ok {
http.Error(w, `{"error":{"message":"Unrecognized request argument supplied: chat_template_kwargs"}}`, 400)
return
}
fmt.Fprint(w, openAIChunk("content", "ok")+"data: [DONE]\n\n")
}))
defer srv.Close()
got, err := collect(t, aiConfig{Type: "openai", URL: srv.URL, Model: "m", think: "off"})
if err != nil || got != "ok" || len(calls) != 2 {
t.Fatalf("got %q, %v, %d calls", got, err, len(calls))
}
}
// Anthropic: «Думать» turns on extended thinking; its thinking_delta is the
// thinking shown.
func TestAIAnthropicThinking(t *testing.T) {
var body map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&body)
fmt.Fprint(w, "data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"хм\"}}\n\n"+
"data: {\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"Да\"}}\n\n"+
"data: {\"type\":\"message_stop\"}\n\n")
}))
defer srv.Close()
got, err := collect(t, aiConfig{Type: "anthropic", URL: srv.URL, Key: "k", Model: "m", think: "on"})
if err != nil || got != "::думаю::\nхм\n::ответ::\nДа" {
t.Fatalf("got %q, %v", got, err)
}
th, _ := body["thinking"].(map[string]any)
if th["type"] != "enabled" || body["max_tokens"].(float64) <= th["budget_tokens"].(float64) {
t.Fatalf("thinking not asked for: %v", body)
}
}
// askThink sends one «Думать» question to the chat and gives the answer and
// the system prompts the model got, in order.
func askThink(t *testing.T, reply func(system string, body map[string]any) string) (string, []string) {
t.Helper()
s, c := newTestServer(t)
var mu sync.Mutex
var systems []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var b map[string]any
_ = json.NewDecoder(r.Body).Decode(&b)
sys := b["messages"].([]any)[0].(map[string]any)["content"].(string)
if strings.Contains(fmt.Sprint(b["messages"]), "название чата") || strings.Contains(sys, "название чата") {
fmt.Fprint(w, openAIChunk("content", "Чат")+"data: [DONE]\n\n")
return
}
mu.Lock()
systems = append(systems, sys)
mu.Unlock()
fmt.Fprint(w, reply(sys, b)+"data: [DONE]\n\n")
}))
t.Cleanup(srv.Close)
postAIModels(t, s, c, [][7]string{{"", "M", "openai", srv.URL, "m", "", ""}})
req := httptest.NewRequest(http.MethodPost, "/api/panel/ai/chat", strings.NewReader(`{"message":"сколько 17*23","context":"x","think":true}`))
req.AddCookie(c)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
mu.Lock()
defer mu.Unlock()
return w.Body.String(), append([]string(nil), systems...)
}
// A model with thinking of its own thinks once, by itself: no "think in
// words" prompt on top.
func TestAIChatThinkNative(t *testing.T) {
got, systems := askThink(t, func(sys string, b map[string]any) string {
return openAIChunk("reasoning_content", "считаю") + openAIChunk("content", "391")
})
if len(systems) != 1 || strings.Contains(systems[0], "Режим размышления") {
t.Fatalf("systems: %q", systems)
}
if !strings.Contains(got, "::думаю::\nсчитаю\n::ответ::\n391") {
t.Fatalf("answer: %q", got)
}
}
// A model without it is asked again with the prompt, its first answer
// dropped unseen.
func TestAIChatThinkPromptFallback(t *testing.T) {
got, systems := askThink(t, func(sys string, b map[string]any) string {
if strings.Contains(sys, "Режим размышления") {
return openAIChunk("content", "::думаю::\nсчитаю\n::ответ::\n391")
}
return openAIChunk("content", "сразу ответ")
})
if len(systems) != 2 || strings.Contains(systems[0], "Режим размышления") || !strings.Contains(systems[1], "Режим размышления") {
t.Fatalf("systems: %q", systems)
}
if strings.Contains(got, "сразу ответ") || !strings.Contains(got, "::ответ::\n391") {
t.Fatalf("answer: %q", got)
}
}
+27 -13
View File
@@ -11,7 +11,8 @@ import (
// DB wraps the SQLite connection and all queries used by the server. // DB wraps the SQLite connection and all queries used by the server.
type DB struct { type DB struct {
sql *sql.DB sql *dbx
path string // the file; the key of saved secrets sits beside it
} }
// OpenDB opens (creating if needed) the SQLite database at path and runs // OpenDB opens (creating if needed) the SQLite database at path and runs
@@ -30,7 +31,7 @@ func OpenDB(path string) (*DB, error) {
if err := sdb.Ping(); err != nil { if err := sdb.Ping(); err != nil {
return nil, err return nil, err
} }
db := &DB{sql: sdb} db := &DB{sql: &dbx{DB: sdb}, path: path}
if err := db.migrate(); err != nil { if err := db.migrate(); err != nil {
return nil, err return nil, err
} }
@@ -122,6 +123,23 @@ CREATE TABLE IF NOT EXISTS forward_rules (
`ALTER TABLE admins ADD COLUMN pin_hash TEXT NOT NULL DEFAULT ''`, `ALTER TABLE admins ADD COLUMN pin_hash TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE admins ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`, `ALTER TABLE admins ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`,
`ALTER TABLE admins ADD COLUMN pw_locked INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE admins ADD COLUMN pw_locked INTEGER NOT NULL DEFAULT 0`,
`CREATE TABLE IF NOT EXISTS ai_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
role TEXT NOT NULL, content TEXT NOT NULL, at INTEGER NOT NULL)`,
`CREATE INDEX IF NOT EXISTS ai_messages_admin ON ai_messages(admin_id, id)`,
`ALTER TABLE ai_messages ADD COLUMN chat_id INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE ai_messages ADD COLUMN think_ms INTEGER NOT NULL DEFAULT 0`,
`CREATE TABLE IF NOT EXISTS ai_chats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
title TEXT NOT NULL, created INTEGER NOT NULL, updated INTEGER NOT NULL)`,
`CREATE INDEX IF NOT EXISTS ai_messages_chat ON ai_messages(chat_id, id)`,
`CREATE TABLE IF NOT EXISTS ssh_hosts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
label TEXT NOT NULL, host TEXT NOT NULL, port INTEGER NOT NULL, login TEXT NOT NULL,
auth TEXT NOT NULL, secret TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS admin_agents ( `CREATE TABLE IF NOT EXISTS admin_agents (
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE, admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
agent_id TEXT NOT NULL, agent_id TEXT NOT NULL,
@@ -131,6 +149,10 @@ CREATE TABLE IF NOT EXISTS forward_rules (
return err return err
} }
} }
// ssh_hosts.user (a word PostgreSQL keeps for itself) became login.
if !db.sql.pg {
_, _ = db.sql.Exec(`ALTER TABLE ssh_hosts RENAME COLUMN user TO login`)
}
return nil return nil
} }
@@ -167,13 +189,9 @@ func (db *DB) CreateAdmin(username, passHash string) error {
// CreateUser adds a panel user with the role given and returns its id. // CreateUser adds a panel user with the role given and returns its id.
func (db *DB) CreateUser(username, passHash, role string) (int64, error) { func (db *DB) CreateUser(username, passHash, role string) (int64, error) {
res, err := db.sql.Exec( return db.sql.insert(
`INSERT INTO admins(username, pass_hash, created_at, role) VALUES(?, ?, ?, ?)`, `INSERT INTO admins(username, pass_hash, created_at, role) VALUES(?, ?, ?, ?)`,
username, passHash, time.Now().Unix(), role) username, passHash, time.Now().Unix(), role)
if err != nil {
return 0, err
}
return res.LastInsertId()
} }
// Roles of panel users: an admin sees and runs everything; a user only the // Roles of panel users: an admin sees and runs everything; a user only the
@@ -584,14 +602,10 @@ func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
if r.Proto == "" { if r.Proto == "" {
r.Proto = "http" r.Proto = "http"
} }
res, err := db.sql.Exec( return db.sql.insert(
`INSERT INTO forward_rules(agent_id, client_port, host_port, listen_addr, proto, no_ufw, label, created_at) `INSERT INTO forward_rules(agent_id, client_port, host_port, listen_addr, proto, no_ufw, label, created_at)
VALUES(?, ?, ?, ?, ?, ?, ?, ?)`, VALUES(?, ?, ?, ?, ?, ?, ?, ?)`,
r.AgentID, r.ClientPort, r.HostPort, r.ListenAddr, r.Proto, r.NoUfw, r.Label, time.Now().Unix()) r.AgentID, r.ClientPort, r.HostPort, r.ListenAddr, r.Proto, boolInt(r.NoUfw), r.Label, time.Now().Unix())
if err != nil {
return 0, err
}
return res.LastInsertId()
} }
func (db *DB) ListForwardRules() ([]ForwardRule, error) { func (db *DB) ListForwardRules() ([]ForwardRule, error) {
+251
View File
@@ -0,0 +1,251 @@
package server
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
// The panel keeps its data in SQLite by default, or in PostgreSQL when the
// admins choose so in Настройки. The choice lives in a small file beside the
// SQLite database (<db>.backend.json, readable by the server only) and
// takes effect on the next start; the first start on an empty PostgreSQL
// copies everything over from the SQLite file, which stays as it was.
type PGConf struct {
Host string `json:"host"`
Port int `json:"port"`
Name string `json:"name"`
User string `json:"user"`
Password string `json:"password"`
SSLMode string `json:"sslmode"`
}
type backendConf struct {
Type string `json:"type"` // sqlite or postgres
PG *PGConf `json:"pg,omitempty"`
}
func (c PGConf) DSN() string {
port := c.Port
if port == 0 {
port = 5432
}
ssl := c.SSLMode
if ssl == "" {
ssl = "prefer"
}
u := url.URL{Scheme: "postgres", User: url.UserPassword(c.User, c.Password), Host: c.Host + ":" + strconv.Itoa(port), Path: "/" + c.Name}
u.RawQuery = url.Values{"sslmode": {ssl}, "connect_timeout": {"10"}}.Encode()
return u.String()
}
func backendPath(dbPath string) string { return dbPath + ".backend.json" }
func readBackend(dbPath string) backendConf {
var c backendConf
if b, err := os.ReadFile(backendPath(dbPath)); err == nil {
_ = json.Unmarshal(b, &c)
}
if c.Type != "postgres" || c.PG == nil {
c = backendConf{Type: "sqlite"}
}
return c
}
func writeBackend(dbPath string, c backendConf) error {
b, _ := json.MarshalIndent(c, "", " ")
return os.WriteFile(backendPath(dbPath), b, 0o600)
}
// OpenStore opens the database the admins chose: SQLite at path, or the
// PostgreSQL named beside it.
func OpenStore(path string) (*DB, error) {
c := readBackend(path)
if c.Type == "postgres" {
return OpenPostgres(*c.PG, path)
}
return OpenDB(path)
}
// pingPG checks that PostgreSQL c answers.
func pingPG(c PGConf) error {
d, err := sql.Open("pgx", c.DSN())
if err != nil {
return err
}
defer d.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return d.PingContext(ctx)
}
// OpenPostgres opens PostgreSQL c, brings its tables up to date and, when it
// has no users yet, copies over the SQLite database at sqlitePath.
func OpenPostgres(c PGConf, sqlitePath string) (*DB, error) {
d, err := sql.Open("pgx", c.DSN())
if err != nil {
return nil, err
}
d.SetMaxOpenConns(16)
d.SetMaxIdleConns(4)
d.SetConnMaxIdleTime(5 * time.Minute)
if err := d.Ping(); err != nil {
return nil, fmt.Errorf("PostgreSQL: %w", err)
}
db := &DB{sql: &dbx{DB: d, pg: true}, path: sqlitePath}
if err := db.migrate(); err != nil {
return nil, fmt.Errorf("PostgreSQL: %w", err)
}
var n int
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM admins`).Scan(&n)
if _, err := os.Stat(sqlitePath); n == 0 && err == nil {
src, err := OpenDB(sqlitePath)
if err != nil {
return nil, err
}
defer src.Close()
if err := copyDB(src, db); err != nil {
return nil, fmt.Errorf("перенос из SQLite: %w", err)
}
log.Printf("db: data copied from %s to PostgreSQL", sqlitePath)
}
return db, nil
}
// copyTables are copied parents first, for the foreign keys.
var copyTables = []string{"config", "admins", "agents", "admin_sessions", "audit", "ufw_rules", "admin_keys",
"forward_rules", "admin_agents", "ssh_hosts", "ai_chats", "ai_messages"}
// copyDB copies every row of src into dst (an empty PostgreSQL), in one
// transaction, then moves the id counters past the copied ids.
func copyDB(src, dst *DB) error {
tx, err := dst.sql.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, t := range copyTables {
rows, err := src.sql.Query(`SELECT * FROM ` + t)
if err != nil {
return err
}
cols, _ := rows.Columns()
ph := make([]string, len(cols))
for i := range ph {
ph[i] = "$" + strconv.Itoa(i+1)
}
ins := `INSERT INTO ` + t + ` (` + strings.Join(cols, ", ") + `) VALUES (` + strings.Join(ph, ", ") + `)`
for rows.Next() {
vals := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
rows.Close()
return err
}
if _, err := tx.Exec(ins, vals...); err != nil {
rows.Close()
return fmt.Errorf("%s: %w", t, err)
}
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
// The id counters past the copied ids, where a table has one.
for _, t := range copyTables {
var seq sql.NullString
err := dst.sql.DB.QueryRow(`SELECT pg_get_serial_sequence($1, column_name) FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'id' AND column_default LIKE 'nextval%'`, t).Scan(&seq)
if err != nil || !seq.Valid {
continue
}
if _, err := dst.sql.DB.Exec(`SELECT setval($1, COALESCE((SELECT MAX(id) FROM `+t+`), 0) + 1, false)`, seq.String); err != nil {
return err
}
}
return nil
}
// Backend tells which database is in use and which is chosen for the next
// start.
func (db *DB) Backend() (now string, chosen backendConf) {
now = "sqlite"
if db.sql.pg {
now = "postgres"
}
return now, readBackend(db.path)
}
var errNoPGHost = errors.New("укажите хост, базу и пользователя PostgreSQL")
// POST /settings/db - the database for the next start; PostgreSQL is tried
// first and kept only if it answers.
func (s *Server) handleSetDB(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
back := func(q string) { http.Redirect(w, r, "/settings?"+q+"#db", http.StatusSeeOther) }
if r.FormValue("db_type") != "postgres" {
if err := writeBackend(s.db.path, backendConf{Type: "sqlite"}); err != nil {
back("err=" + url.QueryEscape(err.Error()))
return
}
back("db=saved")
return
}
old := readBackend(s.db.path).PG
c := PGConf{Host: strings.TrimSpace(r.FormValue("pg_host")), Name: strings.TrimSpace(r.FormValue("pg_name")),
User: strings.TrimSpace(r.FormValue("pg_user")), Password: r.FormValue("pg_password"), SSLMode: r.FormValue("pg_sslmode")}
c.Port, _ = strconv.Atoi(r.FormValue("pg_port"))
if c.Password == "" && old != nil {
c.Password = old.Password
}
if c.Host == "" || c.Name == "" || c.User == "" {
back("err=" + url.QueryEscape(errNoPGHost.Error()))
return
}
if err := pingPG(c); err != nil {
back("err=" + url.QueryEscape("PostgreSQL не отвечает: "+err.Error()))
return
}
if err := writeBackend(s.db.path, backendConf{Type: "postgres", PG: &c}); err != nil {
back("err=" + url.QueryEscape(err.Error()))
return
}
back("db=saved")
}
// POST /settings/db/restart - the server ends; its service (systemd,
// Restart=always) starts it again on the database chosen.
func (s *Server) handleRestart(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden)
return
}
log.Printf("restart asked from the panel")
http.Redirect(w, r, "/settings?restarted=1#db", http.StatusSeeOther)
go func() {
time.Sleep(700 * time.Millisecond)
os.Exit(0)
}()
}
+130
View File
@@ -0,0 +1,130 @@
package server
import (
"database/sql"
"encoding/json"
"os"
"testing"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// On a real PostgreSQL (LPRA_TEST_PG: {"host","port","name","user","password",
// "sslmode"} of an empty database): the first start copies the SQLite data
// over, and the panel's queries work there.
func TestPostgres(t *testing.T) {
raw := os.Getenv("LPRA_TEST_PG")
if raw == "" {
t.Skip("LPRA_TEST_PG not set")
}
var c PGConf
if err := json.Unmarshal([]byte(raw), &c); err != nil {
t.Fatal(err)
}
// SQLite with a user, a machine, a rule, a saved host and some talk.
s, _ := newTestServer(t)
a, _ := s.db.AdminByUsername("adm")
if _, err := s.db.CreateForwardRule(ForwardRule{AgentID: "x1", ClientPort: 80, HostPort: 18080, ListenAddr: "0.0.0.0", Proto: "tcp", NoUfw: true}); err != nil {
t.Fatal(err)
}
if _, err := s.saveSSHHost(a.ID, savedHost{Label: "web", Host: "h", Port: 22, User: "root", Auth: "password"}, sshSecret{Password: "pw"}); err != nil {
t.Fatal(err)
}
chat := s.aiNewChat(a.ID)
s.aiAppendTo(a.ID, chat, "user", "привет")
_ = s.db.SetUserAgents(a.ID, []string{"x1"})
pgReset(t, c)
pg, err := OpenPostgres(c, s.db.path)
if err != nil {
t.Fatal(err)
}
defer pg.Close()
// Clean up for the next run.
defer func() {
for i := len(copyTables) - 1; i >= 0; i-- {
_, _ = pg.sql.Exec(`DROP TABLE IF EXISTS ` + copyTables[i] + ` CASCADE`)
}
}()
p := &Server{db: pg}
if got, _ := pg.AdminByUsername("adm"); got == nil || got.ID != a.ID {
t.Fatalf("admin not copied: %+v", got)
}
if ag, _ := pg.GetAgent("x1"); ag == nil {
t.Fatal("agent not copied")
}
rules, _ := pg.ListForwardRules()
if len(rules) != 1 || !rules[0].NoUfw {
t.Fatalf("rules: %+v", rules)
}
tg := &proto.SSHTarget{Saved: p.listSSHHosts(a.ID)[0].ID}
if err := p.fillSaved(a.ID, tg); err != nil || tg.Password != "pw" {
t.Fatalf("saved host: %+v %v", tg, err)
}
if h := p.aiHistoryOf(a.ID, chat, 10); len(h) != 1 || h[0].Content != "привет" {
t.Fatalf("talk: %+v", h)
}
// New rows go on after the copied ids.
id, err := pg.CreateUser("bob", "x", RoleUser)
if err != nil || id <= a.ID {
t.Fatalf("new user id %d, %v", id, err)
}
if err := pg.SetConfig("k", "v1"); err != nil {
t.Fatal(err)
}
if err := pg.SetConfig("k", "v2"); err != nil {
t.Fatal(err)
}
if v, _ := pg.GetConfig("k"); v != "v2" {
t.Fatalf("config upsert: %q", v)
}
if err := pg.SetUserAgents(id, []string{"x1", "x1"}); err != nil {
t.Fatalf("grants: %v", err)
}
p.aiAppendTo(a.ID, chat, "assistant", "ответ")
if h := p.aiHistoryOf(a.ID, chat, 10); len(h) != 2 {
t.Fatalf("talk after: %+v", h)
}
}
// Chats on PostgreSQL: past the limit the oldest go.
func TestPostgresChats(t *testing.T) {
raw := os.Getenv("LPRA_TEST_PG")
if raw == "" {
t.Skip("LPRA_TEST_PG not set")
}
var c PGConf
_ = json.Unmarshal([]byte(raw), &c)
s, _ := newTestServer(t)
pgReset(t, c)
pg, err := OpenPostgres(c, s.db.path)
if err != nil {
t.Fatal(err)
}
defer pg.Close()
defer func() {
for i := len(copyTables) - 1; i >= 0; i-- {
_, _ = pg.sql.Exec(`DROP TABLE IF EXISTS ` + copyTables[i] + ` CASCADE`)
}
}()
p := &Server{db: pg}
a, _ := pg.AdminByUsername("adm")
for i := 0; i < aiChatsKeep+3; i++ {
p.aiAppendTo(a.ID, p.aiNewChat(a.ID), "user", "q")
}
if n := len(p.aiChats(a.ID)); n != aiChatsKeep {
t.Fatalf("chats kept: %d", n)
}
}
// pgReset empties the test database: the copy runs only into an empty one.
func pgReset(t *testing.T, c PGConf) {
d, err := sql.Open("pgx", c.DSN())
if err != nil {
t.Fatal(err)
}
defer d.Close()
for i := len(copyTables) - 1; i >= 0; i-- {
_, _ = d.Exec(`DROP TABLE IF EXISTS ` + copyTables[i] + ` CASCADE`)
}
}
+93
View File
@@ -0,0 +1,93 @@
package server
import (
"database/sql"
"strconv"
"strings"
)
// dbx is the database the server talks to: SQLite, or PostgreSQL. The
// queries are written once, for SQLite; on PostgreSQL each is rewritten
// (pgSQL) on its way.
type dbx struct {
*sql.DB
pg bool
}
func (d *dbx) q(query string) string {
if d.pg {
return pgSQL(query)
}
return query
}
func (d *dbx) Exec(query string, args ...any) (sql.Result, error) {
return d.DB.Exec(d.q(query), args...)
}
func (d *dbx) Query(query string, args ...any) (*sql.Rows, error) {
return d.DB.Query(d.q(query), args...)
}
func (d *dbx) QueryRow(query string, args ...any) *sql.Row {
return d.DB.QueryRow(d.q(query), args...)
}
func (d *dbx) Begin() (*txx, error) {
tx, err := d.DB.Begin()
return &txx{Tx: tx, pg: d.pg}, err
}
// insert runs an INSERT and gives the new row's id (LastInsertId on SQLite,
// RETURNING id on PostgreSQL, which has no LastInsertId).
func (d *dbx) insert(query string, args ...any) (int64, error) {
if d.pg {
var id int64
err := d.DB.QueryRow(pgSQL(query)+" RETURNING id", args...).Scan(&id)
return id, err
}
res, err := d.DB.Exec(query, args...)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
type txx struct {
*sql.Tx
pg bool
}
func (t *txx) Exec(query string, args ...any) (sql.Result, error) {
if t.pg {
query = pgSQL(query)
}
return t.Tx.Exec(query, args...)
}
// pgSQL rewrites a query for SQLite into PostgreSQL's: $n placeholders,
// INSERT OR IGNORE as ON CONFLICT DO NOTHING, and in table definitions
// BIGSERIAL keys, BIGINT numbers (times are Unix seconds), BYTEA blobs and
// ADD COLUMN IF NOT EXISTS.
func pgSQL(q string) string {
if strings.HasPrefix(q, "INSERT OR IGNORE ") {
q = "INSERT " + strings.TrimPrefix(q, "INSERT OR IGNORE ") + " ON CONFLICT DO NOTHING"
}
if strings.Contains(q, "CREATE TABLE") || strings.Contains(q, "ALTER TABLE") {
q = strings.ReplaceAll(q, "INTEGER PRIMARY KEY AUTOINCREMENT", "BIGSERIAL PRIMARY KEY")
q = strings.ReplaceAll(q, "INTEGER", "BIGINT")
q = strings.ReplaceAll(q, "BLOB", "BYTEA")
q = strings.ReplaceAll(q, "ADD COLUMN ", "ADD COLUMN IF NOT EXISTS ")
}
var b strings.Builder
n, quoted := 0, false
for _, r := range q {
switch {
case r == '\'':
quoted = !quoted
case r == '?' && !quoted:
n++
b.WriteString("$" + strconv.Itoa(n))
continue
}
b.WriteRune(r)
}
return b.String()
}
+19
View File
@@ -0,0 +1,19 @@
package server
import "testing"
// Queries written for SQLite, as PostgreSQL takes them.
func TestPGRebind(t *testing.T) {
for _, c := range [][2]string{
{`SELECT a FROM t WHERE x = ? AND y = ?`, `SELECT a FROM t WHERE x = $1 AND y = $2`},
{`INSERT OR IGNORE INTO admin_agents(admin_id, agent_id) VALUES(?, ?)`, `INSERT INTO admin_agents(admin_id, agent_id) VALUES($1, $2) ON CONFLICT DO NOTHING`},
{`SELECT '?' , ?`, `SELECT '?' , $1`},
{"CREATE TABLE IF NOT EXISTS x (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n n INTEGER NOT NULL,\n b BLOB NOT NULL)",
"CREATE TABLE IF NOT EXISTS x (\n id BIGSERIAL PRIMARY KEY,\n n BIGINT NOT NULL,\n b BYTEA NOT NULL)"},
{`ALTER TABLE agents ADD COLUMN run_user TEXT NOT NULL DEFAULT ''`, `ALTER TABLE agents ADD COLUMN IF NOT EXISTS run_user TEXT NOT NULL DEFAULT ''`},
} {
if got := pgSQL(c[0]); got != c[1] {
t.Errorf("\n got %s\nwant %s", got, c[1])
}
}
}
+199
View File
@@ -0,0 +1,199 @@
package server
import (
"context"
"encoding/json"
"errors"
"html"
"io"
"mime"
"net"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
)
// fetch: one page by its address, for the assistant with «Поиск» on - to
// read what a search found. The page comes back as the model wants it: its
// title and main text, headings and lists kept, scripts, styles and menus
// gone, cut to fetchMaxChars. Only http(s), and never the server's own
// network: loopback, private and link-local addresses are refused at the
// moment of connecting, redirects included.
const fetchMaxChars = 12000
// fetchAllowPrivate lets tests reach their own server.
var fetchAllowPrivate = false
type fetchedPage struct {
URL string `json:"url"`
Title string `json:"title"`
Type string `json:"type"`
Text string `json:"text"`
Truncated bool `json:"truncated"`
}
func privateIP(ip net.IP) bool {
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsUnspecified() || ip.IsMulticast() || ip.IsInterfaceLocalMulticast()
}
var fetchClient = &http.Client{
Timeout: 20 * time.Second,
Transport: &http.Transport{
Proxy: nil,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil {
return nil, err
}
for _, ip := range ips {
if privateIP(ip) && !fetchAllowPrivate {
continue
}
var d net.Dialer
return d.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
}
return nil, errors.New("адрес во внутренней сети - не открываю")
},
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 15 * time.Second,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("слишком много переадресаций")
}
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
return errors.New("переадресация не на http(s)")
}
return nil
},
}
func fetchPage(ctx context.Context, raw string) (fetchedPage, error) {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return fetchedPage{}, errors.New("нужен адрес http(s)://…")
}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36")
req.Header.Set("Accept", "text/html,text/plain,application/json;q=0.9,*/*;q=0.5")
req.Header.Set("Accept-Language", "ru,en;q=0.8")
resp, err := fetchClient.Do(req)
if err != nil {
return fetchedPage{}, err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return fetchedPage{}, errors.New("страница ответила " + resp.Status)
}
ct, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type"))
p := fetchedPage{URL: resp.Request.URL.String(), Type: ct}
b, _ := io.ReadAll(io.LimitReader(resp.Body, 3<<20))
switch {
case ct == "text/html" || ct == "application/xhtml+xml" || (ct == "" && strings.Contains(strings.ToLower(string(b[:min(len(b), 512)])), "<html")):
p.Title, p.Text = pageText(string(b))
case strings.HasPrefix(ct, "text/") || strings.Contains(ct, "json") || strings.Contains(ct, "xml") || strings.Contains(ct, "javascript"):
p.Text = strings.TrimSpace(string(b))
default:
p.Text = "(двоичный файл " + ct + ", " + strconv.Itoa(len(b)) + " байт - текста нет)"
}
if !utf8.ValidString(p.Text) {
p.Text = strings.ToValidUTF8(p.Text, "")
}
if r := []rune(p.Text); len(r) > fetchMaxChars {
p.Text, p.Truncated = string(r[:fetchMaxChars]), true
}
return p, nil
}
var (
reTitle = regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
reDrop = regexp.MustCompile(`(?is)<(script|style|noscript|svg|nav|footer|header|aside|form|iframe|template|button|select)\b[^>]*>.*?</(script|style|noscript|svg|nav|footer|header|aside|form|iframe|template|button|select)>`)
reComment = regexp.MustCompile(`(?s)<!--.*?-->`)
reMain = regexp.MustCompile(`(?is)<(main|article)\b[^>]*>(.*)</(main|article)>`)
reHead = regexp.MustCompile(`(?is)<h([1-6])\b[^>]*>`)
reLi = regexp.MustCompile(`(?is)<li\b[^>]*>`)
reBlock = regexp.MustCompile(`(?is)</?(p|div|section|br|tr|table|ul|ol|pre|blockquote|h[1-6]|dd|dt|figcaption)\b[^>]*>`)
reCell = regexp.MustCompile(`(?is)</t[dh]>`)
reSpaces = regexp.MustCompile(`[ \t\x{00a0}]+`)
reBlanks = regexp.MustCompile(`\n{3,}`)
rePre = regexp.MustCompile(`(?is)<pre\b[^>]*>.*?</pre>`)
rePreMark = regexp.MustCompile("\x00pre[0-9]+\x00")
reWS = regexp.MustCompile(`\s+`)
)
// pageText is a page's title and its text, in Markdown-ish lines.
func pageText(page string) (string, string) {
title := ""
if m := reTitle.FindStringSubmatch(page); m != nil {
title = strings.TrimSpace(html.UnescapeString(reTags.ReplaceAllString(m[1], "")))
}
s := reComment.ReplaceAllString(page, "")
s = reDrop.ReplaceAllString(s, " ")
// Line breaks of the source mean nothing outside <pre>: one space. The
// <pre> blocks keep theirs (held aside meanwhile).
var pres []string
s = rePre.ReplaceAllStringFunc(s, func(p string) string {
pres = append(pres, p)
return "\x00pre" + strconv.Itoa(len(pres)-1) + "\x00"
})
s = reWS.ReplaceAllString(s, " ")
s = rePreMark.ReplaceAllStringFunc(s, func(m string) string {
i, _ := strconv.Atoi(strings.Trim(m, "\x00pre"))
return pres[i]
})
// The page's main part, when it marks one.
if m := reMain.FindStringSubmatch(s); m != nil && len(m[2]) > 200 {
s = m[2]
}
s = reHead.ReplaceAllStringFunc(s, func(t string) string {
n, _ := strconv.Atoi(reHead.FindStringSubmatch(t)[1])
return "\n\n" + strings.Repeat("#", n) + " "
})
s = reLi.ReplaceAllString(s, "\n- ")
s = reCell.ReplaceAllString(s, " | ")
s = reBlock.ReplaceAllString(s, "\n")
s = html.UnescapeString(reTags.ReplaceAllString(s, ""))
var lines []string
for _, l := range strings.Split(s, "\n") {
l = strings.TrimSpace(reSpaces.ReplaceAllString(l, " "))
if l == "-" || l == "|" {
continue
}
lines = append(lines, l)
}
text := reBlanks.ReplaceAllString(strings.Join(lines, "\n"), "\n\n")
return title, strings.TrimSpace(text)
}
// POST /api/panel/ai/fetch - {url}: the page as text, for a user with the
// assistant on.
func (s *Server) handleAIFetch(w http.ResponseWriter, r *http.Request) {
a := s.currentAdmin(r)
if on, _ := s.db.GetConfig(aiUserKey(a.ID)); on != "1" {
writeJSON(w, map[string]any{"ok": false, "error": "ассистент выключен в профиле"})
return
}
var in struct {
URL string `json:"url"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&in); err != nil {
writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"})
return
}
p, err := fetchPage(r.Context(), in.URL)
if err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return
}
writeJSON(w, map[string]any{"ok": true, "data": p})
}
+78
View File
@@ -0,0 +1,78 @@
package server
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// A page as the model wants it: title, the main text with headings and
// lists, no scripts, styles or menus.
func TestPageText(t *testing.T) {
page := `<html><head><title>Go 1.24 &amp; more</title><style>p{color:red}</style>
<meta name="description" content="Release notes"></head><body>
<nav><a href="/">Home</a> <a href="/doc">Docs</a></nav>
<main><h1>Go 1.24</h1><p>Generic <b>type aliases</b> are here.</p><script>track()</script>
<ul><li>Swiss tables</li><li>weak pointers</li></ul></main><footer>© Google</footer></body></html>`
title, text := pageText(page)
if title != "Go 1.24 & more" {
t.Fatalf("title %q", title)
}
for _, want := range []string{"# Go 1.24", "Generic type aliases are here.", "- Swiss tables", "- weak pointers"} {
if !strings.Contains(text, want) {
t.Fatalf("no %q in:\n%s", want, text)
}
}
for _, not := range []string{"track()", "color:red", "Home", "© Google"} {
if strings.Contains(text, not) {
t.Fatalf("%q left in:\n%s", not, text)
}
}
}
// The server's own network is out of reach: no loopback, private or local
// addresses, whatever the name says.
func TestFetchRefusesPrivate(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "secret") }))
defer srv.Close()
for _, u := range []string{srv.URL, "http://localhost:1/", "http://10.0.0.1/", "http://169.254.169.254/latest/meta-data/", "file:///etc/passwd", "ftp://x/"} {
if _, err := fetchPage(context.Background(), u); err == nil {
t.Errorf("%s: fetched", u)
}
}
}
func TestFetchPage(t *testing.T) {
old := fetchAllowPrivate
fetchAllowPrivate = true
defer func() { fetchAllowPrivate = old }()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/json" {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"a":1}`)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, "<title>T</title><p>"+strings.Repeat("слово ", 5000)+"</p>")
}))
defer srv.Close()
p, err := fetchPage(context.Background(), srv.URL+"/page")
if err != nil || p.Title != "T" || !p.Truncated || len([]rune(p.Text)) > fetchMaxChars+10 {
t.Fatalf("page: %v %q %v %d", err, p.Title, p.Truncated, len(p.Text))
}
p, err = fetchPage(context.Background(), srv.URL+"/json")
if err != nil || p.Text != `{"a":1}` {
t.Fatalf("json: %+v %v", p, err)
}
}
// Lines broken in the page's source are one paragraph; code keeps its lines.
func TestPageTextJoinsWrapped(t *testing.T) {
_, text := pageText("<p>The latest Go release,\n version 1.24,\narrives in February.</p><pre>a\nb</pre>")
if !strings.Contains(text, "The latest Go release, version 1.24, arrives in February.") || !strings.Contains(text, "a\nb") {
t.Fatalf("%q", text)
}
}
+5 -3
View File
@@ -12,8 +12,9 @@ import (
// forwardManager owns the host-side listeners for port-forward rules. It // forwardManager owns the host-side listeners for port-forward rules. It
// supports three transports: // supports three transports:
// - "http": tunneled over the HTTP long-poll channel (works anywhere nginx // - "http": over the agent's own link to the panel - its WebSocket, or the
// reaches, highest latency); // HTTP long polls while there is none (works anywhere nginx reaches,
// highest latency);
// - "tcp": direct TCP multiplexed over the tunnel port (low latency); // - "tcp": direct TCP multiplexed over the tunnel port (low latency);
// - "udp": direct UDP over the tunnel port. // - "udp": direct UDP over the tunnel port.
type forwardManager struct { type forwardManager struct {
@@ -103,7 +104,8 @@ func (m *forwardManager) bridgeDirect(rule ForwardRule, conn net.Conn) {
at.openStream(rule.ClientPort, conn) at.openStream(rule.ClientPort, conn)
} }
// bridgeHTTP wires a connection through the HTTP long-poll hub session. // bridgeHTTP wires a connection through a hub session: the agent carries it
// over its WebSocket, or by long polls while it has none.
func (m *forwardManager) bridgeHTTP(rule ForwardRule, conn net.Conn) { func (m *forwardManager) bridgeHTTP(rule ForwardRule, conn net.Conn) {
sess := m.hub.CreateForward(rule.AgentID, rule.ClientPort) sess := m.hub.CreateForward(rule.AgentID, rule.ClientPort)
defer m.hub.CloseSession(sess.ID) defer m.hub.CloseSession(sess.ID)
+94
View File
@@ -0,0 +1,94 @@
package server
import (
"bytes"
"html/template"
"log"
"net/http"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// 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 is shown in; the machine pages (Порты,
// Статус, Изменить) open as a dialog over the machines view.
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{}
}
data["IsAdmin"] = s.currentAdmin(r).IsAdmin()
if !isFrag(r) {
if _, ok := pageView[name]; !ok {
// A machine page asked for whole: the machines view with it in
// a dialog.
s.shell(w, r, map[string]any{"View": "dashboard", "Dialog": r.URL.Path})
return
}
}
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())
s.shell(w, r, data)
}
// shell writes the shell page with data (View, Body, Open, Dialog).
func (s *Server) shell(w http.ResponseWriter, r *http.Request, data map[string]any) {
data["IsAdmin"] = s.currentAdmin(r).IsAdmin()
data["EditLimit"] = proto.FSReadCap
if a := s.currentAdmin(r); a != nil {
data["Prefs"] = s.prefs(a.ID)
}
if data["View"] == "dashboard" && data["Body"] == nil {
// The machines list under a dialog: draw it too.
var body bytes.Buffer
if err := s.tmpl.ExecuteTemplate(&body, "dashboard-body", s.dashboardData(r)); err == nil {
data["Body"] = template.HTML(body.String())
}
}
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)
}
}
// handleShell serves the shell alone: the terminal view, or with a pane of
// a machine opened at once (/agents/{id}/terminal, /agents/{id}/files).
func (s *Server) handleShell(w http.ResponseWriter, r *http.Request) {
data := map[string]any{"View": "terminal"}
if id := r.PathValue("id"); id != "" {
a, _ := s.db.GetAgent(id)
if a == nil {
http.NotFound(w, r)
return
}
kind := "term"
switch r.PathValue("kind") {
case "", "terminal":
case "files":
kind = "files"
default:
http.NotFound(w, r)
return
}
data["Open"] = map[string]any{"kind": kind, "agent": map[string]string{"id": a.ID, "name": a.Name, "host": a.Hostname, "user": a.TerminalUser()}}
}
s.shell(w, r, data)
}
+159
View File
@@ -0,0 +1,159 @@
package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// newTestServer is a server on a fresh database with an admin signed in (the
// cookie) and one machine, x1.
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()})
if err != nil {
t.Fatal(err)
}
hash, _ := hashPassword("Passw0rd12345")
if err := db.CreateAdmin("adm", hash); err != nil {
t.Fatal(err)
}
_ = db.SetConfig(cfgSetupDone, "1")
a, _ := db.AdminByUsername("adm")
if err := db.CreateSession("sid-test", a.ID, sessionTTL); err != nil {
t.Fatal(err)
}
if err := db.CreateAgent(Agent{ID: "x1", Name: "t", Hostname: "h", OS: "linux", Arch: "amd64", TokenHash: "x"}); err != nil {
t.Fatal(err)
}
return s, &http.Cookie{Name: sessionCookie, Value: "sid-test"}
}
func get(t *testing.T, s *Server, c *http.Cookie, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(c)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
return w
}
func TestFragmentHasNoChrome(t *testing.T) {
s, cookie := newTestServer(t)
w := get(t, s, cookie, "/settings?frag=1")
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{"/", "/workspace", "/terminal", "/settings", "/agents/x1/files", "/agents/x1/terminal"} {
if w := get(t, s, cookie, p); !strings.Contains(w.Body.String(), `id="app"`) {
t.Errorf("%s: not the shell (code %d)", p, w.Code)
}
}
}
func TestSSHFSOpen(t *testing.T) {
s, admin := newTestServer(t)
// Long gone: the machine is offline.
if _, err := s.db.sql.Exec(`UPDATE agents SET last_seen = 0`); err != nil {
t.Fatal(err)
}
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)
}
}
// Renaming from the machine dialog keeps it open on «Изменить».
func TestRenameStaysOnEdit(t *testing.T) {
s, c := newTestServer(t)
tok := "tok-test"
var req *http.Request
req = httptest.NewRequest(http.MethodPost, "/agents/x1/rename", strings.NewReader("csrf="+tok+"&name=new"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(c)
req.AddCookie(&http.Cookie{Name: csrfCookie, Value: tok})
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
if loc := w.Header().Get("Location"); w.Code != http.StatusSeeOther || loc != "/agents/x1/edit" {
t.Fatalf("rename: %d to %q", w.Code, loc)
}
}
// Настройки show the assistant's model as it is set, key included.
func TestSettingsShowAI(t *testing.T) {
s, c := newTestServer(t)
_ = s.db.SetConfig(cfgAIKey, "key-123")
body := get(t, s, c, "/settings?frag=1").Body.String()
if !strings.Contains(body, `value="key-123"`) || !strings.Contains(body, `value="`+DefaultAIURL+`"`) {
t.Fatal("the AI card is empty")
}
}
// The look of the panel is each user's: kept on the server, only known
// settings with known values, and given to the page at once.
func TestViewPrefs(t *testing.T) {
s, c := newTestServer(t)
post := func(body string) string {
req := httptest.NewRequest(http.MethodPost, "/api/panel/account/prefs", strings.NewReader(body))
req.AddCookie(c)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
return w.Body.String()
}
if got := post(`{"tabIcons":"right","termFont":16,"compact":true,"motion":"off","evil":"<x>","fileTime":"maybe"}`); !strings.Contains(got, `"ok":true`) {
t.Fatal(got)
}
got := get(t, s, c, "/api/panel/account/prefs").Body.String()
if !strings.Contains(got, `"tabIcons":"right"`) || !strings.Contains(got, `"termFont":16`) || strings.Contains(got, "evil") || strings.Contains(got, "maybe") {
t.Fatalf("prefs: %s", got)
}
if body := get(t, s, c, "/workspace").Body.String(); !strings.Contains(body, `"tabIcons":"right"`) {
t.Fatal("the page does not get the prefs")
}
if got := post(`{"termFont":99}`); !strings.Contains(got, `"ok":true`) || strings.Contains(get(t, s, c, "/api/panel/account/prefs").Body.String(), "99") {
t.Fatal("a font size out of range was kept")
}
post(`{"openHere":"off"}`)
if got := get(t, s, c, "/api/panel/account/prefs").Body.String(); !strings.Contains(got, `"openHere":"off"`) {
t.Fatalf("openHere off not kept: %s", got)
}
post(`{"aiModelPick":"always","aiModel":"a1b2c3"}`)
if got := get(t, s, c, "/api/panel/account/prefs").Body.String(); !strings.Contains(got, `"aiModelPick":"always"`) || !strings.Contains(got, `"aiModel":"a1b2c3"`) {
t.Fatalf("the model choice not kept: %s", got)
}
post(`{"aiModelPick":"often","aiModel":"<x>"}`)
if got := get(t, s, c, "/api/panel/account/prefs").Body.String(); strings.Contains(got, "aiModel") {
t.Fatalf("an unknown model choice was kept: %s", got)
}
post(`{"openHere":"sometimes"}`)
if got := get(t, s, c, "/api/panel/account/prefs").Body.String(); strings.Contains(got, "openHere") {
t.Fatalf("an unknown openHere was kept: %s", got)
}
}
+26 -7
View File
@@ -315,6 +315,13 @@ func readPiece(w http.ResponseWriter, r *http.Request) ([]byte, error) {
// file. The answer names the transfer and the piece size; the browser then // file. The answer names the transfer and the piece size; the browser then
// PUTs the pieces and asks for the result. // PUTs the pieces and asks for the result.
func (s *Server) handleFSUploadStart(w http.ResponseWriter, r *http.Request) { func (s *Server) handleFSUploadStart(w http.ResponseWriter, r *http.Request) {
s.uploadStart(w, r, proto.TaskFSPut, "")
}
// uploadStart starts a file upload to the agent's task (fs.put on the
// machine, sshfs.put onto an SSH host through it, conn naming the
// connection).
func (s *Server) uploadStart(w http.ResponseWriter, r *http.Request, task, conn string) {
agentID, p := r.PathValue("id"), r.URL.Query().Get("path") agentID, p := r.PathValue("id"), r.URL.Query().Get("path")
if !s.fsAgent(w, agentID, true) { if !s.fsAgent(w, agentID, true) {
return return
@@ -329,19 +336,21 @@ func (s *Server) handleFSUploadStart(w http.ResponseWriter, r *http.Request) {
return return
} }
x := s.xfers.open(agentID, size) x := s.xfers.open(agentID, size)
go s.runUpload(x, p) go s.runUpload(x, p, task, conn)
data, _ := json.Marshal(map[string]any{"id": x.id, "chunk": xferChunk}) data, _ := json.Marshal(map[string]any{"id": x.id, "chunk": xferChunk})
writeJSON(w, proto.TaskResult{OK: true, Data: data}) writeJSON(w, proto.TaskResult{OK: true, Data: data})
} }
// runUpload has the agent write the file and keeps the result for the // runUpload has the agent write the file and keeps the result for the
// browser to collect. // browser to collect.
func (s *Server) runUpload(x *xfer, p string) { func (s *Server) runUpload(x *xfer, p, task, conn string) {
ctx, cancel := context.WithTimeout(context.Background(), xferTimeout) ctx, cancel := context.WithTimeout(context.Background(), xferTimeout)
defer cancel() defer cancel()
args := proto.FSXferArgs{Path: p, Xfer: x.id, Size: x.size, Chunk: xferChunk} args := proto.FSXferArgs{Path: p, Xfer: x.id, Size: x.size, Chunk: xferChunk, Conn: conn}
res, err := s.waitTask(ctx, x.agentID, proto.TaskFSPut, args, xferTimeout) res, err := s.waitTask(ctx, x.agentID, task, args, xferTimeout)
if err == nil && unknownOp(res) { if err == nil && unknownOp(res) && task != proto.TaskFSPut {
res = proto.TaskResult{Error: "агент не умеет файлы по SSH - обновите его"}
} else if err == nil && unknownOp(res) {
res = s.legacyUpload(ctx, x, p) res = s.legacyUpload(ctx, x, p)
} }
if err != nil { if err != nil {
@@ -502,6 +511,12 @@ func fileHeaders(w http.ResponseWriter, p string, inline bool) {
// one response fed by the pieces the agent sends. inline=1 serves pictures, // one response fed by the pieces the agent sends. inline=1 serves pictures,
// video and sound for the page to show. // video and sound for the page to show.
func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) { func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) {
s.download(w, r, proto.TaskFSSend, "")
}
// download sends a file from the agent's task (fs.send, or sshfs.send for
// an SSH host through it) as one response.
func (s *Server) download(w http.ResponseWriter, r *http.Request, task, conn string) {
agentID, p := r.PathValue("id"), r.URL.Query().Get("path") agentID, p := r.PathValue("id"), r.URL.Query().Get("path")
inline := r.URL.Query().Get("inline") != "" inline := r.URL.Query().Get("inline") != ""
if !s.fsAgent(w, agentID, false) { if !s.fsAgent(w, agentID, false) {
@@ -511,8 +526,8 @@ func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) {
defer s.xfers.drop(x.id) defer s.xfers.drop(x.id)
defer x.fail("браузер ушёл") // stops the agent if the response ends early defer x.fail("браузер ушёл") // stops the agent if the response ends early
raw, _ := json.Marshal(proto.FSXferArgs{Path: p, Xfer: x.id, Size: -1, Chunk: xferChunk}) raw, _ := json.Marshal(proto.FSXferArgs{Path: p, Xfer: x.id, Size: -1, Chunk: xferChunk, Conn: conn})
taskID, ch := s.hub.CreateTask(agentID, proto.TaskFSSend, raw) taskID, ch := s.hub.CreateTask(agentID, task, raw)
defer s.hub.DiscardTask(taskID) defer s.hub.DiscardTask(taskID)
// The agent's result: an error ends the transfer wherever it stands. // The agent's result: an error ends the transfer wherever it stands.
results := make(chan proto.TaskResult, 1) results := make(chan proto.TaskResult, 1)
@@ -530,6 +545,10 @@ func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) {
select { select {
case <-x.sized: case <-x.sized:
case res := <-results: case res := <-results:
if unknownOp(res) && task != proto.TaskFSSend {
http.Error(w, "агент не умеет файлы по SSH - обновите его", http.StatusBadGateway)
return
}
if unknownOp(res) { if unknownOp(res) {
s.legacyDownload(w, r, agentID, p, inline) s.legacyDownload(w, r, agentID, p, inline)
return return
+17 -23
View File
@@ -112,11 +112,12 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
// --- dashboard --- // --- dashboard ---
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
agents, err := s.db.ListAgents() s.renderPage(w, r, "dashboard", s.dashboardData(r))
if err != nil { }
http.Error(w, "internal error", http.StatusInternalServerError)
return // dashboardData is the machines list as the user may see it.
} func (s *Server) dashboardData(r *http.Request) map[string]any {
agents, _ := s.db.ListAgents()
type row struct { type row struct {
Agent Agent
Online bool Online bool
@@ -131,12 +132,12 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
on := s.online(&a) on := s.online(&a)
rows = append(rows, row{Agent: a, Online: on, Seen: seenText(on, a.LastSeen)}) rows = append(rows, row{Agent: a, Online: on, Seen: seenText(on, a.LastSeen)})
} }
s.render(w, "dashboard.html", map[string]any{ return map[string]any{
"Admin": me, "Admin": me,
"IsAdmin": me.IsAdmin(), "IsAdmin": me.IsAdmin(),
"Agents": rows, "Agents": rows,
"CSRF": csrfToken(r), "CSRF": csrfToken(r),
}) }
} }
// seenText is the "activity" cell: a connected agent is active now, whatever // seenText is the "activity" cell: a connected agent is active now, whatever
@@ -176,19 +177,6 @@ func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) {
writeJSON(w, out) writeJSON(w, out)
} }
func (s *Server) handleTerminalPage(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
a, err := s.db.GetAgent(id)
if err != nil || a == nil {
http.NotFound(w, r)
return
}
s.render(w, "terminal.html", map[string]any{
"Agent": a,
"CSRF": csrfToken(r),
})
}
func (s *Server) handleAgentDelete(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAgentDelete(w http.ResponseWriter, r *http.Request) {
if !checkCSRF(r) { if !checkCSRF(r) {
http.Error(w, "bad csrf", http.StatusForbidden) http.Error(w, "bad csrf", http.StatusForbidden)
@@ -239,7 +227,7 @@ func (s *Server) handleAgentEdit(w http.ResponseWriter, r *http.Request) {
data["UsersErr"] = err.Error() data["UsersErr"] = err.Error()
} }
} }
s.render(w, "edit.html", data) s.renderPage(w, r, "edit", data)
} }
// POST /agents/{id}/transport - how the agent is to reach the server: auto, // POST /agents/{id}/transport - how the agent is to reach the server: auto,
@@ -414,7 +402,8 @@ func (s *Server) handleAgentRename(w http.ResponseWriter, r *http.Request) {
if name != "" { if name != "" {
_ = s.db.RenameAgent(id, name) _ = s.db.RenameAgent(id, name)
} }
http.Redirect(w, r, "/", http.StatusSeeOther) // Back to «Изменить», with the new name: the machine dialog stays open.
http.Redirect(w, r, "/agents/"+id+"/edit", http.StatusSeeOther)
} }
// --- settings --- // --- settings ---
@@ -434,7 +423,8 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
code := s.db.InstallCode() code := s.db.InstallCode()
ufwInstalled, ufwActive, ufwRunnable := s.ufw.status() ufwInstalled, ufwActive, ufwRunnable := s.ufw.status()
ufwRules, _ := s.db.ListUfwRules() ufwRules, _ := s.db.ListUfwRules()
s.render(w, "settings.html", map[string]any{ dbNow, dbChosen := s.db.Backend()
s.renderPage(w, r, "settings", map[string]any{
"Admin": s.currentAdmin(r), "Admin": s.currentAdmin(r),
"Host": host, "Host": host,
"Direct": direct, "Direct": direct,
@@ -453,6 +443,10 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
"InstallCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret), "InstallCurl": fmt.Sprintf("curl -fsSL %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret),
"InstallWget": fmt.Sprintf("wget -qO- %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret), "InstallWget": fmt.Sprintf("wget -qO- %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret),
"ServerVersion": Version, "ServerVersion": Version,
"AIModels": s.aiModels(),
"Search": s.searchConf(),
"DBNow": dbNow,
"DBChosen": dbChosen,
"UfwInstalled": ufwInstalled, "UfwInstalled": ufwInstalled,
"UfwActive": ufwActive, "UfwActive": ufwActive,
"UfwRunnable": ufwRunnable, "UfwRunnable": ufwRunnable,
+1 -10
View File
@@ -29,21 +29,12 @@ var fsOps = map[string]string{
"db.add": proto.TaskDBAdd, "db.add": proto.TaskDBAdd,
"db.del": proto.TaskDBDel, "db.del": proto.TaskDBDel,
"db.sql": proto.TaskDBSQL, "db.sql": proto.TaskDBSQL,
"termcwd": proto.TaskTermCwd,
} }
// fsTaskTimeout bounds how long we wait for an agent to answer a file op. // fsTaskTimeout bounds how long we wait for an agent to answer a file op.
const fsTaskTimeout = 60 * time.Second const fsTaskTimeout = 60 * time.Second
func (s *Server) handleFilesPage(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
a, err := s.db.GetAgent(id)
if err != nil || a == nil {
http.NotFound(w, r)
return
}
s.render(w, "files.html", map[string]any{"Agent": a, "EditLimit": proto.FSReadCap})
}
// POST /api/panel/agents/{id}/fs/{op} - run a file-manager operation on the // POST /api/panel/agents/{id}/fs/{op} - run a file-manager operation on the
// agent and return its result. The request body is the op's JSON args and is // agent and return its result. The request body is the op's JSON args and is
// forwarded verbatim to the agent. // forwarded verbatim to the agent.
+1 -1
View File
@@ -23,7 +23,7 @@ func (s *Server) handleForwardsPage(w http.ResponseWriter, r *http.Request) {
"listen": "Не удалось занять порт на хосте (возможно, уже используется).", "listen": "Не удалось занять порт на хосте (возможно, уже используется).",
"db": "Ошибка базы данных.", "db": "Ошибка базы данных.",
}[r.URL.Query().Get("err")] }[r.URL.Query().Get("err")]
s.render(w, "forwards.html", map[string]any{ s.renderPage(w, r, "forwards", map[string]any{
"Agent": a, "Agent": a,
"Rules": rules, "Rules": rules,
"CSRF": csrfToken(r), "CSRF": csrfToken(r),
+1 -1
View File
@@ -74,7 +74,7 @@ func (s *Server) handleStatusPage(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
s.render(w, "status.html", map[string]any{"Agent": a, "RelayUpMs": speedRelayUpFor.Milliseconds()}) s.renderPage(w, r, "status", map[string]any{"Agent": a, "RelayUpMs": speedRelayUpFor.Milliseconds()})
} }
// runTask runs a one-shot task on the agent and waits for its result. // runTask runs a one-shot task on the agent and waits for its result.
+15 -3
View File
@@ -23,7 +23,8 @@ import (
// POST /api/panel/term/{agentID}/open - create a terminal session: a shell // POST /api/panel/term/{agentID}/open - create a terminal session: a shell
// on the agent, or with {"ssh": {...}} in the body a shell the agent opens // on the agent, or with {"ssh": {...}} in the body a shell the agent opens
// by SSH on another host. // by SSH on another host. {"dir": "/path"} starts the shell in that folder
// (an older agent, or a folder not there: at home).
func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) { func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
agentID := r.PathValue("agentID") agentID := r.PathValue("agentID")
agent, err := s.db.GetAgent(agentID) agent, err := s.db.GetAgent(agentID)
@@ -34,6 +35,7 @@ func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
} }
var body struct { var body struct {
SSH *proto.SSHTarget `json:"ssh"` SSH *proto.SSHTarget `json:"ssh"`
Dir string `json:"dir"`
} }
if r.ContentLength != 0 { if r.ContentLength != 0 {
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&body); err != nil && err != io.EOF { if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&body); err != nil && err != io.EOF {
@@ -41,6 +43,10 @@ func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
if err := s.fillSaved(s.currentAdmin(r).ID, body.SSH); err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
if t := body.SSH; t != nil { if t := body.SSH; t != nil {
t.Host, t.User = strings.TrimSpace(t.Host), strings.TrimSpace(t.User) t.Host, t.User = strings.TrimSpace(t.Host), strings.TrimSpace(t.User)
if t.Port == 0 { if t.Port == 0 {
@@ -59,7 +65,11 @@ func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"session_id": sess.ID}) writeJSON(w, map[string]string{"session_id": sess.ID})
return return
} }
sess := s.hub.CreateSession(agentID, agent.LoginUser) dir := body.Dir
if !strings.HasPrefix(dir, "/") || strings.ContainsAny(dir, "\x00\r\n") || len(dir) > 4096 {
dir = ""
}
sess := s.hub.CreateSession(agentID, agent.LoginUser, dir)
if admin := s.currentAdmin(r); admin != nil { if admin := s.currentAdmin(r); admin != nil {
s.db.Audit(admin.ID, agentID, "open_terminal:"+agent.TerminalUser()) s.db.Audit(admin.ID, agentID, "open_terminal:"+agent.TerminalUser())
} }
@@ -132,7 +142,9 @@ func (s *Server) handlePanelTermWS(w http.ResponseWriter, r *http.Request) {
http.Error(w, "closed", http.StatusGone) http.Error(w, "closed", http.StatusGone)
return return
} }
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled}) // Terminal text packs well: deflate with the context kept between
// messages, for the larger ones (screens of output, not keystrokes).
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionContextTakeover, CompressionThreshold: 256})
if err != nil { if err != nil {
return return
} }
+5 -4
View File
@@ -7,8 +7,9 @@ import (
"github.com/x-grk/longpolling-remote-access/internal/proto" "github.com/x-grk/longpolling-remote-access/internal/proto"
) )
// Hub is the in-memory broker between agents (HTTP long-polling) and the panel. // Hub is the in-memory broker between agents (on a WebSocket, or by HTTP
// It carries three kinds of work to an agent through its single control poll: // long polls) and the panel. It carries three kinds of work to an agent
// through its single control channel:
// terminal sessions, port-forward streams, and one-shot tasks (file manager). // terminal sessions, port-forward streams, and one-shot tasks (file manager).
// Nothing here is persisted. // Nothing here is persisted.
type Hub struct { type Hub struct {
@@ -94,9 +95,9 @@ func (h *Hub) newSession(agentID string) *Session {
// CreateSession registers a terminal session and queues it for the agent. user // CreateSession registers a terminal session and queues it for the agent. user
// is the OS user to run the shell as ("" = the agent's own user). // is the OS user to run the shell as ("" = the agent's own user).
func (h *Hub) CreateSession(agentID, user string) *Session { func (h *Hub) CreateSession(agentID, user, dir string) *Session {
s := h.newSession(agentID) s := h.newSession(agentID)
h.enqueueJob(agentID, proto.ControlMessage{Type: proto.CtrlOpenTerminal, SessionID: s.ID, User: user}) h.enqueueJob(agentID, proto.ControlMessage{Type: proto.CtrlOpenTerminal, SessionID: s.ID, User: user, Dir: dir})
return s return s
} }
+80
View File
@@ -0,0 +1,80 @@
package server
import (
"encoding/json"
"net/http"
"strconv"
"strings"
)
// Each user's look of the panel (Аккаунт › Вид), kept on the server so it
// is the same on every device: where a tab's icons go, the terminal's font
// size, a compact file list, the time column, animations, and whether a
// terminal or file pane opened «Здесь» starts in the active pane's folder;
// and the assistant's model: which one, and whether the chat always offers
// the choice.
type viewPrefs struct {
TabIcons string `json:"tabIcons,omitempty"` // left (default), right, hide
TermFont int `json:"termFont,omitempty"` // 11-20; 0: the default
Compact bool `json:"compact,omitempty"` // denser file lists
FileTime string `json:"fileTime,omitempty"` // show (default), hide
Motion string `json:"motion,omitempty"` // on (default), off
OpenHere string `json:"openHere,omitempty"` // on (default), off
// AIModelPick: the model menu in the chat - when there are several
// (default) or always. AIModel: the id of the model chosen.
AIModelPick string `json:"aiModelPick,omitempty"`
AIModel string `json:"aiModel,omitempty"`
}
func prefsKey(id int64) string { return "prefs_" + strconv.FormatInt(id, 10) }
// clean keeps known values only.
func (p viewPrefs) clean() viewPrefs {
if p.TabIcons != "right" && p.TabIcons != "hide" {
p.TabIcons = ""
}
if p.TermFont < 11 || p.TermFont > 20 {
p.TermFont = 0
}
if p.FileTime != "hide" {
p.FileTime = ""
}
if p.Motion != "off" {
p.Motion = ""
}
if p.OpenHere != "off" {
p.OpenHere = ""
}
if p.AIModelPick != "always" {
p.AIModelPick = ""
}
if len(p.AIModel) > 32 || strings.Trim(p.AIModel, "0123456789abcdefghijklmnopqrstuvwxyz") != "" {
p.AIModel = ""
}
return p
}
func (s *Server) prefs(id int64) viewPrefs {
var p viewPrefs
if v, _ := s.db.GetConfig(prefsKey(id)); v != "" {
_ = json.Unmarshal([]byte(v), &p)
}
return p.clean()
}
// GET /api/panel/account/prefs
func (s *Server) handlePrefs(w http.ResponseWriter, r *http.Request) {
writeJSON(w, s.prefs(s.currentAdmin(r).ID))
}
// POST /api/panel/account/prefs - the whole set.
func (s *Server) handlePrefsSet(w http.ResponseWriter, r *http.Request) {
var p viewPrefs
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&p); err != nil {
writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"})
return
}
b, _ := json.Marshal(p.clean())
_ = s.db.SetConfig(prefsKey(s.currentAdmin(r).ID), string(b))
writeJSON(w, map[string]any{"ok": true})
}
+282
View File
@@ -0,0 +1,282 @@
package server
import (
"context"
"encoding/json"
"errors"
"html"
"io"
"log"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
)
// Web search for the assistant, when the user turned «Поиск» on: DuckDuckGo's
// plain pages, no key and nothing to install. The light page first, the
// HTML one if that fails; DuckDuckGo turns away too many requests from one
// address, and then the assistant is told so.
type searchResult struct {
Title string `json:"title"`
URL string `json:"url"`
Snippet string `json:"snippet"`
}
var (
reLiteLink = regexp.MustCompile(`(?s)<a[^>]+href="([^"]+)"[^>]*class='result-link'[^>]*>(.*?)</a>`)
reLiteSnippet = regexp.MustCompile(`(?s)<td class='result-snippet'>(.*?)</td>`)
reHTMLLink = regexp.MustCompile(`(?s)<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>(.*?)</a>`)
reHTMLSnippet = regexp.MustCompile(`(?s)<a[^>]+class="result__snippet"[^>]*>(.*?)</a>`)
reTags = regexp.MustCompile(`<[^>]+>`)
)
// text is a bit of a results page as plain text.
func text(s string) string {
return strings.Join(strings.Fields(html.UnescapeString(reTags.ReplaceAllString(s, ""))), " ")
}
// realURL takes DuckDuckGo's redirect (/l/?uddg=…) back to the address.
func realURL(h string) string {
h = html.UnescapeString(h)
if strings.HasPrefix(h, "//") {
h = "https:" + h
}
if u, err := url.Parse(h); err == nil {
if t := u.Query().Get("uddg"); t != "" {
return t
}
}
return h
}
// parseDDG reads the results of the light or the HTML page.
func parseDDG(page string) []searchResult {
links, snips := reLiteLink.FindAllStringSubmatch(page, -1), reLiteSnippet.FindAllStringSubmatch(page, -1)
if len(links) == 0 {
links, snips = reHTMLLink.FindAllStringSubmatch(page, -1), reHTMLSnippet.FindAllStringSubmatch(page, -1)
}
var out []searchResult
for i, l := range links {
r := searchResult{Title: text(l[2]), URL: realURL(l[1])}
if i < len(snips) {
r.Snippet = text(snips[i][1])
}
// Ads lead back through DuckDuckGo itself: left out.
if strings.Contains(r.URL, "duckduckgo.com/y.js") || r.Title == "" {
continue
}
out = append(out, r)
}
return out
}
var searchClient = &http.Client{Timeout: 15 * time.Second}
// The engine is the admins' choice (Настройки › ИИ-ассистент): DuckDuckGo,
// a SearXNG of one's own, Brave Search or Tavily; when the chosen one fails,
// DuckDuckGo answers instead.
const (
cfgSearchEngine = "ai_search"
cfgSearchURL = "ai_search_url"
cfgSearchKey = "ai_search_key"
)
type searchConf struct{ Engine, URL, Key string }
func (s *Server) searchConf() searchConf {
e, _ := s.db.GetConfig(cfgSearchEngine)
u, _ := s.db.GetConfig(cfgSearchURL)
k, _ := s.db.GetConfig(cfgSearchKey)
return searchConf{Engine: e, URL: strings.TrimRight(u, "/"), Key: k}
}
var (
braveURL = "https://api.search.brave.com/res/v1/web/search"
tavilyURL = "https://api.tavily.com/search"
ddgSearch = webSearch
)
// engineName is how the engine is called to people and to the model.
func engineName(e string) string {
switch e {
case "searxng":
return "SearXNG"
case "brave":
return "Brave Search"
case "tavily":
return "Tavily"
}
return "DuckDuckGo"
}
// searchWith asks the engine c names; DuckDuckGo if that one fails. It
// tells which engine the results came from (and why, when it was not the
// chosen one).
func searchWith(ctx context.Context, c searchConf, q string, n int) ([]searchResult, string, error) {
var rs []searchResult
var err error
switch c.Engine {
case "searxng":
rs, err = searchSearx(ctx, c, q)
case "brave":
rs, err = searchBrave(ctx, c, q, n)
case "tavily":
rs, err = searchTavily(ctx, c, q, n)
default:
rs, err = ddgSearch(ctx, q, n)
return rs, "DuckDuckGo", err
}
if err != nil || len(rs) == 0 {
why := "ничего не нашёл"
if err != nil {
why = err.Error()
}
log.Printf("search: %s failed (%s), DuckDuckGo instead", engineName(c.Engine), why)
rs, err = ddgSearch(ctx, q, n)
return rs, "DuckDuckGo (" + engineName(c.Engine) + " не ответил: " + why + ")", err
}
if len(rs) > n {
rs = rs[:n]
}
return rs, engineName(c.Engine), nil
}
// searchPrompt tells the model it can search, and with what.
func searchPrompt(c searchConf) string {
return "\nПоиск в интернете включён (поисковик: " + engineName(c.Engine) + "): блок ```search\nзапрос\n``` - панель найдёт и пришлёт результаты " +
"(заголовок, адрес, отрывок) следующим сообщением, с пометкой, через какой поисковик они пришли.\n" +
"Ищи активно: свежие данные, версии, документация, тексты ошибок, всё, в чём не уверен, - сначала поиск, потом ответ со ссылками на найденное. " +
"Чтобы прочитать найденную страницу целиком (или адрес, который дал пользователь), - блок ```fetch\nhttps://адрес\n```: придёт её текст. " +
"Одно действие за ответ. Ссылки не выдумывай."
}
// getJSON fetches url (with the headers) into v.
func getJSON(ctx context.Context, method, url string, body io.Reader, hdr map[string]string, v any) error {
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
for k, val := range hdr {
req.Header.Set(k, val)
}
resp, err := searchClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return errors.New("поисковик ответил " + resp.Status)
}
return json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(v)
}
// searchSearx: a SearXNG with JSON output on (search.formats: json).
func searchSearx(ctx context.Context, c searchConf, q string) ([]searchResult, error) {
if c.URL == "" {
return nil, errors.New("не задан адрес SearXNG")
}
var out struct {
Results []struct{ Title, URL, Content string } `json:"results"`
}
if err := getJSON(ctx, http.MethodGet, c.URL+"/search?format=json&q="+url.QueryEscape(q), nil, nil, &out); err != nil {
return nil, err
}
var rs []searchResult
for _, r := range out.Results {
rs = append(rs, searchResult{Title: r.Title, URL: r.URL, Snippet: text(r.Content)})
}
return rs, nil
}
func searchBrave(ctx context.Context, c searchConf, q string, n int) ([]searchResult, error) {
var out struct {
Web struct {
Results []struct{ Title, URL, Description string } `json:"results"`
} `json:"web"`
}
err := getJSON(ctx, http.MethodGet, braveURL+"?count="+strconv.Itoa(n)+"&q="+url.QueryEscape(q), nil,
map[string]string{"X-Subscription-Token": c.Key}, &out)
if err != nil {
return nil, err
}
var rs []searchResult
for _, r := range out.Web.Results {
rs = append(rs, searchResult{Title: text(r.Title), URL: r.URL, Snippet: text(r.Description)})
}
return rs, nil
}
func searchTavily(ctx context.Context, c searchConf, q string, n int) ([]searchResult, error) {
b, _ := json.Marshal(map[string]any{"query": q, "max_results": n})
var out struct {
Results []struct{ Title, URL, Content string } `json:"results"`
}
err := getJSON(ctx, http.MethodPost, tavilyURL, strings.NewReader(string(b)),
map[string]string{"Authorization": "Bearer " + c.Key, "Content-Type": "application/json"}, &out)
if err != nil {
return nil, err
}
var rs []searchResult
for _, r := range out.Results {
rs = append(rs, searchResult{Title: r.Title, URL: r.URL, Snippet: r.Content})
}
return rs, nil
}
// webSearch asks DuckDuckGo for q: up to n results.
func webSearch(ctx context.Context, q string, n int) ([]searchResult, error) {
var last error
for _, base := range []string{"https://lite.duckduckgo.com/lite/?q=", "https://html.duckduckgo.com/html/?q="} {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+url.QueryEscape(q), nil)
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36")
req.Header.Set("Accept-Language", "ru,en;q=0.8")
resp, err := searchClient.Do(req)
if err != nil {
last = err
continue
}
b, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
resp.Body.Close()
rs := parseDDG(string(b))
if resp.StatusCode == http.StatusOK && len(rs) > 0 {
if len(rs) > n {
rs = rs[:n]
}
return rs, nil
}
if resp.StatusCode == http.StatusOK {
last = errors.New("ничего не нашлось")
} else {
last = errors.New("поисковик не ответил (" + resp.Status + "), возможно, временно ограничил запросы")
}
}
return nil, last
}
// POST /api/panel/ai/search - {q}: the results, for a user with the
// assistant on (the «Поиск» switch is the page's: it lets the model ask).
func (s *Server) handleAISearch(w http.ResponseWriter, r *http.Request) {
a := s.currentAdmin(r)
if on, _ := s.db.GetConfig(aiUserKey(a.ID)); on != "1" {
writeJSON(w, map[string]any{"ok": false, "error": "ассистент выключен в профиле"})
return
}
var in struct {
Q string `json:"q"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&in); err != nil || strings.TrimSpace(in.Q) == "" {
writeJSON(w, map[string]any{"ok": false, "error": "пустой запрос"})
return
}
rs, used, err := searchWith(r.Context(), s.searchConf(), strings.TrimSpace(in.Q), 6)
if err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error(), "engine": used})
return
}
writeJSON(w, map[string]any{"ok": true, "data": rs, "engine": used})
}
+107
View File
@@ -0,0 +1,107 @@
package server
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
// The results of DuckDuckGo's light page: titles, real addresses (not its
// redirects), snippets without tags.
func TestParseDDGLite(t *testing.T) {
b, err := os.ReadFile("testdata/ddg_lite.html")
if err != nil {
t.Fatal(err)
}
rs := parseDDG(string(b))
if len(rs) < 5 {
t.Fatalf("results: %d", len(rs))
}
r := rs[0]
if r.URL != "https://go.dev/doc/database/cancel-operations" || !strings.Contains(r.Title, "Canceling in-progress operations") ||
!strings.Contains(r.Snippet, "Learn how to use context.Context") || strings.Contains(r.Snippet, "<b>") {
t.Fatalf("first: %+v", r)
}
}
// Each search engine of the settings, and DuckDuckGo when the chosen one
// fails.
func TestSearchProviders(t *testing.T) {
var gotKey, gotQ string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/search" && r.URL.Query().Get("format") == "json": // SearXNG
gotQ = r.URL.Query().Get("q")
fmt.Fprint(w, `{"results":[{"title":"SX","url":"https://a.example/","content":"from searx"}]}`)
case r.URL.Path == "/res/v1/web/search": // Brave
gotKey, gotQ = r.Header.Get("X-Subscription-Token"), r.URL.Query().Get("q")
fmt.Fprint(w, `{"web":{"results":[{"title":"BR","url":"https://b.example/","description":"from <strong>brave</strong>"}]}}`)
case r.URL.Path == "/search" && r.Method == http.MethodPost: // Tavily
var in map[string]any
_ = json.NewDecoder(r.Body).Decode(&in)
gotKey, gotQ = r.Header.Get("Authorization"), in["query"].(string)
fmt.Fprint(w, `{"results":[{"title":"TV","url":"https://t.example/","content":"from tavily"}]}`)
default:
http.Error(w, "no", 500)
}
}))
defer srv.Close()
braveURL, tavilyURL = srv.URL+"/res/v1/web/search", srv.URL+"/search"
ctx := context.Background()
rs, used, err := searchWith(ctx, searchConf{Engine: "searxng", URL: srv.URL}, "q1", 6)
if err != nil || len(rs) != 1 || rs[0].Title != "SX" || gotQ != "q1" || used != "SearXNG" {
t.Fatalf("searxng: %+v %v", rs, err)
}
rs, _, err = searchWith(ctx, searchConf{Engine: "brave", Key: "bk"}, "q2", 6)
if err != nil || rs[0].Snippet != "from brave" || gotKey != "bk" || gotQ != "q2" {
t.Fatalf("brave: %+v %v %q", rs, err, gotKey)
}
rs, used, err = searchWith(ctx, searchConf{Engine: "tavily", Key: "tk"}, "q3", 6)
if err != nil || rs[0].Title != "TV" || gotKey != "Bearer tk" || gotQ != "q3" || used != "Tavily" {
t.Fatalf("tavily: %+v %v %q", rs, err, gotKey)
}
// The chosen one fails: DuckDuckGo instead (stood in for here).
ddgSearch = func(ctx context.Context, q string, n int) ([]searchResult, error) {
return []searchResult{{Title: "DDG"}}, nil
}
rs, used, err = searchWith(ctx, searchConf{Engine: "searxng", URL: srv.URL + "/broken"}, "q4", 6)
if err != nil || rs[0].Title != "DDG" || !strings.Contains(used, "DuckDuckGo") || !strings.Contains(used, "SearXNG не ответил") {
t.Fatalf("fallback: %+v %v", rs, err)
}
}
// The chosen engine's limit spent (429 and the like): DuckDuckGo answers.
func TestSearchLimitFallsBack(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"error":"rate limit"}`, http.StatusTooManyRequests)
}))
defer srv.Close()
old := ddgSearch
defer func() { ddgSearch = old }()
ddgSearch = func(ctx context.Context, q string, n int) ([]searchResult, error) {
return []searchResult{{Title: "DDG " + q}}, nil
}
braveURL, tavilyURL = srv.URL, srv.URL
for _, e := range []string{"brave", "tavily", "searxng"} {
rs, _, err := searchWith(context.Background(), searchConf{Engine: e, Key: "k", URL: srv.URL}, "x", 6)
if err != nil || len(rs) != 1 || rs[0].Title != "DDG x" {
t.Fatalf("%s: %+v %v", e, rs, err)
}
}
}
// The model is told which engine it searches with.
func TestSearchPromptNamesEngine(t *testing.T) {
if p := searchPrompt(searchConf{Engine: "tavily"}); !strings.Contains(p, "Tavily") || strings.Contains(p, "DuckDuckGo") {
t.Fatalf("tavily: %s", p)
}
if p := searchPrompt(searchConf{}); !strings.Contains(p, "DuckDuckGo") {
t.Fatalf("default: %s", p)
}
}
+31 -2
View File
@@ -227,8 +227,10 @@ func (s *Server) Handler() http.Handler {
// Panel (admin-only). // Panel (admin-only).
mux.Handle("GET /{$}", s.requireAdmin(s.handleDashboard)) mux.Handle("GET /{$}", s.requireAdmin(s.handleDashboard))
mux.Handle("GET /agents/{id}/terminal", s.requireAdmin(s.handleTerminalPage)) mux.Handle("GET /workspace", s.requireAdmin(s.handleShell))
mux.Handle("GET /agents/{id}/files", s.requireAdmin(s.handleFilesPage)) mux.Handle("GET /terminal", s.requireAdmin(s.handleShell)) // the old address
mux.Handle("GET /agents/{id}/terminal", s.requireAdmin(s.handleShell))
mux.Handle("GET /agents/{id}/{kind}", s.requireAdmin(s.handleShell)) // files
mux.Handle("GET /agents/{id}/forwards", s.requireAdmin(s.handleForwardsPage)) mux.Handle("GET /agents/{id}/forwards", s.requireAdmin(s.handleForwardsPage))
mux.Handle("GET /agents/{id}/status", s.requireAdmin(s.handleStatusPage)) mux.Handle("GET /agents/{id}/status", s.requireAdmin(s.handleStatusPage))
mux.Handle("GET /agents/{id}/edit", s.requireAdmin(s.handleAgentEdit)) mux.Handle("GET /agents/{id}/edit", s.requireAdmin(s.handleAgentEdit))
@@ -249,6 +251,24 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /api/panel/users/{id}", s.adminsOnly(s.handleUserEdit)) mux.Handle("POST /api/panel/users/{id}", s.adminsOnly(s.handleUserEdit))
mux.Handle("POST /settings/admins/keys/reset", s.adminsOnly(s.handleResetAdminKeys)) mux.Handle("POST /settings/admins/keys/reset", s.adminsOnly(s.handleResetAdminKeys))
mux.Handle("GET /api/panel/account", s.requireAdmin(s.handleAccount)) mux.Handle("GET /api/panel/account", s.requireAdmin(s.handleAccount))
mux.Handle("GET /api/panel/account/prefs", s.requireAdmin(s.handlePrefs))
mux.Handle("POST /api/panel/account/prefs", s.requireAdmin(s.handlePrefsSet))
mux.Handle("GET /api/panel/ai", s.requireAdmin(s.handleAIState))
mux.Handle("GET /api/panel/ssh/hosts", s.requireAdmin(s.handleSSHHosts))
mux.Handle("POST /api/panel/ssh/hosts", s.requireAdmin(s.handleSSHHostAdd))
mux.Handle("POST /api/panel/ssh/hosts/{hid}/delete", s.requireAdmin(s.handleSSHHostDelete))
mux.Handle("POST /api/panel/ai/toggle", s.requireAdmin(s.handleAIToggle))
mux.Handle("POST /api/panel/ai/chat", s.requireAdmin(s.handleAIChat))
mux.Handle("GET /api/panel/ai/chats", s.requireAdmin(s.handleAIChats))
mux.Handle("POST /api/panel/ai/chats", s.requireAdmin(s.handleAIChatNew))
mux.Handle("GET /api/panel/ai/chats/{cid}", s.requireAdmin(s.handleAIHistory))
mux.Handle("POST /api/panel/ai/chats/{cid}/delete", s.requireAdmin(s.handleAIChatDelete))
mux.Handle("POST /api/panel/ai/note", s.requireAdmin(s.handleAINote))
mux.Handle("POST /api/panel/ai/search", s.requireAdmin(s.handleAISearch))
mux.Handle("POST /api/panel/ai/fetch", s.requireAdmin(s.handleAIFetch))
mux.Handle("POST /settings/ai", s.adminsOnly(s.handleSetAI))
mux.Handle("POST /settings/db", s.adminsOnly(s.handleSetDB))
mux.Handle("POST /settings/db/restart", s.adminsOnly(s.handleRestart))
mux.Handle("POST /api/panel/account/keys/begin", s.requireAdmin(s.handleKeyRegisterBegin)) mux.Handle("POST /api/panel/account/keys/begin", s.requireAdmin(s.handleKeyRegisterBegin))
mux.Handle("POST /api/panel/account/keys/finish", s.requireAdmin(s.handleKeyRegisterFinish)) mux.Handle("POST /api/panel/account/keys/finish", s.requireAdmin(s.handleKeyRegisterFinish))
mux.Handle("POST /api/panel/account/keys/{id}/delete", s.requireAdmin(s.handleKeyDelete)) mux.Handle("POST /api/panel/account/keys/{id}/delete", s.requireAdmin(s.handleKeyDelete))
@@ -278,6 +298,10 @@ func (s *Server) Handler() http.Handler {
mux.Handle("GET /api/panel/fs/job/{jid}", s.requireAdmin(s.handleShipJob)) mux.Handle("GET /api/panel/fs/job/{jid}", s.requireAdmin(s.handleShipJob))
mux.Handle("POST /api/panel/fs/job/{jid}/cancel", s.requireAdmin(s.handleShipCancel)) mux.Handle("POST /api/panel/fs/job/{jid}/cancel", s.requireAdmin(s.handleShipCancel))
mux.Handle("POST /api/panel/agents/{id}/fs/{op}", s.requireAdmin(s.handleFSOp)) mux.Handle("POST /api/panel/agents/{id}/fs/{op}", s.requireAdmin(s.handleFSOp))
mux.Handle("POST /api/panel/agents/{id}/sshfs/open", s.requireAdmin(s.handleSSHFSOpen))
mux.Handle("GET /api/panel/agents/{id}/sshfs/{conn}/download", s.requireAdmin(s.handleSSHFSDownload))
mux.Handle("POST /api/panel/agents/{id}/sshfs/{conn}/upload", s.requireAdmin(s.handleSSHFSUpload))
mux.Handle("POST /api/panel/agents/{id}/sshfs/{conn}/{op}", s.requireAdmin(s.handleSSHFSOp))
// Machine status (admin). // Machine status (admin).
mux.Handle("GET /api/panel/agents/presence", s.requireAdmin(s.handlePresence)) mux.Handle("GET /api/panel/agents/presence", s.requireAdmin(s.handlePresence))
@@ -434,6 +458,11 @@ func assetHashes(fsys fs.FS) map[string]string {
func staticHandler(fsys fs.FS, hashes map[string]string) http.Handler { func staticHandler(fsys fs.FS, hashes map[string]string) http.Handler {
files := http.FileServer(http.FS(fsys)) files := http.FileServer(http.FS(fsys))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// The JS tests sit beside the scripts but are not served.
if strings.HasSuffix(r.URL.Path, ".test.js") {
http.NotFound(w, r)
return
}
h := hashes[strings.TrimPrefix(r.URL.Path, "/")] h := hashes[strings.TrimPrefix(r.URL.Path, "/")]
if h != "" { if h != "" {
w.Header().Set("ETag", `"`+h+`"`) w.Header().Set("ETag", `"`+h+`"`)
+81 -3
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"net/http" "net/http"
"path"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -115,6 +116,12 @@ type shipReq struct {
Move bool `json:"move"` Move bool `json:"move"`
To string `json:"to"` To string `json:"to"`
SSH *proto.SSHTarget `json:"ssh"` SSH *proto.SSHTarget `json:"ssh"`
// FromConn, ToConn: that side is an SSH host held by its agent for a
// file pane (its connection id); To is still the agent holding ToConn.
FromConn string `json:"from_conn"`
ToConn string `json:"to_conn"`
// More: other things of Src's folder sent along, under their own names.
More []string `json:"more"`
} }
// POST /api/panel/agents/{id}/fs/ship - start sending; the answer names the // POST /api/panel/agents/{id}/fs/ship - start sending; the answer names the
@@ -135,6 +142,10 @@ func (s *Server) handleFSShip(w http.ResponseWriter, r *http.Request) {
return return
} }
where := "" where := ""
if err := s.fillSaved(s.currentAdmin(r).ID, q.SSH); err != nil {
writeJSON(w, proto.TaskResult{Error: err.Error()})
return
}
if q.SSH != nil { if q.SSH != nil {
t := q.SSH t := q.SSH
t.Host, t.User = strings.TrimSpace(t.Host), strings.TrimSpace(t.User) t.Host, t.User = strings.TrimSpace(t.Host), strings.TrimSpace(t.User)
@@ -147,6 +158,9 @@ func (s *Server) handleFSShip(w http.ResponseWriter, r *http.Request) {
} }
where = "ssh:" + t.User + "@" + t.Host + ":" + strconv.Itoa(t.Port) where = "ssh:" + t.User + "@" + t.Host + ":" + strconv.Itoa(t.Port)
} else { } else {
if q.To == "" && q.ToConn != "" {
q.To = from
}
if q.To == "" { if q.To == "" {
writeJSON(w, proto.TaskResult{Error: "не выбрана машина"}) writeJSON(w, proto.TaskResult{Error: "не выбрана машина"})
return return
@@ -203,7 +217,27 @@ func oldAgent(res proto.TaskResult) proto.TaskResult {
} }
func (s *Server) ship1(ctx context.Context, j *shipJob, from string, q shipReq) proto.TaskResult { func (s *Server) ship1(ctx context.Context, j *shipJob, from string, q shipReq) proto.TaskResult {
args := proto.FSShipArgs{Src: q.Src, Name: q.Name, Dir: q.Dir, Conflict: q.Conflict, Job: j.id} if len(q.More) > 0 && !s.shipsSeveral(from, q) {
// One at a time: an SSH address, or an agent from before 0.2.0.
var last proto.TaskResult
done, skipped := 0, 0
for i, p := range append([]string{q.Src}, q.More...) {
one := q
one.Src, one.More = p, nil
if i > 0 {
one.Name = path.Base(path.Clean(p))
}
if last = s.ship1(ctx, j, from, one); !last.OK {
return last
}
var d struct{ Done, Skipped int }
_ = json.Unmarshal(last.Data, &d)
done, skipped = done+d.Done, skipped+d.Skipped
}
data, _ := json.Marshal(map[string]int{"done": done, "skipped": skipped})
return proto.TaskResult{OK: true, Data: data}
}
args := proto.FSShipArgs{Src: q.Src, Name: q.Name, Dir: q.Dir, Conflict: q.Conflict, Job: j.id, More: q.More}
if q.SSH != nil { if q.SSH != nil {
args.SSH, args.Move = q.SSH, q.Move args.SSH, args.Move = q.SSH, q.Move
res, err := s.waitTask(ctx, from, proto.TaskFSShipOut, args, xferTimeout) res, err := s.waitTask(ctx, from, proto.TaskFSShipOut, args, xferTimeout)
@@ -224,12 +258,14 @@ func (s *Server) ship1(ctx context.Context, j *shipJob, from string, q shipReq)
}() }()
go func() { <-ctx.Done(); x.fail("отменено") }() go func() { <-ctx.Done(); x.fail("отменено") }()
args.Relay, args.Chunk = x.id, xferChunk args.Relay, args.Chunk = x.id, xferChunk
inArgs := args
args.Conn, inArgs.Conn = q.FromConn, q.ToConn
var in proto.TaskResult var in proto.TaskResult
inDone := make(chan struct{}) inDone := make(chan struct{})
go func() { go func() {
defer close(inDone) defer close(inDone)
res, err := s.waitTask(ctx, q.To, proto.TaskFSShipIn, args, xferTimeout) res, err := s.waitTask(ctx, q.To, proto.TaskFSShipIn, inArgs, xferTimeout)
if err != nil { if err != nil {
res = proto.TaskResult{Error: err.Error()} res = proto.TaskResult{Error: err.Error()}
} }
@@ -254,7 +290,13 @@ func (s *Server) ship1(ctx context.Context, j *shipJob, from string, q shipReq)
return out return out
} }
if q.Move { if q.Move {
res, err := s.waitTask(ctx, from, proto.TaskFSDelTree, proto.FSPathArgs{Path: q.Src}, fsTaskTimeout) for _, p := range append([]string{q.Src}, q.More...) {
task, del := proto.TaskFSDelTree, any(proto.FSPathArgs{Path: p})
if q.FromConn != "" {
raw, _ := json.Marshal(proto.FSPathArgs{Path: p})
task, del = proto.TaskSSHFSOp, proto.SSHFSArgs{Conn: q.FromConn, Op: "deltree", Args: raw}
}
res, err := s.waitTask(ctx, from, task, del, fsTaskTimeout)
if err == nil && !res.OK { if err == nil && !res.OK {
err = errText(res.Error) err = errText(res.Error)
} }
@@ -262,6 +304,7 @@ func (s *Server) ship1(ctx context.Context, j *shipJob, from string, q shipReq)
return proto.TaskResult{Error: "отправлено, но здесь не удалилось: " + err.Error()} return proto.TaskResult{Error: "отправлено, но здесь не удалилось: " + err.Error()}
} }
} }
}
// The receiver's count: what it put down and what it kept as it was. // The receiver's count: what it put down and what it kept as it was.
return in return in
} }
@@ -416,3 +459,38 @@ func (s *Server) handleRelayGet(w http.ResponseWriter, r *http.Request) {
} }
_, _ = w.Write(data) _, _ = w.Write(data)
} }
// shipsSeveral: both agents take several things in one stream (0.2.0 on).
func (s *Server) shipsSeveral(from string, q shipReq) bool {
if q.SSH != nil {
return false
}
for _, id := range []string{from, q.To} {
a, _ := s.db.GetAgent(id)
if a == nil || !versionAtLeast(a.Version, "0.2.0") {
return false
}
}
return true
}
// versionAtLeast: version v (dotted numbers) is min or newer; anything else
// is taken for older.
func versionAtLeast(v, min string) bool {
a, b := strings.Split(v, "."), strings.Split(min, ".")
for i := range b {
var x, y int
if i < len(a) {
n, err := strconv.Atoi(a[i])
if err != nil {
return false
}
x = n
}
y, _ = strconv.Atoi(b[i])
if x != y {
return x > y
}
}
return true
}
+109
View File
@@ -0,0 +1,109 @@
package server
import (
"encoding/json"
"io"
"net/http"
"strings"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// Files on an SSH host through a machine, for the panel's file panes: the
// machine's agent keeps the SSH connection (open gives its id) and runs the
// operations there. Downloads and uploads go in the same pieces as a
// machine's own files.
// POST /api/panel/agents/{id}/sshfs/open - {"ssh": {...}} → {conn, home}.
func (s *Server) handleSSHFSOpen(w http.ResponseWriter, r *http.Request) {
agentID := r.PathValue("id")
var in struct {
SSH *proto.SSHTarget `json:"ssh"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil || in.SSH == nil {
writeJSON(w, proto.TaskResult{Error: "не указан хост"})
return
}
if err := s.fillSaved(s.currentAdmin(r).ID, in.SSH); err != nil {
writeJSON(w, proto.TaskResult{Error: err.Error()})
return
}
t := in.SSH
t.Host, t.User = strings.TrimSpace(t.Host), strings.TrimSpace(t.User)
if t.Port == 0 {
t.Port = 22
}
if t.Host == "" || t.User == "" || t.Port < 1 || t.Port > 65535 {
writeJSON(w, proto.TaskResult{Error: "нужны хост, порт и пользователь"})
return
}
if !s.fsAgent(w, agentID, true) {
return
}
res, err := s.waitTask(r.Context(), agentID, proto.TaskSSHFSOpen, proto.SSHFSArgs{SSH: t}, fsTaskTimeout)
if err != nil {
res = proto.TaskResult{Error: err.Error()}
}
if unknownOp(res) {
res = proto.TaskResult{Error: "агент не умеет файлы по SSH - обновите его"}
}
if res.OK {
if admin := s.currentAdmin(r); admin != nil {
s.db.Audit(admin.ID, agentID, "sshfs.open:"+t.User+"@"+t.Host)
}
}
writeJSON(w, res)
}
// sshfsChanges are the operations that change files, for the audit log.
var sshfsChanges = map[string]bool{"write": true, "mkdir": true, "delete": true, "deltree": true, "move": true, "copy": true, "chmod": true, "zip": true, "unzip": true}
// POST /api/panel/agents/{id}/sshfs/{conn}/{op} - an operation on the host;
// the body is in the shape of the machine's own fs.{op}. "close" drops the
// connection.
func (s *Server) handleSSHFSOp(w http.ResponseWriter, r *http.Request) {
agentID, conn, op := r.PathValue("id"), r.PathValue("conn"), r.PathValue("op")
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<20))
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !s.fsAgent(w, agentID, true) {
return
}
task, args := proto.TaskSSHFSOp, proto.SSHFSArgs{Conn: conn, Op: op, Args: json.RawMessage(body)}
if op == "close" {
task, args = proto.TaskSSHFSClose, proto.SSHFSArgs{Conn: conn}
}
if len(args.Args) == 0 {
args.Args = json.RawMessage("{}")
}
if sshfsChanges[op] {
var d struct{ Path, Src, Dst string }
_ = json.Unmarshal(body, &d)
if admin := s.currentAdmin(r); admin != nil {
s.db.Audit(admin.ID, agentID, "sshfs."+op+":"+strings.TrimSpace(d.Path+" "+d.Src+" "+d.Dst))
}
}
res, err := s.waitTask(r.Context(), agentID, task, args, fsTaskTimeout)
if err != nil {
res = proto.TaskResult{Error: err.Error()}
}
if unknownOp(res) && op != "close" {
res = proto.TaskResult{Error: "агент не умеет файлы по SSH - обновите его"}
}
writeJSON(w, res)
}
// GET /api/panel/agents/{id}/sshfs/{conn}/download?path=...[&inline=1]
func (s *Server) handleSSHFSDownload(w http.ResponseWriter, r *http.Request) {
s.download(w, r, proto.TaskSSHFSSend, r.PathValue("conn"))
}
// POST /api/panel/agents/{id}/sshfs/{conn}/upload?path=...&size=N
func (s *Server) handleSSHFSUpload(w http.ResponseWriter, r *http.Request) {
if admin := s.currentAdmin(r); admin != nil {
s.db.Audit(admin.ID, r.PathValue("id"), "sshfs.upload:"+r.URL.Query().Get("path"))
}
s.uploadStart(w, r, proto.TaskSSHFSPut, r.PathValue("conn"))
}
+211
View File
@@ -0,0 +1,211 @@
package server
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"os"
"strconv"
"strings"
"sync"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// Saved SSH hosts: each user keeps their own list of hosts with the way in -
// password or key - so the SSH dialog connects with one choice. The secret is
// sealed with AES-256-GCM under the server's key, which lives apart from the
// database (LPRA_SECRET_KEY, or a file beside the database made on first
// use), and bound to its owner: a row read out of the database, or moved to
// another user, opens to nobody.
type savedHost struct {
ID int64 `json:"id"`
Label string `json:"label"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Auth string `json:"auth"` // password, key or agent
}
type sshSecret struct {
Password string `json:"password,omitempty"`
Key string `json:"key,omitempty"`
Passphrase string `json:"passphrase,omitempty"`
}
var (
secretKeyOnce sync.Once
secretKey []byte
secretKeyErr error
)
// sealKey is the server's key for saved secrets.
func (s *Server) sealKey() ([]byte, error) {
secretKeyOnce.Do(func() {
if v := strings.TrimSpace(os.Getenv("LPRA_SECRET_KEY")); v != "" {
k, err := hex.DecodeString(v)
if err != nil || len(k) != 32 {
k, err = base64.StdEncoding.DecodeString(v)
}
if err != nil || len(k) != 32 {
secretKeyErr = errors.New("LPRA_SECRET_KEY: нужны 32 байта (hex или base64)")
return
}
secretKey = k
return
}
path := s.db.path + ".key"
if b, err := os.ReadFile(path); err == nil {
k, err := hex.DecodeString(strings.TrimSpace(string(b)))
if err != nil || len(k) != 32 {
secretKeyErr = errors.New(path + ": испорчен ключ")
return
}
secretKey = k
return
}
k := make([]byte, 32)
_, _ = rand.Read(k)
if err := os.WriteFile(path, []byte(hex.EncodeToString(k)+"\n"), 0o600); err != nil {
secretKeyErr = err
return
}
secretKey = k
})
return secretKey, secretKeyErr
}
func (s *Server) seal(owner int64, plain []byte) (string, error) {
k, err := s.sealKey()
if err != nil {
return "", err
}
blk, _ := aes.NewCipher(k)
g, _ := cipher.NewGCM(blk)
nonce := make([]byte, g.NonceSize())
_, _ = rand.Read(nonce)
out := g.Seal(nonce, nonce, plain, []byte("ssh:"+strconv.FormatInt(owner, 10)))
return base64.StdEncoding.EncodeToString(out), nil
}
func (s *Server) open(owner int64, sealed string) ([]byte, error) {
k, err := s.sealKey()
if err != nil {
return nil, err
}
b, err := base64.StdEncoding.DecodeString(sealed)
blk, _ := aes.NewCipher(k)
g, _ := cipher.NewGCM(blk)
if err != nil || len(b) < g.NonceSize() {
return nil, errors.New("испорчена запись")
}
return g.Open(nil, b[:g.NonceSize()], b[g.NonceSize():], []byte("ssh:"+strconv.FormatInt(owner, 10)))
}
func (s *Server) saveSSHHost(owner int64, h savedHost, sec sshSecret) (int64, error) {
plain, _ := json.Marshal(sec)
sealed, err := s.seal(owner, plain)
if err != nil {
return 0, err
}
return s.db.sql.insert(`INSERT INTO ssh_hosts (admin_id, label, host, port, login, auth, secret) VALUES (?, ?, ?, ?, ?, ?, ?)`,
owner, h.Label, h.Host, h.Port, h.User, h.Auth, sealed)
}
func (s *Server) listSSHHosts(owner int64) []savedHost {
rows, err := s.db.sql.Query(`SELECT id, label, host, port, login, auth FROM ssh_hosts WHERE admin_id = ? ORDER BY label, host`, owner)
list := []savedHost{}
if err != nil {
return list
}
defer rows.Close()
for rows.Next() {
var h savedHost
if rows.Scan(&h.ID, &h.Label, &h.Host, &h.Port, &h.User, &h.Auth) == nil {
list = append(list, h)
}
}
return list
}
// fillSaved puts the saved host t.Saved of owner - address and way in - into
// t, which then goes on as if typed in.
func (s *Server) fillSaved(owner int64, t *proto.SSHTarget) error {
if t == nil || t.Saved == 0 {
return nil
}
var h savedHost
var sealed string
err := s.db.sql.QueryRow(`SELECT host, port, login, auth, secret FROM ssh_hosts WHERE id = ? AND admin_id = ?`, t.Saved, owner).
Scan(&h.Host, &h.Port, &h.User, &h.Auth, &sealed)
if err != nil {
return errors.New("сохранённый хост не найден")
}
plain, err := s.open(owner, sealed)
if err != nil {
return errors.New("сохранённый хост не расшифровался")
}
var sec sshSecret
_ = json.Unmarshal(plain, &sec)
*t = proto.SSHTarget{Host: h.Host, Port: h.Port, User: h.User, Password: sec.Password, Key: sec.Key, Passphrase: sec.Passphrase}
return nil
}
// GET /api/panel/ssh/hosts - the user's saved hosts, without secrets.
func (s *Server) handleSSHHosts(w http.ResponseWriter, r *http.Request) {
writeJSON(w, s.listSSHHosts(s.currentAdmin(r).ID))
}
// POST /api/panel/ssh/hosts - save a host: {label, host, port, user, auth,
// password | key, passphrase}.
func (s *Server) handleSSHHostAdd(w http.ResponseWriter, r *http.Request) {
var in struct {
savedHost
sshSecret
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil {
writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"})
return
}
h := in.savedHost
h.Host, h.User, h.Label = strings.TrimSpace(h.Host), strings.TrimSpace(h.User), strings.TrimSpace(h.Label)
if h.Port == 0 {
h.Port = 22
}
if h.Host == "" || h.User == "" || h.Port < 1 || h.Port > 65535 || strings.ContainsAny(h.Host+h.User, " \t\r\n/") {
writeJSON(w, map[string]any{"ok": false, "error": "нужны хост, порт и пользователь"})
return
}
if h.Label == "" {
h.Label = h.User + "@" + h.Host
}
sec := in.sshSecret
switch h.Auth {
case "key":
sec.Password = ""
case "agent":
sec = sshSecret{}
default:
h.Auth = "password"
sec.Key, sec.Passphrase = "", ""
}
id, err := s.saveSSHHost(s.currentAdmin(r).ID, h, sec)
if err != nil {
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
return
}
writeJSON(w, map[string]any{"ok": true, "id": id})
}
// POST /api/panel/ssh/hosts/{hid}/delete
func (s *Server) handleSSHHostDelete(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("hid"), 10, 64)
_, _ = s.db.sql.Exec(`DELETE FROM ssh_hosts WHERE id = ? AND admin_id = ?`, id, s.currentAdmin(r).ID)
writeJSON(w, map[string]any{"ok": true})
}
+54
View File
@@ -0,0 +1,54 @@
package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/x-grk/longpolling-remote-access/internal/proto"
)
// A saved host's secret is stored encrypted, and only its owner gets it back.
func TestSavedSSHHosts(t *testing.T) {
s, _ := newTestServer(t)
a, _ := s.db.AdminByUsername("adm")
id, err := s.saveSSHHost(a.ID, savedHost{Label: "web", Host: "h", Port: 22, User: "root", Auth: "password"}, sshSecret{Password: "s3cret-pass"})
if err != nil {
t.Fatal(err)
}
var raw string
_ = s.db.sql.QueryRow(`SELECT secret FROM ssh_hosts WHERE id = ?`, id).Scan(&raw)
if raw == "" || strings.Contains(raw, "s3cret-pass") {
t.Fatalf("secret stored as %q", raw)
}
got := &proto.SSHTarget{Saved: id}
if err := s.fillSaved(a.ID, got); err != nil || got.Password != "s3cret-pass" || got.Host != "h" || got.User != "root" || got.Saved != 0 {
t.Fatalf("own: %+v %v", got, err)
}
if err := s.fillSaved(a.ID+1, &proto.SSHTarget{Saved: id}); err == nil {
t.Fatal("another user got the host")
}
if list := s.listSSHHosts(a.ID); len(list) != 1 || list[0].Label != "web" {
t.Fatalf("list: %+v", list)
}
if len(s.listSSHHosts(a.ID+1)) != 0 {
t.Fatal("another user sees the host")
}
}
// The routes: a saved host shows without its secret.
func TestSavedSSHHostsRoute(t *testing.T) {
s, c := newTestServer(t)
req := httptest.NewRequest(http.MethodPost, "/api/panel/ssh/hosts", strings.NewReader(`{"label":"db","host":"h2","user":"u","auth":"password","password":"pw-xyz"}`))
req.AddCookie(c)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
if !strings.Contains(w.Body.String(), `"ok":true`) {
t.Fatalf("save: %s", w.Body.String())
}
w = get(t, s, c, "/api/panel/ssh/hosts")
if !strings.Contains(w.Body.String(), `"h2"`) || strings.Contains(w.Body.String(), "pw-xyz") {
t.Fatalf("list: %s", w.Body.String())
}
}
+23
View File
@@ -0,0 +1,23 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
"testing/fstest"
)
func TestStaticHidesTests(t *testing.T) {
fsys := fstest.MapFS{
"wsutil.js": {Data: []byte("x")},
"wsutil.test.js": {Data: []byte("y")},
}
h := staticHandler(fsys, map[string]string{})
for path, want := range map[string]int{"/wsutil.js": 200, "/wsutil.test.js": 404} {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
if w.Code != want {
t.Errorf("%s: got %d, want %d", path, w.Code, want)
}
}
}
+34
View File
@@ -0,0 +1,34 @@
package server
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// A terminal opened with a folder asks the agent to start there; a folder
// that is not an absolute path is left out (the shell starts at home).
func TestPanelOpenDir(t *testing.T) {
s, cookie := newTestServer(t)
for body, want := range map[string]string{
`{"dir":"/srv/my app"}`: "/srv/my app",
`{"dir":"relative"}`: "",
``: "",
} {
req := httptest.NewRequest(http.MethodPost, "/api/panel/term/x1/open", strings.NewReader(body))
req.AddCookie(cookie)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("%s: code %d %s", body, w.Code, w.Body)
}
s.hub.mu.Lock()
jobs := s.hub.jobs["x1"]
s.hub.jobs["x1"] = nil
s.hub.mu.Unlock()
if len(jobs) != 1 || jobs[0].Dir != want {
t.Fatalf("%s: jobs %+v, want dir %q", body, jobs, want)
}
}
}
+732
View File
@@ -0,0 +1,732 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=3.0, user-scalable=1;">
<meta name="referrer" content="origin">
<meta name="HandheldFriendly" content="true" />
<meta name="robots" content="noindex, nofollow" />
<title>golang context cancel at DuckDuckGo</title>
<link title="DuckDuckGo (Lite)" type="application/opensearchdescription+xml" rel="search" href="//duckduckgo.com/opensearch_lite_v2.xml">
<link href="//duckduckgo.com/favicon.ico" rel="shortcut icon" />
<link rel="icon" href="//duckduckgo.com/favicon.ico" type="image/x-icon" />
<link id="icon60" rel="apple-touch-icon" href="//duckduckgo.com/assets/icons/meta/DDG-iOS-icon_60x60.png?v=2"/>
<link id="icon76" rel="apple-touch-icon" sizes="76x76" href="//duckduckgo.com/assets/icons/meta/DDG-iOS-icon_76x76.png?v=2"/>
<link id="icon120" rel="apple-touch-icon" sizes="120x120" href="//duckduckgo.com/assets/icons/meta/DDG-iOS-icon_120x120.png?v=2"/>
<link id="icon152" rel="apple-touch-icon" sizes="152x152" href="//duckduckgo.com/assets/icons/meta/DDG-iOS-icon_152x152.png?v=2"/>
<link rel="image_src" href="//duckduckgo.com/assets/icons/meta/DDG-icon_256x256.png">
<link rel="stylesheet" media="handheld, all" href="//duckduckgo.com/dist/lr.48ddfe4eadf6a534e93f.css" type="text/css"/>
</head>
<body>
<p class='extra'>&nbsp;</p>
<div class="header">
DuckDuckGo
</div>
<p class='extra'>&nbsp;</p>
<form action="/lite/" method="post">
<input class='query' type="text" size="40" name="q" value="golang context cancel">
<input class='submit' type="submit" value="Search">
<div class="filters">
<select class="submit" name="kl">
<option value="" >All Regions</option>
<option value="ar-es" >Argentina</option>
<option value="au-en" >Australia</option>
<option value="at-de" >Austria</option>
<option value="be-fr" >Belgium (fr)</option>
<option value="be-nl" >Belgium (nl)</option>
<option value="br-pt" >Brazil</option>
<option value="bg-bg" >Bulgaria</option>
<option value="ca-en" >Canada (en)</option>
<option value="ca-fr" >Canada (fr)</option>
<option value="ct-ca" >Catalonia</option>
<option value="cl-es" >Chile</option>
<option value="cn-zh" >China</option>
<option value="co-es" >Colombia</option>
<option value="hr-hr" >Croatia</option>
<option value="cz-cs" >Czech Republic</option>
<option value="dk-da" >Denmark</option>
<option value="ee-et" >Estonia</option>
<option value="fi-fi" >Finland</option>
<option value="fr-fr" >France</option>
<option value="de-de" >Germany</option>
<option value="gr-el" >Greece</option>
<option value="hk-tzh" >Hong Kong</option>
<option value="hu-hu" >Hungary</option>
<option value="is-is" >Iceland</option>
<option value="in-en" >India (en)</option>
<option value="id-en" >Indonesia (en)</option>
<option value="ie-en" >Ireland</option>
<option value="il-en" >Israel (en)</option>
<option value="it-it" >Italy</option>
<option value="jp-jp" >Japan</option>
<option value="kr-kr" >Korea</option>
<option value="lv-lv" >Latvia</option>
<option value="lt-lt" >Lithuania</option>
<option value="my-en" >Malaysia (en)</option>
<option value="mx-es" >Mexico</option>
<option value="nl-nl" >Netherlands</option>
<option value="nz-en" >New Zealand</option>
<option value="no-no" >Norway</option>
<option value="pk-en" >Pakistan (en)</option>
<option value="pe-es" >Peru</option>
<option value="ph-en" >Philippines (en)</option>
<option value="pl-pl" >Poland</option>
<option value="pt-pt" >Portugal</option>
<option value="ro-ro" >Romania</option>
<option value="ru-ru" >Russia</option>
<option value="xa-ar" >Saudi Arabia</option>
<option value="sg-en" >Singapore</option>
<option value="sk-sk" >Slovakia</option>
<option value="sl-sl" >Slovenia</option>
<option value="za-en" >South Africa</option>
<option value="es-ca" >Spain (ca)</option>
<option value="es-es" >Spain (es)</option>
<option value="se-sv" >Sweden</option>
<option value="ch-de" >Switzerland (de)</option>
<option value="ch-fr" >Switzerland (fr)</option>
<option value="tw-tzh" >Taiwan</option>
<option value="th-en" >Thailand (en)</option>
<option value="tr-tr" >Turkey</option>
<option value="us-en" >US (English)</option>
<option value="us-es" >US (Spanish)</option>
<option value="ua-uk" >Ukraine</option>
<option value="uk-en" >United Kingdom</option>
<option value="vn-en" >Vietnam (en)</option>
</select>
<select class="submit" name="df">
<option value="" selected>Any Time</option>
<option value="d" >Past Day</option>
<option value="w" >Past Week</option>
<option value="m" >Past Month</option>
<option value="y" >Past Year</option>
</select>
</div>
</form>
<p class="extra">&nbsp;</p>
<table border="0">
<tr>
<td>
</td>
<td>
<!-- Next Page Button Sub-template -->
<form class="next_form" action="/lite/" method="post">
<input type="submit" class='navbutton' value="Next Page &gt;">
<input type="hidden" name="q" value="golang context cancel">
<input type="hidden" name="s" value="10">
<input type="hidden" name="nextParams" value="">
<input type="hidden" name="v" value="l">
<input type="hidden" name="o" value="json">
<input type="hidden" name="dc" value="11">
<input type="hidden" name="api" value="d.js">
<input type="hidden" name="vqd" value="4-288446547072343849888557955747865435943">
&nbsp;&nbsp;&nbsp;&nbsp;
<input name="kl" value="wt-wt" type="hidden">
</form>
</td>
</tr>
</table>
<p class='extra'>&nbsp;</p>
<table border="0">
</table>
<table border="0">
<!-- Web results are present -->
<tr>
<td valign="top">
1.&nbsp;
</td>
<td>
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fgo.dev%2Fdoc%2Fdatabase%2Fcancel%2Doperations&amp;rut=114b1aa13c9158c8c2b077ccd5f890f8a940277c1fc938e7bdcaad636c444a06" class='result-link'>Canceling in-progress operations - The Go Programming Language</a>
</td>
</tr>
<!-- Only show abstract separately if there's a click URL (not EOF) -->
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td class='result-snippet'>
Learn how to use <b>context</b>.<b>Context</b> to manage and <b>cancel</b> database operations in Go. See examples of how to set a timeout, deadline, and propagate cancellation requests with <b>context</b>.
</td>
</tr>
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td>
<span class='link-text'>go.dev/doc/database/cancel-operations</span>
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td>&nbsp;</td>
</tr>
<tr>
<td valign="top">
2.&nbsp;
</td>
<td>
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fgolangtutorial.com%2Fgolang%2Dcontext%2F&amp;rut=c8e3cbb5601e47ba9c55f0ad6b52790e09acc1c0d3caf33c676b68ee65a1f419" class='result-link'>Go Context Tutorial: Cancellation and Deadlines</a>
</td>
</tr>
<!-- Only show abstract separately if there's a click URL (not EOF) -->
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td class='result-snippet'>
Learn Go <b>context</b> with practical cancellation, timeout, HTTP request, and goroutine examples.
</td>
</tr>
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td>
<span class='link-text'>golangtutorial.com/golang-context/</span>
&nbsp;&nbsp;&nbsp;
<span class='timestamp'>2026-07-29T00:00:00.0000000</span>
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td>&nbsp;</td>
</tr>
<tr>
<td valign="top">
3.&nbsp;
</td>
<td>
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fstackoverflow.com%2Fquestions%2F52799280%2Fcontext%2Dconfusion%2Dregarding%2Dcancellation&amp;rut=0fa1c305fa28cede62f85c49d0501954cac2eba160d5c48f155a1008367ae78c" class='result-link'>go - Context confusion regarding cancellation - Stack Overflow</a>
</td>
</tr>
<!-- Only show abstract separately if there's a click URL (not EOF) -->
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td class='result-snippet'>
Additional info #1: calling <b>cancel</b> () right after <b>context</b> initialized As per <b>context</b> documentation regarding the <b>cancel</b> () function: Canceling this <b>context</b> releases resources associated with it, so code should call <b>cancel</b> as soon as the operations running in this <b>Context</b> complete. It&#x27;s good to always call <b>cancel</b> () function right after the <b>context</b> declaration. doesn&#x27;t matter whether it&#x27;s also ...
</td>
</tr>
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td>
<span class='link-text'>stackoverflow.com/questions/52799280/context-confusion-regarding-cancellation</span>
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td>&nbsp;</td>
</tr>
<tr>
<td valign="top">
4.&nbsp;
</td>
<td>
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fpkg.go.dev%2Fcontext&amp;rut=925dd34b1fc94c2b0cb26f6e920e10aa3131c3cfc14163fd7c01b17554611b77" class='result-link'>context package - context - Go Packages</a>
</td>
</tr>
<!-- Only show abstract separately if there's a click URL (not EOF) -->
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td class='result-snippet'>
Package <b>context</b> defines the <b>Context</b> type, which carries deadlines, cancellation signals, and other request-scoped values across API boundaries and between processes.
</td>
</tr>
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td>
<span class='link-text'>pkg.go.dev/context</span>
&nbsp;&nbsp;&nbsp;
<span class='timestamp'>2026-10-08T00:00:00.0000000</span>
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td>&nbsp;</td>
</tr>
<tr>
<td valign="top">
5.&nbsp;
</td>
<td>
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fstackoverflow.com%2Fquestions%2F70042646%2Fbest%2Dpractices%2Don%2Dgo%2Dcontext%2Dcancelation%2Dfunctions&amp;rut=f3a199db4b71e24ff5a3b0d4cbf7241ee53a03312bc2e2bcadf6d9cb20aaa474" class='result-link'>Best practices on go context cancelation functions</a>
</td>
</tr>
<!-- Only show abstract separately if there's a click URL (not EOF) -->
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td class='result-snippet'>
So, is passing this cancellation function actually an issue? Are there any best practices related to the use of the <b>context</b> package and their <b>cancel</b> function?
</td>
</tr>
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td>
<span class='link-text'>stackoverflow.com/questions/70042646/best-practices-on-go-context-cancelation-functions</span>
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td>&nbsp;</td>
</tr>
<tr>
<td valign="top">
6.&nbsp;
</td>
<td>
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fgolangbot.com%2Fcontext%2Dtimeout%2Dcancellation%2F&amp;rut=dc6314fa589ab9eac3160a9b8df6a7ca4604e480917f0b99cf558d081c78ab02" class='result-link'>Golang Context - Cancellation, Timeout and Propagation | golangbot.com</a>
</td>
</tr>
<!-- Only show abstract separately if there's a click URL (not EOF) -->
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td class='result-snippet'>
Learn how to <b>cancel</b> long running tasks in Go using <b>context</b> cancellation and timeout. This tutorial also teaches how to terminate outstanding web requests which were cancelled by the client.
</td>
</tr>
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td>
<span class='link-text'>golangbot.com/context-timeout-cancellation/</span>
&nbsp;&nbsp;&nbsp;
<span class='timestamp'>2026-07-08T00:00:00.0000000</span>
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td>&nbsp;</td>
</tr>
<tr>
<td valign="top">
7.&nbsp;
</td>
<td>
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fgo.dev%2Fblog%2Fcontext&amp;rut=6d324e0f7c589513830578b05d2a0a5ccedf509c8d4181ac590b3a229c8b9057" class='result-link'>Go Concurrency Patterns: Context - The Go Programming Language</a>
</td>
</tr>
<!-- Only show abstract separately if there's a click URL (not EOF) -->
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td class='result-snippet'>
Learn how to use the <b>context</b> package to pass request-scoped values, cancellation signals, and deadlines across API boundaries in Go servers. See a complete example of an HTTP server that handles Google Web Search requests with <b>context</b>.
</td>
</tr>
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td>
<span class='link-text'>go.dev/blog/context</span>
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td>&nbsp;</td>
</tr>
<tr>
<td valign="top">
8.&nbsp;
</td>
<td>
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.sohamkamani.com%2Fgolang%2Fcontext%2F&amp;rut=7274980ce55c2a5b88f9299c12e1228a7ecfffbb5fabbab8a837cbad990a1a87" class='result-link'>How to Use Context in Golang (Deadlines, Cancellation, and Passing Values)</a>
</td>
</tr>
<!-- Only show abstract separately if there's a click URL (not EOF) -->
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td class='result-snippet'>
In this post, we&#x27;ll learn about Go&#x27;s <b>context</b> package, and more specifically, how we can use it to improve our applications performance. The main purpose of using <b>Context</b> is to manage long-running processes (like an HTTP request, a database call, or a network call) in a way that doesn&#x27;t waste resources. If used correctly, the <b>context</b> package can help you: <b>Cancel</b> long running processes ...
</td>
</tr>
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td>
<span class='link-text'>www.sohamkamani.com/golang/context/</span>
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td>&nbsp;</td>
</tr>
<tr>
<td valign="top">
9.&nbsp;
</td>
<td>
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fgolangdoc.github.io%2Fpkg%2F1.12%2Fcontext%2Findex.html&amp;rut=4baaf27a174aa7e8bbcc0cfe1985ad916fe3ff014eb45fc06dd68178fc84e261" class='result-link'>context - The Go Programming Language</a>
</td>
</tr>
<!-- Only show abstract separately if there's a click URL (not EOF) -->
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td class='result-snippet'>
The returned <b>context&#x27;s</b> Done channel is closed when the deadline expires, when the returned <b>cancel</b> function is called, or when the parent <b>context&#x27;s</b> Done channel is closed, whichever happens first.
</td>
</tr>
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td>
<span class='link-text'>golangdoc.github.io/pkg/1.12/context/index.html</span>
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td>&nbsp;</td>
</tr>
<tr>
<td valign="top">
10.&nbsp;
</td>
<td>
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.golinuxcloud.com%2Fgolang%2Dcontext%2F&amp;rut=efdde246db353602fc52ef95d8d09a146270aeb46114aa061f320d037adea574" class='result-link'>Golang context — WithCancel, WithTimeout, WithDeadline, Background</a>
</td>
</tr>
<!-- Only show abstract separately if there's a click URL (not EOF) -->
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td class='result-snippet'>
The <b>golang</b> <b>context</b> package ties cancellation, timeouts, and optional request values to a <b>Context</b> value you pass through your call stack. Use Background (or TODO temporarily) as the root, WithTimeout / WithDeadline for time bounds, WithCancel for explicit shutdown, and WithValue only for small, request-scoped metadata—always defer <b>cancel</b> ...
</td>
</tr>
<tr>
<td>&nbsp;&nbsp;&nbsp;</td>
<td>
<span class='link-text'>www.golinuxcloud.com/golang-context/</span>
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td>&nbsp;</td>
</tr>
<tr>
<td colspan=2>
<!-- Next Page Button Sub-template -->
<form class="next_form" action="/lite/" method="post">
<input type="submit" class='navbutton' value="Next Page &gt;">
<input type="hidden" name="q" value="golang context cancel">
<input type="hidden" name="s" value="10">
<input type="hidden" name="nextParams" value="">
<input type="hidden" name="v" value="l">
<input type="hidden" name="o" value="json">
<input type="hidden" name="dc" value="11">
<input type="hidden" name="api" value="d.js">
<input type="hidden" name="vqd" value="4-288446547072343849888557955747865435943">
&nbsp;&nbsp;&nbsp;&nbsp;
<input name="kl" value="wt-wt" type="hidden">
</form>
</td>
</tr>
</table>
<p class='extra'>&nbsp;</p>
<form action="/lite/" method="post">
<input class='query' type="text" size="40" name="q" value="golang context cancel" >
<input class='submit' type="submit" value="Search">
<input name="kl" value="wt-wt" type="hidden">
</form>
<p class='extra'>&nbsp;</p>
<img src="//duckduckgo.com/t/sl_l"/>
<div id='end-spacer'>&nbsp;</div>
</body>
</html>
+44 -14
View File
@@ -32,15 +32,12 @@
'<div class="dlg-h"><strong style="flex:1">Аккаунт</strong>' + '<div class="dlg-h"><strong style="flex:1">Аккаунт</strong>' +
'<button type="button" class="btn ghost small icon close x" data-close title="Закрыть" aria-label="Закрыть">' + X + '</button></div>' + '<button type="button" class="btn ghost small icon close x" data-close title="Закрыть" aria-label="Закрыть">' + X + '</button></div>' +
'<div class="dlg-b acct">' + '<div class="dlg-b acct">' +
' <div class="acct-who"><span class="acct-ava"></span><div><div class="acct-name"></div><div class="muted acct-mode"></div></div></div>' + ' <div class="acct-who"><span class="acct-ava"></span><div class="acct-whotext"><div class="acct-name"></div><div class="muted acct-mode"></div></div>' +
' <h3>Ключи доступа</h3>' + ' <form method="post" action="/logout" class="acct-out"><input type="hidden" name="csrf"><button type="submit" class="btn ghost">Выйти</button></form></div>' +
' <p class="muted acct-hint">Вход отпечатком, лицом, PIN-кодом устройства или аппаратным ключом. Ключей может быть несколько - например, телефон и ноутбук.</p>' +
' <ul class="acct-keys"></ul>' +
' <div class="acct-add"><input type="text" class="acct-keyname" maxlength="60" placeholder="название, например «телефон»">' +
' <button type="button" class="acct-addbtn">Добавить ключ</button></div>' +
' <p class="acct-msg"></p>' +
' <label class="acct-pw"><span><b>Вход по паролю</b><small class="muted acct-pwnote"></small></span>' + ' <label class="acct-pw"><span><b>Вход по паролю</b><small class="muted acct-pwnote"></small></span>' +
' <input type="checkbox" class="sw" role="switch"></label>' + ' <input type="checkbox" class="sw" role="switch"></label>' +
' <label class="acct-pw acct-ai"><span><b>ИИ-ассистент</b><small class="muted">кнопка чата в правом нижнем углу</small></span>' +
' <input type="checkbox" class="sw" role="switch"></label>' +
' <details class="acct-chpw"><summary>Сменить пароль</summary>' + ' <details class="acct-chpw"><summary>Сменить пароль</summary>' +
' <form class="acct-pwform" autocomplete="on">' + ' <form class="acct-pwform" autocomplete="on">' +
' <input type="text" name="username" autocomplete="username" hidden>' + ' <input type="text" name="username" autocomplete="username" hidden>' +
@@ -59,12 +56,37 @@
' <option value="0">каждый раз</option><option value="5">5 минут</option><option value="15">15 минут</option>' + ' <option value="0">каждый раз</option><option value="5">5 минут</option><option value="15">15 минут</option>' +
' <option value="30">30 минут</option><option value="60">60 минут</option></select></label>' + ' <option value="30">30 минут</option><option value="60">60 минут</option></select></label>' +
' <p class="acct-pinmsg" aria-live="polite"></p><p class="muted" style="font-size:12px;margin:8px 0 0">Спрашивается в каждой новой вкладке; при перезагрузке той же вкладки - нет.</p></details>' + ' <p class="acct-pinmsg" aria-live="polite"></p><p class="muted" style="font-size:12px;margin:8px 0 0">Спрашивается в каждой новой вкладке; при перезагрузке той же вкладки - нет.</p></details>' +
' <form method="post" action="/logout" class="acct-out"><input type="hidden" name="csrf"><button type="submit" class="btn ghost">Выйти</button></form>' + ' <details class="acct-view"><summary>Вид</summary><div class="acct-viewin">' +
' <label><span>Значки вкладок</span><select data-pref="tabIcons"><option value="">слева</option><option value="right">справа</option><option value="hide">отключены</option></select></label>' +
' <label><span>Шрифт терминала</span><select data-pref="termFont" data-num><option value="0">обычный</option><option value="12">12</option><option value="13">13</option><option value="14">14</option><option value="15">15</option><option value="16">16</option><option value="18">18</option><option value="20">20</option></select></label>' +
' <label><span>Списки файлов</span><select data-pref="compact" data-bool><option value="">обычные</option><option value="1">компактные</option></select></label>' +
' <label><span>Время изменения файлов</span><select data-pref="fileTime"><option value="">показывать</option><option value="hide">скрыть</option></select></label>' +
' <label><span>Анимации</span><select data-pref="motion"><option value="">включены</option><option value="off">выключены</option></select></label>' +
' <label><span>Выбор модели в чате</span><select data-pref="aiModelPick"><option value="">когда моделей несколько</option><option value="always">всегда</option></select></label>' +
' <label><span>«Здесь» в меню «+»</span><select data-pref="openHere"><option value="">в папке активной вкладки</option><option value="off">в домашней папке</option></select></label>' +
' </div></details>' +
' <div class="acct-keysec">' +
' <h3>Ключи доступа</h3>' +
' <p class="muted acct-hint">Вход отпечатком, лицом, PIN-кодом устройства или аппаратным ключом. Ключей может быть несколько - например, телефон и ноутбук.</p>' +
' <ul class="acct-keys"></ul>' +
' <div class="acct-add"><input type="text" class="acct-keyname" maxlength="60" placeholder="название, например «телефон»">' +
' <button type="button" class="acct-addbtn">Добавить ключ</button></div>' +
' <p class="acct-msg"></p>' +
' </div>' +
'</div>'; '</div>';
document.body.appendChild(dlg); document.body.appendChild(dlg);
dlg.querySelector('.acct-out [name=csrf]').value = decodeURIComponent(csrf); dlg.querySelector('.acct-out [name=csrf]').value = decodeURIComponent(csrf);
var $ = function (sel) { return dlg.querySelector(sel); }; var $ = function (sel) { return dlg.querySelector(sel); };
$('[data-close]').onclick = function () { dlg.close(); }; $('[data-close]').onclick = function () { dlg.close(); };
// Вид: each change applies at once and is kept on the server.
dlg.querySelectorAll('[data-pref]').forEach(function (sel) {
var k = sel.dataset.pref, cur = window.Prefs ? Prefs.get()[k] : '';
sel.value = sel.hasAttribute('data-bool') ? (cur ? '1' : '') : String(cur || (sel.hasAttribute('data-num') ? 0 : ''));
sel.onchange = function () {
var v = sel.hasAttribute('data-bool') ? sel.value === '1' : sel.hasAttribute('data-num') ? Number(sel.value) : sel.value;
if (window.Prefs) Prefs.set(k, v).then(function (ok) { if (!ok) msg('не сохранилось', true); });
};
});
var supported = !!(window.PublicKeyCredential && navigator.credentials && window.isSecureContext); var supported = !!(window.PublicKeyCredential && navigator.credentials && window.isSecureContext);
function msg(text, bad) { function msg(text, bad) {
@@ -97,9 +119,9 @@
li.innerHTML = '<span class="acct-kicon">' + KEY + '</span><div class="acct-kinfo"><div>' + esc(k.name) + '</div>' + li.innerHTML = '<span class="acct-kicon">' + KEY + '</span><div class="acct-kinfo"><div>' + esc(k.name) + '</div>' +
'<div class="muted">добавлен ' + esc(when(k.created)) + ' · вход: ' + esc(when(k.last_used)) + '</div></div>' + '<div class="muted">добавлен ' + esc(when(k.created)) + ' · вход: ' + esc(when(k.last_used)) + '</div></div>' +
'<button type="button" class="btn danger small" title="Удалить ключ">Удалить</button>'; '<button type="button" class="btn danger small" title="Удалить ключ">Удалить</button>';
li.querySelector('button').onclick = function () { li.querySelector('button').onclick = async function () {
var last = d.keys.length === 1; var last = d.keys.length === 1;
if (!confirm('Удалить ключ «' + k.name + '»?' + (last && d.password_off ? ' Это последний ключ: вход по паролю снова включится.' : ''))) return; if (!await UI.confirm('Удалить ключ «' + k.name + '»?' + (last && d.password_off ? ' Это последний ключ: вход по паролю снова включится.' : ''), {ok: 'Удалить', danger: true})) return;
fetch('/api/panel/account/keys/' + k.id + '/delete', {method: 'POST'}).then(function (r) { return r.json(); }) fetch('/api/panel/account/keys/' + k.id + '/delete', {method: 'POST'}).then(function (r) { return r.json(); })
.then(function (r) { if (!r.ok) msg(r.error, true); else { msg('ключ удалён'); load(); } }); .then(function (r) { if (!r.ok) msg(r.error, true); else { msg('ключ удалён'); load(); } });
}; };
@@ -188,12 +210,20 @@
} catch (e) { pinMsg('браузер не даёт сохранить настройку', true); return; } } catch (e) { pinMsg('браузер не даёт сохранить настройку', true); return; }
pinMsg(min ? 'на этом устройстве PIN не спросят ' + min + ' мин после ввода' : 'PIN спрашивается в каждой новой вкладке'); pinMsg(min ? 'на этом устройстве PIN не спросят ' + min + ' мин после ввода' : 'PIN спрашивается в каждой новой вкладке');
}; };
$('.acct-pindel').onclick = function () { $('.acct-pindel').onclick = async function () {
if (confirm('Убрать PIN-код?')) savePin(''); if (await UI.confirm('Убрать PIN-код?', {ok: 'Убрать', danger: true})) savePin('');
}; };
$('.acct-pw .sw').onchange = function (ev) { // The assistant: each user's own switch; the chat button follows it.
fetch('/api/panel/ai', {cache: 'no-store'}).then(function (r) { return r.json(); }).then(function (d) { $('.acct-ai .sw').checked = !!d.on; }).catch(function () {});
$('.acct-ai .sw').onchange = function (ev) {
var on = ev.target.checked;
fetch('/api/panel/ai/toggle', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({on: on})})
.then(function (r) { return r.json(); })
.then(function (r) { if (!r.ok) { ev.target.checked = !on; msg(r.error, true); } else document.dispatchEvent(new CustomEvent('lpra:ai', {detail: on})); });
};
$('.acct-pw .sw').onchange = async function (ev) {
const off = !ev.target.checked; const off = !ev.target.checked;
if (off && !confirm('Выключить вход по паролю? Войти можно будет только ключом доступа. Если ключи потеряются, их сможет сбросить другой администратор в «Настройках».')) { if (off && !await UI.confirm('Выключить вход по паролю? Войти можно будет только ключом доступа. Если ключи потеряются, их сможет сбросить другой администратор в «Настройках».', {ok: 'Выключить', danger: true})) {
ev.target.checked = true; ev.target.checked = true;
return; return;
} }
+899
View File
@@ -0,0 +1,899 @@
// The assistant: a round button in the bottom right corner opens a chat. Each
// question goes with what the panel shows - the tabs, the active pane, the
// text of its terminal or its list of files - and the answer streams in.
// Commands the model suggests come as ```run``` blocks with «Выполнить»: a
// click sends the command to the active terminal, with the user's rights.
// On by default; the user can turn it off in the profile.
(function () {
'use strict';
const PLANE = '<svg class="ai-plane" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M3.4 20.4 20.85 12.9a1 1 0 0 0 0-1.8L3.4 3.6a1 1 0 0 0-1.39 1.17L4 11l9 1-9 1-1.99 6.23a1 1 0 0 0 1.39 1.17z"/></svg>';
const STOP = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="5" y="5" width="14" height="14" rx="2"/></svg>';
const esc = v => String(v).replace(/[&<>"]/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c]));
const fab = document.createElement('button');
fab.type = 'button';
fab.className = 'ai-fab';
fab.hidden = true;
fab.title = 'ИИ-ассистент';
fab.setAttribute('aria-label', 'ИИ-ассистент');
fab.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M10 2.5c.3 0 .5.2.6.5l1 3.7a5 5 0 0 0 3.6 3.6l3.7 1c.6.2.6 1 0 1.2l-3.7 1a5 5 0 0 0-3.6 3.6l-1 3.7c-.2.6-1 .6-1.2 0l-1-3.7a5 5 0 0 0-3.6-3.6l-3.7-1c-.6-.2-.6-1 0-1.2l3.7-1a5 5 0 0 0 3.6-3.6l1-3.7c.1-.3.3-.5.6-.5z"/><path d="M18.5 1.5c.2 0 .3.1.4.3l.4 1.4c.2.6.7 1.1 1.3 1.3l1.4.4c.4.1.4.6 0 .8l-1.4.4c-.6.2-1.1.7-1.3 1.3l-.4 1.4c-.1.4-.7.4-.8 0l-.4-1.4a2 2 0 0 0-1.3-1.3l-1.4-.4c-.4-.2-.4-.7 0-.8l1.4-.4c.6-.2 1.1-.7 1.3-1.3l.4-1.4c.1-.2.2-.3.4-.3z" opacity=".75"/></svg>';
const box = document.createElement('div');
box.className = 'ai-box';
box.innerHTML =
'<div class="ai-head"><button type="button" class="ai-chatsel" aria-expanded="false" title="Чаты"><span class="ai-chatname">Новый чат</span>' +
'<svg class="chev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m9 6 6 6-6 6"/></svg></button>' +
'<span class="muted ai-where"></span><span class="spacer"></span>' +
'<button type="button" class="btn ghost small icon close x ai-close" aria-label="Закрыть"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>' +
'<div class="ai-chats" inert><div class="ai-chats-in"><button type="button" class="ai-chatnew">+ Новый чат</button><div class="ai-chatlist"></div></div></div>' +
'<div class="ai-chat-body"><div class="ai-list"><div class="ai-hello"><span class="ai-hello-icon"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 9a2 2 0 0 1-2 2H6l-4 4V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2z"/><path d="M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1"/></svg></span><span class="ai-hello-text">Спросите AI помощника</span></div></div>' +
'<button type="button" class="ai-jump" tabindex="-1" title="К последним сообщениям" aria-label="К последним сообщениям"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12l7 7 7-7"/></svg></button>' +
'<div class="ai-file-error" role="alert"></div></div>' +
'<div class="ai-pills">' +
'<button type="button" class="ai-pill" data-mode="agent" aria-pressed="false" title="Сам выполняет шаги один за другим, спрашивая перед каждой командой и записью файла">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2M20 14h2M9 13v2M15 13v2"/></svg>Агент</button>' +
'<button type="button" class="ai-pill" data-mode="search" aria-pressed="false" title="Поиск в интернете (DuckDuckGo): ассистент ищет сам, когда нужно">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></svg>Поиск</button>' +
'<button type="button" class="ai-pill" data-mode="think" aria-pressed="false" title="Сначала обдумывает, потом отвечает: дольше, но точнее">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.3h6c0-1 .4-1.8 1-2.3A7 7 0 0 0 12 2z"/></svg>Думать</button>' +
// The model, when there is a choice: its name; a click opens the list.
'<div class="ai-mpick" hidden><button type="button" class="ai-mbtn" aria-haspopup="listbox" aria-expanded="false" title="Модель">' +
'<span class="ai-mname"></span><svg class="ai-mchev" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></button>' +
'<div class="ai-mmenu" role="listbox" aria-label="Модель"></div></div>' +
'</div>' +
'<form class="ai-form-in"><input type="file" class="ai-file-input" multiple accept="image/*,audio/*,video/*,.txt,.md,.csv,.tsv,.json,.xml,.doc,.docx,.xlsx,.ods,.odt,.pdf,.mkv,.avi,.3gp,.tif,.tiff" hidden>' +
'<div class="ai-inputbox"><div class="ai-attachments" hidden></div><div class="ai-entry">' +
'<button type="button" class="ai-attach" title="Прикрепить файл" aria-label="Прикрепить файл"><svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.4 11.1-8.9 8.9a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5"/></svg></button>' +
'<textarea rows="1" placeholder="Сообщение…"></textarea>' +
'<button type="submit" class="ai-send" title="Отправить" aria-label="Отправить">' + PLANE + '</button></div></div></form>';
document.body.append(fab, box);
const q = s => box.querySelector(s);
const list = q('.ai-list'), input = q('textarea'), send = q('.ai-send'), jump = q('.ai-jump');
const attachInput = q('.ai-file-input'), attachList = q('.ai-attachments'), fileErr = q('.ai-file-error');
let attachments = [];
const importingPaneFiles = new Set();
const nearBottom = () => list.scrollHeight - list.scrollTop - list.clientHeight < 80;
function updateJump() { showJump(!nearBottom() && isOpen()); }
// The arrow down fades in and out, midway between the right edge the
// answers reach (their max-width, 92% of the list) and the chat's border.
function showJump(on) {
if (on && !jump.classList.contains('show')) {
const body = list.parentNode.getBoundingClientRect(), l = list.getBoundingClientRect(), cs = getComputedStyle(list);
const pl = parseFloat(cs.paddingLeft), inner = list.clientWidth - pl - parseFloat(cs.paddingRight);
const gap = body.right - (l.left + list.clientLeft + pl + inner * 0.92);
jump.style.right = Math.max(2, (gap - jump.offsetWidth) / 2) + 'px';
}
jump.classList.toggle('show', on);
jump.tabIndex = on ? 0 : -1;
}
list.addEventListener('scroll', updateJump, {passive: true});
jump.onclick = () => { list.scrollTo({top: list.scrollHeight, behavior: 'smooth'}); };
function scrollIfFollowing(follow) { if (follow) list.scrollTop = list.scrollHeight; updateJump(); }
// The talk lives on the server (the database); here only what is shown.
let loaded = false;
let ac = null;
// Open and shut with a short slide. On a phone the chat takes the screen
// and is one step of history: «назад» shuts it.
const phone = () => matchMedia('(max-width: 640px)').matches;
const isOpen = () => box.classList.contains('on');
function show() {
loadTalk();
loadState();
placeBox();
box.classList.add('on');
fab.classList.add('away');
requestAnimationFrame(updateJump);
if (phone() && !(history.state && history.state.aiChat)) history.pushState({aiChat: 1}, '', location.href);
where();
if (!phone()) input.focus();
}
function hide() {
if (!isOpen()) return;
if (history.state && history.state.aiChat) { history.back(); return; }
shut();
}
// shut: the chat goes, and the ball comes back in its corner - where the
// chat was last moved to.
function shut() {
if (desk && !phone()) {
const b = box.getBoundingClientRect(), o = box.style.transformOrigin || 'bottom right';
const right = /right/.test(o) ? innerWidth - b.right : innerWidth - b.left - fab.offsetWidth;
const bottom = /bottom/.test(o) ? innerHeight - b.bottom : innerHeight - b.top - fab.offsetHeight;
const pos = placeFab(right, bottom);
try { localStorage.setItem('lpra.ai.pos', JSON.stringify(pos)); } catch (e) { /* not kept */ }
}
box.classList.remove('on');
fab.classList.remove('away');
}
window.addEventListener('popstate', () => { if (isOpen() && !(history.state && history.state.aiChat)) shut(); });
function setOn(on) { fab.hidden = !on; if (!on) hide(); }
const desk = !matchMedia('(pointer: coarse)').matches;
// The models to choose from come with the state, again on every opening
// (an admin may have changed them).
let models = [];
const loadState = () => fetch('/api/panel/ai', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json())
.then(d => { setOn(!!d.on); models = d.models || []; drawModels(); }).catch(() => {});
loadState();
document.addEventListener('lpra:ai', ev => setOn(ev.detail));
fab.onclick = () => { if (moved) { moved = false; return; } isOpen() ? hide() : show(); };
// ---------- on a computer: the ball and the chat move ----------
// The ball goes where it is dragged and stays there (kept in this
// browser, as distances from the right and bottom edges); the chat opens
// beside it, toward the room, and moves by its title.
let moved = false;
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
function placeFab(right, bottom) {
right = clamp(right, 8, innerWidth - fab.offsetWidth - 8);
bottom = clamp(bottom, 8, innerHeight - fab.offsetHeight - 8);
fab.style.right = right + 'px'; fab.style.bottom = bottom + 'px';
return {right, bottom};
}
function placeBox() {
if (!desk || phone()) { box.style.left = box.style.top = box.style.right = box.style.bottom = ''; return; }
const f = fab.getBoundingClientRect(), w = box.offsetWidth, h = box.offsetHeight, gap = 12;
const toLeft = f.left + f.width / 2 > innerWidth / 2, up = f.top + f.height / 2 > innerHeight / 2;
// The chat takes the ball's place, grown from its corner.
const x = toLeft ? f.right - w : f.left;
const y = up ? f.bottom - h : f.top;
box.style.right = box.style.bottom = 'auto';
box.style.left = clamp(x, 8, innerWidth - w - 8) + 'px';
box.style.top = clamp(y, 8, innerHeight - h - 8) + 'px';
box.style.transformOrigin = (up ? 'bottom ' : 'top ') + (toLeft ? 'right' : 'left');
}
if (desk) {
try { const p = JSON.parse(localStorage.getItem('lpra.ai.pos') || 'null'); if (p) requestAnimationFrame(() => placeFab(p.right, p.bottom)); } catch (e) { /* default */ }
const drag = (el, ev, onMove, onEnd) => {
if (ev.button !== 0) return;
const x0 = ev.clientX, y0 = ev.clientY;
let on = false;
const mv = e => {
if (!on && Math.hypot(e.clientX - x0, e.clientY - y0) < 5) return;
on = true;
onMove(e.clientX - x0, e.clientY - y0);
};
const up = () => {
removeEventListener('pointermove', mv); removeEventListener('pointerup', up);
document.body.classList.remove('ai-dragging');
if (on) onEnd();
};
addEventListener('pointermove', mv); addEventListener('pointerup', up);
document.body.classList.add('ai-dragging');
};
fab.addEventListener('pointerdown', ev => {
const r0 = innerWidth - fab.getBoundingClientRect().right, b0 = innerHeight - fab.getBoundingClientRect().bottom;
let pos = {right: r0, bottom: b0};
drag(fab, ev, (dx, dy) => { moved = true; pos = placeFab(r0 - dx, b0 - dy); if (isOpen()) placeBox(); }, () => {
try { localStorage.setItem('lpra.ai.pos', JSON.stringify(pos)); } catch (e) { /* not kept */ }
});
});
q('.ai-head').addEventListener('pointerdown', ev => {
if (ev.target.closest('button')) return;
const r = box.getBoundingClientRect();
drag(box, ev, (dx, dy) => {
box.style.left = clamp(r.left + dx, 8, innerWidth - r.width - 8) + 'px';
box.style.top = clamp(r.top + dy, 8, innerHeight - r.height - 8) + 'px';
}, () => {});
});
addEventListener('resize', () => {
try { const p = JSON.parse(localStorage.getItem('lpra.ai.pos') || 'null'); if (p) placeFab(p.right, p.bottom); } catch (e) { /* default */ }
if (isOpen()) placeBox();
});
}
q('.ai-close').onclick = hide;
// ---------- chats ----------
// Several talks, kept on the server. The header names the current one;
// its arrow opens the list: a click opens a chat, the bin deletes it.
let chatId = 0;
try { chatId = Number(localStorage.getItem('lpra.ai.chat')) || 0; } catch (e) { /* none */ }
const TRASH = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/></svg>';
const chatsEl = q('.ai-chats'), chatSel = q('.ai-chatsel');
let chats = [];
function setChat(id) {
chatId = id || 0;
try { localStorage.setItem('lpra.ai.chat', String(chatId)); } catch (e) { /* not kept */ }
const c = chats.find(x => x.id === chatId);
q('.ai-chatname').textContent = c ? c.title : 'Новый чат';
q('.ai-chatname').title = c ? c.title : '';
}
async function loadChats() {
try { chats = await fetch('/api/panel/ai/chats', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()); } catch (e) { chats = []; }
if (chatId && !chats.some(c => c.id === chatId)) chatId = 0;
setChat(chatId);
drawChats();
}
function drawChats() {
const box = q('.ai-chatlist');
box.innerHTML = '';
if (!chats.length) box.innerHTML = '<div class="muted ai-chatnone">Пока нет чатов</div>';
chats.forEach(c => {
const row = document.createElement('div');
row.className = 'ai-chatrow' + (c.id === chatId ? ' on' : '');
row.innerHTML = '<button type="button" class="ai-chatopen"></button><button type="button" class="ai-chatdel" title="Удалить чат" aria-label="Удалить чат">' + TRASH + '</button>';
row.querySelector('.ai-chatopen').textContent = c.title;
row.querySelector('.ai-chatopen').title = c.title;
row.querySelector('.ai-chatopen').onclick = () => { openChat(c.id); toggleChats(false); };
row.querySelector('.ai-chatdel').onclick = async ev => {
ev.stopPropagation();
await fetch('/api/panel/ai/chats/' + c.id + '/delete', {method: 'POST', credentials: 'same-origin'});
if (c.id === chatId) openChat(0);
loadChats();
};
box.appendChild(row);
});
}
function toggleChats(on) {
on = on == null ? chatSel.getAttribute('aria-expanded') !== 'true' : on;
chatSel.setAttribute('aria-expanded', String(on));
chatsEl.classList.toggle('open', on);
chatsEl.inert = !on;
if (on) loadChats();
}
chatSel.onclick = () => toggleChats();
q('.ai-chatnew').onclick = () => { openChat(0); toggleChats(false); input.focus(); };
document.addEventListener('pointerdown', ev => { if (chatsEl.classList.contains('open') && !chatsEl.contains(ev.target) && !chatSel.contains(ev.target)) toggleChats(false); }, true);
// openChat shows a chat's talk (0: a new, empty one).
async function openChat(id) {
if (ac) ac.abort();
setChat(id);
drawChats();
list.querySelectorAll('.ai-msg').forEach(m => m.remove());
list.scrollTop = 0;
showJump(false);
hideFileError();
q('.ai-hello').hidden = !!id;
if (!id) { updateJump(); return; }
try {
const h = await fetch('/api/panel/ai/chats/' + id, {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json());
if (chatId !== id) return;
h.forEach(m => {
if (m.role === 'result') showResult(m.content);
else { const el = add(m.role, m.content, m.think_ms); el.querySelectorAll('.ai-act').forEach(b => { b.disabled = true; }); }
});
if (!h.length) q('.ai-hello').hidden = false;
list.scrollTop = list.scrollHeight;
updateJump();
} catch (e) { /* stays empty */ }
}
// The chat last open, when the window first opens.
async function loadTalk() {
if (loaded) return;
loaded = true;
await loadChats();
openChat(chatId);
}
// ---------- what the panel shows ----------
const W = () => window.Workspace;
function termText(p, lines) {
const b = p.term && p.term.buffer && p.term.buffer.active;
if (!b) return '';
const out = [];
for (let i = Math.max(0, b.length - lines); i < b.length; i++) {
const l = b.getLine(i);
if (l) out.push(l.translateToString(true));
}
while (out.length && !out[out.length - 1].trim()) out.pop();
return out.join('\n');
}
function paneLine(p) {
const kind = {term: 'терминал', ssh: 'SSH-терминал', files: 'файлы', sshfiles: 'файлы по SSH'}[p.kind] || p.kind;
return kind + ' · ' + (p.tooltip ? p.tooltip() : p.title()) + ' (машина ' + (p.agent ? p.agent.name : '?') + ')';
}
function context() {
const view = document.querySelector('main#app').dataset.view;
const out = ['Страница: ' + ({machines: 'Машины', terminal: 'WorkSpace', settings: 'Настройки'}[view] || view)];
out.push('Машины пользователя: ' + machines.map(m => m.name + ' (' + m.user + '@' + m.hostname + (m.online ? '' : ', офлайн') + ')').join('; '));
const ws = W();
if (!ws) return out.join('\n');
const panes = ws.panes(), act = ws.activePane();
out.push('Панели (' + panes.length + '):');
panes.forEach(p => out.push((p === act ? '* ' : '- ') + paneLine(p)));
if (act && act.term) {
out.push('', 'Активный терминал, последние строки:', '```', termText(act, 120), '```');
} else if (act && act.entries) {
out.push('', 'Активная файловая панель: ' + act.cwd + (act.selected.size ? ' · выбрано: ' + [...act.selected].join(', ') : ''));
out.push(act.entries.slice(0, 200).map(e => (e.is_dir ? e.name + '/' : e.name + ' (' + e.size + ' B)')).join('\n'));
}
return out.join('\n');
}
function where() {
const act = W() && W().activePane();
const name = act && act.agent ? act.agent.name : '';
q('.ai-where').textContent = name;
q('.ai-where').title = name;
}
// ---------- machines ----------
let machines = [];
async function loadMachines() {
try { machines = await fetch('/api/panel/agents/presence', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()); } catch (e) { /* keep */ }
return machines;
}
// machineOf: the machine an action names, else the one of the active pane.
function machineOf(name) {
const n = (name || '').trim().toLowerCase();
if (n) return machines.find(m => m.name.toLowerCase() === n || m.id === n || m.hostname.toLowerCase() === n) || null;
const act = W() && W().activePane();
if (act && act.agent) return machines.find(m => m.id === act.agent.id) || {id: act.agent.id, name: act.agent.name, online: true};
return machines.length === 1 ? machines[0] : null;
}
// ---------- actions ----------
const sleep = ms => new Promise(r => setTimeout(r, ms));
const lineAt = (b, i) => { const l = b.getLine(i); return l ? l.translateToString(true) : ''; };
// terminalFor: a terminal of machine m - the active one if it is, else any
// open one of it, else a new tab.
async function terminalFor(m) {
const ws = W(), act = ws.activePane();
if (act && act.term && !act.ssh && act.agent.id === m.id) return act;
let t = ws.panes().find(p => p.term && !p.ssh && p.agent.id === m.id);
if (!t) {
if (document.querySelector('main#app').dataset.view !== 'terminal') App.go('/workspace');
ws.open('term', {id: m.id, name: m.name, host: m.hostname, user: m.user});
t = ws.panes().find(p => p.term && !p.ssh && p.agent.id === m.id);
for (let i = 0; i < 40 && t && t.state !== 'ok'; i++) await sleep(250);
await sleep(600);
}
return t;
}
// runIn sends cmd to terminal t and gives what it printed: until the
// output has been still for 1.5 s (up to 2 minutes).
async function runIn(t, cmd, signal) {
const b = t.term.buffer.active;
const start = b.baseY + b.cursorY;
t.send(cmd.replace(/\n/g, '\r') + '\r');
let last = '', still = 0;
for (let ms = 0; ms < 120000 && !(signal && signal.aborted); ms += 300) {
await sleep(300);
const end = b.baseY + b.cursorY;
const now = end + ':' + lineAt(b, end);
if (now === last) { if ((still += 300) >= 1500) break; } else { still = 0; last = now; }
}
const lines = [];
for (let i = start; i <= b.baseY + b.cursorY; i++) lines.push(lineAt(b, i));
let out = lines.join('\n').replace(/\s+$/, '');
if (out.length > 8000) out = '…' + out.slice(-8000);
return out;
}
const b64 = text => { const u = new TextEncoder().encode(text); let s = ''; for (let i = 0; i < u.length; i++) s += String.fromCharCode(u[i]); return btoa(s); };
// perform does one action with the user's rights and tells what came of it.
async function perform(a, signal) {
// A page by its address: its text, through the server, with «Поиск» on.
if (a.kind === 'fetch') {
if (!searchOn()) return 'Ошибка: чтение страниц выключено - включите плашку «Поиск»';
const r = await fetch('/api/panel/ai/fetch', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin',
body: JSON.stringify({url: a.body})}).then(x => x.json()).catch(() => ({ok: false, error: 'нет связи'}));
if (!r.ok) return 'Ошибка чтения ' + a.body + ': ' + r.error;
const p = r.data;
return 'Страница ' + p.url + (p.title ? ' - «' + p.title + '»' : '') + (p.truncated ? ' (начало)' : '') + ':\n' + p.text;
}
// A web search: through the server, only with «Поиск» on.
if (a.kind === 'search') {
if (!searchOn()) return 'Ошибка: поиск выключен - включите плашку «Поиск»';
const r = await fetch('/api/panel/ai/search', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin',
body: JSON.stringify({q: a.body})}).then(x => x.json()).catch(() => ({ok: false, error: 'нет связи'}));
if (!r.ok) return 'Ошибка поиска «' + a.body + '»' + (r.engine ? ' (' + r.engine + ')' : '') + ': ' + r.error;
return 'Результаты поиска «' + a.body + '» через ' + (r.engine || 'поисковик') + ':\n' + r.data.map((x, i) => (i + 1) + '. ' + x.title + ' - ' + x.url + (x.snippet ? '\n ' + x.snippet : '')).join('\n');
}
await loadMachines();
const m = machineOf(a.machine);
if (!m) return 'Ошибка: нет машины «' + (a.machine || '?') + '». Машины: ' + machines.map(x => x.name).join(', ');
if (m.online === false) return 'Ошибка: машина ' + m.name + ' офлайн';
const src = new MachineSource({id: m.id, name: m.name});
if (a.kind === 'run') {
const t = await terminalFor(m);
if (!t) return 'Ошибка: терминал на ' + m.name + ' не открылся';
return 'Вывод на ' + m.name + ':\n```\n' + await runIn(t, a.body, signal) + '\n```';
}
if (a.kind === 'read') {
const r = await src.op('read', {path: a.path});
if (!r.ok) return 'Ошибка чтения ' + m.name + ':' + a.path + ': ' + r.error;
if (r.data.binary) return m.name + ':' + a.path + ' - двоичный файл, ' + r.data.size + ' B';
let text = new TextDecoder().decode(FileViews.b64ToBytes(r.data.content));
if (text.length > 20000) text = text.slice(0, 20000) + '\n…';
return 'Файл ' + m.name + ':' + a.path + (r.data.truncated ? ' (начало)' : '') + ':\n```\n' + text + '\n```';
}
if (a.kind === 'ls') {
const r = await src.op('list', {path: a.path || '~'});
if (!r.ok) return 'Ошибка: ' + r.error;
return 'Папка ' + m.name + ':' + r.data.path + ':\n' + (r.data.entries || []).slice(0, 300)
.map(e => (e.is_dir ? e.name + '/' : e.name + ' ' + e.size + ' B')).join('\n');
}
if (a.kind === 'write') {
const r = await src.op('write', {path: a.path, data: b64(a.body + '\n')});
return r.ok ? 'Записано: ' + m.name + ':' + a.path : 'Ошибка записи: ' + r.error;
}
return 'Ошибка: неизвестное действие';
}
// ask first for what changes something: commands and writes.
function needsYes(a) { return a.kind === 'run' || a.kind === 'write'; }
// ask what a command or a write is about to do: what, where, the command
// (or the start of the text) in mono.
function confirmAction(a) {
const m = machineOf(a.machine), where = m ? m.name : (a.machine || 'активная машина');
if (a.kind === 'run') return UI.confirm('Выполнить команду на {chip}?', {ok: 'Выполнить', chip: where, code: a.body});
const lines = a.body.split('\n');
return UI.confirm('Записать файл {chip}?', {ok: 'Записать', chip: where + ':' + a.path,
code: lines.slice(0, 12).join('\n') + (lines.length > 12 ? '\n…' : ''),
note: 'Файл будет заменён целиком (' + a.body.length + ' символов).'});
}
// ---------- messages ----------
// stripThink: the answer without its thinking - what is acted on and kept.
const stripThink = t => t.replace(/^\s*(?:<think>|::думаю::)[\s\S]*?((?:<\/think>|::ответ::)\s*|$)/, '');
const KIND = {run: 'команда', read: 'чтение', ls: 'папка', write: 'запись', search: 'поиск', fetch: 'страница'};
const IC = d => '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">' + d + '</svg>';
const ICONS = {run: IC('<path d="m5 7 5 5-5 5M12 17h7"/>'), read: IC('<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/>'),
ls: IC('<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>'), write: IC('<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/>'),
search: IC('<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/>'),
fetch: IC('<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>')};
const LABEL = {run: 'Выполнить', read: 'Прочитать', ls: 'Открыть', write: 'Записать', search: 'Найти', fetch: 'Открыть'};
// render: text with ``` blocks; action blocks get their button.
// render draws an answer: its thinking, folded, above; the text with
// Markdown; ``` blocks apart, action blocks with their button. The
// thinking block is made once and only its words change, so it stays open
// or shut as the user left it while the answer streams.
// The arrow of the + menu's groups: right shut, down open.
const CHEV = '<span class="chev">›</span>';
function render(el, text, done) {
let think = el.querySelector(':scope > .ai-think'), body = el.querySelector(':scope > .ai-body');
if (!body) {
el.innerHTML = '<div class="ai-think" hidden><button type="button" class="ai-think-h">' + CHEV + '<span class="lbl"></span></button>' +
'<div class="ai-think-b"><div class="ai-think-in"></div></div></div><div class="ai-body"></div>';
think = el.querySelector('.ai-think'); body = el.querySelector('.ai-body');
think.querySelector('.ai-think-h').onclick = () => think.classList.toggle('open');
}
const th = /^\s*(?:<think>|::думаю::)([\s\S]*?)(<\/think>|::ответ::|$)/.exec(text);
if (th) {
const going = !th[2] && !done;
if (!el._t0) el._t0 = Date.now();
if (!going && !el._t1) el._t1 = Date.now();
think.hidden = false;
think.classList.toggle('going', going);
// How long: timed here as it streams, or kept with the answer.
const ms = el._thinkMs || (el._t1 - el._t0);
think.querySelector('.ai-think-h .lbl').textContent = going ? 'Думаю…' : 'Думал ' + Math.max(1, Math.round(ms / 1000)) + ' сек.';
think.querySelector('.ai-think-in').innerHTML = WSUtil.md(th[1].trim());
text = text.slice(th[0].length);
}
let html = '', n = 0;
const parts = text.split(/```/);
parts.forEach((part, i) => {
if (i % 2 === 0) { if (part.trim()) html += '<div class="ai-text">' + WSUtil.md(part) + '</div>'; return; }
const m = /^([^\n]*)\n?([\s\S]*)$/.exec(part);
const head = m[1].trim(), kind = head.split(/\s+/)[0], code = m[2].replace(/\n$/, '');
const closed = i < parts.length - 1 || done;
const act = LABEL[kind] && closed;
// An action: a card with what and where on its title line, a small
// button on the right of it, the command or text below.
if (act) {
const where = head.slice(kind.length).trim();
html += '<div class="ai-code ai-action"><div class="ai-code-h"><span class="ai-kind">' + ICONS[kind] + '<b>' + KIND[kind] + '</b>' +
(where ? '<span class="ai-where-t">' + esc(where) + '</span>' : '') + '</span>' +
'<button type="button" class="ai-act" data-i="' + (n++) + '">' + LABEL[kind] + '</button></div>' +
(code ? '<pre>' + esc(code) + '</pre>' : '') + '</div>';
} else html += '<div class="ai-code"><pre>' + esc(code) + '</pre></div>';
});
body.innerHTML = html;
el.dataset.text = text;
}
const fileExt = name => (String(name).match(/\.([^.]+)$/) || [])[1]?.toLowerCase() || '';
const textExt = /^(txt|md|markdown|csv|tsv|json|xml|yaml|yml|log|ini|cfg|conf|html|htm|js|ts|css|sql|sh)$/;
const officeExt = /^(docx|xlsx|ods|odt|doc)$/;
const MEDIA_MIME = {png:'image/png',jpg:'image/jpeg',jpeg:'image/jpeg',gif:'image/gif',webp:'image/webp',bmp:'image/bmp',tif:'image/tiff',tiff:'image/tiff',mp3:'audio/mpeg',wav:'audio/wav',m4a:'audio/mp4',aac:'audio/aac',ogg:'audio/ogg',oga:'audio/ogg',flac:'audio/flac',opus:'audio/ogg',mp4:'video/mp4',m4v:'video/mp4',webm:'video/webm',mov:'video/quicktime',ogv:'video/ogg',mkv:'video/x-matroska',avi:'video/x-msvideo','3gp':'video/3gpp'};
// attachable: a file the model can take, by its name alone.
const attachable = name => { const ext = fileExt(name); return officeExt.test(ext) || textExt.test(ext) || ext === 'pdf' || ext === 'csv' || ext === 'tsv' || !!MEDIA_MIME[ext]; };
const MAX_FILE = 8 * 1024 * 1024, MAX_ATTACHMENTS = 6, MAX_TOTAL = 16 * 1024 * 1024;
function bytesToBase64(buf) {
const bytes = new Uint8Array(buf); let bin = '';
for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
return btoa(bin);
}
function previewText(html) {
return new DOMParser().parseFromString(html, 'text/html').body.textContent.replace(/\u00a0/g, ' ').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
}
function renderAttachments() {
attachList.innerHTML = '';
attachList.hidden = !attachments.length;
sendState();
attachments.forEach((a, i) => {
const chip = document.createElement('span'); chip.className = 'ai-file-chip';
const name = document.createElement('span'); name.textContent = a.name; name.title = a.name;
const remove = document.createElement('button'); remove.type = 'button'; remove.title = 'Убрать вложение'; remove.setAttribute('aria-label', 'Убрать ' + a.name); remove.textContent = '×';
remove.onclick = () => { attachments.splice(i, 1); renderAttachments(); };
chip.append(name, remove); attachList.appendChild(chip);
});
}
// A file that could not be attached: a red note over the field, like an
// error toast, fading in and out.
let fileErrTimer = 0;
function setFileError(message) {
fileErr.textContent = message;
fileErr.classList.add('show');
clearTimeout(fileErrTimer);
fileErrTimer = setTimeout(hideFileError, 5000);
}
function hideFileError() { clearTimeout(fileErrTimer); fileErr.classList.remove('show'); }
async function makeAttachments(file, name = file.name) {
const ext = fileExt(name), type = (file.type || '').toLowerCase();
if (file.size > MAX_FILE) throw new Error(name + ': максимум 8 МиБ на файл');
if (officeExt.test(ext) || ext === 'pdf' || ext === 'csv' || ext === 'tsv' || textExt.test(ext) || type.startsWith('text/')) {
const buf = await file.arrayBuffer(); let text;
if (ext === 'docx') text = previewText(await DocView.docx(buf));
else if (ext === 'pdf') text = await DocView.pdf(buf);
else if (ext === 'xlsx') text = previewText(await DocView.xlsx(buf));
else if (ext === 'ods') text = previewText(await DocView.ods(buf));
else if (ext === 'odt') text = previewText(await DocView.odt(buf));
else if (ext === 'doc') text = previewText(DocView.doc(buf));
else if (ext === 'csv' || ext === 'tsv') text = previewText(DocView.csv(new TextDecoder().decode(buf), ext === 'tsv'));
else text = new TextDecoder().decode(buf);
if (!text) throw new Error(name + ': текст не найден');
return [{name, mime: 'text/plain', text: text.slice(0, 120000), size: file.size}];
}
const fallback = MEDIA_MIME[ext] || '';
const mime = !type || type === 'application/octet-stream' ? fallback : type;
if (!/^image\//.test(mime) && !/^audio\//.test(mime) && !/^video\//.test(mime)) throw new Error(name + ': поддерживаются изображения, аудио, видео, текст и документы Word/Excel');
return [{name, mime, data: bytesToBase64(await file.arrayBuffer()), size: file.size}];
}
async function addFiles(files, sourceIdentities = []) {
for (let i = 0; i < files.length; i++) {
const file = files[i], sourceIdentity = sourceIdentities[i];
if (attachments.length >= MAX_ATTACHMENTS) { setFileError('Можно прикрепить не больше ' + MAX_ATTACHMENTS + ' файлов или кадров.'); break; }
try {
const ready = await makeAttachments(file);
const total = attachments.reduce((n, a) => n + (a.size || 0), 0) + ready.reduce((n, a) => n + (a.size || 0), 0);
if (attachments.length + ready.length > MAX_ATTACHMENTS) throw new Error('Слишком много вложений (максимум ' + MAX_ATTACHMENTS + ').');
if (total > MAX_TOTAL) throw new Error('Общий размер вложений не должен превышать 16 МиБ.');
if (sourceIdentity) ready.forEach(attachment => Object.defineProperty(attachment, 'sourceIdentity', {value: sourceIdentity}));
attachments.push(...ready); renderAttachments();
} catch (e) { setFileError(e.message || 'Не удалось прочитать файл'); }
}
}
const FILE_KIND = file => {
const ext = fileExt(file.name), mime = (file.mime || '').toLowerCase();
if (mime.startsWith('image/')) return ['image', 'Изображение'];
if (mime.startsWith('audio/')) return ['audio', 'Аудио'];
if (mime.startsWith('video/')) return ['video', 'Видео'];
if (ext === 'pdf') return ['pdf', 'PDF-документ'];
if (/^(doc|docx|odt|xls|xlsx|ods)$/.test(ext)) return ['document', 'Документ'];
return ['text', 'Текстовый файл'];
};
const FILE_ICONS = {
image: '<rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/>',
audio: '<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>',
video: '<rect x="3" y="5" width="14" height="14" rx="2"/><path d="m17 10 4-2v8l-4-2z"/>',
pdf: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h6"/>',
document: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h8"/>',
text: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M8 13h8M8 17h8"/>'
};
function renderFileCards(host, files) {
if (!files.length) return;
const group = document.createElement('div'); group.className = 'ai-history-files';
files.forEach(file => {
const [kind, label] = FILE_KIND(file);
const card = document.createElement('div'); card.className = 'ai-history-file ' + kind;
const icon = document.createElement('span'); icon.className = 'ai-history-file-icon';
icon.innerHTML = '<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' + FILE_ICONS[kind] + '</svg>';
const info = document.createElement('span'); info.className = 'ai-history-file-info';
const name = document.createElement('span'); name.className = 'ai-history-file-name'; name.textContent = file.name; name.title = file.name;
const meta = document.createElement('span'); meta.className = 'ai-history-file-meta';
const ext = fileExt(file.name).toUpperCase();
meta.textContent = label + (ext ? ' · ' + ext : '') + (file.size ? ' · ' + formatFileSize(file.size) : '');
info.append(name, meta); card.append(icon, info); group.appendChild(card);
});
host.appendChild(group);
}
function splitStoredAttachments(text) {
const re = /(^|\n\n)\[Вложение: ([^\r\n]+)\]/g, marks = [...text.matchAll(re)];
if (!marks.length) return {text, files: []};
const files = [];
marks.forEach((mark, i) => {
const start = mark.index + mark[0].length;
const end = i + 1 < marks.length ? marks[i + 1].index : text.length;
const tail = text.slice(start, end).replace(/^\n+|\n+$/g, '');
const content = /^Содержимое файла:\n([\s\S]*)$/.exec(tail);
files.push({name: mark[2], text: content ? content[1] : ''});
});
return {text: text.slice(0, marks[0].index).trimEnd(), files};
}
function add(role, text, thinkMs, files) {
q('.ai-hello').hidden = true;
const el = document.createElement('div');
el.className = 'ai-msg ' + role;
if (role === 'assistant') { el._thinkMs = thinkMs || 0; render(el, text, true); }
else if (role === 'user') {
const parsed = splitStoredAttachments(text), shownFiles = files && files.length ? files : parsed.files;
const message = files && files.length ? text : parsed.text;
if (message) { const copy = document.createElement('div'); copy.className = 'ai-user-copy'; copy.textContent = message; el.appendChild(copy); }
renderFileCards(el, shownFiles);
} else el.textContent = text;
const follow = nearBottom();
list.appendChild(el);
scrollIfFollowing(follow);
return el;
}
function formatFileSize(n) {
if (n < 1024) return n + ' Б';
if (n < 1048576) return (n / 1024).toFixed(1) + ' КБ';
return (n / 1048576).toFixed(1) + ' МБ';
}
// An action's result shows folded; it reaches the talk on the server with
// the next question (or by note, when done by hand).
function showResult(text) {
const el = add('result', '');
el.innerHTML = '<details><summary>Результат</summary><pre></pre></details>';
el.querySelector('pre').textContent = text;
}
list.addEventListener('click', async ev => {
const b = ev.target.closest('.ai-act');
if (!b || b.disabled) return;
const msg = b.closest('.ai-msg'), a = WSUtil.parseActions(msg.dataset.text || '')[Number(b.dataset.i)];
if (!a) return;
b.disabled = true;
b.textContent = '…';
const res = await perform(a);
b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово';
showResult(res);
fetch('/api/panel/ai/note', {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', body: JSON.stringify({chat: chatId, content: res})});
});
function responseError(status, contentType, body) {
const html = /text\/html/i.test(contentType) || /<!doctype\s+html|<html\b|<body\b/i.test(body);
if (html) return status === 502
? 'Сервис ИИ временно недоступен (502 Bad Gateway). Попробуйте позже.'
: 'Сервис ИИ вернул страницу ошибки (HTTP ' + status + '). Попробуйте позже.';
const detail = body.trim().slice(0, 500).replace(/^\[ошибка:\s*/i, '').replace(/\]$/, '').trim();
return detail || 'Ошибка сервиса ИИ (HTTP ' + status + ').';
}
// ask sends the new words (or an action's result) and shows the answer.
async function ask(text, res, files) {
if (text || (files && files.length)) {
add('user', text || '', 0, files || []);
}
if (res) showResult(res);
const el = add('assistant', '');
el.classList.add('wait');
// While the model starts: a typing mark, gone with the first words.
el.querySelector('.ai-body').innerHTML = '<span class="ai-typing" aria-label="печатает"><i></i><i></i><i></i></span>';
ac = new AbortController();
sendState();
let answer = '';
try {
await loadMachines();
const r = await fetch('/api/panel/ai/chat', {
method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin', signal: ac.signal,
body: JSON.stringify({message: text || '', result: res || '', context: context(), agent: agentOn(), think: thinkOn(), search: searchOn(), chat: chatId, attachments: files || [], model: modelId()})
});
// A new chat gets its id (and its name) from the answer.
const cid = Number(r.headers.get('X-Chat-Id')) || 0;
if (cid && cid !== chatId) {
setChat(cid); loadChats();
// The model names the new chat a moment later.
setTimeout(loadChats, 4000); setTimeout(loadChats, 12000);
}
if (r.redirected && new URL(r.url).pathname === '/login') { location.href = '/login'; return; }
const contentType = r.headers.get('content-type') || '';
if (!r.ok) throw new Error(responseError(r.status, contentType, await r.text()));
if (/text\/html/i.test(contentType)) throw new Error(responseError(r.status, contentType, ''));
if (!r.body) throw new Error('Ошибка сервиса ИИ (HTTP ' + r.status + ').');
const rd = r.body.getReader(), dec = new TextDecoder();
for (;;) {
const {done, value} = await rd.read();
if (done) break;
answer += dec.decode(value, {stream: true});
el.classList.remove('wait');
render(el, answer, false);
updateJump();
}
} catch (e) {
if (e.name !== 'AbortError') answer += (answer ? '\n' : '') + '[ошибка: ' + (e.message || 'нет связи') + ']';
} finally {
el.classList.remove('wait');
render(el, answer || '[нет ответа]', true);
updateJump();
}
return {el, answer: stripThink(answer)};
}
const pill = k => q('.ai-pill[data-mode="' + k + '"]');
const agentOn = () => pill('agent').getAttribute('aria-pressed') === 'true';
const thinkOn = () => pill('think').getAttribute('aria-pressed') === 'true';
const searchOn = () => pill('search').getAttribute('aria-pressed') === 'true';
// ---------- the model ----------
// Shown when there are several models, or always if the user asked for it
// (Аккаунт › Вид); the choice is the user's, kept on the server.
const mpick = q('.ai-mpick'), mbtn = q('.ai-mbtn'), mmenu = q('.ai-mmenu');
const CHECK = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
function modelId() {
const want = window.Prefs ? Prefs.get().aiModel : '';
return (models.find(m => m.id === want) || models[0] || {}).id || '';
}
function drawModels() {
const always = window.Prefs && Prefs.get().aiModelPick === 'always';
mpick.hidden = !(models.length > 1 || (always && models.length));
const cur = modelId(), m = models.find(x => x.id === cur);
// The button: the short name if there is one; the list: the full names.
q('.ai-mname').textContent = m ? (m.short || m.name) : '';
mpick.classList.toggle('short', !!(m && m.short));
mbtn.title = m ? 'Модель: ' + m.name : 'Модель';
mmenu.innerHTML = '';
models.forEach(x => {
const b = document.createElement('button');
b.type = 'button'; b.className = 'ai-mitem'; b.setAttribute('role', 'option');
b.setAttribute('aria-selected', String(x.id === cur));
b.innerHTML = '<span class="ai-mitem-name"></span><span class="ai-mcheck">' + CHECK + '</span>';
b.querySelector('.ai-mitem-name').textContent = x.name;
b.onclick = () => {
closeModels();
if (window.Prefs) Prefs.set('aiModel', x.id);
drawModels();
input.focus();
};
mmenu.appendChild(b);
});
}
function openModels() {
mpick.classList.add('open');
mbtn.setAttribute('aria-expanded', 'true');
const sel = mmenu.querySelector('[aria-selected="true"]') || mmenu.firstChild;
if (sel) sel.focus();
}
function closeModels() {
mpick.classList.remove('open');
mbtn.setAttribute('aria-expanded', 'false');
}
mbtn.onclick = () => mpick.classList.contains('open') ? closeModels() : openModels();
document.addEventListener('pointerdown', ev => { if (!mpick.contains(ev.target)) closeModels(); }, true);
mpick.addEventListener('keydown', ev => {
if (ev.key === 'Escape' && mpick.classList.contains('open')) { ev.stopPropagation(); closeModels(); mbtn.focus(); return; }
if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return;
ev.preventDefault();
if (!mpick.classList.contains('open')) { openModels(); return; }
const items = [...mmenu.children], i = items.indexOf(document.activeElement);
const n = items[(i + (ev.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length];
if (n) n.focus();
});
document.addEventListener('lpra:prefs', drawModels);
// The modes are kept in this browser.
box.querySelectorAll('.ai-pill').forEach(b => {
try { b.setAttribute('aria-pressed', String(localStorage.getItem('lpra.ai.' + b.dataset.mode) === '1')); } catch (e) { /* off */ }
b.onclick = () => {
const on = b.getAttribute('aria-pressed') !== 'true';
b.setAttribute('aria-pressed', String(on));
try { localStorage.setItem('lpra.ai.' + b.dataset.mode, on ? '1' : ''); } catch (e) { /* not kept */ }
};
});
// talkTurn: one question, and in agent mode the steps after it - the
// first action of each answer done (asking first for commands and writes)
// and its result sent back - until an answer asks for nothing.
async function talkTurn(text, files) {
let turn = await ask(text, null, files);
for (let step = 0; ac && !ac.signal.aborted && step < 15; step++) {
const a = WSUtil.parseActions(turn.answer)[0];
if (!a) break;
// Without «Агент» only searches go on by themselves, with «Поиск» on.
if (!agentOn() && !((a.kind === 'search' || a.kind === 'fetch') && searchOn())) break;
const b = turn.el.querySelector('.ai-act');
if (needsYes(a) && !await confirmAction(a)) {
if (b) { b.disabled = true; b.textContent = 'Отклонено'; }
turn = await ask(null, 'Пользователь отклонил это действие. Предложи другое или закончи.');
continue;
}
if (b) { b.disabled = true; b.textContent = '…'; }
const res = await perform(a, ac.signal);
if (b) b.textContent = /^Ошибка/.test(res) ? 'Ошибка' : 'Готово';
if (ac.signal.aborted) { showResult(res); break; }
turn = await ask(null, res);
}
ac = null;
sendState();
}
const form = q('.ai-form-in'), attachBtn = q('.ai-attach');
attachBtn.onclick = () => attachInput.click();
attachInput.onchange = () => { addFiles([...attachInput.files]); attachInput.value = ''; };
form.onsubmit = ev => {
ev.preventDefault();
if (ac) { ac.abort(); return; }
const text = input.value.trim();
if (!text && !attachments.length) return;
const files = attachments.slice(); attachments = []; renderAttachments();
input.value = '';
grow();
where();
hideFileError();
talkTurn(text, files);
};
box.addEventListener('dragover', ev => {
if (!ev.dataTransfer || ![...ev.dataTransfer.types].includes('Files')) return;
ev.preventDefault();
form.classList.toggle('drag', isDropTargetAt(ev.clientX, ev.clientY));
});
box.addEventListener('dragleave', ev => { if (!box.contains(ev.relatedTarget)) form.classList.remove('drag'); });
box.addEventListener('drop', ev => {
if (!ev.dataTransfer || ![...ev.dataTransfer.types].includes('Files')) return;
ev.preventDefault(); form.classList.remove('drag');
if (isDropTargetAt(ev.clientX, ev.clientY)) addFiles([...ev.dataTransfer.files]);
});
async function addPaneFiles(pane, names) {
if (!pane || !/files$/.test(pane.kind)) return;
const entries = new Map((pane.entries || []).map(entry => [entry.name, entry]));
const errors = [], paths = [];
for (const name of names) {
const entry = entries.get(name);
if (!entry) { errors.push(name + ': элемент больше не отображается'); continue; }
const path = (pane.cwd === '/' ? '/' : pane.cwd.replace(/\/$/, '') + '/') + name;
// A folder, or a file the model cannot take: its path goes in the text.
if (entry.is_dir || !attachable(name)) { paths.push(path); continue; }
const identity = JSON.stringify([pane.source.id, path]);
if (importingPaneFiles.has(identity) || attachments.some(file => file.sourceIdentity === identity)) {
errors.push(name + ': этот файл уже прикреплён');
continue;
}
importingPaneFiles.add(identity);
try {
const response = await fetch(pane.src.url('download', path), {credentials: 'same-origin'});
if (!response.ok) throw new Error('HTTP ' + response.status);
const blob = await response.blob();
await addFiles([new File([blob], name, {type: blob.type})], [identity]);
} catch (e) { errors.push(name + ': ' + (e.message || 'не удалось скачать')); }
finally { importingPaneFiles.delete(identity); }
}
if (paths.length) insertText(paths.join(' '));
if (errors.length) setFileError(errors.join('; '));
}
function isDropTargetAt(x, y) {
if (!isOpen()) return false;
const r = q('.ai-inputbox').getBoundingClientRect();
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
}
window.AIChat = {
isDropTargetAt,
setDragging(on) { form.classList.toggle('drag', !!on); },
dropFiles: addPaneFiles
};
// Send shows only when there is something to send, or an answer to stop.
// While it fades away it keeps the look it had (a red stop stays a red
// stop) and becomes the plane only once hidden.
let sendLookTimer = 0;
function sendLook(stop) {
clearTimeout(sendLookTimer);
if (send.classList.contains('stop') === stop && send.dataset.look) return;
send.dataset.look = '1';
send.innerHTML = stop ? STOP : PLANE;
send.title = stop ? 'Остановить' : 'Отправить';
send.setAttribute('aria-label', send.title);
send.classList.toggle('stop', stop);
}
function sendState() {
const on = !!ac || !!input.value.trim() || attachments.length > 0;
if (on) sendLook(!!ac);
else { clearTimeout(sendLookTimer); sendLookTimer = setTimeout(() => sendLook(false), 260); }
send.classList.toggle('show', on);
send.tabIndex = on ? 0 : -1;
}
input.addEventListener('input', sendState);
sendState();
// insertText: text at the caret, set apart from its neighbours by spaces.
function insertText(t) {
const v = input.value, a = input.selectionStart ?? v.length, b = input.selectionEnd ?? v.length;
const pre = a && !/\s$/.test(v.slice(0, a)) ? ' ' : '', post = b < v.length && !/^\s/.test(v.slice(b)) ? ' ' : '';
input.value = v.slice(0, a) + pre + t + post + v.slice(b);
const at = a + pre.length + t.length + post.length;
input.focus();
input.setSelectionRange(at, at);
grow(); sendState();
}
// The field grows with the text, up to a few lines.
// Measured with no scrollbar: one showing in the squeezed field would take
// its width and wrap the last word a character too early.
function grow() {
input.style.overflowY = 'hidden';
input.style.height = '32px';
const h = input.scrollHeight;
input.style.height = Math.min(140, h) + 'px';
input.style.overflowY = h > 140 ? 'auto' : 'hidden';
}
input.addEventListener('input', grow);
input.addEventListener('keydown', ev => {
if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing) { ev.preventDefault(); q('.ai-form-in').requestSubmit(); }
});
})();
+899 -3
View File
@@ -32,6 +32,7 @@ header.topbar .brand a.home:hover { color: var(--accent); }
header.topbar .brand { flex: 1 1 auto; min-width: 0; margin-right: 16px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } header.topbar .brand { flex: 1 1 auto; min-width: 0; margin-right: 16px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
header.topbar nav { flex: none; white-space: nowrap; } header.topbar nav { flex: none; white-space: nowrap; }
} }
header.topbar nav { display: flex; align-items: center; }
header.topbar nav a { margin-left: 18px; color: var(--muted); } header.topbar nav a { margin-left: 18px; color: var(--muted); }
header.topbar nav a:hover { color: var(--text); } header.topbar nav a:hover { color: var(--text); }
.wrap { max-width: 960px; margin: 0 auto; padding: 24px 16px; } .wrap { max-width: 960px; margin: 0 auto; padding: 24px 16px; }
@@ -43,7 +44,7 @@ header.topbar nav a:hover { color: var(--text); }
h1 { font-size: 22px; margin: 0 0 18px; } h1 { font-size: 22px; margin: 0 0 18px; }
h2 { font-size: 17px; margin: 0 0 14px; } h2 { font-size: 17px; margin: 0 0 14px; }
label { display: block; margin: 12px 0 4px; color: var(--muted); font-size: 13px; } label { display: block; margin: 12px 0 4px; color: var(--muted); font-size: 13px; }
input[type=text], input[type=password], input[type=number] { input[type=text], input[type=password], input[type=number], input[type=url], input[type=email] {
width: 100%; padding: 10px 12px; border-radius: 8px; width: 100%; padding: 10px 12px; border-radius: 8px;
border: 1px solid var(--border); background: #0c0f13; color: var(--text); font-size: 14px; border: 1px solid var(--border); background: #0c0f13; color: var(--text); font-size: 14px;
} }
@@ -208,7 +209,13 @@ input.sw:disabled { opacity: .45; cursor: not-allowed; }
.acct-pingrace select { width: auto; flex: none; } .acct-pingrace select { width: auto; flex: none; }
.acct-pinmsg { margin: 8px 0 0; font-size: 13px; color: var(--ok, #3fb950); min-height: 0; } .acct-pinmsg { margin: 8px 0 0; font-size: 13px; color: var(--ok, #3fb950); min-height: 0; }
.acct-pinmsg.bad { color: var(--danger); } .acct-pinmsg.bad { color: var(--danger); }
.acct-out { margin-top: 10px; padding-top: 14px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; } .acct-out { margin: 0 0 0 auto; flex: none; }
.acct-whotext { min-width: 0; }
/* Ключи доступа below the rest, after a line. */
.acct-keysec { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border); }
.acct-keysec h3 { margin-top: 0; }
.acct-add input, .acct-add button { height: 40px; box-sizing: border-box; }
.acct-add button { padding-top: 0; padding-bottom: 0; }
/* Sign-in page. */ /* Sign-in page. */
.or { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 13px; margin: 18px 0 4px; } .or { display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 13px; margin: 18px 0 4px; }
.or::before, .or::after { content: ""; flex: 1; border-top: 1px solid var(--border); } .or::before, .or::after { content: ""; flex: 1; border-top: 1px solid var(--border); }
@@ -225,6 +232,15 @@ input.sw:disabled { opacity: .45; cursor: not-allowed; }
overflow-x: auto; white-space: nowrap; scrollbar-width: none; } overflow-x: auto; white-space: nowrap; scrollbar-width: none; }
header.topbar nav::-webkit-scrollbar { display: none; } header.topbar nav::-webkit-scrollbar { display: none; }
header.topbar nav > * { margin-left: 0 !important; flex: none; } header.topbar nav > * { margin-left: 0 !important; flex: none; }
/* One line: no logo, the links and the account on the right; they
scroll sideways when they do not fit. */
header.topbar .brand { display: none; }
header.topbar nav { flex: 1 1 auto; min-width: 0; }
header.topbar nav > :first-child { margin-left: auto !important; }
header.topbar nav #status { flex: 0 1 auto; min-width: 0; max-width: 30vw; }
/* The links scroll; the profile button stays put at the right. */
header.topbar nav #acct { position: sticky; right: 0; z-index: 1; background: var(--panel);
box-shadow: -14px 0 12px -4px var(--panel); }
.acct-add { flex-wrap: wrap; } .acct-add { flex-wrap: wrap; }
.acct-add button { width: 100%; } .acct-add button { width: 100%; }
.acct-keys li { flex-wrap: wrap; } .acct-keys li { flex-wrap: wrap; }
@@ -233,7 +249,7 @@ input.sw:disabled { opacity: .45; cursor: not-allowed; }
h1 { font-size: 20px; } h1 { font-size: 20px; }
code { word-break: break-all; } code { word-break: break-all; }
/* 16px and up, or iOS zooms the page into a field on focus. */ /* 16px and up, or iOS zooms the page into a field on focus. */
input[type=text], input[type=password], input[type=number], select, textarea { font-size: 16px; } input[type=text], input[type=password], input[type=number], input[type=url], input[type=email], select, textarea { font-size: 16px; }
table.stack thead { display: none; } table.stack thead { display: none; }
table.stack, table.stack tbody { display: block; } table.stack, table.stack tbody { display: block; }
table.stack tr { display: flex; flex-direction: column; gap: 2px; padding: 12px 0; border-bottom: 1px solid var(--border); } table.stack tr { display: flex; flex-direction: column; gap: 2px; padding: 12px 0; border-bottom: 1px solid var(--border); }
@@ -259,3 +275,883 @@ input.sw:disabled { opacity: .45; cursor: not-allowed; }
@supports not selector(::-webkit-scrollbar) { @supports not selector(::-webkit-scrollbar) {
* { scrollbar-width: thin; scrollbar-color: rgba(62, 68, 78, calc(var(--sb) * .9)) transparent; } * { scrollbar-width: thin; scrollbar-color: rgba(62, 68, 78, calc(var(--sb) * .9)) transparent; }
} }
/* The one-page shell: the header's current view. */
header.topbar nav a.on { color: var(--text); }
/* ---- The workspace (Терминал view) ---- */
#status.st-ok { color:#37c871; }
#status.st-err { color:#ff5c5c; }
#status { display:inline-block; vertical-align:middle; margin-left:18px; min-width:0; max-width:32vw; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* SSH: the badge on a pane, and the dialog to connect. */
.badge-ssh { flex:none; font-size:10px; font-weight:600; letter-spacing:.04em; padding:1px 5px; border-radius:4px;
background:#12202f; color:var(--accent); border:1px solid #1f3a57; }
dialog.sshdlg { max-width:460px; }
.sshdlg .dlg-h .ico { color:var(--accent); display:inline-flex; }
.sshdlg .dlg-b { display:grid; gap:12px; }
.sshdlg .f { display:grid; gap:5px; margin:0; }
.sshdlg .f > span { font-size:12px; color:var(--muted); }
.sshdlg .frow { display:flex; gap:10px; align-items:end; }
.sshdlg .frow .grow { flex:1; min-width:0; }
.sshdlg .frow .port { width:92px; flex:none; }
.sshdlg input, .sshdlg select, .sshdlg textarea { width:100%; box-sizing:border-box; }
.sshdlg textarea { font:12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; resize:vertical; min-height:90px;
background:var(--bg, #0c0f13); color:var(--text); border:1px solid var(--border); border-radius:8px; padding:8px 10px; }
.sshdlg textarea:focus { border-color:var(--accent); outline:none; }
.sshdlg .auth { display:grid; gap:8px; animation:auth-in .18s ease; }
.sshdlg .auth[hidden] { display:none; }
@keyframes auth-in { from { opacity:0; transform:translateY(-4px); } }
.sshdlg p.auth { margin:0; font-size:13px; }
.sshdlg .keyname { font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; align-self:center; }
.sshdlg .seg { display:flex; padding:3px; gap:3px; background:#0c0f13; border:1px solid var(--border); border-radius:9px; }
.sshdlg .seg button { flex:1; padding:6px 8px; font-size:13px; background:transparent; color:var(--muted); border:0; border-radius:6px;
transition:background .18s, color .18s; }
.sshdlg .seg button:hover { color:var(--text); }
.sshdlg .seg button.on { background:#12202f; color:var(--text); box-shadow:inset 0 0 0 1px #1f3a57; }
.sshdlg .sshmsg { margin:0; min-height:18px; font-size:13px; color:var(--danger); }
.sshdlg .sshmsg.going { color:var(--muted); }
.sshdlg .row.end { display:flex; justify-content:flex-end; gap:8px; }
@media (max-width:520px) { .sshdlg .seg button { font-size:12px; padding:6px 4px; } }
/* The page is the terminal: bar, tabs, then the terminals to the bottom. */
/* The shell: the header, then the view to the bottom. Машины and
Настройки scroll inside it; the terminal view fills it. */
body.app { height:100vh; display:flex; flex-direction:column; overflow:hidden; }
body.app main#app { flex:1; min-height:0; overflow:auto; }
.view-term { height:100%; display:flex; flex-direction:column; overflow:hidden; }
header.topbar { flex:none; }
/* Tabs: folded away while there is one, sliding in with the second. */
.tabs { flex:none; display:flex; align-items:flex-end; gap:4px; padding:0 16px; max-height:0; opacity:0; overflow:hidden;
transition:max-height .28s ease, opacity .22s ease, padding .28s ease; }
body.multi .tabs, body.multi-hint .tabs { max-height:48px; opacity:1; padding-top:10px; }
.tablist { position:relative; display:flex; gap:4px; min-width:0; overflow-x:auto; scrollbar-width:none; }
.tablist::-webkit-scrollbar { display:none; }
.tab { display:flex; align-items:center; gap:8px; flex:none; min-width:0; height:34px; padding:0 6px 0 12px; box-sizing:border-box;
border:1px solid var(--border); border-bottom:0; border-radius:9px 9px 0 0; background:#11151b; color:var(--muted);
font-size:13px; user-select:none; -webkit-user-select:none; cursor:default; overflow:hidden; white-space:nowrap;
transition:background .18s, color .18s, width .22s ease, max-width .22s ease, opacity .2s, padding .22s ease; }
.tab:hover { color:var(--text); background:#151a21; }
.tab.on { background:var(--panel); color:var(--text); }
.tab.enter, .tab.leave { max-width:0; opacity:0; padding:0; }
.tab.dragging { opacity:.3; }
.tab-title { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; }
/* What a tab holds, just before its name. */
.tab-kinds { display:inline-flex; gap:3px; flex:none; color:var(--muted); opacity:.75; }
.tab.on .tab-kinds { color:var(--text); opacity:.6; }
.tab-kinds svg { display:block; }
.tab-edit { width:auto; min-width:60px; max-width:150px; height:24px; padding:2px 6px; font-size:13px; }
.tab-x, .pane-x { flex:none; width:20px; height:20px; padding:0; display:inline-flex; align-items:center; justify-content:center;
background:transparent; border:0; border-radius:5px; color:var(--muted); opacity:.6; transition:opacity .15s, background .15s, color .15s; }
.tab-x:hover, .pane-x:hover { opacity:1; background:#3a1d1d; color:var(--danger); }
.tab-add { flex:none; width:34px; height:34px; padding:0; display:inline-flex; align-items:center; justify-content:center;
border:1px solid var(--border); border-bottom:0; border-radius:9px 9px 0 0; background:transparent; color:var(--muted);
font-size:18px; line-height:1; transition:background .18s, color .18s; }
.tab-add:hover { background:#151a21; color:var(--text); }
.tabmark { position:absolute; top:6px; bottom:0; width:3px; margin-left:-1px; border-radius:2px; background:var(--accent);
pointer-events:none; transition:left .12s ease; }
.tabmark[hidden] { display:none; }
.dot { width:7px; height:7px; border-radius:50%; background:#555d6a; flex:none; transition:background .3s; }
.dot.ok { background:var(--ok); } .dot.err { background:var(--danger); padding:0; border:0; margin:0; } .dot.wait { background:#e6a23c; }
/* The terminals' area. */
.area { position:relative; flex:1; min-height:0; margin:12px 16px 16px; border:1px solid var(--border); border-radius:10px; background:#0c0f13; overflow:hidden; }
body.multi .area { margin-top:0; border-top-left-radius:0; }
.float-add { position:absolute; top:8px; right:10px; z-index:6; width:28px; height:28px; padding:0; display:inline-flex;
align-items:center; justify-content:center; font-size:17px; line-height:1; color:var(--text);
background:transparent; border:1px solid var(--border); opacity:.28; transition:opacity .25s ease, background .25s ease, top .25s ease, right .25s ease; }
/* One tab split in panes: below the panes' title bars, clear of their close buttons. */
body.split1 .float-add { top:42px; right:16px; }
.float-add:hover, .float-add:focus-visible { opacity:1; background:var(--panel); }
body.multi .float-add { opacity:0; pointer-events:none; }
.float-add { transition:opacity .25s ease, background .25s ease, top .3s ease, right .3s ease, width .3s ease, height .3s ease, border-radius .3s ease; }
/* In a file pane's top bar: a button like its neighbours. */
.float-add.in-files { position:static; z-index:auto; width:34px; height:34px; font-size:19px; opacity:1; border-radius:8px; }
.float-add.in-files:hover { background:#151a21; }
.tabview { position:absolute; inset:8px; opacity:0; visibility:hidden; transform:translateY(4px) scale(.995);
transition:opacity .22s ease, transform .22s ease, visibility 0s .22s; }
/* A tab fading out takes no pointer: a drop goes to the tab coming in. */
.tabview:not(.on) { pointer-events:none; }
.tabview.on { opacity:1; visibility:visible; transform:none; transition:opacity .22s ease, transform .22s ease, visibility 0s; }
.pane { position:absolute; display:flex; flex-direction:column; border-radius:7px; overflow:hidden;
transition:left .26s ease, top .26s ease, width .26s ease, height .26s ease, border-color .2s; }
.tabview.instant .pane, .pane.instant { transition:none; }
.pane-head { display:none; }
.tabview.split .pane { border:1px solid var(--border); }
.tabview.split .pane.active { border-color:rgba(79,157,255,.7); }
.tabview.split .pane-head { display:flex; align-items:center; gap:7px; flex:none; height:26px; padding:0 4px 0 9px;
background:#11151b; border-bottom:1px solid var(--border); font-size:12px; color:var(--muted); cursor:grab; user-select:none; -webkit-user-select:none; }
.tabview.split .pane.active .pane-head { color:var(--text); }
.pane-title { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.pane-body { flex:1; min-height:0; padding:4px 0 0 4px; }
/* A lone terminal's scrollbar sits in the margin by the frame, right of
the corner "+", not over the text. */
body:not(.touch) .tabview:not(.split) .pane { overflow:visible; }
body:not(.touch) .tabview:not(.split) .xterm .xterm-viewport { right:-7px; }
.pane.dragging { opacity:.45; }
.gutter { position:absolute; z-index:3; }
.gutter.c { cursor:col-resize; } .gutter.r { cursor:row-resize; }
.gutter::after { content:""; position:absolute; inset:0; border-radius:3px; background:var(--accent); opacity:0; transition:opacity .15s; }
.gutter:hover::after, .gutter.drag::after { opacity:.55; }
.hint { position:absolute; z-index:5; display:flex; align-items:center; justify-content:center; text-align:center; padding:8px;
border:2px solid var(--accent); border-radius:9px; background:rgba(79,157,255,.14); color:var(--text); font-size:13px;
pointer-events:none; transition:left .14s ease, top .14s ease, width .14s ease, height .14s ease; }
.hint.bad { border-color:var(--danger); background:rgba(255,92,92,.12); }
.hint[hidden] { display:none; }
/* No transition on the ghost: restarted on every pointer move, Safari draws
it at the old and the new place at once - doubled and jerky. */
.drag-ghost { position:fixed; left:0; top:0; z-index:60; pointer-events:none; padding:7px 12px; border:1px solid var(--accent);
border-radius:8px; background:var(--panel); color:var(--text); font-size:13px; white-space:nowrap;
box-shadow:0 12px 30px rgba(0,0,0,.55); will-change:transform; }
.drag-ghost[data-hint]:not([data-hint=""])::after { content:attr(data-hint); display:block; margin-top:3px; font-size:11px; color:var(--muted); }
body.dnd, body.dnd * { cursor:grabbing !important; }
.tab, .tab *, .pane-head, .pane-head *, .ai-head, .ai-head *, .ai-fab,
.fp-list tr, .fp-list tr * { -webkit-user-drag:none; }
/* Files dragged out of a pane: the target's outline, and the small window
where they were dropped. */
.hint.drophint { position:fixed; z-index:55; transition:none; }
.dropdlg { position:fixed; z-index:56; width:300px; max-width:calc(100vw - 16px); padding:12px; display:flex; flex-direction:column; gap:8px;
background:var(--panel); border:1px solid var(--border); border-radius:10px; box-shadow:0 14px 36px rgba(0,0,0,.55); font-size:13px; }
.dropdlg[hidden] { display:none; }
.dropdlg .dd-what { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.dropdlg .dd-to { overflow-wrap:anywhere; }
.dropdlg .dd-c { display:flex; align-items:center; gap:6px; }
.dropdlg .mv-prog { margin-top:0; }
.dropdlg .dd-btns { display:flex; gap:6px; justify-content:flex-end; }
/* Dragging rows: no text gets selected meanwhile (it can be selected otherwise). */
body.dnd, body.dnd * { user-select:none !important; -webkit-user-select:none !important; }
.empty { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; color:var(--muted); }
.empty[hidden] { display:none; }
/* The "+" menu: a terminal here, or on another machine. */
.addmenu { position:fixed; z-index:50; min-width:220px; max-width:340px; max-height:70vh; overflow:auto; padding:6px;
background:var(--panel); border:1px solid var(--border); border-radius:10px; box-shadow:0 14px 36px rgba(0,0,0,.55);
animation:menu-in .14s ease; }
.addmenu[hidden] { display:none; }
@keyframes menu-in { from { opacity:0; transform:translateY(-4px); } }
.addmenu .item { display:flex; align-items:center; gap:10px; width:100%; padding:8px 10px; border-radius:7px; text-align:left;
background:transparent; color:var(--text); font-weight:400; }
.addmenu .item:hover:not(:disabled) { background:#12202f; }
.addmenu .item:disabled { opacity:.45; cursor:not-allowed; }
.addmenu .item b { display:block; font-weight:500; font-size:14px; }
.addmenu .item small { display:block; color:var(--muted); font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:240px; }
.addmenu .ico { width:22px; text-align:center; color:var(--accent); font-size:16px; flex:none; }
.addmenu .chev { margin-left:auto; color:var(--muted); transition:transform .2s ease; }
.addmenu .item[aria-expanded="true"] .chev { transform:rotate(90deg); }
.addmenu .mlist { display:grid; grid-template-rows:0fr; margin-top:0; padding-top:0; border-top:0 solid transparent; opacity:0;
transition:grid-template-rows .22s ease, margin-top .22s ease, padding-top .22s ease, border-width .22s ease, opacity .18s ease; }
.addmenu .mlist.open { grid-template-rows:1fr; margin-top:4px; padding-top:4px; border-top:1px solid var(--border); opacity:1; }
.addmenu .mlist-inner { min-height:0; overflow:hidden; }
.addmenu .mlist-content { min-height:0; }
/* Touch screens: the page is as tall as what the on-screen keyboard leaves,
with a row of extra keys at the bottom, the way Termux does it. Tabs are
smaller, and there is no splitting. */
.keys { display:none; }
body.touch { position:fixed; left:0; right:0; top:0; height:100%; }
body.touch .area { margin:6px; }
body.touch.multi .area { margin-top:0; }
body.touch .tabs { padding-left:6px; padding-right:6px; }
body.touch.multi .tabs { padding-top:6px; max-height:38px; }
body.touch .tab { height:28px; font-size:12px; padding:0 2px 0 8px; gap:6px; }
body.touch .tab-add { width:30px; height:28px; }
body.touch .tabview { inset:4px; }
body.touch .tabview:not(.split) .fpane-body { padding-left:10px; padding-right:10px; }
body.touch:not(.term-active) .keys { display:none; }
body.touch.term-active .keys {
display:grid; grid-template-columns:repeat(7, 1fr); gap:4px; flex:none;
padding:4px 4px calc(4px + env(safe-area-inset-bottom)); background:var(--panel); border-top:1px solid var(--border);
}
.keys button {
height:38px; padding:0; border:1px solid var(--border); border-radius:6px;
background:#0c0f13; color:var(--text); font:500 13px ui-monospace, SFMono-Regular, Menlo, monospace;
touch-action:manipulation; user-select:none; -webkit-user-select:none; -webkit-tap-highlight-color:transparent;
}
.keys button:active { background:var(--border); }
.keys button.on { background:var(--accent); border-color:var(--accent); color:#fff; }
.keys button.lock { background:var(--accent); border-color:#fff; color:#fff; }
/* Copy and paste on touch screens: a long press selects, this menu acts. */
.selmenu { position:fixed; left:50%; transform:translateX(-50%); z-index:20; display:flex; gap:7px; padding:5px;
background:var(--panel); border:1px solid var(--border); border-radius:8px; box-shadow:0 6px 24px rgba(0,0,0,.5); }
.selmenu[hidden] { display:none; }
.selmenu button { width:42px; height:42px; padding:0; display:inline-flex; align-items:center; justify-content:center;
touch-action:manipulation; -webkit-tap-highlight-color:transparent; }
body.touch .area { -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; }
#status:empty { display:none; }
/* ---- File panes and their dialogs ---- */
.fm-bar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
/* Normalize buttons and the file <label> button so they line up on one row. */
.fm-bar .btn, .fm-bar button, .fm-bar label.btn {
display:inline-flex; align-items:center; justify-content:center;
vertical-align:middle; height:34px; line-height:1; margin:0; box-sizing:border-box;
}
/* A button with an icon before its text. */
.fm-bar .btn.ico { gap:6px; }
.fm-bar .spacer { flex:1; }
input.fm-path { font-family:ui-monospace,Menlo,monospace; font-size:13px; color:var(--muted);
background:#0c0f13; border:1px solid var(--border); border-radius:8px; padding:8px 12px;
flex:1; min-width:200px; height:34px; box-sizing:border-box; }
input.fm-path:focus { outline:none; border-color:var(--accent); color:var(--text); }
/* The path field, with the folder's properties button inside it on the right. */
.fm-pathbox { position:relative; flex:1; min-width:200px; display:flex; }
.fm-pathbox input.fm-path { width:100%; min-width:0; padding-right:40px; }
.fm-bar .fm-pathbox .btn.sq { position:absolute; right:4px; top:4px; width:26px; height:26px; border:1px solid transparent; }
.fm-bar .fm-pathbox .btn.sq:hover { border-color:var(--border); background:#151a21; }
.spinner { width:16px; height:16px; border:2px solid var(--border); border-top-color:var(--accent);
border-radius:50%; animation:spin .7s linear infinite; display:inline-block; flex:none; }
@keyframes spin { to { transform:rotate(360deg); } }
/* Busy indicator; while files upload it also shows how far they got, and a
click on it lists them. It takes room only while it shows. */
.fp-uppct { display:inline-block; max-width:0; margin-left:0; overflow:hidden; white-space:nowrap;
transition:max-width .3s ease, margin-left .3s ease; }
.fp-uppct.on { max-width:110px; margin-left:8px; }
.busy { flex:none; display:none; align-items:center; gap:8px; background:none; border:0; padding:0 2px;
height:34px; color:var(--muted); font-size:13px; font-variant-numeric:tabular-nums; cursor:default; }
/* Folded to nothing when idle (the negative margin takes back the bar's
gap), it opens smoothly, and only for work that lasts a moment. */
.fm-bar .busy { display:inline-flex; gap:0; box-sizing:border-box; max-width:0; padding:0; margin-left:-8px; opacity:0;
overflow:hidden; white-space:nowrap; transition:max-width .3s ease, margin-left .3s ease, opacity .25s ease; }
.fm-bar .busy.on { max-width:140px; margin-left:0; opacity:1; transition-delay:.15s; }
.busy:hover { background:none; }
.busy.up { cursor:pointer; color:var(--text); }
.busy.up:hover { color:var(--accent); }
#updlg { max-width:480px; }
.upl-item { padding:8px 0; border-bottom:1px solid var(--border); }
.upl-item:last-child { border-bottom:0; }
.upl-row { display:flex; gap:12px; justify-content:space-between; font-size:14px; }
.upl-row .n { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.upl-row .st { flex:none; color:var(--muted); font-size:13px; font-variant-numeric:tabular-nums; }
.upl-row .st.ok { color:var(--ok); }
.upl-row .st.bad { color:var(--danger); }
.upl-bar { height:4px; margin-top:6px; border-radius:2px; background:#0c0f13; overflow:hidden; }
.upl-bar i { display:block; height:100%; background:var(--accent); transform-origin:left; transition:transform .2s; }
.fp-drop { border:1px dashed var(--border); border-radius:10px; }
.fp-drop.drag { border-color:var(--accent); background:#12202f; }
table.fm td, table.fm th { padding:7px 8px; }
.fm .nm { cursor:pointer; }
.fm .nm:hover { color:var(--accent); }
.ic { display:inline-flex; vertical-align:-3px; width:18px; justify-content:center; margin-right:6px; color:var(--muted); }
.ic.dir { color:#7aa7d9; }
.ic.dir svg { fill:rgba(122,167,217,.16); }
.sz { color:var(--muted); font-variant-numeric:tabular-nums; }
.act { display:flex; gap:6px; justify-content:flex-end; }
/* Sortable headers: a click sorts by the column, a second one turns it round. */
table.fm th.srt { cursor:pointer; user-select:none; -webkit-user-select:none; white-space:nowrap; }
table.fm th.srt:hover { color:var(--text); }
table.fm .c-sz { width:84px; }
table.fm .c-mt { width:150px; }
table.fm .c-act { width:1%; }
/* The name takes what is left: size, time and buttons as narrow as they go. */
table.fm td.sz { white-space:nowrap; }
table.fm .act { flex-wrap:nowrap; }
.fp-sel { display:inline-flex; align-items:center; gap:6px; flex:none; margin-left:auto; }
.fp-sel.wrapped { margin-left:0; }
.fp-selinfo { font-size:13px; margin-right:2px; }
.fp-sel .btn.sq { width:34px; }
.fp-selinfo:empty { display:none; }
.fm .nm { overflow-wrap:anywhere; }
/* Phones: no date column, and the row buttons only as wide as they are. */
@media (max-width: 640px) {
table.fm .c-mt, table.fm td.mt { display:none; }
table.fm .c-sz { width:auto; white-space:nowrap; }
table.fm .c-act { width:1%; }
table.fm td, table.fm th { padding:7px 4px; }
.ic { margin-right:4px; }
/* Вверх, the path and Обновить on one line: the path gives way. */
.fm-bar .fm-pathbox { flex:1 1 0; min-width:0; }
/* The selection's buttons on the line of the blue ones: those a little
narrower, the count by the buttons only as a number. */
.fp-sel .fp-selinfo { font-size:12px; }
}
#save { display:inline-flex; align-items:center; gap:8px; }
#save.saving { opacity:.85; cursor:progress; }
#save.saving::before { content:''; width:12px; height:12px; border:2px solid rgba(255,255,255,.35); border-top-color:#fff;
border-radius:50%; animation:spin .7s linear infinite; }
#save[hidden] { display:none; }
/* Square icon buttons, the height of the buttons beside them. */
.btn.sq { width:28px; height:28px; padding:0; display:inline-flex; align-items:center; justify-content:center; }
.fm-bar .btn.sq { width:34px; }
/* Move or copy: the action, where to with suggestions, the name, and what
to do with what is already there. */
#mvdlg { max-width:560px; }
#mvdlg .dlg-b { padding:14px 18px 16px; }
.mv label { margin:12px 0 4px; }
.mv .pp-path { font-family:ui-monospace,Menlo,monospace; font-size:13px; overflow-wrap:anywhere; }
.mv .pp-act { display:flex; justify-content:flex-end; margin-top:16px; }
.mv input[type=text], .mv select { width:100%; height:36px; padding:6px 10px; }
.mv select { padding-right:36px; }
.seg { display:inline-flex; margin-top:12px; border:1px solid var(--border); border-radius:8px; overflow:hidden; }
.seg button { background:transparent; color:var(--muted); border:0; border-radius:0; padding:7px 16px; font-size:14px; }
.seg button + button { border-left:1px solid var(--border); }
.seg button.on { background:#12202f; color:var(--text); box-shadow:inset 0 -2px 0 var(--accent); }
.seg button:hover { background:#151a21; color:var(--text); }
.mv-top { display:flex; align-items:flex-end; gap:8px; }
.mv-top .mv-net { height:36px; width:36px; flex:none; color:var(--muted); transition:background .18s, color .18s, border-color .18s; }
.mv-top .mv-net:hover { color:var(--text); }
.mv-top .mv-net.on { color:var(--accent); border-color:var(--accent); background:#12202f; }
/* The other machine: slides open under the action. */
.mv-remote { display:grid; grid-template-rows:0fr; opacity:0; transition:grid-template-rows .25s ease, opacity .2s ease; }
.mv-remote > div { overflow:hidden; min-height:0; }
.mv-remote.on { grid-template-rows:1fr; opacity:1; }
.mv-remote.on > div { overflow:visible; }
.mv-ssh { display:grid; gap:8px; margin-top:10px; padding:10px 12px; border:1px solid var(--border); border-radius:9px; background:#0f1318;
animation:mv-in .2s ease; }
.mv-ssh[hidden], .mv-a[hidden] { display:none; }
@keyframes mv-in { from { opacity:0; transform:translateY(-4px); } }
.mv-ssh label { margin:0; display:grid; gap:4px; font-size:12px; color:var(--muted); }
.mv-ssh input[type=number] { width:100%; height:36px; padding:6px 10px; }
.mv-row { display:flex; gap:10px; align-items:end; }
.mv-row .grow { flex:1; min-width:0; }
.mv-row .port { width:92px; flex:none; }
.mv-ssh .seg-auth { margin-top:2px; display:flex; }
.mv-ssh .seg-auth button { flex:1; padding:6px 8px; font-size:13px; }
@media (max-width:520px) { .mv-ssh .seg-auth button { font-size:12px; padding:6px 4px; white-space:nowrap; } }
.mv-a { display:grid; gap:8px; animation:mv-in .18s ease; }
.mv-a textarea { width:100%; box-sizing:border-box; font:12px/1.4 ui-monospace,Menlo,monospace; resize:vertical; min-height:80px;
background:var(--bg); color:var(--text); border:1px solid var(--border); border-radius:8px; padding:8px 10px; }
.mv-a textarea:focus { border-color:var(--accent); outline:none; }
p.mv-a { margin:0; font-size:13px; }
#mv-keyname { font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; align-self:center; }
.mv-prog { margin-top:14px; animation:mv-in .2s ease; }
.mv-prog[hidden] { display:none; }
.mv-bar { height:6px; border-radius:3px; background:#1a2029; overflow:hidden; }
.mv-bar i { display:block; height:100%; width:0; background:var(--accent); border-radius:3px; transition:width .5s ease; }
.mv-bar.wait i { width:30% !important; animation:mv-slide 1.1s ease-in-out infinite; }
@keyframes mv-slide { from { transform:translateX(-100%); } to { transform:translateX(340%); } }
.mv-pwrap { display:inline-flex; align-items:center; gap:8px; min-width:0; }
.mv-ptext { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-top:8px; font-size:13px; color:var(--muted); }
.mv.running input, .mv.running select, .mv.running textarea, .mv.running .seg, .mv.running .mv-net { pointer-events:none; opacity:.6; }
.mv-dirbox { position:relative; }
.mv-dirbox input { font-family:ui-monospace,Menlo,monospace; font-size:13px; }
.mv-sug { position:absolute; left:0; right:0; top:calc(100% + 4px); z-index:5; max-height:220px; overflow:auto;
margin:0; padding:4px; list-style:none; background:var(--panel); border:1px solid var(--border); border-radius:8px;
box-shadow:0 8px 24px rgba(0,0,0,.45); font-family:ui-monospace,Menlo,monospace; font-size:13px; }
.mv-sug li { padding:5px 8px; border-radius:5px; cursor:pointer; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.mv-sug li.sel, .mv-sug li:hover { background:#12202f; color:var(--text); }
.mv-sug li .muted { color:var(--muted); }
/* Properties: owner, group and mode, like FileZilla's. */
#propdlg { max-width:520px; }
#propdlg .dlg-b { padding:14px 18px 16px; }
.props .pp-path { font-family:ui-monospace,Menlo,monospace; font-size:13px; overflow-wrap:anywhere; }
.props .pp-info { font-size:13px; margin:2px 0 12px; }
.props .pp-own { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.props .pp-own label { margin:0; }
.props .pp-own input { margin-top:4px; height:36px; padding:6px 10px; }
.props table.pp-perm { margin:12px 0 4px; }
.props .pp-perm th, .props .pp-perm td { padding:4px 8px; text-align:center; font-size:13px; }
.props .pp-perm tbody th { text-align:left; color:var(--text); font-weight:400; }
.props .pp-perm input { width:16px; height:16px; cursor:pointer; }
.props .pp-num { display:flex; align-items:center; gap:10px; margin:8px 0 0; }
.props .pp-num input { width:80px; height:34px; padding:4px 10px; font-family:ui-monospace,Menlo,monospace; }
.props .pp-num input.bad { border-color:var(--danger); }
.props .pp-rec { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:8px 16px; margin-top:12px; padding-top:12px; border-top:1px solid var(--border); }
.props .pp-rec label.check { display:inline-flex; align-items:center; gap:8px; margin:0; color:var(--text); font-size:14px; cursor:pointer; }
.props .pp-rec select { min-width:0; padding:6px 32px 6px 12px; }
.props .pp-act { display:flex; justify-content:flex-end; margin-top:14px; }
/* File viewer: the code editor, a picture / video / sound, or the hex editor. */
#viewer { max-width:1100px; }
#viewer .dlg-h { flex-wrap:wrap; }
/* Right to left so a long path is cut at its start, keeping the name in
sight; still set to the left like any title. */
.vpath { flex:1; min-width:0; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; text-align:left;
font-family:ui-monospace,Menlo,monospace; font-size:13px; font-weight:600; }
.vmsg { width:100%; order:9; font-size:12px; }
.vbody { padding:0; }
#vcode .CodeMirror { height:70vh; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:13px; }
.vmedia { display:flex; align-items:center; justify-content:center; min-height:200px; max-height:75vh; padding:16px;
background:#0c0f13 repeating-conic-gradient(#151a21 0 25%, #0c0f13 0 50%) 0 0/20px 20px; border-radius:0 0 12px 12px; }
.vmedia img, .vmedia video { max-width:100%; max-height:calc(75vh - 32px); object-fit:contain; }
.vmedia audio { width:min(100%, 520px); }
/* Find and replace, over the editor: both fields the same width, the
buttons of each row after its field. */
.find { display:grid; grid-template-columns:minmax(0, 1fr) auto; gap:6px 8px; align-items:center;
padding:8px 12px; border-bottom:1px solid var(--border); background:var(--panel); }
.find .fin { position:relative; grid-column:1; }
.find input { width:100%; height:32px; padding:4px 76px 4px 10px; font-size:13px; font-family:ui-monospace,Menlo,monospace; }
.find #frep { padding-right:10px; }
.find input.bad { border-color:var(--danger); }
.find .cnt { position:absolute; right:10px; top:0; bottom:0; display:flex; align-items:center; pointer-events:none;
color:var(--muted); font-size:12px; font-variant-numeric:tabular-nums; }
.find .fbtns { grid-column:2; justify-self:end; display:flex; gap:6px; }
.find button { height:32px; min-width:32px; padding:0 10px; font-size:13px;
display:inline-flex; align-items:center; justify-content:center; }
.find button.tg { background:transparent; border:1px solid var(--border); color:var(--muted); font-family:ui-monospace,Menlo,monospace; }
.find button.tg.on { border-color:var(--accent); color:var(--text); background:#12202f; }
.find #frow2, .find #frow2b { grid-row:2; }
/* Documents and tables, drawn in the panel's colours. */
.vdoc { height:75vh; overflow:auto; background:#0c0f13; border-radius:0 0 12px 12px; }
/* SQLite editor. */
.dbx { display:flex; flex-direction:column; height:100%; transition:opacity .2s; }
.dbx.busy { opacity:.75; }
.dbx-tabs { display:flex; gap:4px; padding:8px 10px 0; overflow-x:auto; flex:none; border-bottom:1px solid var(--border); }
.dbx-tabs button { flex:none; background:transparent; color:var(--muted); border:1px solid transparent; border-bottom:0; border-radius:8px 8px 0 0;
padding:6px 12px; font-size:13px; transition:background .15s, color .15s; }
.dbx-tabs button:hover { color:var(--text); background:#151a21; }
.dbx-tabs button.on { color:var(--text); background:var(--panel); border-color:var(--border); }
.dbx-tabs .dbx-sqltab { margin-left:auto; font-family:ui-monospace,Menlo,monospace; }
.dbx-tools { display:flex; align-items:center; gap:8px; padding:8px 10px; flex:none; flex-wrap:wrap; font-size:13px; }
.dbx-tools[hidden], .dbx-pager[hidden], .dbx-grid[hidden], .dbx-sql[hidden], .dbx-tools button[hidden] { display:none; }
.dbx-pager { display:inline-flex; align-items:center; gap:6px; }
.dbx-sp { flex:1; }
.dbx-grid { flex:1; min-height:0; overflow:auto; }
.dbx-t { width:auto; border-collapse:separate; border-spacing:0; font-size:13px; font-variant-numeric:tabular-nums; }
.dbx-t th, .dbx-t td { border-right:1px solid var(--border); border-bottom:1px solid var(--border); padding:5px 10px; text-align:left;
max-width:420px; white-space:pre-wrap; overflow-wrap:anywhere; vertical-align:top; }
.dbx-t thead th { position:sticky; top:0; z-index:1; background:var(--panel); color:var(--text); font-weight:500; white-space:nowrap; }
.dbx-t thead th small { display:block; color:var(--muted); font-weight:400; font-size:11px; }
.dbx-t td.num { text-align:right; }
.dbx-t .dbx-k { color:var(--muted); text-align:right; }
.dbx-t .dbx-ck { width:1%; text-align:center; }
.dbx-t td.ed { cursor:text; transition:background .15s; }
.dbx-t td.ed:hover { background:#151a21; }
.dbx-t td.editing { padding:0; background:#12202f; }
.dbx-t td.saving { opacity:.6; }
.dbx-t td.flash-ok { animation:dbx-ok 1.2s ease; }
.dbx-t td.flash-bad { animation:dbx-bad 1.2s ease; }
@keyframes dbx-ok { 0% { background:rgba(55,200,113,.28); } 100% { background:transparent; } }
@keyframes dbx-bad { 0% { background:rgba(255,92,92,.3); } 100% { background:transparent; } }
.dbx-null { color:var(--muted); font-size:12px; }
.dbx-edit { position:relative; }
.dbx-edit textarea { display:block; width:100%; min-height:30px; resize:vertical; box-sizing:border-box; margin:0; padding:5px 28px 5px 10px; border:1px solid var(--accent);
border-radius:0; background:#0c0f13; color:var(--text); font:inherit; outline:none; }
.dbx-nullb { position:absolute; top:4px; right:4px; width:20px; height:20px; padding:0; font-size:12px; line-height:1;
background:#1b222c; color:var(--muted); border:1px solid var(--border); border-radius:5px; }
.dbx-nullb:hover { color:var(--text); background:#232b36; }
.dbx-t tr.dbx-new td { padding:3px; background:#12202f; }
.dbx-t tr.dbx-new input { width:100%; min-width:90px; height:28px; padding:3px 8px; font-size:13px; }
.dbx-t tr.dbx-newbar td { background:#12202f; padding:6px 8px; white-space:nowrap; }
.dbx-sql { flex:1; min-height:0; display:flex; flex-direction:column; gap:8px; padding:10px; }
.dbx-q { flex:none; height:110px; resize:vertical; font:13px/1.5 ui-monospace,Menlo,monospace; background:#0c0f13; color:var(--text);
border:1px solid var(--border); border-radius:8px; padding:8px 10px; }
.dbx-q:focus { border-color:var(--accent); outline:none; }
.dbx-sqlbar { display:flex; align-items:center; gap:10px; font-size:13px; }
.dbx-sqlmsg.bad { color:var(--danger); }
.dbx-sqlres { flex:1; min-height:0; overflow:auto; }
.vdoc .dv-pdf { display:block; width:100%; height:100%; border:0; background:#525659; }
.vdoc .dv-html { display:block; width:100%; height:100%; border:0; background:#fff; }
.dv-doc { max-width:820px; margin:0 auto; padding:28px 32px 40px; font-size:15px; line-height:1.65; color:var(--text); overflow-wrap:anywhere; }
.dv-doc p { margin:0 0 .7em; }
.dv-doc h1, .dv-doc h2, .dv-doc h3, .dv-doc h4, .dv-doc h5, .dv-doc h6 { margin:1.2em 0 .5em; line-height:1.3; }
.dv-doc h1 { font-size:26px; } .dv-doc h2 { font-size:21px; } .dv-doc h3 { font-size:18px; } .dv-doc h4, .dv-doc h5, .dv-doc h6 { font-size:16px; }
.dv-doc ul { margin:0 0 .7em; padding-left:1.4em; }
.dv-doc li p { margin:0; }
.dv-doc .dv-link { color:var(--accent); }
.dv-table { border-collapse:collapse; margin:.4em 0 1em; width:auto; }
.dv-table td { border:1px solid var(--border); padding:6px 10px; vertical-align:top; font-size:14px; }
.dv-table td p { margin:0; }
.dv-scroll { overflow:auto; }
.dv-grid { max-height:100%; }
.dv-sheet { width:auto; border-collapse:separate; border-spacing:0; font-size:13px; font-variant-numeric:tabular-nums; }
.dv-sheet th, .dv-sheet td { border-right:1px solid var(--border); border-bottom:1px solid var(--border); padding:4px 10px;
white-space:nowrap; max-width:360px; overflow:hidden; text-overflow:ellipsis; text-align:left; }
.dv-sheet thead th { position:sticky; top:0; z-index:1; background:var(--panel); color:var(--muted); font-weight:500; text-align:center; }
.dv-sheet tbody th { position:sticky; left:0; background:var(--panel); color:var(--muted); font-weight:500; text-align:right; }
.dv-sheet thead th:first-child { left:0; z-index:2; }
.dv-sheet td.n { text-align:right; }
.dv-tabs { position:sticky; top:0; z-index:3; display:flex; gap:6px; flex-wrap:wrap; padding:8px 12px; background:var(--panel); border-bottom:1px solid var(--border); }
.dv-tabs button { height:28px; padding:0 12px; font-size:13px; background:transparent; border:1px solid var(--border); color:var(--muted); }
.dv-tabs button.on { border-color:var(--accent); color:var(--text); background:#12202f; }
.dv-note { color:var(--muted); font-size:13px; padding:12px 16px; margin:0; }
/* Hex editor: offset, 16 bytes, the same as text. Only the rows in sight are drawn. */
.hexed { position:relative; height:70vh; overflow:auto; outline:none; background:#0c0f13;
font:13px/20px ui-monospace,SFMono-Regular,Menlo,monospace; }
.hexed .hx-rows { position:absolute; left:0; right:0; padding:0 12px; }
.hx-r { display:flex; gap:18px; height:20px; white-space:pre; }
.hx-o { color:var(--muted); }
.hx-b i, .hx-a i { font-style:normal; cursor:text; }
.hx-b i { margin-right:6px; }
.hx-b i:nth-child(8) { margin-right:14px; }
.hx-a { color:#9aa4b2; }
.hx-r i.ch { color:#ffcb6b; }
.hx-r i.cur { background:var(--accent); color:#fff; border-radius:2px; }
.hx-r i.cur2 { outline:1px solid var(--accent); border-radius:2px; }
/* Editor colours, in the panel's palette. */
.cm-s-lpra.CodeMirror { background:#0c0f13; color:#d7dce4; }
.cm-s-lpra .CodeMirror-gutters { background:#0c0f13; border-right:1px solid var(--border); }
.cm-s-lpra .CodeMirror-linenumber { color:#4b5563; }
.cm-s-lpra .CodeMirror-cursor { border-left:2px solid #e6e9ef; }
.cm-s-lpra .CodeMirror-selected, .cm-s-lpra.CodeMirror-focused .CodeMirror-selected { background:rgba(79,157,255,.28); }
.cm-s-lpra .CodeMirror-line::selection, .cm-s-lpra .CodeMirror-line > span::selection { background:rgba(79,157,255,.28); }
.cm-s-lpra span.cm-keyword { color:#c792ea; }
.cm-s-lpra span.cm-def { color:#82aaff; }
.cm-s-lpra span.cm-variable-2, .cm-s-lpra span.cm-variable-3, .cm-s-lpra span.cm-type { color:#ffcb6b; }
.cm-s-lpra span.cm-builtin { color:#ffcb6b; }
.cm-s-lpra span.cm-string, .cm-s-lpra span.cm-string-2 { color:#c3e88d; }
.cm-s-lpra span.cm-number, .cm-s-lpra span.cm-atom { color:#f78c6c; }
.cm-s-lpra span.cm-comment { color:#6b7686; font-style:italic; }
.cm-s-lpra span.cm-meta, .cm-s-lpra span.cm-qualifier { color:#89ddff; }
.cm-s-lpra span.cm-operator { color:#89ddff; }
.cm-s-lpra span.cm-property { color:#82aaff; }
.cm-s-lpra span.cm-tag { color:#f07178; }
.cm-s-lpra span.cm-attribute { color:#ffcb6b; }
.cm-s-lpra span.cm-header { color:#82aaff; font-weight:600; }
.cm-s-lpra span.cm-link { color:#89ddff; text-decoration:underline; }
.cm-s-lpra span.cm-error { color:#ff5c5c; }
.cm-s-lpra span.cm-positive { color:#37c871; }
.cm-s-lpra span.cm-negative { color:#ff5c5c; }
.cm-s-lpra .CodeMirror-matchingbracket { color:#fff !important; outline:1px solid #4f9dff; }
.cm-s-lpra .cm-searching { background:rgba(255,203,107,.32); border-radius:2px; }
@media (max-width: 640px) {
#vcode .CodeMirror, .hexed { height:calc(100vh - 220px); }
.hexed { font-size:11px; }
.hx-r { gap:10px; }
.hx-b i { margin-right:3px; }
.hx-b i:nth-child(8) { margin-right:8px; }
}
.toast { z-index:50; position:fixed; bottom:18px; left:50%; transform:translateX(-50%);
background:#2a2f3a; border:1px solid var(--border); border-radius:8px;
padding:10px 16px; font-size:14px; opacity:0; transition:opacity .2s; pointer-events:none; }
.toast.show { opacity:1; }
.toast.err { background:#3a1d1d; border-color:#6b2b2b; color:#ffb3b3; }
/* A mid-width file pane drops the time column for the names. */
.fpane.mid table.fm .c-mt, .fpane.mid table.fm td.mt { display:none; }
/* A file pane narrower than ~520 px (a split tab) folds as on a phone. */
.fpane.narrow table.fm .c-mt, .fpane.narrow table.fm td.mt { display:none; }
.fpane.narrow table.fm .c-sz { width:auto; white-space:nowrap; }
.fpane.narrow table.fm .c-act { width:1%; }
.fpane.narrow table.fm td, .fpane.narrow table.fm th { padding:7px 4px; }
.fpane.narrow .fm-bar .fm-pathbox { flex:1 1 100%; order:-1; }
/* The selection's buttons stay on the line of the blue ones while they fit:
those a little more compact; the line breaks only when there is no room. */
.fpane.narrow .fm-bar { gap:5px; }
.fpane.narrow .fm-bar .fm-pathbox { flex:1 1 0; min-width:0; order:0; }
.fpane.narrow .fm-bar .btn.ico { padding-left:8px; padding-right:8px; gap:4px; font-size:12.5px; }
.fpane.narrow .fp-sel { gap:4px; }
.fpane.narrow .fp-selinfo { margin-right:6px; }
.fpane.narrow .fp-sel .btn.sq { width:32px; height:32px; }
/* The pane: its bar and list scroll inside it. */
.fpane-body { flex:1; min-height:0; overflow:auto; padding:10px 12px 12px; }
.tabview:not(.split) .fpane-body { padding:12px 16px 16px; }
/* The bars' square buttons, as tall as the path box beside them. */
.fm-bar > .btn.sq, .fp-sel .btn.sq { width:34px; height:34px; }
/* A machine's Порты, Статус, Изменить over the current page. */
dialog.machinedlg { max-width:1000px; width:94vw; max-height:90vh; padding:0; overflow:hidden; }
/* The dialog's title names the page: the page's own heading goes. */
.machinedlg .frag-area .wrap > h1:first-child { display:none; }
.machinedlg .frag-area .wrap { padding-top:18px; padding-bottom:18px; }
.machinedlg .frag-area { overflow:auto; max-height:calc(90vh - 58px); }
.mdmenu { min-width:180px; }
.mdmenu .md-head { padding:6px 10px 4px; font-size:12px; }
@media (max-width: 640px) {
dialog.machinedlg { width:100vw; max-width:none; height:100dvh; max-height:none; margin:0; border-radius:0; }
.machinedlg .frag-area { max-height:calc(100dvh - 58px); }
}
/* Настройки › ИИ-ассистент. */
.ai-form { display:grid; gap:10px; max-width:520px; }
.ai-form label { display:grid; gap:4px; font-size:13px; color:var(--muted); }
/* The assistant: a round button, and the chat over the page's corner. */
.ai-fab { position:fixed; right:28px; bottom:28px; z-index:40; width:52px; height:52px; padding:0; border-radius:50%;
display:inline-flex; align-items:center; justify-content:center; box-shadow:0 8px 24px rgba(0,0,0,.45); }
.ai-fab[hidden] { display:none; }
body.touch.term-active .ai-fab { bottom:104px; }
.ai-box { position:fixed; right:28px; bottom:96px; z-index:41; width:408px; max-width:calc(100vw - 24px); height:min(500px, calc(100vh - 120px));
display:flex; flex-direction:column; background:var(--panel); border:1px solid var(--border); border-radius:12px;
box-shadow:0 18px 44px rgba(0,0,0,.55); overflow:hidden; transform-origin:bottom right;
opacity:0; visibility:hidden; pointer-events:none; transform:translateY(14px) scale(.96);
transition:opacity .22s ease, transform .22s ease, visibility 0s linear .22s; }
.ai-box.on { opacity:1; visibility:visible; pointer-events:auto; transform:none; transition:opacity .22s ease, transform .22s ease; }
.ai-fab { transition:transform .2s ease, box-shadow .2s ease; background:linear-gradient(135deg, #5b8cff, #8b5cf6); border:0; color:#fff; }
.ai-fab:hover { transform:scale(1.06); box-shadow:0 10px 28px rgba(91,140,255,.45); }
.ai-head { display:flex; align-items:center; gap:8px; padding:10px 12px; border-bottom:1px solid var(--border); }
/* The machine the chat sees: its name on one line, cut with … */
/* The machine's name: room for 7-8 letters at least, the chat's name gives way. */
.ai-head .ai-where { flex:0 0 auto; min-width:0; max-width:40%; font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.ai-head .ai-chatsel { min-width:0; }
.ai-head .ai-chatname { min-width:3ch; }
.ai-head .ai-chatsel { flex:0 1 auto; }
.ai-chat-body { position:relative; flex:1; min-height:0; }
/* The empty chat: a quiet chat mark and a word, in the middle. */
.ai-hello { margin:auto; display:flex; flex-direction:column; align-items:center; gap:12px; padding:24px 12px; text-align:center; animation:ai-hello-in .3s ease both; }
.ai-hello[hidden] { display:none; }
.ai-hello-icon { width:60px; height:60px; display:flex; align-items:center; justify-content:center; border-radius:50%;
color:var(--muted); background:#151a21; border:1px solid var(--border); }
.ai-hello-text { color:var(--muted); font-size:14px; font-weight:500; }
@keyframes ai-hello-in { from { opacity:0; transform:translateY(6px); } }
.ai-list { height:100%; box-sizing:border-box; overflow:auto; padding:12px; display:flex; flex-direction:column; gap:10px; }
.ai-jump { position:absolute; right:4px; bottom:14px; z-index:2; width:32px; height:32px; padding:0; border-radius:50%; display:inline-flex; align-items:center; justify-content:center; color:var(--text); background:var(--panel); border:1px solid var(--border); box-shadow:0 4px 14px rgba(0,0,0,.35);
opacity:0; transform:translateY(8px) scale(.85); pointer-events:none; transition:opacity .2s ease, transform .2s ease, background-color .15s; }
.ai-jump.show { opacity:1; transform:none; pointer-events:auto; }
.ai-jump:hover { background:#202833; }
.ai-msg { max-width:92%; padding:8px 11px; border-radius:10px; font-size:14px; line-height:1.45; overflow-wrap:anywhere; }
.ai-msg.user { align-self:flex-end; background:#16324f; white-space:normal; }
.ai-user-copy { white-space:pre-wrap; }
.ai-msg.assistant { align-self:flex-start; background:#151a21; border:1px solid var(--border); }
.ai-history-files { display:grid; gap:6px; margin-top:8px; }
.ai-history-file { display:flex; align-items:center; gap:9px; min-width:0; padding:8px 10px; border:1px solid rgba(255,255,255,.12); border-radius:10px; background:rgba(8,13,20,.32); }
.ai-history-file-icon { flex:none; width:32px; height:32px; display:flex; align-items:center; justify-content:center; border-radius:9px; color:#a9c1ff; background:rgba(91,140,255,.14); }
.ai-history-file.audio .ai-history-file-icon { color:#d1aaff; background:rgba(139,92,246,.16); }
.ai-history-file.video .ai-history-file-icon { color:#ffb4a2; background:rgba(249,115,85,.14); }
.ai-history-file.pdf .ai-history-file-icon { color:#ffaaa5; background:rgba(239,68,68,.14); }
.ai-history-file.document .ai-history-file-icon, .ai-history-file.text .ai-history-file-icon { color:#9ed9c0; background:rgba(52,168,120,.14); }
.ai-history-file-info { display:grid; gap:2px; min-width:0; }
.ai-history-file-name { overflow:hidden; color:var(--text); font-size:12.5px; font-weight:550; text-overflow:ellipsis; white-space:nowrap; }
.ai-history-file-meta { color:var(--muted); font-size:10.5px; }
/* The model starting: three dots rising in turn. */
.ai-typing { display:inline-flex; gap:4px; padding:4px 2px; }
.ai-typing i { width:6px; height:6px; border-radius:50%; background:var(--muted); animation:ai-typing 1.2s ease-in-out infinite; }
.ai-typing i:nth-child(2) { animation-delay:.15s; } .ai-typing i:nth-child(3) { animation-delay:.3s; }
@keyframes ai-typing { 0%, 60%, 100% { transform:translateY(0); opacity:.4; } 30% { transform:translateY(-5px); opacity:1; } }
.ai-code { margin:6px 0; }
.ai-code pre { margin:0; padding:8px; background:#0c0f13; border:1px solid var(--border); border-radius:7px; overflow:auto; font-size:12.5px; white-space:pre-wrap; }
.ai-code .ai-run { margin-top:6px; }
.ai-form-in { display:flex; padding:8px 10px 10px; }
.ai-inputbox { position:relative; min-width:0; flex:1; padding:4px; background:#0c0f13; border:1px solid var(--border); border-radius:13px; transition:border-color .15s, box-shadow .15s, background-color .15s; }
.ai-inputbox:focus-within { border-color:var(--accent); box-shadow:0 0 0 2px rgba(91,140,255,.12); }
/* The clip and send keep to the last line of the text, as in Telegram:
at the bottom, centred on that line (a line's field is their height). */
.ai-entry { display:flex; align-items:flex-end; gap:5px; min-width:0; }
.ai-form-in textarea { flex:1; min-width:0; resize:none; font:inherit; font-size:14px; line-height:20px; padding:6px; height:32px; max-height:140px; overflow-y:auto; color:var(--text); background:transparent; border:0; border-radius:8px; }
.ai-form-in textarea:focus { outline:none; border-color:transparent; }
.ai-attach { flex:none; width:32px; height:32px; padding:0; display:inline-flex; align-items:center; justify-content:center; color:var(--muted); background:transparent; border:0; border-radius:50%; }
.ai-attach:hover { color:var(--text); background:#1b222c; }
/* Send, as in Telegram: no plate, a blue plane that comes in when there is
something to send (and stays as "stop" while the answer runs), and goes
away when the field is empty. A round shade under the pointer, like the
clip's. As far from the right edge as from the top and bottom. */
.ai-send { flex:none; box-sizing:border-box; width:0; height:32px; min-width:0; padding:0; margin:0 0 0 -5px; overflow:hidden;
display:inline-flex; align-items:center; justify-content:center; align-self:flex-end; line-height:1;
color:var(--accent); background:transparent; border:0; border-radius:50%;
opacity:0; transform:scale(.4); pointer-events:none;
transition:width .2s ease, margin .2s ease, opacity .16s ease, transform .24s cubic-bezier(.3,1.45,.55,1), background-color .15s, color .15s; }
.ai-send.show { width:32px; margin:0; opacity:1; transform:none; pointer-events:auto; }
.ai-send:hover { color:#7ab6ff; background:#1b222c; }
.ai-send:active { transform:scale(.9); }
.ai-send:focus-visible { outline:none; box-shadow:0 0 0 2px rgba(79,157,255,.45); }
.ai-send .ai-plane { display:block; flex:none; transform:translateX(1px); }
.ai-attachments { display:flex; flex-wrap:wrap; gap:5px; padding:2px 3px 6px; }
.ai-attachments[hidden] { display:none; }
.ai-file-chip { display:inline-flex; align-items:center; gap:4px; max-width:100%; padding:3px 7px 3px 9px; border:1px solid var(--border); border-radius:999px; background:#151a21; color:var(--muted); font-size:11.5px; }
.ai-file-chip span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ai-file-chip button { flex:none; width:18px; height:18px; padding:0; border:0; border-radius:50%; background:transparent; color:var(--muted); line-height:1; }
.ai-file-chip button:hover { color:var(--text); background:#303846; }
.ai-file-error { position:absolute; left:12px; right:12px; bottom:12px; z-index:3; padding:10px 14px; border:1px solid #6b2b2b; border-radius:8px;
background:#3a1d1d; color:#ffb3b3; font-size:13px; line-height:1.4; text-align:center; overflow-wrap:anywhere;
box-shadow:0 8px 24px rgba(0,0,0,.4); opacity:0; transform:translateY(8px); pointer-events:none;
transition:opacity .2s ease, transform .2s ease; }
.ai-file-error.show { opacity:1; transform:none; }
.ai-form-in.drag .ai-inputbox { border-color:var(--accent); background:#111a28; box-shadow:0 0 0 3px rgba(91,140,255,.24), 0 0 18px rgba(91,140,255,.16); }
.ai-form-in.drag .ai-inputbox::after { content:'Отпустите файл, чтобы прикрепить'; position:absolute; inset:3px; z-index:4; display:grid; place-items:center; padding:8px; border:1px dashed rgba(157,184,255,.75); border-radius:9px; background:rgba(23,43,72,.88); color:#dce7ff; font-size:12px; font-weight:550; text-align:center; pointer-events:none; }
@media (max-width: 640px) {
/* A phone: the chat is a screen of its own, sliding up. */
.ai-box { inset:0; width:auto; max-width:none; height:auto; border-radius:0; border:0; z-index:60; transform:translateY(40px); }
.ai-box.on { transform:none; }
}
/* The panel's own confirm and prompt. */
dialog.uidlg { width:fit-content; min-width:min(300px, 92vw); max-width:min(560px, 92vw); }
.uidlg form { padding:18px; display:grid; gap:14px; }
.uidlg .ui-text { margin:0; white-space:pre-wrap; line-height:1.45; }
.uidlg form > * { min-width:0; }
.uidlg .ui-text { overflow:hidden; }
.uidlg .ui-long { display:inline-block; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; vertical-align:bottom; }
.uidlg .ui-input[hidden] { display:none; }
.uidlg .ui-input:focus { outline:none; border-color:var(--accent); box-shadow:0 0 0 2px rgba(91,140,255,.12); }
.sshdlg .chk { display:flex; align-items:center; gap:8px; font-size:13px; color:var(--muted); }
.sshdlg .chk input { width:auto; }
.sshdlg .saved-row[hidden] { display:none; }
.sshdlg .saved-row .frow { align-items:center; }
.ai-agent { display:inline-flex; align-items:center; gap:5px; font-size:12.5px; color:var(--muted); cursor:pointer; }
.ai-agent input { width:auto; margin:0; }
.ai-msg.result { align-self:stretch; max-width:none; padding:4px 10px; font-size:12.5px; color:var(--muted); background:transparent; border:1px dashed var(--border); }
.ai-msg.result summary { cursor:pointer; }
.ai-msg.result pre { margin:6px 0 2px; max-height:220px; overflow:auto; white-space:pre-wrap; font-size:12px; }
.ai-cap { font-size:12px; margin-bottom:4px; }
/* Modes as pills: on - lit in the accent, off - a quiet outline. */
.ai-pills { display:flex; align-items:center; gap:6px; padding:8px 10px 0; border-top:1px solid var(--border); }
.ai-pills .ai-pill { flex:none; }
/* The model: its name and a chevron at the right of the pills, quiet until
pointed at; the list opens upwards, as a small card. */
.ai-mpick { position:relative; margin-left:auto; min-width:0; }
.ai-mpick[hidden] { display:none; }
/* A short name (12 characters at most) is shown whole. */
.ai-mpick.short { flex-shrink:0; }
.ai-mbtn { display:inline-flex; align-items:center; gap:3px; max-width:100%; height:28px; padding:0 5px 0 7px; border:0; border-radius:8px;
background:transparent; color:var(--muted); font-size:12.5px; font-weight:500; transition:background-color .15s, color .15s; }
.ai-mbtn:hover, .ai-mpick.open .ai-mbtn { color:var(--text); background:#1b222c; }
.ai-mbtn:focus-visible { outline:none; box-shadow:0 0 0 2px rgba(79,157,255,.45); }
.ai-mname { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ai-mchev { flex:none; opacity:.8; transition:transform .2s ease; }
.ai-mpick.open .ai-mchev { transform:rotate(180deg); }
.ai-mmenu { position:absolute; right:0; bottom:calc(100% + 8px); z-index:6; min-width:210px; max-width:280px; max-height:260px; overflow:auto;
display:grid; gap:2px; padding:5px; background:var(--panel); border:1px solid var(--border); border-radius:12px;
box-shadow:0 12px 32px rgba(0,0,0,.5); transform-origin:bottom right;
opacity:0; visibility:hidden; transform:translateY(6px) scale(.98); pointer-events:none;
transition:opacity .16s ease, transform .16s ease, visibility 0s linear .16s; }
.ai-mmenu::before { content:'Модель'; padding:5px 10px 4px; font-size:11px; font-weight:600; letter-spacing:.04em; text-transform:uppercase; color:var(--muted); }
.ai-mpick.open .ai-mmenu { opacity:1; visibility:visible; transform:none; pointer-events:auto; transition:opacity .16s ease, transform .16s ease; }
.ai-mitem { display:flex; align-items:center; justify-content:space-between; gap:12px; width:100%; padding:8px 10px; border:0; border-radius:8px;
background:transparent; color:var(--text); font-size:13.5px; font-weight:450; text-align:left; }
.ai-mitem:hover, .ai-mitem:focus-visible { outline:none; background:#1b222c; }
.ai-mitem-name { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ai-mcheck { flex:none; display:flex; color:var(--accent); visibility:hidden; }
.ai-mitem[aria-selected="true"] .ai-mcheck { visibility:visible; }
.ai-pill { display:inline-flex; align-items:center; gap:6px; height:28px; padding:0 10px; border-radius:999px; font-size:12.5px; font-weight:500;
background:transparent; color:var(--muted); border:1px solid var(--border); transition:background .18s, color .18s, border-color .18s; }
.ai-pill:hover { color:var(--text); border-color:#3a4554; }
.ai-pill[aria-pressed="true"] { background:rgba(91,140,255,.16); color:#9db8ff; border-color:rgba(91,140,255,.55); }
/* The chat's title moves it on a computer. */
body:not(.touch) .ai-head { cursor:grab; user-select:none; -webkit-user-select:none; }
body.ai-dragging, body.ai-dragging * { cursor:grabbing !important; user-select:none; -webkit-user-select:none; }
body:not(.touch) .ai-fab { cursor:grab; touch-action:none; }
.ai-form-in textarea, .ai-attach { box-sizing:border-box; }
@media (max-width: 640px) {
.ai-fab { width:46px; height:46px; right:22px; bottom:24px; }
body.touch.term-active .ai-fab { bottom:110px; }
.ai-form-in textarea { font-size:16px; }
}
.ai-clear { width:30px; height:30px; padding:0; display:inline-flex; align-items:center; justify-content:center; }
/* The ball steps aside while the chat is open, and back when it shuts. */
.ai-fab { transition:transform .22s ease, opacity .22s ease, box-shadow .2s ease, right .25s ease, bottom .25s ease; }
.ai-fab.away { opacity:0; transform:scale(.5); pointer-events:none; }
body.ai-dragging .ai-fab, body.ai-dragging .ai-box { transition:none; }
/* The field and the send button: one height on every phone, the button
square whatever the text size. */
.ai-form-in textarea { min-height:32px; }
.ai-form-in textarea { border-radius:10px; }
/* Настройки: the key with its eye; PostgreSQL's fields only when chosen. */
.ai-form .keybox { display:flex; gap:6px; align-items:center; }
.ai-form .keybox input { flex:1; }
.ai-form .keybox .btn.sq { width:38px; height:38px; flex:none; }
.db-form:not(.pg) .pg-fields { display:none; }
.db-form .pg-fields { display:grid; gap:10px; }
.db-form .frow2 { display:flex; gap:10px; }
.db-form .frow2 label:first-child { flex:1; }
.db-form .frow2 .port { width:120px; }
.db-actions { margin-top:12px; }
.db-actions form { margin:0; }
/* Moving between Машины, WorkSpace and Настройки: the page comes in softly. */
main#app > .view:not([hidden]) { animation:view-in .22s ease; } /* no fill: a lasting transform would carry fixed menus with it */
@keyframes view-in { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:none; } }
/* The assistant's answer: Markdown, and its thinking folded above. */
.ai-text p { margin:0 0 6px; } .ai-text p:last-child { margin-bottom:0; }
.ai-text h4 { margin:8px 0 4px; font-size:14px; }
.ai-text ul, .ai-text ol { margin:2px 0 6px; padding-left:20px; }
.ai-text li { margin:2px 0; }
.ai-text code { font:12.5px ui-monospace, SFMono-Regular, Menlo, monospace; background:#0c0f13; border:1px solid var(--border); border-radius:5px; padding:0 4px; }
.ai-text blockquote { margin:4px 0; padding-left:10px; border-left:2px solid var(--border); color:var(--muted); }
.ai-text a { color:var(--accent); }
.ai-text hr { border:0; border-top:1px solid var(--border); margin:8px 0; }
.ai-think { margin:0 0 6px; }
.ai-think[hidden] { display:none; }
.ai-think-h { display:inline-flex; align-items:center; gap:5px; padding:2px 8px 2px 6px; margin-left:-6px; background:none; border:0; border-radius:6px; color:var(--muted); font-size:12.5px; cursor:pointer; }
.ai-think-h:hover, .ai-think-h:focus-visible { color:var(--text); background:#151a21; }
.ai-think-h:focus:not(:focus-visible) { background:none; }
.ai-think-h .chev { display:inline-block; width:10px; text-align:center; font-size:15px; line-height:1; transition:transform .2s ease; }
.ai-think.open .ai-think-h .chev { transform:rotate(90deg); }
.ai-think.going .ai-think-h .lbl { background:linear-gradient(90deg, var(--muted) 30%, var(--text) 50%, var(--muted) 70%) 0 0/200% 100%;
-webkit-background-clip:text; background-clip:text; color:transparent; animation:ai-shine 1.4s linear infinite; }
@keyframes ai-shine { to { background-position:-200% 0; } }
.ai-think-b { display:grid; grid-template-rows:0fr; transition:grid-template-rows .22s ease, opacity .2s ease; opacity:0; }
.ai-think.open .ai-think-b { grid-template-rows:1fr; opacity:1; }
.ai-think-in { min-height:0; overflow:hidden; font-size:12px; color:var(--muted); line-height:1.45;
margin-top:4px; padding-left:10px; border-left:2px solid var(--border); }
.ai-think.open .ai-think-in { max-height:220px; overflow:auto; }
/* With thinking, the answer keeps its width: the thinking opens without
reflowing (short answers made the bubble narrow, the words then moved). */
.ai-msg.assistant:has(> .ai-think:not([hidden])) { width:92%; }
.ai-think-in p { margin:0 0 4px; }
/* Folding parts (Сменить пароль, PIN-код, more settings, results) open and
shut smoothly, their arrow turning, as the + menu's groups do. */
:root { interpolate-size: allow-keywords; }
details > summary { list-style: none; cursor: pointer; display: flex; align-items: center; gap: 6px; }
details > summary::-webkit-details-marker { display: none; }
details > summary::before { content: ''; flex: none; width: 6px; height: 6px; margin: 0 4px 0 2px; border: solid currentColor;
border-width: 0 1.6px 1.6px 0; transform: rotate(-45deg); opacity: .7; transition: transform .2s ease; }
details[open] > summary::before { transform: rotate(45deg); }
details::details-content { block-size: 0; overflow: hidden; opacity: 0;
transition: block-size .25s ease, opacity .2s ease, content-visibility .25s allow-discrete; }
details[open]::details-content { block-size: auto; opacity: 1; }
/* The assistant's chats: the name in the header, the list under it. */
.ai-head { position:relative; }
.ai-chatsel { display:inline-flex; align-items:center; gap:6px; min-width:0; max-width:calc(100% - 64px); padding:4px 6px; margin-left:-6px; border-radius:7px;
background:none; border:0; color:var(--text); font-weight:600; font-size:14px; cursor:pointer; }
.ai-chatsel:hover { background:#151a21; }
.ai-chatname { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ai-chatsel .chev { flex:none; color:var(--muted); transition:transform .2s ease; }
.ai-chatsel[aria-expanded="true"] .chev { transform:rotate(90deg); }
.ai-box { position:fixed; }
.ai-chats { position:absolute; left:8px; right:8px; top:52px; z-index:3; display:grid; grid-template-rows:0fr; opacity:0; pointer-events:none;
background:var(--panel); border:1px solid transparent; border-radius:10px; box-shadow:0 14px 30px rgba(0,0,0,.5);
transition:grid-template-rows .22s ease, opacity .18s ease, border-color .22s ease; }
.ai-chats.open { grid-template-rows:1fr; opacity:1; pointer-events:auto; border-color:var(--border); }
.ai-chats-in { min-height:0; overflow:hidden; display:flex; flex-direction:column; }
.ai-chatnew { margin:6px; padding:7px 10px; text-align:left; background:none; border:1px dashed var(--border); border-radius:7px; color:var(--accent); font-size:13px; cursor:pointer; }
.ai-chatnew:hover { background:#12202f; }
.ai-chatlist { max-height:min(320px, 50vh); overflow-y:auto; padding:0 6px 6px; }
.ai-chatrow { display:flex; align-items:center; gap:4px; border-radius:7px; }
.ai-chatrow:hover, .ai-chatrow.on { background:#12202f; }
.ai-chatopen { flex:1; min-width:0; padding:7px 10px; text-align:left; background:none; border:0; color:var(--text); font-size:13px;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; cursor:pointer; }
.ai-chatdel { flex:none; width:28px; height:28px; padding:0; display:inline-flex; align-items:center; justify-content:center; background:none; border:0;
border-radius:6px; color:var(--muted); cursor:pointer; opacity:.6; }
.ai-chatrow:hover .ai-chatdel { opacity:1; }
.ai-chatdel:hover { color:var(--danger); background:rgba(255,92,92,.1); }
.ai-chatnone { padding:8px 10px; font-size:13px; }
/* An action in an answer: a compact card, as in Cursor. */
.ai-code.ai-action { border:1px solid var(--border); border-radius:9px; overflow:hidden; background:#0c0f13; }
.ai-code-h { display:flex; align-items:center; gap:8px; padding:5px 6px 5px 10px; background:#11161d; border-bottom:1px solid var(--border); font-size:12px; }
.ai-code.ai-action:not(:has(pre)) .ai-code-h { border-bottom:0; }
.ai-kind { display:inline-flex; align-items:baseline; gap:6px; min-width:0; color:var(--muted); }
.ai-kind svg { align-self:center; flex:none; color:var(--accent); }
.ai-kind b { font-weight:500; color:var(--text); flex:none; }
.ai-where-t { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-family:ui-monospace, Menlo, monospace; font-size:11.5px; }
.ai-act { margin-left:auto; flex:none; height:24px; padding:0 9px; min-height:0; line-height:24px; border-radius:6px; border:0; font-size:12px; font-weight:500;
background:var(--accent); color:#fff; cursor:pointer; }
.ai-act:hover:not(:disabled) { filter:brightness(1.1); }
.ai-act:disabled { background:#1f2733; color:var(--muted); cursor:default; }
.ai-code.ai-action pre { margin:0; border:0; border-radius:0; background:transparent; }
/* Аккаунт › Вид. */
.acct-view { margin-top:12px; padding-top:12px; border-top:1px solid var(--border); }
.acct-view summary { font-size:14px; color:var(--text); }
.acct-viewin { display:grid; gap:8px; margin-top:10px; }
.acct-viewin label { display:flex; align-items:center; justify-content:space-between; gap:10px; font-size:13px; color:var(--muted); }
.acct-viewin select { width:170px; flex:none; }
/* The look chosen. */
body[data-tabicons="right"] .tab-kinds { order:2; }
body[data-tabicons="right"] .tab-title { order:1; }
body[data-tabicons="right"] .tab-x { order:3; }
body[data-tabicons="hide"] .tab-kinds { display:none; }
body.compact table.fm td { padding-top:3px; padding-bottom:3px; }
body.compact table.fm td .btn.icon { width:28px; height:28px; }
body.no-ftime table.fm .c-mt, body.no-ftime table.fm td.mt { display:none; }
body.no-motion *, body.no-motion *::before, body.no-motion *::after, body.no-motion::backdrop { transition:none !important; animation:none !important; }
/* Uploading: a small ring that fills as the files go up. */
.fp-ring { display:none; transform:rotate(-90deg); flex:none; }
.busy.uploading .spinner { display:none; }
.busy.uploading .fp-ring { display:block; }
.fp-ring circle { fill:none; stroke-width:2.6; }
.fp-ring .bg { stroke:var(--border); }
.fp-ring .fg { stroke:var(--accent); stroke-linecap:round; stroke-dasharray:100; stroke-dashoffset:calc(100 - var(--up, 0)); transition:stroke-dashoffset .25s ease; }
/* Настройки › ИИ-ассистент › поиск: the fields of the engine chosen. */
.ai-form .ai-sub { margin:10px 0 0; font-size:15px; }
/* Настройки: the models, a card each. */
.ai-models { display:grid; gap:10px; }
.ai-model { display:grid; gap:10px; min-width:0; margin:0; padding:12px; border:1px solid var(--border); border-radius:10px; }
.ai-model-head { display:flex; align-items:flex-end; gap:8px; margin:12px 0 4px; }
.ai-model-head .ai-model-name { flex:1; min-width:0; margin:0; }
.ai-model-head .ai-model-short { flex:none; width:96px; margin:0; }
.ai-model-del { flex:none; height:38px; }
.ai-models .ai-model:only-child .ai-model-del { display:none; }
.ai-form:not([data-search="searxng"]) .s-url { display:none; }
.ai-form:not([data-search="brave"]):not([data-search="tavily"]) .s-key { display:none; }
/* A question about an action: where, the command in mono, a note. */
.ui-extra { display:grid; gap:8px; margin-top:-4px; min-width:0; }
.ui-chip { justify-self:start; display:inline-block; vertical-align:middle; margin:-2px 2px 0; max-width:100%; padding:1px 8px; line-height:18px; border-radius:999px; background:#12202f; color:#9db8ff;
font:12px ui-monospace, SFMono-Regular, Menlo, monospace; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ui-code { margin:0; padding:9px 11px; background:#0c0f13; border:1px solid var(--border); border-radius:8px; max-height:220px; overflow:auto;
font:12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space:pre; color:var(--text); }
/* A command is shown as it is: no line broken inside it, a long one scrolls. */
.ui-note { margin:0; font-size:12px; color:var(--muted); }
/* Close buttons stay square, however tight the row they are in. */
.btn.close.x { flex:none; aspect-ratio:1; min-width:0; }
.ai-head .ai-close { width:32px; height:32px; }
.addmenu .ico .dot { display:inline-block; vertical-align:middle; margin:0; }
/* Машины: each column's name over the middle of its values (on a computer;
a phone lays the rows out as blocks). */
@media (min-width: 641px) {
table.machines th, table.machines td { text-align:center; }
table.machines td.actions .row { justify-content:center; }
}
/* The + menu's roots (Терминал, Файловый менеджер) a little to the left of
what they open, so the nesting reads. */
.addmenu > .item { padding-left:7px; }
+132
View File
@@ -0,0 +1,132 @@
// 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 from here and the fragment drawn again from the answer. A fragment's
// script is a module registered here, mounted on the fragment's root and
// unmounted - its timers, requests and listeners dropped - when it goes.
(function () {
'use strict';
const views = {'/': 'machines', '/workspace': 'terminal', '/terminal': 'terminal', '/settings': 'settings'};
const modules = {};
const mounted = new Map(); // area -> {mod, scope}
const $ = s => document.querySelector(s);
const viewOf = path => views[path] || null;
function register(name, mod) { modules[name] = mod; }
const scope = WSUtil.scope;
function unmount(area) {
const m = mounted.get(area);
if (!m) return;
mounted.delete(area);
try { if (m.mod.unmount) m.mod.unmount(m.root); } finally { m.scope.end(); }
}
function mount(area) {
const root = area.querySelector('[data-module]');
const mod = root && modules[root.dataset.module];
if (!mod) return;
const sc = scope();
mounted.set(area, {mod, scope: sc, root});
mod.mount(root, sc);
}
// put draws html in area, the old module unmounted and the new one mounted.
function put(area, html) {
unmount(area);
area.innerHTML = html;
mount(area);
}
const failHTML = msg => '<div class="wrap"><p class="err">' + msg + ' <button type="button" class="btn ghost small" data-retry>Повторить</button></p></div>';
function withFrag(url) { const u = new URL(url, location.href); u.searchParams.set('frag', '1'); return u; }
// A request that ended on the sign-in page: the session is over.
function signedOut(r) { return r.redirected && new URL(r.url).pathname === '/login'; }
async function load(area, url) {
let r;
try { r = await fetch(withFrag(url), {credentials: 'same-origin', cache: 'no-store'}); } catch (e) { r = null; }
if (r && signedOut(r)) { location.href = '/login'; return; }
if (!r || !r.ok) {
put(area, failHTML(r ? 'не загрузилось: ошибка ' + r.status : 'нет связи с сервером'));
area.querySelector('[data-retry]').onclick = () => load(area, url);
return;
}
put(area, await r.text());
}
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}));
}
// The address the shell shows; a history step that keeps it (a chat on a
// phone closing by «назад») moves no view.
let shown = location.pathname + location.search;
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 = u.href; return; }
if (push && u.pathname + u.search !== location.pathname + location.search) history.pushState({}, '', u.pathname + u.search + u.hash);
shown = u.pathname + u.search;
show(view);
if (view !== 'terminal') await load($('.view[data-view="' + view + '"]'), u.pathname + u.search);
}
// Links to a view move to it; any other link behaves as a link.
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 || a.hasAttribute('download')) 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', () => { if (location.pathname + location.search !== shown) go(location.href, false); });
// A form in a fragment is sent from here: the answer's address says what
// to draw. /logout and forms outside the views go the usual way; a form
// whose onsubmit said no (a confirm) is not sent.
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; }
if (signedOut(r)) { location.href = '/login'; return; }
const to = new URL(r.url);
// From a dialog to a view (a machine deleted): the dialog closes.
if (!area.classList.contains('view') && viewOf(to.pathname)) {
const d = area.closest('dialog');
if (d) d.close();
go(to.href);
return;
}
if (area.classList.contains('view')) {
const v = viewOf(to.pathname);
if (v && v !== area.dataset.view) { go(to.href); return; }
history.replaceState({}, '', to.pathname + to.search + to.hash);
}
// The answer is the page whole (the shell): ask for its fragment.
await load(area, to.pathname + to.search);
if (to.hash) { const t = area.querySelector(to.hash); if (t) t.scrollIntoView(); }
});
// Leaving the page drops the terminals: the browser asks while one is alive.
window.addEventListener('beforeunload', ev => {
if (window.Workspace && window.Workspace.hasLiveSessions()) { ev.preventDefault(); ev.returnValue = ''; }
});
// start mounts what the server drew, once every script has registered.
function start() {
// The old address of the workspace shows as the new one.
if (location.pathname === '/terminal') { history.replaceState({}, '', '/workspace' + location.search); shown = '/workspace' + location.search; }
document.querySelectorAll('main#app > .view').forEach(mount);
show({dashboard: 'machines', settings: 'settings'}[$('main#app').dataset.view] || 'terminal');
// /agents/{id}/terminal or /files: that pane, at once.
const o = window.APP && window.APP.open;
if (o && window.Workspace) window.Workspace.open(o.kind, o.agent);
}
window.App = {go, load, register, put, start, scope};
})();
+2 -2
View File
@@ -262,10 +262,10 @@
if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); done(); } if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); done(); }
}); });
}; };
$('.dbx-del').onclick = function () { $('.dbx-del').onclick = async function () {
var keys = []; var keys = [];
S.sel.forEach(function (i) { keys.push(S.data.rows[i].k); }); S.sel.forEach(function (i) { keys.push(S.data.rows[i].k); });
if (!keys.length || !confirm('Удалить строк: ' + keys.length + '?')) return; if (!keys.length || !await UI.confirm('Удалить строк: ' + keys.length + '?', {ok: 'Удалить', danger: true})) return;
req('db.del', {table: S.cur, keys: keys}).then(function (r) { req('db.del', {table: S.cur, keys: keys}).then(function (r) {
if (!r.ok) { toast(r.error || 'не удалилось', true); return; } if (!r.ok) { toast(r.error || 'не удалилось', true); return; }
toast('удалено строк: ' + r.data.deleted); toast('удалено строк: ' + r.data.deleted);
+221
View File
@@ -0,0 +1,221 @@
// Dragging files out of a file pane: onto another file pane or a folder in
// it to move or copy them there (between machines and SSH hosts too, by the
// transfer the move dialog uses), or into a terminal of the same place to
// paste their paths. A terminal of another place refuses them, red, with
// the reason. Held over a tab's title for half a second, the tab opens to
// drop into. Esc or empty space cancels. Computers only.
(function () {
'use strict';
if (!Workspace.desk) return;
const FV = window.FileViews;
const {toast, join, esc} = FV;
let d = null, justDropped = false;
const box = document.createElement('div');
box.className = 'hint drophint';
box.hidden = true;
document.body.appendChild(box);
// start: a pointerdown on a row of pane; the drag begins past 6px.
function start(ev, pane, tr) {
if (ev.button !== 0 || ev.target.closest('input, button, a, .act')) return;
// Keep the file pane's pointer drag as the only drag source. Otherwise
// the browser can start a native text-selection drag beside our ghost.
ev.preventDefault();
const name = tr.dataset.name;
const names = pane.selected.has(name) ? [...pane.selected] : [name];
d = {pane, names, x0: ev.clientX, y0: ev.clientY, id: ev.pointerId, on: false,
item: {source: pane.source, dir: pane.cwd, paths: names.map(n => join(pane.cwd, n))}};
}
function begin() {
d.on = true;
const g = d.ghost = document.createElement('div');
g.className = 'drag-ghost';
g.textContent = d.names.length === 1 ? d.names[0] : d.names.length + ' элементов';
document.body.appendChild(g);
document.body.classList.add('dnd');
getSelection().removeAllRanges();
}
function show(r, label, bad) {
box.hidden = false;
box.classList.toggle('bad', !!bad);
box.style.left = r.left + 'px'; box.style.top = r.top + 'px';
box.style.width = r.width + 'px'; box.style.height = r.height + 'px';
box.textContent = label;
}
// inside: dropping folder p into itself or below it.
const inside = (dir, p) => dir === p || dir.startsWith(p.replace(/\/+$/, '') + '/');
function move(ev) {
if (!d || ev.pointerId !== d.id) return;
if (!d.on) {
if (Math.hypot(ev.clientX - d.x0, ev.clientY - d.y0) < 6) return;
begin();
}
d.ghost.style.transform = 'translate(' + (ev.clientX + 12) + 'px,' + (ev.clientY + 10) + 'px)';
// What the browser began to select before the drag was told apart goes.
const sel = getSelection();
if (sel && !sel.isCollapsed) sel.removeAllRanges();
d.target = null;
box.hidden = true;
d.ghost.dataset.hint = '';
if (window.AIChat) window.AIChat.setDragging(false);
// Over a tab's title: it opens after half a second.
const tab = Workspace.tabAt(ev.clientX, ev.clientY);
if (tab !== d.overTab) {
clearTimeout(d.tabTimer);
d.overTab = tab;
if (tab) d.tabTimer = setTimeout(() => { if (d && d.overTab === tab) Workspace.activate(tab); }, 500);
}
if (tab) { d.ghost.dataset.hint = 'открыть вкладку'; return; }
if (window.AIChat && window.AIChat.isDropTargetAt(ev.clientX, ev.clientY)) {
window.AIChat.setDragging(true);
d.target = {kind: 'ai'};
return;
}
const pane = Workspace.paneAt(ev.clientX, ev.clientY);
if (!pane || !pane.dropTarget) return;
const t = pane.dropTarget(ev.clientX, ev.clientY);
const a = WSUtil.dropAction(d.item, t);
const r = t.kind === 'folder' ? t.row.getBoundingClientRect() : pane.el.getBoundingClientRect();
if (a.kind === 'deny') { show(r, a.reason, true); return; }
if (a.kind === 'none') return;
if (a.kind === 'transfer' && t.source.id === d.item.source.id && d.item.paths.some(p => inside(t.dir, p))) {
show(r, 'папку не положить в саму себя', true);
return;
}
show(r, a.kind === 'paste' ? 'вставить путь' : 'сюда: ' + t.dir);
d.target = {pane, t, a};
}
function end(ev, cancel) {
if (!d || (ev && ev.pointerId !== d.id)) return;
const x = d;
d = null;
clearTimeout(x.tabTimer);
if (!x.on) return;
x.ghost.remove();
box.hidden = true;
document.body.classList.remove('dnd');
if (window.AIChat) window.AIChat.setDragging(false);
justDropped = true;
setTimeout(() => { justDropped = false; }, 0);
if (cancel || !x.target) return;
if (x.target.kind === 'ai') { window.AIChat.dropFiles(x.pane, x.names); return; }
const {pane, t, a} = x.target;
Workspace.setActivePane(pane);
if (a.kind === 'paste') pane.drop(x.item);
else if (a.kind === 'transfer') ask(x.item, x.pane, t, ev.clientX, ev.clientY);
}
// While FileDnD owns the pointer, never let the browser start a second,
// native drag (for example from the SVG icon or the filename under it).
document.addEventListener('dragstart', ev => {
if (!d) return;
ev.preventDefault();
ev.stopImmediatePropagation();
}, true);
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', ev => end(ev));
window.addEventListener('pointercancel', ev => end(ev, true));
window.addEventListener('keydown', ev => { if (ev.key === 'Escape' && d) end(null, true); });
// A drop is not a click on the row it started from.
document.addEventListener('click', ev => { if (justDropped) { ev.stopPropagation(); ev.preventDefault(); } }, true);
// ---------- the small window where the files were dropped ----------
const dlg = document.createElement('div');
dlg.className = 'dropdlg';
dlg.hidden = true;
dlg.innerHTML =
'<div class="dd-what"></div><div class="dd-to muted"></div>' +
'<label class="dd-c">если уже есть: <select class="dd-conflict">' +
'<option value="error">сообщить</option><option value="replace">заменить</option><option value="skip">оставить</option></select></label>' +
'<div class="mv-prog dd-prog" hidden><div class="mv-bar"><i class="dd-bar"></i></div><div class="muted dd-ptext"></div></div>' +
'<div class="dd-btns"><button type="button" class="btn small primary dd-move">Переместить</button>' +
'<button type="button" class="btn small dd-copy">Копировать</button>' +
'<button type="button" class="btn small ghost dd-cancel">Отмена</button></div>';
document.body.appendChild(dlg);
const q = s => dlg.querySelector(s);
let cur = null;
function ask(item, from, t, x, y) {
if (cur && cur.job) { toast('дождитесь конца передачи', true); return; }
cur = {item, from, t, job: null, stop: false};
const n = item.paths.length;
q('.dd-what').textContent = n === 1 ? item.paths[0].split('/').pop() : n + ' элементов';
q('.dd-to').innerHTML = '→ ' + esc(t.source.label) + ':' + esc(t.dir);
q('.dd-conflict').value = 'error';
q('.dd-prog').hidden = true;
q('.dd-move').disabled = q('.dd-copy').disabled = false;
q('.dd-cancel').textContent = 'Отмена';
dlg.hidden = false;
const w = dlg.offsetWidth, h = dlg.offsetHeight;
dlg.style.left = Math.max(8, Math.min(innerWidth - w - 8, x - w / 2)) + 'px';
dlg.style.top = Math.max(8, Math.min(innerHeight - h - 8, y + 12)) + 'px';
q('.dd-move').focus();
}
function close() { dlg.hidden = true; cur = null; }
q('.dd-cancel').onclick = () => {
if (cur && cur.job) { cur.stop = true; fetch('/api/panel/fs/job/' + cur.job + '/cancel', {method: 'POST'}); q('.dd-ptext').textContent = 'отмена…'; return; }
close();
};
dlg.addEventListener('keydown', ev => { if (ev.key === 'Escape' && !(cur && cur.job)) close(); });
document.addEventListener('pointerdown', ev => { if (!dlg.hidden && !dlg.contains(ev.target) && !(cur && cur.job)) close(); }, true);
q('.dd-move').onclick = () => run(true);
q('.dd-copy').onclick = () => run(false);
async function run(isMove) {
const c = cur, {item, from, t} = c, conflict = q('.dd-conflict').value;
q('.dd-move').disabled = q('.dd-copy').disabled = true;
// One place (a machine, or one SSH host): copied there by its own means.
// Between places: the transfer of the move dialog, one item at a time.
const here = item.source.id === t.source.id;
let done = 0, skipped = 0, err = '';
if (!here) {
q('.dd-prog').hidden = false;
q('.dd-bar').style.width = '0';
q('.dd-bar').parentNode.classList.add('wait');
q('.dd-ptext').textContent = 'подготовка…';
q('.dd-cancel').textContent = 'Стоп';
}
// Between places, all of them go as one stream: the first, and the rest
// as "more" (the server sends one by one to older agents).
const batch = here ? item.paths.map(p => [p]) : [item.paths];
for (const group of batch) {
const p = group[0];
if (c.stop) break;
const name = p.split('/').pop();
let r;
if (here) {
r = await from.src.op('copy', {src: p, dst: join(t.dir, name), move: isMove, conflict});
} else {
const body = {src: p, name, dir: t.dir, conflict, move: isMove, more: group.slice(1)};
if (t.source.kind === 'ssh') { body.to = t.pane.agent.id; body.to_conn = t.pane.src.conn; }
else body.to = t.pane.agent.id;
if (from.kind === 'sshfiles') body.from_conn = from.src.conn;
// The transfer is the machine's, whichever side is an SSH host.
r = await new MachineSource(from.agent).op('ship', body);
if (r.ok) {
c.job = r.data.job;
const pre = '';
r = await FV.followJob(c.job, (pr, text) => {
if (pr.total > 0 || pr.done > 0) {
q('.dd-bar').parentNode.classList.remove('wait');
q('.dd-bar').style.width = (pr.total ? Math.min(100, pr.done / pr.total * 100) : 0).toFixed(1) + '%';
}
if (text) q('.dd-ptext').textContent = pre + text;
});
c.job = null;
}
}
if (!r.ok) { err = r.error || 'ошибка'; break; }
done += group.length;
skipped += (r.data && r.data.skipped) || 0;
}
close();
from.reload();
if (t.pane !== from) t.pane.reload();
if (err) toast((done ? 'сделано ' + done + ' из ' + item.paths.length + '; ' : '') + err, true);
else toast((isMove ? 'перемещено' : 'скопировано') + (item.paths.length > 1 ? ': ' + done : '') + (skipped ? ', оставлено как было: ' + skipped : ''));
}
window.FileDnD = {start, get active() { return !!(d && d.on); }};
})();
+148 -1
View File
@@ -524,5 +524,152 @@
return sheets(list); return sheets(list);
} }
window.DocView = {docx, xlsx, ods, odt, csv, doc, sqlite, bindTabs}; // Extract text from PDF content streams without a heavyweight renderer or OCR.
// Flate-compressed page streams are inflated with the browser's native API.
async function pdf(buf) {
const input = new Uint8Array(buf);
let source = '';
for (let i = 0; i < input.length; i += 0x8000) source += String.fromCharCode(...input.subarray(i, i + 0x8000));
if (!source.startsWith('%PDF-')) throw new Error('это не PDF-файл');
const objects = /\b(\d+)\s+\d+\s+obj\b/g, pages = [];
let inflatedBytes = 0, textBytes = 0, match;
async function inflate(data) {
const reader = new Blob([data]).stream().pipeThrough(new DecompressionStream('deflate')).getReader();
const chunks = []; let total = 0;
for (;;) {
const part = await reader.read();
if (part.done) break;
total += part.value.length;
if (total > 16 * 1024 * 1024 || inflatedBytes + total > 32 * 1024 * 1024) {
await reader.cancel(); throw new Error('PDF stream слишком велик');
}
chunks.push(part.value);
}
const result = new Uint8Array(total); let at = 0;
chunks.forEach(chunk => { result.set(chunk, at); at += chunk.length; });
inflatedBytes += total;
return result;
}
while ((match = objects.exec(source))) {
const bodyStart = objects.lastIndex, possibleEnd = source.indexOf('endobj', bodyStart);
if (possibleEnd < 0) break;
const probe = source.slice(bodyStart, possibleEnd), stream = /\bstream\r?\n/.exec(probe);
if (!stream) { objects.lastIndex = possibleEnd + 6; continue; }
const dict = probe.slice(0, stream.index);
let start = bodyStart + stream.index + stream[0].length;
const length = /\/Length\s+(\d+)\b/.exec(dict);
let end = length ? start + Number(length[1]) : source.indexOf('endstream', start);
if (end < start) { objects.lastIndex = possibleEnd + 6; continue; }
let endObj = source.indexOf('endobj', length ? end : end + 9);
if (endObj < 0) break;
if (/\/Subtype\s*\/Image|\/Type\s*\/(?:ObjStm|XObject|Font|FontFile)/.test(dict)) { objects.lastIndex = endObj + 6; continue; }
let raw = source.slice(start, end);
if (!length) raw = raw.replace(/[\r\n]+$/, '');
let data = Uint8Array.from(raw, c => c.charCodeAt(0) & 255);
if (dict.includes('/FlateDecode') && !dict.includes('/ASCII85Decode')) {
try { data = await inflate(data); }
catch (e) { objects.lastIndex = endObj + 6; continue; }
} else if (/\/Filter\b/.test(dict)) { objects.lastIndex = endObj + 6; continue; }
let content = '';
for (let i = 0; i < data.length; i += 0x8000) content += String.fromCharCode(...data.subarray(i, i + 0x8000));
if (content.includes('BT') && (content.includes('Tj') || content.includes('TJ'))) {
pages.push(content);
textBytes += content.length;
if (textBytes >= 2 * 1024 * 1024) break;
}
objects.lastIndex = endObj + 6;
}
const decode = bytes => {
if (bytes.length > 1 && bytes[0] === 0xfe && bytes[1] === 0xff) {
const swapped = new Uint8Array(bytes.length - 2);
for (let i = 2; i + 1 < bytes.length; i += 2) { swapped[i - 2] = bytes[i + 1]; swapped[i - 1] = bytes[i]; }
return new TextDecoder('utf-16le').decode(swapped);
}
return new TextDecoder('windows-1252').decode(bytes);
};
function readString(s, at, literal) {
const bytes = [];
if (literal) {
let depth = 1, i = at + 1;
for (; i < s.length && depth; i++) {
let c = s.charCodeAt(i);
if (c === 92) {
c = s.charCodeAt(++i);
if (c === 110) bytes.push(10); else if (c === 114) bytes.push(13); else if (c === 116) bytes.push(9);
else if (c === 98) bytes.push(8); else if (c === 102) bytes.push(12);
else if (c === 10) { /* escaped line break */ }
else if (c === 13) { if (s.charCodeAt(i + 1) === 10) i++; }
else if (c >= 48 && c <= 55) {
let oct = String.fromCharCode(c);
for (let n = 0; n < 2 && /[0-7]/.test(s.charAt(i + 1)); n++) oct += s.charAt(++i);
bytes.push(parseInt(oct, 8) & 255);
} else bytes.push(c & 255);
} else if (c === 40) { depth++; bytes.push(c); }
else if (c === 41) { if (--depth) bytes.push(c); }
else bytes.push(c & 255);
}
return {value: decode(Uint8Array.from(bytes)), end: i};
}
let hex = '', i = at + 1;
for (; i < s.length && s.charAt(i) !== '>'; i++) if (!/\s/.test(s.charAt(i))) hex += s.charAt(i);
if (hex.length % 2) hex += '0';
for (let i = 0; i + 1 < hex.length; i += 2) bytes.push(parseInt(hex.slice(i, i + 2), 16));
return {value: decode(Uint8Array.from(bytes)), end: i + 1};
}
function textFrom(content) {
let out = '', inText = false, i = 0, operands = [];
const token = () => {
while (i < content.length && /[\x00\t\n\f\r ]/.test(content.charAt(i))) i++;
if (content.charAt(i) === '%') { while (i < content.length && content.charAt(i) !== '\n') i++; return token(); }
if (i >= content.length) return null;
const c = content.charAt(i);
if (c === '(') { const v = readString(content, i, true); i = v.end; return {type: 'str', value: v.value}; }
if (c === '<' && content.charAt(i + 1) !== '<') { const v = readString(content, i, false); i = v.end; return {type: 'str', value: v.value}; }
if (c === '[') {
i++; const values = [];
while (i < content.length) {
while (/[\x00\t\n\f\r ]/.test(content.charAt(i))) i++;
if (content.charAt(i) === ']') { i++; break; }
if (content.charAt(i) === '(' || (content.charAt(i) === '<' && content.charAt(i + 1) !== '<')) {
const v = readString(content, i, content.charAt(i) === '('); i = v.end; values.push(v.value);
} else {
const number = /^[+-]?(?:\d+\.?\d*|\.\d+)/.exec(content.slice(i));
if (number) { values.push(Number(number[0])); i += number[0].length; } else i++;
}
}
return {type: 'array', value: values};
}
if (c === '/') { i++; while (i < content.length && !/[\x00\t\n\f\r ()<>\[\]{}\/%]/.test(content.charAt(i))) i++; return {type: 'name'}; }
const start = i++;
while (i < content.length && !/[\x00\t\n\f\r ()<>\[\]{}\/%]/.test(content.charAt(i))) i++;
return {type: 'word', value: content.slice(start, i)};
};
while (i < content.length) {
const t = token();
if (!t) break;
if (t.type !== 'word' || !/^[A-Za-z*'"]+$/.test(t.value)) { operands.push(t); continue; }
const op = t.value;
if (op === 'BT') { inText = true; operands = []; continue; }
if (op === 'ET') { inText = false; out += '\n'; operands = []; continue; }
if (inText && ['Tj', 'TJ', "'", '"'].includes(op)) {
if (op === "'" || op === '"') out += '\n';
const v = operands[operands.length - 1];
if (v && v.type === 'str') out += v.value;
else if (v && v.type === 'array') out += v.value.map(x => typeof x === 'number' ? (x < -120 ? ' ' : '') : x).join('');
} else if (inText && op === 'Tm') {
if (out && !out.endsWith('\n')) out += '\n';
} else if (inText && (op === 'T*' || op === 'Td' || op === 'TD')) {
if (op === 'T*' || Number(operands[operands.length - 1]?.value) !== 0) out += '\n';
else out += ' ';
}
operands = [];
}
return out;
}
const text = pages.map(textFrom).join('\n').replace(/[\t ]+\n/g, '\n').replace(/ {2,}/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
if (!text) throw new Error('в PDF не найден извлекаемый текст (сканированные страницы без OCR не читаются)');
return text.slice(0, 120000);
}
window.DocView = {docx, xlsx, ods, odt, csv, doc, pdf, sqlite, bindTabs};
})(); })();
@@ -0,0 +1,32 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const zlib = require('node:zlib');
const runtime = {window: {}, Blob, Response, DecompressionStream, TextDecoder, Uint8Array};
vm.createContext(runtime);
vm.runInContext(fs.readFileSync(path.join(__dirname, 'docview.js'), 'utf8'), runtime);
function pdfWithStream(content, compressed = false) {
const stream = compressed ? zlib.deflateSync(Buffer.from(content, 'latin1')) : Buffer.from(content, 'latin1');
const filter = compressed ? ' /Filter /FlateDecode' : '';
const prefix = `%PDF-1.4\n1 0 obj\n<< /Length ${stream.length}${filter} >>\nstream\n`;
return Buffer.concat([Buffer.from(prefix, 'latin1'), stream, Buffer.from('\nendstream\nendobj\n%%EOF', 'latin1')]);
}
test('PDF text extraction handles literal strings and escaped parentheses', async () => {
const pdf = pdfWithStream('BT /F1 12 Tf 72 700 Td (Hello \\(PDF\\)) Tj ET');
assert.equal(await runtime.window.DocView.pdf(pdf), 'Hello (PDF)');
});
test('PDF text extraction inflates streams and keeps TJ word spacing', async () => {
const pdf = pdfWithStream('BT /F1 12 Tf [(lightweight) -250 ( parser)] TJ ET', true);
assert.equal(await runtime.window.DocView.pdf(pdf), 'lightweight parser');
});
test('PDF text extraction reports scanned or non-text PDFs without OCR', async () => {
const pdf = pdfWithStream('q 0 0 100 100 re f Q');
await assert.rejects(runtime.window.DocView.pdf(pdf), /без OCR/);
});
+457
View File
@@ -0,0 +1,457 @@
// File panes of the workspace: a folder of a machine - or of an SSH host
// through one - as a list to go through, with uploads, new folders and files,
// zip and delete. Each pane keeps its own folder, list and selection; the
// dialogs (viewer, properties, move) are shared and live in fileviews.js.
// A pane's data source does the work: MachineSource here, SSHSource for
// files over SSH.
(function () {
'use strict';
const FV = window.FileViews;
const {toast, join, parent, fmtSize, fmtTime, esc} = FV;
const X = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>';
// Monochrome inline icons (inherit button color via currentColor).
const SVG = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">';
const ICON_DL = SVG + '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
const ICON_MV = SVG + '<polyline points="5 9 2 12 5 15"/><polyline points="9 5 12 2 15 5"/><polyline points="15 19 12 22 9 19"/><polyline points="19 9 22 12 19 15"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="12" y1="2" x2="12" y2="22"/></svg>';
const ICON_UNZIP = SVG + '<rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h2"/><path d="M20 8v11a2 2 0 0 1-2 2h-2"/><path d="m9 15 3-3 3 3"/><path d="M12 12v9"/></svg>';
const ICON_ZIP = SVG + '<path d="M21 8v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V8"/><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M10 12h4"/></svg>';
const ICON_DEL = SVG + '<path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>';
// File icons: one colour, the line style of the buttons; a few kinds by
// the name's ending.
const FI = '<svg class="fi" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">';
const FICONS = {
dir: FI + '<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>',
file: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg>',
text: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M9 13h6M9 17h4"/></svg>',
code: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M10 12l-2 2.5 2 2.5M14 12l2 2.5-2 2.5"/></svg>',
img: FI + '<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="1.6"/><path d="m21 16-5-5-8 9"/></svg>',
arc: FI + '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5M11 4v2M11 8v2M11 12v2"/><rect x="9.5" y="15" width="3" height="3" rx=".6"/></svg>',
media:FI + '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m10 9 5 3-5 3z"/></svg>',
dirlink: FI + '<path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M10 15.5l4-4M11 11.5h3v3"/></svg>',
link: FI + '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/></svg>'
};
function fileIcon(e) {
let k = 'file';
if (e.is_dir) k = e.link ? 'dirlink' : 'dir'; // a link to a folder opens like one
else if (/^L/.test(e.mode || '')) k = 'link';
else if (/\.(png|jpe?g|gif|webp|bmp|ico|svg|avif|heic)$/i.test(e.name)) k = 'img';
else if (/\.(zip|tar|gz|tgz|bz2|xz|zst|7z|rar|deb|rpm)$/i.test(e.name)) k = 'arc';
else if (/\.(mp4|m4v|webm|mov|mkv|avi|mp3|wav|ogg|flac|m4a|aac|opus)$/i.test(e.name)) k = 'media';
else if (/\.(js|ts|go|py|rb|rs|c|h|cpp|java|kt|php|sh|bash|zsh|json|ya?ml|toml|html?|css|sql|lua|pl|swift|vue|jsx|tsx|ini|xml)$/i.test(e.name)) k = 'code';
else if (/\.(txt|md|log|csv|rst|cfg|conf|ovpn|env|properties)$|^(readme|license|makefile|dockerfile)/i.test(e.name)) k = 'text';
return '<span class="ic' + (k === 'dir' || k === 'dirlink' ? ' dir' : '') + '"' + (e.link ? ' title="ссылка"' : '') + '>' + FICONS[k] + '</span>';
}
// A machine's files: the panel's file API of that machine.
class MachineSource {
constructor(agent) { this.agent = agent; this.caps = {zip: true, unzip: true, sqlite: true}; }
url(what, path) { return '/api/panel/agents/' + encodeURIComponent(this.agent.id) + '/fs/' + what + '?path=' + encodeURIComponent(path); }
op(name, body) {
return fetch('/api/panel/agents/' + encodeURIComponent(this.agent.id) + '/fs/' + name, {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body || {}), credentials: 'same-origin'
}).then(r => r.status === 404 ? {ok: false, error: 'нет доступа к машине'} : r.json()).catch(() => ({ok: false, error: 'ошибка сети'}));
}
home() { return '~' + (this.agent.user || ''); }
close() {}
}
// An SSH host's files, through the machine that holds the connection.
class SSHSource {
// caps: zip and unzip where the host has the commands.
constructor(agent, conn, home, caps) { this.agent = agent; this.conn = conn; this.homeDir = home || '/'; this.caps = Object.assign({zip: false, unzip: false, sqlite: false}, caps); }
base() { return '/api/panel/agents/' + encodeURIComponent(this.agent.id) + '/sshfs/' + encodeURIComponent(this.conn) + '/'; }
url(what, path) { return this.base() + what + '?path=' + encodeURIComponent(path); }
op(name, body) {
return fetch(this.base() + name, {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body || {}), credentials: 'same-origin'
}).then(r => r.status === 404 ? {ok: false, error: 'нет доступа к машине'} : r.json()).catch(() => ({ok: false, error: 'ошибка сети'}));
}
home() { return this.homeDir; }
// The pane closing lets the machine drop the connection at once.
close() { fetch(this.base() + 'close', {method: 'POST', keepalive: true, credentials: 'same-origin'}).catch(() => {}); }
}
const UP_CAP = 512 * 1024 * 1024;
const SORT_DEF = {by: 'name', dir: 1};
const byName = (a, b) => a.name.localeCompare(b.name, undefined, {numeric: true, sensitivity: 'base'}) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
class FilePane {
// o: {kind: 'agent'|'ssh', agent, src} - src made by the caller for SSH.
constructor(o) {
this.agent = o.agent;
this.src = o.src || new MachineSource(o.agent);
this.kind = o.kind === 'ssh' ? 'sshfiles' : 'files';
this.ssh = o.ssh || null;
this.source = o.kind === 'ssh'
? {kind: 'ssh', id: o.agent.id + '|' + o.ssh.user + '@' + o.ssh.host + ':' + (o.ssh.port || 22), label: o.ssh.user + '@' + o.ssh.host}
: {kind: 'agent', id: o.agent.id, label: o.agent.name};
this.cwd = '/';
// startDir: the folder to open first (or a promise of it), not home.
this.startDir = o.dir || '';
this.entries = [];
this.selected = new Set();
this.uploads = [];
this.upActive = false;
this.busyN = 0;
this.state = 'wait';
this.text = 'загрузка…';
this.el = document.createElement('div');
this.el.className = 'pane fpane';
this.el.innerHTML =
'<div class="pane-head"><span class="dot wait"></span>' + (this.ssh ? '<span class="badge-ssh">SSH</span>' : '') +
'<span class="pane-title"></span><button type="button" class="pane-x" title="Закрыть" aria-label="Закрыть">' + X + '</button></div>' +
'<div class="fpane-body">' +
'<div class="fm-bar">' +
' <button class="btn ghost small icon sq fp-up" type="button" title="Вверх" aria-label="Вверх"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button>' +
' <div class="fm-pathbox"><input class="fm-path fp-path" value="/" spellcheck="false" autocapitalize="off" autocomplete="off" aria-label="Папка">' +
' <button type="button" class="btn ghost small icon sq fp-dirprops" title="Свойства папки" aria-label="Свойства папки"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4"/></svg></button></div>' +
' <button type="button" class="busy fp-busy" title=""><span class="spinner"></span>' +
'<svg class="fp-ring" width="18" height="18" viewBox="0 0 20 20" aria-hidden="true"><circle class="bg" cx="10" cy="10" r="8"/><circle class="fg" cx="10" cy="10" r="8" pathLength="100"/></svg>' +
'<span class="fp-uppct"></span></button>' +
' <button class="btn ghost small icon sq fp-refresh" type="button" 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"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg></button>' +
'</div>' +
'<div class="fm-bar">' +
' <button class="btn small ico fp-mkdir" type="button"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/><path d="M12 11v6M9 14h6"/></svg>Папка</button>' +
' <button class="btn small ico fp-newfile" type="button"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M12 12v6M9 15h6"/></svg>Файл</button>' +
' <label class="btn small ico" style="cursor:pointer"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12M6 10l6 6 6-6M5 21h14"/></svg>Загрузить<input type="file" class="fp-upl" multiple hidden></label>' +
// What is done to the ticked rows: two small icons on the right,
// their words on hover, the count beside them.
' <span class="spacer"></span>' +
' <span class="fp-sel"><span class="muted fp-selinfo"></span>' +
' <button class="btn ghost small icon sq fp-zip" type="button" disabled title="В zip" aria-label="В zip">' + ICON_ZIP + '</button>' +
' <button class="btn danger small icon sq fp-delsel" type="button" disabled title="Удалить выбранное" aria-label="Удалить выбранное">' + ICON_DEL + '</button></span>' +
'</div>' +
'<div class="card fp-drop" style="padding:0"><table class="fm"><thead><tr>' +
' <th style="width:28px"><input type="checkbox" class="fp-all" aria-label="Выбрать все"></th>' +
' <th class="srt" data-sort="name">Имя</th><th class="sz c-sz srt" data-sort="size">Размер</th>' +
' <th class="sz c-mt srt" data-sort="mtime">Изменён</th><th class="c-act"></th>' +
'</tr></thead><tbody class="fp-list"></tbody></table></div>' +
'<p class="muted fp-msg" hidden></p>' +
'</div>';
this.q('.fp-zip').hidden = !this.src.caps.zip;
this.bind();
// Narrow panes (a split tab) fold like the phone layout.
// A mid-width pane gives the time's room to the names.
if (window.ResizeObserver) new ResizeObserver(() => {
const w = this.el.clientWidth;
this.el.classList.toggle('narrow', w < 520);
this.el.classList.toggle('mid', w < 760);
this.fitSel();
}).observe(this.el);
this.start();
}
q(sel) { return this.el.querySelector(sel); }
// ---- the pane interface of the workspace ----
title() { return (this.ssh ? this.ssh.host : this.agent.name) + ' · ' + (this.cwd === '/' ? '/' : this.cwd.replace(/\/+$/, '').split('/').pop()); }
tooltip() { return (this.ssh ? this.source.label : this.agent.name) + ':' + this.cwd; }
live() { return false; }
fit() {}
focus() { this.q('.fp-path').focus({preventScroll: true}); }
close() { this.src.close(); this.el.remove(); }
selectedPaths() { return [...this.selected].map(n => join(this.cwd, n)); }
// dropTarget: a folder row under the point, else this folder.
dropTarget(x, y) {
const el = document.elementFromPoint(x, y);
const tr = el && el.closest && el.closest('tr[data-dir]');
if (tr && this.el.contains(tr)) return {kind: 'folder', source: this.source, dir: join(this.cwd, tr.dataset.dir), pane: this, row: tr};
return {kind: 'files', source: this.source, dir: this.cwd, pane: this};
}
setState(state, text) {
this.state = state; this.text = text;
this.q('.pane-head .dot').className = 'dot ' + state;
this.q('.pane-title').textContent = this.title();
this.q('.pane-title').title = this.tooltip();
if (window.Workspace) Workspace.renderPane(this);
}
setBusy(on) {
this.busyN = Math.max(0, this.busyN + (on ? 1 : -1));
this.q('.fp-busy').classList.toggle('on', this.busyN > 0);
}
async api(op, body) {
this.setBusy(true);
try { return await this.src.op(op, body); } finally { this.setBusy(false); }
}
// message: a line over the list - why it is empty - with a button to
// do something about it.
message(text, label, fn) {
const m = this.q('.fp-msg');
m.hidden = !text;
m.textContent = text || '';
if (label) {
const b = document.createElement('button');
b.type = 'button'; b.className = 'btn ghost small'; b.textContent = label;
b.onclick = fn;
m.append(' ', b);
}
}
// trouble: the machine offline, the access gone or the SSH connection
// lost - the pane says so and offers the way back.
trouble(err) {
// A machine that just went away first times out, then is offline.
this.offline = /офлайн|таймаут ожидания агента/.test(err);
if (this.offline) this.message((/офлайн/.test(err) ? 'Машина офлайн' : 'Машина не отвечает') + '. Панель обновится сама, когда машина вернётся.', 'Повторить', () => this.reload());
else if (/нет доступа/.test(err)) this.message('Нет доступа к машине.');
else if (this.ssh && /подключение (к хосту потеряно|закрыто)/.test(err)) this.message(err, 'Переподключиться', () => Workspace.openSSH(this.agent, 'files', this));
else return false;
this.entries = []; this.selected.clear(); this.render();
this.setState('err', err);
return true;
}
async start() {
// Start in the home folder of the machine's user (or the SSH home);
// a listing that fails there falls back to the root.
const dir = await this.startDir;
this.startDir = '';
if (dir) {
const d = await this.api('list', {path: dir});
if (d.ok) { this.show(d.data); return; }
}
const r = await this.api('list', {path: this.src.home()});
if (r.ok) this.show(r.data); else if (!this.trouble(r.error || '')) this.go('/');
}
show(d) {
this.offline = false; this.loaded = true;
this.cwd = d.path; this.entries = d.entries || []; this.selected.clear();
this.q('.fp-path').value = this.cwd;
this.message('');
this.render();
// The status says whether it is connected, as a terminal's does; the
// folder is in the pane itself.
this.setState('ok', this.ssh ? 'SSH: ' + this.source.label + ':' + (this.ssh.port || 22) : 'подключено');
}
async go(path) {
const r = await this.api('list', {path});
if (!r.ok) {
if (!this.trouble(r.error || '')) toast(r.error || 'ошибка', true);
return false;
}
this.show(r.data);
return true;
}
// Never listed yet (offline from the start): from the home folder.
reload() { return this.loaded ? this.go(this.cwd) : this.start(); }
// Sorting: by name, size or time, folders always ahead of files. Kept in
// this browser for each folder of each place.
sortKey() { return 'lpra.sort:' + this.source.id + ':' + this.cwd; }
getSort() {
try { const v = JSON.parse(localStorage.getItem(this.sortKey())); if (v && ['name', 'size', 'mtime'].includes(v.by)) return v; } catch (e) { /* default */ }
return SORT_DEF;
}
setSort(v) {
try {
if (v.by === SORT_DEF.by && v.dir === SORT_DEF.dir) localStorage.removeItem(this.sortKey());
else localStorage.setItem(this.sortKey(), JSON.stringify(v));
} catch (e) { /* not kept */ }
}
sorted() {
const s = this.getSort();
const key = s.by === 'size' ? e => e.size || 0 : s.by === 'mtime' ? e => e.mtime || 0 : null;
return this.entries.slice().sort((a, b) => {
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
const d = key ? key(a) - key(b) : 0;
return (d || byName(a, b)) * s.dir;
});
}
render() {
const tb = this.q('.fp-list');
tb.innerHTML = '';
this.updateSel();
for (const e of this.sorted()) {
const tr = document.createElement('tr');
tr.draggable = false;
tr.addEventListener('dragstart', ev => { ev.preventDefault(); ev.stopPropagation(); });
if (e.is_dir) tr.dataset.dir = e.name;
tr.dataset.name = e.name;
const isZip = this.src.caps.unzip && !e.is_dir && /\.zip$/i.test(e.name);
tr.innerHTML =
'<td><input type="checkbox" class="ck" aria-label="Выбрать"></td>' +
'<td class="nm">' + fileIcon(e) + esc(e.name) + '</td>' +
'<td class="sz c-sz">' + (e.is_dir ? '' : fmtSize(e.size)) + '</td>' +
'<td class="sz mt">' + fmtTime(e.mtime) + '</td>' +
'<td><div class="act">' +
(isZip ? '<button class="btn ghost small icon" data-act="unzip" title="Распаковать сюда">' + ICON_UNZIP + '</button>' : '') +
(e.is_dir ? '' : '<a class="btn ghost small icon" title="Скачать" download>' + ICON_DL + '</a>') +
'<button class="btn ghost small icon" data-act="ren" title="Переместить или копировать">' + ICON_MV + '</button>' +
'<button class="btn danger small icon" data-act="del" title="Удалить">' + ICON_DEL + '</button>' +
'</div></td>';
const dl = tr.querySelector('a[download]');
if (dl) { dl.draggable = false; dl.href = this.src.url('download', join(this.cwd, e.name)); }
tr.querySelector('.nm').onclick = () => e.is_dir ? this.go(join(this.cwd, e.name)) : FV.view(this, e);
tr.querySelector('.ck').checked = this.selected.has(e.name);
tr.querySelector('.ck').onchange = ev => { ev.target.checked ? this.selected.add(e.name) : this.selected.delete(e.name); this.updateSel(); };
tr.querySelector('[data-act="del"]').onclick = () => FV.del(this, e);
tr.querySelector('[data-act="ren"]').onclick = () => FV.move(this, e);
const uz = tr.querySelector('[data-act="unzip"]');
if (uz) uz.onclick = () => this.unzip(e);
if (window.FileDnD) tr.addEventListener('pointerdown', ev => FileDnD.start(ev, this, tr));
tb.appendChild(tr);
}
}
// fitSel: the selection's buttons on the right of the blue ones; moved to
// a line of their own, they start on the left.
fitSel() {
const sel = this.q('.fp-sel');
sel.classList.remove('wrapped');
sel.classList.toggle('wrapped', sel.offsetTop > this.q('.fp-mkdir').offsetTop + 4);
}
updateSel() {
const n = this.selected.size;
// A narrow pane (a phone) has room for the number alone.
this.q('.fp-selinfo').textContent = n ? (this.el.classList.contains('narrow') ? n : 'Выбрано: ' + n) : '';
this.q('.fp-selinfo').title = n ? 'Выбрано: ' + n : '';
this.fitSel();
this.q('.fp-zip').disabled = !n;
this.q('.fp-delsel').disabled = !n;
}
async unzip(e) {
const r = await this.api('unzip', {path: join(this.cwd, e.name), dest: this.cwd});
r.ok ? (toast('распаковано'), this.reload()) : toast(r.error || 'ошибка', true);
}
bind() {
const q = s => this.q(s);
q('.pane-x').onclick = ev => { ev.stopPropagation(); Workspace.closePane(this); };
q('.pane-head').addEventListener('pointerdown', ev => { if (Workspace.desk && !ev.target.closest('.pane-x')) Workspace.startDrag(ev, 'pane', this); });
q('.pane-head').addEventListener('contextmenu', ev => { if (window.MachineDialog) MachineDialog.menu(ev, this.agent); });
this.el.addEventListener('pointerdown', () => Workspace.setActivePane(this), true);
q('.fp-up').onclick = () => this.go(parent(this.cwd));
q('.fp-refresh').onclick = () => this.reload();
q('.fp-dirprops').onclick = () => FV.props(this, this.cwd);
q('.fp-path').addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); this.go((q('.fp-path').value || '').trim() || '/'); }
});
this.el.querySelectorAll('table.fm th.srt').forEach(th => th.onclick = () => {
const s = this.getSort(), by = th.dataset.sort;
// A new column starts the way it is most often wanted: names A to Z,
// sizes and times biggest and newest first.
this.setSort(s.by === by ? {by, dir: -s.dir} : {by, dir: by === 'name' ? 1 : -1});
this.render();
});
q('.fp-mkdir').onclick = async () => {
const n = await UI.prompt('Имя новой папки:'); if (!n) return;
const r = await this.api('mkdir', {path: join(this.cwd, n)});
r.ok ? (toast('создано'), this.reload()) : toast(r.error || 'ошибка', true);
};
q('.fp-newfile').onclick = async () => {
const n = await UI.prompt('Имя нового файла:'); if (!n) return;
const r = await this.api('write', {path: join(this.cwd, n), data: ''});
r.ok ? (toast('создано'), this.reload()) : toast(r.error || 'ошибка', true);
};
q('.fp-delsel').onclick = async () => {
if (!await UI.confirm('Удалить выбранное (' + this.selected.size + ')?', {ok: 'Удалить', danger: true})) return;
for (const n of this.selected) {
const e = this.entries.find(x => x.name === n);
await this.api(e && e.is_dir ? 'deltree' : 'delete', {path: join(this.cwd, n)});
}
toast('удалено'); this.reload();
};
q('.fp-zip').onclick = async () => {
const name = await UI.prompt('Имя архива:', 'archive.zip'); if (!name) return;
const r = await this.api('zip', {paths: this.selectedPaths(), dest: join(this.cwd, name)});
r.ok ? (toast('архив создан'), this.reload()) : toast(r.error || 'ошибка', true);
};
q('.fp-all').onchange = ev => {
this.selected.clear();
if (ev.target.checked) this.entries.forEach(e => this.selected.add(e.name));
this.el.querySelectorAll('.fp-list .ck').forEach(c => { c.checked = ev.target.checked; });
this.updateSel();
};
q('.fp-busy').onclick = () => { if (this.uploads.length) FV.uploads(this); };
q('.fp-upl').onchange = ev => { this.upload(ev.target.files); ev.target.value = ''; };
const drop = q('.fp-drop');
// Files from the computer dropped on the list go up here.
const fromOS = e => e.dataTransfer && [...e.dataTransfer.types].includes('Files');
['dragenter', 'dragover'].forEach(x => drop.addEventListener(x, e => { if (!fromOS(e)) return; e.preventDefault(); drop.classList.add('drag'); }));
drop.addEventListener('dragleave', () => drop.classList.remove('drag'));
drop.addEventListener('drop', e => {
if (!fromOS(e)) return;
e.preventDefault();
drop.classList.remove('drag');
if (e.dataTransfer.files.length) this.upload(e.dataTransfer.files);
});
}
// Uploads: one file at a time, each with its progress. What has gone up
// shows as a percentage by the spinner; a click on it lists the files.
renderUploads() {
const ups = this.uploads;
const pend = ups.filter(u => u.state === 'wait' || u.state === 'send' || u.state === 'write');
const counted = ups.filter(u => !u.skip);
const total = counted.reduce((n, u) => n + u.size, 0) || 1;
const sent = counted.reduce((n, u) => n + (u.state === 'wait' ? 0 : u.state === 'send' ? u.size * u.frac : u.size), 0);
const busy = this.q('.fp-busy'), pct = this.q('.fp-uppct');
busy.classList.toggle('up', ups.length > 0);
busy.title = ups.length ? 'Показать загружаемые файлы' : '';
if (pend.length) pct.textContent = Math.floor(100 * sent / total) + '%' + (counted.length > 1 ? ' · ' + (counted.length - pend.length) + '/' + counted.length : '');
// While files go up, the round mark fills as they do.
busy.classList.toggle('uploading', pend.length > 0);
busy.style.setProperty('--up', String(pend.length ? 100 * sent / total : 0));
pct.classList.toggle('on', pend.length > 0);
FV.uploadsChanged(this);
}
async upload(files) {
clearTimeout(this.upDoneTimer);
// A new batch starts a fresh list once the last one is over.
if (!this.upActive) this.uploads.length = 0;
const dir = this.cwd;
const batch = [...files].map(f => f.size > UP_CAP
? {file: f, name: f.name, size: f.size, dir, state: 'fail', frac: 0, error: 'больше 512 МБ', skip: true}
: {file: f, name: f.name, size: f.size, dir, state: 'wait', frac: 0, error: ''});
this.uploads.push(...batch);
this.renderUploads();
if (this.upActive) return;
this.upActive = true;
this.setBusy(true);
let failed = 0;
for (let u; (u = this.uploads.find(x => x.state === 'wait')); ) {
u.state = 'send'; this.renderUploads();
const r = await FV.sendFile(this.src, join(u.dir, u.name), u.file, frac => {
u.frac = frac;
if (frac >= 1 && u.state === 'send') u.state = 'write';
this.renderUploads();
});
if (r.ok) u.state = 'done';
else { u.state = 'fail'; u.error = r.error || 'ошибка'; failed++; }
this.renderUploads();
if (u.dir === this.cwd) this.reload();
}
this.setBusy(false);
this.upActive = false;
failed += this.uploads.filter(u => u.skip).length;
toast(failed ? 'не загружено: ' + failed : 'загружено', !!failed);
this.renderUploads();
// The list stays a while for a look, then the indicator goes quiet.
this.upDoneTimer = setTimeout(() => { if (!this.upActive) { this.uploads.length = 0; this.renderUploads(); } }, failed ? 30000 : 8000);
}
}
// Panes of a machine gone offline look every 15 s whether it is back.
setInterval(() => {
const off = Workspace.panes().filter(p => p.offline);
if (!off.length || document.hidden) return;
fetch('/api/panel/agents/presence', {cache: 'no-store', credentials: 'same-origin'}).then(r => r.json()).then(all => {
off.forEach(p => { if (all.some(a => a.id === p.agent.id && a.online)) p.reload(); });
}).catch(() => {});
}, 15000);
window.FilePane = FilePane;
window.MachineSource = MachineSource;
Workspace.paneTypes.files = (agent, o) => new FilePane({kind: 'agent', agent, dir: o.dir});
Workspace.paneTypes.sshfiles = (agent, o) => new FilePane({kind: 'ssh', agent, ssh: o.ssh, src: new SSHSource(agent, o.conn, o.home, o.caps)});
// sshFiles connects agent to the SSH host t, then opens its files in a
// pane; the password or key goes to the machine once and is not kept here.
Workspace.sshFiles = (agent, t, into) => fetch('/api/panel/agents/' + encodeURIComponent(agent.id) + '/sshfs/open', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ssh: t}), credentials: 'same-origin'
}).then(r => r.status === 404 ? {ok: false, error: 'нет доступа к машине'} : r.json()).then(r => {
const caps = r.ok ? {zip: !!r.data.zip, unzip: !!r.data.unzip} : null;
if (r.ok && into) { into.src.conn = r.data.id; Object.assign(into.src.caps, caps); into.q('.fp-zip').hidden = !caps.zip; into.reload(); }
else if (r.ok) Workspace.open('sshfiles', agent, {conn: r.data.id, home: r.data.home, caps, ssh: {host: t.host, port: t.port, user: t.user}});
return r;
}).catch(() => ({ok: false, error: 'ошибка сети'}));
})();
File diff suppressed because it is too large. Load diff
+88
View File
@@ -0,0 +1,88 @@
// Порты, Статус and Изменить of a machine in a dialog over the current page:
// the server's pages as fragments, so nothing behind is lost. They open from
// the buttons of a machine's row, and from a menu on a tab's title or a
// pane's title bar (a right click, or a long press on a phone). Closing the
// dialog unmounts the fragment's module: its charts and polling stop.
(function () {
'use strict';
const TITLES = {forwards: 'Порты', status: 'Статус', edit: 'Изменить'};
const dlg = document.getElementById('machinedlg');
const area = dlg.querySelector('.frag-area');
const page = /^\/agents\/([^/]+)\/(forwards|status|edit)$/;
let changed = false;
function open(id, what, name) {
dlg.querySelector('.md-title').textContent = (name || id) + ' · ' + TITLES[what];
App.put(area, '<p class="muted" style="padding:16px 18px">загрузка…</p>');
if (!dlg.open) (window.FileViews ? FileViews.openModal(dlg) : dlg.showModal());
App.load(area, '/agents/' + encodeURIComponent(id) + '/' + what);
}
dlg.addEventListener('close', () => {
App.put(area, '');
// A form sent in the dialog (a new name, say) shows in the list behind.
if (changed && document.querySelector('main#app').dataset.view === 'machines') App.go('/', false);
changed = false;
});
dlg.querySelector('.md-close').onclick = () => dlg.close();
// «Изменить» drawn again after a new name: the title follows it.
new MutationObserver(() => {
const h = area.querySelector('[data-module="edit"] .wrap > h1');
if (h) dlg.querySelector('.md-title').textContent = h.textContent.trim() + ' · ' + TITLES.edit;
}).observe(area, {childList: true});
dlg.addEventListener('submit', () => { changed = true; });
// The fragment's own links: another page of a machine stays in the dialog,
// anything else closes it and goes on as usual.
area.addEventListener('click', ev => {
const a = ev.target.closest('a[href]');
if (!a || ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || a.target || a.hasAttribute('download')) return;
const u = new URL(a.href, location.href), m = page.exec(u.pathname);
if (u.origin !== location.origin) return;
if (m) { ev.preventDefault(); ev.stopPropagation(); App.load(area, u.pathname + u.search); return; }
dlg.close();
}, true);
document.addEventListener('click', ev => {
const b = ev.target.closest('[data-dialog]');
if (!b || ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey) return;
ev.preventDefault();
open(b.dataset.id, b.dataset.dialog, b.dataset.name);
});
// menu: Порты, Статус, Изменить of agent, by the pointer.
const menu = document.createElement('div');
menu.className = 'addmenu mdmenu';
menu.hidden = true;
document.body.appendChild(menu);
function closeMenu() { menu.hidden = true; document.removeEventListener('pointerdown', outside, true); }
function outside(ev) { if (!menu.contains(ev.target)) closeMenu(); }
function showMenu(ev, agent) {
ev.preventDefault();
menu.innerHTML = '';
const head = document.createElement('div');
head.className = 'muted md-head';
head.textContent = agent.name;
menu.appendChild(head);
Object.keys(TITLES).forEach(k => {
const b = document.createElement('button');
b.type = 'button'; b.className = 'item';
b.innerHTML = '<span><b></b></span>';
b.querySelector('b').textContent = TITLES[k];
b.onclick = () => { closeMenu(); open(agent.id, k, agent.name); };
menu.appendChild(b);
});
menu.hidden = false;
const m = menu.getBoundingClientRect();
menu.style.left = Math.max(8, Math.min(innerWidth - m.width - 8, ev.clientX)) + 'px';
menu.style.top = Math.max(8, Math.min(innerHeight - m.height - 8, ev.clientY)) + 'px';
setTimeout(() => document.addEventListener('pointerdown', outside, true), 0);
}
document.addEventListener('keydown', ev => { if (ev.key === 'Escape' && !menu.hidden) closeMenu(); });
// A machine page asked for by its address: the machines view with it open.
const m = window.APP && window.APP.dialog && page.exec(window.APP.dialog);
if (m) {
const row = document.querySelector('[data-open][data-id="' + CSS.escape(m[1]) + '"]');
history.replaceState({}, '', '/');
open(m[1], m[2], row ? row.dataset.name : m[1]);
}
window.MachineDialog = {open, menu: showMenu};
})();
+341
View File
@@ -0,0 +1,341 @@
// Scripts of the pages the shell shows as fragments, as modules: mount(root,
// S) runs when the fragment is put in place, with S the module's scope -
// timers, requests and listeners that end when it is replaced or closed.
(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'); b.title = 'Скопировано';
setTimeout(() => { b.innerHTML = icon; b.classList.remove('done'); b.title = 'Скопировать'; }, 1500);
}, e => alert('Не скопировалось: ' + e.message));
});
if (window.UsersTable) window.UsersTable.mount(root);
// ИИ-ассистент: a card per model, added from the blank one and taken
// away by its «Удалить» (the last one stays).
const list = root.querySelector('.ai-models'), blank = root.querySelector('template.ai-model-new');
if (list && blank) {
root.querySelector('.ai-model-add').onclick = () => {
const card = blank.content.firstElementChild.cloneNode(true);
list.appendChild(card);
card.querySelector('[name=m_name]').focus();
};
list.addEventListener('click', ev => {
const del = ev.target.closest('.ai-model-del');
if (del && list.children.length > 1) del.closest('.ai-model').remove();
});
}
}
});
// The machines list keeps its status column live; nothing is asked while
// the tab is hidden.
App.register('dashboard', {
mount(root, S) {
// Terminal and files buttons open a tab in the workspace, no reload.
S.on(root, 'click', function (ev) {
var b = ev.target.closest('[data-open]');
if (!b || !window.Workspace || !Workspace.paneTypes[b.dataset.open]) return;
ev.preventDefault();
Workspace.open(b.dataset.open, {id: b.dataset.id, name: b.dataset.name, host: b.dataset.host, user: b.dataset.user});
});
var setTimeout = S.timeout, clearTimeout = S.clear, fetch = S.fetch;
var rows = {};
root.querySelectorAll('tr[data-agent]').forEach(function (tr) { rows[tr.dataset.agent] = tr; });
var timer = null, busy = false;
function schedule() {
clearTimeout(timer);
if (!document.hidden) timer = setTimeout(load, 5000);
}
function load() {
if (busy || document.hidden) return;
busy = true;
fetch('/api/panel/agents/presence', {credentials: 'same-origin'})
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (list) {
list.forEach(function (a) {
var tr = rows[a.id];
if (!tr) return;
tr.querySelector('.dot').className = 'dot ' + (a.online ? 'on' : 'off');
tr.querySelector('.state').textContent = a.online ? 'online' : 'offline';
tr.querySelector('.seen').textContent = a.seen;
});
})
.catch(function () {})
.then(function () { busy = false; schedule(); });
}
S.on(document, 'visibilitychange', function () {
if (document.hidden) clearTimeout(timer); else load();
});
if (Object.keys(rows).length) schedule();
}
});
// Ports: the note under the mode follows the chosen mode.
App.register('forwards', {
mount(root) {
var notes = {
http: 'Трафик идёт через соединение агента с панелью (порт ' + root.dataset.panelPort + '): по WebSocket, без него - long-poll. Порт ' + root.dataset.tunnelPort + ' не требуется.',
tcp: 'Требуется открытый порт ' + root.dataset.tunnelPort + '/tcp на хосте. Самая низкая задержка.',
udp: 'Требуется открытый порт ' + root.dataset.tunnelPort + '/udp на хосте.'
};
var sel = root.querySelector('#proto'), note = root.querySelector('#note');
if (!sel || !note) return;
function upd() { note.textContent = notes[sel.value] || ''; }
sel.onchange = upd; upd();
}
});
// Status: meters, live speed and the speed test of a machine.
App.register('status', {
mount(root, S) {
// Timers, requests and listeners end with the page.
var setTimeout = S.timeout, setInterval = S.interval, clearTimeout = S.clear, clearInterval = S.clear, fetch = S.fetch;
var base = '/api/panel/agents/' + encodeURIComponent(root.dataset.agent);
var $ = function (id) { return root.querySelector('#' + id); };
var errBox = $('err');
function size(n) {
var u = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ'], i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return n.toFixed(n < 10 && i > 0 ? 1 : 0) + ' ' + u[i];
}
function rateParts(bps) {
var u = ['бит/с', 'Кбит/с', 'Мбит/с', 'Гбит/с'], i = 0;
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++; }
return [bps.toFixed(bps < 100 && i > 0 ? 1 : 0), u[i]];
}
function rate(bps) { return rateParts(bps).join(' '); }
function meter(name, pct, sub) {
pct = Math.max(0, Math.min(100, pct));
$(name + '-val').textContent = Math.round(pct) + '%';
var bar = $(name + '-bar');
bar.style.transform = 'scaleX(' + pct / 100 + ')';
bar.className = pct >= 90 ? 'crit' : pct >= 75 ? 'warn' : '';
$(name + '-sub').textContent = sub;
}
function showErr(msg) {
errBox.textContent = msg || '';
errBox.hidden = !msg;
}
function cores(n) {
var d = n % 10, h = n % 100;
return n + (d === 1 && h !== 11 ? ' ядро' : d >= 2 && d <= 4 && (h < 12 || h > 14) ? ' ядра' : ' ядер');
}
// One request at a time, and none while the tab is hidden: the client is
// sampled only for a page somebody is looking at.
var timer = null, busy = false;
function schedule(ms) {
clearTimeout(timer);
if (!document.hidden) timer = setTimeout(load, ms);
}
function load() {
if (busy || document.hidden) return;
busy = true;
var next = 3000;
fetch(base + '/stat', {credentials: 'same-origin'})
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (res) {
if (!res.ok) { showErr(res.error || 'ошибка'); next = 10000; return; }
showErr('');
var d = res.data;
meter('cpu', d.cpu, cores(d.ncpu));
meter('mem', d.mem_total ? 100 * d.mem_used / d.mem_total : 0,
'занято ' + size(d.mem_used) + ' из ' + size(d.mem_total) + ' · свободно ' + size(d.mem_total - d.mem_used));
meter('disk', d.disk_total ? 100 * (d.disk_total - d.disk_free) / d.disk_total : 0,
'свободно ' + size(d.disk_free) + ' из ' + size(d.disk_total));
})
.catch(function (e) { showErr('Нет связи с панелью: ' + e.message); next = 10000; })
.then(function () { busy = false; schedule(next); });
}
S.on(document, 'visibilitychange', function () {
if (document.hidden) clearTimeout(timer); else load();
});
load();
var btn = $('speed-btn'), note = $('speed-note');
function json(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }
function post(path) { return fetch(base + path, {method: 'POST', credentials: 'same-origin'}).then(json); }
// The browser's round trip to the panel: the first request may pay for a
// new connection, so keep the best.
function panelPing() {
var best = Infinity, left = 4;
function one() {
var t = performance.now();
return fetch('/api/panel/ping', {credentials: 'same-origin', cache: 'no-store'})
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); })
.then(function () {
best = Math.min(best, performance.now() - t);
return --left > 0 ? one() : best;
});
}
return one();
}
// Keeps the client's download fed until stopped or the panel says the
// client has read enough; the pieces grow while they go through quickly.
function feed(stop) {
var block = new Uint8Array(65536), n = 4;
crypto.getRandomValues(block);
function one() {
var parts = [], t = performance.now();
for (var i = 0; i < n; i++) parts.push(block);
return fetch(base + '/speed/down', {method: 'POST', body: new Blob(parts), credentials: 'same-origin', signal: stop.signal})
.then(function (r) {
if (r.status !== 204) return;
if (performance.now() - t < 500 && n < 256) n *= 2;
return one();
});
}
one().catch(function () {});
}
// Reads what the client sends and times it from the first byte. It stops by
// itself a little after the client's window, so bytes still queued on a slow
// link don't hold the result back. st.n is the count so far, for the live
// reading.
function drain(st, onFirst) {
var ac = new AbortController(), last = 0;
st.stop = function () { ac.abort(); };
function done() { return st.start && last > st.start ? st.n * 8000 / (last - st.start) : 0; }
return fetch(base + '/speed/up', {credentials: 'same-origin', cache: 'no-store', signal: ac.signal}).then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
var rd = r.body.getReader();
function pump() {
return rd.read().then(function (c) {
if (c.done) return done();
last = performance.now();
if (!st.start) { st.start = last; setTimeout(st.stop, Number(root.dataset.relayUpMs)); onFirst(); }
else st.n += c.value.length;
return pump();
});
}
return pump();
}).catch(function (e) { if (st.start) return done(); throw e; });
}
// The test through the browser: the client's download is what this page
// uploads, and its upload is timed here, where it arrives.
function relayTest(st) {
return panelPing().then(function (ping) {
return post('/speed/relay').then(function (res) {
if (!res.ok) return res;
var stop = new AbortController();
var up = drain(st, function () { stop.abort(); });
up.catch(function () {});
feed(stop);
return post('/speed?relay=1').then(function (res) {
stop.abort();
// No upload under way by now: none is coming.
if (!res.ok || !st.start) { st.stop(); }
if (!res.ok) return res;
return up.catch(function () { return 0; }).then(function (bps) {
res.data.ping_ms += ping;
if (bps) res.data.up_bps = bps;
return res;
});
}, function (e) { stop.abort(); st.stop(); throw e; });
});
});
}
// The live reading: the rate over the last second, from the byte counts the
// panel keeps for the running test - or, for the client's upload in a test
// through the browser, from what has arrived here.
var ARC = 251.4, lv = null;
function liveStart(relay, st) {
lv = {relay: relay, st: st, phase: '', pts: [], hist: [], busy: false};
$('live').hidden = false;
liveShow('ping', 0);
lv.timer = setInterval(liveTick, 200);
}
function liveStop() {
if (!lv) return;
clearInterval(lv.timer);
lv = null;
$('live').hidden = true;
}
function liveTick() {
var cur = lv;
if (cur.relay && cur.st.start) { livePoint(cur, 'up', cur.st.n, performance.now()); return; }
if (cur.busy) return;
cur.busy = true;
fetch(base + '/speed/progress', {credentials: 'same-origin', cache: 'no-store'})
.then(json)
.then(function (d) {
if (cur !== lv || !d.phase || (cur.relay && cur.st.start)) return;
livePoint(cur, d.phase, d.phase === 'up' ? d.up : d.down, d.ms);
})
.catch(function () {})
.then(function () { cur.busy = false; });
}
function livePoint(cur, phase, bytes, ms) {
if (phase !== cur.phase) { cur.phase = phase; cur.pts = []; cur.hist = []; }
if (phase === 'ping') { liveShow(phase, 0); return; }
var pts = cur.pts;
pts.push([ms, bytes]);
while (pts.length > 2 && ms - pts[0][0] > 1000) pts.shift();
if (pts.length < 2 || ms <= pts[0][0]) return;
var bps = (bytes - pts[0][1]) * 8000 / (ms - pts[0][0]);
cur.hist.push(bps);
liveShow(phase, bps, cur);
}
function liveShow(phase, bps, cur) {
var relay = lv && lv.relay, p = rateParts(bps);
$('live').className = 'live' + (phase === 'up' ? ' up' : '');
$('lv-ico').textContent = phase === 'up' ? '↑' : phase === 'down' ? '↓' : '';
$('lv-phase').textContent = phase === 'ping' ? 'Задержка…'
: phase === 'up' ? (relay ? 'клиент → браузер' : 'клиент → хост')
: (relay ? 'браузер → клиент' : 'хост → клиент');
$('lv-num').textContent = phase === 'ping' ? '…' : p[0];
$('lv-unit').textContent = phase === 'ping' ? '' : p[1];
// A log scale up to 1 Гбит/с, so a slow link moves the needle too.
var f = Math.min(1, Math.log(1 + bps / 1e6) / Math.log(1001));
$('lv-arc').style.strokeDashoffset = ARC * (1 - f);
var h = cur ? cur.hist : [], max = 1.25 * Math.max.apply(null, h.concat(1)), line = '';
for (var i = 0; i < h.length; i++) {
line += (h.length > 1 ? 100 * i / (h.length - 1) : 0).toFixed(1) + ',' + (98 - 94 * h[i] / max).toFixed(1) + ' ';
}
$('lv-line').setAttribute('points', line);
$('lv-area').setAttribute('points', h.length > 1 ? '0,100 ' + line + '100,100' : '');
}
btn.onclick = function () {
var relay = $('speed-relay').checked, st = {n: 0, start: 0, stop: function () {}};
btn.disabled = true;
note.textContent = 'Идёт замер…';
$('speed').hidden = true;
liveStart(relay, st);
(relay ? relayTest(st) : post('/speed'))
.then(function (res) {
if (!res.ok) { note.textContent = res.error || 'ошибка'; return; }
var d = res.data;
$('sp-ping').textContent = d.ping_ms ? d.ping_ms.toFixed(d.ping_ms < 10 ? 1 : 0) + ' мс' : '-';
$('sp-down').textContent = rate(d.down_bps);
$('sp-up').textContent = d.up_bps ? rate(d.up_bps) : '-';
$('sp-down-lbl').textContent = relay ? 'браузер → клиент' : 'хост → клиент';
$('sp-up-lbl').textContent = relay ? 'клиент → браузер' : 'клиент → хост';
$('speed').hidden = false;
note.textContent = d.partial ? 'Замер остановлен по времени - показано то, что успело набраться' : '';
})
.catch(function (e) { note.textContent = 'Не удалось: ' + e.message; })
.then(function () { liveStop(); btn.disabled = false; });
};
}
});
})();
+29
View File
@@ -0,0 +1,29 @@
// The look of the panel, as each user set it (Аккаунт › Вид): applied at
// once on load from what the page came with, and again on every change.
(function () {
'use strict';
let p = Object.assign({}, (window.APP && window.APP.prefs) || {});
function apply() {
const b = document.body;
// Parts that follow a setting of their own (the chat's model menu).
document.dispatchEvent(new CustomEvent('lpra:prefs'));
b.dataset.tabicons = p.tabIcons || 'left';
b.classList.toggle('compact', !!p.compact);
b.classList.toggle('no-ftime', p.fileTime === 'hide');
b.classList.toggle('no-motion', p.motion === 'off');
// Open terminals take the new size at once.
if (window.Workspace) Workspace.panes().forEach(t => {
if (t.term && t.term.options.fontSize !== termFont()) { t.term.options.fontSize = termFont(); t.fit(); }
});
}
const termFont = () => p.termFont || (matchMedia('(pointer: coarse)').matches ? 13 : 14);
async function set(k, v) {
p[k] = v;
apply();
const r = await fetch('/api/panel/account/prefs', {method: 'POST', headers: {'Content-Type': 'application/json'},
credentials: 'same-origin', body: JSON.stringify(p)}).then(x => x.json()).catch(() => ({ok: false}));
return r.ok;
}
apply();
window.Prefs = {get: () => Object.assign({}, p), set, apply, termFont};
})();
+471
View File
@@ -0,0 +1,471 @@
// Terminal panes of the workspace: a shell on a machine, or a shell the
// machine opens by SSH. Also the touch helpers for typing in them (extra
// keys, long-press copy). The workspace (workspace.js) holds the tabs.
// Touch helpers, for the terminal being typed in: the extra keys with their
// CTRL / ALT, and selecting and copying with a long press.
window.TermKeys = (function () {
const touch = window.matchMedia('(pointer: coarse)').matches;
const cur = () => window.TermPane && window.TermPane.active();
// CTRL and ALT: a tap arms the key for the next key press, a long press
// keeps it on until tapped again.
const mods = { ctrl: 0, alt: 0 }; // 0 off, 1 next key, 2 locked
const modBtn = {};
document.querySelectorAll('#keys [data-mod]').forEach(function (b) { modBtn[b.dataset.mod] = b; });
function showMods() {
for (const m in mods) {
modBtn[m].classList.toggle('on', mods[m] === 1);
modBtn[m].classList.toggle('lock', mods[m] === 2);
}
}
function useMods() {
const used = { ctrl: !!mods.ctrl, alt: !!mods.alt };
for (const m in mods) if (mods[m] === 1) mods[m] = 0;
showMods();
return used;
}
function ctrlChar(c) {
if (c === ' ' || c === '@') return '\x00';
if (c === '?') return '\x7f';
const code = c.toUpperCase().charCodeAt(0);
return code >= 64 && code <= 95 ? String.fromCharCode(code & 31) : c;
}
function withMods(data) {
if (!mods.ctrl && !mods.alt) return data;
const m = useMods();
if (m.ctrl && data.length === 1) data = ctrlChar(data);
return m.alt ? '\x1b' + data : data;
}
// xterm's modifier parameter: 1 + shift(1) + alt(2) + ctrl(4).
const CSI = { up: 'A', down: 'B', right: 'C', left: 'D', home: 'H', end: 'F' };
const TILDE = { pgup: 5, pgdn: 6 };
function keySeq(k, term) {
const m = useMods(), p = 1 + (m.alt ? 2 : 0) + (m.ctrl ? 4 : 0);
if (CSI[k]) {
if (p > 1) return '\x1b[1;' + p + CSI[k];
return (term.modes.applicationCursorKeysMode ? '\x1bO' : '\x1b[') + CSI[k];
}
if (TILDE[k]) return '\x1b[' + TILDE[k] + (p > 1 ? ';' + p : '') + '~';
let s = k === 'esc' ? '\x1b' : k === 'tab' ? '\t' : k;
if (m.ctrl && s.length === 1) s = ctrlChar(s);
return m.alt ? '\x1b' + s : s;
}
let press = null;
document.querySelectorAll('#keys button').forEach(function (b) {
// Keep the focus (and the on-screen keyboard) on the terminal.
b.addEventListener('pointerdown', function (e) {
e.preventDefault();
const mod = b.dataset.mod;
if (mod) press = { mod: mod, timer: setTimeout(function () { mods[mod] = 2; press = null; showMods(); }, 450) };
});
b.addEventListener('pointerup', function (e) {
e.preventDefault();
const mod = b.dataset.mod;
if (mod) {
if (press && press.mod === mod) {
clearTimeout(press.timer);
press = null;
mods[mod] = mods[mod] ? 0 : 1;
showMods();
}
return;
}
const s = cur();
if (s) s.send(keySeq(b.dataset.k, s.term));
});
b.addEventListener('pointercancel', function () { if (press) { clearTimeout(press.timer); press = null; } });
b.addEventListener('contextmenu', function (e) { e.preventDefault(); });
});
// --- Selecting and copying on touch screens ---
// xterm.js has no touch selection of its own. A long press selects the word
// under the finger and dragging on from there stretches the selection; a
// menu then copies it or pastes. A plain tap or swipe still types and scrolls.
const menu = document.getElementById('selmenu');
let selecting = false;
function showMenu() {
const s = cur();
const keys = document.getElementById('keys').getBoundingClientRect();
menu.style.top = '';
menu.style.bottom = (window.innerHeight - keys.top + 14) + 'px';
menu.querySelector('[data-a="copy"]').disabled = !(s && s.term.hasSelection());
menu.hidden = false;
}
function selectionChanged(s) {
if (!touch || s !== cur()) return;
if (s.term.hasSelection()) showMenu();
else if (!selecting) menu.hidden = true;
}
if (touch) {
const box = document.getElementById('area');
let timer = null, start = null, anchor = null;
function cellAt(term, el, x, y) {
const r = el.querySelector('.xterm-screen').getBoundingClientRect();
const col = Math.max(0, Math.min(term.cols - 1, Math.floor((x - r.left) / (r.width / term.cols))));
const row = Math.max(0, Math.min(term.rows - 1, Math.floor((y - r.top) / (r.height / term.rows))));
return { col: col, row: row + term.buffer.active.viewportY };
}
function selectWord(term, c) {
const line = term.buffer.active.getLine(c.row);
const text = line ? line.translateToString(false) : '';
const word = ch => ch && !/\s/.test(ch);
let a = c.col, b = c.col;
if (word(text[c.col])) {
while (a > 0 && word(text[a - 1])) a--;
while (b < text.length - 1 && word(text[b + 1])) b++;
}
term.select(a, c.row, b - a + 1);
anchor = { from: c.row * term.cols + a, to: c.row * term.cols + b };
}
function stretch(term, c) {
const p = c.row * term.cols + c.col;
const from = Math.min(anchor.from, p), to = Math.max(anchor.to, p);
term.select(from % term.cols, Math.floor(from / term.cols), to - from + 1);
}
function hideMenu() { menu.hidden = true; const s = cur(); if (s) s.term.clearSelection(); anchor = null; }
box.addEventListener('touchstart', function (e) {
if (e.touches.length !== 1 || !e.target.closest('.pane-body')) { clearTimeout(timer); return; }
const t = e.touches[0];
start = { x: t.clientX, y: t.clientY };
if (!menu.hidden) hideMenu();
clearTimeout(timer);
timer = setTimeout(function () {
const s = cur();
if (!s) return;
selecting = true;
selectWord(s.term, cellAt(s.term, s.el, start.x, start.y));
showMenu();
if (navigator.vibrate) navigator.vibrate(15);
}, 450);
}, { passive: true, capture: true });
box.addEventListener('touchmove', function (e) {
const t = e.touches[0], s = cur();
if (selecting && s) {
e.preventDefault(); // no scrolling while the selection stretches
e.stopPropagation();
stretch(s.term, cellAt(s.term, s.el, t.clientX, t.clientY));
} else if (start && Math.hypot(t.clientX - start.x, t.clientY - start.y) > 8) {
clearTimeout(timer); // a swipe: let it scroll
}
}, { passive: false, capture: true });
box.addEventListener('touchend', function (e) {
clearTimeout(timer);
if (!selecting) return;
selecting = false;
e.preventDefault(); // no tap: the keyboard stays as it was
e.stopPropagation();
showMenu();
}, { passive: false, capture: true });
box.addEventListener('contextmenu', function (e) { e.preventDefault(); });
const statusEl = document.getElementById('status');
function flash(t, cls) {
statusEl.textContent = t; statusEl.className = cls;
setTimeout(function () { const s = cur(); if (s) s.setState(s.state, s.text); }, 1500);
}
function copyText(text) {
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
// Plain http: the old way, through a field that is there for a moment.
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('нет доступа к буферу'));
}
menu.addEventListener('pointerdown', function (e) { e.preventDefault(); });
menu.addEventListener('click', function (e) {
const b = e.target.closest('button'), s = cur();
if (!b || !s) return;
const a = b.dataset.a;
if (a === 'copy') {
const text = s.term.getSelection();
hideMenu();
copyText(text).then(function () { flash('скопировано', 'st-ok'); }, function (err) { flash('не скопировалось: ' + err.message, 'st-err'); });
} else if (a === 'paste') {
hideMenu();
if (!navigator.clipboard || !navigator.clipboard.readText) { flash('вставка: браузер не даёт буфер', 'st-err'); return; }
navigator.clipboard.readText().then(function (t) { if (t) s.term.paste(t); }, function () { flash('вставка: нет доступа к буферу', 'st-err'); });
} else if (a === 'all') {
s.term.selectAll();
showMenu();
} else {
hideMenu();
}
});
}
return { withMods: withMods, selectionChanged: selectionChanged };
})();
(function () {
'use strict';
const W = window.Workspace;
const touch = W.touch;
const statusEl = document.getElementById('status');
const base = '/api/panel/term/';
const X = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>';
function bytesToB64(bytes) {
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
function b64ToBytes(b64) {
const bin = atob(b64), u = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
return u;
}
// ---------- copy and paste by keys ----------
// Off a Mac, where Cmd does it, Ctrl+C copies when there is a selection and
// interrupts as ever when there is none; Ctrl+Shift+C always copies (and is
// kept from the browser, whose developer tools it would open); Ctrl+V and
// Ctrl+Shift+V paste. Keys go by position, so a Russian layout works too.
const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
function copyText(text) {
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
// Plain http: the old way, through a field that is there for a moment.
const ta = document.createElement('textarea'), back = document.activeElement;
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();
if (back && back.focus) back.focus();
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
}
function clipKeys(s, ev) {
if (mac || !ev.ctrlKey || ev.altKey || ev.metaKey) return true;
const c = ev.code === 'KeyC', v = ev.code === 'KeyV';
if (!c && !v) return true;
if (c && !ev.shiftKey && !s.term.hasSelection()) return true; // ^C to the shell
// From here the key is ours: xterm does not turn it into a control code.
if (ev.type === 'keydown' && c) {
ev.preventDefault();
if (s.term.hasSelection()) {
const text = s.term.getSelection();
s.term.clearSelection();
copyText(text).then(() => flashStatus('скопировано', 'st-ok'), e => flashStatus('не скопировалось: ' + e.message, 'st-err'));
}
}
// Ctrl+V: the browser's paste goes on into the terminal's text field.
return false;
}
function flashStatus(t, cls) {
statusEl.textContent = t;
statusEl.className = cls;
clearTimeout(flashStatus.t);
flashStatus.t = setTimeout(W.showStatus, 1500);
}
// ---------- one shell ----------
class Session {
// ssh, when given, is where the agent connects by SSH for this shell:
// {host, port, user} and the secret to sign in with, sent once on open.
// dir, for a shell on the machine, is the folder it starts in.
constructor(agent, ssh, dir) {
this.agent = agent;
this.ssh = ssh || null;
this.dir = dir || '';
this.kind = ssh ? 'ssh' : 'term';
// Where its files are, for dropped paths: the machine, or the SSH host
// through it.
this.source = ssh
? {kind: 'ssh', id: agent.id + '|' + ssh.user + '@' + ssh.host + ':' + (ssh.port || 22), label: ssh.user + '@' + ssh.host}
: {kind: 'agent', id: agent.id, label: agent.name};
this.sid = null; this.closed = false; this.connected = false;
this.state = 'wait'; this.text = 'подключение…';
this.inSeq = 0; this.inBusy = false; this.inQueue = [];
this.el = document.createElement('div');
this.el.className = 'pane';
this.el.innerHTML = '<div class="pane-head"><span class="dot wait"></span><span class="pane-title"></span>' +
'<button type="button" class="pane-x" title="Закрыть терминал" aria-label="Закрыть терминал">' + X + '</button></div>' +
'<div class="pane-body"></div>';
this.el.querySelector('.pane-title').textContent = who(this);
this.el.querySelector('.pane-title').title = ssh ? 'SSH через ' + agent.name : agent.name;
if (ssh) this.el.querySelector('.dot').insertAdjacentHTML('afterend', '<span class="badge-ssh">SSH</span>');
this.term = new Terminal({
cursorBlink: true, fontSize: window.Prefs ? Prefs.termFont() : (touch ? 13 : 14),
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', theme: { background: '#0c0f13' }
});
this.fitter = new FitAddon.FitAddon();
this.term.loadAddon(this.fitter);
this.term.open(this.el.querySelector('.pane-body'));
this.term.attachCustomKeyEventHandler(ev => clipKeys(this, ev));
this.term.onData(d => this.send(TermKeys.withMods(d)));
this.term.onResize(() => this.queueIn({ t: 'r', cols: this.term.cols, rows: this.term.rows }));
this.term.onSelectionChange(() => TermKeys.selectionChanged(this));
this.el.addEventListener('pointerdown', () => W.setActivePane(this), true);
this.term.textarea.addEventListener('focus', () => W.setActivePane(this));
this.el.querySelector('.pane-x').onclick = ev => { ev.stopPropagation(); W.closePane(this); };
if (W.desk) this.el.querySelector('.pane-head').addEventListener('pointerdown', ev => W.startDrag(ev, 'pane', this));
this.el.querySelector('.pane-head').addEventListener('contextmenu', ev => { if (window.MachineDialog) MachineDialog.menu(ev, this.agent); });
this.open();
}
setState(state, text) {
this.state = state; this.text = text;
this.el.querySelector('.dot').className = 'dot ' + state;
const t = W.tabOf(this);
if (t) W.renderTab(t);
W.showStatus();
}
async open() {
try {
let opt = { method: 'POST' };
if (this.dir) opt = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dir: this.dir }) };
if (this.ssh) {
opt = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ssh: this.ssh }) };
// The password or key goes once, to the agent; the page keeps no copy.
this.ssh = { host: this.ssh.host, port: this.ssh.port, user: this.ssh.user };
}
const r = await fetch(base + encodeURIComponent(this.agent.id) + '/open', opt);
if (!r.ok) {
let why = 'HTTP ' + r.status;
try { why = (await r.json()).error || why; } catch (e) { /* plain text */ }
this.setState('err', 'ошибка открытия: ' + why);
this.term.write('\r\n\x1b[31m[не удалось открыть сессию: ' + why + ']\x1b[0m\r\n');
return;
}
this.sid = (await r.json()).session_id;
this.setState('wait', 'ожидание агента…');
// An agent from before SSH terminals does not answer the request.
if (this.ssh) setTimeout(() => {
if (!this.closed && !this.ended && this.text === 'ожидание агента…') {
this.setState('err', 'агент не начал SSH-подключение');
this.term.write('\x1b[33mАгент не ответил. Если он старой версии, обновите его - SSH-терминал появился в новой.\x1b[0m\r\n');
}
}, 20000);
this.fit();
// A WebSocket if it connects; the long polls otherwise, and from a
// dropped WebSocket on for the rest of this session.
if (!(await this.openWS())) this.poll();
this.queueIn({ t: 'r', cols: this.term.cols, rows: this.term.rows });
} catch (e) {
this.setState('err', 'ошибка сети');
this.term.write('\r\n\x1b[31m[ошибка сети: ' + (e && e.message ? e.message : e) + ']\x1b[0m\r\n');
}
}
openWS() {
return new Promise(resolve => {
if (!window.WebSocket) { resolve(false); return; }
const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + base + this.sid + '/ws');
let opened = false;
const timer = setTimeout(() => { if (!opened) { ws.close(); resolve(false); } }, 5000);
ws.onopen = () => { opened = true; clearTimeout(timer); this.ws = ws; resolve(true); };
ws.onmessage = ev => {
let j;
try { j = JSON.parse(ev.data); } catch (e) { return; }
this.take(j);
};
ws.onclose = () => {
clearTimeout(timer);
if (!opened) { resolve(false); return; }
this.ws = null;
if (!this.closed && !this.ended) this.poll();
};
});
}
// take applies one answer of the output channel, either transport's.
take(j) {
if (j.closed) { this.finish('[сессия завершена]'); return false; }
for (const f of j.frames || []) {
if (f.status) this.sshStatus(f.status, f.note || '');
if (f.exit) { this.finish('[сессия завершена]'); return false; }
if (f.data) {
if (!this.connected && !this.ssh) { this.connected = true; this.setState('ok', 'подключено'); }
this.term.write(b64ToBytes(f.data));
}
}
return true;
}
// sshStatus shows how the agent's SSH connection goes.
sshStatus(st, note) {
if (st === 'dial') this.setState('wait', 'SSH: ' + note + '…');
else if (st === 'auth') this.setState('wait', 'SSH: ' + note + '…');
else if (st === 'ok') { this.connected = true; this.setState('ok', 'SSH: ' + note); }
else if (st === 'err') this.setState('err', 'SSH: ' + note);
}
async poll() {
while (!this.closed && this.sid) {
let r;
try { r = await fetch(base + this.sid + '/out', { cache: 'no-store' }); }
catch (e) { await new Promise(res => setTimeout(res, 1000)); continue; }
if (this.closed) return;
if (!r.ok) { this.finish('[соединение закрыто]'); return; }
if (!this.take(await r.json())) return;
}
}
finish(msg) {
if (this.ended) return;
this.ended = true;
if (this.state !== 'err') this.setState('off', 'соединение закрыто');
this.term.write('\r\n\x1b[33m' + msg + '\x1b[0m\r\n');
}
// Input goes out one request at a time, so on a slow link a keystroke can
// never overtake the one before it; a request that fails goes again under
// the same number, and the server drops one it already has.
queueIn(m) {
if (this.closed || !this.sid) return;
const q = this.inQueue, last = q[q.length - 1];
if (last && last.t === 'i' && m.t === 'i') last.text += m.text;
else if (last && last.t === 'r' && m.t === 'r') q[q.length - 1] = m;
else q.push(m);
this.pumpIn();
}
async pumpIn() {
if (this.inBusy || !this.inQueue.length) return;
this.inBusy = true;
while (this.inQueue.length && !this.closed) {
const m = this.inQueue.shift();
const body = m.t === 'i'
? { t: 'i', d: bytesToB64(new TextEncoder().encode(m.text)), seq: ++this.inSeq }
: { t: 'r', cols: m.cols, rows: m.rows, seq: ++this.inSeq };
// On the WebSocket a message goes out in order with no answer to wait
// for; numbered as well, so a repeat by the long polls is dropped.
if (this.ws && this.ws.readyState === 1) {
try { this.ws.send(JSON.stringify(body)); continue; } catch (e) { /* by request below */ }
}
for (let wait = 300; !this.closed; wait = Math.min(wait * 2, 5000)) {
let r = null;
try {
r = await fetch(base + this.sid + '/in', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
} catch (e) { /* no answer: send it again */ }
if (r && r.status < 500) break;
await new Promise(res => setTimeout(res, wait));
}
}
this.inBusy = false;
}
send(data) { this.queueIn({ t: 'i', text: data }); }
fit() {
const b = this.el.querySelector('.pane-body');
if (b.clientWidth > 20 && b.clientHeight > 20) { try { this.fitter.fit(); } catch (e) { /* not shown yet */ } }
}
title() { return this.ssh ? this.ssh.host : this.agent.name; }
tooltip() { return who(this); }
live() { return !!this.sid && !this.ended && !this.closed; }
focus() { this.term.focus(); }
dropTarget() { return {kind: 'term', source: this.source}; }
// Files dropped here from the same place: their paths, quoted, typed in.
drop(item) { this.send(WSUtil.shellQuoteAll(item.paths) + ' '); this.term.focus(); }
close() {
if (this.closed) return;
this.closed = true;
if (this.ws) { try { this.ws.close(); } catch (e) { /* closed already */ } }
if (this.sid) fetch(base + this.sid + '/close', { method: 'POST', keepalive: true }).catch(() => {});
this.term.dispose();
this.el.remove();
}
}
// who is what a shell is signed in as, for titles.
function who(s) {
return s.ssh ? s.ssh.user + '@' + s.ssh.host + (s.ssh.port && s.ssh.port !== 22 ? ':' + s.ssh.port : '') : s.agent.user + '@' + s.agent.host;
}
W.paneTypes.term = (agent, opts) => new Session(agent, null, opts.dir);
W.paneTypes.ssh = (agent, opts) => new Session(agent, opts.ssh);
// For the touch helpers: the terminal being typed in.
window.TermPane = {active: () => { const p = W.activePane(); return p && p.term ? p : null; }};
})();
+84
View File
@@ -0,0 +1,84 @@
// The panel's own question and input dialogs in place of the browser's
// confirm() and prompt(): UI.confirm(text, {ok, danger}) and
// UI.prompt(text, value) answer with promises. A form with data-confirm asks
// first and is sent only on «да».
(function () {
'use strict';
let dlg = null;
function make() {
dlg = document.createElement('dialog');
dlg.className = 'uidlg';
dlg.innerHTML = '<form method="dialog"><p class="ui-text"></p><input type="text" class="ui-input" autocomplete="off" spellcheck="false">' +
'<div class="row end"><button type="button" class="btn ghost close ui-no">Отмена</button><button type="submit" class="ui-yes">OK</button></div></form>';
document.body.appendChild(dlg);
}
function ask(text, o) {
if (!dlg) make();
if (dlg.open) dlg.close('');
const input = dlg.querySelector('.ui-input'), yes = dlg.querySelector('.ui-yes');
// Long words (a file name) stay on one line, cut with "…"; the window
// is as wide as its words, up to the screen's room.
const t = dlg.querySelector('.ui-text');
t.textContent = '';
// "{chip}" in the text: the chip stands there, inside the sentence.
const parts = String(text).split('{chip}');
text = parts.shift();
String(text).split(/(\s+)/).forEach(w => {
if (w.length > 28 && !/\s/.test(w)) {
const s = document.createElement('span');
s.className = 'ui-long'; s.textContent = w; s.title = w;
t.appendChild(s);
} else t.appendChild(document.createTextNode(w));
});
// Extras of a question about an action: where (a chip), the command in
// mono, a note under it.
if (parts.length && o.chip) {
const c = document.createElement('span'); c.className = 'ui-chip'; c.textContent = o.chip; c.title = o.chip;
t.append(c, parts.join(''));
o = Object.assign({}, o, {chip: ''});
}
dlg.querySelectorAll('.ui-extra').forEach(e => e.remove());
if (o.chip || o.code || o.note) {
const box = document.createElement('div');
box.className = 'ui-extra';
if (o.chip) { const c = document.createElement('div'); c.className = 'ui-chip'; c.textContent = o.chip; c.title = o.chip; box.appendChild(c); }
if (o.code) { const p = document.createElement('pre'); p.className = 'ui-code'; p.textContent = o.code; box.appendChild(p); }
if (o.note) { const n = document.createElement('p'); n.className = 'ui-note'; n.textContent = o.note; box.appendChild(n); }
t.after(box);
}
input.hidden = !o.input;
input.value = o.value || '';
yes.textContent = o.ok || 'OK';
yes.className = 'ui-yes' + (o.danger ? ' btn danger' : '');
// «Отмена» red, unless «да» is the red one (a delete): never two reds.
dlg.querySelector('.ui-no').classList.toggle('close', !o.danger);
return new Promise(done => {
const end = v => { dlg.onclose = null; done(v); };
dlg.querySelector('.ui-no').onclick = () => dlg.close('');
dlg.querySelector('form').onsubmit = ev => { ev.preventDefault(); dlg.close('ok'); };
dlg.onclose = () => end(dlg.returnValue === 'ok' ? (o.input ? input.value : true) : (o.input ? null : false));
dlg.returnValue = '';
dlg.showModal();
if (o.input) { input.focus(); input.select(); } else yes.focus();
});
}
const UI = {
confirm: (text, o) => ask(text, Object.assign({ok: 'Да'}, o)),
prompt: (text, value) => ask(text, {input: true, value, ok: 'OK'})
};
window.UI = UI;
// Forms that ask first: data-confirm, and data-danger for a red «да».
document.addEventListener('submit', ev => {
const f = ev.target;
if (!f.dataset || !f.dataset.confirm) return;
if (f.dataset.confirmed) { delete f.dataset.confirmed; return; }
ev.preventDefault();
ev.stopImmediatePropagation();
const by = ev.submitter;
UI.confirm(f.dataset.confirm, {ok: f.dataset.ok || 'Да', danger: 'danger' in f.dataset}).then(ok => {
if (!ok) return;
f.dataset.confirmed = '1';
f.requestSubmit(by && f.contains(by) ? by : undefined);
});
}, true);
})();
+5 -4
View File
@@ -1,11 +1,12 @@
// The users table in Settings: the role, the machines a user may use (a // The users table in Settings: the role, the machines a user may use (a
// dialog with a list to tick), the password sign-in and whether the user // dialog with a list to tick), the password sign-in and whether the user
// may switch it themselves. Each change is saved at once. // may switch it themselves. Each change is saved at once.
(function () { // It is mounted on the settings fragment each time the shell puts it in place.
window.UsersTable = {mount: function (root) {
'use strict'; 'use strict';
var table = document.querySelector('table.users'); var table = root.querySelector('table.users');
if (!table) return; if (!table) return;
var $ = function (id) { return document.getElementById(id); }; var $ = function (id) { return root.querySelector('#' + id); };
function save(id, body) { function save(id, body) {
return fetch('/api/panel/users/' + id, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)}) return fetch('/api/panel/users/' + id, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)})
@@ -102,4 +103,4 @@
// ---- a new user: the machines appear for a plain user ---- // ---- a new user: the machines appear for a plain user ----
var role = $('newrole'), pick = $('newagents'); var role = $('newrole'), pick = $('newagents');
if (role) role.onchange = function () { pick.hidden = role.value !== 'user'; }; if (role) role.onchange = function () { pick.hidden = role.value !== 'user'; };
})(); }};
@@ -1,12 +1,13 @@
// Terminal page: shells on one or more machines, in tabs. With one tab the // The workspace of the Терминал view: panes - terminals, file managers - of
// page is a plain terminal and a faint "+" in the corner; a second tab brings // one or more machines, in tabs. It lives as long as the page, so moving to
// the tab strip in. On a computer a tab can be dragged onto the current one // Машины or Настройки and back keeps every session. With one tab there is
// to share it - up to four terminals, split side by side or one above the // only a faint "+" in the corner; a second tab brings the tab strip in. On a
// other - and a terminal dragged out of it back to the strip. Splits resize // computer a tab can be dragged onto the current one to share it - up to four
// by their gutters; a tab is renamed by a double click on its title. // panes, split side by side or one above the other - and a pane dragged out
// of it back to the strip. Splits resize by their gutters; a tab is renamed
// by a double click on its title. Pane types register in Workspace.paneTypes.
(function () { (function () {
'use strict'; 'use strict';
const PAGE = window.TERM_PAGE;
const touch = matchMedia('(pointer: coarse)').matches; const touch = matchMedia('(pointer: coarse)').matches;
const desk = !touch; // drag and drop, splits: computers only const desk = !touch; // drag and drop, splits: computers only
if (touch) document.body.classList.add('touch'); if (touch) document.body.classList.add('touch');
@@ -17,252 +18,14 @@
const GAP = 6; const GAP = 6;
const X = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>'; const X = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>';
function bytesToB64(bytes) {
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
function b64ToBytes(b64) {
const bin = atob(b64), u = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
return u;
}
// ---------- copy and paste by keys ----------
// Off a Mac, where Cmd does it, Ctrl+C copies when there is a selection and
// interrupts as ever when there is none; Ctrl+Shift+C always copies (and is
// kept from the browser, whose developer tools it would open); Ctrl+V and
// Ctrl+Shift+V paste. Keys go by position, so a Russian layout works too.
const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
function copyText(text) {
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
// Plain http: the old way, through a field that is there for a moment.
const ta = document.createElement('textarea'), back = document.activeElement;
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();
if (back && back.focus) back.focus();
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
}
function clipKeys(s, ev) {
if (mac || !ev.ctrlKey || ev.altKey || ev.metaKey) return true;
const c = ev.code === 'KeyC', v = ev.code === 'KeyV';
if (!c && !v) return true;
if (c && !ev.shiftKey && !s.term.hasSelection()) return true; // ^C to the shell
// From here the key is ours: xterm does not turn it into a control code.
if (ev.type === 'keydown' && c) {
ev.preventDefault();
if (s.term.hasSelection()) {
const text = s.term.getSelection();
s.term.clearSelection();
copyText(text).then(() => flashStatus('скопировано', 'st-ok'), e => flashStatus('не скопировалось: ' + e.message, 'st-err'));
}
}
// Ctrl+V: the browser's paste goes on into the terminal's text field.
return false;
}
function flashStatus(t, cls) {
statusEl.textContent = t;
statusEl.className = cls;
clearTimeout(flashStatus.t);
flashStatus.t = setTimeout(showStatus, 1500);
}
// ---------- one shell ----------
class Session {
// ssh, when given, is where the agent connects by SSH for this shell:
// {host, port, user} and the secret to sign in with, sent once on open.
constructor(agent, ssh) {
this.agent = agent;
this.ssh = ssh || null;
this.sid = null; this.closed = false; this.connected = false;
this.state = 'wait'; this.text = 'подключение…';
this.inSeq = 0; this.inBusy = false; this.inQueue = [];
this.el = document.createElement('div');
this.el.className = 'pane';
this.el.innerHTML = '<div class="pane-head"><span class="dot wait"></span><span class="pane-title"></span>' +
'<button type="button" class="pane-x" title="Закрыть терминал" aria-label="Закрыть терминал">' + X + '</button></div>' +
'<div class="pane-body"></div>';
this.el.querySelector('.pane-title').textContent = who(this);
this.el.querySelector('.pane-title').title = ssh ? 'SSH через ' + agent.name : agent.name;
if (ssh) this.el.querySelector('.dot').insertAdjacentHTML('afterend', '<span class="badge-ssh">SSH</span>');
this.term = new Terminal({
cursorBlink: true, fontSize: touch ? 13 : 14,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', theme: { background: '#0c0f13' }
});
this.fitter = new FitAddon.FitAddon();
this.term.loadAddon(this.fitter);
this.term.open(this.el.querySelector('.pane-body'));
this.term.attachCustomKeyEventHandler(ev => clipKeys(this, ev));
this.term.onData(d => this.send(TermKeys.withMods(d)));
this.term.onResize(() => this.queueIn({ t: 'r', cols: this.term.cols, rows: this.term.rows }));
this.term.onSelectionChange(() => TermKeys.selectionChanged(this));
this.el.addEventListener('pointerdown', () => setActivePane(this), true);
this.term.textarea.addEventListener('focus', () => setActivePane(this));
this.el.querySelector('.pane-x').onclick = ev => { ev.stopPropagation(); closePane(this); };
if (desk) this.el.querySelector('.pane-head').addEventListener('pointerdown', ev => startDrag(ev, 'pane', this));
this.open();
}
setState(state, text) {
this.state = state; this.text = text;
this.el.querySelector('.dot').className = 'dot ' + state;
const t = tabOf(this);
if (t) renderTab(t);
showStatus();
}
async open() {
try {
let opt = { method: 'POST' };
if (this.ssh) {
opt = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ssh: this.ssh }) };
// The password or key goes once, to the agent; the page keeps no copy.
this.ssh = { host: this.ssh.host, port: this.ssh.port, user: this.ssh.user };
}
const r = await fetch(base + encodeURIComponent(this.agent.id) + '/open', opt);
if (!r.ok) {
let why = 'HTTP ' + r.status;
try { why = (await r.json()).error || why; } catch (e) { /* plain text */ }
this.setState('err', 'ошибка открытия: ' + why);
this.term.write('\r\n\x1b[31m[не удалось открыть сессию: ' + why + ']\x1b[0m\r\n');
return;
}
this.sid = (await r.json()).session_id;
this.setState('wait', 'ожидание агента…');
// An agent from before SSH terminals does not answer the request.
if (this.ssh) setTimeout(() => {
if (!this.closed && !this.ended && this.text === 'ожидание агента…') {
this.setState('err', 'агент не начал SSH-подключение');
this.term.write('\x1b[33mАгент не ответил. Если он старой версии, обновите его - SSH-терминал появился в новой.\x1b[0m\r\n');
}
}, 20000);
this.fit();
// A WebSocket if it connects; the long polls otherwise, and from a
// dropped WebSocket on for the rest of this session.
if (!(await this.openWS())) this.poll();
this.queueIn({ t: 'r', cols: this.term.cols, rows: this.term.rows });
} catch (e) {
this.setState('err', 'ошибка сети');
this.term.write('\r\n\x1b[31m[ошибка сети: ' + (e && e.message ? e.message : e) + ']\x1b[0m\r\n');
}
}
openWS() {
return new Promise(resolve => {
if (!window.WebSocket) { resolve(false); return; }
const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + base + this.sid + '/ws');
let opened = false;
const timer = setTimeout(() => { if (!opened) { ws.close(); resolve(false); } }, 5000);
ws.onopen = () => { opened = true; clearTimeout(timer); this.ws = ws; resolve(true); };
ws.onmessage = ev => {
let j;
try { j = JSON.parse(ev.data); } catch (e) { return; }
this.take(j);
};
ws.onclose = () => {
clearTimeout(timer);
if (!opened) { resolve(false); return; }
this.ws = null;
if (!this.closed && !this.ended) this.poll();
};
});
}
// take applies one answer of the output channel, either transport's.
take(j) {
if (j.closed) { this.finish('[сессия завершена]'); return false; }
for (const f of j.frames || []) {
if (f.status) this.sshStatus(f.status, f.note || '');
if (f.exit) { this.finish('[сессия завершена]'); return false; }
if (f.data) {
if (!this.connected && !this.ssh) { this.connected = true; this.setState('ok', 'подключено'); }
this.term.write(b64ToBytes(f.data));
}
}
return true;
}
// sshStatus shows how the agent's SSH connection goes.
sshStatus(st, note) {
if (st === 'dial') this.setState('wait', 'SSH: ' + note + '…');
else if (st === 'auth') this.setState('wait', 'SSH: ' + note + '…');
else if (st === 'ok') { this.connected = true; this.setState('ok', 'SSH: ' + note); }
else if (st === 'err') this.setState('err', 'SSH: ' + note);
}
async poll() {
while (!this.closed && this.sid) {
let r;
try { r = await fetch(base + this.sid + '/out', { cache: 'no-store' }); }
catch (e) { await new Promise(res => setTimeout(res, 1000)); continue; }
if (this.closed) return;
if (!r.ok) { this.finish('[соединение закрыто]'); return; }
if (!this.take(await r.json())) return;
}
}
finish(msg) {
if (this.ended) return;
this.ended = true;
if (this.state !== 'err') this.setState('off', 'соединение закрыто');
this.term.write('\r\n\x1b[33m' + msg + '\x1b[0m\r\n');
}
// Input goes out one request at a time, so on a slow link a keystroke can
// never overtake the one before it; a request that fails goes again under
// the same number, and the server drops one it already has.
queueIn(m) {
if (this.closed || !this.sid) return;
const q = this.inQueue, last = q[q.length - 1];
if (last && last.t === 'i' && m.t === 'i') last.text += m.text;
else if (last && last.t === 'r' && m.t === 'r') q[q.length - 1] = m;
else q.push(m);
this.pumpIn();
}
async pumpIn() {
if (this.inBusy || !this.inQueue.length) return;
this.inBusy = true;
while (this.inQueue.length && !this.closed) {
const m = this.inQueue.shift();
const body = m.t === 'i'
? { t: 'i', d: bytesToB64(new TextEncoder().encode(m.text)), seq: ++this.inSeq }
: { t: 'r', cols: m.cols, rows: m.rows, seq: ++this.inSeq };
// On the WebSocket a message goes out in order with no answer to wait
// for; numbered as well, so a repeat by the long polls is dropped.
if (this.ws && this.ws.readyState === 1) {
try { this.ws.send(JSON.stringify(body)); continue; } catch (e) { /* by request below */ }
}
for (let wait = 300; !this.closed; wait = Math.min(wait * 2, 5000)) {
let r = null;
try {
r = await fetch(base + this.sid + '/in', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
} catch (e) { /* no answer: send it again */ }
if (r && r.status < 500) break;
await new Promise(res => setTimeout(res, wait));
}
}
this.inBusy = false;
}
send(data) { this.queueIn({ t: 'i', text: data }); }
fit() {
const b = this.el.querySelector('.pane-body');
if (b.clientWidth > 20 && b.clientHeight > 20) { try { this.fitter.fit(); } catch (e) { /* not shown yet */ } }
}
close() {
if (this.closed) return;
this.closed = true;
if (this.ws) { try { this.ws.close(); } catch (e) { /* closed already */ } }
if (this.sid) fetch(base + this.sid + '/close', { method: 'POST', keepalive: true }).catch(() => {});
this.term.dispose();
this.el.remove();
}
}
// who is what a shell is signed in as, for titles.
function who(s) {
return s.ssh ? s.ssh.user + '@' + s.ssh.host + (s.ssh.port && s.ssh.port !== 22 ? ':' + s.ssh.port : '') : s.agent.user + '@' + s.agent.host;
}
// ---------- tabs ---------- // ---------- tabs ----------
const tabs = []; const tabs = [];
let active = null, activePane = null; let active = null, activePane = null;
const tabOf = s => tabs.find(t => t.panes.includes(s)); const tabOf = s => tabs.find(t => t.panes.includes(s));
const KIND_ICON = {
term: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 7 5 5-5 5M12 17h7"/></svg>',
files: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>'
};
function makeTab(panes, index) { function makeTab(panes, index) {
const t = { title: null, panes: panes, layout: panes.length === 1 ? '1' : 'h', c: 0.5, r: 0.5, gutters: [] }; const t = { title: null, panes: panes, layout: panes.length === 1 ? '1' : 'h', c: 0.5, r: 0.5, gutters: [] };
@@ -272,11 +35,16 @@
panes.forEach(s => t.view.appendChild(s.el)); panes.forEach(s => t.view.appendChild(s.el));
t.el = document.createElement('div'); t.el = document.createElement('div');
t.el.className = 'tab enter'; t.el.className = 'tab enter';
t.el.innerHTML = '<span class="dot wait"></span><span class="tab-title"></span>' + t.el.innerHTML = '<span class="dot wait"></span><span class="tab-kinds"></span><span class="tab-title"></span>' +
'<button type="button" class="tab-x" title="Закрыть вкладку" aria-label="Закрыть вкладку">' + X + '</button>'; '<button type="button" class="tab-x" title="Закрыть вкладку" aria-label="Закрыть вкладку">' + X + '</button>';
t.el.addEventListener('click', ev => { if (!ev.target.closest('.tab-x, input') && !dragJustEnded) activate(t); }); t.el.addEventListener('click', ev => { if (!ev.target.closest('.tab-x, input') && !dragJustEnded) activate(t); });
t.el.querySelector('.tab-x').onclick = ev => { ev.stopPropagation(); closeTab(t); }; t.el.querySelector('.tab-x').onclick = ev => { ev.stopPropagation(); closeTab(t); };
t.el.querySelector('.tab-title').addEventListener('dblclick', ev => { ev.stopPropagation(); rename(t); }); t.el.querySelector('.tab-title').addEventListener('dblclick', ev => { ev.stopPropagation(); rename(t); });
// Its machine's Порты, Статус, Изменить: a right click on the title.
t.el.addEventListener('contextmenu', ev => {
const p = t.panes.includes(activePane) ? activePane : t.panes[0];
if (window.MachineDialog && !ev.target.closest('input')) MachineDialog.menu(ev, p.agent);
});
if (desk) t.el.addEventListener('pointerdown', ev => { if (!ev.target.closest('.tab-x, input')) startDrag(ev, 'tab', t); }); if (desk) t.el.addEventListener('pointerdown', ev => { if (!ev.target.closest('.tab-x, input')) startDrag(ev, 'tab', t); });
const at = index == null ? tabs.length : index; const at = index == null ? tabs.length : index;
tablist.insertBefore(t.el, tabs[at] ? tabs[at].el : mark); tablist.insertBefore(t.el, tabs[at] ? tabs[at].el : mark);
@@ -288,7 +56,7 @@
} }
function tabTitle(t) { function tabTitle(t) {
if (t.title) return t.title; if (t.title) return t.title;
const s = t.panes[0], name = s.ssh ? s.ssh.host : s.agent.name; const name = t.panes[0].title();
return t.panes.length > 1 ? name + ' +' + (t.panes.length - 1) : name; return t.panes.length > 1 ? name + ' +' + (t.panes.length - 1) : name;
} }
// Tab widths: as wide as the title wants, from a comfortable minimum up to // Tab widths: as wide as the title wants, from a comfortable minimum up to
@@ -300,7 +68,8 @@
function fitTabs() { function fitTabs() {
ruler.font = W.font + 'px ' + getComputedStyle(document.body).fontFamily; ruler.font = W.font + 'px ' + getComputedStyle(document.body).fontFamily;
const live = tabs; const live = tabs;
const want = live.map(t => Math.min(W.max, Math.max(W.base, Math.ceil(ruler.measureText(tabTitle(t)).width) + CHROME + (t.panes.some(p => p.ssh) ? 46 : 0)))); const want = live.map(t => Math.min(W.max, Math.max(W.base, Math.ceil(ruler.measureText(tabTitle(t)).width) + CHROME + (t.panes.some(p => p.ssh) ? 46 : 0) +
18 * new Set(t.panes.map(p => /files$/.test(p.kind))).size)));
const strip = $('tabs'); const strip = $('tabs');
const room = strip.clientWidth - parseFloat(getComputedStyle(strip).paddingLeft) * 2 - addBtn.offsetWidth - 4 * (live.length + 1); const room = strip.clientWidth - parseFloat(getComputedStyle(strip).paddingLeft) * 2 - addBtn.offsetWidth - 4 * (live.length + 1);
let cap = W.max; let cap = W.max;
@@ -315,17 +84,25 @@
} }
live.forEach((t, i) => { t.el.style.width = Math.min(want[i], cap) + 'px'; }); live.forEach((t, i) => { t.el.style.width = Math.min(want[i], cap) + 'px'; });
} }
window.addEventListener('resize', () => fitTabs()); window.addEventListener('resize', () => { fitTabs(); placeFloat(); });
function renderTab(t) { function renderTab(t) {
t.el.querySelector('.tab-title').textContent = tabTitle(t); t.el.querySelector('.tab-title').textContent = tabTitle(t);
// Before the name, what the tab holds: a terminal, files, or both.
const kinds = [...new Set(t.panes.map(p => /files$/.test(p.kind) ? 'files' : 'term'))];
const k = t.el.querySelector('.tab-kinds');
if (k.dataset.k !== kinds.join()) {
k.dataset.k = kinds.join();
k.innerHTML = kinds.map(x => KIND_ICON[x]).join('');
k.title = kinds.map(x => x === 'files' ? 'файлы' : 'терминал').join(' и ');
}
const ssh = t.panes.some(s => s.ssh); const ssh = t.panes.some(s => s.ssh);
if (ssh !== !!t.el.querySelector('.badge-ssh')) { if (ssh !== !!t.el.querySelector('.badge-ssh')) {
if (ssh) t.el.querySelector('.dot').insertAdjacentHTML('afterend', '<span class="badge-ssh">SSH</span>'); if (ssh) t.el.querySelector('.dot').insertAdjacentHTML('afterend', '<span class="badge-ssh">SSH</span>');
else t.el.querySelector('.badge-ssh').remove(); else t.el.querySelector('.badge-ssh').remove();
} }
requestAnimationFrame(fitTabs); requestAnimationFrame(fitTabs);
t.el.title = t.panes.map(who).join(', '); t.el.title = t.panes.map(p => p.tooltip ? p.tooltip() : p.title()).join(', ');
// The tab's dot: red if any shell failed, amber while one connects. // The tab's dot: red if any shell failed, amber while one connects.
const st = t.panes.map(s => s.state); const st = t.panes.map(s => s.state);
t.el.querySelector('.dot').className = 'dot ' + (st.includes('err') ? 'err' : st.includes('wait') ? 'wait' : st.includes('ok') ? 'ok' : 'off'); t.el.querySelector('.dot').className = 'dot ' + (st.includes('err') ? 'err' : st.includes('wait') ? 'wait' : st.includes('ok') ? 'ok' : 'off');
@@ -340,17 +117,26 @@
if (!t.panes.includes(activePane)) setActivePane(t.panes[0]); if (!t.panes.includes(activePane)) setActivePane(t.panes[0]);
layout(t, false); layout(t, false);
t.el.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' }); t.el.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
if (desk && activePane) activePane.term.focus(); if (desk && activePane) activePane.focus();
updateMode(); updateMode();
// The tab comes in moving a little; the "+" goes by where it ends up.
setTimeout(placeFloat, 260);
} }
function setActivePane(s) { function setActivePane(s) {
activePane = s; activePane = s;
// The phone's extra keys are a terminal's: a file pane goes without.
const term = s.kind === 'term' || s.kind === 'ssh';
if (touch && document.body.classList.contains('term-active') !== term) {
document.body.classList.toggle('term-active', term);
setTimeout(() => active && layout(active, false), 0);
}
document.querySelectorAll('.pane.active').forEach(p => p.classList.toggle('active', p === s.el)); document.querySelectorAll('.pane.active').forEach(p => p.classList.toggle('active', p === s.el));
s.el.classList.add('active'); s.el.classList.add('active');
showStatus(); showStatus();
} }
function showStatus() { function showStatus() {
if (!activePane) { statusEl.textContent = ''; return; } // The header's status line is the workspace's only while it is shown.
if (!activePane || document.querySelector('main#app').dataset.view !== 'terminal') { statusEl.textContent = ''; return; }
statusEl.textContent = activePane.text; statusEl.textContent = activePane.text;
statusEl.title = activePane.text; statusEl.title = activePane.text;
statusEl.className = activePane.state === 'ok' ? 'st-ok' : activePane.state === 'err' ? 'st-err' : 'muted'; statusEl.className = activePane.state === 'ok' ? 'st-ok' : activePane.state === 'err' ? 'st-err' : 'muted';
@@ -358,6 +144,28 @@
// The corner "+" moves down out of the way of a split tab's title bars. // The corner "+" moves down out of the way of a split tab's title bars.
function syncFloat() { function syncFloat() {
document.body.classList.toggle('split1', tabs.length === 1 && tabs[0].panes.length > 1); document.body.classList.toggle('split1', tabs.length === 1 && tabs[0].panes.length > 1);
placeFloat();
}
// With one tab and a file pane in the top right corner, the "+" joins
// that pane's first toolbar after "Обновить". It belongs to the scrolling
// file pane, so it stays beside the toolbar when the pane is scrolled.
// Otherwise it returns to the corner of .area.
function placeFloat() {
const p = tabs.length === 1 ? tabs[0].panes.find(s => s.el.classList.contains('under-plus')) : null;
const bars = p && /files$/.test(p.kind) ? p.el.querySelectorAll('.fm-bar') : [];
// A bar folded in two (a phone or a narrow pane) keeps the corner "+".
const on = bars.length > 1 && bars[0].offsetParent !== null && bars[0].offsetHeight < 48;
floatAdd.classList.toggle('in-files', on);
if (on) {
bars[0].appendChild(floatAdd);
if (bars[0].offsetHeight >= 48) {
floatAdd.classList.remove('in-files');
area.insertBefore(floatAdd, area.firstChild);
}
} else {
area.insertBefore(floatAdd, area.firstChild);
}
floatAdd.style.top = floatAdd.style.right = '';
} }
// One tab: a plain terminal, the strip folded into the corner "+". // One tab: a plain terminal, the strip folded into the corner "+".
function updateMode() { function updateMode() {
@@ -367,15 +175,19 @@
document.body.classList.toggle('multi', multi); document.body.classList.toggle('multi', multi);
setTimeout(() => active && layout(active, false), 280); setTimeout(() => active && layout(active, false), 280);
} }
function openTerminal(agent, ssh) { function openPane(kind, agent, opts) {
const make = paneTypes[kind];
if (!make) return null;
$('empty').hidden = true; $('empty').hidden = true;
const t = makeTab([new Session(agent, ssh)]); const t = makeTab([make(agent, opts || {})]);
activate(t); activate(t);
return t;
} }
function closeTab(t) { function openTerminal(agent, ssh) { return openPane(ssh ? 'ssh' : 'term', agent, {ssh}); }
async function closeTab(t) {
const i = tabs.indexOf(t); const i = tabs.indexOf(t);
if (i < 0) return; if (i < 0) return;
if (t.panes.length > 1 && !confirm('Закрыть вкладку с ' + t.panes.length + ' терминалами?')) return; if (t.panes.length > 1 && !await UI.confirm('Закрыть вкладку с ' + t.panes.length + ' панелями?', {ok: 'Закрыть', danger: true})) return;
tabs.splice(i, 1); tabs.splice(i, 1);
t.panes.forEach(s => s.close()); t.panes.forEach(s => s.close());
t.el.classList.add('leave'); t.el.classList.add('leave');
@@ -385,7 +197,7 @@
active = null; active = null;
activate(tabs[Math.min(i, tabs.length - 1)]); activate(tabs[Math.min(i, tabs.length - 1)]);
} }
if (!tabs.length) { activePane = null; showStatus(); $('empty').hidden = false; } if (!tabs.length) { activePane = null; showStatus(); $('empty').hidden = false; document.body.classList.remove('term-active'); }
updateMode(); updateMode();
} }
function rename(t) { function rename(t) {
@@ -434,10 +246,15 @@
if (!W || !H) return; if (!W || !H) return;
const L = rects(t.layout, t.c, t.r, W, H); const L = rects(t.layout, t.c, t.r, W, H);
t.view.classList.toggle('instant', !animate); t.view.classList.toggle('instant', !animate);
// The pane in the top right corner sits under the corner "+": it keeps
// its first bar clear of it.
let corner = 0;
L.panes.forEach((p, i) => { const c = L.panes[corner]; if (p.y < c.y || (p.y === c.y && p.x + p.w > c.x + c.w)) corner = i; });
t.panes.forEach((s, i) => { t.panes.forEach((s, i) => {
const p = L.panes[i]; const p = L.panes[i];
s.el.style.left = p.x + 'px'; s.el.style.top = p.y + 'px'; s.el.style.left = p.x + 'px'; s.el.style.top = p.y + 'px';
s.el.style.width = p.w + 'px'; s.el.style.height = p.h + 'px'; s.el.style.width = p.w + 'px'; s.el.style.height = p.h + 'px';
s.el.classList.toggle('under-plus', i === corner);
}); });
while (t.gutters.length < L.gutters.length) { while (t.gutters.length < L.gutters.length) {
const g = document.createElement('div'); const g = document.createElement('div');
@@ -454,7 +271,7 @@
g.style.left = d.x + 'px'; g.style.top = d.y + 'px'; g.style.width = d.w + 'px'; g.style.height = d.h + 'px'; g.style.left = d.x + 'px'; g.style.top = d.y + 'px'; g.style.width = d.w + 'px'; g.style.height = d.h + 'px';
}); });
clearTimeout(t.fitTimer); clearTimeout(t.fitTimer);
t.fitTimer = setTimeout(() => t.panes.forEach(s => s.fit()), animate ? 270 : 0); t.fitTimer = setTimeout(() => { t.panes.forEach(s => s.fit()); placeFloat(); }, animate ? 270 : 0);
} }
function startResize(ev, t, g) { function startResize(ev, t, g) {
ev.preventDefault(); ev.preventDefault();
@@ -532,16 +349,26 @@
// it. A terminal from a split, by its title bar: to the strip to make it a // it. A terminal from a split, by its title bar: to the strip to make it a
// tab of its own, or onto another terminal of its tab to swap places. // tab of its own, or onto another terminal of its tab to swap places.
let drag = null, dragJustEnded = false; let drag = null, dragJustEnded = false;
// Safari may start a native drag for the tab or header text while our
// pointer-based drag is active, producing a second image under the cursor.
// External files are unaffected: their dragstart originates outside this page.
document.addEventListener('dragstart', ev => {
if (!drag && !document.body.classList.contains('ai-dragging')) return;
ev.preventDefault();
ev.stopImmediatePropagation();
}, true);
function startDrag(ev, kind, obj) { function startDrag(ev, kind, obj) {
if (ev.button !== 0) return; if (ev.button !== 0) return;
drag = { kind, obj, x0: ev.clientX, y0: ev.clientY, on: false, id: ev.pointerId }; drag = { kind, obj, x0: ev.clientX, y0: ev.clientY, on: false, id: ev.pointerId };
} }
function beginDrag() { function beginDrag() {
drag.on = true; drag.on = true;
// The tab open when the drag began: it comes back if nothing is done.
drag.origActive = active;
const src = drag.kind === 'tab' ? drag.obj.el : drag.obj.el.querySelector('.pane-head'); const src = drag.kind === 'tab' ? drag.obj.el : drag.obj.el.querySelector('.pane-head');
const g = document.createElement('div'); const g = document.createElement('div');
g.className = 'drag-ghost ' + drag.kind; g.className = 'drag-ghost ' + drag.kind;
g.textContent = drag.kind === 'tab' ? tabTitle(drag.obj) : drag.obj.agent.user + '@' + drag.obj.agent.host; g.textContent = drag.kind === 'tab' ? tabTitle(drag.obj) : (drag.obj.tooltip ? drag.obj.tooltip() : drag.obj.title());
document.body.appendChild(g); document.body.appendChild(g);
drag.ghost = g; drag.ghost = g;
drag.src = src; drag.src = src;
@@ -580,6 +407,16 @@
const stripShown = document.body.classList.contains('multi') || document.body.classList.contains('multi-hint'); const stripShown = document.body.classList.contains('multi') || document.body.classList.contains('multi-hint');
const inStrip = stripShown && ev.clientY >= strip.top - 6 && ev.clientY <= strip.bottom + 10; const inStrip = stripShown && ev.clientY >= strip.top - 6 && ev.clientY <= strip.bottom + 10;
const inArea = active && (() => { const r = active.view.getBoundingClientRect(); return ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom; })(); const inArea = active && (() => { const r = active.view.getBoundingClientRect(); return ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom; })();
// A tab held over another tab's title for a moment opens that one, so
// the dragged tab can be dropped into it.
if (drag.kind === 'tab') {
const over = inStrip ? tabs.find(t => t !== drag.obj && t !== active && (() => { const r = t.el.getBoundingClientRect(); return ev.clientX >= r.left && ev.clientX <= r.right; })()) : null;
if (over !== drag.overTab) {
clearTimeout(drag.overTimer);
drag.overTab = over;
if (over) drag.overTimer = setTimeout(() => { if (drag && drag.overTab === over) activate(over); }, 450);
}
}
if (inStrip) { if (inStrip) {
const s = stripIndex(ev.clientX); const s = stripIndex(ev.clientX);
mark.hidden = false; mark.style.left = s.mx + 'px'; mark.hidden = false; mark.style.left = s.mx + 'px';
@@ -615,6 +452,14 @@
const d = drag; const d = drag;
drag = null; drag = null;
if (!d.on) return; if (!d.on) return;
clearTimeout(d.overTimer);
// Dropped with nothing done (or only moved along the strip): back to the
// tab that was dragged, if another one was opened meanwhile.
const tg0 = d.target;
if (d.kind === 'tab' && d.origActive && active !== d.origActive && !(tg0 && tg0.kind === 'merge')) {
const back = d.origActive;
setTimeout(() => activate(back), 0);
}
d.ghost.remove(); d.ghost.remove();
d.src.classList.remove('dragging'); d.src.classList.remove('dragging');
document.body.classList.remove('dnd', 'multi-hint'); document.body.classList.remove('dnd', 'multi-hint');
@@ -645,7 +490,7 @@
renderTab(t); renderTab(t);
layout(t, true); layout(t, true);
updateMode(); updateMode();
s.term.focus(); s.focus();
} else if (d.kind === 'pane' && tg.kind === 'strip') { } else if (d.kind === 'pane' && tg.kind === 'strip') {
const s = d.obj, from = tabOf(s), ref = tg.others[tg.index]; const s = d.obj, from = tabOf(s), ref = tg.others[tg.index];
takePane(from, s); takePane(from, s);
@@ -666,43 +511,83 @@
// ---------- the "+" menu ---------- // ---------- the "+" menu ----------
let agentsCache = null; let agentsCache = null;
function openMenu(anchor) { function openMenu(anchor) {
const here = (activePane && activePane.agent) || PAGE.agent; const here = activePane && activePane.agent;
addMenu.innerHTML = ''; addMenu.innerHTML = '';
const item = (html, fn, cls) => { const esc = v => String(v).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const item = (parent, html, fn, cls) => {
const b = document.createElement('button'); const b = document.createElement('button');
b.type = 'button'; b.className = 'item ' + (cls || ''); b.innerHTML = html; b.type = 'button'; b.className = 'item ' + (cls || ''); b.innerHTML = html;
b.onclick = fn; b.onclick = fn;
addMenu.appendChild(b); parent.appendChild(b);
return b; return b;
}; };
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])); const FILES = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>';
item('<span class="ico">+</span><span><b>Новый терминал здесь</b><small>' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '</small></span>', // Both groups keep their contents mounted so they can animate in both directions.
() => { closeMenu(); openTerminal(here); }); const group = (icon, label, kind, mode) => {
item('<span class="ico">' + KEYI + '</span><span><b>SSH-подключение</b><small>агент откроет SSH к другому хосту</small></span>', let loaded = false;
() => { closeMenu(); openSSH(here); }); const g = item(addMenu, '<span class="ico">' + icon + '</span><span><b>' + label + '</b></span><span class="chev" aria-hidden="true">›</span>', () => {
const other = item('<span class="ico">⇄</span><span><b>На другой машине</b><small>выбрать из списка</small></span><span class="chev">›</span>', () => { const opening = g.getAttribute('aria-expanded') !== 'true';
other.remove(); addMenu.querySelectorAll('.item[aria-expanded="true"]').forEach(b => {
const list = document.createElement('div'); b.setAttribute('aria-expanded', 'false');
list.className = 'mlist'; b.nextSibling.classList.remove('open');
list.innerHTML = '<div class="muted" style="padding:8px 10px;font-size:13px">загрузка…</div>'; b.nextSibling.inert = true;
addMenu.appendChild(list); });
if (!opening) { placeMenu(anchor); return; }
g.setAttribute('aria-expanded', 'true');
list.classList.add('open');
list.inert = false;
placeMenu(anchor);
if (loaded) return;
loaded = true;
(agentsCache ? Promise.resolve(agentsCache) : fetch('/api/panel/agents/presence', { cache: 'no-store' }).then(r => r.json())) (agentsCache ? Promise.resolve(agentsCache) : fetch('/api/panel/agents/presence', { cache: 'no-store' }).then(r => r.json()))
.then(all => { .then(all => {
agentsCache = all; agentsCache = all;
list.innerHTML = ''; box.innerHTML = '';
all.forEach(a => { all.filter(a => !here || a.id !== here.id).forEach(a => {
const b = document.createElement('button'); const b = item(box, '<span class="ico"><span class="dot ' + (a.online ? 'ok' : 'off') + '"></span></span><span><b>' + esc(a.name) + '</b><small>' +
b.type = 'button'; b.className = 'item machine'; b.disabled = !a.online; esc(a.user + '@' + a.hostname) + (a.online ? '' : ' · ' + esc(a.seen)) + '</small></span>',
b.innerHTML = '<span class="dot ' + (a.online ? 'ok' : 'off') + '"></span><span><b>' + esc(a.name) + '</b><small>' + () => { closeMenu(); openPane(kind, { id: a.id, name: a.name, host: a.hostname, user: a.user }); }, 'machine');
esc(a.user + '@' + a.hostname) + (a.online ? '' : ' · ' + esc(a.seen)) + '</small></span>'; b.disabled = !a.online;
b.onclick = () => { closeMenu(); openTerminal({ id: a.id, name: a.name, host: a.hostname, user: a.user }); };
list.appendChild(b);
}); });
if (!all.length) list.innerHTML = '<div class="muted" style="padding:8px 10px">машин нет</div>'; if (list.isConnected) placeMenu(anchor);
placeMenu(anchor);
}) })
.catch(() => { list.innerHTML = '<div class="muted" style="padding:8px 10px">не загрузилось</div>'; }); .catch(() => { box.innerHTML = '<div class="muted" style="padding:8px 10px">не загрузилось</div>'; });
}); });
g.setAttribute('aria-expanded', 'false');
const list = document.createElement('div');
list.className = 'mlist';
list.inert = true;
const inner = document.createElement('div');
inner.className = 'mlist-inner';
const content = document.createElement('div');
content.className = 'mlist-content';
inner.appendChild(content);
list.appendChild(inner);
g.after(list);
if (here) item(content, '<span class="ico">+</span><span><b>Здесь</b><small>' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '</small></span>',
() => {
closeMenu();
// A terminal from a machine's file pane starts in its folder, a
// file pane from a machine's terminal in the terminal's (asked of
// the machine; home if it cannot tell) - unless turned off.
const on = !(window.Prefs && Prefs.get().openHere === 'off');
let opts = null;
if (on && kind === 'term' && activePane.kind === 'files') opts = {dir: activePane.cwd};
else if (on && kind === 'files' && activePane.kind === 'term' && activePane.sid) {
opts = {dir: new MachineSource(here).op('termcwd', {session: activePane.sid}).then(r => r.ok && r.data ? r.data.path : '')};
}
openPane(kind, here, opts);
});
const box = document.createElement('div');
box.innerHTML = '<div class="muted" style="padding:8px 10px;font-size:13px">загрузка…</div>';
content.appendChild(box);
item(content, '<span class="ico">' + KEYI + '</span><span><b>SSH-хост…</b><small>машина откроет SSH к другому хосту</small></span>',
() => { closeMenu(); openSSH(here, mode); });
if (!paneTypes[kind]) g.disabled = true;
return g;
};
group('&gt;_', 'Терминал', 'term', 'term');
group(FILES, 'Файловый менеджер', 'files', 'files');
addMenu.hidden = false; addMenu.hidden = false;
placeMenu(anchor); placeMenu(anchor);
setTimeout(() => document.addEventListener('pointerdown', outside, true), 0); setTimeout(() => document.addEventListener('pointerdown', outside, true), 0);
@@ -725,10 +610,14 @@
// ---------- the SSH dialog ---------- // ---------- the SSH dialog ----------
const KEYI = '<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="7.5" cy="15.5" r="4.5"/><path d="m21 2-9.6 9.6M15.5 7.5l3 3L22 7l-3-3"/></svg>'; const KEYI = '<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="7.5" cy="15.5" r="4.5"/><path d="m21 2-9.6 9.6M15.5 7.5l3 3L22 7l-3-3"/></svg>';
let sshDlg = null; let sshInto = null, sshDlg = null, sshMode = 'term';
function openSSH(here) { // openSSH asks where to connect; mode says what opens there: a terminal
// ('term') or a file manager ('files', through Workspace.sshFiles).
// into: a file pane whose SSH connection is lost - the dialog comes with
// its host, port and user, and connects that pane again.
function openSSH(here, mode, into) {
if (!sshDlg) sshDlg = makeSSHDialog(); if (!sshDlg) sshDlg = makeSSHDialog();
sshDlg.show(here); sshDlg.show(here, mode || 'term', into);
} }
function makeSSHDialog() { function makeSSHDialog() {
const d = document.createElement('dialog'); const d = document.createElement('dialog');
@@ -739,10 +628,12 @@
'<button type="button" class="btn ghost small icon close x" data-close aria-label="Закрыть">' + X + '</button></div>' + '<button type="button" class="btn ghost small icon close x" data-close aria-label="Закрыть">' + X + '</button></div>' +
'<div class="dlg-b">' + '<div class="dlg-b">' +
'<label class="f"><span>Подключаться с машины</span><select name="agent"></select></label>' + '<label class="f"><span>Подключаться с машины</span><select name="agent"></select></label>' +
'<div class="f saved-row"><span>Сохранённый хост</span><div class="frow"><select name="saved" class="grow"><option value="">новый хост</option></select>' +
'<button type="button" class="btn ghost small icon close x" data-delsaved title="Удалить из сохранённых" aria-label="Удалить из сохранённых" hidden>' + X + '</button></div></div>' +
'<div class="frow"><label class="f grow"><span>Хост</span><input type="text" name="host" placeholder="example.com или 10.0.0.5" required autocapitalize="off" spellcheck="false"></label>' + '<div class="frow"><label class="f grow"><span>Хост</span><input type="text" name="host" placeholder="example.com или 10.0.0.5" required autocapitalize="off" spellcheck="false"></label>' +
'<label class="f port"><span>Порт</span><input type="number" name="port" min="1" max="65535" value="22" required></label></div>' + '<label class="f port"><span>Порт</span><input type="number" name="port" min="1" max="65535" value="22" required></label></div>' +
'<label class="f"><span>Пользователь</span><input type="text" name="user" placeholder="root" required autocapitalize="off" spellcheck="false"></label>' + '<label class="f"><span>Пользователь</span><input type="text" name="user" placeholder="root" required autocapitalize="off" spellcheck="false"></label>' +
'<div class="f"><span>Вход</span><div class="seg" role="tablist">' + '<div class="f auth-row"><span>Вход</span><div class="seg" role="tablist">' +
'<button type="button" data-auth="password" class="on">Пароль</button><button type="button" data-auth="key">Ключ</button><button type="button" data-auth="agent">Ключи агента</button></div></div>' + '<button type="button" data-auth="password" class="on">Пароль</button><button type="button" data-auth="key">Ключ</button><button type="button" data-auth="agent">Ключи агента</button></div></div>' +
'<div class="auth" data-for="password"><input type="password" name="password" placeholder="пароль" autocomplete="off"></div>' + '<div class="auth" data-for="password"><input type="password" name="password" placeholder="пароль" autocomplete="off"></div>' +
'<div class="auth" data-for="key" hidden>' + '<div class="auth" data-for="key" hidden>' +
@@ -751,11 +642,14 @@
'<input type="file" hidden></div>' + '<input type="file" hidden></div>' +
'<input type="password" name="passphrase" placeholder="фраза ключа, если есть" autocomplete="off"></div>' + '<input type="password" name="passphrase" placeholder="фраза ключа, если есть" autocomplete="off"></div>' +
'<p class="auth muted" data-for="agent" hidden>Агент войдёт своими ключами из ~/.ssh (id_ed25519, id_ecdsa, id_rsa) без фразы.</p>' + '<p class="auth muted" data-for="agent" hidden>Агент войдёт своими ключами из ~/.ssh (id_ed25519, id_ecdsa, id_rsa) без фразы.</p>' +
'<label class="chk save-row"><input type="checkbox" name="save"> Сохранить хост и вход - на сервере, зашифровано, только для вас</label>' +
'<p class="sshmsg"></p>' + '<p class="sshmsg"></p>' +
'<div class="row end"><button type="button" class="btn ghost" data-close>Отмена</button><button type="submit">Подключиться</button></div>' + '<div class="row end"><button type="button" class="btn ghost" data-close>Отмена</button><button type="submit">Подключиться</button></div>' +
'</div></form>'; '</div></form>';
document.body.appendChild(d); document.body.appendChild(d);
const f = d.querySelector('form'), msg = d.querySelector('.sshmsg'); const f = d.querySelector('form'), msg = d.querySelector('.sshmsg');
// say: a line under the form - red for what went wrong, quiet for what goes on.
const say = (t, going) => { msg.textContent = t; msg.classList.toggle('going', !!going); };
let auth = 'password'; let auth = 'password';
const setAuth = a => { const setAuth = a => {
auth = a; auth = a;
@@ -769,8 +663,8 @@
file.onchange = () => { file.onchange = () => {
const fl = file.files[0]; const fl = file.files[0];
if (!fl) return; if (!fl) return;
if (fl.size > 32768) { msg.textContent = 'файл великоват для ключа'; return; } if (fl.size > 32768) { say('файл великоват для ключа'); return; }
fl.text().then(t => { f.key.value = t.trim() + '\n'; d.querySelector('.keyname').textContent = fl.name; msg.textContent = ''; }); fl.text().then(t => { f.key.value = t.trim() + '\n'; d.querySelector('.keyname').textContent = fl.name; say(''); });
file.value = ''; file.value = '';
}; };
// then runs once the dialog is shut and focus is back where it was, so // then runs once the dialog is shut and focus is back where it was, so
@@ -781,31 +675,82 @@
} }
d.addEventListener('cancel', ev => { ev.preventDefault(); close(); }); d.addEventListener('cancel', ev => { ev.preventDefault(); close(); });
d.addEventListener('click', ev => { if (ev.target === d) close(); }); d.addEventListener('click', ev => { if (ev.target === d) close(); });
let agents = []; let agents = [], saved = [];
f.onsubmit = ev => { // A saved host: its address shows, the way in stays on the server.
const useSaved = () => {
const h = saved.find(x => String(x.id) === f.saved.value);
d.querySelector('[data-delsaved]').hidden = !h;
d.querySelectorAll('.auth-row, .auth, .save-row').forEach(el => { el.style.display = h ? 'none' : ''; });
if (!h) { setAuth(auth); return; }
f.host.value = h.host; f.port.value = h.port; f.user.value = h.user;
};
f.saved.onchange = useSaved;
const loadSaved = () => fetch('/api/panel/ssh/hosts', { cache: 'no-store' }).then(r => r.json()).then(list => {
saved = list || [];
const keep = f.saved.value;
f.saved.innerHTML = '<option value="">новый хост</option>' + saved.map(h => '<option value="' + h.id + '">' +
String(h.label).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])) + '</option>').join('');
f.saved.value = saved.some(h => String(h.id) === keep) ? keep : '';
d.querySelector('.saved-row').hidden = !saved.length;
useSaved();
}).catch(() => {});
d.querySelector('[data-delsaved]').onclick = async () => {
const h = saved.find(x => String(x.id) === f.saved.value);
if (!h || !await UI.confirm('Удалить «' + h.label + '» из сохранённых?', { ok: 'Удалить', danger: true })) return;
await fetch('/api/panel/ssh/hosts/' + h.id + '/delete', { method: 'POST' });
f.saved.value = '';
loadSaved();
};
f.onsubmit = async ev => {
ev.preventDefault(); ev.preventDefault();
const a = agents.find(x => x.id === f.agent.value); const a = agents.find(x => x.id === f.agent.value);
if (!a) { msg.textContent = 'выберите машину'; return; } if (!a) { say('выберите машину'); return; }
const t = { host: f.host.value.trim(), port: Number(f.port.value) || 22, user: f.user.value.trim() }; const t = { host: f.host.value.trim(), port: Number(f.port.value) || 22, user: f.user.value.trim() };
if (auth === 'password') { if (f.saved.value) {
if (!f.password.value) { msg.textContent = 'введите пароль'; f.password.focus(); return; } t.saved = Number(f.saved.value);
} else if (auth === 'password') {
if (!f.password.value) { say('введите пароль'); f.password.focus(); return; }
t.password = f.password.value; t.password = f.password.value;
} else if (auth === 'key') { } else if (auth === 'key') {
if (!/PRIVATE KEY/.test(f.key.value)) { msg.textContent = 'вставьте закрытый ключ или загрузите файл'; return; } if (!/PRIVATE KEY/.test(f.key.value)) { say('вставьте закрытый ключ или загрузите файл'); return; }
t.key = f.key.value; t.key = f.key.value;
if (f.passphrase.value) t.passphrase = f.passphrase.value; if (f.passphrase.value) t.passphrase = f.passphrase.value;
} }
try { localStorage.setItem('lpra.ssh.last', JSON.stringify({ agent: a.id, host: t.host, port: t.port, user: t.user, auth: auth })); } catch (e) { /* not kept */ } if (!t.saved && f.save.checked) {
// Saved first, then used by its id like any saved host.
const r = await fetch('/api/panel/ssh/hosts', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.assign({ auth: auth }, t)) }).then(x => x.json()).catch(() => ({ ok: false, error: 'ошибка сети' }));
if (!r.ok) { say(r.error || 'не сохранилось'); return; }
f.save.checked = false;
loadSaved();
}
try { localStorage.setItem('lpra.ssh.last', JSON.stringify({ agent: a.id, host: t.host, port: t.port, user: t.user, auth: auth, saved: t.saved || 0 })); } catch (e) { /* not kept */ }
f.password.value = ''; f.passphrase.value = ''; f.key.value = ''; f.password.value = ''; f.passphrase.value = ''; f.key.value = '';
d.querySelector('.keyname').textContent = ''; d.querySelector('.keyname').textContent = '';
if (sshMode === 'files') {
// The machine connects first; the pane opens once it is in.
const btn = f.querySelector('button[type=submit]');
btn.disabled = true;
say('подключение…', true);
window.Workspace.sshFiles(a, t, sshInto).then(r => {
btn.disabled = false;
if (!r.ok) { say(r.error || 'не подключилось'); return; }
say('');
close();
});
return;
}
close(() => openTerminal(a, t)); close(() => openTerminal(a, t));
}; };
return { return {
show(here) { show(here, mode, into) {
msg.textContent = ''; sshMode = mode;
sshInto = into || null;
d.querySelector('.dlg-h strong').textContent = mode === 'files' ? 'Файлы по SSH' : 'SSH-подключение';
say('');
let last = {}; let last = {};
try { last = JSON.parse(localStorage.getItem('lpra.ssh.last') || '{}'); } catch (e) { /* none */ } try { last = JSON.parse(localStorage.getItem('lpra.ssh.last') || '{}'); } catch (e) { /* none */ }
agents = [here]; agents = here ? [here] : [];
const fill = () => { const fill = () => {
f.agent.innerHTML = ''; f.agent.innerHTML = '';
agents.forEach(a => { agents.forEach(a => {
@@ -814,14 +759,20 @@
o.disabled = a.online === false; o.disabled = a.online === false;
f.agent.appendChild(o); f.agent.appendChild(o);
}); });
f.agent.value = agents.some(a => a.id === last.agent && a.online !== false) ? last.agent : here.id; f.agent.value = agents.some(a => a.id === last.agent && a.online !== false) ? last.agent : (here ? here.id : (agents.find(a => a.online !== false) || {}).id || '');
}; };
fill(); fill();
fetch('/api/panel/agents/presence', { cache: 'no-store' }).then(r => r.json()).then(all => { fetch('/api/panel/agents/presence', { cache: 'no-store' }).then(r => r.json()).then(all => {
agents = all.map(a => ({ id: a.id, name: a.name, host: a.hostname, user: a.user, online: a.online })); agents = all.map(a => ({ id: a.id, name: a.name, host: a.hostname, user: a.user, online: a.online }));
if (!agents.some(a => a.id === here.id)) agents.unshift(here); if (here && !agents.some(a => a.id === here.id)) agents.unshift(here);
fill(); fill();
}).catch(() => {}); }).catch(() => {});
if (into) last = { agent: here.id, host: into.ssh.host, port: into.ssh.port, user: into.ssh.user, auth: last.auth, saved: last.saved };
f.saved.value = '';
loadSaved().then(() => {
// The host used last, if it was a saved one and still is.
if (last.saved && saved.some(h => h.id === last.saved && h.host === last.host)) { f.saved.value = String(last.saved); useSaved(); }
});
if (last.host) { f.host.value = last.host; f.port.value = last.port || 22; f.user.value = last.user || ''; } if (last.host) { f.host.value = last.host; f.port.value = last.port || 22; f.user.value = last.user || ''; }
setAuth(last.auth === 'key' || last.auth === 'agent' ? last.auth : 'password'); setAuth(last.auth === 'key' || last.auth === 'agent' ? last.auth : 'password');
d.showModal(); d.showModal();
@@ -848,11 +799,36 @@
window.visualViewport.addEventListener('scroll', refit); window.visualViewport.addEventListener('scroll', refit);
} }
window.addEventListener('pagehide', () => { window.addEventListener('pagehide', () => {
tabs.forEach(t => t.panes.forEach(s => { if (s.sid) navigator.sendBeacon(base + s.sid + '/close'); })); tabs.forEach(t => t.panes.forEach(s => {
if (s.sid) navigator.sendBeacon(base + s.sid + '/close');
// A file pane on an SSH host lets the machine drop its connection.
if (s.src && s.src.close) s.src.close();
}));
}); });
// For the touch helpers in the page: the terminal being typed in. // The view coming into sight: fit the panes, show the status line.
window.TermPane = { active: () => activePane }; document.addEventListener('app:view', ev => {
openTerminal(PAGE.agent); if (ev.detail === 'terminal') { refit(); showStatus(); } else statusEl.textContent = '';
refit(); });
$('empty').hidden = tabs.length > 0;
const paneTypes = {};
// open opens a pane of kind on agent in a new tab and shows the view.
function open(kind, agent, opts) {
const t = openPane(kind, agent, opts);
if (location.pathname !== '/workspace' && window.App) App.go('/workspace');
return t;
}
const in_ = (el, x, y) => { const r = el.getBoundingClientRect(); return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom; };
window.Workspace = {
paneTypes, desk, touch, open, activate,
hasLiveSessions: () => tabs.some(t => t.panes.some(p => p.live())),
panes: () => tabs.flatMap(t => t.panes),
activePane: () => activePane,
renderPane: p => { const t = tabOf(p); if (t) renderTab(t); if (p === activePane) showStatus(); },
setActivePane, tabOf, renderTab, showStatus, closePane, startDrag, openSSH,
// paneAt, tabAt: the pane of the shown tab, the tab title at a point.
paneAt: (x, y) => active && active.panes.find(p => in_(p.el, x, y)),
tabAt: (x, y) => document.body.classList.contains('multi') ? tabs.find(t => in_(t.el, x, y)) : null
};
})(); })();
+107
View File
@@ -0,0 +1,107 @@
// 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;
// dropAction says what dropping item on target does: a transfer between
// file panes, paths pasted into a terminal of the same place, nothing on
// its own folder, or a refusal with the reason.
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';
}
// scope gives a module timers, requests and listeners that end with it;
// once ended it starts nothing new (a request's late answer cannot set a
// timer going again).
function scope() {
let ended = false;
const timers = new Set(), offs = [], ac = new AbortController();
return {
timeout(fn, ms) { if (ended) return 0; const id = setTimeout(() => { timers.delete(id); fn(); }, ms); timers.add(id); return id; },
interval(fn, ms) { if (ended) return 0; const id = setInterval(fn, ms); timers.add(id); return id; },
clear(id) { clearTimeout(id); clearInterval(id); timers.delete(id); },
fetch(url, opt) {
const o = Object.assign({credentials: 'same-origin'}, opt || {});
// A request with its own signal ends on either.
o.signal = o.signal && AbortSignal.any ? AbortSignal.any([o.signal, ac.signal]) : (o.signal || ac.signal);
return fetch(url, o);
},
on(target, ev, fn, opt) { target.addEventListener(ev, fn, opt); offs.push(() => target.removeEventListener(ev, fn, opt)); },
signal: ac.signal,
end() { ended = true; timers.forEach(id => { clearTimeout(id); clearInterval(id); }); timers.clear(); offs.forEach(f => f()); ac.abort(); }
};
}
// parseActions finds what an assistant's answer asks the panel to do:
// closed ``` blocks headed run [machine], read|ls [machine:]path or
// write [machine:]path (its body the file's text).
function parseActions(text) {
const out = [], re = /```(run|read|ls|write|search|fetch)([^\n]*)\n([\s\S]*?)```/g;
for (let m; (m = re.exec(text));) {
const kind = m[1], head = m[2].trim(), body = m[3].replace(/\n$/, '');
if (kind === 'run') { out.push({kind, machine: head, path: '', body}); continue; }
if (kind === 'search' || kind === 'fetch') { out.push({kind, machine: '', path: '', body: (head + '\n' + body).trim()}); continue; }
const c = head.indexOf(':');
const machine = c > 0 && !head.startsWith('/') ? head.slice(0, c) : '';
out.push({kind, machine, path: machine ? head.slice(c + 1) : head, body: kind === 'write' ? body : ''});
}
return out;
}
// md turns an assistant's text (no ``` blocks - those are drawn apart)
// into HTML: headings, lists, quotes, bold, italic, `code` and http(s)
// links. Everything is escaped first, so nothing else gets through.
const escH = v => String(v).replace(/[&<>"]/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c]));
function mdInline(t) {
const codes = [];
t = t.replace(/`([^`\n]+)`/g, (_, c) => { codes.push(c); return '\u0000' + (codes.length - 1) + '\u0000'; });
t = escH(t)
.replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
.replace(/(^|[^*\w])\*([^*\n]+)\*(?!\w)/g, '$1<i>$2</i>')
.replace(/(^|\W)_([^_\n]+)_(?!\w)/g, '$1<i>$2</i>')
.replace(/\[([^\]\n]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)/g, (_, txt, url) => /^https?:\/\//i.test(url.replace(/&amp;/g, '&'))
? '<a href="' + url + '" target="_blank" rel="noopener">' + txt + '</a>' : txt);
return t.replace(/\u0000(\d+)\u0000/g, (_, i) => '<code>' + escH(codes[+i]) + '</code>');
}
function md(text) {
const out = [];
let para = [], list = null;
const flush = () => {
if (para.length) out.push('<p>' + para.map(mdInline).join('<br>') + '</p>');
para = [];
if (list) { out.push('<' + list.tag + '>' + list.items.map(x => '<li>' + mdInline(x) + '</li>').join('') + '</' + list.tag + '>'); list = null; }
};
for (const line of String(text).split('\n')) {
let m;
if (!line.trim()) { flush(); continue; }
if ((m = /^#{1,6}\s+(.*)$/.exec(line))) { flush(); out.push('<h4>' + mdInline(m[1]) + '</h4>'); continue; }
if (/^\s*(-{3,}|\*{3,})\s*$/.test(line)) { flush(); out.push('<hr>'); continue; }
if ((m = /^>\s?(.*)$/.exec(line))) { flush(); out.push('<blockquote>' + mdInline(m[1]) + '</blockquote>'); continue; }
const ul = /^\s*[-*•]\s+(.*)$/.exec(line), ol = /^\s*\d+[.)]\s+(.*)$/.exec(line);
if (ul || ol) {
const tag = ul ? 'ul' : 'ol';
if (para.length || (list && list.tag !== tag)) flush();
if (!list) list = {tag, items: []};
list.items.push((ul || ol)[1]);
continue;
}
if (list) flush();
para.push(line);
}
flush();
return out.join('');
}
const api = {shellQuote, shellQuoteAll, dropAction, fmtSize, scope, parseActions, md};
if (typeof module !== 'undefined') module.exports = api; else root.WSUtil = api;
})(this);
+87
View File
@@ -0,0 +1,87 @@
'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');
});
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');
});
test('scope: nothing new runs after it ended', async () => {
const s = U.scope();
let n = 0;
s.end();
s.timeout(() => n++, 1);
s.interval(() => n++, 1);
await new Promise(r => setTimeout(r, 20));
assert.strictEqual(n, 0);
});
test('parseActions: run, read, ls and write blocks with or without a machine', () => {
const text = 'Сделаю так:\n```run web\nuptime\n```\nи\n```read db:/etc/hosts\n```\n```ls /var/log\n```\n```write web:/tmp/a b.txt\nline 1\nline 2\n```\n```bash\nnot an action\n```\n```run\ndf -h\n```';
assert.deepStrictEqual(U.parseActions(text), [
{kind: 'run', machine: 'web', path: '', body: 'uptime'},
{kind: 'read', machine: 'db', path: '/etc/hosts', body: ''},
{kind: 'ls', machine: '', path: '/var/log', body: ''},
{kind: 'write', machine: 'web', path: '/tmp/a b.txt', body: 'line 1\nline 2'},
{kind: 'run', machine: '', path: '', body: 'df -h'}
]);
// A block still being written is not an action yet.
assert.strictEqual(U.parseActions('```run\nls').length, 0);
});
test('md: the common marks, everything else escaped', () => {
assert.strictEqual(U.md('**жирный** и *курсив*, `ls -la <x>`'),
'<p><b>жирный</b> и <i>курсив</i>, <code>ls -la &lt;x&gt;</code></p>');
assert.strictEqual(U.md('# Итог\n- раз\n- два\n\n1. первое\n2. второе'),
'<h4>Итог</h4><ul><li>раз</li><li>два</li></ul><ol><li>первое</li><li>второе</li></ol>');
assert.strictEqual(U.md('[док](https://ex.com/a?b=1&c=2) и [плохо](javascript:alert(1))'),
'<p><a href="https://ex.com/a?b=1&amp;c=2" target="_blank" rel="noopener">док</a> и плохо</p>');
assert.strictEqual(U.md('> цитата\n\nстрока 1\nстрока 2'), '<blockquote>цитата</blockquote><p>строка 1<br>строка 2</p>');
assert.strictEqual(U.md('<script>x</script>'), '<p>&lt;script&gt;x&lt;/script&gt;</p>');
// Inside code nothing is marked up.
assert.strictEqual(U.md('`**a**`'), '<p><code>**a**</code></p>');
});
test('parseActions: search blocks', () => {
assert.deepStrictEqual(U.parseActions('Ищу:\n```search\nnginx 1.27 changelog\n```'),
[{kind: 'search', machine: '', path: '', body: 'nginx 1.27 changelog'}]);
});
test('parseActions: fetch blocks', () => {
assert.deepStrictEqual(U.parseActions('```fetch\nhttps://go.dev/doc/go1.24\n```'),
[{kind: 'fetch', machine: '', path: '', body: 'https://go.dev/doc/go1.24'}]);
});
+218
View File
@@ -0,0 +1,218 @@
{{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}}>
<div class="tabs" id="tabs">
<div class="tablist" id="tablist"><div class="tabmark" id="tabmark" hidden></div></div>
<button type="button" class="tab-add" id="tabadd" title="Новый терминал" aria-label="Новый терминал">+</button>
</div>
<div class="area" id="area">
<button type="button" class="float-add" id="floatadd" title="Новый терминал" aria-label="Новый терминал">+</button>
<div class="empty" id="empty" hidden>Нет открытых терминалов<button type="button" id="emptyadd">Новый терминал</button></div>
<div class="hint" id="hint" hidden></div>
</div>
<div class="addmenu" id="addmenu" hidden></div>
<div class="selmenu" id="selmenu" hidden>
<button type="button" data-a="copy" title="Копировать" aria-label="Копировать">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg>
</button>
<button type="button" class="btn ghost" data-a="paste" title="Вставить" aria-label="Вставить">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/></svg>
</button>
<button type="button" class="btn ghost" data-a="all" title="Выделить всё" aria-label="Выделить всё">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3a2 2 0 0 0-2 2M19 3a2 2 0 0 1 2 2M21 19a2 2 0 0 1-2 2M5 21a2 2 0 0 1-2-2M9 3h1M9 21h1M14 3h1M14 21h1M3 9v1M21 9v1M3 14v1M21 14v1"/><rect x="7" y="7" width="10" height="10" rx="1"/></svg>
</button>
<button type="button" class="btn ghost close" data-a="close" title="Закрыть" aria-label="Закрыть">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="keys" id="keys">
<button type="button" data-k="esc">ESC</button>
<button type="button" data-k="/">/</button>
<button type="button" data-k="-">-</button>
<button type="button" data-k="home">HOME</button>
<button type="button" data-k="up">↑</button>
<button type="button" data-k="end">END</button>
<button type="button" data-k="pgup">PGUP</button>
<button type="button" data-k="tab">TAB</button>
<button type="button" data-k="ctrl" data-mod="ctrl">CTRL</button>
<button type="button" data-k="alt" data-mod="alt">ALT</button>
<button type="button" data-k="left">←</button>
<button type="button" data-k="down">↓</button>
<button type="button" data-k="right">→</button>
<button type="button" data-k="pgdn">PGDN</button>
</div>
</section>
<section class="view" data-view="settings"{{if ne .View "settings"}} hidden{{end}}>{{if eq .View "settings"}}{{.Body}}{{end}}</section>
</main>
<dialog id="viewer" tabindex="-1">
<div class="dlg-h">
<strong class="vpath" dir="rtl"><bdi id="vname"></bdi></strong>
<button class="btn ghost small" id="vtext" hidden>Редактировать</button>
<button class="btn ghost small" id="vhex" hidden>Редактировать бинарно</button>
<button class="btn small" id="save" hidden>Сохранить</button>
<button class="btn ghost small icon sq" id="vprops" title="Свойства" aria-label="Свойства"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4"/></svg></button>
<button class="btn ghost small icon close x" id="closev" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
<div class="vmsg muted" id="vmsg"></div>
</div>
<div class="vbody">
<div class="find" id="find" hidden>
<div class="fin"><input type="text" id="fq" placeholder="Найти" spellcheck="false" autocomplete="off"><span class="cnt" id="fcnt"></span></div>
<div class="fbtns">
<button type="button" class="tg" id="fcase" title="Учитывать регистр">Aa</button>
<button type="button" class="tg" id="fre" title="Регулярное выражение">.*</button>
<button type="button" class="btn ghost" id="fprev" title="Предыдущее (Shift+Enter)" aria-label="Предыдущее">
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M6 2.5 11 9.5H1z" fill="currentColor"/></svg>
</button>
<button type="button" class="btn ghost" id="fnext" title="Следующее (Enter)" aria-label="Следующее">
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M6 9.5 1 2.5h10z" fill="currentColor"/></svg>
</button>
<button type="button" class="btn ghost close" id="fclose" title="Закрыть поиск (Esc)" aria-label="Закрыть поиск">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="fin" id="frow2"><input type="text" id="frep" placeholder="Заменить на" spellcheck="false" autocomplete="off"></div>
<div class="fbtns" id="frow2b">
<button type="button" class="btn ghost" id="frepone">Заменить</button>
<button type="button" class="btn ghost" id="frepall">Все</button>
</div>
</div>
<div id="vcode" hidden></div>
<div class="vmedia" id="vmedia" hidden></div>
<div class="vdoc" id="vdoc" hidden></div>
<div class="hexed" id="vhexed" tabindex="0" hidden><div id="hxsp"></div><div class="hx-rows" id="hxrows"></div></div>
</div>
</dialog>
<dialog id="updlg" tabindex="-1">
<div class="dlg-h">
<strong style="flex:1">Загрузка файлов</strong>
<button class="btn ghost small icon close x" id="updlg-close" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
</div>
<div class="dlg-b" id="updlg-list"></div>
</dialog>
<dialog id="mvdlg" tabindex="-1">
<div class="dlg-h">
<strong style="flex:1">Переместить или копировать</strong>
<button class="btn ghost small icon close x" id="mvdlg-close" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
</div>
<div class="dlg-b mv">
<div class="pp-path" id="mv-src"></div>
<div class="mv-top">
<div class="seg" id="mv-op" role="radiogroup">
<button type="button" data-op="move" role="radio">Переместить</button>
<button type="button" data-op="copy" role="radio">Копировать</button>
</div>
<button type="button" class="btn ghost sq mv-net" id="mv-net" title="На другую машину или по SSH" aria-label="На другую машину или по SSH" aria-pressed="false"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="6" rx="1"/><rect x="2" y="16" width="6" height="6" rx="1"/><rect x="16" y="16" width="6" height="6" rx="1"/><path d="M12 8v4M5 16v-2a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v2"/></svg></button>
</div>
<div class="mv-remote" id="mv-remote"><div>
<label for="mv-to">На машину</label>
<select id="mv-to"></select>
<div class="mv-ssh" id="mv-ssh" hidden>
<div class="mv-row"><label class="grow">Хост<input type="text" id="mv-host" placeholder="example.com" autocomplete="off" spellcheck="false" autocapitalize="off"></label>
<label class="port">Порт<input type="number" id="mv-port" min="1" max="65535" value="22"></label></div>
<label>Пользователь<input type="text" id="mv-user" placeholder="root" autocomplete="off" spellcheck="false" autocapitalize="off"></label>
<div class="seg seg-auth" id="mv-auth">
<button type="button" data-auth="password" class="on">Пароль</button><button type="button" data-auth="key">Ключ</button><button type="button" data-auth="agent">Ключи агента</button>
</div>
<div class="mv-a" data-for="password"><input type="password" id="mv-pw" placeholder="пароль" autocomplete="off"></div>
<div class="mv-a" data-for="key" hidden>
<textarea id="mv-key" rows="4" spellcheck="false" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;…вставьте ключ или загрузите файл"></textarea>
<div class="mv-row"><button type="button" class="btn ghost small" id="mv-keyload">Загрузить файл…</button><span class="muted" id="mv-keyname"></span><input type="file" id="mv-keyfile" hidden></div>
<input type="password" id="mv-pass" placeholder="фраза ключа, если есть" autocomplete="off">
</div>
<p class="mv-a muted" data-for="agent" hidden>Агент войдёт своими ключами из ~/.ssh без фразы.</p>
</div>
</div></div>
<label for="mv-dir">Куда (папка)</label>
<div class="mv-dirbox">
<input type="text" id="mv-dir" autocomplete="off" spellcheck="false" autocapitalize="off">
<ul class="mv-sug" id="mv-sug" hidden></ul>
</div>
<label for="mv-name">Имя</label>
<input type="text" id="mv-name" autocomplete="off" spellcheck="false" autocapitalize="off">
<label for="mv-conflict">Если там уже есть такое</label>
<select id="mv-conflict">
<option value="error">ничего не трогать и сообщить</option>
<option value="replace">заменить (папки слить, файлы заменить)</option>
<option value="skip">оставить существующее (папки слить)</option>
</select>
<div class="mv-prog" id="mv-prog" hidden>
<div class="mv-bar"><i id="mv-bar"></i></div>
<div class="mv-ptext"><span class="mv-pwrap"><span class="spinner"></span><span id="mv-ptext">подготовка…</span></span><button type="button" class="btn ghost small" id="mv-stop">Отменить</button></div>
</div>
<div class="pp-act"><button type="button" id="mv-go">Переместить</button></div>
</div>
</dialog>
<dialog id="propdlg" tabindex="-1">
<div class="dlg-h">
<strong style="flex:1">Свойства</strong>
<button class="btn ghost small icon close x" id="propdlg-close" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
</div>
<div class="dlg-b props">
<div class="pp-path" id="pp-path"></div>
<div class="pp-info muted" id="pp-info"></div>
<div class="pp-own">
<label>Владелец<input type="text" id="pp-owner" list="pp-users" autocomplete="off" spellcheck="false"></label>
<label>Группа<input type="text" id="pp-group" list="pp-groups" autocomplete="off" spellcheck="false"></label>
</div>
<datalist id="pp-users"></datalist><datalist id="pp-groups"></datalist>
<table class="pp-perm">
<thead><tr><th></th><th>Чтение</th><th>Запись</th><th>Выполнение</th></tr></thead>
<tbody>
<tr><th>Владелец</th><td><input type="checkbox" data-bit="256"></td><td><input type="checkbox" data-bit="128"></td><td><input type="checkbox" data-bit="64"></td></tr>
<tr><th>Группа</th><td><input type="checkbox" data-bit="32"></td><td><input type="checkbox" data-bit="16"></td><td><input type="checkbox" data-bit="8"></td></tr>
<tr><th>Остальные</th><td><input type="checkbox" data-bit="4"></td><td><input type="checkbox" data-bit="2"></td><td><input type="checkbox" data-bit="1"></td></tr>
</tbody>
</table>
<label class="pp-num">Числовое значение <input type="text" id="pp-mode" maxlength="4" inputmode="numeric" autocomplete="off"></label>
<div class="pp-rec" id="pp-rec" hidden>
<label class="check"><input type="checkbox" id="pp-recursive"> Применить ко всем вложенным</label>
<select id="pp-only" disabled>
<option value="">к файлам и папкам</option>
<option value="files">только к файлам</option>
<option value="dirs">только к папкам</option>
</select>
</div>
<div class="pp-act"><button type="button" id="pp-apply">Применить</button></div>
</div>
</dialog>
<dialog id="machinedlg" class="machinedlg" tabindex="-1"><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="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>
<div class="frag-area"></div></dialog>
<div class="toast" id="toast"></div>
<script>window.APP = {admin: {{.IsAdmin}}, open: {{.Open}}, dialog: {{.Dialog}}, editLimit: {{.EditLimit}}, prefs: {{.Prefs}}};</script>
<script src="{{asset "wsutil.js"}}"></script>
<script src="{{asset "ui.js"}}"></script>
<script src="{{asset "prefs.js"}}"></script>
<script src="{{asset "xterm.js"}}"></script>
<script src="{{asset "addon-fit.js"}}"></script>
<script src="{{asset "app.js"}}"></script>
<script src="{{asset "workspace.js"}}"></script>
<script src="{{asset "termpane.js"}}"></script>
<script src="{{asset "codemirror/codemirror.js"}}"></script>
<script src="{{asset "docview.js"}}"></script>
<script src="{{asset "dbedit.js"}}"></script>
<script src="{{asset "fileviews.js"}}"></script>
<script src="{{asset "filepane.js"}}"></script>
<script src="{{asset "dnd.js"}}"></script>
<script src="{{asset "pages.js"}}"></script>
<script src="{{asset "users.js"}}"></script>
<script src="{{asset "account.js"}}"></script>
<script src="{{asset "machine.js"}}"></script>
<script src="{{asset "ai.js"}}"></script>
<script>App.start();</script>
</body>
</html>{{end}}
+9 -62
View File
@@ -1,27 +1,9 @@
<!doctype html> {{define "dashboard-body"}}<div class="page" data-module="dashboard">
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Машины - LPRA</title>
<link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<script src="{{asset "scroll.js"}}" defer></script>
</head>
<body>
<header class="topbar">
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Удалённый доступ</div>
<nav>
<a href="/">Машины</a>
{{if .IsAdmin}}<a href="/settings">Настройки</a>{{end}}
<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>
<div class="wrap"> <div class="wrap">
<h1>Машины-клиенты</h1> <h1>Машины-клиенты</h1>
<div class="card"> <div class="card">
{{if .Agents}} {{if .Agents}}
<table class="stack"> <table class="stack machines">
<thead><tr><th>Статус</th><th>Имя</th><th>Hostname</th><th>ОС / Арх</th><th>Активность</th><th>Действия</th></tr></thead> <thead><tr><th>Статус</th><th>Имя</th><th>Hostname</th><th>ОС / Арх</th><th>Активность</th><th>Действия</th></tr></thead>
<tbody> <tbody>
{{range .Agents}} {{range .Agents}}
@@ -33,23 +15,23 @@
<td class="muted nowrap seen" data-l="Активность">{{.Seen}}</td> <td class="muted nowrap seen" data-l="Активность">{{.Seen}}</td>
<td class="actions"> <td class="actions">
<div class="row" style="gap:6px"> <div class="row" style="gap:6px">
<a class="btn small icon" href="/agents/{{.ID}}/terminal" data-tip="Терминал" aria-label="Терминал"> <a class="btn small icon" href="/agents/{{.ID}}/terminal" data-open="term" data-id="{{.ID}}" data-name="{{.Name}}" data-host="{{.Hostname}}" data-user="{{.TerminalUser}}" data-tip="Терминал" aria-label="Терминал">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
</a> </a>
<a class="btn ghost small icon" href="/agents/{{.ID}}/files" data-tip="Файлы" aria-label="Файлы"> <a class="btn ghost small icon" href="/agents/{{.ID}}/files" data-open="files" data-id="{{.ID}}" data-name="{{.Name}}" data-host="{{.Hostname}}" data-user="{{.TerminalUser}}" data-tip="Файлы" aria-label="Файлы">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
</a> </a>
<a class="btn ghost small icon" href="/agents/{{.ID}}/forwards" data-tip="Порты" aria-label="Порты"> <a class="btn ghost small icon" href="/agents/{{.ID}}/forwards" data-dialog="forwards" data-id="{{.ID}}" data-name="{{.Name}}" data-tip="Порты" aria-label="Порты">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
</a> </a>
<a class="btn ghost small icon" href="/agents/{{.ID}}/status" data-tip="Статус" aria-label="Статус"> <a class="btn ghost small icon" href="/agents/{{.ID}}/status" data-dialog="status" data-id="{{.ID}}" data-name="{{.Name}}" data-tip="Статус" aria-label="Статус">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
</a> </a>
<a class="btn ghost small icon" href="/agents/{{.ID}}/edit" data-tip="Изменить" aria-label="Изменить"> <a class="btn ghost small icon" href="/agents/{{.ID}}/edit" data-dialog="edit" data-id="{{.ID}}" data-name="{{.Name}}" data-tip="Изменить" aria-label="Изменить">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
</a> </a>
{{if $.IsAdmin}}<form method="post" action="/agents/{{.ID}}/delete" style="display:inline" {{if $.IsAdmin}}<form method="post" action="/agents/{{.ID}}/delete" style="display:inline"
onsubmit="return confirm('Удалить машину {{.Name}}? Сервис на ней продолжит работать, но перестанет быть виден. Чтобы снова подключиться - переустановите агента.')"> data-confirm="Удалить машину {{.Name}}? Сервис на ней продолжит работать, но перестанет быть виден. Чтобы снова подключиться - переустановите агента." data-danger>
<input type="hidden" name="csrf" value="{{$.CSRF}}"> <input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="btn danger small icon" type="submit" data-tip="Удалить" aria-label="Удалить"> <button class="btn danger small icon" type="submit" data-tip="Удалить" aria-label="Удалить">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
@@ -68,39 +50,4 @@
{{end}} {{end}}
</div> </div>
</div> </div>
<script> </div>{{end}}
// Keep the status column live; nothing is asked while the tab is hidden.
(function () {
var rows = {};
document.querySelectorAll('tr[data-agent]').forEach(function (tr) { rows[tr.dataset.agent] = tr; });
var timer = null, busy = false;
function schedule() {
clearTimeout(timer);
if (!document.hidden) timer = setTimeout(load, 5000);
}
function load() {
if (busy || document.hidden) return;
busy = true;
fetch('/api/panel/agents/presence', {credentials: 'same-origin'})
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (list) {
list.forEach(function (a) {
var tr = rows[a.id];
if (!tr) return;
tr.querySelector('.dot').className = 'dot ' + (a.online ? 'on' : 'off');
tr.querySelector('.state').textContent = a.online ? 'online' : 'offline';
tr.querySelector('.seen').textContent = a.seen;
});
})
.catch(function () {})
.then(function () { busy = false; schedule(); });
}
document.addEventListener('visibilitychange', function () {
if (document.hidden) clearTimeout(timer); else load();
});
if (Object.keys(rows).length) schedule();
})();
</script>
<script src="{{asset "account.js"}}"></script>
</body>
</html>
+4 -25
View File
@@ -1,24 +1,4 @@
<!doctype html> {{define "edit-body"}}<div class="page" data-module="edit">
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Изменить машину - LPRA</title>
<link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<script src="{{asset "scroll.js"}}" defer></script>
</head>
<body>
<header class="topbar">
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Изменить машину</div>
<nav>
<a href="/">← Машины</a>
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
</nav>
</header>
<div class="wrap"> <div class="wrap">
<h1>{{.Agent.Name}}</h1> <h1>{{.Agent.Name}}</h1>
@@ -104,7 +84,7 @@
</p> </p>
{{if .Outdated}} {{if .Outdated}}
<form method="post" action="/agents/{{.Agent.ID}}/update" <form method="post" action="/agents/{{.Agent.ID}}/update"
onsubmit="return confirm('Обновить клиента до версии {{.ServerVersion}}? Бинарник будет заменён, сервис перезапущен (без удаления).')"> data-confirm="Обновить клиента до версии {{.ServerVersion}}? Бинарник будет заменён, сервис перезапущен (без удаления)." data-danger>
<input type="hidden" name="csrf" value="{{.CSRF}}"> <input type="hidden" name="csrf" value="{{.CSRF}}">
<button type="submit">Обновить клиента</button> <button type="submit">Обновить клиента</button>
</form> </form>
@@ -118,11 +98,10 @@
<p class="muted">Удаляет машину из панели. Сервис на самой машине продолжит работать, <p class="muted">Удаляет машину из панели. Сервис на самой машине продолжит работать,
но перестанет быть виден; чтобы подключиться снова - переустановите агента.</p> но перестанет быть виден; чтобы подключиться снова - переустановите агента.</p>
<form method="post" action="/agents/{{.Agent.ID}}/delete" <form method="post" action="/agents/{{.Agent.ID}}/delete"
onsubmit="return confirm('Удалить машину {{.Agent.Name}}?')"> data-confirm="Удалить машину {{.Agent.Name}}?" data-danger>
<input type="hidden" name="csrf" value="{{.CSRF}}"> <input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="btn danger" type="submit">Удалить машину</button> <button class="btn danger" type="submit">Удалить машину</button>
</form> </form>
</div>{{end}} </div>{{end}}
</div> </div>
</body> </div>{{end}}
</html>
File diff suppressed because it is too large. Load diff
+5 -36
View File
@@ -1,12 +1,4 @@
<!doctype html> {{define "forwards-body"}}<div class="page" data-module="forwards" data-panel-port="{{.PanelPort}}" data-tunnel-port="{{.TunnelPort}}">
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Порты · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<script src="{{asset "scroll.js"}}" defer></script>
<style> <style>
/* One line: fixed narrow fields, the label takes what is left. */ /* One line: fixed narrow fields, the label takes what is left. */
.frow { display:flex; align-items:flex-end; gap:12px; flex-wrap:wrap; } .frow { display:flex; align-items:flex-end; gap:12px; flex-wrap:wrap; }
@@ -36,18 +28,6 @@
.under { margin-right:0; } .under { margin-right:0; }
} }
</style> </style>
</head>
<body>
<header class="topbar">
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Порты - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
<nav>
<a href="/">← Машины</a>
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
</nav>
</header>
<div class="wrap"> <div class="wrap">
<h1>Проброс портов</h1> <h1>Проброс портов</h1>
<p class="muted">Порт сервиса на машине-клиенте становится доступен на порту хоста. <p class="muted">Порт сервиса на машине-клиенте становится доступен на порту хоста.
@@ -58,7 +38,7 @@
<h2>Правила</h2> <h2>Правила</h2>
{{if .Rules}} {{if .Rules}}
<table class="stack"> <table class="stack">
<thead><tr><th>Тип</th><th>Хост (порт)</th><th>→ Клиент (порт)</th><th>Слушать на</th><th>Метка</th><th></th></tr></thead> <thead><tr><th>Тип</th><th>Хост (порт)</th><th>→ Клиент (порт)</th><th>Слушать на</th><th>Метка</th><th style="text-align:right">Действие</th></tr></thead>
<tbody> <tbody>
{{range .Rules}} {{range .Rules}}
<tr> <tr>
@@ -69,7 +49,7 @@
<td class="lead">{{.Label}}</td> <td class="lead">{{.Label}}</td>
<td class="actions" style="text-align:right"> <td class="actions" style="text-align:right">
<form method="post" action="/forwards/{{.ID}}/delete" style="display:inline" <form method="post" action="/forwards/{{.ID}}/delete" style="display:inline"
onsubmit="return confirm('Удалить правило?')"> data-confirm="Удалить правило?" data-danger>
<input type="hidden" name="csrf" value="{{$.CSRF}}"> <input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="btn danger small" type="submit">Удалить</button> <button class="btn danger small" type="submit">Удалить</button>
</form> </form>
@@ -128,22 +108,11 @@
<div class="card"> <div class="card">
<h2>Режимы</h2> <h2>Режимы</h2>
<ul class="muted" style="font-size:14px; line-height:1.7"> <ul class="muted" style="font-size:14px; line-height:1.7">
<li><b>http-lp</b> - туннель поверх HTTP long-poll (через адрес панели, порт {{.PanelPort}}). Работает везде, где доступна панель, но задержка выше.</li> <li><b>http-lp</b> - через соединение агента с панелью (порт {{.PanelPort}}): по WebSocket, а если он недоступен - по HTTP long-poll. Работает везде, где доступна панель, но задержка выше, чем у tcp.</li>
<li><b>tcp</b> - прямой TCP-мультиплексор через порт <b>{{.TunnelPort}}</b> хоста. Минимальная задержка, keep-alive, TCP_NODELAY. Нужно открыть {{.TunnelPort}}/tcp на хосте.</li> <li><b>tcp</b> - прямой TCP-мультиплексор через порт <b>{{.TunnelPort}}</b> хоста. Минимальная задержка, keep-alive, TCP_NODELAY. Нужно открыть {{.TunnelPort}}/tcp на хосте.</li>
<li><b>udp</b> - прямой UDP через порт <b>{{.TunnelPort}}</b> хоста. Нужно открыть {{.TunnelPort}}/udp.</li> <li><b>udp</b> - прямой UDP через порт <b>{{.TunnelPort}}</b> хоста. Нужно открыть {{.TunnelPort}}/udp.</li>
</ul> </ul>
<p class="muted" style="font-size:13px">Для tcp/udp агент держит исходящее соединение к <code>host:{{.TunnelPort}}</code>; несколько клиентов и портов мультиплексируются на нём.</p> <p class="muted" style="font-size:13px">Для tcp/udp агент держит исходящее соединение к <code>host:{{.TunnelPort}}</code>; несколько клиентов и портов мультиплексируются на нём.</p>
</div> </div>
</div> </div>
<script> </div>{{end}}
var notes = {
http: 'Трафик идёт через порт панели {{.PanelPort}} (long-poll). Порт {{.TunnelPort}} не требуется.',
tcp: 'Требуется открытый порт {{.TunnelPort}}/tcp на хосте. Самая низкая задержка.',
udp: 'Требуется открытый порт {{.TunnelPort}}/udp на хосте.'
};
var sel = document.getElementById('proto'), note = document.getElementById('note');
function upd(){ note.textContent = notes[sel.value] || ''; }
sel.onchange = upd; upd();
</script>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
{{define "head"}}<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="{{asset "codemirror/codemirror.css"}}">
<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="/workspace" data-nav="terminal">WorkSpace</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}}
+98 -57
View File
@@ -1,12 +1,4 @@
<!doctype html> {{define "settings-body"}}<div class="page" data-module="settings">
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Настройки - LPRA</title>
<link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<script src="{{asset "scroll.js"}}" defer></script>
<style> <style>
/* An install command, with a button to copy it at its right. */ /* An install command, with a button to copy it at its right. */
/* Users: role, machines, password sign-in. */ /* Users: role, machines, password sign-in. */
@@ -49,15 +41,6 @@
.codeform input[type=number] { width:72px; height:30px; padding:4px 8px; } .codeform input[type=number] { width:72px; height:30px; padding:4px 8px; }
.codeform .ico { display:inline-flex; align-items:center; gap:6px; height:30px; } .codeform .ico { display:inline-flex; align-items:center; gap:6px; height:30px; }
</style> </style>
</head>
<body>
<header class="topbar">
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Удалённый доступ</div>
<nav>
<a href="/">Машины</a>
<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>
<div class="wrap"> <div class="wrap">
<h1>Настройки</h1> <h1>Настройки</h1>
{{if .Error}}<div class="err">{{.Error}}</div>{{end}} {{if .Error}}<div class="err">{{.Error}}</div>{{end}}
@@ -71,7 +54,7 @@
<div class="copybox"><pre id="short">{{.InstallShort}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="short" title="Скопировать" aria-label="Скопировать"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div> <div class="copybox"><pre id="short">{{.InstallShort}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="short" title="Скопировать" aria-label="Скопировать"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div>
<form method="post" action="/settings/install/code" class="codeform" <form method="post" action="/settings/install/code" class="codeform"
onsubmit="return confirm('Сделать новый код? Старая короткая ссылка перестанет работать.')"> data-confirm="Сделать новый код? Старая короткая ссылка перестанет работать." data-danger>
<input type="hidden" name="csrf" value="{{.CSRF}}"> <input type="hidden" name="csrf" value="{{.CSRF}}">
<label for="codelen">Символов в коде</label> <label for="codelen">Символов в коде</label>
<input type="number" id="codelen" name="len" min="3" max="24" value="{{.CodeLen}}"> <input type="number" id="codelen" name="len" min="3" max="24" value="{{.CodeLen}}">
@@ -105,10 +88,7 @@
<p class="muted">URL, на который звонят агенты. Подставляется в скрипт установки.</p> <p class="muted">URL, на который звонят агенты. Подставляется в скрипт установки.</p>
<label for="direct">Прямой (без Cloudflare) - необязательно</label> <label for="direct">Прямой (без Cloudflare) - необязательно</label>
<input type="text" id="direct" name="direct" value="{{.Direct}}" placeholder="https://direct.lpra.example.com"> <input type="text" id="direct" name="direct" value="{{.Direct}}" placeholder="https://direct.lpra.example.com">
<p class="muted">Имя, которое ведёт прямо на сервер, мимо прокси. Агенты переходят на него, если основной <p class="muted">Адрес сервера в обход прокси: агенты и установщик переходят на него, если основной не отвечает.</p>
недоступен 30 секунд, и возвращаются, когда он ответит; уже установленные получат его сами. Установщик
качает агента с него, если основной не отвечает. Если хост туннеля ниже не задан, туннель тоже идёт на этот хост.
Ключи доступа с основного домена работают на прямом, если он его поддомен (direct.lpra.example.com).</p>
<div><button type="submit">Сохранить</button></div> <div><button type="submit">Сохранить</button></div>
</form> </form>
</div> </div>
@@ -133,13 +113,103 @@
{{end}} {{end}}
</div> </div>
<div class="card" id="db">
<h2>База данных</h2>
<p class="muted">Сейчас: <b>{{if eq .DBNow "postgres"}}PostgreSQL{{else}}SQLite{{end}}</b>.
{{if ne .DBNow .DBChosen.Type}}<span class="err" style="display:inline">Выбрана {{if eq .DBChosen.Type "postgres"}}PostgreSQL{{else}}SQLite{{end}} - перезапустите сервер, чтобы перейти.</span>{{end}}
При первом запуске на пустой PostgreSQL всё копируется из SQLite-файла (он остаётся как есть).</p>
<form method="post" action="/settings/db" id="dbform" class="ai-form db-form{{if eq .DBChosen.Type "postgres"}} pg{{end}}">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Хранить в<select name="db_type" onchange="this.form.classList.toggle('pg', this.value === 'postgres')">
<option value="sqlite"{{if ne .DBChosen.Type "postgres"}} selected{{end}}>SQLite (файл рядом с сервером)</option>
<option value="postgres"{{if eq .DBChosen.Type "postgres"}} selected{{end}}>PostgreSQL</option>
</select></label>
<div class="pg-fields">
<div class="frow2"><label>Хост<input type="text" name="pg_host" value="{{with .DBChosen.PG}}{{.Host}}{{end}}" placeholder="localhost"></label>
<label class="port">Порт<input type="number" name="pg_port" min="1" max="65535" value="{{with .DBChosen.PG}}{{if .Port}}{{.Port}}{{end}}{{end}}" placeholder="5432"></label></div>
<label>База<input type="text" name="pg_name" value="{{with .DBChosen.PG}}{{.Name}}{{end}}" placeholder="lpra"></label>
<label>Пользователь<input type="text" name="pg_user" value="{{with .DBChosen.PG}}{{.User}}{{end}}" autocomplete="off"></label>
<label>Пароль<input type="password" name="pg_password" placeholder="{{with .DBChosen.PG}}{{if .Password}}задан - пусто, чтобы оставить{{end}}{{end}}" autocomplete="new-password"></label>
<label>SSL<select name="pg_sslmode">
{{$m := "prefer"}}{{with .DBChosen.PG}}{{if .SSLMode}}{{$m = .SSLMode}}{{end}}{{end}}
<option value="disable"{{if eq $m "disable"}} selected{{end}}>disable</option>
<option value="prefer"{{if eq $m "prefer"}} selected{{end}}>prefer</option>
<option value="require"{{if eq $m "require"}} selected{{end}}>require</option>
<option value="verify-full"{{if eq $m "verify-full"}} selected{{end}}>verify-full</option>
</select></label>
</div>
</form>
<div class="row db-actions">
<button type="submit" form="dbform">Проверить и сохранить</button>
<form method="post" action="/settings/db/restart" data-confirm="Перезапустить сервер? Терминалы и передачи прервутся, страница переподключится сама." data-danger>
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button type="submit" class="btn ghost">Перезапустить сервер</button>
</form>
</div>
</div>
<div class="card" id="ai">
<h2>ИИ-ассистент</h2>
<form method="post" action="/settings/ai" class="ai-form" data-search="{{.Search.Engine}}">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<p class="muted" style="margin:0">Моделей может быть несколько - в чате появится выбор; первая - по умолчанию.</p>
<div class="ai-models">
{{range .AIModels}} <fieldset class="ai-model">
<input type="hidden" name="m_id" value="{{.ID}}">
<div class="ai-model-head"><label class="ai-model-name">Название в чате<input type="text" name="m_name" value="{{.Name}}" placeholder="например, Claude"></label>
<label class="ai-model-short">Коротко<input type="text" name="m_short" value="{{.Short}}" maxlength="12" placeholder="до 12" title="Название на кнопке модели в чате, до 12 символов"></label>
<button type="button" class="btn ghost small close ai-model-del" title="Удалить модель" aria-label="Удалить модель">Удалить</button></div>
<label>Тип API<select name="m_type">
<option value="gradio"{{if eq .Type "gradio"}} selected{{end}}>Gradio (Qwen Omni через прокси)</option>
<option value="openai"{{if eq .Type "openai"}} selected{{end}}>OpenAI-совместимый</option>
<option value="anthropic"{{if eq .Type "anthropic"}} selected{{end}}>Anthropic</option>
</select></label>
<label>Адрес<input type="url" name="m_url" value="{{.URL}}" placeholder="https://api.openai.com/v1"></label>
<label>Модель<input type="text" name="m_model" value="{{.Model}}" placeholder="для OpenAI и Anthropic"></label>
<label>Ключ<span class="keybox"><input type="password" name="m_key" value="{{.Key}}" placeholder="не задан" autocomplete="off" spellcheck="false">
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
</fieldset>
{{end}} </div>
<template class="ai-model-new">
<fieldset class="ai-model">
<input type="hidden" name="m_id" value="">
<div class="ai-model-head"><label class="ai-model-name">Название в чате<input type="text" name="m_name" value="" placeholder="например, Claude"></label>
<label class="ai-model-short">Коротко<input type="text" name="m_short" value="" maxlength="12" placeholder="до 12" title="Название на кнопке модели в чате, до 12 символов"></label>
<button type="button" class="btn ghost small close ai-model-del" title="Удалить модель" aria-label="Удалить модель">Удалить</button></div>
<label>Тип API<select name="m_type">
<option value="gradio">Gradio (Qwen Omni через прокси)</option>
<option value="openai">OpenAI-совместимый</option>
<option value="anthropic">Anthropic</option>
</select></label>
<label>Адрес<input type="url" name="m_url" value="" placeholder="https://api.openai.com/v1"></label>
<label>Модель<input type="text" name="m_model" value="" placeholder="для OpenAI и Anthropic"></label>
<label>Ключ<span class="keybox"><input type="password" name="m_key" value="" placeholder="не задан" autocomplete="off" spellcheck="false">
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
</fieldset>
</template>
<div><button type="button" class="btn ghost small ai-model-add">+ Добавить модель</button></div>
<h3 class="ai-sub">Поиск в интернете</h3>
<p class="muted" style="margin:0">Запасной - DuckDuckGo.</p>
<label>Поисковик<select name="ai_search" onchange="this.form.dataset.search = this.value">
<option value=""{{if eq .Search.Engine ""}} selected{{end}}>DuckDuckGo - бесплатно, без настройки</option>
<option value="searxng"{{if eq .Search.Engine "searxng"}} selected{{end}}>SearXNG - свой сервер</option>
<option value="brave"{{if eq .Search.Engine "brave"}} selected{{end}}>Brave Search API - нужен ключ</option>
<option value="tavily"{{if eq .Search.Engine "tavily"}} selected{{end}}>Tavily - API для ИИ, нужен ключ</option>
</select></label>
<label class="s-url">Адрес SearXNG<input type="url" name="ai_search_url" value="{{.Search.URL}}" placeholder="http://127.0.0.1:8888 (в settings.yml: search.formats - json)"></label>
<label class="s-key">Ключ поисковика<span class="keybox"><input type="password" name="ai_search_key" value="{{.Search.Key}}" placeholder="не задан" autocomplete="off" spellcheck="false">
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
<div><button type="submit">Сохранить</button></div>
</form>
</div>
<div class="card"> <div class="card">
<h2>Enroll-секрет</h2> <h2>Enroll-секрет</h2>
<p class="muted">Общий секрет для регистрации новых агентов.</p> <p class="muted">Общий секрет для регистрации новых агентов.</p>
<div class="row"> <div class="row">
<code>{{.Secret}}</code> <code>{{.Secret}}</code>
<form method="post" action="/settings/secret/rotate" style="display:inline" <form method="post" action="/settings/secret/rotate" style="display:inline"
onsubmit="return confirm('Сменить секрет? Старые one-liner перестанут работать для новых установок.')"> data-confirm="Сменить секрет? Старые one-liner перестанут работать для новых установок." data-danger>
<input type="hidden" name="csrf" value="{{.CSRF}}"> <input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="btn ghost small" type="submit">Сменить</button> <button class="btn ghost small" type="submit">Сменить</button>
</form> </form>
@@ -171,14 +241,14 @@
<td style="text-align:right" class="nowrap"> <td style="text-align:right" class="nowrap">
{{if index $.KeyCounts .ID}} {{if index $.KeyCounts .ID}}
<form method="post" action="/settings/admins/keys/reset" style="display:inline" <form method="post" action="/settings/admins/keys/reset" style="display:inline"
onsubmit="return confirm('Сбросить все ключи доступа {{.Username}}? Вход по паролю снова включится.')"> data-confirm="Сбросить все ключи доступа {{.Username}}? Вход по паролю снова включится." data-danger>
<input type="hidden" name="csrf" value="{{$.CSRF}}"> <input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="id" value="{{.ID}}"> <input type="hidden" name="id" value="{{.ID}}">
<button class="btn ghost small" type="submit" title="Удалить все ключи доступа и снова включить вход по паролю">Сбросить ключи</button> <button class="btn ghost small" type="submit" title="Удалить все ключи доступа и снова включить вход по паролю">Сбросить ключи</button>
</form> </form>
{{end}} {{end}}
<form method="post" action="/settings/admins/delete" style="display:inline" <form method="post" action="/settings/admins/delete" style="display:inline"
onsubmit="return confirm('Удалить {{.Username}}?')"> data-confirm="Удалить {{.Username}}?" data-danger>
<input type="hidden" name="csrf" value="{{$.CSRF}}"> <input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="id" value="{{.ID}}"> <input type="hidden" name="id" value="{{.ID}}">
<button class="btn danger small" type="submit">Удалить</button> <button class="btn danger small" type="submit">Удалить</button>
@@ -248,7 +318,7 @@
<td class="muted">{{if .Manual}}вручную{{else if .RuleID}}проброс{{else}}туннель{{end}}</td> <td class="muted">{{if .Manual}}вручную{{else if .RuleID}}проброс{{else}}туннель{{end}}</td>
<td style="text-align:right"> <td style="text-align:right">
<form method="post" action="/settings/ufw/revoke" style="display:inline" <form method="post" action="/settings/ufw/revoke" style="display:inline"
onsubmit="return confirm('Отменить правило ufw {{.Port}}/{{.Proto}}?')"> data-confirm="Отменить правило ufw {{.Port}}/{{.Proto}}?" data-danger>
<input type="hidden" name="csrf" value="{{$.CSRF}}"> <input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="id" value="{{.ID}}"> <input type="hidden" name="id" value="{{.ID}}">
<button class="btn danger small" type="submit">Отменить</button> <button class="btn danger small" type="submit">Отменить</button>
@@ -280,33 +350,4 @@
<p class="muted" style="font-size:12px; text-align:center">LPRA сервер · версия {{.ServerVersion}}</p> <p class="muted" style="font-size:12px; text-align:center">LPRA сервер · версия {{.ServerVersion}}</p>
</div> </div>
<script> </div>{{end}}
// Copy an install command: the clipboard API on https, the old way otherwise.
(function () {
var 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);
var ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
document.body.appendChild(ta);
ta.select();
var ok = document.execCommand('copy');
ta.remove();
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
}
document.querySelectorAll('.copybox .copy').forEach(function (b) {
var icon = b.innerHTML;
b.onclick = function () {
copy(document.getElementById(b.dataset.copy).textContent.trim()).then(function () {
b.innerHTML = CHECK; b.classList.add('done'); b.title = 'Скопировано';
setTimeout(function () { b.innerHTML = icon; b.classList.remove('done'); b.title = 'Скопировать'; }, 1500);
}, function (e) { alert('Не скопировалось: ' + e.message); });
};
});
})();
</script>
<script src="{{asset "users.js"}}"></script>
<script src="{{asset "account.js"}}"></script>
</body>
</html>
+2 -256
View File
@@ -1,12 +1,4 @@
<!doctype html> {{define "status-body"}}<div class="page" data-module="status" data-agent="{{.Agent.ID}}" data-relay-up-ms="{{.RelayUpMs}}">
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Статус · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<script src="{{asset "scroll.js"}}" defer></script>
<style> <style>
.meters { display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:20px; margin-bottom:20px; } .meters { display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:20px; margin-bottom:20px; }
.meters .card { margin:0; } .meters .card { margin:0; }
@@ -41,18 +33,6 @@
.live.up .spark polygon { fill:var(--ok); } .live.up .spark polygon { fill:var(--ok); }
label.check { display:inline-flex; align-items:center; gap:8px; margin:0; color:var(--text); font-size:14px; cursor:pointer; } label.check { display:inline-flex; align-items:center; gap:8px; margin:0; color:var(--text); font-size:14px; cursor:pointer; }
</style> </style>
</head>
<body>
<header class="topbar">
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Статус - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
<nav>
<a href="/">← Машины</a>
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
</nav>
</header>
<div class="wrap"> <div class="wrap">
<h1>Статус</h1> <h1>Статус</h1>
<div class="err" id="err" hidden></div> <div class="err" id="err" hidden></div>
@@ -111,238 +91,4 @@
</div> </div>
</div> </div>
</div> </div>
<script> </div>{{end}}
(function () {
var base = '/api/panel/agents/' + encodeURIComponent({{.Agent.ID}});
var $ = function (id) { return document.getElementById(id); };
var errBox = $('err');
function size(n) {
var u = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ'], i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return n.toFixed(n < 10 && i > 0 ? 1 : 0) + ' ' + u[i];
}
function rateParts(bps) {
var u = ['бит/с', 'Кбит/с', 'Мбит/с', 'Гбит/с'], i = 0;
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++; }
return [bps.toFixed(bps < 100 && i > 0 ? 1 : 0), u[i]];
}
function rate(bps) { return rateParts(bps).join(' '); }
function meter(name, pct, sub) {
pct = Math.max(0, Math.min(100, pct));
$(name + '-val').textContent = Math.round(pct) + '%';
var bar = $(name + '-bar');
bar.style.transform = 'scaleX(' + pct / 100 + ')';
bar.className = pct >= 90 ? 'crit' : pct >= 75 ? 'warn' : '';
$(name + '-sub').textContent = sub;
}
function showErr(msg) {
errBox.textContent = msg || '';
errBox.hidden = !msg;
}
function cores(n) {
var d = n % 10, h = n % 100;
return n + (d === 1 && h !== 11 ? ' ядро' : d >= 2 && d <= 4 && (h < 12 || h > 14) ? ' ядра' : ' ядер');
}
// One request at a time, and none while the tab is hidden: the client is
// sampled only for a page somebody is looking at.
var timer = null, busy = false;
function schedule(ms) {
clearTimeout(timer);
if (!document.hidden) timer = setTimeout(load, ms);
}
function load() {
if (busy || document.hidden) return;
busy = true;
var next = 3000;
fetch(base + '/stat', {credentials: 'same-origin'})
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function (res) {
if (!res.ok) { showErr(res.error || 'ошибка'); next = 10000; return; }
showErr('');
var d = res.data;
meter('cpu', d.cpu, cores(d.ncpu));
meter('mem', d.mem_total ? 100 * d.mem_used / d.mem_total : 0,
'занято ' + size(d.mem_used) + ' из ' + size(d.mem_total) + ' · свободно ' + size(d.mem_total - d.mem_used));
meter('disk', d.disk_total ? 100 * (d.disk_total - d.disk_free) / d.disk_total : 0,
'свободно ' + size(d.disk_free) + ' из ' + size(d.disk_total));
})
.catch(function (e) { showErr('Нет связи с панелью: ' + e.message); next = 10000; })
.then(function () { busy = false; schedule(next); });
}
document.addEventListener('visibilitychange', function () {
if (document.hidden) clearTimeout(timer); else load();
});
load();
var btn = $('speed-btn'), note = $('speed-note');
function json(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }
function post(path) { return fetch(base + path, {method: 'POST', credentials: 'same-origin'}).then(json); }
// The browser's round trip to the panel: the first request may pay for a
// new connection, so keep the best.
function panelPing() {
var best = Infinity, left = 4;
function one() {
var t = performance.now();
return fetch('/api/panel/ping', {credentials: 'same-origin', cache: 'no-store'})
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); })
.then(function () {
best = Math.min(best, performance.now() - t);
return --left > 0 ? one() : best;
});
}
return one();
}
// Keeps the client's download fed until stopped or the panel says the
// client has read enough; the pieces grow while they go through quickly.
function feed(stop) {
var block = new Uint8Array(65536), n = 4;
crypto.getRandomValues(block);
function one() {
var parts = [], t = performance.now();
for (var i = 0; i < n; i++) parts.push(block);
return fetch(base + '/speed/down', {method: 'POST', body: new Blob(parts), credentials: 'same-origin', signal: stop.signal})
.then(function (r) {
if (r.status !== 204) return;
if (performance.now() - t < 500 && n < 256) n *= 2;
return one();
});
}
one().catch(function () {});
}
// Reads what the client sends and times it from the first byte. It stops by
// itself a little after the client's window, so bytes still queued on a slow
// link don't hold the result back. st.n is the count so far, for the live
// reading.
function drain(st, onFirst) {
var ac = new AbortController(), last = 0;
st.stop = function () { ac.abort(); };
function done() { return st.start && last > st.start ? st.n * 8000 / (last - st.start) : 0; }
return fetch(base + '/speed/up', {credentials: 'same-origin', cache: 'no-store', signal: ac.signal}).then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
var rd = r.body.getReader();
function pump() {
return rd.read().then(function (c) {
if (c.done) return done();
last = performance.now();
if (!st.start) { st.start = last; setTimeout(st.stop, {{.RelayUpMs}}); onFirst(); }
else st.n += c.value.length;
return pump();
});
}
return pump();
}).catch(function (e) { if (st.start) return done(); throw e; });
}
// The test through the browser: the client's download is what this page
// uploads, and its upload is timed here, where it arrives.
function relayTest(st) {
return panelPing().then(function (ping) {
return post('/speed/relay').then(function (res) {
if (!res.ok) return res;
var stop = new AbortController();
var up = drain(st, function () { stop.abort(); });
up.catch(function () {});
feed(stop);
return post('/speed?relay=1').then(function (res) {
stop.abort();
// No upload under way by now: none is coming.
if (!res.ok || !st.start) { st.stop(); }
if (!res.ok) return res;
return up.catch(function () { return 0; }).then(function (bps) {
res.data.ping_ms += ping;
if (bps) res.data.up_bps = bps;
return res;
});
}, function (e) { stop.abort(); st.stop(); throw e; });
});
});
}
// The live reading: the rate over the last second, from the byte counts the
// panel keeps for the running test - or, for the client's upload in a test
// through the browser, from what has arrived here.
var ARC = 251.4, lv = null;
function liveStart(relay, st) {
lv = {relay: relay, st: st, phase: '', pts: [], hist: [], busy: false};
$('live').hidden = false;
liveShow('ping', 0);
lv.timer = setInterval(liveTick, 200);
}
function liveStop() {
if (!lv) return;
clearInterval(lv.timer);
lv = null;
$('live').hidden = true;
}
function liveTick() {
var cur = lv;
if (cur.relay && cur.st.start) { livePoint(cur, 'up', cur.st.n, performance.now()); return; }
if (cur.busy) return;
cur.busy = true;
fetch(base + '/speed/progress', {credentials: 'same-origin', cache: 'no-store'})
.then(json)
.then(function (d) {
if (cur !== lv || !d.phase || (cur.relay && cur.st.start)) return;
livePoint(cur, d.phase, d.phase === 'up' ? d.up : d.down, d.ms);
})
.catch(function () {})
.then(function () { cur.busy = false; });
}
function livePoint(cur, phase, bytes, ms) {
if (phase !== cur.phase) { cur.phase = phase; cur.pts = []; cur.hist = []; }
if (phase === 'ping') { liveShow(phase, 0); return; }
var pts = cur.pts;
pts.push([ms, bytes]);
while (pts.length > 2 && ms - pts[0][0] > 1000) pts.shift();
if (pts.length < 2 || ms <= pts[0][0]) return;
var bps = (bytes - pts[0][1]) * 8000 / (ms - pts[0][0]);
cur.hist.push(bps);
liveShow(phase, bps, cur);
}
function liveShow(phase, bps, cur) {
var relay = lv && lv.relay, p = rateParts(bps);
$('live').className = 'live' + (phase === 'up' ? ' up' : '');
$('lv-ico').textContent = phase === 'up' ? '↑' : phase === 'down' ? '↓' : '';
$('lv-phase').textContent = phase === 'ping' ? 'Задержка…'
: phase === 'up' ? (relay ? 'клиент → браузер' : 'клиент → хост')
: (relay ? 'браузер → клиент' : 'хост → клиент');
$('lv-num').textContent = phase === 'ping' ? '…' : p[0];
$('lv-unit').textContent = phase === 'ping' ? '' : p[1];
// A log scale up to 1 Гбит/с, so a slow link moves the needle too.
var f = Math.min(1, Math.log(1 + bps / 1e6) / Math.log(1001));
$('lv-arc').style.strokeDashoffset = ARC * (1 - f);
var h = cur ? cur.hist : [], max = 1.25 * Math.max.apply(null, h.concat(1)), line = '';
for (var i = 0; i < h.length; i++) {
line += (h.length > 1 ? 100 * i / (h.length - 1) : 0).toFixed(1) + ',' + (98 - 94 * h[i] / max).toFixed(1) + ' ';
}
$('lv-line').setAttribute('points', line);
$('lv-area').setAttribute('points', h.length > 1 ? '0,100 ' + line + '100,100' : '');
}
btn.onclick = function () {
var relay = $('speed-relay').checked, st = {n: 0, start: 0, stop: function () {}};
btn.disabled = true;
note.textContent = 'Идёт замер…';
$('speed').hidden = true;
liveStart(relay, st);
(relay ? relayTest(st) : post('/speed'))
.then(function (res) {
if (!res.ok) { note.textContent = res.error || 'ошибка'; return; }
var d = res.data;
$('sp-ping').textContent = d.ping_ms ? d.ping_ms.toFixed(d.ping_ms < 10 ? 1 : 0) + ' мс' : '-';
$('sp-down').textContent = rate(d.down_bps);
$('sp-up').textContent = d.up_bps ? rate(d.up_bps) : '-';
$('sp-down-lbl').textContent = relay ? 'браузер → клиент' : 'хост → клиент';
$('sp-up-lbl').textContent = relay ? 'клиент → браузер' : 'клиент → хост';
$('speed').hidden = false;
note.textContent = d.partial ? 'Замер остановлен по времени - показано то, что успело набраться' : '';
})
.catch(function (e) { note.textContent = 'Не удалось: ' + e.message; })
.then(function () { liveStop(); btn.disabled = false; });
};
})();
</script>
</body>
</html>
-423
View File
@@ -1,423 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Терминал · {{.Agent.Name}} - LPRA</title>
<link rel="stylesheet" href="{{asset "app.css"}}">
<script src="{{asset "pin.js"}}"></script>
<script src="{{asset "scroll.js"}}" defer></script>
<link rel="stylesheet" href="{{asset "xterm.css"}}">
<style>
#status.st-ok { color:#37c871; }
#status.st-err { color:#ff5c5c; }
#status { display:inline-block; vertical-align:bottom; min-width:0; max-width:42vw; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* SSH: the badge on a pane, and the dialog to connect. */
.badge-ssh { flex:none; font-size:10px; font-weight:600; letter-spacing:.04em; padding:1px 5px; border-radius:4px;
background:#12202f; color:var(--accent); border:1px solid #1f3a57; }
dialog.sshdlg { max-width:460px; }
.sshdlg .dlg-h .ico { color:var(--accent); display:inline-flex; }
.sshdlg .dlg-b { display:grid; gap:12px; }
.sshdlg .f { display:grid; gap:5px; margin:0; }
.sshdlg .f > span { font-size:12px; color:var(--muted); }
.sshdlg .frow { display:flex; gap:10px; align-items:end; }
.sshdlg .frow .grow { flex:1; min-width:0; }
.sshdlg .frow .port { width:92px; flex:none; }
.sshdlg input, .sshdlg select, .sshdlg textarea { width:100%; box-sizing:border-box; }
.sshdlg textarea { font:12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; resize:vertical; min-height:90px;
background:var(--bg, #0c0f13); color:var(--text); border:1px solid var(--border); border-radius:8px; padding:8px 10px; }
.sshdlg textarea:focus { border-color:var(--accent); outline:none; }
.sshdlg .auth { display:grid; gap:8px; animation:auth-in .18s ease; }
.sshdlg .auth[hidden] { display:none; }
@keyframes auth-in { from { opacity:0; transform:translateY(-4px); } }
.sshdlg p.auth { margin:0; font-size:13px; }
.sshdlg .keyname { font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; align-self:center; }
.sshdlg .seg { display:flex; padding:3px; gap:3px; background:#0c0f13; border:1px solid var(--border); border-radius:9px; }
.sshdlg .seg button { flex:1; padding:6px 8px; font-size:13px; background:transparent; color:var(--muted); border:0; border-radius:6px;
transition:background .18s, color .18s; }
.sshdlg .seg button:hover { color:var(--text); }
.sshdlg .seg button.on { background:#12202f; color:var(--text); box-shadow:inset 0 0 0 1px #1f3a57; }
.sshdlg .sshmsg { margin:0; min-height:18px; font-size:13px; color:var(--danger); }
.sshdlg .row.end { display:flex; justify-content:flex-end; gap:8px; }
@media (max-width:520px) { .sshdlg .seg button { font-size:12px; padding:6px 4px; } }
/* The page is the terminal: bar, tabs, then the terminals to the bottom. */
body { height:100vh; display:flex; flex-direction:column; overflow:hidden; }
header.topbar { flex:none; }
/* Tabs: folded away while there is one, sliding in with the second. */
.tabs { flex:none; display:flex; align-items:flex-end; gap:4px; padding:0 16px; max-height:0; opacity:0; overflow:hidden;
transition:max-height .28s ease, opacity .22s ease, padding .28s ease; }
body.multi .tabs, body.multi-hint .tabs { max-height:48px; opacity:1; padding-top:10px; }
.tablist { position:relative; display:flex; gap:4px; min-width:0; overflow-x:auto; scrollbar-width:none; }
.tablist::-webkit-scrollbar { display:none; }
.tab { display:flex; align-items:center; gap:8px; flex:none; min-width:0; height:34px; padding:0 6px 0 12px; box-sizing:border-box;
border:1px solid var(--border); border-bottom:0; border-radius:9px 9px 0 0; background:#11151b; color:var(--muted);
font-size:13px; user-select:none; cursor:default; overflow:hidden; white-space:nowrap;
transition:background .18s, color .18s, width .22s ease, max-width .22s ease, opacity .2s, padding .22s ease; }
.tab:hover { color:var(--text); background:#151a21; }
.tab.on { background:var(--panel); color:var(--text); }
.tab.enter, .tab.leave { max-width:0; opacity:0; padding:0; }
.tab.dragging { opacity:.3; }
.tab-title { flex:1; overflow:hidden; text-overflow:ellipsis; min-width:0; }
.tab-edit { width:auto; min-width:60px; max-width:150px; height:24px; padding:2px 6px; font-size:13px; }
.tab-x, .pane-x { flex:none; width:20px; height:20px; padding:0; display:inline-flex; align-items:center; justify-content:center;
background:transparent; border:0; border-radius:5px; color:var(--muted); opacity:.6; transition:opacity .15s, background .15s, color .15s; }
.tab-x:hover, .pane-x:hover { opacity:1; background:#3a1d1d; color:var(--danger); }
.tab-add { flex:none; width:34px; height:34px; padding:0; display:inline-flex; align-items:center; justify-content:center;
border:1px solid var(--border); border-bottom:0; border-radius:9px 9px 0 0; background:transparent; color:var(--muted);
font-size:18px; line-height:1; transition:background .18s, color .18s; }
.tab-add:hover { background:#151a21; color:var(--text); }
.tabmark { position:absolute; top:6px; bottom:0; width:3px; margin-left:-1px; border-radius:2px; background:var(--accent);
pointer-events:none; transition:left .12s ease; }
.tabmark[hidden] { display:none; }
.dot { width:7px; height:7px; border-radius:50%; background:#555d6a; flex:none; transition:background .3s; }
.dot.ok { background:var(--ok); } .dot.err { background:var(--danger); padding:0; border:0; margin:0; } .dot.wait { background:#e6a23c; }
/* The terminals' area. */
.area { position:relative; flex:1; min-height:0; margin:12px 16px 16px; border:1px solid var(--border); border-radius:10px; background:#0c0f13; overflow:hidden; }
body.multi .area { margin-top:0; border-top-left-radius:0; }
.float-add { position:absolute; top:8px; right:10px; z-index:6; width:28px; height:28px; padding:0; display:inline-flex;
align-items:center; justify-content:center; font-size:17px; line-height:1; color:var(--text);
background:transparent; border:1px solid var(--border); opacity:.28; transition:opacity .25s ease, background .25s ease, top .25s ease, right .25s ease; }
/* One tab split in panes: below the panes' title bars, clear of their close buttons. */
body.split1 .float-add { top:42px; right:16px; }
.float-add:hover, .float-add:focus-visible { opacity:1; background:var(--panel); }
body.multi .float-add { opacity:0; pointer-events:none; }
.tabview { position:absolute; inset:8px; opacity:0; visibility:hidden; transform:translateY(4px) scale(.995);
transition:opacity .22s ease, transform .22s ease, visibility 0s .22s; }
.tabview.on { opacity:1; visibility:visible; transform:none; transition:opacity .22s ease, transform .22s ease, visibility 0s; }
.pane { position:absolute; display:flex; flex-direction:column; border-radius:7px; overflow:hidden;
transition:left .26s ease, top .26s ease, width .26s ease, height .26s ease, border-color .2s; }
.tabview.instant .pane, .pane.instant { transition:none; }
.pane-head { display:none; }
.tabview.split .pane { border:1px solid var(--border); }
.tabview.split .pane.active { border-color:rgba(79,157,255,.7); }
.tabview.split .pane-head { display:flex; align-items:center; gap:7px; flex:none; height:26px; padding:0 4px 0 9px;
background:#11151b; border-bottom:1px solid var(--border); font-size:12px; color:var(--muted); cursor:grab; user-select:none; }
.tabview.split .pane.active .pane-head { color:var(--text); }
.pane-title { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.pane-body { flex:1; min-height:0; padding:4px 0 0 4px; }
/* A lone terminal's scrollbar sits in the margin by the frame, right of
the corner "+", not over the text. */
body:not(.touch) .tabview:not(.split) .pane { overflow:visible; }
body:not(.touch) .tabview:not(.split) .xterm .xterm-viewport { right:-7px; }
.pane.dragging { opacity:.45; }
.gutter { position:absolute; z-index:3; }
.gutter.c { cursor:col-resize; } .gutter.r { cursor:row-resize; }
.gutter::after { content:""; position:absolute; inset:0; border-radius:3px; background:var(--accent); opacity:0; transition:opacity .15s; }
.gutter:hover::after, .gutter.drag::after { opacity:.55; }
.hint { position:absolute; z-index:5; display:flex; align-items:center; justify-content:center; text-align:center; padding:8px;
border:2px solid var(--accent); border-radius:9px; background:rgba(79,157,255,.14); color:var(--text); font-size:13px;
pointer-events:none; transition:left .14s ease, top .14s ease, width .14s ease, height .14s ease; }
.hint.bad { border-color:var(--danger); background:rgba(255,92,92,.12); }
.hint[hidden] { display:none; }
.drag-ghost { position:fixed; left:0; top:0; z-index:60; pointer-events:none; padding:7px 12px; border:1px solid var(--accent);
border-radius:8px; background:var(--panel); color:var(--text); font-size:13px; white-space:nowrap;
box-shadow:0 12px 30px rgba(0,0,0,.55); transition:transform .04s linear; }
.drag-ghost[data-hint]:not([data-hint=""])::after { content:attr(data-hint); display:block; margin-top:3px; font-size:11px; color:var(--muted); }
body.dnd, body.dnd * { cursor:grabbing !important; }
.empty { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; color:var(--muted); }
.empty[hidden] { display:none; }
/* The "+" menu: a terminal here, or on another machine. */
.addmenu { position:fixed; z-index:50; min-width:270px; max-width:340px; max-height:70vh; overflow:auto; padding:6px;
background:var(--panel); border:1px solid var(--border); border-radius:10px; box-shadow:0 14px 36px rgba(0,0,0,.55);
animation:menu-in .14s ease; }
.addmenu[hidden] { display:none; }
@keyframes menu-in { from { opacity:0; transform:translateY(-4px); } }
.addmenu .item { display:flex; align-items:center; gap:10px; width:100%; padding:8px 10px; border-radius:7px; text-align:left;
background:transparent; color:var(--text); font-weight:400; }
.addmenu .item:hover:not(:disabled) { background:#12202f; }
.addmenu .item:disabled { opacity:.45; cursor:not-allowed; }
.addmenu .item b { display:block; font-weight:500; font-size:14px; }
.addmenu .item small { display:block; color:var(--muted); font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:240px; }
.addmenu .ico { width:22px; text-align:center; color:var(--accent); font-size:16px; flex:none; }
.addmenu .chev { margin-left:auto; color:var(--muted); }
.addmenu .mlist { border-top:1px solid var(--border); margin-top:4px; padding-top:4px; }
/* Touch screens: the page is as tall as what the on-screen keyboard leaves,
with a row of extra keys at the bottom, the way Termux does it. Tabs are
smaller, and there is no splitting. */
.keys { display:none; }
body.touch { position:fixed; left:0; right:0; top:0; height:100%; }
body.touch .area { margin:6px; }
body.touch.multi .area { margin-top:0; }
body.touch .tabs { padding-left:6px; padding-right:6px; }
body.touch.multi .tabs { padding-top:6px; max-height:38px; }
body.touch .tab { height:28px; font-size:12px; padding:0 2px 0 8px; gap:6px; }
body.touch .tab-add { width:30px; height:28px; }
body.touch .tabview { inset:4px; }
body.touch .keys {
display:grid; grid-template-columns:repeat(7, 1fr); gap:4px; flex:none;
padding:4px 4px calc(4px + env(safe-area-inset-bottom)); background:var(--panel); border-top:1px solid var(--border);
}
.keys button {
height:38px; padding:0; border:1px solid var(--border); border-radius:6px;
background:#0c0f13; color:var(--text); font:500 13px ui-monospace, SFMono-Regular, Menlo, monospace;
touch-action:manipulation; user-select:none; -webkit-user-select:none; -webkit-tap-highlight-color:transparent;
}
.keys button:active { background:var(--border); }
.keys button.on { background:var(--accent); border-color:var(--accent); color:#fff; }
.keys button.lock { background:var(--accent); border-color:#fff; color:#fff; }
/* Copy and paste on touch screens: a long press selects, this menu acts. */
.selmenu { position:fixed; left:50%; transform:translateX(-50%); z-index:20; display:flex; gap:7px; padding:5px;
background:var(--panel); border:1px solid var(--border); border-radius:8px; box-shadow:0 6px 24px rgba(0,0,0,.5); }
.selmenu[hidden] { display:none; }
.selmenu button { width:42px; height:42px; padding:0; display:inline-flex; align-items:center; justify-content:center;
touch-action:manipulation; -webkit-tap-highlight-color:transparent; }
body.touch .area { -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; }
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Терминал - {{.Agent.Name}} <span class="muted">({{.Agent.TerminalUser}}@{{.Agent.Hostname}})</span></div>
<nav>
<a href="/">← Машины</a>
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
<span id="status" class="muted" style="margin-left:28px"></span>
</nav>
</header>
<div class="tabs" id="tabs">
<div class="tablist" id="tablist"><div class="tabmark" id="tabmark" hidden></div></div>
<button type="button" class="tab-add" id="tabadd" title="Новый терминал" aria-label="Новый терминал">+</button>
</div>
<div class="area" id="area">
<button type="button" class="float-add" id="floatadd" title="Новый терминал" aria-label="Новый терминал">+</button>
<div class="empty" id="empty" hidden>Нет открытых терминалов<button type="button" id="emptyadd">Новый терминал</button></div>
<div class="hint" id="hint" hidden></div>
</div>
<div class="addmenu" id="addmenu" hidden></div>
<div class="selmenu" id="selmenu" hidden>
<button type="button" data-a="copy" title="Копировать" aria-label="Копировать">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg>
</button>
<button type="button" class="btn ghost" data-a="paste" title="Вставить" aria-label="Вставить">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/></svg>
</button>
<button type="button" class="btn ghost" data-a="all" title="Выделить всё" aria-label="Выделить всё">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3a2 2 0 0 0-2 2M19 3a2 2 0 0 1 2 2M21 19a2 2 0 0 1-2 2M5 21a2 2 0 0 1-2-2M9 3h1M9 21h1M14 3h1M14 21h1M3 9v1M21 9v1M3 14v1M21 14v1"/><rect x="7" y="7" width="10" height="10" rx="1"/></svg>
</button>
<button type="button" class="btn ghost close" data-a="close" title="Закрыть" aria-label="Закрыть">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="keys" id="keys">
<button type="button" data-k="esc">ESC</button>
<button type="button" data-k="/">/</button>
<button type="button" data-k="-">-</button>
<button type="button" data-k="home">HOME</button>
<button type="button" data-k="up">↑</button>
<button type="button" data-k="end">END</button>
<button type="button" data-k="pgup">PGUP</button>
<button type="button" data-k="tab">TAB</button>
<button type="button" data-k="ctrl" data-mod="ctrl">CTRL</button>
<button type="button" data-k="alt" data-mod="alt">ALT</button>
<button type="button" data-k="left">←</button>
<button type="button" data-k="down">↓</button>
<button type="button" data-k="right">→</button>
<button type="button" data-k="pgdn">PGDN</button>
</div>
<script src="{{asset "xterm.js"}}"></script>
<script src="{{asset "addon-fit.js"}}"></script>
<script>
window.TERM_PAGE = { agent: { id: {{.Agent.ID}}, name: {{.Agent.Name}}, host: {{.Agent.Hostname}}, user: {{.Agent.TerminalUser}} } };
// Touch helpers, for the terminal being typed in: the extra keys with their
// CTRL / ALT, and selecting and copying with a long press.
window.TermKeys = (function () {
const touch = window.matchMedia('(pointer: coarse)').matches;
const cur = () => window.TermPane && window.TermPane.active();
// CTRL and ALT: a tap arms the key for the next key press, a long press
// keeps it on until tapped again.
const mods = { ctrl: 0, alt: 0 }; // 0 off, 1 next key, 2 locked
const modBtn = {};
document.querySelectorAll('#keys [data-mod]').forEach(function (b) { modBtn[b.dataset.mod] = b; });
function showMods() {
for (const m in mods) {
modBtn[m].classList.toggle('on', mods[m] === 1);
modBtn[m].classList.toggle('lock', mods[m] === 2);
}
}
function useMods() {
const used = { ctrl: !!mods.ctrl, alt: !!mods.alt };
for (const m in mods) if (mods[m] === 1) mods[m] = 0;
showMods();
return used;
}
function ctrlChar(c) {
if (c === ' ' || c === '@') return '\x00';
if (c === '?') return '\x7f';
const code = c.toUpperCase().charCodeAt(0);
return code >= 64 && code <= 95 ? String.fromCharCode(code & 31) : c;
}
function withMods(data) {
if (!mods.ctrl && !mods.alt) return data;
const m = useMods();
if (m.ctrl && data.length === 1) data = ctrlChar(data);
return m.alt ? '\x1b' + data : data;
}
// xterm's modifier parameter: 1 + shift(1) + alt(2) + ctrl(4).
const CSI = { up: 'A', down: 'B', right: 'C', left: 'D', home: 'H', end: 'F' };
const TILDE = { pgup: 5, pgdn: 6 };
function keySeq(k, term) {
const m = useMods(), p = 1 + (m.alt ? 2 : 0) + (m.ctrl ? 4 : 0);
if (CSI[k]) {
if (p > 1) return '\x1b[1;' + p + CSI[k];
return (term.modes.applicationCursorKeysMode ? '\x1bO' : '\x1b[') + CSI[k];
}
if (TILDE[k]) return '\x1b[' + TILDE[k] + (p > 1 ? ';' + p : '') + '~';
let s = k === 'esc' ? '\x1b' : k === 'tab' ? '\t' : k;
if (m.ctrl && s.length === 1) s = ctrlChar(s);
return m.alt ? '\x1b' + s : s;
}
let press = null;
document.querySelectorAll('#keys button').forEach(function (b) {
// Keep the focus (and the on-screen keyboard) on the terminal.
b.addEventListener('pointerdown', function (e) {
e.preventDefault();
const mod = b.dataset.mod;
if (mod) press = { mod: mod, timer: setTimeout(function () { mods[mod] = 2; press = null; showMods(); }, 450) };
});
b.addEventListener('pointerup', function (e) {
e.preventDefault();
const mod = b.dataset.mod;
if (mod) {
if (press && press.mod === mod) {
clearTimeout(press.timer);
press = null;
mods[mod] = mods[mod] ? 0 : 1;
showMods();
}
return;
}
const s = cur();
if (s) s.send(keySeq(b.dataset.k, s.term));
});
b.addEventListener('pointercancel', function () { if (press) { clearTimeout(press.timer); press = null; } });
b.addEventListener('contextmenu', function (e) { e.preventDefault(); });
});
// --- Selecting and copying on touch screens ---
// xterm.js has no touch selection of its own. A long press selects the word
// under the finger and dragging on from there stretches the selection; a
// menu then copies it or pastes. A plain tap or swipe still types and scrolls.
const menu = document.getElementById('selmenu');
let selecting = false;
function showMenu() {
const s = cur();
const keys = document.getElementById('keys').getBoundingClientRect();
menu.style.top = '';
menu.style.bottom = (window.innerHeight - keys.top + 14) + 'px';
menu.querySelector('[data-a="copy"]').disabled = !(s && s.term.hasSelection());
menu.hidden = false;
}
function selectionChanged(s) {
if (!touch || s !== cur()) return;
if (s.term.hasSelection()) showMenu();
else if (!selecting) menu.hidden = true;
}
if (touch) {
const box = document.getElementById('area');
let timer = null, start = null, anchor = null;
function cellAt(term, el, x, y) {
const r = el.querySelector('.xterm-screen').getBoundingClientRect();
const col = Math.max(0, Math.min(term.cols - 1, Math.floor((x - r.left) / (r.width / term.cols))));
const row = Math.max(0, Math.min(term.rows - 1, Math.floor((y - r.top) / (r.height / term.rows))));
return { col: col, row: row + term.buffer.active.viewportY };
}
function selectWord(term, c) {
const line = term.buffer.active.getLine(c.row);
const text = line ? line.translateToString(false) : '';
const word = ch => ch && !/\s/.test(ch);
let a = c.col, b = c.col;
if (word(text[c.col])) {
while (a > 0 && word(text[a - 1])) a--;
while (b < text.length - 1 && word(text[b + 1])) b++;
}
term.select(a, c.row, b - a + 1);
anchor = { from: c.row * term.cols + a, to: c.row * term.cols + b };
}
function stretch(term, c) {
const p = c.row * term.cols + c.col;
const from = Math.min(anchor.from, p), to = Math.max(anchor.to, p);
term.select(from % term.cols, Math.floor(from / term.cols), to - from + 1);
}
function hideMenu() { menu.hidden = true; const s = cur(); if (s) s.term.clearSelection(); anchor = null; }
box.addEventListener('touchstart', function (e) {
if (e.touches.length !== 1 || !e.target.closest('.pane-body')) { clearTimeout(timer); return; }
const t = e.touches[0];
start = { x: t.clientX, y: t.clientY };
if (!menu.hidden) hideMenu();
clearTimeout(timer);
timer = setTimeout(function () {
const s = cur();
if (!s) return;
selecting = true;
selectWord(s.term, cellAt(s.term, s.el, start.x, start.y));
showMenu();
if (navigator.vibrate) navigator.vibrate(15);
}, 450);
}, { passive: true, capture: true });
box.addEventListener('touchmove', function (e) {
const t = e.touches[0], s = cur();
if (selecting && s) {
e.preventDefault(); // no scrolling while the selection stretches
e.stopPropagation();
stretch(s.term, cellAt(s.term, s.el, t.clientX, t.clientY));
} else if (start && Math.hypot(t.clientX - start.x, t.clientY - start.y) > 8) {
clearTimeout(timer); // a swipe: let it scroll
}
}, { passive: false, capture: true });
box.addEventListener('touchend', function (e) {
clearTimeout(timer);
if (!selecting) return;
selecting = false;
e.preventDefault(); // no tap: the keyboard stays as it was
e.stopPropagation();
showMenu();
}, { passive: false, capture: true });
box.addEventListener('contextmenu', function (e) { e.preventDefault(); });
const statusEl = document.getElementById('status');
function flash(t, cls) {
statusEl.textContent = t; statusEl.className = cls;
setTimeout(function () { const s = cur(); if (s) s.setState(s.state, s.text); }, 1500);
}
function copyText(text) {
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
// Plain http: the old way, through a field that is there for a moment.
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('нет доступа к буферу'));
}
menu.addEventListener('pointerdown', function (e) { e.preventDefault(); });
menu.addEventListener('click', function (e) {
const b = e.target.closest('button'), s = cur();
if (!b || !s) return;
const a = b.dataset.a;
if (a === 'copy') {
const text = s.term.getSelection();
hideMenu();
copyText(text).then(function () { flash('скопировано', 'st-ok'); }, function (err) { flash('не скопировалось: ' + err.message, 'st-err'); });
} else if (a === 'paste') {
hideMenu();
if (!navigator.clipboard || !navigator.clipboard.readText) { flash('вставка: браузер не даёт буфер', 'st-err'); return; }
navigator.clipboard.readText().then(function (t) { if (t) s.term.paste(t); }, function () { flash('вставка: нет доступа к буферу', 'st-err'); });
} else if (a === 'all') {
s.term.selectAll();
showMenu();
} else {
hideMenu();
}
});
}
return { withMods: withMods, selectionChanged: selectionChanged };
})();
</script>
<script src="{{asset "terminal.js"}}"></script>
</body>
</html>
+314
View File
@@ -0,0 +1,314 @@
#!/bin/sh
# LPRA server: install or update in one command.
#
# wget -qO- https://gitlab.com/itzredtea/lpra/-/raw/main/server-install.sh | sudo sh
#
# Takes the server binary for this machine from the latest GitLab release and
# the agent binaries (dist/) from the main branch, puts them in /opt/lpra,
# sets up the systemd service and, if asked, nginx with a domain and a
# certificate from certbot. Run again to update: the binary and dist/ are
# replaced, the database, the service's settings and nginx stay.
#
# Questions are asked on the terminal even when the script comes through a
# pipe. Without a terminal the answers come from the environment (or their
# defaults): LPRA_PORT (8091), LPRA_NGINX (y/n), LPRA_DOMAIN, LPRA_EMAIL,
# LPRA_CERTBOT (y/n), LPRA_UFW (y/n). LPRA_REPO and LPRA_DIR change where from
# and where to.
set -eu
REPO=${LPRA_REPO:-itzredtea/lpra}
GL=https://gitlab.com
# The project in the API's paths: its path with "/" as %2F.
API="$GL/api/v4/projects/$(printf '%s' "$REPO" | sed 's|/|%2F|g')"
DIR=${LPRA_DIR:-/opt/lpra}
SERVICE=lpra-server
UNIT=/etc/systemd/system/$SERVICE.service
TUNNEL_PORT=8446
say() { printf '\033[1;34m>>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; }
# ---------- checks ----------
[ "$(id -u)" -eq 0 ] || die "нужен root: ... | sudo sh"
[ "$(uname -s)" = Linux ] || die "сервер ставится только на Linux"
command -v systemctl >/dev/null 2>&1 || die "нужен systemd"
case "$(uname -m)" in
x86_64 | amd64) ARCH=amd64 ;;
aarch64 | arm64) ARCH=arm64 ;;
*) die "архитектура $(uname -m) не поддерживается (есть amd64 и arm64)" ;;
esac
if command -v curl >/dev/null 2>&1; then
dl() { curl -fsSL --retry 3 -o "$2" "$1"; }
fetch() { curl -fsSL --retry 3 "$1"; }
elif command -v wget >/dev/null 2>&1; then
dl() { wget -q -O "$2" "$1"; }
fetch() { wget -q -O - "$1"; }
else
die "нужен curl или wget"
fi
# ---------- questions ----------
TTY=
if (: </dev/tty) 2>/dev/null; then TTY=/dev/tty; fi
# ask VAR "question" default [KEY]: the answer from the terminal, else the
# environment's LPRA_<KEY> (KEY is VAR if not given), else the default.
ask() {
var=$1 q=$2 def=$3 key=${4:-$1}
eval "env=\${LPRA_$key:-}"
[ -n "$env" ] && def=$env
ans=
if [ -n "$TTY" ]; then
if [ -n "$def" ]; then printf '%s [%s]: ' "$q" "$def" >"$TTY"; else printf '%s: ' "$q" >"$TTY"; fi
read -r ans <"$TTY" || ans=
fi
[ -n "$ans" ] || ans=$def
eval "$var=\$ans"
}
# confirm KEY "question" y|n: true for a yes (LPRA_<KEY> without a terminal).
# The answer stays apart from the script's own variables of that name.
confirm() {
ask _yn "$2 (y/n)" "$3" "$1"
case "$_yn" in [yYдД]*) return 0 ;; *) return 1 ;; esac
}
# ---------- download ----------
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT INT TERM
TAG=$(fetch "$API/releases/permalink/latest" 2>/dev/null | tr ',' '\n' |
sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1) || TAG=
if [ -n "$TAG" ]; then
say "последний релиз: $TAG"
BIN_URL="$GL/$REPO/-/releases/$TAG/downloads/lpra-server-linux-$ARCH"
else
warn "не узнать номер релиза у GitLab API - беру latest"
BIN_URL="$GL/$REPO/-/releases/permalink/latest/downloads/lpra-server-linux-$ARCH"
fi
say "скачиваю сервер (linux/$ARCH)"
dl "$BIN_URL" "$TMP/lpra-server" || die "не скачался $BIN_URL"
chmod +x "$TMP/lpra-server"
say "скачиваю dist/ из ветки main"
mkdir -p "$TMP/dist"
# The files of dist/ on main, by the API's list of the folder.
URLS=$(fetch "$API/repository/tree?path=dist&ref=main&per_page=100" 2>/dev/null | tr '{},' '\n\n\n' |
sed -n 's|^"path": *"\(dist/[^"]*\)"$|'"$GL/$REPO"'/-/raw/main/\1|p') || URLS=
if [ -z "$URLS" ]; then
warn "не получить список dist/ у GitLab API - беру агентов linux amd64 и arm64"
URLS="$GL/$REPO/-/raw/main/dist/agent-linux-amd64
$GL/$REPO/-/raw/main/dist/agent-linux-arm64"
fi
for u in $URLS; do
f=${u##*/}
dl "$u" "$TMP/dist/$f" || die "не скачался $u"
case "$f" in agent-*) chmod +x "$TMP/dist/$f" ;; esac
printf ' %s\n' "$f"
done
# ---------- install ----------
UPDATE=
[ -f "$UNIT" ] && UPDATE=1
if [ -n "$UPDATE" ]; then
say "уже установлен - обновляю"
systemctl stop "$SERVICE" 2>/dev/null || true
fi
mkdir -p "$DIR"
mv -f "$TMP/lpra-server" "$DIR/lpra-server"
rm -rf "$DIR/dist.old"
[ -d "$DIR/dist" ] && mv "$DIR/dist" "$DIR/dist.old"
mv "$TMP/dist" "$DIR/dist"
rm -rf "$DIR/dist.old"
say "файлы в $DIR"
# The port: from the existing service on an update, asked otherwise.
PORT=
if [ -n "$UPDATE" ]; then
PORT=$(sed -n 's/.*-addr [^ ]*:\([0-9][0-9]*\).*/\1/p' "$UNIT" | head -n 1)
fi
if [ -z "$PORT" ]; then
ask PORT "порт сервера" 8091
case "$PORT" in '' | *[!0-9]*) die "порт - это число" ;; esac
fi
# ---------- nginx ----------
NGINX= DOMAIN= TLS=
if [ -n "$UPDATE" ]; then NGDEF=n; else NGDEF=y; fi
if confirm NGINX "настроить nginx с доменом" "$NGDEF"; then
if ! command -v nginx >/dev/null 2>&1; then
say "ставлю nginx"
if command -v apt-get >/dev/null 2>&1; then
{ apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nginx; } >/dev/null 2>&1
elif command -v dnf >/dev/null 2>&1; then
dnf install -y -q nginx >/dev/null 2>&1
elif command -v yum >/dev/null 2>&1; then
yum install -y -q nginx >/dev/null 2>&1
fi
fi
if command -v nginx >/dev/null 2>&1; then
ask DOMAIN "домен (например lpra.example.com)" ""
[ -n "$DOMAIN" ] || die "без домена nginx не настроить"
NGINX=1
if [ -d /etc/nginx/sites-available ]; then
CONF=/etc/nginx/sites-available/lpra
LINK=/etc/nginx/sites-enabled/lpra
else
CONF=/etc/nginx/conf.d/lpra.conf
LINK=
fi
cat >"$CONF" <<EOF
# LPRA (server-install.sh): $DOMAIN -> 127.0.0.1:$PORT
# Agents and terminals use WebSocket (the Upgrade headers) and long polling
# (no buffering, long timeouts).
map \$http_upgrade \$lpra_connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name $DOMAIN;
client_max_body_size 120m;
location / {
proxy_pass http://127.0.0.1:$PORT;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection \$lpra_connection_upgrade;
proxy_buffering off;
proxy_cache off;
proxy_request_buffering off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
EOF
[ -n "$LINK" ] && ln -sf "$CONF" "$LINK"
if ! nginx -t >/dev/null 2>&1; then nginx -t; die "nginx -t не прошёл, конфиг: $CONF"; fi
systemctl enable --now nginx >/dev/null 2>&1 || true
systemctl reload nginx
say "nginx: $DOMAIN -> 127.0.0.1:$PORT ($CONF)"
# A certificate, when certbot with its nginx plugin is there.
if command -v certbot >/dev/null 2>&1 && certbot plugins 2>/dev/null | grep -q nginx; then
if confirm CERTBOT "получить сертификат Let's Encrypt (certbot --nginx)" y; then
ask EMAIL "email для Let's Encrypt (можно пусто)" ""
if [ -n "$EMAIL" ]; then M="-m $EMAIL"; else M="--register-unsafely-without-email"; fi
# shellcheck disable=SC2086
if certbot --nginx -d "$DOMAIN" --non-interactive --agree-tos --redirect $M; then
TLS=1
else
warn "certbot не смог - остаётся http. Домен должен указывать на этот сервер, порт 80 открыт"
fi
fi
else
warn "certbot с плагином nginx не найден - сертификат пропускаю (apt install certbot python3-certbot-nginx)"
fi
else
warn "nginx не установлен и не поставился - пропускаю"
fi
fi
# Not set up now, but by an earlier run: its domain, and https if certbot
# added it.
if [ -z "$NGINX" ]; then
for c in /etc/nginx/sites-available/lpra /etc/nginx/conf.d/lpra.conf; do
[ -f "$c" ] || continue
DOMAIN=$(sed -n 's/^[[:space:]]*server_name[[:space:]]\{1,\}\([^;[:space:]]*\).*/\1/p' "$c" | head -n 1)
grep -q 'listen.*443' "$c" && TLS=1
break
done
fi
# ---------- firewall ----------
if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then
if confirm UFW "ufw включён: открыть 80, 443 и туннель $TUNNEL_PORT tcp/udp" y; then
ufw allow 80/tcp >/dev/null
ufw allow 443/tcp >/dev/null
ufw allow $TUNNEL_PORT/tcp >/dev/null
ufw allow $TUNNEL_PORT/udp >/dev/null
if [ -z "$DOMAIN" ]; then ufw allow "$PORT/tcp" >/dev/null; fi
say "ufw: открыто"
fi
fi
# ---------- service ----------
# Behind nginx: local only. Without it the panel listens on every address.
# Cookies are Secure only when the panel is reached by https.
if [ -z "$UPDATE" ] || [ -n "$NGINX" ]; then
if [ -n "$NGINX" ]; then ADDR=127.0.0.1:$PORT; else ADDR=0.0.0.0:$PORT; fi
COOKIES=
[ -z "$TLS" ] && COOKIES='Environment=LPRA_INSECURE_COOKIES=1'
cat >"$UNIT" <<EOF
# LPRA server (server-install.sh). Its database and key: $DIR/lpra.db, $DIR/lpra.db.key
[Unit]
Description=LPRA remote access host (admin panel + agent rendezvous)
After=network-online.target
Wants=network-online.target
[Service]
WorkingDirectory=$DIR
ExecStart=$DIR/lpra-server -addr $ADDR -db $DIR/lpra.db -dist $DIR/dist
$COOKIES
Restart=always
RestartSec=3
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictSUIDSGID=true
# The database, and what ufw writes when the panel opens forwarded ports.
ReadWritePaths=$DIR -/etc/ufw -/run
[Install]
WantedBy=multi-user.target
EOF
fi
systemctl daemon-reload
systemctl enable "$SERVICE" >/dev/null 2>&1
systemctl restart "$SERVICE"
say "служба $SERVICE запущена"
# ---------- health check ----------
ok=
i=0
while [ $i -lt 20 ]; do
if fetch "http://127.0.0.1:$PORT/login" >/dev/null 2>&1; then ok=1; break; fi
i=$((i + 1))
sleep 1
done
[ -n "$ok" ] || die "сервер не отвечает на 127.0.0.1:$PORT - смотрите: journalctl -u $SERVICE -n 50"
say "сервер отвечает"
if [ -n "$NGINX" ]; then
if fetch "http://127.0.0.1/login" >/dev/null 2>&1 || [ -n "$TLS" ]; then say "nginx отвечает"; fi
fi
# ---------- done ----------
if [ -n "$TLS" ]; then URL=https://$DOMAIN
elif [ -n "$DOMAIN" ]; then URL=http://$DOMAIN
else
IP=$(hostname -I 2>/dev/null | awk '{print $1}')
URL="http://${IP:-<IP сервера>}:$PORT"
fi
echo
say "готово: LPRA ${TAG:-} в $DIR"
if [ -z "$UPDATE" ]; then
echo " откройте $URL/setup - мастер создаст администратора"
else
echo " панель: $URL"
fi
if [ -z "$TLS" ]; then echo " панель без https: куки без Secure. Для работы через интернет лучше домен с сертификатом"; fi
echo " логи: journalctl -u $SERVICE -f"