Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adadfb19a6 | ||
|
|
3f288c6fd6 | ||
|
|
3d2f886f04 | ||
|
|
916a49958b | ||
|
|
dde85031aa | ||
|
|
dd1244a169 | ||
|
|
c72fcfebf3 | ||
|
|
054fc97da8 | ||
|
|
114f8b1e1b | ||
|
|
a1604206ee | ||
|
|
a107745355 | ||
|
|
dcbc602d88 | ||
|
|
fcf032042b | ||
|
|
01f02360c4 | ||
|
|
57de7bf967 | ||
|
|
173f46a145 | ||
|
|
03f963340e | ||
|
|
37ef886503 | ||
|
|
6c1da79b4a | ||
|
|
6a17f40042 | ||
|
|
351e4ad28d | ||
|
|
983809714b | ||
|
|
ded86e1c3f | ||
|
|
b4bcc05162 | ||
|
|
e8d45f2f7b | ||
|
|
e93a2bbd3a | ||
|
|
e0b3999d0c | ||
|
|
77322e8644 | ||
|
|
6085b5a094 | ||
|
|
e0cc5a06b7 | ||
|
|
667c6177f4 | ||
|
|
86f7c96807 | ||
|
|
cf0b2baf75 | ||
|
|
2ab2aa1e38 | ||
|
|
087eabf3b4 | ||
|
|
57c2f1338f | ||
|
|
4fb26a9652 | ||
|
|
46f7f743b3 | ||
|
|
70729ef63f | ||
|
|
2d6b3916c8 | ||
|
|
35ad2d435b | ||
|
|
37ae830cbf | ||
|
|
ffe7c138f1 | ||
|
|
ae8fffe7d8 | ||
|
|
84b42c5369 | ||
|
|
8aba30a093 | ||
|
|
9337bd26b0 | ||
|
|
8bbb2b0ccb | ||
|
|
80ed7d4ffa | ||
|
|
6442c39360 | ||
|
|
d5d57fbf1b | ||
|
|
7ae75e910a | ||
|
|
74a22ab57f | ||
|
|
060975a443 | ||
|
|
fdd0483286 | ||
|
|
fcf9417f5a | ||
|
|
3641a3627c | ||
|
|
9912255289 | ||
|
|
9bf30b281e | ||
|
|
fa62f2c5b6 | ||
|
|
3b7a6271ac | ||
|
|
5216aed237 | ||
|
|
c003b51b4c | ||
|
|
96163ebf86 | ||
|
|
bc1736f105 | ||
|
|
b23943ff42 | ||
|
|
6d8f39f2a5 | ||
|
|
458d826de6 | ||
|
|
0c44ef006d | ||
|
|
187dc2ae97 | ||
|
|
055020c3bb | ||
|
|
4da1cc4200 | ||
|
|
4b7220fffb | ||
|
|
434900a168 | ||
|
|
6db78399ac | ||
|
|
352b016384 | ||
|
|
b272aed8fd | ||
|
|
ce7c417884 | ||
|
|
bfa38e7d18 | ||
|
|
00617451aa | ||
|
|
92a90646a5 | ||
|
|
69600133a4 | ||
|
|
3044e650d3 | ||
|
|
ed77c8e0c1 | ||
|
|
7d1996dd7d | ||
|
|
6916af4236 | ||
|
|
8d7489b577 | ||
|
|
847bcd68c0 | ||
|
|
a7e43ab700 | ||
|
|
d9c7c2dd42 | ||
|
|
94bf292494 | ||
|
|
1f930e5aea | ||
|
|
3f113ce8d6 | ||
|
|
46948f5ddc | ||
|
|
9572a35ad5 | ||
|
|
64ce8f2b72 | ||
|
|
e05ff05015 | ||
|
|
01dc45ecad | ||
|
|
d92696226e | ||
|
|
2009796421 | ||
|
|
f2740c50b4 | ||
|
|
a30b89f1d8 | ||
|
|
7e4daeda4c | ||
|
|
2859e69abe | ||
|
|
f5d6bd30e8 | ||
|
|
012c0af7f9 | ||
|
|
113c63fd1b | ||
|
|
7b9daa524e | ||
|
|
0ada0899cf | ||
|
|
8e2028e7f9 | ||
|
|
698bc68f99 |
No files matched your search
@@ -34,7 +34,6 @@ go.work.sum
|
|||||||
|
|
||||||
|
|
||||||
bin
|
bin
|
||||||
dist
|
|
||||||
*.db
|
*.db
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*/.DS_Store
|
*/.DS_Store
|
||||||
@@ -12,9 +12,14 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
- **Терминал** - вкладки на нескольких машинах сразу, до четырёх терминалов в одной вкладке
|
- **Одна страница** - Машины, WorkSpace (терминалы и файлы), Настройки; переходы без перезагрузки.
|
||||||
(перетаскиванием, с изменяемыми размерами), переименование вкладок. На телефоне - панель
|
Вкладки WorkSpace живут, пока открыта вкладка браузера: уход на другие страницы их не
|
||||||
клавиш (Esc, Ctrl, Alt, стрелки и т.д.).
|
закрывает, перезагрузка сбрасывает (браузер сначала спросит). «Порты», «Статус» и «Изменить»
|
||||||
|
машины открываются окном поверх текущей страницы - из списка машин или правым кликом
|
||||||
|
по заголовку вкладки или панели.
|
||||||
|
- **WorkSpace** - вкладки на нескольких машинах сразу, до четырёх панелей в одной вкладке -
|
||||||
|
терминал или файловый менеджер (перетаскиванием, с изменяемыми размерами), переименование
|
||||||
|
вкладок. На телефоне - панель клавиш (Esc, Ctrl, Alt, стрелки и т.д.).
|
||||||
- **SSH-терминал** - в меню «+»: агент открывает SSH-соединение к другому хосту (по паролю,
|
- **SSH-терминал** - в меню «+»: агент открывает SSH-соединение к другому хосту (по паролю,
|
||||||
ключу - можно загрузить файл - или своими ключами из `~/.ssh`), шелл открывается во вкладке,
|
ключу - можно загрузить файл - или своими ключами из `~/.ssh`), шелл открывается во вкладке,
|
||||||
а шаги подключения видны в статусе. Ключ хоста запоминается при первом входе
|
а шаги подключения видны в статусе. Ключ хоста запоминается при первом входе
|
||||||
@@ -22,12 +27,53 @@
|
|||||||
- **Файлы** - просмотр, редактор с подсветкой, 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.
|
||||||
|
**Картинки**: если администратор задал API-ключ в «Настройки › ИИ-ассистент › Генерация изображений»
|
||||||
|
([Vertex API](https://s2.x-grk.com/api/vertex/docs)), модели сами, по смыслу разговора и без плашки, рисуют картинки
|
||||||
|
и правят фото - то, что пользователь прикрепил к сообщению, или последнюю созданную картинку
|
||||||
|
(блоки ```` ```image ```` и ```` ```image edit ````, формат и качество - в первой строке; улучшение описания
|
||||||
|
можно отключить: `noenhance`). Картинка показывается в чате, описание в карточке сворачивается.
|
||||||
|
Название прикреплённого файла открывает его (или скачивает). Удаление чата стирает его содержимое, в том числе
|
||||||
|
текст вложений, из файлов базы; картинки хранятся на сервисе Vertex и оттуда не удаляются.
|
||||||
|
- **Сохранённые SSH-хосты** - в окне SSH-подключения галочка «Сохранить»: хост и вход (пароль или
|
||||||
|
ключ) хранятся на сервере, у каждого пользователя свои, зашифрованы AES-256-GCM ключом сервера.
|
||||||
|
Ключ берётся из `LPRA_SECRET_KEY` (32 байта, hex или base64) или из файла `<база>.key` рядом с
|
||||||
|
базой, который создаётся сам - храните его вместе с бэкапом базы, без него сохранённые входы
|
||||||
|
не расшифровать.
|
||||||
- **Проброс портов** - `http` через панель, `tcp`/`udp` через прямой туннель.
|
- **Проброс портов** - `http` через панель, `tcp`/`udp` через прямой туннель.
|
||||||
- **Статус** - нагрузка, память, диски, сеть, тест скорости канала до сервера.
|
- **Статус** - нагрузка, память, диски, сеть, тест скорости канала до сервера.
|
||||||
|
- **Автообновление** («Настройки › Автообновление», обе части выключены, пока не включены):
|
||||||
|
*клиентов* - агенты раз в N минут/часов/суток сверяют свою версию с версией сервера и, если у сервера новее,
|
||||||
|
обновляются сами (как по кнопке «Обновить»; нужен root или sudo без пароля; агентам до 0.2.5 один раз обновиться
|
||||||
|
придётся вручную); *сервера* - сервер раз в N сверяет себя с последним релизом на GitLab, и когда вышел новый,
|
||||||
|
администратору при входе (после PIN) показывается окно «Обновить / Отказаться» - отказ запоминается для этой
|
||||||
|
версии. «Обновить» запускает `server-install.sh` отдельной службой systemd (нужны root и systemd); там же кнопки
|
||||||
|
«Проверить сейчас» и «Обновить сейчас».
|
||||||
- **Вход** - пароль и/или ключи доступа (passkeys); пароль можно выключить, когда есть ключ.
|
- **Вход** - пароль и/или ключи доступа (passkeys); пароль можно выключить, когда есть ключ.
|
||||||
- **PIN-код** (необязательный, 4 цифры) - спрашивается в каждой новой вкладке браузера, но не при
|
- **PIN-код** (необязательный, 4 цифры) - спрашивается в каждой новой вкладке браузера, но не при
|
||||||
перезагрузке. На устройстве можно не спрашивать его 5/15/30/60 минут после ввода.
|
перезагрузке. На устройстве можно не спрашивать его 5/15/30/60 минут после ввода.
|
||||||
@@ -42,6 +88,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 +109,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 +199,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 +227,12 @@ 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" />
|
||||||
|
|
||||||
|
*Автор иконки репозитория - * [grafixpoint](https://www.flaticon.com/authors/grafixpoint)
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.1.2
|
0.2.5
|
||||||
+5
-1
@@ -6,6 +6,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -26,7 +27,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)
|
||||||
}
|
}
|
||||||
@@ -42,6 +43,9 @@ func main() {
|
|||||||
log.Fatalf("init server: %v", err)
|
log.Fatalf("init server: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The server updates itself, if told to (Настройки › Автообновление).
|
||||||
|
go srv.RunAutoUpdate(context.Background())
|
||||||
|
|
||||||
httpSrv := &http.Server{
|
httpSrv := &http.Server{
|
||||||
Addr: *addr,
|
Addr: *addr,
|
||||||
Handler: srv.Handler(),
|
Handler: srv.Handler(),
|
||||||
|
|||||||
@@ -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;
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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, начат ДД.ММ.ГГГГ» - раз дата создания новая.
|
||||||
|
- Токены, которые светились в чатах и логах, - отозвать и выпустить новые.
|
||||||
@@ -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 и перетаскивании.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Auto-update, the agent's part: the server tells (with every poll, on its
|
||||||
|
// config and on the WebSocket) its own version and how often to compare -
|
||||||
|
// in minutes, 0 for never. When the server's version is newer, the agent
|
||||||
|
// takes the new one from it, as the panel's «Обновить» does.
|
||||||
|
|
||||||
|
// autoUpdate is what the agent was told, and when it last looked.
|
||||||
|
type autoUpdate struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
minutes int
|
||||||
|
serverVer string
|
||||||
|
last time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) applyUpdateInfo(minutes int, serverVersion string) {
|
||||||
|
a.auto.mu.Lock()
|
||||||
|
defer a.auto.mu.Unlock()
|
||||||
|
if minutes < 0 {
|
||||||
|
minutes = 0
|
||||||
|
}
|
||||||
|
a.auto.minutes = minutes
|
||||||
|
if serverVersion != "" {
|
||||||
|
a.auto.serverVer = serverVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyUpdateHeaders takes the settings from the headers of an answer; an
|
||||||
|
// answer without them (an older server) changes nothing.
|
||||||
|
func (a *Agent) applyUpdateHeaders(h http.Header) {
|
||||||
|
v := h.Get(proto.AutoUpdateHeader)
|
||||||
|
if v == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
m = 0
|
||||||
|
}
|
||||||
|
a.applyUpdateInfo(m, h.Get(proto.ServerVersionHeader))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) updateInfo() (int, string) {
|
||||||
|
a.auto.mu.Lock()
|
||||||
|
defer a.auto.mu.Unlock()
|
||||||
|
return a.auto.minutes, a.auto.serverVer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) autoUpdateOn() bool { m, _ := a.updateInfo(); return m > 0 }
|
||||||
|
|
||||||
|
// autoUpdateTick: one look at the clock. Out of the interval since the last
|
||||||
|
// look, it looks; the server's version newer than its own, it updates.
|
||||||
|
// Reports whether it did.
|
||||||
|
func (a *Agent) autoUpdateTick(now time.Time, update func() proto.TaskResult) bool {
|
||||||
|
a.auto.mu.Lock()
|
||||||
|
if a.auto.minutes <= 0 || (!a.auto.last.IsZero() && now.Sub(a.auto.last) < time.Duration(a.auto.minutes)*time.Minute) {
|
||||||
|
a.auto.mu.Unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
a.auto.last = now
|
||||||
|
server := a.auto.serverVer
|
||||||
|
a.auto.mu.Unlock()
|
||||||
|
if !proto.VersionNewer(server, Version) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
log.Printf("auto-update: server has %s, this is %s - updating", server, Version)
|
||||||
|
if res := update(); !res.OK {
|
||||||
|
log.Printf("auto-update: %s", res.Error)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoUpdateLoop watches the clock until ctx ends.
|
||||||
|
func (a *Agent) autoUpdateLoop(ctx context.Context) {
|
||||||
|
t := time.NewTicker(30 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case now := <-t.C:
|
||||||
|
a.autoUpdateTick(now, a.doUpdate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// updates counts what an agent was made to update with.
|
||||||
|
type updates struct{ n int }
|
||||||
|
|
||||||
|
func (u *updates) do() proto.TaskResult { u.n++; return proto.TaskResult{OK: true} }
|
||||||
|
|
||||||
|
// The agent is told, by headers or by the WebSocket's message, how often to
|
||||||
|
// compare versions; nothing told, nothing done.
|
||||||
|
func TestAutoUpdateIsTold(t *testing.T) {
|
||||||
|
a := New(Config{})
|
||||||
|
if a.autoUpdateOn() {
|
||||||
|
t.Fatal("on with nothing told")
|
||||||
|
}
|
||||||
|
h := http.Header{}
|
||||||
|
h.Set(proto.AutoUpdateHeader, "90")
|
||||||
|
h.Set(proto.ServerVersionHeader, "9.9.9")
|
||||||
|
a.applyUpdateHeaders(h)
|
||||||
|
if m, v := a.updateInfo(); m != 90 || v != "9.9.9" {
|
||||||
|
t.Fatalf("by headers: %d %q", m, v)
|
||||||
|
}
|
||||||
|
a.applyUpdateInfo(0, "9.9.9")
|
||||||
|
if a.autoUpdateOn() {
|
||||||
|
t.Fatal("still on after being told 0")
|
||||||
|
}
|
||||||
|
a.applyUpdateInfo(30, "9.9.9")
|
||||||
|
if m, _ := a.updateInfo(); m != 30 {
|
||||||
|
t.Fatalf("by the message: %d", m)
|
||||||
|
}
|
||||||
|
// Rubbish is not taken for a number.
|
||||||
|
h.Set(proto.AutoUpdateHeader, "soon")
|
||||||
|
a.applyUpdateHeaders(h)
|
||||||
|
if m, _ := a.updateInfo(); m != 0 {
|
||||||
|
t.Fatalf("rubbish: %d", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the interval is out the agent looks; it updates only to a newer
|
||||||
|
// version of the server's, and tries again the next time if it did not work.
|
||||||
|
func TestAutoUpdateTick(t *testing.T) {
|
||||||
|
a := New(Config{})
|
||||||
|
u := &updates{}
|
||||||
|
now := time.Now()
|
||||||
|
a.applyUpdateInfo(60, "9.9.9")
|
||||||
|
|
||||||
|
if !a.autoUpdateTick(now, u.do) || u.n != 1 {
|
||||||
|
t.Fatalf("a newer server: %d updates", u.n)
|
||||||
|
}
|
||||||
|
if a.autoUpdateTick(now.Add(30*time.Minute), u.do) || u.n != 1 {
|
||||||
|
t.Fatalf("before the interval is out: %d updates", u.n)
|
||||||
|
}
|
||||||
|
if !a.autoUpdateTick(now.Add(61*time.Minute), u.do) || u.n != 2 {
|
||||||
|
t.Fatalf("at the next time: %d updates", u.n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same, an older one, a build that is not a version: nothing.
|
||||||
|
for _, v := range []string{Version, "0.0.1", "dev", ""} {
|
||||||
|
b := New(Config{})
|
||||||
|
b.applyUpdateInfo(60, v)
|
||||||
|
if b.autoUpdateTick(now, u.do) {
|
||||||
|
t.Fatalf("updated to %q", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Off: nothing, whatever the server has.
|
||||||
|
c := New(Config{})
|
||||||
|
c.applyUpdateInfo(0, "9.9.9")
|
||||||
|
if c.autoUpdateTick(now, u.do) || u.n != 2 {
|
||||||
|
t.Fatalf("off, yet updated: %d", u.n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,8 @@ type Agent struct {
|
|||||||
tunMu sync.Mutex
|
tunMu sync.Mutex
|
||||||
tunAddr string
|
tunAddr string
|
||||||
tunCancel context.CancelFunc
|
tunCancel context.CancelFunc
|
||||||
|
|
||||||
|
auto autoUpdate // what the server told of auto-update
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds an agent. It does not perform any network I/O.
|
// New builds an agent. It does not perform any network I/O.
|
||||||
@@ -115,6 +117,7 @@ func (a *Agent) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
go a.watchMain(ctx)
|
go a.watchMain(ctx)
|
||||||
|
go a.autoUpdateLoop(ctx)
|
||||||
log.Printf("agent %s connected to %s", a.state.AgentID, a.host())
|
log.Printf("agent %s connected to %s", a.state.AgentID, a.host())
|
||||||
|
|
||||||
// Direct forward tunnel (low-latency TCP mux + UDP). Its address comes
|
// Direct forward tunnel (low-latency TCP mux + UDP). Its address comes
|
||||||
@@ -321,6 +324,7 @@ func (a *Agent) pollOnce(ctx, reqCtx context.Context) error {
|
|||||||
a.applyTunnelHeader(ctx, resp.Header)
|
a.applyTunnelHeader(ctx, resp.Header)
|
||||||
a.learnAddrs(resp.Header.Get(proto.MainHeader), resp.Header.Get(proto.FallbackHeader))
|
a.learnAddrs(resp.Header.Get(proto.MainHeader), resp.Header.Get(proto.FallbackHeader))
|
||||||
a.applyTransport(resp.Header.Get(proto.TransportHeader))
|
a.applyTransport(resp.Header.Get(proto.TransportHeader))
|
||||||
|
a.applyUpdateHeaders(resp.Header)
|
||||||
a.dispatch(ctx, msg)
|
a.dispatch(ctx, msg)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -330,7 +334,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:
|
||||||
@@ -356,6 +360,7 @@ func (a *Agent) fetchConfig(ctx context.Context) bool {
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
a.applyTransport(resp.Header.Get(proto.TransportHeader))
|
a.applyTransport(resp.Header.Get(proto.TransportHeader))
|
||||||
a.learnAddrs(resp.Header.Get(proto.MainHeader), resp.Header.Get(proto.FallbackHeader))
|
a.learnAddrs(resp.Header.Get(proto.MainHeader), resp.Header.Get(proto.FallbackHeader))
|
||||||
|
a.applyUpdateHeaders(resp.Header)
|
||||||
return a.applyTunnelHeader(ctx, resp.Header)
|
return a.applyTunnelHeader(ctx, resp.Header)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
+121
-26
@@ -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()
|
||||||
return proto.TaskResult{Error: err.Error()}
|
for _, n := range names {
|
||||||
|
if err := shipName(n); err != nil {
|
||||||
|
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,15 +352,30 @@ 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()
|
||||||
return proto.TaskResult{Error: err.Error()}
|
for _, n := range names {
|
||||||
|
if err := shipName(n); err != nil {
|
||||||
|
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.
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
c, err := sshfsGet(args.Conn)
|
||||||
return proto.TaskResult{Error: err.Error()}
|
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 {
|
||||||
|
return proto.TaskResult{Error: err.Error()}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithCancel(ctx)
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -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,10 +455,11 @@ 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
|
||||||
uid, gid int
|
names map[string]bool // the top entries the stream may carry
|
||||||
chown bool
|
uid, gid int
|
||||||
done, skipped int
|
chown bool
|
||||||
|
done, skipped int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *unpacker) unpack(tr *tar.Reader) error {
|
func (u *unpacker) unpack(tr *tar.Reader) error {
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ func (a *Agent) runWS(ctx context.Context) error {
|
|||||||
a.applyTunnelAddr(ctx, m.Tunnel)
|
a.applyTunnelAddr(ctx, m.Tunnel)
|
||||||
a.learnAddrs(m.Main, m.Fallback)
|
a.learnAddrs(m.Main, m.Fallback)
|
||||||
a.applyTransport(m.Transport)
|
a.applyTransport(m.Transport)
|
||||||
|
a.applyUpdateInfo(m.AutoUpdate, m.ServerVersion)
|
||||||
if a.mode() == proto.TransportLP {
|
if a.mode() == proto.TransportLP {
|
||||||
return errors.New("set to long polling")
|
return errors.New("set to long polling")
|
||||||
}
|
}
|
||||||
|
|||||||
+65
-5
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,8 +103,19 @@ type WSMsg struct {
|
|||||||
Transport string `json:"transport,omitempty"`
|
Transport string `json:"transport,omitempty"`
|
||||||
Fallback string `json:"fallback,omitempty"`
|
Fallback string `json:"fallback,omitempty"`
|
||||||
Main string `json:"main,omitempty"`
|
Main string `json:"main,omitempty"`
|
||||||
|
// Auto-update of agents: how often (minutes) an agent compares its
|
||||||
|
// version with the server's and updates itself if the server's is newer;
|
||||||
|
// 0 or missing - it does not. ServerVersion is the server's.
|
||||||
|
AutoUpdate int `json:"autoupdate,omitempty"`
|
||||||
|
ServerVersion string `json:"server_version,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Headers with the same on the poll answer and on /api/agent/config.
|
||||||
|
const (
|
||||||
|
AutoUpdateHeader = "X-Auto-Update" // minutes, "0" or missing: off
|
||||||
|
ServerVersionHeader = "X-Server-Version" // the server's release version
|
||||||
|
)
|
||||||
|
|
||||||
// Terminal / stream frame types on the session "in" channel (server -> agent).
|
// Terminal / stream frame types on the session "in" channel (server -> agent).
|
||||||
// Reused for both PTY terminals and raw TCP port-forward streams.
|
// Reused for both PTY terminals and raw TCP port-forward streams.
|
||||||
const (
|
const (
|
||||||
@@ -167,6 +185,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 +210,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 +306,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 +387,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 +414,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"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package proto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// versionParts reads "0.2.5" (a leading v and a "-suffix" are allowed) as
|
||||||
|
// numbers; ok is false for anything that is not a dotted number.
|
||||||
|
func versionParts(v string) (parts []int, ok bool) {
|
||||||
|
v = strings.TrimPrefix(strings.TrimSpace(v), "v")
|
||||||
|
if i := strings.IndexAny(v, "-+"); i >= 0 {
|
||||||
|
v = v[:i]
|
||||||
|
}
|
||||||
|
if v == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
for _, p := range strings.Split(v, ".") {
|
||||||
|
n, err := strconv.Atoi(p)
|
||||||
|
if err != nil || n < 0 {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
parts = append(parts, n)
|
||||||
|
}
|
||||||
|
return parts, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// VersionNewer reports whether version a is higher than b. Anything that is
|
||||||
|
// not a dotted number (a build named "dev", an empty string) is never newer
|
||||||
|
// and never older - so a program is never "updated" down, or from a guess.
|
||||||
|
func VersionNewer(a, b string) bool {
|
||||||
|
pa, okA := versionParts(a)
|
||||||
|
pb, okB := versionParts(b)
|
||||||
|
if !okA || !okB {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i < len(pa) || i < len(pb); i++ {
|
||||||
|
var x, y int
|
||||||
|
if i < len(pa) {
|
||||||
|
x = pa[i]
|
||||||
|
}
|
||||||
|
if i < len(pb) {
|
||||||
|
y = pb[i]
|
||||||
|
}
|
||||||
|
if x != y {
|
||||||
|
return x > y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package proto
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// A version is newer only when it is a higher dotted number: never for the
|
||||||
|
// same, an older one, or what is not a version at all.
|
||||||
|
func TestVersionNewer(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
a, b string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"0.2.5", "0.2.4", true},
|
||||||
|
{"0.2.10", "0.2.9", true},
|
||||||
|
{"0.3.0", "0.2.99", true},
|
||||||
|
{"1.0", "0.9.9", true},
|
||||||
|
{"0.2.4", "0.2.4", false},
|
||||||
|
{"0.2.3", "0.2.4", false},
|
||||||
|
{"0.2", "0.2.0", false},
|
||||||
|
{"v0.2.5", "0.2.4", true},
|
||||||
|
{"", "0.2.4", false},
|
||||||
|
{"dev", "0.2.4", false},
|
||||||
|
{"0.2.5", "dev", false},
|
||||||
|
{"0.2.5", "", false},
|
||||||
|
{"0.2.5-rc1", "0.2.4", true},
|
||||||
|
} {
|
||||||
|
if got := VersionNewer(c.a, c.b); got != c.want {
|
||||||
|
t.Errorf("VersionNewer(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The assistant is on unless the user turned it off - for the web search
|
||||||
|
// and page reading as for the chat (a user who never touched the switch
|
||||||
|
// has no setting at all).
|
||||||
|
func TestAISearchAndFetchOnByDefault(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
sx := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, `{"results":[{"title":"Intel","url":"https://example.com/intel","content":"процессоры"}]}`)
|
||||||
|
}))
|
||||||
|
defer sx.Close()
|
||||||
|
_ = s.db.SetConfig(cfgSearchEngine, "searxng")
|
||||||
|
_ = s.db.SetConfig(cfgSearchURL, sx.URL)
|
||||||
|
post := func(path, body string) string {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
|
||||||
|
req.AddCookie(c)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(w, req)
|
||||||
|
return w.Body.String()
|
||||||
|
}
|
||||||
|
if got := post("/api/panel/ai/search", `{"q":"intel"}`); !strings.Contains(got, `"ok":true`) {
|
||||||
|
t.Fatalf("search by default: %s", got)
|
||||||
|
}
|
||||||
|
if got := post("/api/panel/ai/fetch", `{"url":"не адрес"}`); strings.Contains(got, "выключен") {
|
||||||
|
t.Fatalf("fetch by default: %s", got)
|
||||||
|
}
|
||||||
|
a, _ := s.db.AdminByUsername("adm")
|
||||||
|
_ = s.db.SetConfig(aiUserKey(a.ID), "0")
|
||||||
|
for _, p := range []string{"/api/panel/ai/search", "/api/panel/ai/fetch"} {
|
||||||
|
if got := post(p, `{"q":"intel","url":"https://example.com"}`); !strings.Contains(got, "выключен") {
|
||||||
|
t.Fatalf("%s with the assistant off: %s", p, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Auto-update, two kinds, each off until asked for (Настройки › Автообновление):
|
||||||
|
// - of the agents: they are told how often to compare their version with
|
||||||
|
// the server's and, if the server's is newer, to take the agent from it
|
||||||
|
// (as the panel's «Обновить» does);
|
||||||
|
// - of the server: it compares itself with the latest release on GitLab;
|
||||||
|
// when that is newer, an administrator is offered it at login (after the
|
||||||
|
// PIN), and on «Обновить» the install script is run (the same one as
|
||||||
|
// `wget -qO- …/server-install.sh | sudo sh`). Nothing runs unasked.
|
||||||
|
|
||||||
|
const (
|
||||||
|
cfgAgentAuto = "agent_autoupdate"
|
||||||
|
cfgAgentAutoMin = "agent_autoupdate_min"
|
||||||
|
cfgServerAuto = "server_autoupdate"
|
||||||
|
cfgServerAutoMin = "server_autoupdate_min"
|
||||||
|
|
||||||
|
// Where releases are. Not in the settings: for tests.
|
||||||
|
cfgUpdateRepo = "update_repo"
|
||||||
|
cfgUpdateBase = "update_base"
|
||||||
|
|
||||||
|
DefaultUpdateRepo = "itzredtea/lpra"
|
||||||
|
DefaultUpdateBase = "https://gitlab.com"
|
||||||
|
|
||||||
|
defaultAutoMin = 360 // 6 hours
|
||||||
|
maxAutoMin = 60 * 24 * 30 // 30 days
|
||||||
|
)
|
||||||
|
|
||||||
|
// autoSetting reads an "on" flag and its interval in minutes.
|
||||||
|
func (s *Server) autoSetting(onKey, minKey string) (bool, int) {
|
||||||
|
on, _ := s.db.GetConfig(onKey)
|
||||||
|
m, _ := strconv.Atoi(func() string { v, _ := s.db.GetConfig(minKey); return v }())
|
||||||
|
if m < 1 || m > maxAutoMin {
|
||||||
|
m = defaultAutoMin
|
||||||
|
}
|
||||||
|
return on == "1", m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) agentAuto() (bool, int) { return s.autoSetting(cfgAgentAuto, cfgAgentAutoMin) }
|
||||||
|
func (s *Server) serverAuto() (bool, int) { return s.autoSetting(cfgServerAuto, cfgServerAutoMin) }
|
||||||
|
|
||||||
|
// setUpdateHeaders tells an agent the server's version and how often to
|
||||||
|
// compare ("0": never).
|
||||||
|
func (s *Server) setUpdateHeaders(w http.ResponseWriter) {
|
||||||
|
w.Header().Set(proto.ServerVersionHeader, Version)
|
||||||
|
w.Header().Set(proto.AutoUpdateHeader, strconv.Itoa(s.agentAutoMinutes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentAutoMinutes is the interval agents are given, 0 when off.
|
||||||
|
func (s *Server) agentAutoMinutes() int {
|
||||||
|
if on, m := s.agentAuto(); on {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// minutesOf turns "2" and "h" into 120: min, h, d.
|
||||||
|
func minutesOf(n, unit string) int {
|
||||||
|
v, err := strconv.Atoi(strings.TrimSpace(n))
|
||||||
|
if err != nil || v < 1 {
|
||||||
|
return defaultAutoMin
|
||||||
|
}
|
||||||
|
switch unit {
|
||||||
|
case "h":
|
||||||
|
v *= 60
|
||||||
|
case "d":
|
||||||
|
v *= 60 * 24
|
||||||
|
}
|
||||||
|
if v > maxAutoMin {
|
||||||
|
v = maxAutoMin
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /settings/autoupdate - both settings.
|
||||||
|
func (s *Server) handleSetAutoUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !checkCSRF(r) {
|
||||||
|
http.Error(w, "bad csrf", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flag := func(v string) string {
|
||||||
|
if v == "1" {
|
||||||
|
return "1"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
_ = s.db.SetConfig(cfgAgentAuto, flag(r.FormValue("agent_auto")))
|
||||||
|
_ = s.db.SetConfig(cfgAgentAutoMin, strconv.Itoa(minutesOf(r.FormValue("agent_every"), r.FormValue("agent_unit"))))
|
||||||
|
_ = s.db.SetConfig(cfgServerAuto, flag(r.FormValue("server_auto")))
|
||||||
|
_ = s.db.SetConfig(cfgServerAutoMin, strconv.Itoa(minutesOf(r.FormValue("server_every"), r.FormValue("server_unit"))))
|
||||||
|
// A server that was told to look again looks at once.
|
||||||
|
s.upd.mu.Lock()
|
||||||
|
s.upd.last = time.Time{}
|
||||||
|
s.upd.mu.Unlock()
|
||||||
|
http.Redirect(w, r, "/settings#autoupdate", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// intervalParts is a number of minutes as the form shows it: 120 - 2 h.
|
||||||
|
func intervalParts(min int) (int, string) {
|
||||||
|
switch {
|
||||||
|
case min%(60*24) == 0:
|
||||||
|
return min / (60 * 24), "d"
|
||||||
|
case min%60 == 0:
|
||||||
|
return min / 60, "h"
|
||||||
|
}
|
||||||
|
return min, "min"
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoView is what Настройки show of the settings.
|
||||||
|
type autoView struct {
|
||||||
|
AgentOn, ServerOn bool
|
||||||
|
AgentEvery, ServerEvery int
|
||||||
|
AgentUnit, ServerUnit string
|
||||||
|
Status updStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) autoView() autoView {
|
||||||
|
var v autoView
|
||||||
|
var am, sm int
|
||||||
|
v.AgentOn, am = s.agentAuto()
|
||||||
|
v.ServerOn, sm = s.serverAuto()
|
||||||
|
v.AgentEvery, v.AgentUnit = intervalParts(am)
|
||||||
|
v.ServerEvery, v.ServerUnit = intervalParts(sm)
|
||||||
|
v.Status = s.updateStatus()
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// updState is what the updating of the server remembers.
|
||||||
|
type updState struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
last time.Time // when it last looked
|
||||||
|
latest string
|
||||||
|
checked time.Time
|
||||||
|
err string
|
||||||
|
}
|
||||||
|
|
||||||
|
// updStatus is that, for the settings page.
|
||||||
|
type updStatus struct {
|
||||||
|
Current, Latest string
|
||||||
|
Checked string
|
||||||
|
Error string
|
||||||
|
Newer bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateStatus() updStatus {
|
||||||
|
s.upd.mu.Lock()
|
||||||
|
defer s.upd.mu.Unlock()
|
||||||
|
st := updStatus{Current: Version, Latest: s.upd.latest, Error: s.upd.err, Newer: proto.VersionNewer(s.upd.latest, Version)}
|
||||||
|
if !s.upd.checked.IsZero() {
|
||||||
|
st.Checked = s.upd.checked.Format("02.01.2006 15:04")
|
||||||
|
}
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateRepoBase() (base, repo string) {
|
||||||
|
base, _ = s.db.GetConfig(cfgUpdateBase)
|
||||||
|
if base == "" {
|
||||||
|
base = DefaultUpdateBase
|
||||||
|
}
|
||||||
|
repo, _ = s.db.GetConfig(cfgUpdateRepo)
|
||||||
|
if repo == "" {
|
||||||
|
repo = DefaultUpdateRepo
|
||||||
|
}
|
||||||
|
return strings.TrimRight(base, "/"), repo
|
||||||
|
}
|
||||||
|
|
||||||
|
var updateClient = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
|
||||||
|
// latestRelease is the tag of the project's latest release on GitLab.
|
||||||
|
func (s *Server) latestRelease(ctx context.Context) (string, error) {
|
||||||
|
base, repo := s.updateRepoBase()
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+"/api/v4/projects/"+url.PathEscape(repo)+"/releases/permalink/latest", nil)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := updateClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("GitLab не отвечает: " + err.Error())
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
return "", errors.New("GitLab ответил " + resp.Status)
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Tag string `json:"tag_name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&out); err != nil || out.Tag == "" {
|
||||||
|
return "", errors.New("не прочиталась версия последнего релиза")
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(out.Tag, "v"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkRelease looks at GitLab and keeps what it found for the page.
|
||||||
|
func (s *Server) checkRelease(ctx context.Context) (string, error) {
|
||||||
|
tag, err := s.latestRelease(ctx)
|
||||||
|
s.upd.mu.Lock()
|
||||||
|
defer s.upd.mu.Unlock()
|
||||||
|
s.upd.checked = time.Now()
|
||||||
|
if err != nil {
|
||||||
|
s.upd.err = err.Error()
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s.upd.latest, s.upd.err = tag, ""
|
||||||
|
return tag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// installScript is the install script on the project's main branch.
|
||||||
|
func (s *Server) installScript(ctx context.Context) ([]byte, error) {
|
||||||
|
base, repo := s.updateRepoBase()
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+"/"+repo+"/-/raw/main/server-install.sh", nil)
|
||||||
|
resp, err := updateClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("скрипт установки не скачался: " + err.Error())
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode/100 != 2 {
|
||||||
|
return nil, errors.New("скрипт установки: GitLab ответил " + resp.Status)
|
||||||
|
}
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
// What is run as root must be the script, not a page of an error.
|
||||||
|
if !strings.HasPrefix(string(b), "#!/bin/sh\n") || !strings.Contains(string(b), "LPRA server") {
|
||||||
|
return nil, errors.New("по адресу скрипта установки лежит не он")
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startServerUpdate takes the install script and has it run, apart from
|
||||||
|
// this process: it stops the service, and a script in the service's own
|
||||||
|
// control group would go with it.
|
||||||
|
func (s *Server) startServerUpdate(ctx context.Context) error {
|
||||||
|
script, err := s.installScript(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
run := s.updRun
|
||||||
|
if run == nil {
|
||||||
|
run = runServerUpdate
|
||||||
|
}
|
||||||
|
return run(script)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runServerUpdate runs the script as a transient systemd unit of its own,
|
||||||
|
// in the folder this server lives in. Needs root and systemd.
|
||||||
|
func runServerUpdate(script []byte) error {
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
return errors.New("сервер запущен не от root - обновить его самому нельзя, запустите server-install.sh")
|
||||||
|
}
|
||||||
|
sr, err := exec.LookPath("systemd-run")
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("нет systemd-run - автообновление сервера работает только под systemd")
|
||||||
|
}
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(exe)
|
||||||
|
path := filepath.Join(dir, ".server-update.sh")
|
||||||
|
if err := os.WriteFile(path, script, 0o700); err != nil {
|
||||||
|
return errors.New("скрипт не записался: " + err.Error())
|
||||||
|
}
|
||||||
|
out, err := exec.Command(sr, "--unit=lpra-self-update", "--collect", "--setenv=LPRA_DIR="+dir,
|
||||||
|
"/bin/sh", "-c", "sh '"+path+"'; rm -f '"+path+"'").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
return fmt.Errorf("systemd-run: %s", strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoUpdateTick: one look at the clock. When the server is to look at
|
||||||
|
// GitLab (on, and the interval is out) it does, and keeps what it found.
|
||||||
|
func (s *Server) autoUpdateTick(now time.Time) {
|
||||||
|
on, min := s.serverAuto()
|
||||||
|
if !on {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.upd.mu.Lock()
|
||||||
|
if !s.upd.last.IsZero() && now.Sub(s.upd.last) < time.Duration(min)*time.Minute {
|
||||||
|
s.upd.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.upd.last = now
|
||||||
|
s.upd.mu.Unlock()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
_, _ = s.checkRelease(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunAutoUpdate watches the clock until ctx ends.
|
||||||
|
func (s *Server) RunAutoUpdate(ctx context.Context) {
|
||||||
|
t := time.NewTicker(30 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case now := <-t.C:
|
||||||
|
s.autoUpdateTick(now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/panel/update/check - looks at GitLab now.
|
||||||
|
func (s *Server) handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tag, err := s.checkRelease(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "error": err.Error(), "current": Version})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"ok": true, "current": Version, "latest": tag, "newer": proto.VersionNewer(tag, Version)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/panel/update/run - updates the server now, to a newer release.
|
||||||
|
func (s *Server) handleUpdateRun(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tag, err := s.checkRelease(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !proto.VersionNewer(tag, Version) {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "error": "уже последняя версия (" + Version + ")"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.startServerUpdate(r.Context()); err != nil {
|
||||||
|
writeJSON(w, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"ok": true, "latest": tag})
|
||||||
|
}
|
||||||
|
|
||||||
|
func declinedKey(id int64) string { return "update_declined_" + strconv.FormatInt(id, 10) }
|
||||||
|
|
||||||
|
// GET /api/panel/update/offer - whether this administrator is to be offered
|
||||||
|
// an update now: auto-update on, a release newer than this one, and not one
|
||||||
|
// they declined.
|
||||||
|
func (s *Server) handleUpdateOffer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
no := func() { writeJSON(w, map[string]any{"offer": false}) }
|
||||||
|
on, min := s.serverAuto()
|
||||||
|
if !on {
|
||||||
|
no()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Not looked at lately (just started, or the interval is out): look now.
|
||||||
|
s.upd.mu.Lock()
|
||||||
|
stale := s.upd.checked.IsZero() || time.Since(s.upd.checked) >= time.Duration(min)*time.Minute
|
||||||
|
s.upd.mu.Unlock()
|
||||||
|
if stale {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
_, _ = s.checkRelease(ctx)
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
st := s.updateStatus()
|
||||||
|
declined, _ := s.db.GetConfig(declinedKey(s.currentAdmin(r).ID))
|
||||||
|
if !st.Newer || declined == st.Latest {
|
||||||
|
no()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"offer": true, "current": st.Current, "latest": st.Latest})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/panel/update/decline - {version}: this administrator is not to
|
||||||
|
// be asked about that release again.
|
||||||
|
func (s *Server) handleUpdateDecline(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var in struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&in) == nil && len(in.Version) <= 32 {
|
||||||
|
_ = s.db.SetConfig(declinedKey(s.currentAdmin(r).ID), in.Version)
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeGitLab is the place releases come from: the latest release's tag and
|
||||||
|
// the install script on main.
|
||||||
|
type fakeGitLab struct {
|
||||||
|
srv *httptest.Server
|
||||||
|
mu sync.Mutex
|
||||||
|
tag string
|
||||||
|
script string
|
||||||
|
hits int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeGitLab(t *testing.T, tag string) *fakeGitLab {
|
||||||
|
g := &fakeGitLab{tag: tag, script: "#!/bin/sh\n# LPRA server: install or update in one command.\necho updating\n"}
|
||||||
|
g.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
g.hits++
|
||||||
|
switch r.URL.EscapedPath() {
|
||||||
|
case "/api/v4/projects/itzredtea%2Flpra/releases/permalink/latest":
|
||||||
|
// The real one sends the caller on to the release itself.
|
||||||
|
http.Redirect(w, r, "/api/v4/projects/1/releases/"+g.tag, http.StatusFound)
|
||||||
|
case "/api/v4/projects/1/releases/" + g.tag:
|
||||||
|
fmt.Fprintf(w, `{"tag_name":%q}`, g.tag)
|
||||||
|
case "/itzredtea/lpra/-/raw/main/server-install.sh":
|
||||||
|
fmt.Fprint(w, g.script)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(g.srv.Close)
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
func useFakeGitLab(s *Server, g *fakeGitLab) {
|
||||||
|
_ = s.db.SetConfig(cfgUpdateBase, g.srv.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A runner that only notes what it was given, in place of a real update.
|
||||||
|
type runs struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
scripts []string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runs) run(script []byte) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.scripts = append(r.scripts, string(script))
|
||||||
|
return r.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *runs) n() int { r.mu.Lock(); defer r.mu.Unlock(); return len(r.scripts) }
|
||||||
|
|
||||||
|
// postAuto sends the form of Настройки › Автообновление.
|
||||||
|
func postAuto(t *testing.T, s *Server, c *http.Cookie, f url.Values) {
|
||||||
|
t.Helper()
|
||||||
|
f.Set("csrf", "tok")
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/settings/autoupdate", 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The settings are kept in minutes whatever unit they were given in, and
|
||||||
|
// off unless asked for.
|
||||||
|
func TestAutoUpdateSettings(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
if on, _ := s.agentAuto(); on {
|
||||||
|
t.Fatal("agents update by themselves with nothing set")
|
||||||
|
}
|
||||||
|
postAuto(t, s, c, url.Values{"agent_auto": {"1"}, "agent_every": {"2"}, "agent_unit": {"h"}, "server_auto": {"1"}, "server_every": {"1"}, "server_unit": {"d"}})
|
||||||
|
if on, m := s.agentAuto(); !on || m != 120 {
|
||||||
|
t.Fatalf("agents: %v %d", on, m)
|
||||||
|
}
|
||||||
|
if on, m := s.serverAuto(); !on || m != 1440 {
|
||||||
|
t.Fatalf("server: %v %d", on, m)
|
||||||
|
}
|
||||||
|
// Out of range or not a number: the default, or the largest.
|
||||||
|
postAuto(t, s, c, url.Values{"agent_auto": {"1"}, "agent_every": {"x"}, "agent_unit": {"min"}, "server_every": {"999"}, "server_unit": {"d"}})
|
||||||
|
if _, m := s.agentAuto(); m != defaultAutoMin {
|
||||||
|
t.Fatalf("a bad number: %d", m)
|
||||||
|
}
|
||||||
|
if on, m := s.serverAuto(); on || m != maxAutoMin {
|
||||||
|
t.Fatalf("server off, a large number: %v %d", on, m)
|
||||||
|
}
|
||||||
|
body := get(t, s, c, "/settings?frag=1").Body.String()
|
||||||
|
if !strings.Contains(body, `id="autoupdate"`) || !strings.Contains(body, "Автообновление клиентов") || !strings.Contains(body, "Автообновление сервера") {
|
||||||
|
t.Fatal("the settings page has no auto-update card")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agents are told of it - on the config they fetch, and with their poll -
|
||||||
|
// together with the server's own version.
|
||||||
|
func TestAgentsToldOfAutoUpdate(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
if err := s.db.CreateAgent(Agent{ID: "a2", Name: "n", Hostname: "h", OS: "linux", Arch: "amd64", TokenHash: hashToken("tok2")}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg := func() http.Header {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/agent/config", nil)
|
||||||
|
req.Header.Set("X-Agent-Id", "a2")
|
||||||
|
req.Header.Set("Authorization", "Bearer tok2")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("config: %d", w.Code)
|
||||||
|
}
|
||||||
|
return w.Header()
|
||||||
|
}
|
||||||
|
h := cfg()
|
||||||
|
if h.Get(proto.AutoUpdateHeader) != "0" || h.Get(proto.ServerVersionHeader) != Version {
|
||||||
|
t.Fatalf("off: %v", h)
|
||||||
|
}
|
||||||
|
postAuto(t, s, c, url.Values{"agent_auto": {"1"}, "agent_every": {"90"}, "agent_unit": {"min"}})
|
||||||
|
if h := cfg(); h.Get(proto.AutoUpdateHeader) != "90" {
|
||||||
|
t.Fatalf("on: %v", h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The latest release is read from GitLab, through its redirect.
|
||||||
|
func TestLatestRelease(t *testing.T) {
|
||||||
|
s, _ := newTestServer(t)
|
||||||
|
g := newFakeGitLab(t, "9.9.9")
|
||||||
|
useFakeGitLab(s, g)
|
||||||
|
tag, err := s.latestRelease(t.Context())
|
||||||
|
if err != nil || tag != "9.9.9" {
|
||||||
|
t.Fatalf("latest: %q %v", tag, err)
|
||||||
|
}
|
||||||
|
g.srv.Close()
|
||||||
|
if _, err := s.latestRelease(t.Context()); err == nil {
|
||||||
|
t.Fatal("no error with GitLab out of reach")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With auto-update on the server looks at GitLab by itself, at the interval,
|
||||||
|
// and only looks: updating is the administrator's to say yes to.
|
||||||
|
func TestServerAutoUpdateTick(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
g := newFakeGitLab(t, "99.0.0")
|
||||||
|
useFakeGitLab(s, g)
|
||||||
|
r := &runs{}
|
||||||
|
s.updRun = r.run
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
s.autoUpdateTick(now)
|
||||||
|
if g.hits != 0 {
|
||||||
|
t.Fatalf("off, yet it looked: %d hits", g.hits)
|
||||||
|
}
|
||||||
|
postAuto(t, s, c, url.Values{"server_auto": {"1"}, "server_every": {"60"}, "server_unit": {"min"}})
|
||||||
|
s.autoUpdateTick(now)
|
||||||
|
if st := s.updateStatus(); st.Latest != "99.0.0" || !st.Newer {
|
||||||
|
t.Fatalf("it did not learn of the release: %+v", st)
|
||||||
|
}
|
||||||
|
hits := g.hits
|
||||||
|
s.autoUpdateTick(now.Add(10 * time.Minute))
|
||||||
|
if g.hits != hits {
|
||||||
|
t.Fatal("it looked again before the interval was out")
|
||||||
|
}
|
||||||
|
s.autoUpdateTick(now.Add(61 * time.Minute))
|
||||||
|
if g.hits == hits {
|
||||||
|
t.Fatal("it did not look when the interval was out")
|
||||||
|
}
|
||||||
|
if r.n() != 0 {
|
||||||
|
t.Fatalf("it updated by itself: %d runs", r.n())
|
||||||
|
}
|
||||||
|
// GitLab out of reach: told on the page, kept until it answers.
|
||||||
|
g.srv.Close()
|
||||||
|
s.autoUpdateTick(now.Add(130 * time.Minute))
|
||||||
|
if st := s.updateStatus(); st.Error == "" {
|
||||||
|
t.Fatalf("the failure is not kept: %+v", st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What an administrator is offered at login: a newer release, with
|
||||||
|
// auto-update on, not one they said no to - and again when a newer one comes.
|
||||||
|
func TestUpdateOffer(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
g := newFakeGitLab(t, "99.0.0")
|
||||||
|
useFakeGitLab(s, g)
|
||||||
|
offer := func() string { return apiCall(t, s, c, "GET", "/api/panel/update/offer", "") }
|
||||||
|
if got := offer(); !strings.Contains(got, `"offer":false`) {
|
||||||
|
t.Fatalf("auto-update off: %s", got)
|
||||||
|
}
|
||||||
|
postAuto(t, s, c, url.Values{"server_auto": {"1"}, "server_every": {"60"}, "server_unit": {"min"}})
|
||||||
|
got := offer()
|
||||||
|
if !strings.Contains(got, `"offer":true`) || !strings.Contains(got, `"latest":"99.0.0"`) || !strings.Contains(got, `"current":"`+Version+`"`) {
|
||||||
|
t.Fatalf("a newer release: %s", got)
|
||||||
|
}
|
||||||
|
apiCall(t, s, c, "POST", "/api/panel/update/decline", `{"version":"99.0.0"}`)
|
||||||
|
if got := offer(); !strings.Contains(got, `"offer":false`) {
|
||||||
|
t.Fatalf("after saying no: %s", got)
|
||||||
|
}
|
||||||
|
g.tag = "99.1.0"
|
||||||
|
s.upd.mu.Lock()
|
||||||
|
s.upd.checked = time.Time{}
|
||||||
|
s.upd.mu.Unlock()
|
||||||
|
if got := offer(); !strings.Contains(got, `"offer":true`) || !strings.Contains(got, `"latest":"99.1.0"`) {
|
||||||
|
t.Fatalf("a still newer release: %s", got)
|
||||||
|
}
|
||||||
|
g.tag = Version
|
||||||
|
s.upd.mu.Lock()
|
||||||
|
s.upd.checked = time.Time{}
|
||||||
|
s.upd.mu.Unlock()
|
||||||
|
if got := offer(); !strings.Contains(got, `"offer":false`) {
|
||||||
|
t.Fatalf("nothing newer: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anything but the install script is not run: a page of GitLab's, say.
|
||||||
|
func TestUpdateScriptChecked(t *testing.T) {
|
||||||
|
s, _ := newTestServer(t)
|
||||||
|
g := newFakeGitLab(t, "99.0.0")
|
||||||
|
g.script = "<html>502 Bad Gateway</html>"
|
||||||
|
useFakeGitLab(s, g)
|
||||||
|
r := &runs{}
|
||||||
|
s.updRun = r.run
|
||||||
|
if err := s.startServerUpdate(t.Context()); err == nil || r.n() != 0 {
|
||||||
|
t.Fatalf("a page was run as a script: %v, %d runs", err, r.n())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// From the panel: check now, and update now - only to a newer release.
|
||||||
|
func TestUpdatePanelButtons(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
g := newFakeGitLab(t, "99.0.0")
|
||||||
|
useFakeGitLab(s, g)
|
||||||
|
r := &runs{}
|
||||||
|
s.updRun = r.run
|
||||||
|
got := apiCall(t, s, c, "POST", "/api/panel/update/check", "")
|
||||||
|
if !strings.Contains(got, `"newer":true`) || !strings.Contains(got, `"latest":"99.0.0"`) || !strings.Contains(got, `"current":"`+Version+`"`) {
|
||||||
|
t.Fatalf("check: %s", got)
|
||||||
|
}
|
||||||
|
if got := apiCall(t, s, c, "POST", "/api/panel/update/run", ""); !strings.Contains(got, `"ok":true`) || r.n() != 1 {
|
||||||
|
t.Fatalf("run: %s, %d runs", got, r.n())
|
||||||
|
}
|
||||||
|
g.tag = Version
|
||||||
|
if got := apiCall(t, s, c, "POST", "/api/panel/update/run", ""); !strings.Contains(got, `"ok":false`) || r.n() != 1 {
|
||||||
|
t.Fatalf("run with nothing newer: %s, %d runs", got, r.n())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not for everybody: only an administrator updates the server.
|
||||||
|
func TestUpdateOnlyForAdmins(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
a, _ := s.db.AdminByUsername("adm")
|
||||||
|
if a == nil {
|
||||||
|
t.Fatal("no admin")
|
||||||
|
}
|
||||||
|
// The test's user is an administrator; the guard is the route's own.
|
||||||
|
if got := apiCall(t, s, c, "POST", "/api/panel/update/check", ""); strings.Contains(got, "только для администраторов") {
|
||||||
|
t.Fatalf("an administrator was refused: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A chat deleted takes with it what the user attached to it: the text of
|
||||||
|
// its files lives in the messages, and must be gone from the database
|
||||||
|
// files too - not just from the tables (SQLite leaves deleted rows in its
|
||||||
|
// free pages and in the write-ahead log).
|
||||||
|
func TestDeleteChatWipesAttachments(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "t.db")
|
||||||
|
db, err := OpenDB(path)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
adm, _ := db.AdminByUsername("adm")
|
||||||
|
me := adm.ID
|
||||||
|
const secret = "TOP-SECRET-CONTENT-OF-AN-ATTACHED-FILE-7731"
|
||||||
|
chat := s.aiNewChat(me)
|
||||||
|
other := s.aiNewChat(me)
|
||||||
|
s.aiAppendTo(me, chat, "user", "посмотри\n\n[Вложение: notes.txt]\nСодержимое файла:\n"+secret)
|
||||||
|
s.aiAppendTo(me, chat, "result", "Картинка готова [картинка: https://s2.x-grk.com/uploads/userphoto/ai/zzz-7731.png]")
|
||||||
|
s.aiAppendTo(me, other, "user", "другой чат, остаётся")
|
||||||
|
|
||||||
|
s.aiDeleteChat(me, chat)
|
||||||
|
|
||||||
|
var n int
|
||||||
|
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM ai_messages WHERE chat_id = ?`, chat).Scan(&n)
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("%d messages of the deleted chat remain", n)
|
||||||
|
}
|
||||||
|
_ = db.sql.QueryRow(`SELECT COUNT(*) FROM ai_messages WHERE chat_id = ?`, other).Scan(&n)
|
||||||
|
if n != 1 {
|
||||||
|
t.Fatalf("the other chat has %d messages, want 1", n)
|
||||||
|
}
|
||||||
|
for _, f := range []string{path, path + "-wal"} {
|
||||||
|
b, _ := os.ReadFile(f)
|
||||||
|
if bytes.Contains(b, []byte(secret)) || bytes.Contains(b, []byte("zzz-7731")) {
|
||||||
|
t.Fatalf("the deleted chat's content is still in %s", filepath.Base(f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The oldest chats go past the limit the same way: nothing of them is kept.
|
||||||
|
func TestOldChatsWipedToo(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "t.db")
|
||||||
|
db, err := OpenDB(path)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
adm, _ := db.AdminByUsername("adm")
|
||||||
|
me := adm.ID
|
||||||
|
first := s.aiNewChat(me)
|
||||||
|
s.aiAppendTo(me, first, "user", "[Вложение: a.txt]\nСодержимое файла:\nOLD-ATTACHMENT-TEXT-5512")
|
||||||
|
for i := 0; i < aiChatsKeep; i++ {
|
||||||
|
s.aiNewChat(me)
|
||||||
|
}
|
||||||
|
if s.aiOwns(me, first) {
|
||||||
|
t.Fatal("the oldest chat is kept past the limit")
|
||||||
|
}
|
||||||
|
for _, f := range []string{path, path + "-wal"} {
|
||||||
|
b, _ := os.ReadFile(f)
|
||||||
|
if bytes.Contains(b, []byte("OLD-ATTACHMENT-TEXT-5512")) {
|
||||||
|
t.Fatalf("the old chat's content is still in %s", filepath.Base(f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
-14
@@ -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
|
||||||
@@ -19,7 +20,7 @@ type DB struct {
|
|||||||
func OpenDB(path string) (*DB, error) {
|
func OpenDB(path string) (*DB, error) {
|
||||||
// _pragma busy_timeout avoids "database is locked" under the concurrent
|
// _pragma busy_timeout avoids "database is locked" under the concurrent
|
||||||
// long-poll load; foreign_keys keeps the schema honest.
|
// long-poll load; foreign_keys keeps the schema honest.
|
||||||
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)", path)
|
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=secure_delete(1)", path)
|
||||||
sdb, err := sql.Open("sqlite", dsn)
|
sdb, err := sql.Open("sqlite", dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -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) {
|
||||||
@@ -740,3 +754,15 @@ func boolInt(b bool) int {
|
|||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wipeDeleted makes what was deleted leave the database's files: with
|
||||||
|
// secure_delete the freed pages are overwritten, and the write-ahead log,
|
||||||
|
// which still holds the rows as they were written, is emptied (SQLite only;
|
||||||
|
// PostgreSQL cleans up for itself). The attachments of a chat deleted are
|
||||||
|
// among that.
|
||||||
|
func (db *DB) wipeDeleted() {
|
||||||
|
if db.sql.pg {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = db.sql.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -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`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 !s.aiOn(a.ID) {
|
||||||
|
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})
|
||||||
|
}
|
||||||
@@ -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 & 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-12
@@ -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)
|
||||||
@@ -127,20 +129,39 @@ func (m *forwardManager) bridgeHTTP(rule ForwardRule, conn net.Conn) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// write passes one message on; false: the stream is over.
|
||||||
|
write := func(out proto.OutMessage) bool {
|
||||||
|
if out.Exit {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if out.Data != "" {
|
||||||
|
b, err := base64.StdEncoding.DecodeString(out.Data)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, err := conn.Write(b); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-sess.Done():
|
|
||||||
return
|
|
||||||
case out, ok := <-sess.Out():
|
case out, ok := <-sess.Out():
|
||||||
if !ok || out.Exit {
|
if !ok || !write(out) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if out.Data != "" {
|
case <-sess.Done():
|
||||||
b, err := base64.StdEncoding.DecodeString(out.Data)
|
// The agent's end of stream closes the session at once, while
|
||||||
if err != nil {
|
// what it sent before may still be queued (a download read
|
||||||
return
|
// faster than the browser takes it): that goes out first.
|
||||||
}
|
for {
|
||||||
if _, err := conn.Write(b); err != nil {
|
select {
|
||||||
|
case out := <-sess.Out():
|
||||||
|
if !write(out) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// An http-lp download the agent has finished (its last chunk, then the end)
|
||||||
|
// reaches the browser whole: the session closing does not cut off what is
|
||||||
|
// still queued for it.
|
||||||
|
func TestForwardHTTPDrainsAfterClose(t *testing.T) {
|
||||||
|
hub := NewHub()
|
||||||
|
m := &forwardManager{hub: hub}
|
||||||
|
user, host := net.Pipe()
|
||||||
|
go m.bridgeHTTP(ForwardRule{AgentID: "a1", ClientPort: 80, Proto: "http"}, host)
|
||||||
|
|
||||||
|
var sess *Session
|
||||||
|
for i := 0; i < 100 && sess == nil; i++ {
|
||||||
|
hub.mu.Lock()
|
||||||
|
for _, s := range hub.sessions {
|
||||||
|
sess = s
|
||||||
|
}
|
||||||
|
hub.mu.Unlock()
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if sess == nil {
|
||||||
|
t.Fatal("no session")
|
||||||
|
}
|
||||||
|
want := bytes.Repeat([]byte("0123456789abcdef"), 4096) // 64 KiB in 64 chunks
|
||||||
|
go func() {
|
||||||
|
for i := 0; i < len(want); i += 1024 {
|
||||||
|
sess.PushOut(proto.OutMessage{Data: base64.StdEncoding.EncodeToString(want[i : i+1024])})
|
||||||
|
}
|
||||||
|
sess.PushOut(proto.OutMessage{Exit: true})
|
||||||
|
sess.close() // as the server does on the agent's end of stream
|
||||||
|
}()
|
||||||
|
time.Sleep(50 * time.Millisecond) // all of it queued, the session closed, before the browser reads
|
||||||
|
_ = user.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
got, _ := io.ReadAll(user)
|
||||||
|
if !bytes.Equal(got, want) {
|
||||||
|
t.Fatalf("got %d of %d bytes", len(got), len(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) {
|
|||||||
msg := s.hub.WaitControl(r.Context().Done(), a.ID, pollHold)
|
msg := s.hub.WaitControl(r.Context().Done(), a.ID, pollHold)
|
||||||
// Set after the hold so a setting changed meanwhile reaches the agent now.
|
// Set after the hold so a setting changed meanwhile reaches the agent now.
|
||||||
s.setTunnelHeader(w)
|
s.setTunnelHeader(w)
|
||||||
|
s.setUpdateHeaders(w)
|
||||||
w.Header().Set(proto.TransportHeader, s.transportOf(a.ID))
|
w.Header().Set(proto.TransportHeader, s.transportOf(a.ID))
|
||||||
writeJSON(w, msg)
|
writeJSON(w, msg)
|
||||||
}
|
}
|
||||||
@@ -83,6 +84,7 @@ func (s *Server) handleAgentConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.setTunnelHeader(w)
|
s.setTunnelHeader(w)
|
||||||
|
s.setUpdateHeaders(w)
|
||||||
w.Header().Set(proto.TransportHeader, s.transportOf(a.ID))
|
w.Header().Set(proto.TransportHeader, s.transportOf(a.ID))
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,12 @@ 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(),
|
||||||
|
"ImageKey": func() string { _, k := s.imageConf(); return k }(),
|
||||||
|
"Auto": s.autoView(),
|
||||||
|
"DBNow": dbNow,
|
||||||
|
"DBChosen": dbChosen,
|
||||||
"UfwInstalled": ufwInstalled,
|
"UfwInstalled": ufwInstalled,
|
||||||
"UfwActive": ufwActive,
|
"UfwActive": ufwActive,
|
||||||
"UfwRunnable": ufwRunnable,
|
"UfwRunnable": ufwRunnable,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/textproto"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pictures for the assistant: the model asks for one in its answer - a
|
||||||
|
// ```image block, a new picture or an edit of a photo - and the panel makes
|
||||||
|
// it through the Vertex API (https://s2.x-grk.com/api/vertex/docs). The
|
||||||
|
// work is a job there: it is put, then asked about until it is done. The
|
||||||
|
// API key is the admins', set in Настройки and never sent to the browser.
|
||||||
|
|
||||||
|
const (
|
||||||
|
cfgImageKey = "ai_image_key"
|
||||||
|
cfgImageURL = "ai_image_url" // not in the settings: the service's address, for tests
|
||||||
|
|
||||||
|
// DefaultImageURL is where the Vertex API is.
|
||||||
|
DefaultImageURL = "https://s2.x-grk.com"
|
||||||
|
|
||||||
|
imageMaxBytes = 20 << 20 // what the service takes of one photo
|
||||||
|
)
|
||||||
|
|
||||||
|
// imageConf is the service's address and the key (none: pictures are off).
|
||||||
|
func (s *Server) imageConf() (base, key string) {
|
||||||
|
base, _ = s.db.GetConfig(cfgImageURL)
|
||||||
|
if base == "" {
|
||||||
|
base = DefaultImageURL
|
||||||
|
}
|
||||||
|
key, _ = s.db.GetConfig(cfgImageKey)
|
||||||
|
return strings.TrimRight(base, "/"), strings.TrimSpace(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// imagePrompt tells the model it can make pictures, and how - when there
|
||||||
|
// is a key.
|
||||||
|
func (s *Server) imagePrompt() string {
|
||||||
|
if _, key := s.imageConf(); key == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "\n\nКартинки. Ты умеешь рисовать и править фото: сам, по смыслу разговора, без просьбы «вызови инструмент» и без лишних вопросов, " +
|
||||||
|
"когда пользователь просит нарисовать, сгенерировать, изобразить, сделать картинку, арт, логотип, обои, иллюстрацию " +
|
||||||
|
"или изменить, дорисовать, убрать, заменить что-то на фото.\n" +
|
||||||
|
"```image\nописание картинки\n``` - создать новую. Опиши сцену, стиль, свет, детали; язык описания любой.\n" +
|
||||||
|
"```image edit\nчто изменить на фото\n``` - править фото пользователя: то, что приложено к его сообщению " +
|
||||||
|
"(приложено несколько - после edit назови файл: ```image edit имя.jpg```); фото нет - последнюю созданную картинку.\n" +
|
||||||
|
"В первой строке блока можно задать формат: 1:1 (по умолчанию), 16:9, 9:16, 3:2, 2:3, 4:3, 3:4, auto - и качество: " +
|
||||||
|
"fast (по умолчанию, 1 кредит), artist (5, лучше), 0.2plus (10, лучшее); повышай качество, только если пользователь просит. " +
|
||||||
|
"Например: ```image 16:9 artist```.\n" +
|
||||||
|
"Сервис по умолчанию сам улучшает описание (дописывает детали и стиль) - это хорошо для короткого запроса. " +
|
||||||
|
"Если пользователь дал готовое, точное описание (промпт целиком, логотип «ровно как сказано», текст на картинке, строгая композиция) " +
|
||||||
|
"или просит ничего не добавлять - добавь noenhance: ```image noenhance 3:2```. Для правки фото это не применяется.\n" +
|
||||||
|
"Картинку покажет сама панель: ссылку в ответ не вставляй, скажи коротко, что получилось, и предложи поправить. " +
|
||||||
|
"Делается она до пары минут. Одно действие за ответ."
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageError says in Russian what an error code of the service means.
|
||||||
|
func imageError(code, msg string) string {
|
||||||
|
switch code {
|
||||||
|
case "unauthorized":
|
||||||
|
return "сервис не принял API-ключ - проверьте его в Настройки › ИИ-ассистент › Генерация изображений"
|
||||||
|
case "banned":
|
||||||
|
return "аккаунт сервиса картинок заблокирован"
|
||||||
|
case "insufficient_credits":
|
||||||
|
return "на аккаунте сервиса картинок не хватает кредитов"
|
||||||
|
case "daily_limit":
|
||||||
|
return "исчерпан дневной лимит картинок"
|
||||||
|
case "job_busy":
|
||||||
|
return "уже делается другая картинка - дождитесь её"
|
||||||
|
case "invalid_prompt":
|
||||||
|
return "сервис не принял описание (пустое или длиннее 4000 символов)"
|
||||||
|
case "invalid_image":
|
||||||
|
return "сервис не принял фото (нужен png, jpg или webp)"
|
||||||
|
case "image_too_large":
|
||||||
|
return "фото больше 20 МБ"
|
||||||
|
case "tokens_exhausted":
|
||||||
|
return "сервис картинок перегружен - попробуйте позже"
|
||||||
|
case "generation_failed", "edit_failed":
|
||||||
|
return "сервис не справился с картинкой (кредиты вернутся)"
|
||||||
|
case "source_missing":
|
||||||
|
return "исходное фото потерялось на сервисе - загрузите его заново"
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
msg = "сервис картинок ответил ошибкой"
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageClient = &http.Client{Timeout: 90 * time.Second}
|
||||||
|
|
||||||
|
// imageCall sends the request to the service with the key and reads its
|
||||||
|
// JSON; an error is words for the user.
|
||||||
|
func imageCall(ctx context.Context, req *http.Request, key string, v any) error {
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+key)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := imageClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("сервис картинок не отвечает: " + err.Error())
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
var e struct {
|
||||||
|
OK *bool `json:"ok"`
|
||||||
|
ErrorCode string `json:"error_code"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(b, &e) != nil {
|
||||||
|
return errors.New("сервис картинок ответил " + resp.Status)
|
||||||
|
}
|
||||||
|
if (e.OK != nil && !*e.OK) || resp.StatusCode/100 != 2 {
|
||||||
|
return errors.New(imageError(e.ErrorCode, e.Error))
|
||||||
|
}
|
||||||
|
return json.Unmarshal(b, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
imageRatios = map[string]bool{"1:1": true, "16:9": true, "9:16": true, "3:2": true, "2:3": true, "4:3": true, "3:4": true, "auto": true}
|
||||||
|
imageModels = map[string]bool{"fast": true, "artist": true, "0.2plus": true}
|
||||||
|
imageJobRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||||
|
imageExt = map[string]string{"image/png": "png", "image/jpeg": "jpg", "image/jpg": "jpg", "image/webp": "webp"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// POST /api/panel/ai/image - {mode: generate|edit, prompt, model, ratio,
|
||||||
|
// image: {mime, data (base64)} | image_url}: puts the job, answers its id.
|
||||||
|
func (s *Server) handleAIImage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fail := func(msg string) { writeJSON(w, map[string]any{"ok": false, "error": msg}) }
|
||||||
|
if !s.aiOn(s.currentAdmin(r).ID) {
|
||||||
|
fail("ассистент выключен в профиле")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base, key := s.imageConf()
|
||||||
|
if key == "" {
|
||||||
|
fail("не задан API-ключ сервиса картинок (Настройки › ИИ-ассистент › Генерация изображений)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Ratio string `json:"ratio"`
|
||||||
|
Enhance *bool `json:"enhance"` // the service improves the description; on unless false
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
Image struct {
|
||||||
|
MIME string `json:"mime"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
} `json:"image"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<20)).Decode(&in); err != nil {
|
||||||
|
fail("плохой запрос")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.Prompt = strings.TrimSpace(in.Prompt)
|
||||||
|
if in.Prompt == "" {
|
||||||
|
fail("пустое описание картинки")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r := []rune(in.Prompt); len(r) > 4000 {
|
||||||
|
in.Prompt = string(r[:4000])
|
||||||
|
}
|
||||||
|
var job struct {
|
||||||
|
JobID string `json:"job_id"`
|
||||||
|
Cost int `json:"cost"`
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
if in.Mode != "edit" {
|
||||||
|
if !imageModels[in.Model] {
|
||||||
|
in.Model = "fast"
|
||||||
|
}
|
||||||
|
if !imageRatios[in.Ratio] {
|
||||||
|
in.Ratio = "1:1"
|
||||||
|
}
|
||||||
|
enhance := in.Enhance == nil || *in.Enhance
|
||||||
|
b, _ := json.Marshal(map[string]any{"prompt": in.Prompt, "model": in.Model, "ratio": in.Ratio, "enhance": enhance})
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, base+"/api/vertex/v1/generate", bytes.NewReader(b))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if err := imageCall(ctx, req, key, &job); err != nil {
|
||||||
|
fail(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mime, data, err := s.imageSource(ctx, base, in.Image.MIME, in.Image.Data, in.ImageURL)
|
||||||
|
if err != nil {
|
||||||
|
fail(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&body)
|
||||||
|
h := textproto.MIMEHeader{}
|
||||||
|
h.Set("Content-Disposition", `form-data; name="image"; filename="photo.`+imageExt[mime]+`"`)
|
||||||
|
h.Set("Content-Type", mime)
|
||||||
|
part, _ := mw.CreatePart(h)
|
||||||
|
_, _ = part.Write(data)
|
||||||
|
_ = mw.WriteField("prompt", in.Prompt)
|
||||||
|
_ = mw.Close()
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, base+"/api/vertex/v1/edit", &body)
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
if err := imageCall(ctx, req, key, &job); err != nil {
|
||||||
|
fail(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if job.JobID == "" {
|
||||||
|
fail("сервис картинок не дал номер задачи")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"ok": true, "job_id": job.JobID, "cost": job.Cost})
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageSource is the photo to edit: sent by the browser, or a picture made
|
||||||
|
// before, taken from the service's own address (and from no other).
|
||||||
|
func (s *Server) imageSource(ctx context.Context, base, mime, b64, picURL string) (string, []byte, error) {
|
||||||
|
if picURL != "" {
|
||||||
|
u, err := url.Parse(picURL)
|
||||||
|
bu, _ := url.Parse(base)
|
||||||
|
if err != nil || bu == nil || u.Host != bu.Host || (u.Scheme != "https" && u.Scheme != "http") {
|
||||||
|
return "", nil, errors.New("прежнюю картинку можно править только с сервиса картинок")
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||||
|
resp, err := imageClient.Do(req)
|
||||||
|
if err != nil || resp.StatusCode/100 != 2 {
|
||||||
|
if resp != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
return "", nil, errors.New("прежняя картинка больше не открывается")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, imageMaxBytes+1))
|
||||||
|
mime = strings.ToLower(strings.SplitN(resp.Header.Get("Content-Type"), ";", 2)[0])
|
||||||
|
if imageExt[mime] == "" {
|
||||||
|
mime = "image/png"
|
||||||
|
}
|
||||||
|
return mime, data, nil
|
||||||
|
}
|
||||||
|
mime = strings.ToLower(mime)
|
||||||
|
if imageExt[mime] == "" {
|
||||||
|
return "", nil, errors.New("править можно png, jpg и webp")
|
||||||
|
}
|
||||||
|
data, err := base64.StdEncoding.DecodeString(b64)
|
||||||
|
if err != nil || len(data) == 0 {
|
||||||
|
return "", nil, errors.New("нет фото для правки: прикрепите его к сообщению")
|
||||||
|
}
|
||||||
|
if len(data) > imageMaxBytes {
|
||||||
|
return "", nil, errors.New("фото больше 20 МБ")
|
||||||
|
}
|
||||||
|
return mime, data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/panel/ai/image/{job}?wait=1..45 - the job's state, waiting up to
|
||||||
|
// wait seconds for it to end.
|
||||||
|
func (s *Server) handleAIImageJob(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fail := func(msg string) { writeJSON(w, map[string]any{"ok": false, "error": msg}) }
|
||||||
|
if !s.aiOn(s.currentAdmin(r).ID) {
|
||||||
|
fail("ассистент выключен в профиле")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base, key := s.imageConf()
|
||||||
|
id := r.PathValue("job")
|
||||||
|
if key == "" || !imageJobRe.MatchString(id) {
|
||||||
|
fail("нет такой задачи")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wait, _ := strconv.Atoi(r.URL.Query().Get("wait"))
|
||||||
|
if wait < 0 {
|
||||||
|
wait = 0
|
||||||
|
}
|
||||||
|
if wait > 45 {
|
||||||
|
wait = 45
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, base+"/api/vertex/v1/jobs/"+id+"?wait="+strconv.Itoa(wait), nil)
|
||||||
|
var st struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
PhotoURL string `json:"photo_url"`
|
||||||
|
Cost int `json:"cost"`
|
||||||
|
Balance int `json:"balance"`
|
||||||
|
ErrorCode string `json:"error_code"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if err := imageCall(r.Context(), req, key, &st); err != nil {
|
||||||
|
fail(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := map[string]any{"ok": true, "status": st.Status}
|
||||||
|
switch st.Status {
|
||||||
|
case "done":
|
||||||
|
out["photo_url"], out["cost"], out["balance"] = st.PhotoURL, st.Cost, st.Balance
|
||||||
|
case "failed":
|
||||||
|
out["error"] = imageError(st.ErrorCode, st.Error)
|
||||||
|
}
|
||||||
|
writeJSON(w, out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeVertex is the image service: it records what it was asked and answers
|
||||||
|
// like the real one (a job to poll, a picture when done).
|
||||||
|
type fakeVertex struct {
|
||||||
|
srv *httptest.Server
|
||||||
|
mu sync.Mutex
|
||||||
|
auth []string
|
||||||
|
gen map[string]any
|
||||||
|
edit struct {
|
||||||
|
prompt string
|
||||||
|
image []byte
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
polls int
|
||||||
|
failAs string // an error_code the job ends with
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeVertex(t *testing.T) *fakeVertex {
|
||||||
|
f := &fakeVertex{}
|
||||||
|
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.auth = append(f.auth, r.Header.Get("Authorization"))
|
||||||
|
// The pictures are open by their address, as at the real service.
|
||||||
|
if r.URL.Path == "/uploads/userphoto/ai/t.png" {
|
||||||
|
w.Header().Set("Content-Type", "image/png")
|
||||||
|
_, _ = w.Write([]byte("\x89PNG-original"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if r.Header.Get("Authorization") != "Bearer vx_good" {
|
||||||
|
w.WriteHeader(401)
|
||||||
|
fmt.Fprint(w, `{"ok":false,"error_code":"unauthorized","error":"Неверный ключ"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/api/vertex/v1/generate":
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&f.gen)
|
||||||
|
w.WriteHeader(202)
|
||||||
|
fmt.Fprint(w, `{"ok":true,"job_id":"job_gen1","status":"queued","cost":5}`)
|
||||||
|
case r.URL.Path == "/api/vertex/v1/edit":
|
||||||
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||||
|
w.WriteHeader(400)
|
||||||
|
fmt.Fprint(w, `{"ok":false,"error_code":"invalid_image","error":"нет фото"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.edit.prompt = r.FormValue("prompt")
|
||||||
|
file, hdr, err := r.FormFile("image")
|
||||||
|
if err == nil {
|
||||||
|
f.edit.image, _ = io.ReadAll(file)
|
||||||
|
f.edit.name = hdr.Filename
|
||||||
|
}
|
||||||
|
w.WriteHeader(202)
|
||||||
|
fmt.Fprint(w, `{"ok":true,"job_id":"job_edit1","status":"queued","cost":2}`)
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/api/vertex/v1/jobs/"):
|
||||||
|
f.polls++
|
||||||
|
if f.failAs != "" {
|
||||||
|
fmt.Fprintf(w, `{"ok":true,"job_id":"x","status":"failed","error_code":%q,"error":"сбой"}`, f.failAs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if f.polls < 2 {
|
||||||
|
fmt.Fprint(w, `{"ok":true,"job_id":"x","status":"running"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, `{"ok":true,"job_id":"x","status":"done","photo_url":"%s/uploads/userphoto/ai/t.png","cost":5,"balance":42}`, f.srv.URL)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(f.srv.Close)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// setImage points the panel at the fake service with a key.
|
||||||
|
func setImage(t *testing.T, s *Server, f *fakeVertex, key string) {
|
||||||
|
t.Helper()
|
||||||
|
_ = s.db.SetConfig(cfgImageURL, f.srv.URL)
|
||||||
|
_ = s.db.SetConfig(cfgImageKey, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiCall(t *testing.T, s *Server, c *http.Cookie, method, path, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||||
|
req.AddCookie(c)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(w, req)
|
||||||
|
return w.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A picture from nothing: the prompt, model and ratio go to the service with
|
||||||
|
// the key; the job's progress and its picture come back, the key never.
|
||||||
|
func TestImageGenerate(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
f := newFakeVertex(t)
|
||||||
|
setImage(t, s, f, "vx_good")
|
||||||
|
got := apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"cat in space","model":"artist","ratio":"3:4"}`)
|
||||||
|
if !strings.Contains(got, `"ok":true`) || !strings.Contains(got, `"job_id":"job_gen1"`) || strings.Contains(got, "vx_good") {
|
||||||
|
t.Fatalf("create: %s", got)
|
||||||
|
}
|
||||||
|
if f.gen["prompt"] != "cat in space" || f.gen["model"] != "artist" || f.gen["ratio"] != "3:4" || f.gen["enhance"] != true {
|
||||||
|
t.Fatalf("what the service got: %v", f.gen)
|
||||||
|
}
|
||||||
|
if got := apiCall(t, s, c, "GET", "/api/panel/ai/image/job_gen1?wait=1", ""); !strings.Contains(got, `"status":"running"`) {
|
||||||
|
t.Fatalf("first poll: %s", got)
|
||||||
|
}
|
||||||
|
got = apiCall(t, s, c, "GET", "/api/panel/ai/image/job_gen1?wait=1", "")
|
||||||
|
if !strings.Contains(got, `"status":"done"`) || !strings.Contains(got, "/uploads/userphoto/ai/t.png") || !strings.Contains(got, `"balance":42`) || strings.Contains(got, "vx_good") {
|
||||||
|
t.Fatalf("done: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown models and ratios are not sent on: the defaults go instead.
|
||||||
|
func TestImageGenerateDefaults(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
f := newFakeVertex(t)
|
||||||
|
setImage(t, s, f, "vx_good")
|
||||||
|
apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x","model":"gpt","ratio":"7:1"}`)
|
||||||
|
if f.gen["model"] != "fast" || f.gen["ratio"] != "1:1" {
|
||||||
|
t.Fatalf("defaults: %v", f.gen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editing sends the user's photo as a file with the instruction.
|
||||||
|
func TestImageEditFromAttachedPhoto(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
f := newFakeVertex(t)
|
||||||
|
setImage(t, s, f, "vx_good")
|
||||||
|
photo := []byte("\xff\xd8\xffJPEGDATA")
|
||||||
|
b, _ := json.Marshal(map[string]any{"mode": "edit", "prompt": "сделай закат",
|
||||||
|
"image": map[string]string{"mime": "image/jpeg", "data": base64.StdEncoding.EncodeToString(photo)}})
|
||||||
|
got := apiCall(t, s, c, "POST", "/api/panel/ai/image", string(b))
|
||||||
|
if !strings.Contains(got, `"job_id":"job_edit1"`) {
|
||||||
|
t.Fatalf("edit: %s", got)
|
||||||
|
}
|
||||||
|
if f.edit.prompt != "сделай закат" || !bytes.Equal(f.edit.image, photo) || !strings.HasSuffix(f.edit.name, ".jpg") {
|
||||||
|
t.Fatalf("what the service got: %q %q %q", f.edit.prompt, f.edit.image, f.edit.name)
|
||||||
|
}
|
||||||
|
// A format the service does not take is told so, not sent.
|
||||||
|
b, _ = json.Marshal(map[string]any{"mode": "edit", "prompt": "x", "image": map[string]string{"mime": "image/gif", "data": "R0lG"}})
|
||||||
|
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", string(b)); !strings.Contains(got, `"ok":false`) || !strings.Contains(got, "png") {
|
||||||
|
t.Fatalf("gif: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A picture made before can be edited again: fetched from the service's own
|
||||||
|
// address only - never from another.
|
||||||
|
func TestImageEditFromEarlierPicture(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
f := newFakeVertex(t)
|
||||||
|
setImage(t, s, f, "vx_good")
|
||||||
|
b, _ := json.Marshal(map[string]any{"mode": "edit", "prompt": "темнее", "image_url": f.srv.URL + "/uploads/userphoto/ai/t.png"})
|
||||||
|
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", string(b)); !strings.Contains(got, `"ok":true`) {
|
||||||
|
t.Fatalf("by url: %s", got)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(f.edit.image, []byte("\x89PNG-original")) || !strings.HasSuffix(f.edit.name, ".png") {
|
||||||
|
t.Fatalf("the picture sent: %q %q", f.edit.image, f.edit.name)
|
||||||
|
}
|
||||||
|
b, _ = json.Marshal(map[string]any{"mode": "edit", "prompt": "x", "image_url": "http://127.0.0.1:9/secret.png"})
|
||||||
|
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", string(b)); !strings.Contains(got, `"ok":false`) {
|
||||||
|
t.Fatalf("a foreign address was fetched: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What goes wrong is told in words: no key, a wrong key, the service's own
|
||||||
|
// codes, a job that failed.
|
||||||
|
func TestImageErrors(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
f := newFakeVertex(t)
|
||||||
|
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x"}`); !strings.Contains(got, "ключ") {
|
||||||
|
t.Fatalf("no key: %s", got)
|
||||||
|
}
|
||||||
|
setImage(t, s, f, "vx_bad")
|
||||||
|
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x"}`); !strings.Contains(got, `"ok":false`) || !strings.Contains(got, "ключ") {
|
||||||
|
t.Fatalf("wrong key: %s", got)
|
||||||
|
}
|
||||||
|
setImage(t, s, f, "vx_good")
|
||||||
|
f.failAs = "insufficient_credits"
|
||||||
|
if got := apiCall(t, s, c, "GET", "/api/panel/ai/image/job_gen1", ""); !strings.Contains(got, `"status":"failed"`) || !strings.Contains(got, "кредит") {
|
||||||
|
t.Fatalf("failed job: %s", got)
|
||||||
|
}
|
||||||
|
if got := apiCall(t, s, c, "GET", "/api/panel/ai/image/..%2Fme", ""); !strings.Contains(got, `"ok":false`) {
|
||||||
|
t.Fatalf("a job id out of shape: %s", got)
|
||||||
|
}
|
||||||
|
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":""}`); !strings.Contains(got, `"ok":false`) {
|
||||||
|
t.Fatalf("empty prompt: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The assistant off in the profile means no pictures either.
|
||||||
|
func TestImageNeedsAssistantOn(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
f := newFakeVertex(t)
|
||||||
|
setImage(t, s, f, "vx_good")
|
||||||
|
a, _ := s.db.AdminByUsername("adm")
|
||||||
|
_ = s.db.SetConfig(aiUserKey(a.ID), "0")
|
||||||
|
if got := apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x"}`); !strings.Contains(got, "выключен") {
|
||||||
|
t.Fatalf("off: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The key is saved from Настройки, shown back to the admin, and the chat
|
||||||
|
// learns only that pictures are on.
|
||||||
|
func TestImageKeySetting(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
postAIModels(t, s, c, [][7]string{{"", "M", "gradio", "", "", "", ""}})
|
||||||
|
f := url.Values{"csrf": {"tok"}, "m_type": {"gradio"}, "ai_image_key": {"vx_secret"}}
|
||||||
|
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"})
|
||||||
|
s.Handler().ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
if _, k := s.imageConf(); k != "vx_secret" {
|
||||||
|
t.Fatalf("key not saved: %q", k)
|
||||||
|
}
|
||||||
|
if body := get(t, s, c, "/settings?frag=1").Body.String(); !strings.Contains(body, `value="vx_secret"`) {
|
||||||
|
t.Fatal("the settings page does not show the key")
|
||||||
|
}
|
||||||
|
state := get(t, s, c, "/api/panel/ai").Body.String()
|
||||||
|
if strings.Contains(state, "vx_secret") || !strings.Contains(state, `"image":true`) {
|
||||||
|
t.Fatalf("state: %s", state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With a key the model is told it can draw; without, it is not.
|
||||||
|
func TestImagePromptOnlyWithKey(t *testing.T) {
|
||||||
|
s, _ := newTestServer(t)
|
||||||
|
if strings.Contains(s.imagePrompt(), "```image") {
|
||||||
|
t.Fatal("told about pictures with no key")
|
||||||
|
}
|
||||||
|
_ = s.db.SetConfig(cfgImageKey, "vx_good")
|
||||||
|
p := s.imagePrompt()
|
||||||
|
for _, want := range []string{"```image\n", "```image edit", "fast", "artist", "0.2plus", "16:9"} {
|
||||||
|
if !strings.Contains(p, want) {
|
||||||
|
t.Fatalf("the prompt lacks %q:\n%s", want, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The chat gives the model the image instructions when pictures are on.
|
||||||
|
func TestAIChatMentionsImageTool(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
var system string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var b map[string]any
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&b)
|
||||||
|
system, _ = b["messages"].([]any)[0].(map[string]any)["content"].(string)
|
||||||
|
fmt.Fprint(w, openAIChunk("content", "ok")+"data: [DONE]\n\n")
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
postAIModels(t, s, c, [][7]string{{"", "M", "openai", srv.URL, "m", "", ""}})
|
||||||
|
ask := func() {
|
||||||
|
apiCall(t, s, c, "POST", "/api/panel/ai/chat", `{"message":"нарисуй кота","context":"x"}`)
|
||||||
|
}
|
||||||
|
ask()
|
||||||
|
if strings.Contains(system, "```image") {
|
||||||
|
t.Fatal("image tool offered with no key")
|
||||||
|
}
|
||||||
|
_ = s.db.SetConfig(cfgImageKey, "vx_good")
|
||||||
|
ask()
|
||||||
|
if !strings.Contains(system, "```image") {
|
||||||
|
t.Fatalf("image tool not offered with a key:\n%s", system)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The description is improved by the service unless the model asks for it
|
||||||
|
// as it is.
|
||||||
|
func TestImageEnhanceSwitch(t *testing.T) {
|
||||||
|
s, c := newTestServer(t)
|
||||||
|
f := newFakeVertex(t)
|
||||||
|
setImage(t, s, f, "vx_good")
|
||||||
|
apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x"}`)
|
||||||
|
if f.gen["enhance"] != true {
|
||||||
|
t.Fatalf("by default: %v", f.gen)
|
||||||
|
}
|
||||||
|
apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x","enhance":false}`)
|
||||||
|
if f.gen["enhance"] != false {
|
||||||
|
t.Fatalf("switched off: %v", f.gen)
|
||||||
|
}
|
||||||
|
apiCall(t, s, c, "POST", "/api/panel/ai/image", `{"mode":"generate","prompt":"x","enhance":true}`)
|
||||||
|
if f.gen["enhance"] != true {
|
||||||
|
t.Fatalf("switched on: %v", f.gen)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
p := s.imagePrompt()
|
||||||
|
_ = s.db.SetConfig(cfgImageKey, "vx_good")
|
||||||
|
if !strings.Contains(s.imagePrompt(), "noenhance") {
|
||||||
|
t.Fatalf("the model is not told of the switch:\n%s", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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 !s.aiOn(a.ID) {
|
||||||
|
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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,6 +65,9 @@ type Server struct {
|
|||||||
wsAgents wsAgents // agents on their WebSocket now
|
wsAgents wsAgents // agents on their WebSocket now
|
||||||
pinMisses pinMisses // wrong PINs per session
|
pinMisses pinMisses // wrong PINs per session
|
||||||
assets map[string]string // static file -> hash of its content
|
assets map[string]string // static file -> hash of its content
|
||||||
|
|
||||||
|
upd updState // the server's own updating
|
||||||
|
updRun func(script []byte) error // runs the install script; the real one unless a test sets it
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds the server, parsing embedded templates.
|
// New builds the server, parsing embedded templates.
|
||||||
@@ -227,8 +230,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 +254,31 @@ 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 /api/panel/ai/image", s.requireAdmin(s.handleAIImage))
|
||||||
|
mux.Handle("GET /api/panel/ai/image/{job}", s.requireAdmin(s.handleAIImageJob))
|
||||||
|
mux.Handle("POST /settings/ai", s.adminsOnly(s.handleSetAI))
|
||||||
|
mux.Handle("POST /settings/autoupdate", s.adminsOnly(s.handleSetAutoUpdate))
|
||||||
|
mux.Handle("POST /api/panel/update/check", s.adminsOnly(s.handleUpdateCheck))
|
||||||
|
mux.Handle("GET /api/panel/update/offer", s.adminsOnly(s.handleUpdateOffer))
|
||||||
|
mux.Handle("POST /api/panel/update/decline", s.adminsOnly(s.handleUpdateDecline))
|
||||||
|
mux.Handle("POST /api/panel/update/run", s.adminsOnly(s.handleUpdateRun))
|
||||||
|
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 +308,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 +468,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+`"`)
|
||||||
|
|||||||
+86
-8
@@ -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,12 +290,19 @@ 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...) {
|
||||||
if err == nil && !res.OK {
|
task, del := proto.TaskFSDelTree, any(proto.FSPathArgs{Path: p})
|
||||||
err = errText(res.Error)
|
if q.FromConn != "" {
|
||||||
}
|
raw, _ := json.Marshal(proto.FSPathArgs{Path: p})
|
||||||
if err != nil {
|
task, del = proto.TaskSSHFSOp, proto.SSHFSArgs{Conn: q.FromConn, Op: "deltree", Args: raw}
|
||||||
return proto.TaskResult{Error: "отправлено, но здесь не удалилось: " + err.Error()}
|
}
|
||||||
|
res, err := s.waitTask(ctx, from, task, del, fsTaskTimeout)
|
||||||
|
if err == nil && !res.OK {
|
||||||
|
err = errText(res.Error)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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'> </p>
|
||||||
|
<div class="header">
|
||||||
|
DuckDuckGo
|
||||||
|
</div>
|
||||||
|
<p class='extra'> </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"> </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 >">
|
||||||
|
<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">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<input name="kl" value="wt-wt" type="hidden">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<p class='extra'> </p>
|
||||||
|
|
||||||
|
<table border="0">
|
||||||
|
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table border="0">
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Web results are present -->
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
|
1.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fgo.dev%2Fdoc%2Fdatabase%2Fcancel%2Doperations&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> </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> </td>
|
||||||
|
<td>
|
||||||
|
<span class='link-text'>go.dev/doc/database/cancel-operations</span>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td> </td>
|
||||||
|
<td> </td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
|
2.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fgolangtutorial.com%2Fgolang%2Dcontext%2F&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> </td>
|
||||||
|
<td class='result-snippet'>
|
||||||
|
Learn Go <b>context</b> with practical cancellation, timeout, HTTP request, and goroutine examples.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td> </td>
|
||||||
|
<td>
|
||||||
|
<span class='link-text'>golangtutorial.com/golang-context/</span>
|
||||||
|
|
||||||
|
|
||||||
|
<span class='timestamp'>2026-07-29T00:00:00.0000000</span>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td> </td>
|
||||||
|
<td> </td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
|
3.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fstackoverflow.com%2Fquestions%2F52799280%2Fcontext%2Dconfusion%2Dregarding%2Dcancellation&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> </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's good to always call <b>cancel</b> () function right after the <b>context</b> declaration. doesn't matter whether it's also ...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td> </td>
|
||||||
|
<td>
|
||||||
|
<span class='link-text'>stackoverflow.com/questions/52799280/context-confusion-regarding-cancellation</span>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td> </td>
|
||||||
|
<td> </td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
|
4.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fpkg.go.dev%2Fcontext&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> </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> </td>
|
||||||
|
<td>
|
||||||
|
<span class='link-text'>pkg.go.dev/context</span>
|
||||||
|
|
||||||
|
|
||||||
|
<span class='timestamp'>2026-10-08T00:00:00.0000000</span>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td> </td>
|
||||||
|
<td> </td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
|
5.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fstackoverflow.com%2Fquestions%2F70042646%2Fbest%2Dpractices%2Don%2Dgo%2Dcontext%2Dcancelation%2Dfunctions&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> </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> </td>
|
||||||
|
<td>
|
||||||
|
<span class='link-text'>stackoverflow.com/questions/70042646/best-practices-on-go-context-cancelation-functions</span>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td> </td>
|
||||||
|
<td> </td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
|
6.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fgolangbot.com%2Fcontext%2Dtimeout%2Dcancellation%2F&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> </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> </td>
|
||||||
|
<td>
|
||||||
|
<span class='link-text'>golangbot.com/context-timeout-cancellation/</span>
|
||||||
|
|
||||||
|
|
||||||
|
<span class='timestamp'>2026-07-08T00:00:00.0000000</span>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td> </td>
|
||||||
|
<td> </td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
|
7.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fgo.dev%2Fblog%2Fcontext&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> </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> </td>
|
||||||
|
<td>
|
||||||
|
<span class='link-text'>go.dev/blog/context</span>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td> </td>
|
||||||
|
<td> </td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
|
8.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.sohamkamani.com%2Fgolang%2Fcontext%2F&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> </td>
|
||||||
|
<td class='result-snippet'>
|
||||||
|
In this post, we'll learn about Go'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't waste resources. If used correctly, the <b>context</b> package can help you: <b>Cancel</b> long running processes ...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td> </td>
|
||||||
|
<td>
|
||||||
|
<span class='link-text'>www.sohamkamani.com/golang/context/</span>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td> </td>
|
||||||
|
<td> </td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
|
9.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fgolangdoc.github.io%2Fpkg%2F1.12%2Fcontext%2Findex.html&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> </td>
|
||||||
|
<td class='result-snippet'>
|
||||||
|
The returned <b>context'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's</b> Done channel is closed, whichever happens first.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td> </td>
|
||||||
|
<td>
|
||||||
|
<span class='link-text'>golangdoc.github.io/pkg/1.12/context/index.html</span>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td> </td>
|
||||||
|
<td> </td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
|
||||||
|
<td valign="top">
|
||||||
|
|
||||||
|
10.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a rel="nofollow" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.golinuxcloud.com%2Fgolang%2Dcontext%2F&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> </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> </td>
|
||||||
|
<td>
|
||||||
|
<span class='link-text'>www.golinuxcloud.com/golang-context/</span>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td> </td>
|
||||||
|
<td> </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 >">
|
||||||
|
<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">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<input name="kl" value="wt-wt" type="hidden">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p class='extra'> </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'> </p>
|
||||||
|
|
||||||
|
|
||||||
|
<img src="//duckduckgo.com/t/sl_l"/>
|
||||||
|
|
||||||
|
<div id='end-spacer'> </div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -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,919 @@ 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; }
|
||||||
|
/* A button has a line height of its own, and the name clips at its box: same as the text around. */
|
||||||
|
button.ai-history-file-name.link { padding:0; border:0; background:transparent; font-family:inherit; line-height:1.45; text-align:left; cursor:pointer; max-width:100%; justify-self:start; }
|
||||||
|
button.ai-history-file-name.link:hover { color:var(--accent); text-decoration:underline; text-underline-offset:2px; }
|
||||||
|
button.ai-history-file-name.link:focus-visible { outline:none; color:var(--accent); text-decoration:underline; }
|
||||||
|
.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; }
|
||||||
|
/* A picture the model made: shown whole, the result folded small under it. */
|
||||||
|
.ai-msg.result.pic { padding:0; border:0; display:grid; gap:6px; }
|
||||||
|
.ai-pic { display:block; overflow:hidden; border:1px solid var(--border); border-radius:12px; background:#0c0f13; line-height:0; }
|
||||||
|
.ai-pic img { display:block; width:100%; height:auto; max-height:440px; object-fit:contain; background:#0c0f13; }
|
||||||
|
.ai-pic.gone { padding:18px 12px; line-height:1.4; text-align:center; color:var(--muted); font-size:12.5px; }
|
||||||
|
|
||||||
|
.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:center; 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; }
|
||||||
|
/* A picture's or a search's text: a line folded, open up to --ai-fold-lines
|
||||||
|
lines and then it scrolls. */
|
||||||
|
.ai-code.foldable { --ai-fold-lines:6; }
|
||||||
|
.ai-code.foldable pre { line-height:1.45; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; cursor:pointer; word-break:normal; overflow-wrap:anywhere; }
|
||||||
|
.ai-code.foldable.open pre { white-space:pre-wrap; overflow:auto; max-height:calc(var(--ai-fold-lines) * 1.45em); text-overflow:clip; cursor:text; }
|
||||||
|
.ai-code.foldable.short pre { cursor:default; }
|
||||||
|
.ai-fold { flex:none; width:20px; height:20px; padding:0; margin:0 -2px 0 -4px; display:inline-flex; align-items:center; justify-content:center;
|
||||||
|
color:var(--muted); background:transparent; border:0; border-radius:6px; transition:color .15s, background-color .15s; }
|
||||||
|
.ai-fold:hover { color:var(--text); background:#1b222c; }
|
||||||
|
.ai-fold svg { transition:transform .2s ease; }
|
||||||
|
.ai-code.foldable.open .ai-fold svg { transform:rotate(90deg); }
|
||||||
|
.ai-code.foldable.short .ai-fold { display:none; }
|
||||||
|
/* Аккаунт › Вид. */
|
||||||
|
.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, .ai-search { min-width:0; margin:0; border:1px solid var(--border); border-radius:10px; }
|
||||||
|
/* Folded to a line: its name, and its kind and model quieter. */
|
||||||
|
.ai-model > summary.ai-model-sum, .ai-search > summary.ai-model-sum { min-width:0; padding:11px 12px; border-radius:10px; }
|
||||||
|
.ai-model > summary.ai-model-sum:hover, .ai-search > summary.ai-model-sum:hover { background:#151a21; }
|
||||||
|
.ai-model-title { flex:none; color:var(--text); font-size:14px; font-weight:550; }
|
||||||
|
.ai-model-sub { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--muted); font-size:12.5px; }
|
||||||
|
.ai-model-sub:not(:empty)::before { content:'· '; }
|
||||||
|
.ai-model-body { display:grid; gap:10px; min-width:0; padding:0 12px 12px; }
|
||||||
|
.ai-form label.check { display:inline-flex; align-items:center; gap:8px; margin:0; color:var(--text); font-size:14px; cursor:pointer; }
|
||||||
|
.ai-form label.check input { margin:0; }
|
||||||
|
.auto-every { display:flex; align-items:center; gap:8px; }
|
||||||
|
.auto-every input[type=number] { width:84px; flex:none; }
|
||||||
|
.auto-every select { width:auto; flex:none; }
|
||||||
|
.auto-status { font-size:13px; line-height:1.5; }
|
||||||
|
.auto-status .bad { color:var(--danger); }
|
||||||
|
.ai-model-head { display:flex; align-items:flex-end; gap:8px; margin:2px 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; }
|
||||||
@@ -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};
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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); }};
|
||||||
|
})();
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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
@@ -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};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
// 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);
|
||||||
|
// Автообновление: look at GitLab now, and update now.
|
||||||
|
const au = root.querySelector('#autoupdate .auto-check');
|
||||||
|
if (au) {
|
||||||
|
const st = root.querySelector('#autoupdate .auto-status'), run = root.querySelector('#autoupdate .auto-run');
|
||||||
|
const post = u => fetch(u, {method: 'POST', credentials: 'same-origin'}).then(r => r.json()).catch(() => ({ok: false, error: 'нет связи с сервером'}));
|
||||||
|
const say = (text, bad) => { st.textContent = ''; const s = document.createElement('span'); s.textContent = text; if (bad) s.className = 'bad'; st.appendChild(s); };
|
||||||
|
au.onclick = async () => {
|
||||||
|
au.disabled = true; say('Проверяю…');
|
||||||
|
const r = await post('/api/panel/update/check');
|
||||||
|
au.disabled = false;
|
||||||
|
if (!r.ok) { say(r.error, true); return; }
|
||||||
|
say('Версия сервера: ' + r.current + ' · на GitLab: ' + r.latest + (r.newer ? ' - вышла новая' : ' - это последняя'));
|
||||||
|
run.hidden = !r.newer;
|
||||||
|
};
|
||||||
|
run.onclick = async () => {
|
||||||
|
if (!await UI.confirm('Обновить сервер сейчас? Панель будет недоступна около минуты.', {ok: 'Обновить'})) return;
|
||||||
|
run.disabled = true; say('Запускаю обновление…');
|
||||||
|
const r = await post('/api/panel/update/run');
|
||||||
|
run.disabled = false;
|
||||||
|
if (!r.ok) { say(r.error, true); return; }
|
||||||
|
run.hidden = true;
|
||||||
|
say('Обновление до ' + r.latest + ' запущено: сервер перезапустится в течение минуты - потом обновите страницу.');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// ИИ-ассистент: 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();
|
||||||
|
});
|
||||||
|
// A folded card's line follows its fields: the name, the kind and model.
|
||||||
|
list.addEventListener('input', ev => {
|
||||||
|
const card = ev.target.closest('.ai-model');
|
||||||
|
if (!card) return;
|
||||||
|
const v = n => card.querySelector('[name=' + n + ']').value.trim();
|
||||||
|
card.querySelector('.ai-model-title').textContent = v('m_name') || 'Модель';
|
||||||
|
card.querySelector('.ai-model-sub').textContent = v('m_type') + (v('m_model') ? ' · ' + v('m_model') : '');
|
||||||
|
});
|
||||||
|
// A field to fix in a folded card: the card opens to show it.
|
||||||
|
list.closest('form').addEventListener('invalid', ev => {
|
||||||
|
const d = ev.target.closest('details');
|
||||||
|
if (d) d.open = true;
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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; });
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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};
|
||||||
|
})();
|
||||||
@@ -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; }};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// 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' : '');
|
||||||
|
dlg.querySelector('.ui-no').textContent = o.cancel || 'Отмена';
|
||||||
|
// «Отмена» 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);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// A new release of the server: an administrator is offered it when they come
|
||||||
|
// in - after the PIN, or at once when there is none - once for each tab. The
|
||||||
|
// window says the versions and has «Обновить» and «Отказаться»; a refusal is
|
||||||
|
// kept for that release (a still newer one asks again). Only when the server
|
||||||
|
// is set to look for releases (Настройки › Автообновление).
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
var root = document.documentElement;
|
||||||
|
var SHOWN = 'lpra.upd.shown';
|
||||||
|
|
||||||
|
// The PIN lock hides the page until it is unlocked.
|
||||||
|
function whenUnlocked(fn) {
|
||||||
|
if (!root.classList.contains('pin-wait')) { fn(); return; }
|
||||||
|
var mo = new MutationObserver(function () {
|
||||||
|
if (!root.classList.contains('pin-wait')) { mo.disconnect(); fn(); }
|
||||||
|
});
|
||||||
|
mo.observe(root, {attributes: true, attributeFilter: ['class']});
|
||||||
|
}
|
||||||
|
function toast(text, bad) {
|
||||||
|
if (window.FileViews && FileViews.toast) FileViews.toast(text, bad);
|
||||||
|
}
|
||||||
|
function post(url, body) {
|
||||||
|
return fetch(url, {method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'same-origin',
|
||||||
|
body: JSON.stringify(body || {})}).then(function (r) { return r.json(); })
|
||||||
|
.catch(function () { return {ok: false, error: 'нет связи с сервером'}; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function offer() {
|
||||||
|
try { if (sessionStorage.getItem(SHOWN) === '1') return; } catch (e) { /* offered again */ }
|
||||||
|
fetch('/api/panel/update/offer', {cache: 'no-store', credentials: 'same-origin'})
|
||||||
|
.then(function (r) { return r.ok ? r.json() : {}; })
|
||||||
|
.then(function (d) {
|
||||||
|
if (!d.offer || !window.UI) return;
|
||||||
|
try { sessionStorage.setItem(SHOWN, '1'); } catch (e) { /* not remembered */ }
|
||||||
|
UI.confirm('Вышла новая версия LPRA {chip}\nОбновить сервер сейчас? Панель будет недоступна около минуты.',
|
||||||
|
{ok: 'Обновить', cancel: 'Отказаться', chip: d.current + ' → ' + d.latest}).then(function (yes) {
|
||||||
|
if (!yes) { post('/api/panel/update/decline', {version: d.latest}); return; }
|
||||||
|
post('/api/panel/update/run').then(function (r) {
|
||||||
|
if (r.ok) toast('Обновление до ' + r.latest + ' запущено: сервер перезапустится в течение минуты - потом обновите страницу');
|
||||||
|
else toast(r.error || 'обновить не получилось', true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () { /* no offer */ });
|
||||||
|
}
|
||||||
|
whenUnlocked(offer);
|
||||||
|
})();
|
||||||
@@ -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'; };
|
||||||
})();
|
}};
|
||||||
+292
-316
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 => {
|
||||||
|
b.setAttribute('aria-expanded', 'false');
|
||||||
|
b.nextSibling.classList.remove('open');
|
||||||
|
b.nextSibling.inert = true;
|
||||||
|
});
|
||||||
|
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()))
|
||||||
|
.then(all => {
|
||||||
|
agentsCache = all;
|
||||||
|
box.innerHTML = '';
|
||||||
|
all.filter(a => !here || a.id !== here.id).forEach(a => {
|
||||||
|
const b = item(box, '<span class="ico"><span class="dot ' + (a.online ? 'ok' : 'off') + '"></span></span><span><b>' + esc(a.name) + '</b><small>' +
|
||||||
|
esc(a.user + '@' + a.hostname) + (a.online ? '' : ' · ' + esc(a.seen)) + '</small></span>',
|
||||||
|
() => { closeMenu(); openPane(kind, { id: a.id, name: a.name, host: a.hostname, user: a.user }); }, 'machine');
|
||||||
|
b.disabled = !a.online;
|
||||||
|
});
|
||||||
|
if (list.isConnected) placeMenu(anchor);
|
||||||
|
})
|
||||||
|
.catch(() => { box.innerHTML = '<div class="muted" style="padding:8px 10px">не загрузилось</div>'; });
|
||||||
|
});
|
||||||
|
g.setAttribute('aria-expanded', 'false');
|
||||||
const list = document.createElement('div');
|
const list = document.createElement('div');
|
||||||
list.className = 'mlist';
|
list.className = 'mlist';
|
||||||
list.innerHTML = '<div class="muted" style="padding:8px 10px;font-size:13px">загрузка…</div>';
|
list.inert = true;
|
||||||
addMenu.appendChild(list);
|
const inner = document.createElement('div');
|
||||||
(agentsCache ? Promise.resolve(agentsCache) : fetch('/api/panel/agents/presence', { cache: 'no-store' }).then(r => r.json()))
|
inner.className = 'mlist-inner';
|
||||||
.then(all => {
|
const content = document.createElement('div');
|
||||||
agentsCache = all;
|
content.className = 'mlist-content';
|
||||||
list.innerHTML = '';
|
inner.appendChild(content);
|
||||||
all.forEach(a => {
|
list.appendChild(inner);
|
||||||
const b = document.createElement('button');
|
g.after(list);
|
||||||
b.type = 'button'; b.className = 'item machine'; b.disabled = !a.online;
|
if (here) item(content, '<span class="ico">+</span><span><b>Здесь</b><small>' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '</small></span>',
|
||||||
b.innerHTML = '<span class="dot ' + (a.online ? 'ok' : 'off') + '"></span><span><b>' + esc(a.name) + '</b><small>' +
|
() => {
|
||||||
esc(a.user + '@' + a.hostname) + (a.online ? '' : ' · ' + esc(a.seen)) + '</small></span>';
|
closeMenu();
|
||||||
b.onclick = () => { closeMenu(); openTerminal({ id: a.id, name: a.name, host: a.hostname, user: a.user }); };
|
// A terminal from a machine's file pane starts in its folder, a
|
||||||
list.appendChild(b);
|
// file pane from a machine's terminal in the terminal's (asked of
|
||||||
});
|
// the machine; home if it cannot tell) - unless turned off.
|
||||||
if (!all.length) list.innerHTML = '<div class="muted" style="padding:8px 10px">машин нет</div>';
|
const on = !(window.Prefs && Prefs.get().openHere === 'off');
|
||||||
placeMenu(anchor);
|
let opts = null;
|
||||||
})
|
if (on && kind === 'term' && activePane.kind === 'files') opts = {dir: activePane.cwd};
|
||||||
.catch(() => { list.innerHTML = '<div class="muted" style="padding:8px 10px">не загрузилось</div>'; });
|
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('>_', 'Терминал', '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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// 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); search, fetch, and image
|
||||||
|
// (a picture to make or - with edit - to change; the first line may give
|
||||||
|
// its ratio, quality and the photo's file name).
|
||||||
|
function parseActions(text) {
|
||||||
|
const out = [], re = /```(run|read|ls|write|search|fetch|image)([^\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 === 'image') {
|
||||||
|
// ```image [edit] [1:1|16:9|…|auto] [fast|artist|0.2plus] [noenhance] [file name]
|
||||||
|
let rest = head, edit = false, enhance = true, ratio = '', model = '', file = '', t;
|
||||||
|
if ((t = /^edit(?=\s|$)\s*/.exec(rest))) { edit = true; rest = rest.slice(t[0].length); }
|
||||||
|
rest = rest.replace(/(?:^|\s)(\d+:\d+|auto)(?=\s|$)/, (_, v) => { ratio = v; return ' '; })
|
||||||
|
.replace(/(?:^|\s)(fast|artist|0\.2plus)(?=\s|$)/, (_, v) => { model = v; return ' '; })
|
||||||
|
.replace(/(?:^|\s)(noenhance|enhance)(?=\s|$)/, (_, v) => { enhance = v === 'enhance'; return ' '; });
|
||||||
|
file = rest.trim();
|
||||||
|
out.push({kind, machine: '', path: '', body, edit, enhance, ratio, model, file});
|
||||||
|
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 => ({'&': '&', '<': '<', '>': '>', '"': '"'}[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(/&/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);
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
'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 <x></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&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><script>x</script></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'}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseActions: image blocks - a new picture, or an edit, with format, quality and file name in the first line', () => {
|
||||||
|
assert.deepStrictEqual(U.parseActions('Рисую.\n```image\nкот в скафандре на луне\n```'),
|
||||||
|
[{kind: 'image', machine: '', path: '', body: 'кот в скафандре на луне', edit: false, enhance: true, ratio: '', model: '', file: ''}]);
|
||||||
|
assert.deepStrictEqual(U.parseActions('```image 16:9 artist\nзакат над морем\n```'),
|
||||||
|
[{kind: 'image', machine: '', path: '', body: 'закат над морем', edit: false, enhance: true, ratio: '16:9', model: 'artist', file: ''}]);
|
||||||
|
assert.deepStrictEqual(U.parseActions('```image edit my photo.jpg 0.2plus\nубери человека справа\n```'),
|
||||||
|
[{kind: 'image', machine: '', path: '', body: 'убери человека справа', edit: true, enhance: true, ratio: '', model: '0.2plus', file: 'my photo.jpg'}]);
|
||||||
|
assert.deepStrictEqual(U.parseActions('```image edit\nсделай ночь\n```')[0].file, '');
|
||||||
|
assert.strictEqual(U.parseActions('```image\nне закрыт').length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseActions: image blocks - noenhance asks for the description as it is', () => {
|
||||||
|
assert.deepStrictEqual(U.parseActions('```image noenhance 3:2\nflat vector logo, white background\n```'),
|
||||||
|
[{kind: 'image', machine: '', path: '', body: 'flat vector logo, white background', edit: false, enhance: false, ratio: '3:2', model: '', file: ''}]);
|
||||||
|
assert.strictEqual(U.parseActions('```image enhance\nx\n```')[0].enhance, true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
{{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----- …вставьте ключ или загрузите файл"></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 src="{{asset "updatenote.js"}}"></script>
|
||||||
|
<script>App.start();</script>
|
||||||
|
</body>
|
||||||
|
</html>{{end}}
|
||||||
@@ -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>
|
|
||||||
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user