Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
dist
|
||||
*.db
|
||||
.DS_Store
|
||||
*/.DS_Store
|
||||
@@ -12,9 +12,14 @@
|
||||
```
|
||||
|
||||
## Возможности
|
||||
- **Терминал** - вкладки на нескольких машинах сразу, до четырёх терминалов в одной вкладке
|
||||
(перетаскиванием, с изменяемыми размерами), переименование вкладок. На телефоне - панель
|
||||
клавиш (Esc, Ctrl, Alt, стрелки и т.д.).
|
||||
- **Одна страница** - Машины, WorkSpace (терминалы и файлы), Настройки; переходы без перезагрузки.
|
||||
Вкладки WorkSpace живут, пока открыта вкладка браузера: уход на другие страницы их не
|
||||
закрывает, перезагрузка сбрасывает (браузер сначала спросит). «Порты», «Статус» и «Изменить»
|
||||
машины открываются окном поверх текущей страницы - из списка машин или правым кликом
|
||||
по заголовку вкладки или панели.
|
||||
- **WorkSpace** - вкладки на нескольких машинах сразу, до четырёх панелей в одной вкладке -
|
||||
терминал или файловый менеджер (перетаскиванием, с изменяемыми размерами), переименование
|
||||
вкладок. На телефоне - панель клавиш (Esc, Ctrl, Alt, стрелки и т.д.).
|
||||
- **SSH-терминал** - в меню «+»: агент открывает SSH-соединение к другому хосту (по паролю,
|
||||
ключу - можно загрузить файл - или своими ключами из `~/.ssh`), шелл открывается во вкладке,
|
||||
а шаги подключения видны в статусе. Ключ хоста запоминается при первом входе
|
||||
@@ -22,10 +27,44 @@
|
||||
- **Файлы** - просмотр, редактор с подсветкой, hex-редактор, предпросмотр картинок, видео, PDF,
|
||||
баз SQLite с правкой (значение по клику, добавление и удаление строк, вкладка SQL; агент меняет базу через сам SQLite, так что работающая база не ломается); загрузка и скачивание до 512 МБ кусками по 4 МБ (проходит через Cloudflare);
|
||||
свойства (chmod/chown), zip/unzip.
|
||||
- **Файлы по SSH** - в меню «+» › «Файловый менеджер» › «SSH-хост…»: машина подключается к хосту
|
||||
и работает с файлами командами по SSH, SFTP не нужен. Есть всё, кроме редактора SQLite;
|
||||
zip и распаковка - если на хосте установлены `zip` и `unzip`. Оборвалось подключение -
|
||||
панель предложит переподключиться.
|
||||
- **Перетаскивание файлов** - строку (или все отмеченные) в другую файловую панель или в папку
|
||||
в ней: окно «Переместить / Копировать» у места броска, между машинами и SSH-хостами - с
|
||||
прогрессом. В терминал той же машины или того же SSH-хоста - вставляется путь в кавычках,
|
||||
в терминал другой - нельзя, с подсказкой почему. Задержать над заголовком вкладки - она
|
||||
откроется. Только на компьютере.
|
||||
- **Перемещение и копирование** - внутри машины или, кнопкой «сеть» в том же окне, **на другую
|
||||
машину или на 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` через прямой туннель.
|
||||
- **Статус** - нагрузка, память, диски, сеть, тест скорости канала до сервера.
|
||||
- **Вход** - пароль и/или ключи доступа (passkeys); пароль можно выключить, когда есть ключ.
|
||||
@@ -42,6 +81,15 @@
|
||||
Там же галочка «Пароль» включает или выключает вход по паролю (выключить можно, когда у человека есть ключ доступа),
|
||||
а «Закрепить» не даёт ему поменять это самому. Последнего администратора нельзя удалить или понизить.
|
||||
|
||||
## База данных
|
||||
По умолчанию - SQLite (файл из `-db`, по умолчанию `lpra.db`). В «Настройки › База данных» можно выбрать
|
||||
PostgreSQL: хост, порт, база, пользователь, пароль, SSL; кнопка «Проверить и сохранить» подключается и
|
||||
записывает выбор в `<база>.backend.json` рядом с SQLite-файлом (права 0600), «Перезапустить сервер»
|
||||
завершает процесс - systemd (`Restart=always`) поднимает его уже на PostgreSQL. При первом запуске на
|
||||
пустой PostgreSQL все данные копируются из SQLite-файла, сам файл остаётся как был (к нему можно
|
||||
вернуться тем же выбором). История ИИ-ассистента тоже хранится в базе - у каждого пользователя
|
||||
последние 200 сообщений.
|
||||
|
||||
## Компоненты
|
||||
- **Сервер** (`cmd/server`) - панель и точка встречи для агентов. Ставится за nginx (TLS).
|
||||
- **Агент** (`cmd/agent`) - бинарь для Linux (amd64/arm64), systemd-сервис от root.
|
||||
@@ -54,6 +102,29 @@
|
||||
Получаются `bin/lpra-server*` и `dist/agent-linux-{amd64,arm64}`.
|
||||
Версия задаётся в одном месте - `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
|
||||
./bin/lpra-server -addr 127.0.0.1:8091 -dist ./dist -db ./lpra.db
|
||||
@@ -121,7 +192,8 @@ curl -fsSL https://panel.example/install.sh | sudo HOST=https://panel.example EN
|
||||
- **lp** - только long-polling.
|
||||
|
||||
## Проброс портов и прямой туннель
|
||||
Правила создаются на странице машины «Порты». Режим `http` идёт через адрес панели,
|
||||
Правила создаются на странице машины «Порты». Режим `http` (в панели - http-lp) идёт через
|
||||
соединение агента с панелью: по WebSocket, а если он недоступен - по long-polling;
|
||||
`tcp`/`udp` - через прямой туннель: агент держит исходящее соединение к порту туннеля хоста,
|
||||
по нему мультиплексируются все соединения.
|
||||
|
||||
@@ -148,10 +220,10 @@ curl -fsSL https://panel.example/install.sh | sudo HOST=https://panel.example EN
|
||||
- Открытие терминала и SSH, перенос файлов на другие машины, смена пользователя пишутся в `audit`.
|
||||
|
||||
## Скриншоты
|
||||
<img width="1256" height="709" alt="image" src="https://github.com/user-attachments/assets/5f0b30f1-d5e3-4b62-817e-4041641df6e3" />
|
||||
<img width="1256" height="709" alt="image" src="docs/screenshots/1.png" />
|
||||
|
||||
<img width="1254" height="532" alt="image" src="https://github.com/user-attachments/assets/0207a7f0-2b59-40ee-abd1-d8673059cc24" />
|
||||
<img width="1254" height="532" alt="image" src="docs/screenshots/2.png" />
|
||||
|
||||
<img width="1255" height="526" alt="image" src="https://github.com/user-attachments/assets/4dcbe907-33f5-493e-a56f-01cd8bf0fea1" />
|
||||
<img width="1255" height="526" alt="image" src="docs/screenshots/3.png" />
|
||||
|
||||
<img width="1260" height="654" alt="image" src="https://github.com/user-attachments/assets/fdf59733-8e5d-46e3-b232-235ddfe1be73" />
|
||||
<img width="1260" height="654" alt="image" src="docs/screenshots/4.png" />
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.1.2
|
||||
0.2.4
|
||||
+1
-1
@@ -26,7 +26,7 @@ func main() {
|
||||
"do not mark cookies Secure (plain-HTTP local testing only)")
|
||||
flag.Parse()
|
||||
|
||||
db, err := server.OpenDB(*dbPath)
|
||||
db, err := server.OpenStore(*dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open db: %v", err)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
# "Connection: upgrade" for a WebSocket, "close" for everything else.
|
||||
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8445 ssl;
|
||||
server_name REDACTED;
|
||||
listen 443 ssl;
|
||||
server_name example.ru;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/REDACTED/fullchain.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
|
||||
# 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 / {
|
||||
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 (
|
||||
github.com/coder/websocket v1.8.12
|
||||
github.com/creack/pty v1.1.21
|
||||
golang.org/x/crypto v0.31.0
|
||||
golang.org/x/sys v0.28.0
|
||||
github.com/jackc/pgx/v5 v5.7.5
|
||||
golang.org/x/crypto v0.37.0
|
||||
golang.org/x/sys v0.32.0
|
||||
modernc.org/sqlite v1.24.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // 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/mattn/go-isatty v0.0.16 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/mod v0.3.0 // indirect
|
||||
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
golang.org/x/mod v0.17.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // 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
|
||||
modernc.org/cc/v3 v3.40.0 // 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/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
|
||||
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/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
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/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
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/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/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
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-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
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=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
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.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78 h1:M8tBwCtWD/cZV9DZpFYRUgaymAYAr+aIUTWzDaM3uPs=
|
||||
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
|
||||
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
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/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
|
||||
|
||||
@@ -330,7 +330,7 @@ func (a *Agent) pollOnce(ctx, reqCtx context.Context) error {
|
||||
func (a *Agent) dispatch(ctx context.Context, msg proto.ControlMessage) {
|
||||
switch msg.Type {
|
||||
case proto.CtrlOpenTerminal:
|
||||
go a.runTerminal(ctx, msg.SessionID, msg.User)
|
||||
go a.runTerminal(ctx, msg.SessionID, msg.User, msg.Dir)
|
||||
case proto.CtrlOpenForward:
|
||||
go a.runForward(ctx, msg.SessionID, msg.Port)
|
||||
case proto.CtrlTask:
|
||||
|
||||
+10
-2
@@ -114,13 +114,21 @@ func fsList(path string) (proto.FSListResult, error) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, proto.FSEntry{
|
||||
ent := proto.FSEntry{
|
||||
Name: e.Name(),
|
||||
IsDir: e.IsDir(),
|
||||
Size: info.Size(),
|
||||
Mode: info.Mode().String(),
|
||||
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.
|
||||
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})
|
||||
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.
|
||||
// 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) {
|
||||
term, err := StartPTY(user)
|
||||
func (a *Agent) runTerminal(parent context.Context, sid, user, dir string) {
|
||||
term, err := StartPTY(user, dir)
|
||||
if err != nil {
|
||||
log.Printf("term %s: start pty: %v", sid, err)
|
||||
// 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
|
||||
}
|
||||
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)
|
||||
log.Printf("term %s: ended", sid)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
d := net.Dialer{Timeout: 10 * time.Second}
|
||||
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.
|
||||
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.
|
||||
|
||||
+112
-17
@@ -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
|
||||
// left out: the numbers mean other people on another machine.
|
||||
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)
|
||||
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 {
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -152,11 +169,8 @@ func packTar(ctx context.Context, w io.Writer, src, name string, m *shipMeter) e
|
||||
m.files.Add(1)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tw.Close()
|
||||
}
|
||||
|
||||
type countReader struct {
|
||||
r io.Reader
|
||||
@@ -175,11 +189,33 @@ func (a *Agent) doShipOut(parent context.Context, env proto.TaskEnvelope) proto.
|
||||
if err := json.Unmarshal(env.Args, &args); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
if err := shipName(args.Name); err != nil {
|
||||
names := args.ShipNames()
|
||||
for _, n := range names {
|
||||
if err := shipName(n); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
}
|
||||
if len(args.More) > 0 && args.SSH != nil {
|
||||
return proto.TaskResult{Error: "по SSH-адресу - по одному"}
|
||||
}
|
||||
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()}
|
||||
}
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
@@ -187,14 +223,28 @@ func (a *Agent) doShipOut(parent context.Context, env proto.TaskEnvelope) proto.
|
||||
m := &shipMeter{}
|
||||
m.note.Store("подсчёт")
|
||||
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
|
||||
if args.SSH != nil {
|
||||
err = a.shipSSH(ctx, args, src, m)
|
||||
} else {
|
||||
m.note.Store("")
|
||||
err = a.shipRelay(ctx, args, src, m)
|
||||
err = a.shipRelay(ctx, args, pack)
|
||||
}
|
||||
if 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
|
||||
// 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 {
|
||||
return errors.New("не задана передача")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
pr, pw := io.Pipe()
|
||||
go func() { pw.CloseWithError(packTar(ctx, pw, src, args.Name, m)) }()
|
||||
go func() { pw.CloseWithError(pack(pw)) }()
|
||||
defer pr.Close()
|
||||
|
||||
type piece struct {
|
||||
@@ -302,16 +352,31 @@ func (a *Agent) doShipIn(ctx context.Context, env proto.TaskEnvelope) proto.Task
|
||||
if err := json.Unmarshal(env.Args, &args); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
if err := shipName(args.Name); err != nil {
|
||||
names := args.ShipNames()
|
||||
for _, n := range names {
|
||||
if err := shipName(n); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
}
|
||||
dir := filepath.Clean(args.Dir)
|
||||
if _, err := os.Lstat(filepath.Join(dir, args.Name)); err == nil && args.Conflict != "replace" && args.Conflict != "skip" {
|
||||
return proto.TaskResult{Error: filepath.Join(dir, args.Name) + " уже существует"}
|
||||
var conn *sshfsConn
|
||||
if args.Conn != "" {
|
||||
// Into an SSH host this agent holds a connection to.
|
||||
c, err := sshfsGet(args.Conn)
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
conn, dir = c, path.Clean(args.Dir)
|
||||
} else {
|
||||
for _, n := range names {
|
||||
if _, err := os.Lstat(filepath.Join(dir, n)); err == nil && args.Conflict != "replace" && args.Conflict != "skip" {
|
||||
return proto.TaskResult{Error: filepath.Join(dir, n) + " уже существует"}
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
pr, pw := io.Pipe()
|
||||
@@ -339,7 +404,27 @@ func (a *Agent) doShipIn(ctx context.Context, env proto.TaskEnvelope) proto.Task
|
||||
}
|
||||
}
|
||||
}()
|
||||
u := &unpacker{dir: dir, name: args.Name, conflict: args.Conflict}
|
||||
if conn != nil {
|
||||
skipped, err := a.unpackRemote(conn, pr, dir, names, args.Conflict)
|
||||
if err == nil {
|
||||
_, _ = io.Copy(io.Discard, pr)
|
||||
}
|
||||
pr.CloseWithError(errors.New("распаковка закончена"))
|
||||
if err != nil {
|
||||
return proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
res := map[string]int{}
|
||||
if skipped > 0 {
|
||||
res["skipped"] = skipped
|
||||
}
|
||||
data, _ := json.Marshal(res)
|
||||
return proto.TaskResult{OK: true, Data: data}
|
||||
}
|
||||
allowed := map[string]bool{}
|
||||
for _, n := range names {
|
||||
allowed[n] = true
|
||||
}
|
||||
u := &unpacker{dir: dir, names: allowed, conflict: args.Conflict}
|
||||
if os.Geteuid() == 0 {
|
||||
// New things belong to whoever owns the folder they land in.
|
||||
if info, err := os.Stat(dir); err == nil {
|
||||
@@ -370,7 +455,8 @@ func (a *Agent) doShipIn(ctx context.Context, env proto.TaskEnvelope) proto.Task
|
||||
// the conflict rule for what is there already: folders merge, and files are
|
||||
// replaced or kept.
|
||||
type unpacker struct {
|
||||
dir, name, conflict string
|
||||
dir, conflict string
|
||||
names map[string]bool // the top entries the stream may carry
|
||||
uid, gid int
|
||||
chown bool
|
||||
done, skipped int
|
||||
@@ -387,7 +473,8 @@ func (u *unpacker) unpack(tr *tar.Reader) error {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
}
|
||||
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.
|
||||
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.
|
||||
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) })
|
||||
@@ -528,7 +623,7 @@ func (a *Agent) shipSSH(ctx context.Context, args proto.FSShipArgs, src string,
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
m.note.Store("")
|
||||
@@ -559,7 +654,7 @@ func sshRun(c *ssh.Client, cmd string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
defer s.Close()
|
||||
out, err := s.Output(cmd)
|
||||
out, err := s.Output(posix(cmd))
|
||||
var ee *ssh.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
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)
|
||||
case proto.TaskFSSend:
|
||||
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:
|
||||
res = a.doShipOut(ctx, env)
|
||||
case proto.TaskFSShipIn:
|
||||
@@ -107,3 +113,15 @@ func itoa64(n int64) string {
|
||||
}
|
||||
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"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -41,8 +43,12 @@ func CloseTerminals() {
|
||||
// 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,
|
||||
// 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.
|
||||
func StartPTY(user string) (*PTY, error) {
|
||||
// `su -l`, which only a root agent can do without a password. dir, when it
|
||||
// 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
|
||||
env := append(os.Environ(), "TERM=xterm-256color")
|
||||
if user == "" || user == currentUser() {
|
||||
@@ -54,6 +60,9 @@ func StartPTY(user string) (*PTY, error) {
|
||||
cmd.Dir = home
|
||||
env = append(env, "HOME="+home)
|
||||
}
|
||||
if dir != "" {
|
||||
cmd.Dir = dir
|
||||
}
|
||||
} else {
|
||||
if !validUserName(user) {
|
||||
return nil, fmt.Errorf("недопустимое имя пользователя %q", user)
|
||||
@@ -69,6 +78,11 @@ func StartPTY(user string) (*PTY, error) {
|
||||
return nil, fmt.Errorf("su не найден: %w", err)
|
||||
}
|
||||
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
|
||||
// out of "/" should it not.
|
||||
if home := homeDir(user); isDir(home) {
|
||||
@@ -126,3 +140,9 @@ func (p *PTY) Close() error {
|
||||
})
|
||||
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)
|
||||
case proto.TaskDBTables, proto.TaskDBRows, proto.TaskDBSet, proto.TaskDBAdd, proto.TaskDBDel, proto.TaskDBSQL:
|
||||
return dbTask(env)
|
||||
case proto.TaskTermCwd:
|
||||
var a proto.TermCwdArgs
|
||||
_ = json.Unmarshal(env.Args, &a)
|
||||
return termCwd(a.Session)
|
||||
}
|
||||
return dispatchFS(env)
|
||||
}
|
||||
|
||||
+54
-5
@@ -1,15 +1,17 @@
|
||||
// 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
|
||||
// survives NAT and restrictive egress firewalls: the agent only ever makes
|
||||
// outbound requests.
|
||||
// All agent<->server traffic is plain JSON, on a WebSocket the agent opens or,
|
||||
// where that does not get through, over HTTP(S) long polls, so it survives
|
||||
// NAT and restrictive egress firewalls: the agent only ever connects out.
|
||||
package proto
|
||||
|
||||
import "strings"
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// Control poll message types (server -> agent, returned from /api/agent/poll).
|
||||
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"
|
||||
// CtrlOpenTerminal tells the agent to spawn a PTY for the session id.
|
||||
CtrlOpenTerminal = "open_terminal"
|
||||
@@ -33,6 +35,9 @@ type SSHTarget struct {
|
||||
Password string `json:"password,omitempty"`
|
||||
Key string `json:"key,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.
|
||||
@@ -51,13 +56,15 @@ type EnrollResponse struct {
|
||||
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 string `json:"type"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Port int `json:"port,omitempty"` // CtrlOpenForward: client-side port to dial
|
||||
TaskID string `json:"task_id,omitempty"` // CtrlTask
|
||||
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
|
||||
}
|
||||
|
||||
@@ -167,6 +174,12 @@ const (
|
||||
TaskDBAdd = "db.add" // add a row
|
||||
TaskDBDel = "db.del" // delete rows by their keys
|
||||
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
|
||||
TaskFSShipIn = "fs.ship.in" // unpack what another machine sends
|
||||
)
|
||||
@@ -186,6 +199,18 @@ type FSXferArgs struct {
|
||||
Xfer string `json:"xfer"`
|
||||
Size int64 `json:"size"`
|
||||
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.
|
||||
@@ -270,6 +295,8 @@ type FSEntry struct {
|
||||
Size int64 `json:"size"`
|
||||
Mode string `json:"mode"`
|
||||
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.
|
||||
@@ -349,6 +376,23 @@ type FSShipArgs struct {
|
||||
Chunk int `json:"chunk,omitempty"`
|
||||
Job string `json:"job,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
|
||||
@@ -359,3 +403,8 @@ type ShipProgress struct {
|
||||
Files int `json:"files"`
|
||||
Note string `json:"note,omitempty"` // what is going on: connecting, signing in
|
||||
}
|
||||
|
||||
// TermCwdArgs names the terminal session whose folder the panel asks for.
|
||||
type TermCwdArgs struct {
|
||||
Session string `json:"session"`
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,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.
|
||||
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
|
||||
@@ -19,7 +20,7 @@ type DB struct {
|
||||
func OpenDB(path string) (*DB, error) {
|
||||
// _pragma busy_timeout avoids "database is locked" under the concurrent
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -30,7 +31,7 @@ func OpenDB(path string) (*DB, error) {
|
||||
if err := sdb.Ping(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db := &DB{sql: sdb}
|
||||
db := &DB{sql: &dbx{DB: sdb}, path: path}
|
||||
if err := db.migrate(); err != nil {
|
||||
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 role TEXT NOT NULL DEFAULT 'admin'`,
|
||||
`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 (
|
||||
admin_id INTEGER NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
|
||||
agent_id TEXT NOT NULL,
|
||||
@@ -131,6 +149,10 @@ CREATE TABLE IF NOT EXISTS forward_rules (
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
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(?, ?, ?, ?)`,
|
||||
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
|
||||
@@ -584,14 +602,10 @@ func (db *DB) CreateForwardRule(r ForwardRule) (int64, error) {
|
||||
if r.Proto == "" {
|
||||
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)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
r.AgentID, r.ClientPort, r.HostPort, r.ListenAddr, r.Proto, r.NoUfw, r.Label, time.Now().Unix())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
r.AgentID, r.ClientPort, r.HostPort, r.ListenAddr, r.Proto, boolInt(r.NoUfw), r.Label, time.Now().Unix())
|
||||
}
|
||||
|
||||
func (db *DB) ListForwardRules() ([]ForwardRule, error) {
|
||||
@@ -740,3 +754,15 @@ func boolInt(b bool) int {
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
+32
-11
@@ -12,8 +12,9 @@ import (
|
||||
|
||||
// forwardManager owns the host-side listeners for port-forward rules. It
|
||||
// supports three transports:
|
||||
// - "http": tunneled over the HTTP long-poll channel (works anywhere nginx
|
||||
// reaches, highest latency);
|
||||
// - "http": over the agent's own link to the panel - its WebSocket, or the
|
||||
// HTTP long polls while there is none (works anywhere nginx reaches,
|
||||
// highest latency);
|
||||
// - "tcp": direct TCP multiplexed over the tunnel port (low latency);
|
||||
// - "udp": direct UDP over the tunnel port.
|
||||
type forwardManager struct {
|
||||
@@ -103,7 +104,8 @@ func (m *forwardManager) bridgeDirect(rule ForwardRule, conn net.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) {
|
||||
sess := m.hub.CreateForward(rule.AgentID, rule.ClientPort)
|
||||
defer m.hub.CloseSession(sess.ID)
|
||||
@@ -127,20 +129,39 @@ func (m *forwardManager) bridgeHTTP(rule ForwardRule, conn net.Conn) {
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-sess.Done():
|
||||
return
|
||||
case out, ok := <-sess.Out():
|
||||
if !ok || out.Exit {
|
||||
return
|
||||
// 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
|
||||
return false
|
||||
}
|
||||
if _, err := conn.Write(b); err != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case out, ok := <-sess.Out():
|
||||
if !ok || !write(out) {
|
||||
return
|
||||
}
|
||||
case <-sess.Done():
|
||||
// The agent's end of stream closes the session at once, while
|
||||
// what it sent before may still be queued (a download read
|
||||
// faster than the browser takes it): that goes out first.
|
||||
for {
|
||||
select {
|
||||
case out := <-sess.Out():
|
||||
if !write(out) {
|
||||
return
|
||||
}
|
||||
default:
|
||||
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
|
||||
// PUTs the pieces and asks for the result.
|
||||
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")
|
||||
if !s.fsAgent(w, agentID, true) {
|
||||
return
|
||||
@@ -329,19 +336,21 @@ func (s *Server) handleFSUploadStart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
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})
|
||||
writeJSON(w, proto.TaskResult{OK: true, Data: data})
|
||||
}
|
||||
|
||||
// runUpload has the agent write the file and keeps the result for the
|
||||
// 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)
|
||||
defer cancel()
|
||||
args := proto.FSXferArgs{Path: p, Xfer: x.id, Size: x.size, Chunk: xferChunk}
|
||||
res, err := s.waitTask(ctx, x.agentID, proto.TaskFSPut, args, xferTimeout)
|
||||
if err == nil && unknownOp(res) {
|
||||
args := proto.FSXferArgs{Path: p, Xfer: x.id, Size: x.size, Chunk: xferChunk, Conn: conn}
|
||||
res, err := s.waitTask(ctx, x.agentID, task, args, xferTimeout)
|
||||
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)
|
||||
}
|
||||
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,
|
||||
// video and sound for the page to show.
|
||||
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")
|
||||
inline := r.URL.Query().Get("inline") != ""
|
||||
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 x.fail("браузер ушёл") // stops the agent if the response ends early
|
||||
|
||||
raw, _ := json.Marshal(proto.FSXferArgs{Path: p, Xfer: x.id, Size: -1, Chunk: xferChunk})
|
||||
taskID, ch := s.hub.CreateTask(agentID, proto.TaskFSSend, raw)
|
||||
raw, _ := json.Marshal(proto.FSXferArgs{Path: p, Xfer: x.id, Size: -1, Chunk: xferChunk, Conn: conn})
|
||||
taskID, ch := s.hub.CreateTask(agentID, task, raw)
|
||||
defer s.hub.DiscardTask(taskID)
|
||||
// The agent's result: an error ends the transfer wherever it stands.
|
||||
results := make(chan proto.TaskResult, 1)
|
||||
@@ -530,6 +545,10 @@ func (s *Server) handleFSDownload(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-x.sized:
|
||||
case res := <-results:
|
||||
if unknownOp(res) && task != proto.TaskFSSend {
|
||||
http.Error(w, "агент не умеет файлы по SSH - обновите его", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if unknownOp(res) {
|
||||
s.legacyDownload(w, r, agentID, p, inline)
|
||||
return
|
||||
|
||||
@@ -112,11 +112,12 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
// --- dashboard ---
|
||||
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
agents, err := s.db.ListAgents()
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
s.renderPage(w, r, "dashboard", s.dashboardData(r))
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Agent
|
||||
Online bool
|
||||
@@ -131,12 +132,12 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
on := s.online(&a)
|
||||
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,
|
||||
"IsAdmin": me.IsAdmin(),
|
||||
"Agents": rows,
|
||||
"CSRF": csrfToken(r),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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) {
|
||||
if !checkCSRF(r) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
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,
|
||||
@@ -414,7 +402,8 @@ func (s *Server) handleAgentRename(w http.ResponseWriter, r *http.Request) {
|
||||
if 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 ---
|
||||
@@ -434,7 +423,8 @@ func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
code := s.db.InstallCode()
|
||||
ufwInstalled, ufwActive, ufwRunnable := s.ufw.status()
|
||||
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),
|
||||
"Host": host,
|
||||
"Direct": direct,
|
||||
@@ -453,6 +443,11 @@ 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),
|
||||
"InstallWget": fmt.Sprintf("wget -qO- %s/install.sh | sudo HOST=%s ENROLL_SECRET=%s sh", host, host, secret),
|
||||
"ServerVersion": Version,
|
||||
"AIModels": s.aiModels(),
|
||||
"Search": s.searchConf(),
|
||||
"ImageKey": func() string { _, k := s.imageConf(); return k }(),
|
||||
"DBNow": dbNow,
|
||||
"DBChosen": dbChosen,
|
||||
"UfwInstalled": ufwInstalled,
|
||||
"UfwActive": ufwActive,
|
||||
"UfwRunnable": ufwRunnable,
|
||||
|
||||
@@ -29,21 +29,12 @@ var fsOps = map[string]string{
|
||||
"db.add": proto.TaskDBAdd,
|
||||
"db.del": proto.TaskDBDel,
|
||||
"db.sql": proto.TaskDBSQL,
|
||||
"termcwd": proto.TaskTermCwd,
|
||||
}
|
||||
|
||||
// fsTaskTimeout bounds how long we wait for an agent to answer a file op.
|
||||
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
|
||||
// agent and return its result. The request body is the op's JSON args and is
|
||||
// forwarded verbatim to the agent.
|
||||
|
||||
@@ -23,7 +23,7 @@ func (s *Server) handleForwardsPage(w http.ResponseWriter, r *http.Request) {
|
||||
"listen": "Не удалось занять порт на хосте (возможно, уже используется).",
|
||||
"db": "Ошибка базы данных.",
|
||||
}[r.URL.Query().Get("err")]
|
||||
s.render(w, "forwards.html", map[string]any{
|
||||
s.renderPage(w, r, "forwards", map[string]any{
|
||||
"Agent": a,
|
||||
"Rules": rules,
|
||||
"CSRF": csrfToken(r),
|
||||
|
||||
@@ -74,7 +74,7 @@ func (s *Server) handleStatusPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
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.
|
||||
|
||||
@@ -23,7 +23,8 @@ import (
|
||||
|
||||
// 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
|
||||
// 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) {
|
||||
agentID := r.PathValue("agentID")
|
||||
agent, err := s.db.GetAgent(agentID)
|
||||
@@ -34,6 +35,7 @@ func (s *Server) handlePanelOpen(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
var body struct {
|
||||
SSH *proto.SSHTarget `json:"ssh"`
|
||||
Dir string `json:"dir"`
|
||||
}
|
||||
if r.ContentLength != 0 {
|
||||
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
|
||||
}
|
||||
}
|
||||
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 {
|
||||
t.Host, t.User = strings.TrimSpace(t.Host), strings.TrimSpace(t.User)
|
||||
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})
|
||||
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 {
|
||||
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)
|
||||
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 {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@ import (
|
||||
"github.com/x-grk/longpolling-remote-access/internal/proto"
|
||||
)
|
||||
|
||||
// Hub is the in-memory broker between agents (HTTP long-polling) and the panel.
|
||||
// It carries three kinds of work to an agent through its single control poll:
|
||||
// Hub is the in-memory broker between agents (on a WebSocket, or by HTTP
|
||||
// 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).
|
||||
// Nothing here is persisted.
|
||||
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
|
||||
// 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)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -227,8 +227,10 @@ func (s *Server) Handler() http.Handler {
|
||||
|
||||
// Panel (admin-only).
|
||||
mux.Handle("GET /{$}", s.requireAdmin(s.handleDashboard))
|
||||
mux.Handle("GET /agents/{id}/terminal", s.requireAdmin(s.handleTerminalPage))
|
||||
mux.Handle("GET /agents/{id}/files", s.requireAdmin(s.handleFilesPage))
|
||||
mux.Handle("GET /workspace", s.requireAdmin(s.handleShell))
|
||||
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}/status", s.requireAdmin(s.handleStatusPage))
|
||||
mux.Handle("GET /agents/{id}/edit", s.requireAdmin(s.handleAgentEdit))
|
||||
@@ -249,6 +251,26 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("POST /api/panel/users/{id}", s.adminsOnly(s.handleUserEdit))
|
||||
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/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/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/finish", s.requireAdmin(s.handleKeyRegisterFinish))
|
||||
mux.Handle("POST /api/panel/account/keys/{id}/delete", s.requireAdmin(s.handleKeyDelete))
|
||||
@@ -278,6 +300,10 @@ func (s *Server) Handler() http.Handler {
|
||||
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/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).
|
||||
mux.Handle("GET /api/panel/agents/presence", s.requireAdmin(s.handlePresence))
|
||||
@@ -434,6 +460,11 @@ func assetHashes(fsys fs.FS) map[string]string {
|
||||
func staticHandler(fsys fs.FS, hashes map[string]string) http.Handler {
|
||||
files := http.FileServer(http.FS(fsys))
|
||||
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, "/")]
|
||||
if h != "" {
|
||||
w.Header().Set("ETag", `"`+h+`"`)
|
||||
|
||||
+81
-3
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -115,6 +116,12 @@ type shipReq struct {
|
||||
Move bool `json:"move"`
|
||||
To string `json:"to"`
|
||||
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
|
||||
@@ -135,6 +142,10 @@ func (s *Server) handleFSShip(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
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 {
|
||||
t := q.SSH
|
||||
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)
|
||||
} else {
|
||||
if q.To == "" && q.ToConn != "" {
|
||||
q.To = from
|
||||
}
|
||||
if q.To == "" {
|
||||
writeJSON(w, proto.TaskResult{Error: "не выбрана машина"})
|
||||
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 {
|
||||
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 {
|
||||
args.SSH, args.Move = q.SSH, q.Move
|
||||
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("отменено") }()
|
||||
args.Relay, args.Chunk = x.id, xferChunk
|
||||
inArgs := args
|
||||
args.Conn, inArgs.Conn = q.FromConn, q.ToConn
|
||||
|
||||
var in proto.TaskResult
|
||||
inDone := make(chan struct{})
|
||||
go func() {
|
||||
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 {
|
||||
res = proto.TaskResult{Error: err.Error()}
|
||||
}
|
||||
@@ -254,7 +290,13 @@ func (s *Server) ship1(ctx context.Context, j *shipJob, from string, q shipReq)
|
||||
return out
|
||||
}
|
||||
if q.Move {
|
||||
res, err := s.waitTask(ctx, from, proto.TaskFSDelTree, proto.FSPathArgs{Path: q.Src}, fsTaskTimeout)
|
||||
for _, p := range append([]string{q.Src}, q.More...) {
|
||||
task, del := proto.TaskFSDelTree, any(proto.FSPathArgs{Path: p})
|
||||
if q.FromConn != "" {
|
||||
raw, _ := json.Marshal(proto.FSPathArgs{Path: p})
|
||||
task, del = proto.TaskSSHFSOp, proto.SSHFSArgs{Conn: q.FromConn, Op: "deltree", Args: raw}
|
||||
}
|
||||
res, err := s.waitTask(ctx, from, task, del, fsTaskTimeout)
|
||||
if err == nil && !res.OK {
|
||||
err = errText(res.Error)
|
||||
}
|
||||
@@ -262,6 +304,7 @@ func (s *Server) ship1(ctx context.Context, j *shipJob, from string, q shipReq)
|
||||
return proto.TaskResult{Error: "отправлено, но здесь не удалилось: " + err.Error()}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The receiver's count: what it put down and what it kept as it was.
|
||||
return in
|
||||
}
|
||||
@@ -416,3 +459,38 @@ func (s *Server) handleRelayGet(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
_, _ = 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>' +
|
||||
'<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="acct-who"><span class="acct-ava"></span><div><div class="acct-name"></div><div class="muted acct-mode"></div></div></div>' +
|
||||
' <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 class="acct-who"><span class="acct-ava"></span><div class="acct-whotext"><div class="acct-name"></div><div class="muted acct-mode"></div></div>' +
|
||||
' <form method="post" action="/logout" class="acct-out"><input type="hidden" name="csrf"><button type="submit" class="btn ghost">Выйти</button></form></div>' +
|
||||
' <label class="acct-pw"><span><b>Вход по паролю</b><small class="muted acct-pwnote"></small></span>' +
|
||||
' <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>' +
|
||||
' <form class="acct-pwform" autocomplete="on">' +
|
||||
' <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="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>' +
|
||||
' <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>';
|
||||
document.body.appendChild(dlg);
|
||||
dlg.querySelector('.acct-out [name=csrf]').value = decodeURIComponent(csrf);
|
||||
var $ = function (sel) { return dlg.querySelector(sel); };
|
||||
$('[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);
|
||||
|
||||
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>' +
|
||||
'<div class="muted">добавлен ' + esc(when(k.created)) + ' · вход: ' + esc(when(k.last_used)) + '</div></div>' +
|
||||
'<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;
|
||||
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(); })
|
||||
.then(function (r) { if (!r.ok) msg(r.error, true); else { msg('ключ удалён'); load(); } });
|
||||
};
|
||||
@@ -188,12 +210,20 @@
|
||||
} catch (e) { pinMsg('браузер не даёт сохранить настройку', true); return; }
|
||||
pinMsg(min ? 'на этом устройстве PIN не спросят ' + min + ' мин после ввода' : 'PIN спрашивается в каждой новой вкладке');
|
||||
};
|
||||
$('.acct-pindel').onclick = function () {
|
||||
if (confirm('Убрать PIN-код?')) savePin('');
|
||||
$('.acct-pindel').onclick = async function () {
|
||||
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;
|
||||
if (off && !confirm('Выключить вход по паролю? Войти можно будет только ключом доступа. Если ключи потеряются, их сможет сбросить другой администратор в «Настройках».')) {
|
||||
if (off && !await UI.confirm('Выключить вход по паролю? Войти можно будет только ключом доступа. Если ключи потеряются, их сможет сбросить другой администратор в «Настройках».', {ok: 'Выключить', danger: true})) {
|
||||
ev.target.checked = true;
|
||||
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 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:hover { color: var(--text); }
|
||||
.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; }
|
||||
h2 { font-size: 17px; margin: 0 0 14px; }
|
||||
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;
|
||||
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-pinmsg { margin: 8px 0 0; font-size: 13px; color: var(--ok, #3fb950); min-height: 0; }
|
||||
.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. */
|
||||
.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); }
|
||||
@@ -225,6 +232,15 @@ input.sw:disabled { opacity: .45; cursor: not-allowed; }
|
||||
overflow-x: auto; white-space: nowrap; scrollbar-width: none; }
|
||||
header.topbar nav::-webkit-scrollbar { display: 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 button { width: 100%; }
|
||||
.acct-keys li { flex-wrap: wrap; }
|
||||
@@ -233,7 +249,7 @@ input.sw:disabled { opacity: .45; cursor: not-allowed; }
|
||||
h1 { font-size: 20px; }
|
||||
code { word-break: break-all; }
|
||||
/* 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, table.stack tbody { display: block; }
|
||||
table.stack tr { display: flex; flex-direction: column; gap: 2px; padding: 12px 0; border-bottom: 1px solid var(--border); }
|
||||
@@ -259,3 +275,912 @@ input.sw:disabled { opacity: .45; cursor: not-allowed; }
|
||||
@supports not selector(::-webkit-scrollbar) {
|
||||
* { 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.pic details { padding:0 4px; }
|
||||
.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-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(); }
|
||||
});
|
||||
};
|
||||
$('.dbx-del').onclick = function () {
|
||||
$('.dbx-del').onclick = async function () {
|
||||
var keys = [];
|
||||
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) {
|
||||
if (!r.ok) { toast(r.error || 'не удалилось', true); return; }
|
||||
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);
|
||||
}
|
||||
|
||||
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,354 @@
|
||||
// Scripts of the pages the shell shows as fragments, as modules: mount(root,
|
||||
// S) runs when the fragment is put in place, with S the module's scope -
|
||||
// timers, requests and listeners that end when it is replaced or closed.
|
||||
(function () {
|
||||
'use strict';
|
||||
const CHECK = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
|
||||
function copy(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text; ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
|
||||
document.body.appendChild(ta); ta.select();
|
||||
const ok = document.execCommand('copy'); ta.remove();
|
||||
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
|
||||
}
|
||||
|
||||
App.register('settings', {
|
||||
mount(root) {
|
||||
root.querySelectorAll('.copybox .copy').forEach(b => {
|
||||
const icon = b.innerHTML;
|
||||
b.onclick = () => copy(root.querySelector('#' + b.dataset.copy).textContent.trim()).then(() => {
|
||||
b.innerHTML = CHECK; b.classList.add('done'); b.title = 'Скопировано';
|
||||
setTimeout(() => { b.innerHTML = icon; b.classList.remove('done'); b.title = 'Скопировать'; }, 1500);
|
||||
}, e => alert('Не скопировалось: ' + e.message));
|
||||
});
|
||||
if (window.UsersTable) window.UsersTable.mount(root);
|
||||
// ИИ-ассистент: a card per model, added from the blank one and taken
|
||||
// away by its «Удалить» (the last one stays).
|
||||
const list = root.querySelector('.ai-models'), blank = root.querySelector('template.ai-model-new');
|
||||
if (list && blank) {
|
||||
root.querySelector('.ai-model-add').onclick = () => {
|
||||
const card = blank.content.firstElementChild.cloneNode(true);
|
||||
list.appendChild(card);
|
||||
card.querySelector('[name=m_name]').focus();
|
||||
};
|
||||
list.addEventListener('click', ev => {
|
||||
const del = ev.target.closest('.ai-model-del');
|
||||
if (del && list.children.length > 1) del.closest('.ai-model').remove();
|
||||
});
|
||||
// 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,84 @@
|
||||
// The panel's own question and input dialogs in place of the browser's
|
||||
// confirm() and prompt(): UI.confirm(text, {ok, danger}) and
|
||||
// UI.prompt(text, value) answer with promises. A form with data-confirm asks
|
||||
// first and is sent only on «да».
|
||||
(function () {
|
||||
'use strict';
|
||||
let dlg = null;
|
||||
function make() {
|
||||
dlg = document.createElement('dialog');
|
||||
dlg.className = 'uidlg';
|
||||
dlg.innerHTML = '<form method="dialog"><p class="ui-text"></p><input type="text" class="ui-input" autocomplete="off" spellcheck="false">' +
|
||||
'<div class="row end"><button type="button" class="btn ghost close ui-no">Отмена</button><button type="submit" class="ui-yes">OK</button></div></form>';
|
||||
document.body.appendChild(dlg);
|
||||
}
|
||||
function ask(text, o) {
|
||||
if (!dlg) make();
|
||||
if (dlg.open) dlg.close('');
|
||||
const input = dlg.querySelector('.ui-input'), yes = dlg.querySelector('.ui-yes');
|
||||
// Long words (a file name) stay on one line, cut with "…"; the window
|
||||
// is as wide as its words, up to the screen's room.
|
||||
const t = dlg.querySelector('.ui-text');
|
||||
t.textContent = '';
|
||||
// "{chip}" in the text: the chip stands there, inside the sentence.
|
||||
const parts = String(text).split('{chip}');
|
||||
text = parts.shift();
|
||||
String(text).split(/(\s+)/).forEach(w => {
|
||||
if (w.length > 28 && !/\s/.test(w)) {
|
||||
const s = document.createElement('span');
|
||||
s.className = 'ui-long'; s.textContent = w; s.title = w;
|
||||
t.appendChild(s);
|
||||
} else t.appendChild(document.createTextNode(w));
|
||||
});
|
||||
// Extras of a question about an action: where (a chip), the command in
|
||||
// mono, a note under it.
|
||||
if (parts.length && o.chip) {
|
||||
const c = document.createElement('span'); c.className = 'ui-chip'; c.textContent = o.chip; c.title = o.chip;
|
||||
t.append(c, parts.join(''));
|
||||
o = Object.assign({}, o, {chip: ''});
|
||||
}
|
||||
dlg.querySelectorAll('.ui-extra').forEach(e => e.remove());
|
||||
if (o.chip || o.code || o.note) {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'ui-extra';
|
||||
if (o.chip) { const c = document.createElement('div'); c.className = 'ui-chip'; c.textContent = o.chip; c.title = o.chip; box.appendChild(c); }
|
||||
if (o.code) { const p = document.createElement('pre'); p.className = 'ui-code'; p.textContent = o.code; box.appendChild(p); }
|
||||
if (o.note) { const n = document.createElement('p'); n.className = 'ui-note'; n.textContent = o.note; box.appendChild(n); }
|
||||
t.after(box);
|
||||
}
|
||||
input.hidden = !o.input;
|
||||
input.value = o.value || '';
|
||||
yes.textContent = o.ok || 'OK';
|
||||
yes.className = 'ui-yes' + (o.danger ? ' btn danger' : '');
|
||||
// «Отмена» red, unless «да» is the red one (a delete): never two reds.
|
||||
dlg.querySelector('.ui-no').classList.toggle('close', !o.danger);
|
||||
return new Promise(done => {
|
||||
const end = v => { dlg.onclose = null; done(v); };
|
||||
dlg.querySelector('.ui-no').onclick = () => dlg.close('');
|
||||
dlg.querySelector('form').onsubmit = ev => { ev.preventDefault(); dlg.close('ok'); };
|
||||
dlg.onclose = () => end(dlg.returnValue === 'ok' ? (o.input ? input.value : true) : (o.input ? null : false));
|
||||
dlg.returnValue = '';
|
||||
dlg.showModal();
|
||||
if (o.input) { input.focus(); input.select(); } else yes.focus();
|
||||
});
|
||||
}
|
||||
const UI = {
|
||||
confirm: (text, o) => ask(text, Object.assign({ok: 'Да'}, o)),
|
||||
prompt: (text, value) => ask(text, {input: true, value, ok: 'OK'})
|
||||
};
|
||||
window.UI = UI;
|
||||
// Forms that ask first: data-confirm, and data-danger for a red «да».
|
||||
document.addEventListener('submit', ev => {
|
||||
const f = ev.target;
|
||||
if (!f.dataset || !f.dataset.confirm) return;
|
||||
if (f.dataset.confirmed) { delete f.dataset.confirmed; return; }
|
||||
ev.preventDefault();
|
||||
ev.stopImmediatePropagation();
|
||||
const by = ev.submitter;
|
||||
UI.confirm(f.dataset.confirm, {ok: f.dataset.ok || 'Да', danger: 'danger' in f.dataset}).then(ok => {
|
||||
if (!ok) return;
|
||||
f.dataset.confirmed = '1';
|
||||
f.requestSubmit(by && f.contains(by) ? by : undefined);
|
||||
});
|
||||
}, true);
|
||||
})();
|
||||
@@ -1,11 +1,12 @@
|
||||
// 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
|
||||
// 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';
|
||||
var table = document.querySelector('table.users');
|
||||
var table = root.querySelector('table.users');
|
||||
if (!table) return;
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
var $ = function (id) { return root.querySelector('#' + id); };
|
||||
|
||||
function save(id, 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 ----
|
||||
var role = $('newrole'), pick = $('newagents');
|
||||
if (role) role.onchange = function () { pick.hidden = role.value !== 'user'; };
|
||||
})();
|
||||
}};
|
||||
+288
-312
@@ -1,12 +1,13 @@
|
||||
// Terminal page: shells on one or more machines, in tabs. With one tab the
|
||||
// page is a plain terminal and a faint "+" in the corner; a second tab brings
|
||||
// the tab strip in. On a computer a tab can be dragged onto the current one
|
||||
// to share it - up to four terminals, split side by side or one above the
|
||||
// other - and a terminal dragged out of it back to the strip. Splits resize
|
||||
// by their gutters; a tab is renamed by a double click on its title.
|
||||
// The workspace of the Терминал view: panes - terminals, file managers - of
|
||||
// one or more machines, in tabs. It lives as long as the page, so moving to
|
||||
// Машины or Настройки and back keeps every session. With one tab there is
|
||||
// only a faint "+" in the corner; a second tab brings the tab strip in. On a
|
||||
// computer a tab can be dragged onto the current one to share it - up to four
|
||||
// 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 () {
|
||||
'use strict';
|
||||
const PAGE = window.TERM_PAGE;
|
||||
const touch = matchMedia('(pointer: coarse)').matches;
|
||||
const desk = !touch; // drag and drop, splits: computers only
|
||||
if (touch) document.body.classList.add('touch');
|
||||
@@ -17,252 +18,14 @@
|
||||
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>';
|
||||
|
||||
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 ----------
|
||||
const tabs = [];
|
||||
let active = null, activePane = null;
|
||||
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) {
|
||||
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));
|
||||
t.el = document.createElement('div');
|
||||
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>';
|
||||
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-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); });
|
||||
const at = index == null ? tabs.length : index;
|
||||
tablist.insertBefore(t.el, tabs[at] ? tabs[at].el : mark);
|
||||
@@ -288,7 +56,7 @@
|
||||
}
|
||||
function tabTitle(t) {
|
||||
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;
|
||||
}
|
||||
// Tab widths: as wide as the title wants, from a comfortable minimum up to
|
||||
@@ -300,7 +68,8 @@
|
||||
function fitTabs() {
|
||||
ruler.font = W.font + 'px ' + getComputedStyle(document.body).fontFamily;
|
||||
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 room = strip.clientWidth - parseFloat(getComputedStyle(strip).paddingLeft) * 2 - addBtn.offsetWidth - 4 * (live.length + 1);
|
||||
let cap = W.max;
|
||||
@@ -315,17 +84,25 @@
|
||||
}
|
||||
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) {
|
||||
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);
|
||||
if (ssh !== !!t.el.querySelector('.badge-ssh')) {
|
||||
if (ssh) t.el.querySelector('.dot').insertAdjacentHTML('afterend', '<span class="badge-ssh">SSH</span>');
|
||||
else t.el.querySelector('.badge-ssh').remove();
|
||||
}
|
||||
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.
|
||||
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');
|
||||
@@ -340,17 +117,26 @@
|
||||
if (!t.panes.includes(activePane)) setActivePane(t.panes[0]);
|
||||
layout(t, false);
|
||||
t.el.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
|
||||
if (desk && activePane) activePane.term.focus();
|
||||
if (desk && activePane) activePane.focus();
|
||||
updateMode();
|
||||
// The tab comes in moving a little; the "+" goes by where it ends up.
|
||||
setTimeout(placeFloat, 260);
|
||||
}
|
||||
function setActivePane(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));
|
||||
s.el.classList.add('active');
|
||||
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.title = activePane.text;
|
||||
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.
|
||||
function syncFloat() {
|
||||
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 "+".
|
||||
function updateMode() {
|
||||
@@ -367,15 +175,19 @@
|
||||
document.body.classList.toggle('multi', multi);
|
||||
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;
|
||||
const t = makeTab([new Session(agent, ssh)]);
|
||||
const t = makeTab([make(agent, opts || {})]);
|
||||
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);
|
||||
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);
|
||||
t.panes.forEach(s => s.close());
|
||||
t.el.classList.add('leave');
|
||||
@@ -385,7 +197,7 @@
|
||||
active = null;
|
||||
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();
|
||||
}
|
||||
function rename(t) {
|
||||
@@ -434,10 +246,15 @@
|
||||
if (!W || !H) return;
|
||||
const L = rects(t.layout, t.c, t.r, W, H);
|
||||
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) => {
|
||||
const p = L.panes[i];
|
||||
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.classList.toggle('under-plus', i === corner);
|
||||
});
|
||||
while (t.gutters.length < L.gutters.length) {
|
||||
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';
|
||||
});
|
||||
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) {
|
||||
ev.preventDefault();
|
||||
@@ -532,16 +349,26 @@
|
||||
// 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.
|
||||
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) {
|
||||
if (ev.button !== 0) return;
|
||||
drag = { kind, obj, x0: ev.clientX, y0: ev.clientY, on: false, id: ev.pointerId };
|
||||
}
|
||||
function beginDrag() {
|
||||
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 g = document.createElement('div');
|
||||
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);
|
||||
drag.ghost = g;
|
||||
drag.src = src;
|
||||
@@ -580,6 +407,16 @@
|
||||
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 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) {
|
||||
const s = stripIndex(ev.clientX);
|
||||
mark.hidden = false; mark.style.left = s.mx + 'px';
|
||||
@@ -615,6 +452,14 @@
|
||||
const d = drag;
|
||||
drag = null;
|
||||
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.src.classList.remove('dragging');
|
||||
document.body.classList.remove('dnd', 'multi-hint');
|
||||
@@ -645,7 +490,7 @@
|
||||
renderTab(t);
|
||||
layout(t, true);
|
||||
updateMode();
|
||||
s.term.focus();
|
||||
s.focus();
|
||||
} else if (d.kind === 'pane' && tg.kind === 'strip') {
|
||||
const s = d.obj, from = tabOf(s), ref = tg.others[tg.index];
|
||||
takePane(from, s);
|
||||
@@ -666,43 +511,83 @@
|
||||
// ---------- the "+" menu ----------
|
||||
let agentsCache = null;
|
||||
function openMenu(anchor) {
|
||||
const here = (activePane && activePane.agent) || PAGE.agent;
|
||||
const here = activePane && activePane.agent;
|
||||
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');
|
||||
b.type = 'button'; b.className = 'item ' + (cls || ''); b.innerHTML = html;
|
||||
b.onclick = fn;
|
||||
addMenu.appendChild(b);
|
||||
parent.appendChild(b);
|
||||
return b;
|
||||
};
|
||||
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
item('<span class="ico">+</span><span><b>Новый терминал здесь</b><small>' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '</small></span>',
|
||||
() => { closeMenu(); openTerminal(here); });
|
||||
item('<span class="ico">' + KEYI + '</span><span><b>SSH-подключение</b><small>агент откроет SSH к другому хосту</small></span>',
|
||||
() => { closeMenu(); openSSH(here); });
|
||||
const other = item('<span class="ico">⇄</span><span><b>На другой машине</b><small>выбрать из списка</small></span><span class="chev">›</span>', () => {
|
||||
other.remove();
|
||||
const list = document.createElement('div');
|
||||
list.className = 'mlist';
|
||||
list.innerHTML = '<div class="muted" style="padding:8px 10px;font-size:13px">загрузка…</div>';
|
||||
addMenu.appendChild(list);
|
||||
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>';
|
||||
// Both groups keep their contents mounted so they can animate in both directions.
|
||||
const group = (icon, label, kind, mode) => {
|
||||
let loaded = false;
|
||||
const g = item(addMenu, '<span class="ico">' + icon + '</span><span><b>' + label + '</b></span><span class="chev" aria-hidden="true">›</span>', () => {
|
||||
const opening = g.getAttribute('aria-expanded') !== 'true';
|
||||
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;
|
||||
list.innerHTML = '';
|
||||
all.forEach(a => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button'; b.className = 'item machine'; b.disabled = !a.online;
|
||||
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>';
|
||||
b.onclick = () => { closeMenu(); openTerminal({ id: a.id, name: a.name, host: a.hostname, user: a.user }); };
|
||||
list.appendChild(b);
|
||||
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 (!all.length) list.innerHTML = '<div class="muted" style="padding:8px 10px">машин нет</div>';
|
||||
placeMenu(anchor);
|
||||
if (list.isConnected) placeMenu(anchor);
|
||||
})
|
||||
.catch(() => { list.innerHTML = '<div class="muted" style="padding:8px 10px">не загрузилось</div>'; });
|
||||
.catch(() => { box.innerHTML = '<div class="muted" style="padding:8px 10px">не загрузилось</div>'; });
|
||||
});
|
||||
g.setAttribute('aria-expanded', 'false');
|
||||
const list = document.createElement('div');
|
||||
list.className = 'mlist';
|
||||
list.inert = true;
|
||||
const inner = document.createElement('div');
|
||||
inner.className = 'mlist-inner';
|
||||
const content = document.createElement('div');
|
||||
content.className = 'mlist-content';
|
||||
inner.appendChild(content);
|
||||
list.appendChild(inner);
|
||||
g.after(list);
|
||||
if (here) item(content, '<span class="ico">+</span><span><b>Здесь</b><small>' + esc(here.name) + ' · ' + esc(here.user + '@' + here.host) + '</small></span>',
|
||||
() => {
|
||||
closeMenu();
|
||||
// A terminal from a machine's file pane starts in its folder, a
|
||||
// file pane from a machine's terminal in the terminal's (asked of
|
||||
// the machine; home if it cannot tell) - unless turned off.
|
||||
const on = !(window.Prefs && Prefs.get().openHere === 'off');
|
||||
let opts = null;
|
||||
if (on && kind === 'term' && activePane.kind === 'files') opts = {dir: activePane.cwd};
|
||||
else if (on && kind === 'files' && activePane.kind === 'term' && activePane.sid) {
|
||||
opts = {dir: new MachineSource(here).op('termcwd', {session: activePane.sid}).then(r => r.ok && r.data ? r.data.path : '')};
|
||||
}
|
||||
openPane(kind, here, opts);
|
||||
});
|
||||
const box = document.createElement('div');
|
||||
box.innerHTML = '<div class="muted" style="padding:8px 10px;font-size:13px">загрузка…</div>';
|
||||
content.appendChild(box);
|
||||
item(content, '<span class="ico">' + KEYI + '</span><span><b>SSH-хост…</b><small>машина откроет SSH к другому хосту</small></span>',
|
||||
() => { closeMenu(); openSSH(here, mode); });
|
||||
if (!paneTypes[kind]) g.disabled = true;
|
||||
return g;
|
||||
};
|
||||
group('>_', 'Терминал', 'term', 'term');
|
||||
group(FILES, 'Файловый менеджер', 'files', 'files');
|
||||
addMenu.hidden = false;
|
||||
placeMenu(anchor);
|
||||
setTimeout(() => document.addEventListener('pointerdown', outside, true), 0);
|
||||
@@ -725,10 +610,14 @@
|
||||
|
||||
// ---------- 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>';
|
||||
let sshDlg = null;
|
||||
function openSSH(here) {
|
||||
let sshInto = null, sshDlg = null, sshMode = 'term';
|
||||
// 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();
|
||||
sshDlg.show(here);
|
||||
sshDlg.show(here, mode || 'term', into);
|
||||
}
|
||||
function makeSSHDialog() {
|
||||
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>' +
|
||||
'<div class="dlg-b">' +
|
||||
'<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>' +
|
||||
'<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>' +
|
||||
'<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>' +
|
||||
'<div class="auth" data-for="password"><input type="password" name="password" placeholder="пароль" autocomplete="off"></div>' +
|
||||
'<div class="auth" data-for="key" hidden>' +
|
||||
@@ -751,11 +642,14 @@
|
||||
'<input type="file" hidden></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>' +
|
||||
'<label class="chk save-row"><input type="checkbox" name="save"> Сохранить хост и вход - на сервере, зашифровано, только для вас</label>' +
|
||||
'<p class="sshmsg"></p>' +
|
||||
'<div class="row end"><button type="button" class="btn ghost" data-close>Отмена</button><button type="submit">Подключиться</button></div>' +
|
||||
'</div></form>';
|
||||
document.body.appendChild(d);
|
||||
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';
|
||||
const setAuth = a => {
|
||||
auth = a;
|
||||
@@ -769,8 +663,8 @@
|
||||
file.onchange = () => {
|
||||
const fl = file.files[0];
|
||||
if (!fl) return;
|
||||
if (fl.size > 32768) { msg.textContent = 'файл великоват для ключа'; return; }
|
||||
fl.text().then(t => { f.key.value = t.trim() + '\n'; d.querySelector('.keyname').textContent = fl.name; msg.textContent = ''; });
|
||||
if (fl.size > 32768) { say('файл великоват для ключа'); return; }
|
||||
fl.text().then(t => { f.key.value = t.trim() + '\n'; d.querySelector('.keyname').textContent = fl.name; say(''); });
|
||||
file.value = '';
|
||||
};
|
||||
// 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('click', ev => { if (ev.target === d) close(); });
|
||||
let agents = [];
|
||||
f.onsubmit = ev => {
|
||||
let agents = [], saved = [];
|
||||
// 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();
|
||||
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() };
|
||||
if (auth === 'password') {
|
||||
if (!f.password.value) { msg.textContent = 'введите пароль'; f.password.focus(); return; }
|
||||
if (f.saved.value) {
|
||||
t.saved = Number(f.saved.value);
|
||||
} else if (auth === 'password') {
|
||||
if (!f.password.value) { say('введите пароль'); f.password.focus(); return; }
|
||||
t.password = f.password.value;
|
||||
} 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;
|
||||
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 = '';
|
||||
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));
|
||||
};
|
||||
return {
|
||||
show(here) {
|
||||
msg.textContent = '';
|
||||
show(here, mode, into) {
|
||||
sshMode = mode;
|
||||
sshInto = into || null;
|
||||
d.querySelector('.dlg-h strong').textContent = mode === 'files' ? 'Файлы по SSH' : 'SSH-подключение';
|
||||
say('');
|
||||
let last = {};
|
||||
try { last = JSON.parse(localStorage.getItem('lpra.ssh.last') || '{}'); } catch (e) { /* none */ }
|
||||
agents = [here];
|
||||
agents = here ? [here] : [];
|
||||
const fill = () => {
|
||||
f.agent.innerHTML = '';
|
||||
agents.forEach(a => {
|
||||
@@ -814,14 +759,20 @@
|
||||
o.disabled = a.online === false;
|
||||
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();
|
||||
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 }));
|
||||
if (!agents.some(a => a.id === here.id)) agents.unshift(here);
|
||||
if (here && !agents.some(a => a.id === here.id)) agents.unshift(here);
|
||||
fill();
|
||||
}).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 || ''; }
|
||||
setAuth(last.auth === 'key' || last.auth === 'agent' ? last.auth : 'password');
|
||||
d.showModal();
|
||||
@@ -848,11 +799,36 @@
|
||||
window.visualViewport.addEventListener('scroll', refit);
|
||||
}
|
||||
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.
|
||||
window.TermPane = { active: () => activePane };
|
||||
openTerminal(PAGE.agent);
|
||||
refit();
|
||||
// The view coming into sight: fit the panes, show the status line.
|
||||
document.addEventListener('app:view', ev => {
|
||||
if (ev.detail === 'terminal') { refit(); showStatus(); } else statusEl.textContent = '';
|
||||
});
|
||||
$('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,218 @@
|
||||
{{define "app.html"}}<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<title>LPRA</title>
|
||||
{{template "head" .}}
|
||||
<link rel="stylesheet" href="{{asset "xterm.css"}}">
|
||||
</head>
|
||||
<body class="app">
|
||||
{{template "topbar" .}}
|
||||
<main id="app" data-view="{{.View}}">
|
||||
<section class="view" data-view="machines"{{if ne .View "dashboard"}} hidden{{end}}>{{if eq .View "dashboard"}}{{.Body}}{{end}}</section>
|
||||
<section class="view view-term" data-view="terminal"{{if ne .View "terminal"}} hidden{{end}}>
|
||||
<div class="tabs" id="tabs">
|
||||
<div class="tablist" id="tablist"><div class="tabmark" id="tabmark" hidden></div></div>
|
||||
<button type="button" class="tab-add" id="tabadd" title="Новый терминал" aria-label="Новый терминал">+</button>
|
||||
</div>
|
||||
<div class="area" id="area">
|
||||
<button type="button" class="float-add" id="floatadd" title="Новый терминал" aria-label="Новый терминал">+</button>
|
||||
<div class="empty" id="empty" hidden>Нет открытых терминалов<button type="button" id="emptyadd">Новый терминал</button></div>
|
||||
<div class="hint" id="hint" hidden></div>
|
||||
</div>
|
||||
<div class="addmenu" id="addmenu" hidden></div>
|
||||
<div class="selmenu" id="selmenu" hidden>
|
||||
<button type="button" data-a="copy" title="Копировать" aria-label="Копировать">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn ghost" data-a="paste" title="Вставить" aria-label="Вставить">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn ghost" data-a="all" title="Выделить всё" aria-label="Выделить всё">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3a2 2 0 0 0-2 2M19 3a2 2 0 0 1 2 2M21 19a2 2 0 0 1-2 2M5 21a2 2 0 0 1-2-2M9 3h1M9 21h1M14 3h1M14 21h1M3 9v1M21 9v1M3 14v1M21 14v1"/><rect x="7" y="7" width="10" height="10" rx="1"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn ghost close" data-a="close" title="Закрыть" aria-label="Закрыть">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="keys" id="keys">
|
||||
<button type="button" data-k="esc">ESC</button>
|
||||
<button type="button" data-k="/">/</button>
|
||||
<button type="button" data-k="-">-</button>
|
||||
<button type="button" data-k="home">HOME</button>
|
||||
<button type="button" data-k="up">↑</button>
|
||||
<button type="button" data-k="end">END</button>
|
||||
<button type="button" data-k="pgup">PGUP</button>
|
||||
<button type="button" data-k="tab">TAB</button>
|
||||
<button type="button" data-k="ctrl" data-mod="ctrl">CTRL</button>
|
||||
<button type="button" data-k="alt" data-mod="alt">ALT</button>
|
||||
<button type="button" data-k="left">←</button>
|
||||
<button type="button" data-k="down">↓</button>
|
||||
<button type="button" data-k="right">→</button>
|
||||
<button type="button" data-k="pgdn">PGDN</button>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<section class="view" data-view="settings"{{if ne .View "settings"}} hidden{{end}}>{{if eq .View "settings"}}{{.Body}}{{end}}</section>
|
||||
</main>
|
||||
<dialog id="viewer" tabindex="-1">
|
||||
<div class="dlg-h">
|
||||
<strong class="vpath" dir="rtl"><bdi id="vname"></bdi></strong>
|
||||
<button class="btn ghost small" id="vtext" hidden>Редактировать</button>
|
||||
<button class="btn ghost small" id="vhex" hidden>Редактировать бинарно</button>
|
||||
<button class="btn small" id="save" hidden>Сохранить</button>
|
||||
<button class="btn ghost small icon sq" id="vprops" title="Свойства" aria-label="Свойства"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4"/></svg></button>
|
||||
<button class="btn ghost small icon close x" id="closev" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
||||
<div class="vmsg muted" id="vmsg"></div>
|
||||
</div>
|
||||
<div class="vbody">
|
||||
<div class="find" id="find" hidden>
|
||||
<div class="fin"><input type="text" id="fq" placeholder="Найти" spellcheck="false" autocomplete="off"><span class="cnt" id="fcnt"></span></div>
|
||||
<div class="fbtns">
|
||||
<button type="button" class="tg" id="fcase" title="Учитывать регистр">Aa</button>
|
||||
<button type="button" class="tg" id="fre" title="Регулярное выражение">.*</button>
|
||||
<button type="button" class="btn ghost" id="fprev" title="Предыдущее (Shift+Enter)" aria-label="Предыдущее">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M6 2.5 11 9.5H1z" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn ghost" id="fnext" title="Следующее (Enter)" aria-label="Следующее">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12"><path d="M6 9.5 1 2.5h10z" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn ghost close" id="fclose" title="Закрыть поиск (Esc)" aria-label="Закрыть поиск">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="fin" id="frow2"><input type="text" id="frep" placeholder="Заменить на" spellcheck="false" autocomplete="off"></div>
|
||||
<div class="fbtns" id="frow2b">
|
||||
<button type="button" class="btn ghost" id="frepone">Заменить</button>
|
||||
<button type="button" class="btn ghost" id="frepall">Все</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="vcode" hidden></div>
|
||||
<div class="vmedia" id="vmedia" hidden></div>
|
||||
<div class="vdoc" id="vdoc" hidden></div>
|
||||
<div class="hexed" id="vhexed" tabindex="0" hidden><div id="hxsp"></div><div class="hx-rows" id="hxrows"></div></div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="updlg" tabindex="-1">
|
||||
<div class="dlg-h">
|
||||
<strong style="flex:1">Загрузка файлов</strong>
|
||||
<button class="btn ghost small icon close x" id="updlg-close" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
||||
</div>
|
||||
<div class="dlg-b" id="updlg-list"></div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="mvdlg" tabindex="-1">
|
||||
<div class="dlg-h">
|
||||
<strong style="flex:1">Переместить или копировать</strong>
|
||||
<button class="btn ghost small icon close x" id="mvdlg-close" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
||||
</div>
|
||||
<div class="dlg-b mv">
|
||||
<div class="pp-path" id="mv-src"></div>
|
||||
<div class="mv-top">
|
||||
<div class="seg" id="mv-op" role="radiogroup">
|
||||
<button type="button" data-op="move" role="radio">Переместить</button>
|
||||
<button type="button" data-op="copy" role="radio">Копировать</button>
|
||||
</div>
|
||||
<button type="button" class="btn ghost sq mv-net" id="mv-net" title="На другую машину или по SSH" aria-label="На другую машину или по SSH" aria-pressed="false"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="6" rx="1"/><rect x="2" y="16" width="6" height="6" rx="1"/><rect x="16" y="16" width="6" height="6" rx="1"/><path d="M12 8v4M5 16v-2a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v2"/></svg></button>
|
||||
</div>
|
||||
<div class="mv-remote" id="mv-remote"><div>
|
||||
<label for="mv-to">На машину</label>
|
||||
<select id="mv-to"></select>
|
||||
<div class="mv-ssh" id="mv-ssh" hidden>
|
||||
<div class="mv-row"><label class="grow">Хост<input type="text" id="mv-host" placeholder="example.com" autocomplete="off" spellcheck="false" autocapitalize="off"></label>
|
||||
<label class="port">Порт<input type="number" id="mv-port" min="1" max="65535" value="22"></label></div>
|
||||
<label>Пользователь<input type="text" id="mv-user" placeholder="root" autocomplete="off" spellcheck="false" autocapitalize="off"></label>
|
||||
<div class="seg seg-auth" id="mv-auth">
|
||||
<button type="button" data-auth="password" class="on">Пароль</button><button type="button" data-auth="key">Ключ</button><button type="button" data-auth="agent">Ключи агента</button>
|
||||
</div>
|
||||
<div class="mv-a" data-for="password"><input type="password" id="mv-pw" placeholder="пароль" autocomplete="off"></div>
|
||||
<div class="mv-a" data-for="key" hidden>
|
||||
<textarea id="mv-key" rows="4" spellcheck="false" placeholder="-----BEGIN OPENSSH PRIVATE KEY----- …вставьте ключ или загрузите файл"></textarea>
|
||||
<div class="mv-row"><button type="button" class="btn ghost small" id="mv-keyload">Загрузить файл…</button><span class="muted" id="mv-keyname"></span><input type="file" id="mv-keyfile" hidden></div>
|
||||
<input type="password" id="mv-pass" placeholder="фраза ключа, если есть" autocomplete="off">
|
||||
</div>
|
||||
<p class="mv-a muted" data-for="agent" hidden>Агент войдёт своими ключами из ~/.ssh без фразы.</p>
|
||||
</div>
|
||||
</div></div>
|
||||
<label for="mv-dir">Куда (папка)</label>
|
||||
<div class="mv-dirbox">
|
||||
<input type="text" id="mv-dir" autocomplete="off" spellcheck="false" autocapitalize="off">
|
||||
<ul class="mv-sug" id="mv-sug" hidden></ul>
|
||||
</div>
|
||||
<label for="mv-name">Имя</label>
|
||||
<input type="text" id="mv-name" autocomplete="off" spellcheck="false" autocapitalize="off">
|
||||
<label for="mv-conflict">Если там уже есть такое</label>
|
||||
<select id="mv-conflict">
|
||||
<option value="error">ничего не трогать и сообщить</option>
|
||||
<option value="replace">заменить (папки слить, файлы заменить)</option>
|
||||
<option value="skip">оставить существующее (папки слить)</option>
|
||||
</select>
|
||||
<div class="mv-prog" id="mv-prog" hidden>
|
||||
<div class="mv-bar"><i id="mv-bar"></i></div>
|
||||
<div class="mv-ptext"><span class="mv-pwrap"><span class="spinner"></span><span id="mv-ptext">подготовка…</span></span><button type="button" class="btn ghost small" id="mv-stop">Отменить</button></div>
|
||||
</div>
|
||||
<div class="pp-act"><button type="button" id="mv-go">Переместить</button></div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="propdlg" tabindex="-1">
|
||||
<div class="dlg-h">
|
||||
<strong style="flex:1">Свойства</strong>
|
||||
<button class="btn ghost small icon close x" id="propdlg-close" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
||||
</div>
|
||||
<div class="dlg-b props">
|
||||
<div class="pp-path" id="pp-path"></div>
|
||||
<div class="pp-info muted" id="pp-info"></div>
|
||||
<div class="pp-own">
|
||||
<label>Владелец<input type="text" id="pp-owner" list="pp-users" autocomplete="off" spellcheck="false"></label>
|
||||
<label>Группа<input type="text" id="pp-group" list="pp-groups" autocomplete="off" spellcheck="false"></label>
|
||||
</div>
|
||||
<datalist id="pp-users"></datalist><datalist id="pp-groups"></datalist>
|
||||
<table class="pp-perm">
|
||||
<thead><tr><th></th><th>Чтение</th><th>Запись</th><th>Выполнение</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><th>Владелец</th><td><input type="checkbox" data-bit="256"></td><td><input type="checkbox" data-bit="128"></td><td><input type="checkbox" data-bit="64"></td></tr>
|
||||
<tr><th>Группа</th><td><input type="checkbox" data-bit="32"></td><td><input type="checkbox" data-bit="16"></td><td><input type="checkbox" data-bit="8"></td></tr>
|
||||
<tr><th>Остальные</th><td><input type="checkbox" data-bit="4"></td><td><input type="checkbox" data-bit="2"></td><td><input type="checkbox" data-bit="1"></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<label class="pp-num">Числовое значение <input type="text" id="pp-mode" maxlength="4" inputmode="numeric" autocomplete="off"></label>
|
||||
<div class="pp-rec" id="pp-rec" hidden>
|
||||
<label class="check"><input type="checkbox" id="pp-recursive"> Применить ко всем вложенным</label>
|
||||
<select id="pp-only" disabled>
|
||||
<option value="">к файлам и папкам</option>
|
||||
<option value="files">только к файлам</option>
|
||||
<option value="dirs">только к папкам</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="pp-act"><button type="button" id="pp-apply">Применить</button></div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="machinedlg" class="machinedlg" tabindex="-1"><div class="dlg-h"><strong class="md-title" style="flex:1"></strong>
|
||||
<button type="button" class="btn ghost small icon close x md-close" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>
|
||||
<div class="frag-area"></div></dialog>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script>window.APP = {admin: {{.IsAdmin}}, open: {{.Open}}, dialog: {{.Dialog}}, editLimit: {{.EditLimit}}, prefs: {{.Prefs}}};</script>
|
||||
<script src="{{asset "wsutil.js"}}"></script>
|
||||
<script src="{{asset "ui.js"}}"></script>
|
||||
<script src="{{asset "prefs.js"}}"></script>
|
||||
<script src="{{asset "xterm.js"}}"></script>
|
||||
<script src="{{asset "addon-fit.js"}}"></script>
|
||||
<script src="{{asset "app.js"}}"></script>
|
||||
<script src="{{asset "workspace.js"}}"></script>
|
||||
<script src="{{asset "termpane.js"}}"></script>
|
||||
<script src="{{asset "codemirror/codemirror.js"}}"></script>
|
||||
<script src="{{asset "docview.js"}}"></script>
|
||||
<script src="{{asset "dbedit.js"}}"></script>
|
||||
<script src="{{asset "fileviews.js"}}"></script>
|
||||
<script src="{{asset "filepane.js"}}"></script>
|
||||
<script src="{{asset "dnd.js"}}"></script>
|
||||
<script src="{{asset "pages.js"}}"></script>
|
||||
<script src="{{asset "users.js"}}"></script>
|
||||
<script src="{{asset "account.js"}}"></script>
|
||||
<script src="{{asset "machine.js"}}"></script>
|
||||
<script src="{{asset "ai.js"}}"></script>
|
||||
<script>App.start();</script>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
@@ -1,27 +1,9 @@
|
||||
<!doctype html>
|
||||
<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>
|
||||
{{define "dashboard-body"}}<div class="page" data-module="dashboard">
|
||||
<div class="wrap">
|
||||
<h1>Машины-клиенты</h1>
|
||||
<div class="card">
|
||||
{{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>
|
||||
<tbody>
|
||||
{{range .Agents}}
|
||||
@@ -33,23 +15,23 @@
|
||||
<td class="muted nowrap seen" data-l="Активность">{{.Seen}}</td>
|
||||
<td class="actions">
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</a>
|
||||
{{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}}">
|
||||
<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>
|
||||
@@ -68,39 +50,4 @@
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// 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>
|
||||
</div>{{end}}
|
||||
@@ -1,24 +1,4 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Изменить машину - LPRA</title>
|
||||
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||
<script src="{{asset "pin.js"}}"></script>
|
||||
<script src="{{asset "scroll.js"}}" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Изменить машину</div>
|
||||
<nav>
|
||||
<a href="/">← Машины</a>
|
||||
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
|
||||
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
||||
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
|
||||
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
|
||||
</nav>
|
||||
</header>
|
||||
{{define "edit-body"}}<div class="page" data-module="edit">
|
||||
<div class="wrap">
|
||||
<h1>{{.Agent.Name}}</h1>
|
||||
|
||||
@@ -104,7 +84,7 @@
|
||||
</p>
|
||||
{{if .Outdated}}
|
||||
<form method="post" action="/agents/{{.Agent.ID}}/update"
|
||||
onsubmit="return confirm('Обновить клиента до версии {{.ServerVersion}}? Бинарник будет заменён, сервис перезапущен (без удаления).')">
|
||||
data-confirm="Обновить клиента до версии {{.ServerVersion}}? Бинарник будет заменён, сервис перезапущен (без удаления)." data-danger>
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button type="submit">Обновить клиента</button>
|
||||
</form>
|
||||
@@ -118,11 +98,10 @@
|
||||
<p class="muted">Удаляет машину из панели. Сервис на самой машине продолжит работать,
|
||||
но перестанет быть виден; чтобы подключиться снова - переустановите агента.</p>
|
||||
<form method="post" action="/agents/{{.Agent.ID}}/delete"
|
||||
onsubmit="return confirm('Удалить машину {{.Agent.Name}}?')">
|
||||
data-confirm="Удалить машину {{.Agent.Name}}?" data-danger>
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="btn danger" type="submit">Удалить машину</button>
|
||||
</form>
|
||||
</div>{{end}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</div>{{end}}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,12 +1,4 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Порты · {{.Agent.Name}} - LPRA</title>
|
||||
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||
<script src="{{asset "pin.js"}}"></script>
|
||||
<script src="{{asset "scroll.js"}}" defer></script>
|
||||
{{define "forwards-body"}}<div class="page" data-module="forwards" data-panel-port="{{.PanelPort}}" data-tunnel-port="{{.TunnelPort}}">
|
||||
<style>
|
||||
/* One line: fixed narrow fields, the label takes what is left. */
|
||||
.frow { display:flex; align-items:flex-end; gap:12px; flex-wrap:wrap; }
|
||||
@@ -18,36 +10,21 @@
|
||||
height:40px; box-sizing:border-box; font-size:14px; line-height:1; margin:0;
|
||||
}
|
||||
.frow select { min-width:0; width:100%; padding:0 30px 0 12px; background-position:right 10px center; }
|
||||
/* The ufw switch hangs under the button, centred on it, level with the note. */
|
||||
.frow .act { position:relative; }
|
||||
.frow .act label.check {
|
||||
position:absolute; top:100%; left:50%; transform:translateX(-50%);
|
||||
display:inline-flex; align-items:center; gap:8px; height:20px; margin:12px 0 0;
|
||||
color:var(--text); font-size:14px; cursor:pointer;
|
||||
}
|
||||
.under { min-height:20px; margin:12px 110px 0 0; font-size:13px; line-height:20px; }
|
||||
/* Under the fields: the note, and the ufw switch at the right, under the
|
||||
button - on a line of its own when the room runs out, never over the note. */
|
||||
.under { display:flex; align-items:flex-start; justify-content:space-between; gap:8px 16px; flex-wrap:wrap;
|
||||
min-height:20px; margin:12px 0 0; font-size:13px; line-height:20px; }
|
||||
.under #note { flex:1 1 240px; min-width:0; }
|
||||
.under label.check { flex:none; display:inline-flex; align-items:center; gap:8px; height:20px; margin:0 0 0 auto;
|
||||
color:var(--text); font-size:14px; cursor:pointer; white-space:nowrap; }
|
||||
/* Phones: two fields a line, the label and the button each a line of their own. */
|
||||
@media (max-width: 640px) {
|
||||
.frow .fld, .frow .fld.addr { width:calc(50% - 6px); }
|
||||
.frow .fld.grow { flex:1 1 100%; }
|
||||
.frow .act { display:flex; align-items:center; gap:16px; width:100%; }
|
||||
.frow .act button { flex:1; }
|
||||
.frow .act label.check { position:static; transform:none; margin:0; }
|
||||
.under { margin-right:0; }
|
||||
.frow .act { width:100%; }
|
||||
.frow .act button { width:100%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Порты - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
||||
<nav>
|
||||
<a href="/">← Машины</a>
|
||||
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
|
||||
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
||||
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
|
||||
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<h1>Проброс портов</h1>
|
||||
<p class="muted">Порт сервиса на машине-клиенте становится доступен на порту хоста.
|
||||
@@ -58,7 +35,7 @@
|
||||
<h2>Правила</h2>
|
||||
{{if .Rules}}
|
||||
<table class="stack">
|
||||
<thead><tr><th>Тип</th><th>Хост (порт)</th><th>→ Клиент (порт)</th><th>Слушать на</th><th>Метка</th><th></th></tr></thead>
|
||||
<thead><tr><th>Тип</th><th>Хост (порт)</th><th>→ Клиент (порт)</th><th>Слушать на</th><th>Метка</th><th style="text-align:right">Действие</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Rules}}
|
||||
<tr>
|
||||
@@ -69,7 +46,7 @@
|
||||
<td class="lead">{{.Label}}</td>
|
||||
<td class="actions" style="text-align:right">
|
||||
<form method="post" action="/forwards/{{.ID}}/delete" style="display:inline"
|
||||
onsubmit="return confirm('Удалить правило?')">
|
||||
data-confirm="Удалить правило?" data-danger>
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button class="btn danger small" type="submit">Удалить</button>
|
||||
</form>
|
||||
@@ -114,36 +91,26 @@
|
||||
</div>
|
||||
<div class="act">
|
||||
<button type="submit">Создать</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="under"><span class="muted" id="note"></span>
|
||||
{{if .UfwEnabled}}
|
||||
<label class="check" title="Порт не будет открыт в ufw, и при удалении правила в ufw ничего не изменится">
|
||||
<input type="checkbox" name="no_ufw" value="1"> Без ufw
|
||||
</label>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="under"><span class="muted" id="note"></span></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Режимы</h2>
|
||||
<ul class="muted" style="font-size:14px; line-height:1.7">
|
||||
<li><b>http-lp</b> - туннель поверх HTTP long-poll (через адрес панели, порт {{.PanelPort}}). Работает везде, где доступна панель, но задержка выше.</li>
|
||||
<li><b>http-lp</b> - через соединение агента с панелью (порт {{.PanelPort}}): по WebSocket, а если он недоступен - по HTTP long-poll. Работает везде, где доступна панель, но задержка выше, чем у tcp.</li>
|
||||
<li><b>tcp</b> - прямой TCP-мультиплексор через порт <b>{{.TunnelPort}}</b> хоста. Минимальная задержка, keep-alive, TCP_NODELAY. Нужно открыть {{.TunnelPort}}/tcp на хосте.</li>
|
||||
<li><b>udp</b> - прямой UDP через порт <b>{{.TunnelPort}}</b> хоста. Нужно открыть {{.TunnelPort}}/udp.</li>
|
||||
</ul>
|
||||
<p class="muted" style="font-size:13px">Для tcp/udp агент держит исходящее соединение к <code>host:{{.TunnelPort}}</code>; несколько клиентов и портов мультиплексируются на нём.</p>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
var notes = {
|
||||
http: 'Трафик идёт через порт панели {{.PanelPort}} (long-poll). Порт {{.TunnelPort}} не требуется.',
|
||||
tcp: 'Требуется открытый порт {{.TunnelPort}}/tcp на хосте. Самая низкая задержка.',
|
||||
udp: 'Требуется открытый порт {{.TunnelPort}}/udp на хосте.'
|
||||
};
|
||||
var sel = document.getElementById('proto'), note = document.getElementById('note');
|
||||
function upd(){ note.textContent = notes[sel.value] || ''; }
|
||||
sel.onchange = upd; upd();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</div>{{end}}
|
||||
@@ -0,0 +1,17 @@
|
||||
{{define "head"}}<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="{{asset "codemirror/codemirror.css"}}">
|
||||
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||
<script src="{{asset "pin.js"}}"></script>
|
||||
<script src="{{asset "scroll.js"}}" defer></script>{{end}}
|
||||
|
||||
{{define "topbar"}}<header class="topbar">
|
||||
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · <span id="brandtitle">Удалённый доступ</span></div>
|
||||
<nav>
|
||||
<a href="/" data-nav="machines">Машины</a>
|
||||
<a href="/workspace" data-nav="terminal">WorkSpace</a>
|
||||
{{if .IsAdmin}}<a href="/settings" data-nav="settings">Настройки</a>{{end}}
|
||||
<span id="status" class="muted"></span>
|
||||
<button type="button" class="btn ghost icon acct-btn" id="acct" title="Аккаунт" aria-label="Аккаунт"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg></button>
|
||||
</nav>
|
||||
</header>{{end}}
|
||||
@@ -1,12 +1,4 @@
|
||||
<!doctype html>
|
||||
<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>
|
||||
{{define "settings-body"}}<div class="page" data-module="settings">
|
||||
<style>
|
||||
/* An install command, with a button to copy it at its right. */
|
||||
/* Users: role, machines, password sign-in. */
|
||||
@@ -49,15 +41,6 @@
|
||||
.codeform input[type=number] { width:72px; height:30px; padding:4px 8px; }
|
||||
.codeform .ico { display:inline-flex; align-items:center; gap:6px; height:30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Удалённый доступ</div>
|
||||
<nav>
|
||||
<a href="/">Машины</a>
|
||||
<button type="button" class="btn ghost icon acct-btn" id="acct" title="Аккаунт" aria-label="Аккаунт"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></svg></button>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<h1>Настройки</h1>
|
||||
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
|
||||
@@ -71,7 +54,7 @@
|
||||
<div class="copybox"><pre id="short">{{.InstallShort}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="short" title="Скопировать" aria-label="Скопировать"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div>
|
||||
|
||||
<form method="post" action="/settings/install/code" class="codeform"
|
||||
onsubmit="return confirm('Сделать новый код? Старая короткая ссылка перестанет работать.')">
|
||||
data-confirm="Сделать новый код? Старая короткая ссылка перестанет работать." data-danger>
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label for="codelen">Символов в коде</label>
|
||||
<input type="number" id="codelen" name="len" min="3" max="24" value="{{.CodeLen}}">
|
||||
@@ -105,10 +88,7 @@
|
||||
<p class="muted">URL, на который звонят агенты. Подставляется в скрипт установки.</p>
|
||||
<label for="direct">Прямой (без Cloudflare) - необязательно</label>
|
||||
<input type="text" id="direct" name="direct" value="{{.Direct}}" placeholder="https://direct.lpra.example.com">
|
||||
<p class="muted">Имя, которое ведёт прямо на сервер, мимо прокси. Агенты переходят на него, если основной
|
||||
недоступен 30 секунд, и возвращаются, когда он ответит; уже установленные получат его сами. Установщик
|
||||
качает агента с него, если основной не отвечает. Если хост туннеля ниже не задан, туннель тоже идёт на этот хост.
|
||||
Ключи доступа с основного домена работают на прямом, если он его поддомен (direct.lpra.example.com).</p>
|
||||
<p class="muted">Адрес сервера в обход прокси: агенты и установщик переходят на него, если основной не отвечает.</p>
|
||||
<div><button type="submit">Сохранить</button></div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -133,13 +113,112 @@
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="card" id="db">
|
||||
<h2>База данных</h2>
|
||||
<p class="muted">Сейчас: <b>{{if eq .DBNow "postgres"}}PostgreSQL{{else}}SQLite{{end}}</b>.
|
||||
{{if ne .DBNow .DBChosen.Type}}<span class="err" style="display:inline">Выбрана {{if eq .DBChosen.Type "postgres"}}PostgreSQL{{else}}SQLite{{end}} - перезапустите сервер, чтобы перейти.</span>{{end}}
|
||||
При первом запуске на пустой PostgreSQL всё копируется из SQLite-файла (он остаётся как есть).</p>
|
||||
<form method="post" action="/settings/db" id="dbform" class="ai-form db-form{{if eq .DBChosen.Type "postgres"}} pg{{end}}">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>Хранить в<select name="db_type" onchange="this.form.classList.toggle('pg', this.value === 'postgres')">
|
||||
<option value="sqlite"{{if ne .DBChosen.Type "postgres"}} selected{{end}}>SQLite (файл рядом с сервером)</option>
|
||||
<option value="postgres"{{if eq .DBChosen.Type "postgres"}} selected{{end}}>PostgreSQL</option>
|
||||
</select></label>
|
||||
<div class="pg-fields">
|
||||
<div class="frow2"><label>Хост<input type="text" name="pg_host" value="{{with .DBChosen.PG}}{{.Host}}{{end}}" placeholder="localhost"></label>
|
||||
<label class="port">Порт<input type="number" name="pg_port" min="1" max="65535" value="{{with .DBChosen.PG}}{{if .Port}}{{.Port}}{{end}}{{end}}" placeholder="5432"></label></div>
|
||||
<label>База<input type="text" name="pg_name" value="{{with .DBChosen.PG}}{{.Name}}{{end}}" placeholder="lpra"></label>
|
||||
<label>Пользователь<input type="text" name="pg_user" value="{{with .DBChosen.PG}}{{.User}}{{end}}" autocomplete="off"></label>
|
||||
<label>Пароль<input type="password" name="pg_password" placeholder="{{with .DBChosen.PG}}{{if .Password}}задан - пусто, чтобы оставить{{end}}{{end}}" autocomplete="new-password"></label>
|
||||
<label>SSL<select name="pg_sslmode">
|
||||
{{$m := "prefer"}}{{with .DBChosen.PG}}{{if .SSLMode}}{{$m = .SSLMode}}{{end}}{{end}}
|
||||
<option value="disable"{{if eq $m "disable"}} selected{{end}}>disable</option>
|
||||
<option value="prefer"{{if eq $m "prefer"}} selected{{end}}>prefer</option>
|
||||
<option value="require"{{if eq $m "require"}} selected{{end}}>require</option>
|
||||
<option value="verify-full"{{if eq $m "verify-full"}} selected{{end}}>verify-full</option>
|
||||
</select></label>
|
||||
</div>
|
||||
</form>
|
||||
<div class="row db-actions">
|
||||
<button type="submit" form="dbform">Проверить и сохранить</button>
|
||||
<form method="post" action="/settings/db/restart" data-confirm="Перезапустить сервер? Терминалы и передачи прервутся, страница переподключится сама." data-danger>
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button type="submit" class="btn ghost">Перезапустить сервер</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="ai">
|
||||
<h2>ИИ-ассистент</h2>
|
||||
<form method="post" action="/settings/ai" class="ai-form" data-search="{{.Search.Engine}}">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<p class="muted" style="margin:0">Моделей может быть несколько - в чате появится выбор; первая - по умолчанию.</p>
|
||||
<div class="ai-models">
|
||||
{{range .AIModels}} <details class="ai-model"><summary class="ai-model-sum"><span class="ai-model-title">{{if .Name}}{{.Name}}{{else}}Модель{{end}}</span><span class="ai-model-sub">{{.Type}}{{if .Model}} · {{.Model}}{{end}}</span></summary>
|
||||
<div class="ai-model-body">
|
||||
<input type="hidden" name="m_id" value="{{.ID}}">
|
||||
<div class="ai-model-head"><label class="ai-model-name">Название в чате<input type="text" name="m_name" value="{{.Name}}" placeholder="например, Claude"></label>
|
||||
<label class="ai-model-short">Коротко<input type="text" name="m_short" value="{{.Short}}" maxlength="12" placeholder="до 12" title="Название на кнопке модели в чате, до 12 символов"></label>
|
||||
<button type="button" class="btn ghost small close ai-model-del" title="Удалить модель" aria-label="Удалить модель">Удалить</button></div>
|
||||
<label>Тип API<select name="m_type">
|
||||
<option value="gradio"{{if eq .Type "gradio"}} selected{{end}}>Gradio (Qwen Omni через прокси)</option>
|
||||
<option value="openai"{{if eq .Type "openai"}} selected{{end}}>OpenAI-совместимый</option>
|
||||
<option value="anthropic"{{if eq .Type "anthropic"}} selected{{end}}>Anthropic</option>
|
||||
</select></label>
|
||||
<label>Адрес<input type="url" name="m_url" value="{{.URL}}" placeholder="https://api.openai.com/v1"></label>
|
||||
<label>Модель<input type="text" name="m_model" value="{{.Model}}" placeholder="для OpenAI и Anthropic"></label>
|
||||
<label>Ключ<span class="keybox"><input type="password" name="m_key" value="{{.Key}}" placeholder="не задан" autocomplete="off" spellcheck="false">
|
||||
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
|
||||
</div></details>
|
||||
{{end}} </div>
|
||||
<template class="ai-model-new">
|
||||
<details class="ai-model" open><summary class="ai-model-sum"><span class="ai-model-title">Новая модель</span><span class="ai-model-sub"></span></summary>
|
||||
<div class="ai-model-body">
|
||||
<input type="hidden" name="m_id" value="">
|
||||
<div class="ai-model-head"><label class="ai-model-name">Название в чате<input type="text" name="m_name" value="" placeholder="например, Claude"></label>
|
||||
<label class="ai-model-short">Коротко<input type="text" name="m_short" value="" maxlength="12" placeholder="до 12" title="Название на кнопке модели в чате, до 12 символов"></label>
|
||||
<button type="button" class="btn ghost small close ai-model-del" title="Удалить модель" aria-label="Удалить модель">Удалить</button></div>
|
||||
<label>Тип API<select name="m_type">
|
||||
<option value="gradio">Gradio (Qwen Omni через прокси)</option>
|
||||
<option value="openai">OpenAI-совместимый</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
</select></label>
|
||||
<label>Адрес<input type="url" name="m_url" value="" placeholder="https://api.openai.com/v1"></label>
|
||||
<label>Модель<input type="text" name="m_model" value="" placeholder="для OpenAI и Anthropic"></label>
|
||||
<label>Ключ<span class="keybox"><input type="password" name="m_key" value="" placeholder="не задан" autocomplete="off" spellcheck="false">
|
||||
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
|
||||
</div></details>
|
||||
</template>
|
||||
<div><button type="button" class="btn ghost small ai-model-add">+ Добавить модель</button></div>
|
||||
<details class="ai-search"><summary class="ai-model-sum"><span class="ai-model-title">Поиск в интернете</span><span class="ai-model-sub">Запасной - DuckDuckGo</span></summary>
|
||||
<div class="ai-model-body">
|
||||
<label>Поисковик<select name="ai_search" onchange="this.form.dataset.search = this.value">
|
||||
<option value=""{{if eq .Search.Engine ""}} selected{{end}}>DuckDuckGo - бесплатно, без настройки</option>
|
||||
<option value="searxng"{{if eq .Search.Engine "searxng"}} selected{{end}}>SearXNG - свой сервер</option>
|
||||
<option value="brave"{{if eq .Search.Engine "brave"}} selected{{end}}>Brave Search API - нужен ключ</option>
|
||||
<option value="tavily"{{if eq .Search.Engine "tavily"}} selected{{end}}>Tavily - API для ИИ, нужен ключ</option>
|
||||
</select></label>
|
||||
<label class="s-url">Адрес SearXNG<input type="url" name="ai_search_url" value="{{.Search.URL}}" placeholder="http://127.0.0.1:8888 (в settings.yml: search.formats - json)"></label>
|
||||
<label class="s-key">Ключ поисковика<span class="keybox"><input type="password" name="ai_search_key" value="{{.Search.Key}}" placeholder="не задан" autocomplete="off" spellcheck="false">
|
||||
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
|
||||
</div></details>
|
||||
<details class="ai-search"><summary class="ai-model-sum"><span class="ai-model-title">Генерация изображений</span><span class="ai-model-sub">{{if .ImageKey}}включена{{else}}ключ не задан{{end}}</span></summary>
|
||||
<div class="ai-model-body">
|
||||
<p class="muted" style="margin:0">Модели сами рисуют и правят фото, когда это нужно по разговору, - через <a href="https://s2.x-grk.com/api/vertex/docs" target="_blank" rel="noopener">Vertex API</a>. Нет ключа - нет и инструмента.</p>
|
||||
<label>API-ключ<span class="keybox"><input type="password" name="ai_image_key" value="{{.ImageKey}}" placeholder="vx_… не задан" autocomplete="off" spellcheck="false">
|
||||
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
|
||||
</div></details>
|
||||
<div><button type="submit">Сохранить</button></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Enroll-секрет</h2>
|
||||
<p class="muted">Общий секрет для регистрации новых агентов.</p>
|
||||
<div class="row">
|
||||
<code>{{.Secret}}</code>
|
||||
<form method="post" action="/settings/secret/rotate" style="display:inline"
|
||||
onsubmit="return confirm('Сменить секрет? Старые one-liner перестанут работать для новых установок.')">
|
||||
data-confirm="Сменить секрет? Старые one-liner перестанут работать для новых установок." data-danger>
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="btn ghost small" type="submit">Сменить</button>
|
||||
</form>
|
||||
@@ -171,14 +250,14 @@
|
||||
<td style="text-align:right" class="nowrap">
|
||||
{{if index $.KeyCounts .ID}}
|
||||
<form method="post" action="/settings/admins/keys/reset" style="display:inline"
|
||||
onsubmit="return confirm('Сбросить все ключи доступа {{.Username}}? Вход по паролю снова включится.')">
|
||||
data-confirm="Сбросить все ключи доступа {{.Username}}? Вход по паролю снова включится." data-danger>
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button class="btn ghost small" type="submit" title="Удалить все ключи доступа и снова включить вход по паролю">Сбросить ключи</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="post" action="/settings/admins/delete" style="display:inline"
|
||||
onsubmit="return confirm('Удалить {{.Username}}?')">
|
||||
data-confirm="Удалить {{.Username}}?" data-danger>
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button class="btn danger small" type="submit">Удалить</button>
|
||||
@@ -248,7 +327,7 @@
|
||||
<td class="muted">{{if .Manual}}вручную{{else if .RuleID}}проброс{{else}}туннель{{end}}</td>
|
||||
<td style="text-align:right">
|
||||
<form method="post" action="/settings/ufw/revoke" style="display:inline"
|
||||
onsubmit="return confirm('Отменить правило ufw {{.Port}}/{{.Proto}}?')">
|
||||
data-confirm="Отменить правило ufw {{.Port}}/{{.Proto}}?" data-danger>
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
<button class="btn danger small" type="submit">Отменить</button>
|
||||
@@ -280,33 +359,4 @@
|
||||
|
||||
<p class="muted" style="font-size:12px; text-align:center">LPRA сервер · версия {{.ServerVersion}}</p>
|
||||
</div>
|
||||
<script>
|
||||
// Copy an install command: the clipboard API on https, the old way otherwise.
|
||||
(function () {
|
||||
var CHECK = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
|
||||
function copy(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
var ok = document.execCommand('copy');
|
||||
ta.remove();
|
||||
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
|
||||
}
|
||||
document.querySelectorAll('.copybox .copy').forEach(function (b) {
|
||||
var icon = b.innerHTML;
|
||||
b.onclick = function () {
|
||||
copy(document.getElementById(b.dataset.copy).textContent.trim()).then(function () {
|
||||
b.innerHTML = CHECK; b.classList.add('done'); b.title = 'Скопировано';
|
||||
setTimeout(function () { b.innerHTML = icon; b.classList.remove('done'); b.title = 'Скопировать'; }, 1500);
|
||||
}, function (e) { alert('Не скопировалось: ' + e.message); });
|
||||
};
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script src="{{asset "users.js"}}"></script>
|
||||
<script src="{{asset "account.js"}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
</div>{{end}}
|
||||
@@ -1,12 +1,4 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Статус · {{.Agent.Name}} - LPRA</title>
|
||||
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||
<script src="{{asset "pin.js"}}"></script>
|
||||
<script src="{{asset "scroll.js"}}" defer></script>
|
||||
{{define "status-body"}}<div class="page" data-module="status" data-agent="{{.Agent.ID}}" data-relay-up-ms="{{.RelayUpMs}}">
|
||||
<style>
|
||||
.meters { display:grid; grid-template-columns:repeat(auto-fit, minmax(240px, 1fr)); gap:20px; margin-bottom:20px; }
|
||||
.meters .card { margin:0; }
|
||||
@@ -41,18 +33,6 @@
|
||||
.live.up .spark polygon { fill:var(--ok); }
|
||||
label.check { display:inline-flex; align-items:center; gap:8px; margin:0; color:var(--text); font-size:14px; cursor:pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Статус - {{.Agent.Name}} <span class="muted">({{.Agent.Hostname}})</span></div>
|
||||
<nav>
|
||||
<a href="/">← Машины</a>
|
||||
<a href="/agents/{{.Agent.ID}}/terminal">Терминал</a>
|
||||
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
||||
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
|
||||
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<h1>Статус</h1>
|
||||
<div class="err" id="err" hidden></div>
|
||||
@@ -111,238 +91,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var base = '/api/panel/agents/' + encodeURIComponent({{.Agent.ID}});
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
var errBox = $('err');
|
||||
|
||||
function size(n) {
|
||||
var u = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ'], i = 0;
|
||||
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||||
return n.toFixed(n < 10 && i > 0 ? 1 : 0) + ' ' + u[i];
|
||||
}
|
||||
function rateParts(bps) {
|
||||
var u = ['бит/с', 'Кбит/с', 'Мбит/с', 'Гбит/с'], i = 0;
|
||||
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++; }
|
||||
return [bps.toFixed(bps < 100 && i > 0 ? 1 : 0), u[i]];
|
||||
}
|
||||
function rate(bps) { return rateParts(bps).join(' '); }
|
||||
function meter(name, pct, sub) {
|
||||
pct = Math.max(0, Math.min(100, pct));
|
||||
$(name + '-val').textContent = Math.round(pct) + '%';
|
||||
var bar = $(name + '-bar');
|
||||
bar.style.transform = 'scaleX(' + pct / 100 + ')';
|
||||
bar.className = pct >= 90 ? 'crit' : pct >= 75 ? 'warn' : '';
|
||||
$(name + '-sub').textContent = sub;
|
||||
}
|
||||
function showErr(msg) {
|
||||
errBox.textContent = msg || '';
|
||||
errBox.hidden = !msg;
|
||||
}
|
||||
function cores(n) {
|
||||
var d = n % 10, h = n % 100;
|
||||
return n + (d === 1 && h !== 11 ? ' ядро' : d >= 2 && d <= 4 && (h < 12 || h > 14) ? ' ядра' : ' ядер');
|
||||
}
|
||||
|
||||
// One request at a time, and none while the tab is hidden: the client is
|
||||
// sampled only for a page somebody is looking at.
|
||||
var timer = null, busy = false;
|
||||
function schedule(ms) {
|
||||
clearTimeout(timer);
|
||||
if (!document.hidden) timer = setTimeout(load, ms);
|
||||
}
|
||||
function load() {
|
||||
if (busy || document.hidden) return;
|
||||
busy = true;
|
||||
var next = 3000;
|
||||
fetch(base + '/stat', {credentials: 'same-origin'})
|
||||
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||
.then(function (res) {
|
||||
if (!res.ok) { showErr(res.error || 'ошибка'); next = 10000; return; }
|
||||
showErr('');
|
||||
var d = res.data;
|
||||
meter('cpu', d.cpu, cores(d.ncpu));
|
||||
meter('mem', d.mem_total ? 100 * d.mem_used / d.mem_total : 0,
|
||||
'занято ' + size(d.mem_used) + ' из ' + size(d.mem_total) + ' · свободно ' + size(d.mem_total - d.mem_used));
|
||||
meter('disk', d.disk_total ? 100 * (d.disk_total - d.disk_free) / d.disk_total : 0,
|
||||
'свободно ' + size(d.disk_free) + ' из ' + size(d.disk_total));
|
||||
})
|
||||
.catch(function (e) { showErr('Нет связи с панелью: ' + e.message); next = 10000; })
|
||||
.then(function () { busy = false; schedule(next); });
|
||||
}
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.hidden) clearTimeout(timer); else load();
|
||||
});
|
||||
load();
|
||||
|
||||
var btn = $('speed-btn'), note = $('speed-note');
|
||||
function json(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }
|
||||
function post(path) { return fetch(base + path, {method: 'POST', credentials: 'same-origin'}).then(json); }
|
||||
|
||||
// The browser's round trip to the panel: the first request may pay for a
|
||||
// new connection, so keep the best.
|
||||
function panelPing() {
|
||||
var best = Infinity, left = 4;
|
||||
function one() {
|
||||
var t = performance.now();
|
||||
return fetch('/api/panel/ping', {credentials: 'same-origin', cache: 'no-store'})
|
||||
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); })
|
||||
.then(function () {
|
||||
best = Math.min(best, performance.now() - t);
|
||||
return --left > 0 ? one() : best;
|
||||
});
|
||||
}
|
||||
return one();
|
||||
}
|
||||
// Keeps the client's download fed until stopped or the panel says the
|
||||
// client has read enough; the pieces grow while they go through quickly.
|
||||
function feed(stop) {
|
||||
var block = new Uint8Array(65536), n = 4;
|
||||
crypto.getRandomValues(block);
|
||||
function one() {
|
||||
var parts = [], t = performance.now();
|
||||
for (var i = 0; i < n; i++) parts.push(block);
|
||||
return fetch(base + '/speed/down', {method: 'POST', body: new Blob(parts), credentials: 'same-origin', signal: stop.signal})
|
||||
.then(function (r) {
|
||||
if (r.status !== 204) return;
|
||||
if (performance.now() - t < 500 && n < 256) n *= 2;
|
||||
return one();
|
||||
});
|
||||
}
|
||||
one().catch(function () {});
|
||||
}
|
||||
// Reads what the client sends and times it from the first byte. It stops by
|
||||
// itself a little after the client's window, so bytes still queued on a slow
|
||||
// link don't hold the result back. st.n is the count so far, for the live
|
||||
// reading.
|
||||
function drain(st, onFirst) {
|
||||
var ac = new AbortController(), last = 0;
|
||||
st.stop = function () { ac.abort(); };
|
||||
function done() { return st.start && last > st.start ? st.n * 8000 / (last - st.start) : 0; }
|
||||
return fetch(base + '/speed/up', {credentials: 'same-origin', cache: 'no-store', signal: ac.signal}).then(function (r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
var rd = r.body.getReader();
|
||||
function pump() {
|
||||
return rd.read().then(function (c) {
|
||||
if (c.done) return done();
|
||||
last = performance.now();
|
||||
if (!st.start) { st.start = last; setTimeout(st.stop, {{.RelayUpMs}}); onFirst(); }
|
||||
else st.n += c.value.length;
|
||||
return pump();
|
||||
});
|
||||
}
|
||||
return pump();
|
||||
}).catch(function (e) { if (st.start) return done(); throw e; });
|
||||
}
|
||||
// The test through the browser: the client's download is what this page
|
||||
// uploads, and its upload is timed here, where it arrives.
|
||||
function relayTest(st) {
|
||||
return panelPing().then(function (ping) {
|
||||
return post('/speed/relay').then(function (res) {
|
||||
if (!res.ok) return res;
|
||||
var stop = new AbortController();
|
||||
var up = drain(st, function () { stop.abort(); });
|
||||
up.catch(function () {});
|
||||
feed(stop);
|
||||
return post('/speed?relay=1').then(function (res) {
|
||||
stop.abort();
|
||||
// No upload under way by now: none is coming.
|
||||
if (!res.ok || !st.start) { st.stop(); }
|
||||
if (!res.ok) return res;
|
||||
return up.catch(function () { return 0; }).then(function (bps) {
|
||||
res.data.ping_ms += ping;
|
||||
if (bps) res.data.up_bps = bps;
|
||||
return res;
|
||||
});
|
||||
}, function (e) { stop.abort(); st.stop(); throw e; });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// The live reading: the rate over the last second, from the byte counts the
|
||||
// panel keeps for the running test - or, for the client's upload in a test
|
||||
// through the browser, from what has arrived here.
|
||||
var ARC = 251.4, lv = null;
|
||||
function liveStart(relay, st) {
|
||||
lv = {relay: relay, st: st, phase: '', pts: [], hist: [], busy: false};
|
||||
$('live').hidden = false;
|
||||
liveShow('ping', 0);
|
||||
lv.timer = setInterval(liveTick, 200);
|
||||
}
|
||||
function liveStop() {
|
||||
if (!lv) return;
|
||||
clearInterval(lv.timer);
|
||||
lv = null;
|
||||
$('live').hidden = true;
|
||||
}
|
||||
function liveTick() {
|
||||
var cur = lv;
|
||||
if (cur.relay && cur.st.start) { livePoint(cur, 'up', cur.st.n, performance.now()); return; }
|
||||
if (cur.busy) return;
|
||||
cur.busy = true;
|
||||
fetch(base + '/speed/progress', {credentials: 'same-origin', cache: 'no-store'})
|
||||
.then(json)
|
||||
.then(function (d) {
|
||||
if (cur !== lv || !d.phase || (cur.relay && cur.st.start)) return;
|
||||
livePoint(cur, d.phase, d.phase === 'up' ? d.up : d.down, d.ms);
|
||||
})
|
||||
.catch(function () {})
|
||||
.then(function () { cur.busy = false; });
|
||||
}
|
||||
function livePoint(cur, phase, bytes, ms) {
|
||||
if (phase !== cur.phase) { cur.phase = phase; cur.pts = []; cur.hist = []; }
|
||||
if (phase === 'ping') { liveShow(phase, 0); return; }
|
||||
var pts = cur.pts;
|
||||
pts.push([ms, bytes]);
|
||||
while (pts.length > 2 && ms - pts[0][0] > 1000) pts.shift();
|
||||
if (pts.length < 2 || ms <= pts[0][0]) return;
|
||||
var bps = (bytes - pts[0][1]) * 8000 / (ms - pts[0][0]);
|
||||
cur.hist.push(bps);
|
||||
liveShow(phase, bps, cur);
|
||||
}
|
||||
function liveShow(phase, bps, cur) {
|
||||
var relay = lv && lv.relay, p = rateParts(bps);
|
||||
$('live').className = 'live' + (phase === 'up' ? ' up' : '');
|
||||
$('lv-ico').textContent = phase === 'up' ? '↑' : phase === 'down' ? '↓' : '';
|
||||
$('lv-phase').textContent = phase === 'ping' ? 'Задержка…'
|
||||
: phase === 'up' ? (relay ? 'клиент → браузер' : 'клиент → хост')
|
||||
: (relay ? 'браузер → клиент' : 'хост → клиент');
|
||||
$('lv-num').textContent = phase === 'ping' ? '…' : p[0];
|
||||
$('lv-unit').textContent = phase === 'ping' ? '' : p[1];
|
||||
// A log scale up to 1 Гбит/с, so a slow link moves the needle too.
|
||||
var f = Math.min(1, Math.log(1 + bps / 1e6) / Math.log(1001));
|
||||
$('lv-arc').style.strokeDashoffset = ARC * (1 - f);
|
||||
var h = cur ? cur.hist : [], max = 1.25 * Math.max.apply(null, h.concat(1)), line = '';
|
||||
for (var i = 0; i < h.length; i++) {
|
||||
line += (h.length > 1 ? 100 * i / (h.length - 1) : 0).toFixed(1) + ',' + (98 - 94 * h[i] / max).toFixed(1) + ' ';
|
||||
}
|
||||
$('lv-line').setAttribute('points', line);
|
||||
$('lv-area').setAttribute('points', h.length > 1 ? '0,100 ' + line + '100,100' : '');
|
||||
}
|
||||
|
||||
btn.onclick = function () {
|
||||
var relay = $('speed-relay').checked, st = {n: 0, start: 0, stop: function () {}};
|
||||
btn.disabled = true;
|
||||
note.textContent = 'Идёт замер…';
|
||||
$('speed').hidden = true;
|
||||
liveStart(relay, st);
|
||||
(relay ? relayTest(st) : post('/speed'))
|
||||
.then(function (res) {
|
||||
if (!res.ok) { note.textContent = res.error || 'ошибка'; return; }
|
||||
var d = res.data;
|
||||
$('sp-ping').textContent = d.ping_ms ? d.ping_ms.toFixed(d.ping_ms < 10 ? 1 : 0) + ' мс' : '-';
|
||||
$('sp-down').textContent = rate(d.down_bps);
|
||||
$('sp-up').textContent = d.up_bps ? rate(d.up_bps) : '-';
|
||||
$('sp-down-lbl').textContent = relay ? 'браузер → клиент' : 'хост → клиент';
|
||||
$('sp-up-lbl').textContent = relay ? 'клиент → браузер' : 'клиент → хост';
|
||||
$('speed').hidden = false;
|
||||
note.textContent = d.partial ? 'Замер остановлен по времени - показано то, что успело набраться' : '';
|
||||
})
|
||||
.catch(function (e) { note.textContent = 'Не удалось: ' + e.message; })
|
||||
.then(function () { liveStop(); btn.disabled = false; });
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</div>{{end}}
|
||||
@@ -1,423 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Терминал · {{.Agent.Name}} - LPRA</title>
|
||||
<link rel="stylesheet" href="{{asset "app.css"}}">
|
||||
<script src="{{asset "pin.js"}}"></script>
|
||||
<script src="{{asset "scroll.js"}}" defer></script>
|
||||
<link rel="stylesheet" href="{{asset "xterm.css"}}">
|
||||
<style>
|
||||
#status.st-ok { color:#37c871; }
|
||||
#status.st-err { color:#ff5c5c; }
|
||||
#status { display:inline-block; vertical-align:bottom; min-width:0; max-width:42vw; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
/* SSH: the badge on a pane, and the dialog to connect. */
|
||||
.badge-ssh { flex:none; font-size:10px; font-weight:600; letter-spacing:.04em; padding:1px 5px; border-radius:4px;
|
||||
background:#12202f; color:var(--accent); border:1px solid #1f3a57; }
|
||||
dialog.sshdlg { max-width:460px; }
|
||||
.sshdlg .dlg-h .ico { color:var(--accent); display:inline-flex; }
|
||||
.sshdlg .dlg-b { display:grid; gap:12px; }
|
||||
.sshdlg .f { display:grid; gap:5px; margin:0; }
|
||||
.sshdlg .f > span { font-size:12px; color:var(--muted); }
|
||||
.sshdlg .frow { display:flex; gap:10px; align-items:end; }
|
||||
.sshdlg .frow .grow { flex:1; min-width:0; }
|
||||
.sshdlg .frow .port { width:92px; flex:none; }
|
||||
.sshdlg input, .sshdlg select, .sshdlg textarea { width:100%; box-sizing:border-box; }
|
||||
.sshdlg textarea { font:12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; resize:vertical; min-height:90px;
|
||||
background:var(--bg, #0c0f13); color:var(--text); border:1px solid var(--border); border-radius:8px; padding:8px 10px; }
|
||||
.sshdlg textarea:focus { border-color:var(--accent); outline:none; }
|
||||
.sshdlg .auth { display:grid; gap:8px; animation:auth-in .18s ease; }
|
||||
.sshdlg .auth[hidden] { display:none; }
|
||||
@keyframes auth-in { from { opacity:0; transform:translateY(-4px); } }
|
||||
.sshdlg p.auth { margin:0; font-size:13px; }
|
||||
.sshdlg .keyname { font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; align-self:center; }
|
||||
.sshdlg .seg { display:flex; padding:3px; gap:3px; background:#0c0f13; border:1px solid var(--border); border-radius:9px; }
|
||||
.sshdlg .seg button { flex:1; padding:6px 8px; font-size:13px; background:transparent; color:var(--muted); border:0; border-radius:6px;
|
||||
transition:background .18s, color .18s; }
|
||||
.sshdlg .seg button:hover { color:var(--text); }
|
||||
.sshdlg .seg button.on { background:#12202f; color:var(--text); box-shadow:inset 0 0 0 1px #1f3a57; }
|
||||
.sshdlg .sshmsg { margin:0; min-height:18px; font-size:13px; color:var(--danger); }
|
||||
.sshdlg .row.end { display:flex; justify-content:flex-end; gap:8px; }
|
||||
@media (max-width:520px) { .sshdlg .seg button { font-size:12px; padding:6px 4px; } }
|
||||
/* The page is the terminal: bar, tabs, then the terminals to the bottom. */
|
||||
body { height:100vh; display:flex; flex-direction:column; overflow:hidden; }
|
||||
header.topbar { flex:none; }
|
||||
/* Tabs: folded away while there is one, sliding in with the second. */
|
||||
.tabs { flex:none; display:flex; align-items:flex-end; gap:4px; padding:0 16px; max-height:0; opacity:0; overflow:hidden;
|
||||
transition:max-height .28s ease, opacity .22s ease, padding .28s ease; }
|
||||
body.multi .tabs, body.multi-hint .tabs { max-height:48px; opacity:1; padding-top:10px; }
|
||||
.tablist { position:relative; display:flex; gap:4px; min-width:0; overflow-x:auto; scrollbar-width:none; }
|
||||
.tablist::-webkit-scrollbar { display:none; }
|
||||
.tab { display:flex; align-items:center; gap:8px; flex:none; min-width:0; height:34px; padding:0 6px 0 12px; box-sizing:border-box;
|
||||
border:1px solid var(--border); border-bottom:0; border-radius:9px 9px 0 0; background:#11151b; color:var(--muted);
|
||||
font-size:13px; user-select:none; cursor:default; overflow:hidden; white-space:nowrap;
|
||||
transition:background .18s, color .18s, width .22s ease, max-width .22s ease, opacity .2s, padding .22s ease; }
|
||||
.tab:hover { color:var(--text); background:#151a21; }
|
||||
.tab.on { background:var(--panel); color:var(--text); }
|
||||
.tab.enter, .tab.leave { max-width:0; opacity:0; padding:0; }
|
||||
.tab.dragging { opacity:.3; }
|
||||
.tab-title { flex:1; overflow:hidden; text-overflow:ellipsis; min-width:0; }
|
||||
.tab-edit { width:auto; min-width:60px; max-width:150px; height:24px; padding:2px 6px; font-size:13px; }
|
||||
.tab-x, .pane-x { flex:none; width:20px; height:20px; padding:0; display:inline-flex; align-items:center; justify-content:center;
|
||||
background:transparent; border:0; border-radius:5px; color:var(--muted); opacity:.6; transition:opacity .15s, background .15s, color .15s; }
|
||||
.tab-x:hover, .pane-x:hover { opacity:1; background:#3a1d1d; color:var(--danger); }
|
||||
.tab-add { flex:none; width:34px; height:34px; padding:0; display:inline-flex; align-items:center; justify-content:center;
|
||||
border:1px solid var(--border); border-bottom:0; border-radius:9px 9px 0 0; background:transparent; color:var(--muted);
|
||||
font-size:18px; line-height:1; transition:background .18s, color .18s; }
|
||||
.tab-add:hover { background:#151a21; color:var(--text); }
|
||||
.tabmark { position:absolute; top:6px; bottom:0; width:3px; margin-left:-1px; border-radius:2px; background:var(--accent);
|
||||
pointer-events:none; transition:left .12s ease; }
|
||||
.tabmark[hidden] { display:none; }
|
||||
.dot { width:7px; height:7px; border-radius:50%; background:#555d6a; flex:none; transition:background .3s; }
|
||||
.dot.ok { background:var(--ok); } .dot.err { background:var(--danger); padding:0; border:0; margin:0; } .dot.wait { background:#e6a23c; }
|
||||
/* The terminals' area. */
|
||||
.area { position:relative; flex:1; min-height:0; margin:12px 16px 16px; border:1px solid var(--border); border-radius:10px; background:#0c0f13; overflow:hidden; }
|
||||
body.multi .area { margin-top:0; border-top-left-radius:0; }
|
||||
.float-add { position:absolute; top:8px; right:10px; z-index:6; width:28px; height:28px; padding:0; display:inline-flex;
|
||||
align-items:center; justify-content:center; font-size:17px; line-height:1; color:var(--text);
|
||||
background:transparent; border:1px solid var(--border); opacity:.28; transition:opacity .25s ease, background .25s ease, top .25s ease, right .25s ease; }
|
||||
/* One tab split in panes: below the panes' title bars, clear of their close buttons. */
|
||||
body.split1 .float-add { top:42px; right:16px; }
|
||||
.float-add:hover, .float-add:focus-visible { opacity:1; background:var(--panel); }
|
||||
body.multi .float-add { opacity:0; pointer-events:none; }
|
||||
.tabview { position:absolute; inset:8px; opacity:0; visibility:hidden; transform:translateY(4px) scale(.995);
|
||||
transition:opacity .22s ease, transform .22s ease, visibility 0s .22s; }
|
||||
.tabview.on { opacity:1; visibility:visible; transform:none; transition:opacity .22s ease, transform .22s ease, visibility 0s; }
|
||||
.pane { position:absolute; display:flex; flex-direction:column; border-radius:7px; overflow:hidden;
|
||||
transition:left .26s ease, top .26s ease, width .26s ease, height .26s ease, border-color .2s; }
|
||||
.tabview.instant .pane, .pane.instant { transition:none; }
|
||||
.pane-head { display:none; }
|
||||
.tabview.split .pane { border:1px solid var(--border); }
|
||||
.tabview.split .pane.active { border-color:rgba(79,157,255,.7); }
|
||||
.tabview.split .pane-head { display:flex; align-items:center; gap:7px; flex:none; height:26px; padding:0 4px 0 9px;
|
||||
background:#11151b; border-bottom:1px solid var(--border); font-size:12px; color:var(--muted); cursor:grab; user-select:none; }
|
||||
.tabview.split .pane.active .pane-head { color:var(--text); }
|
||||
.pane-title { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.pane-body { flex:1; min-height:0; padding:4px 0 0 4px; }
|
||||
/* A lone terminal's scrollbar sits in the margin by the frame, right of
|
||||
the corner "+", not over the text. */
|
||||
body:not(.touch) .tabview:not(.split) .pane { overflow:visible; }
|
||||
body:not(.touch) .tabview:not(.split) .xterm .xterm-viewport { right:-7px; }
|
||||
.pane.dragging { opacity:.45; }
|
||||
.gutter { position:absolute; z-index:3; }
|
||||
.gutter.c { cursor:col-resize; } .gutter.r { cursor:row-resize; }
|
||||
.gutter::after { content:""; position:absolute; inset:0; border-radius:3px; background:var(--accent); opacity:0; transition:opacity .15s; }
|
||||
.gutter:hover::after, .gutter.drag::after { opacity:.55; }
|
||||
.hint { position:absolute; z-index:5; display:flex; align-items:center; justify-content:center; text-align:center; padding:8px;
|
||||
border:2px solid var(--accent); border-radius:9px; background:rgba(79,157,255,.14); color:var(--text); font-size:13px;
|
||||
pointer-events:none; transition:left .14s ease, top .14s ease, width .14s ease, height .14s ease; }
|
||||
.hint.bad { border-color:var(--danger); background:rgba(255,92,92,.12); }
|
||||
.hint[hidden] { display:none; }
|
||||
.drag-ghost { position:fixed; left:0; top:0; z-index:60; pointer-events:none; padding:7px 12px; border:1px solid var(--accent);
|
||||
border-radius:8px; background:var(--panel); color:var(--text); font-size:13px; white-space:nowrap;
|
||||
box-shadow:0 12px 30px rgba(0,0,0,.55); transition:transform .04s linear; }
|
||||
.drag-ghost[data-hint]:not([data-hint=""])::after { content:attr(data-hint); display:block; margin-top:3px; font-size:11px; color:var(--muted); }
|
||||
body.dnd, body.dnd * { cursor:grabbing !important; }
|
||||
.empty { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; color:var(--muted); }
|
||||
.empty[hidden] { display:none; }
|
||||
/* The "+" menu: a terminal here, or on another machine. */
|
||||
.addmenu { position:fixed; z-index:50; min-width:270px; max-width:340px; max-height:70vh; overflow:auto; padding:6px;
|
||||
background:var(--panel); border:1px solid var(--border); border-radius:10px; box-shadow:0 14px 36px rgba(0,0,0,.55);
|
||||
animation:menu-in .14s ease; }
|
||||
.addmenu[hidden] { display:none; }
|
||||
@keyframes menu-in { from { opacity:0; transform:translateY(-4px); } }
|
||||
.addmenu .item { display:flex; align-items:center; gap:10px; width:100%; padding:8px 10px; border-radius:7px; text-align:left;
|
||||
background:transparent; color:var(--text); font-weight:400; }
|
||||
.addmenu .item:hover:not(:disabled) { background:#12202f; }
|
||||
.addmenu .item:disabled { opacity:.45; cursor:not-allowed; }
|
||||
.addmenu .item b { display:block; font-weight:500; font-size:14px; }
|
||||
.addmenu .item small { display:block; color:var(--muted); font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:240px; }
|
||||
.addmenu .ico { width:22px; text-align:center; color:var(--accent); font-size:16px; flex:none; }
|
||||
.addmenu .chev { margin-left:auto; color:var(--muted); }
|
||||
.addmenu .mlist { border-top:1px solid var(--border); margin-top:4px; padding-top:4px; }
|
||||
/* Touch screens: the page is as tall as what the on-screen keyboard leaves,
|
||||
with a row of extra keys at the bottom, the way Termux does it. Tabs are
|
||||
smaller, and there is no splitting. */
|
||||
.keys { display:none; }
|
||||
body.touch { position:fixed; left:0; right:0; top:0; height:100%; }
|
||||
body.touch .area { margin:6px; }
|
||||
body.touch.multi .area { margin-top:0; }
|
||||
body.touch .tabs { padding-left:6px; padding-right:6px; }
|
||||
body.touch.multi .tabs { padding-top:6px; max-height:38px; }
|
||||
body.touch .tab { height:28px; font-size:12px; padding:0 2px 0 8px; gap:6px; }
|
||||
body.touch .tab-add { width:30px; height:28px; }
|
||||
body.touch .tabview { inset:4px; }
|
||||
body.touch .keys {
|
||||
display:grid; grid-template-columns:repeat(7, 1fr); gap:4px; flex:none;
|
||||
padding:4px 4px calc(4px + env(safe-area-inset-bottom)); background:var(--panel); border-top:1px solid var(--border);
|
||||
}
|
||||
.keys button {
|
||||
height:38px; padding:0; border:1px solid var(--border); border-radius:6px;
|
||||
background:#0c0f13; color:var(--text); font:500 13px ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
touch-action:manipulation; user-select:none; -webkit-user-select:none; -webkit-tap-highlight-color:transparent;
|
||||
}
|
||||
.keys button:active { background:var(--border); }
|
||||
.keys button.on { background:var(--accent); border-color:var(--accent); color:#fff; }
|
||||
.keys button.lock { background:var(--accent); border-color:#fff; color:#fff; }
|
||||
/* Copy and paste on touch screens: a long press selects, this menu acts. */
|
||||
.selmenu { position:fixed; left:50%; transform:translateX(-50%); z-index:20; display:flex; gap:7px; padding:5px;
|
||||
background:var(--panel); border:1px solid var(--border); border-radius:8px; box-shadow:0 6px 24px rgba(0,0,0,.5); }
|
||||
.selmenu[hidden] { display:none; }
|
||||
.selmenu button { width:42px; height:42px; padding:0; display:inline-flex; align-items:center; justify-content:center;
|
||||
touch-action:manipulation; -webkit-tap-highlight-color:transparent; }
|
||||
body.touch .area { -webkit-user-select:none; user-select:none; -webkit-touch-callout:none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand"><a class="home" href="/" title="К списку машин">LPRA</a> · Терминал - {{.Agent.Name}} <span class="muted">({{.Agent.TerminalUser}}@{{.Agent.Hostname}})</span></div>
|
||||
<nav>
|
||||
<a href="/">← Машины</a>
|
||||
<a href="/agents/{{.Agent.ID}}/files">Файлы</a>
|
||||
<a href="/agents/{{.Agent.ID}}/forwards">Порты</a>
|
||||
<a href="/agents/{{.Agent.ID}}/status">Статус</a>
|
||||
<a href="/agents/{{.Agent.ID}}/edit">Изменить</a>
|
||||
<span id="status" class="muted" style="margin-left:28px"></span>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="tabs" id="tabs">
|
||||
<div class="tablist" id="tablist"><div class="tabmark" id="tabmark" hidden></div></div>
|
||||
<button type="button" class="tab-add" id="tabadd" title="Новый терминал" aria-label="Новый терминал">+</button>
|
||||
</div>
|
||||
<div class="area" id="area">
|
||||
<button type="button" class="float-add" id="floatadd" title="Новый терминал" aria-label="Новый терминал">+</button>
|
||||
<div class="empty" id="empty" hidden>Нет открытых терминалов<button type="button" id="emptyadd">Новый терминал</button></div>
|
||||
<div class="hint" id="hint" hidden></div>
|
||||
</div>
|
||||
<div class="addmenu" id="addmenu" hidden></div>
|
||||
<div class="selmenu" id="selmenu" hidden>
|
||||
<button type="button" data-a="copy" title="Копировать" aria-label="Копировать">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn ghost" data-a="paste" title="Вставить" aria-label="Вставить">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn ghost" data-a="all" title="Выделить всё" aria-label="Выделить всё">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3a2 2 0 0 0-2 2M19 3a2 2 0 0 1 2 2M21 19a2 2 0 0 1-2 2M5 21a2 2 0 0 1-2-2M9 3h1M9 21h1M14 3h1M14 21h1M3 9v1M21 9v1M3 14v1M21 14v1"/><rect x="7" y="7" width="10" height="10" rx="1"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn ghost close" data-a="close" title="Закрыть" aria-label="Закрыть">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="keys" id="keys">
|
||||
<button type="button" data-k="esc">ESC</button>
|
||||
<button type="button" data-k="/">/</button>
|
||||
<button type="button" data-k="-">-</button>
|
||||
<button type="button" data-k="home">HOME</button>
|
||||
<button type="button" data-k="up">↑</button>
|
||||
<button type="button" data-k="end">END</button>
|
||||
<button type="button" data-k="pgup">PGUP</button>
|
||||
<button type="button" data-k="tab">TAB</button>
|
||||
<button type="button" data-k="ctrl" data-mod="ctrl">CTRL</button>
|
||||
<button type="button" data-k="alt" data-mod="alt">ALT</button>
|
||||
<button type="button" data-k="left">←</button>
|
||||
<button type="button" data-k="down">↓</button>
|
||||
<button type="button" data-k="right">→</button>
|
||||
<button type="button" data-k="pgdn">PGDN</button>
|
||||
</div>
|
||||
|
||||
<script src="{{asset "xterm.js"}}"></script>
|
||||
<script src="{{asset "addon-fit.js"}}"></script>
|
||||
<script>
|
||||
window.TERM_PAGE = { agent: { id: {{.Agent.ID}}, name: {{.Agent.Name}}, host: {{.Agent.Hostname}}, user: {{.Agent.TerminalUser}} } };
|
||||
// Touch helpers, for the terminal being typed in: the extra keys with their
|
||||
// CTRL / ALT, and selecting and copying with a long press.
|
||||
window.TermKeys = (function () {
|
||||
const touch = window.matchMedia('(pointer: coarse)').matches;
|
||||
const cur = () => window.TermPane && window.TermPane.active();
|
||||
// CTRL and ALT: a tap arms the key for the next key press, a long press
|
||||
// keeps it on until tapped again.
|
||||
const mods = { ctrl: 0, alt: 0 }; // 0 off, 1 next key, 2 locked
|
||||
const modBtn = {};
|
||||
document.querySelectorAll('#keys [data-mod]').forEach(function (b) { modBtn[b.dataset.mod] = b; });
|
||||
function showMods() {
|
||||
for (const m in mods) {
|
||||
modBtn[m].classList.toggle('on', mods[m] === 1);
|
||||
modBtn[m].classList.toggle('lock', mods[m] === 2);
|
||||
}
|
||||
}
|
||||
function useMods() {
|
||||
const used = { ctrl: !!mods.ctrl, alt: !!mods.alt };
|
||||
for (const m in mods) if (mods[m] === 1) mods[m] = 0;
|
||||
showMods();
|
||||
return used;
|
||||
}
|
||||
function ctrlChar(c) {
|
||||
if (c === ' ' || c === '@') return '\x00';
|
||||
if (c === '?') return '\x7f';
|
||||
const code = c.toUpperCase().charCodeAt(0);
|
||||
return code >= 64 && code <= 95 ? String.fromCharCode(code & 31) : c;
|
||||
}
|
||||
function withMods(data) {
|
||||
if (!mods.ctrl && !mods.alt) return data;
|
||||
const m = useMods();
|
||||
if (m.ctrl && data.length === 1) data = ctrlChar(data);
|
||||
return m.alt ? '\x1b' + data : data;
|
||||
}
|
||||
// xterm's modifier parameter: 1 + shift(1) + alt(2) + ctrl(4).
|
||||
const CSI = { up: 'A', down: 'B', right: 'C', left: 'D', home: 'H', end: 'F' };
|
||||
const TILDE = { pgup: 5, pgdn: 6 };
|
||||
function keySeq(k, term) {
|
||||
const m = useMods(), p = 1 + (m.alt ? 2 : 0) + (m.ctrl ? 4 : 0);
|
||||
if (CSI[k]) {
|
||||
if (p > 1) return '\x1b[1;' + p + CSI[k];
|
||||
return (term.modes.applicationCursorKeysMode ? '\x1bO' : '\x1b[') + CSI[k];
|
||||
}
|
||||
if (TILDE[k]) return '\x1b[' + TILDE[k] + (p > 1 ? ';' + p : '') + '~';
|
||||
let s = k === 'esc' ? '\x1b' : k === 'tab' ? '\t' : k;
|
||||
if (m.ctrl && s.length === 1) s = ctrlChar(s);
|
||||
return m.alt ? '\x1b' + s : s;
|
||||
}
|
||||
let press = null;
|
||||
document.querySelectorAll('#keys button').forEach(function (b) {
|
||||
// Keep the focus (and the on-screen keyboard) on the terminal.
|
||||
b.addEventListener('pointerdown', function (e) {
|
||||
e.preventDefault();
|
||||
const mod = b.dataset.mod;
|
||||
if (mod) press = { mod: mod, timer: setTimeout(function () { mods[mod] = 2; press = null; showMods(); }, 450) };
|
||||
});
|
||||
b.addEventListener('pointerup', function (e) {
|
||||
e.preventDefault();
|
||||
const mod = b.dataset.mod;
|
||||
if (mod) {
|
||||
if (press && press.mod === mod) {
|
||||
clearTimeout(press.timer);
|
||||
press = null;
|
||||
mods[mod] = mods[mod] ? 0 : 1;
|
||||
showMods();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const s = cur();
|
||||
if (s) s.send(keySeq(b.dataset.k, s.term));
|
||||
});
|
||||
b.addEventListener('pointercancel', function () { if (press) { clearTimeout(press.timer); press = null; } });
|
||||
b.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
||||
});
|
||||
|
||||
// --- Selecting and copying on touch screens ---
|
||||
// xterm.js has no touch selection of its own. A long press selects the word
|
||||
// under the finger and dragging on from there stretches the selection; a
|
||||
// menu then copies it or pastes. A plain tap or swipe still types and scrolls.
|
||||
const menu = document.getElementById('selmenu');
|
||||
let selecting = false;
|
||||
function showMenu() {
|
||||
const s = cur();
|
||||
const keys = document.getElementById('keys').getBoundingClientRect();
|
||||
menu.style.top = '';
|
||||
menu.style.bottom = (window.innerHeight - keys.top + 14) + 'px';
|
||||
menu.querySelector('[data-a="copy"]').disabled = !(s && s.term.hasSelection());
|
||||
menu.hidden = false;
|
||||
}
|
||||
function selectionChanged(s) {
|
||||
if (!touch || s !== cur()) return;
|
||||
if (s.term.hasSelection()) showMenu();
|
||||
else if (!selecting) menu.hidden = true;
|
||||
}
|
||||
if (touch) {
|
||||
const box = document.getElementById('area');
|
||||
let timer = null, start = null, anchor = null;
|
||||
function cellAt(term, el, x, y) {
|
||||
const r = el.querySelector('.xterm-screen').getBoundingClientRect();
|
||||
const col = Math.max(0, Math.min(term.cols - 1, Math.floor((x - r.left) / (r.width / term.cols))));
|
||||
const row = Math.max(0, Math.min(term.rows - 1, Math.floor((y - r.top) / (r.height / term.rows))));
|
||||
return { col: col, row: row + term.buffer.active.viewportY };
|
||||
}
|
||||
function selectWord(term, c) {
|
||||
const line = term.buffer.active.getLine(c.row);
|
||||
const text = line ? line.translateToString(false) : '';
|
||||
const word = ch => ch && !/\s/.test(ch);
|
||||
let a = c.col, b = c.col;
|
||||
if (word(text[c.col])) {
|
||||
while (a > 0 && word(text[a - 1])) a--;
|
||||
while (b < text.length - 1 && word(text[b + 1])) b++;
|
||||
}
|
||||
term.select(a, c.row, b - a + 1);
|
||||
anchor = { from: c.row * term.cols + a, to: c.row * term.cols + b };
|
||||
}
|
||||
function stretch(term, c) {
|
||||
const p = c.row * term.cols + c.col;
|
||||
const from = Math.min(anchor.from, p), to = Math.max(anchor.to, p);
|
||||
term.select(from % term.cols, Math.floor(from / term.cols), to - from + 1);
|
||||
}
|
||||
function hideMenu() { menu.hidden = true; const s = cur(); if (s) s.term.clearSelection(); anchor = null; }
|
||||
box.addEventListener('touchstart', function (e) {
|
||||
if (e.touches.length !== 1 || !e.target.closest('.pane-body')) { clearTimeout(timer); return; }
|
||||
const t = e.touches[0];
|
||||
start = { x: t.clientX, y: t.clientY };
|
||||
if (!menu.hidden) hideMenu();
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(function () {
|
||||
const s = cur();
|
||||
if (!s) return;
|
||||
selecting = true;
|
||||
selectWord(s.term, cellAt(s.term, s.el, start.x, start.y));
|
||||
showMenu();
|
||||
if (navigator.vibrate) navigator.vibrate(15);
|
||||
}, 450);
|
||||
}, { passive: true, capture: true });
|
||||
box.addEventListener('touchmove', function (e) {
|
||||
const t = e.touches[0], s = cur();
|
||||
if (selecting && s) {
|
||||
e.preventDefault(); // no scrolling while the selection stretches
|
||||
e.stopPropagation();
|
||||
stretch(s.term, cellAt(s.term, s.el, t.clientX, t.clientY));
|
||||
} else if (start && Math.hypot(t.clientX - start.x, t.clientY - start.y) > 8) {
|
||||
clearTimeout(timer); // a swipe: let it scroll
|
||||
}
|
||||
}, { passive: false, capture: true });
|
||||
box.addEventListener('touchend', function (e) {
|
||||
clearTimeout(timer);
|
||||
if (!selecting) return;
|
||||
selecting = false;
|
||||
e.preventDefault(); // no tap: the keyboard stays as it was
|
||||
e.stopPropagation();
|
||||
showMenu();
|
||||
}, { passive: false, capture: true });
|
||||
box.addEventListener('contextmenu', function (e) { e.preventDefault(); });
|
||||
|
||||
const statusEl = document.getElementById('status');
|
||||
function flash(t, cls) {
|
||||
statusEl.textContent = t; statusEl.className = cls;
|
||||
setTimeout(function () { const s = cur(); if (s) s.setState(s.state, s.text); }, 1500);
|
||||
}
|
||||
function copyText(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
|
||||
// Plain http: the old way, through a field that is there for a moment.
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
ta.remove();
|
||||
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
|
||||
}
|
||||
menu.addEventListener('pointerdown', function (e) { e.preventDefault(); });
|
||||
menu.addEventListener('click', function (e) {
|
||||
const b = e.target.closest('button'), s = cur();
|
||||
if (!b || !s) return;
|
||||
const a = b.dataset.a;
|
||||
if (a === 'copy') {
|
||||
const text = s.term.getSelection();
|
||||
hideMenu();
|
||||
copyText(text).then(function () { flash('скопировано', 'st-ok'); }, function (err) { flash('не скопировалось: ' + err.message, 'st-err'); });
|
||||
} else if (a === 'paste') {
|
||||
hideMenu();
|
||||
if (!navigator.clipboard || !navigator.clipboard.readText) { flash('вставка: браузер не даёт буфер', 'st-err'); return; }
|
||||
navigator.clipboard.readText().then(function (t) { if (t) s.term.paste(t); }, function () { flash('вставка: нет доступа к буферу', 'st-err'); });
|
||||
} else if (a === 'all') {
|
||||
s.term.selectAll();
|
||||
showMenu();
|
||||
} else {
|
||||
hideMenu();
|
||||
}
|
||||
});
|
||||
}
|
||||
return { withMods: withMods, selectionChanged: selectionChanged };
|
||||
})();
|
||||
</script>
|
||||
<script src="{{asset "terminal.js"}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+314
@@ -0,0 +1,314 @@
|
||||
#!/bin/sh
|
||||
# LPRA server: install or update in one command.
|
||||
#
|
||||
# wget -qO- https://gitlab.com/itzredtea/lpra/-/raw/main/server-install.sh | sudo sh
|
||||
#
|
||||
# Takes the server binary for this machine from the latest GitLab release and
|
||||
# the agent binaries (dist/) from the main branch, puts them in /opt/lpra,
|
||||
# sets up the systemd service and, if asked, nginx with a domain and a
|
||||
# certificate from certbot. Run again to update: the binary and dist/ are
|
||||
# replaced, the database, the service's settings and nginx stay.
|
||||
#
|
||||
# Questions are asked on the terminal even when the script comes through a
|
||||
# pipe. Without a terminal the answers come from the environment (or their
|
||||
# defaults): LPRA_PORT (8091), LPRA_NGINX (y/n), LPRA_DOMAIN, LPRA_EMAIL,
|
||||
# LPRA_CERTBOT (y/n), LPRA_UFW (y/n). LPRA_REPO and LPRA_DIR change where from
|
||||
# and where to.
|
||||
set -eu
|
||||
|
||||
REPO=${LPRA_REPO:-itzredtea/lpra}
|
||||
GL=https://gitlab.com
|
||||
# The project in the API's paths: its path with "/" as %2F.
|
||||
API="$GL/api/v4/projects/$(printf '%s' "$REPO" | sed 's|/|%2F|g')"
|
||||
DIR=${LPRA_DIR:-/opt/lpra}
|
||||
SERVICE=lpra-server
|
||||
UNIT=/etc/systemd/system/$SERVICE.service
|
||||
TUNNEL_PORT=8446
|
||||
|
||||
say() { printf '\033[1;34m>>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# ---------- checks ----------
|
||||
[ "$(id -u)" -eq 0 ] || die "нужен root: ... | sudo sh"
|
||||
[ "$(uname -s)" = Linux ] || die "сервер ставится только на Linux"
|
||||
command -v systemctl >/dev/null 2>&1 || die "нужен systemd"
|
||||
case "$(uname -m)" in
|
||||
x86_64 | amd64) ARCH=amd64 ;;
|
||||
aarch64 | arm64) ARCH=arm64 ;;
|
||||
*) die "архитектура $(uname -m) не поддерживается (есть amd64 и arm64)" ;;
|
||||
esac
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
dl() { curl -fsSL --retry 3 -o "$2" "$1"; }
|
||||
fetch() { curl -fsSL --retry 3 "$1"; }
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
dl() { wget -q -O "$2" "$1"; }
|
||||
fetch() { wget -q -O - "$1"; }
|
||||
else
|
||||
die "нужен curl или wget"
|
||||
fi
|
||||
|
||||
# ---------- questions ----------
|
||||
TTY=
|
||||
if (: </dev/tty) 2>/dev/null; then TTY=/dev/tty; fi
|
||||
|
||||
# ask VAR "question" default [KEY]: the answer from the terminal, else the
|
||||
# environment's LPRA_<KEY> (KEY is VAR if not given), else the default.
|
||||
ask() {
|
||||
var=$1 q=$2 def=$3 key=${4:-$1}
|
||||
eval "env=\${LPRA_$key:-}"
|
||||
[ -n "$env" ] && def=$env
|
||||
ans=
|
||||
if [ -n "$TTY" ]; then
|
||||
if [ -n "$def" ]; then printf '%s [%s]: ' "$q" "$def" >"$TTY"; else printf '%s: ' "$q" >"$TTY"; fi
|
||||
read -r ans <"$TTY" || ans=
|
||||
fi
|
||||
[ -n "$ans" ] || ans=$def
|
||||
eval "$var=\$ans"
|
||||
}
|
||||
# confirm KEY "question" y|n: true for a yes (LPRA_<KEY> without a terminal).
|
||||
# The answer stays apart from the script's own variables of that name.
|
||||
confirm() {
|
||||
ask _yn "$2 (y/n)" "$3" "$1"
|
||||
case "$_yn" in [yYдД]*) return 0 ;; *) return 1 ;; esac
|
||||
}
|
||||
|
||||
# ---------- download ----------
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT INT TERM
|
||||
|
||||
TAG=$(fetch "$API/releases/permalink/latest" 2>/dev/null | tr ',' '\n' |
|
||||
sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1) || TAG=
|
||||
if [ -n "$TAG" ]; then
|
||||
say "последний релиз: $TAG"
|
||||
BIN_URL="$GL/$REPO/-/releases/$TAG/downloads/lpra-server-linux-$ARCH"
|
||||
else
|
||||
warn "не узнать номер релиза у GitLab API - беру latest"
|
||||
BIN_URL="$GL/$REPO/-/releases/permalink/latest/downloads/lpra-server-linux-$ARCH"
|
||||
fi
|
||||
say "скачиваю сервер (linux/$ARCH)"
|
||||
dl "$BIN_URL" "$TMP/lpra-server" || die "не скачался $BIN_URL"
|
||||
chmod +x "$TMP/lpra-server"
|
||||
|
||||
say "скачиваю dist/ из ветки main"
|
||||
mkdir -p "$TMP/dist"
|
||||
# The files of dist/ on main, by the API's list of the folder.
|
||||
URLS=$(fetch "$API/repository/tree?path=dist&ref=main&per_page=100" 2>/dev/null | tr '{},' '\n\n\n' |
|
||||
sed -n 's|^"path": *"\(dist/[^"]*\)"$|'"$GL/$REPO"'/-/raw/main/\1|p') || URLS=
|
||||
if [ -z "$URLS" ]; then
|
||||
warn "не получить список dist/ у GitLab API - беру агентов linux amd64 и arm64"
|
||||
URLS="$GL/$REPO/-/raw/main/dist/agent-linux-amd64
|
||||
$GL/$REPO/-/raw/main/dist/agent-linux-arm64"
|
||||
fi
|
||||
for u in $URLS; do
|
||||
f=${u##*/}
|
||||
dl "$u" "$TMP/dist/$f" || die "не скачался $u"
|
||||
case "$f" in agent-*) chmod +x "$TMP/dist/$f" ;; esac
|
||||
printf ' %s\n' "$f"
|
||||
done
|
||||
|
||||
# ---------- install ----------
|
||||
UPDATE=
|
||||
[ -f "$UNIT" ] && UPDATE=1
|
||||
if [ -n "$UPDATE" ]; then
|
||||
say "уже установлен - обновляю"
|
||||
systemctl stop "$SERVICE" 2>/dev/null || true
|
||||
fi
|
||||
mkdir -p "$DIR"
|
||||
mv -f "$TMP/lpra-server" "$DIR/lpra-server"
|
||||
rm -rf "$DIR/dist.old"
|
||||
[ -d "$DIR/dist" ] && mv "$DIR/dist" "$DIR/dist.old"
|
||||
mv "$TMP/dist" "$DIR/dist"
|
||||
rm -rf "$DIR/dist.old"
|
||||
say "файлы в $DIR"
|
||||
|
||||
# The port: from the existing service on an update, asked otherwise.
|
||||
PORT=
|
||||
if [ -n "$UPDATE" ]; then
|
||||
PORT=$(sed -n 's/.*-addr [^ ]*:\([0-9][0-9]*\).*/\1/p' "$UNIT" | head -n 1)
|
||||
fi
|
||||
if [ -z "$PORT" ]; then
|
||||
ask PORT "порт сервера" 8091
|
||||
case "$PORT" in '' | *[!0-9]*) die "порт - это число" ;; esac
|
||||
fi
|
||||
|
||||
# ---------- nginx ----------
|
||||
NGINX= DOMAIN= TLS=
|
||||
if [ -n "$UPDATE" ]; then NGDEF=n; else NGDEF=y; fi
|
||||
if confirm NGINX "настроить nginx с доменом" "$NGDEF"; then
|
||||
if ! command -v nginx >/dev/null 2>&1; then
|
||||
say "ставлю nginx"
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
{ apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq nginx; } >/dev/null 2>&1
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y -q nginx >/dev/null 2>&1
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
yum install -y -q nginx >/dev/null 2>&1
|
||||
fi
|
||||
fi
|
||||
if command -v nginx >/dev/null 2>&1; then
|
||||
ask DOMAIN "домен (например lpra.example.com)" ""
|
||||
[ -n "$DOMAIN" ] || die "без домена nginx не настроить"
|
||||
NGINX=1
|
||||
if [ -d /etc/nginx/sites-available ]; then
|
||||
CONF=/etc/nginx/sites-available/lpra
|
||||
LINK=/etc/nginx/sites-enabled/lpra
|
||||
else
|
||||
CONF=/etc/nginx/conf.d/lpra.conf
|
||||
LINK=
|
||||
fi
|
||||
cat >"$CONF" <<EOF
|
||||
# LPRA (server-install.sh): $DOMAIN -> 127.0.0.1:$PORT
|
||||
# Agents and terminals use WebSocket (the Upgrade headers) and long polling
|
||||
# (no buffering, long timeouts).
|
||||
map \$http_upgrade \$lpra_connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name $DOMAIN;
|
||||
|
||||
client_max_body_size 120m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:$PORT;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection \$lpra_connection_upgrade;
|
||||
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
[ -n "$LINK" ] && ln -sf "$CONF" "$LINK"
|
||||
if ! nginx -t >/dev/null 2>&1; then nginx -t; die "nginx -t не прошёл, конфиг: $CONF"; fi
|
||||
systemctl enable --now nginx >/dev/null 2>&1 || true
|
||||
systemctl reload nginx
|
||||
say "nginx: $DOMAIN -> 127.0.0.1:$PORT ($CONF)"
|
||||
|
||||
# A certificate, when certbot with its nginx plugin is there.
|
||||
if command -v certbot >/dev/null 2>&1 && certbot plugins 2>/dev/null | grep -q nginx; then
|
||||
if confirm CERTBOT "получить сертификат Let's Encrypt (certbot --nginx)" y; then
|
||||
ask EMAIL "email для Let's Encrypt (можно пусто)" ""
|
||||
if [ -n "$EMAIL" ]; then M="-m $EMAIL"; else M="--register-unsafely-without-email"; fi
|
||||
# shellcheck disable=SC2086
|
||||
if certbot --nginx -d "$DOMAIN" --non-interactive --agree-tos --redirect $M; then
|
||||
TLS=1
|
||||
else
|
||||
warn "certbot не смог - остаётся http. Домен должен указывать на этот сервер, порт 80 открыт"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
warn "certbot с плагином nginx не найден - сертификат пропускаю (apt install certbot python3-certbot-nginx)"
|
||||
fi
|
||||
else
|
||||
warn "nginx не установлен и не поставился - пропускаю"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Not set up now, but by an earlier run: its domain, and https if certbot
|
||||
# added it.
|
||||
if [ -z "$NGINX" ]; then
|
||||
for c in /etc/nginx/sites-available/lpra /etc/nginx/conf.d/lpra.conf; do
|
||||
[ -f "$c" ] || continue
|
||||
DOMAIN=$(sed -n 's/^[[:space:]]*server_name[[:space:]]\{1,\}\([^;[:space:]]*\).*/\1/p' "$c" | head -n 1)
|
||||
grep -q 'listen.*443' "$c" && TLS=1
|
||||
break
|
||||
done
|
||||
fi
|
||||
|
||||
# ---------- firewall ----------
|
||||
if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then
|
||||
if confirm UFW "ufw включён: открыть 80, 443 и туннель $TUNNEL_PORT tcp/udp" y; then
|
||||
ufw allow 80/tcp >/dev/null
|
||||
ufw allow 443/tcp >/dev/null
|
||||
ufw allow $TUNNEL_PORT/tcp >/dev/null
|
||||
ufw allow $TUNNEL_PORT/udp >/dev/null
|
||||
if [ -z "$DOMAIN" ]; then ufw allow "$PORT/tcp" >/dev/null; fi
|
||||
say "ufw: открыто"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------- service ----------
|
||||
# Behind nginx: local only. Without it the panel listens on every address.
|
||||
# Cookies are Secure only when the panel is reached by https.
|
||||
if [ -z "$UPDATE" ] || [ -n "$NGINX" ]; then
|
||||
if [ -n "$NGINX" ]; then ADDR=127.0.0.1:$PORT; else ADDR=0.0.0.0:$PORT; fi
|
||||
COOKIES=
|
||||
[ -z "$TLS" ] && COOKIES='Environment=LPRA_INSECURE_COOKIES=1'
|
||||
cat >"$UNIT" <<EOF
|
||||
# LPRA server (server-install.sh). Its database and key: $DIR/lpra.db, $DIR/lpra.db.key
|
||||
[Unit]
|
||||
Description=LPRA remote access host (admin panel + agent rendezvous)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=$DIR
|
||||
ExecStart=$DIR/lpra-server -addr $ADDR -db $DIR/lpra.db -dist $DIR/dist
|
||||
$COOKIES
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
# The database, and what ufw writes when the panel opens forwarded ports.
|
||||
ReadWritePaths=$DIR -/etc/ufw -/run
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
fi
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$SERVICE" >/dev/null 2>&1
|
||||
systemctl restart "$SERVICE"
|
||||
say "служба $SERVICE запущена"
|
||||
|
||||
# ---------- health check ----------
|
||||
ok=
|
||||
i=0
|
||||
while [ $i -lt 20 ]; do
|
||||
if fetch "http://127.0.0.1:$PORT/login" >/dev/null 2>&1; then ok=1; break; fi
|
||||
i=$((i + 1))
|
||||
sleep 1
|
||||
done
|
||||
[ -n "$ok" ] || die "сервер не отвечает на 127.0.0.1:$PORT - смотрите: journalctl -u $SERVICE -n 50"
|
||||
say "сервер отвечает"
|
||||
if [ -n "$NGINX" ]; then
|
||||
if fetch "http://127.0.0.1/login" >/dev/null 2>&1 || [ -n "$TLS" ]; then say "nginx отвечает"; fi
|
||||
fi
|
||||
|
||||
# ---------- done ----------
|
||||
if [ -n "$TLS" ]; then URL=https://$DOMAIN
|
||||
elif [ -n "$DOMAIN" ]; then URL=http://$DOMAIN
|
||||
else
|
||||
IP=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||
URL="http://${IP:-<IP сервера>}:$PORT"
|
||||
fi
|
||||
echo
|
||||
say "готово: LPRA ${TAG:-} в $DIR"
|
||||
if [ -z "$UPDATE" ]; then
|
||||
echo " откройте $URL/setup - мастер создаст администратора"
|
||||
else
|
||||
echo " панель: $URL"
|
||||
fi
|
||||
if [ -z "$TLS" ]; then echo " панель без https: куки без Secure. Для работы через интернет лучше домен с сертификатом"; fi
|
||||
echo " логи: journalctl -u $SERVICE -f"
|
||||
Reference in new issue
Block a user