mirror of
https://github.com/LazarenkoA/Ox.git
synced 2025-11-23 21:33:13 +02:00
better
This commit is contained in:
10
.gitignore
vendored
10
.gitignore
vendored
@@ -8,6 +8,8 @@
|
||||
*.so
|
||||
*.dylib
|
||||
.idea
|
||||
playwright
|
||||
*.log
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
@@ -31,3 +33,11 @@ go.work.sum
|
||||
# Editor/IDE
|
||||
# .idea/
|
||||
# .vscode/
|
||||
|
||||
# Playwright
|
||||
node_modules/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
|
||||
367
README.md
Normal file
367
README.md
Normal file
@@ -0,0 +1,367 @@
|
||||
# 🐂 Ox — Нагрузочное тестирование 1С
|
||||
|
||||

|
||||
|
||||
> Кроссплатформенный инструмент для нагрузочного тестирования веб-клиента 1С на базе Playwright
|
||||
|
||||
## 📋 Содержание
|
||||
|
||||
- [Введение](#введение)
|
||||
- [Возможности и ограничения](#возможности-и-ограничения)
|
||||
- [Установка](#установка)
|
||||
- [Архитектура](#архитектура)
|
||||
- [Быстрый старт](#быстрый-старт)
|
||||
- [Создание тестовых сценариев](#создание-тестовых-сценариев)
|
||||
- [Отладка и диагностика](#отладка-и-диагностика)
|
||||
- [Документация](#документация)
|
||||
|
||||
## 📖 Введение
|
||||
|
||||
**Ox** — это открытоисходный инструмент для нагрузочного тестирования веб-клиента 1С. Он позволяет эмулировать действия пользователей и создавать нагрузку на тестируемую систему без необходимости глубокого понимания архитектуры Test Center 1C.
|
||||
|
||||
Проект создан на базе [Playwright](https://playwright.dev/) и предоставляет простой и удобный способ записи и воспроизведения пользовательских сценариев с поддержкой множественных параллельных исполнителей.
|
||||
|
||||
### Почему Ox?
|
||||
|
||||
В экосистеме открытого ПО отсутствует достойная альтернатива Test Center 1C для нагрузочного тестирования. Хотя Ox не претендует на полную замену Test Center (который предоставляет больше функций и возможностей), он компенсирует это **простотой использования**:
|
||||
|
||||
- ✅ Интуитивная запись сценариев (как встроенный менеджер тестирования в 1С)
|
||||
- ✅ Не требует специальной квалификации от инженеров
|
||||
- ✅ Удобная панель управления и распределённое выполнение тестов
|
||||
- ✅ Автоматическая запись скриншотов и видео при сбоях
|
||||
|
||||
## 🎯 Возможности и ограничения
|
||||
|
||||
### ✅ Что поддерживает Ox
|
||||
|
||||
- **Простая эксплуатация** — минимальная настройка и кривая обучения
|
||||
- **Запись сценариев через UI** — как встроенный менеджер тестирования в 1С
|
||||
- **Скриншоты и видео при ошибках** — автоматическая диагностика сбоев
|
||||
- **Распределённое выполнение** — запуск множества worker'ов на разных машинах
|
||||
- **Параллельные потоки** — настраиваемое количество одновременных тестов на worker'е
|
||||
- **Панель управления** — веб-интерфейс для оркестрации и мониторинга
|
||||
- **Выявление проблем** — логи worker'ов содержат подробную диагностику ошибок
|
||||
|
||||
### ❌ Ограничения
|
||||
|
||||
- **Только веб-клиент** — не поддерживает толстый клиент 1С
|
||||
- **Упрощённый функционал** — по сравнению с Test Center 1C
|
||||
- **Зависит от Playwright** — используются возможности и ограничения браузерной автоматизации
|
||||
|
||||
## 💾 Установка
|
||||
|
||||
### Вариант 1: Из готовых бинарников
|
||||
|
||||
Скачайте последний релиз из [GitHub Releases](https://github.com/LazarenkoA/extensions-info/releases)
|
||||
|
||||
### Вариант 2: Сборка из исходников
|
||||
|
||||
**Требования:**
|
||||
- [Go](https://go.dev/dl/) 1.21+
|
||||
- Make или аналогичный инструмент
|
||||
|
||||
```bash
|
||||
make build-backend
|
||||
```
|
||||
|
||||
**Требования для worker'ов:**
|
||||
- [Node.js](https://nodejs.org/en/download) 18+
|
||||
- Playwright установится автоматически при первом запуске worker'а
|
||||
|
||||
## 🏗️ Архитектура
|
||||
|
||||
Ox состоит из двух компонентов:
|
||||
|
||||
### Worker
|
||||
|
||||
- **Описание:** Рабочий процесс, запускаемый на ВМ, с которой идёт нагрузка
|
||||
- **Функция:** Выполнение Playwright сценариев в несколько потоков
|
||||
- **Масштабирование:** Можно запускать множество экземпляров worker'ов
|
||||
- **Требования:** Node.js, доступ к тестируемому веб-сервису 1С
|
||||
|
||||
### Observer
|
||||
|
||||
- **Описание:** Центральный оркестратор и панель управления
|
||||
- **Функция:** Управление worker'ами, распределение сценариев, сбор результатов
|
||||
- **Экземпляры:** Должен быть ровно один экземпляр
|
||||
- **Требования:** Сетевой доступ ко всем worker'ам по TCP
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### Шаг 1: Запустить Worker
|
||||
|
||||
На машине, с которой будет идти нагрузка:
|
||||
|
||||
```bash
|
||||
worker.exe -p 55556
|
||||
```
|
||||
|
||||
**Доступные флаги:**
|
||||
- `-p, --port` — порт для прослушивания (по умолчанию 55556)
|
||||
- `-h, --host` — адрес для прослушивания (по умолчанию 0.0.0.0)
|
||||
|
||||
Можно запустить несколько экземпляров на разных портах:
|
||||
|
||||
```bash
|
||||
worker.exe -p 55556 &
|
||||
worker.exe -p 55557 &
|
||||
worker.exe -p 55558 &
|
||||
```
|
||||
|
||||
### Шаг 2: Настроить Observer
|
||||
|
||||
Отредактируйте конфиг [observer/config.yaml](observer/config.yaml):
|
||||
|
||||
```yaml
|
||||
workers:
|
||||
- host: 192.168.1.10
|
||||
port: 55556
|
||||
- host: 192.168.1.11
|
||||
port: 55556
|
||||
- host: 192.168.1.11
|
||||
port: 55557
|
||||
|
||||
observer:
|
||||
port: 8091
|
||||
listen: 0.0.0.0
|
||||
```
|
||||
|
||||
### Шаг 3: Запустить Observer
|
||||
|
||||
```bash
|
||||
observer.exe -c config.yaml
|
||||
```
|
||||
|
||||
Observer запустит веб-сервер на `http://localhost:8091`
|
||||
|
||||
### Шаг 4: Проверить статус Worker'ов
|
||||
|
||||
Откройте в браузере `http://localhost:8091` и проверьте, что все worker'ы в статусе **READY**:
|
||||
|
||||

|
||||
|
||||
#### Устранение проблем с подключением
|
||||
|
||||
**Если worker'ы в статусе OFFLINE:**
|
||||
|
||||
1. Проверьте сетевую доступность:
|
||||
```bash
|
||||
telnet <worker_host> <worker_port>
|
||||
# или
|
||||
nc -zv <worker_host> <worker_port>
|
||||
```
|
||||
|
||||
2. Проверьте, что worker'ы запущены и слушают нужные порты:
|
||||
```bash
|
||||
lsof -i :<port> # macOS/Linux
|
||||
netstat -ano | findstr :<port> # Windows
|
||||
```
|
||||
|
||||
3. Проверьте firewall правила на машинах с worker'ами
|
||||
|
||||
4. Убедитесь, что в конфиге Observer правильно указаны IP-адреса и порты
|
||||
|
||||
## 📝 Создание тестовых сценариев
|
||||
|
||||
### Запись сценария с помощью Codegen
|
||||
|
||||
Используйте встроенный UI редактор Playwright:
|
||||
|
||||
```bash
|
||||
npx playwright codegen http://localhost/bsp/ru_RU/
|
||||
```
|
||||
|
||||
Откроется два окна:
|
||||
- **Браузер** — для выполнения действий
|
||||
- **Inspector** — для просмотра генерируемого кода
|
||||
|
||||
Выполняйте действия в браузере, они автоматически записываются как код.
|
||||
|
||||
### Пример сгенерированного сценария
|
||||
|
||||
```javascript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('create_survey_template', async ({ page }) => {
|
||||
// Переход на систему и авторизация
|
||||
await page.goto('http://localhost/bsp/ru_RU/');
|
||||
await page.locator('#userName').fill('Администратор');
|
||||
await page.locator('#userName').press('Tab');
|
||||
await page.locator('#userPassword').fill('123');
|
||||
await page.locator('#userPassword').press('Enter');
|
||||
|
||||
// Ожидание загрузки интерфейса
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Навигация по меню
|
||||
await page.getByText('Анкетирование').click();
|
||||
await page.getByText('Шаблоны анкет').click();
|
||||
|
||||
// Создание нового шаблона
|
||||
await page.locator('[id="form4_ФормаСоздать"]').click();
|
||||
|
||||
const templateName = randomString();
|
||||
await page.getByRole('textbox', { name: 'Наименование:' })
|
||||
.pressSequentially(templateName);
|
||||
|
||||
await page.locator('[id="form5_Заголовок"] > .inputs')
|
||||
.pressSequentially(randomString());
|
||||
|
||||
// Сохранение
|
||||
await page.locator('[id="form5_ФормаЗаписатьИЗакрыть"]').click();
|
||||
|
||||
// Удаление созданного шаблона
|
||||
await page.locator('[id="grid_form4_Список"]')
|
||||
.getByText(templateName, { exact: true })
|
||||
.click({ button: 'right' });
|
||||
|
||||
await page.locator('#popupItem4')
|
||||
.getByText('Пометить на удаление / Снять пометку')
|
||||
.click();
|
||||
|
||||
await page.locator('#form6_Button0 a')
|
||||
.filter({ hasText: 'Да' })
|
||||
.click();
|
||||
});
|
||||
|
||||
function randomString() {
|
||||
return Math.random().toString(36).substring(2, 10);
|
||||
}
|
||||
```
|
||||
|
||||
### Рекомендации при редактировании сценариев
|
||||
|
||||
1. **Используйте `.pressSequentially()` вместо `.fill()`** — для элементов с автодополнением и валидацией
|
||||
2. **Добавляйте явные ожидания** — `await page.waitForLoadState('networkidle')`
|
||||
3. **Используйте подробные селекторы** — избегайте селекторов, зависящих от порядка элементов
|
||||
4. **Генерируйте уникальные данные** — используйте функции для создания случайных значений
|
||||
5. **Обрабатывайте ошибки** — добавляйте проверки важных состояний
|
||||
|
||||
Полная документация: [Playwright Documentation](https://playwright.dev/docs/intro)
|
||||
|
||||
## 🧪 Проверка и отладка сценариев
|
||||
|
||||
### Локальное тестирование в UI режиме
|
||||
|
||||
После записи сценария проверьте его работу:
|
||||
|
||||
```bash
|
||||
cd playwright
|
||||
npx playwright test ./tests/bsp.spec.js --project=chromium --ui
|
||||
```
|
||||
|
||||
Откроется интерактивное окно, где можно:
|
||||
- Запустить сценарий целиком или отдельные шаги
|
||||
- Увидеть скриншот каждого шага
|
||||
- Отследить выполнение с замедлением
|
||||
|
||||
### Отладка в режиме Inspector'а
|
||||
|
||||
```bash
|
||||
PWDEBUG=1 npx playwright test ./tests/bsp.spec.js --project=chromium
|
||||
```
|
||||
|
||||
Откроется Inspector для пошагового выполнения.
|
||||
|
||||
### Просмотр отчётов
|
||||
|
||||
После выполнения тестов на worker'е, результаты находятся в:
|
||||
|
||||
```
|
||||
<worker_dir>/playwright/playwright-report/index.html
|
||||
```
|
||||
|
||||
Отчёт содержит:
|
||||
- 📸 Скриншоты каждого шага
|
||||
- 🎥 Видео выполнения (особенно полезно при ошибках)
|
||||
- 📋 Список всех действий и их результатов
|
||||
- ⏱️ Время выполнения каждого шага
|
||||
|
||||
### Распространённые проблемы
|
||||
|
||||
| Проблема | Решение |
|
||||
|----------|---------|
|
||||
| Элемент не найден | Проверьте селектор в DevTools браузера, используйте более специфичные селекторы |
|
||||
| Timeout при ожидании | Увеличьте timeout: `await page.locator(...).click({ timeout: 30000 })` |
|
||||
| Случайные падения | Добавьте явные ожидания загрузки: `await page.waitForLoadState('networkidle')` |
|
||||
| Элемент скрыт за другим | Используйте scroll: `await page.locator(...).scrollIntoViewIfNeeded()` |
|
||||
| Данные не совпадают при повторном запуске | Используйте генерацию уникальных данных для каждого прогона |
|
||||
|
||||
## 📊 Управление тестами из Observer'а
|
||||
|
||||
В веб-интерфейсе Observer'а вы можете:
|
||||
|
||||
1. **Выбрать worker** — на котором запустится тест
|
||||
2. **Установить параллелизм** — количество одновременных потоков
|
||||
3. **Загрузить сценарий** — выбрать подготовленный скрипт Playwright
|
||||
4. **Запустить тест** — начать выполнение
|
||||
5. **Мониторить статус** — просматривать результаты в реальном времени
|
||||
|
||||
### Интерпретация статусов
|
||||
|
||||
- **READY** — worker подключен и готов к работе
|
||||
- **RUNNING** — выполняется тест
|
||||
- **ERROR** — тест завершился с ошибкой (см. логи)
|
||||
- **OFFLINE** — worker недоступен (проверьте сетевое соединение)
|
||||
|
||||
## 📚 Документация
|
||||
|
||||
- [Playwright Documentation](https://playwright.dev/)
|
||||
- [1C Web Client Documentation](https://its.1c.eu/db/v8314doc)
|
||||
- [Структура проекта](docs/ARCHITECTURE.md)
|
||||
- [Примеры сценариев](examples/)
|
||||
|
||||
## 🐛 Диагностика проблем
|
||||
|
||||
### Логи Worker'а
|
||||
|
||||
При ошибке в тесте:
|
||||
|
||||
```bash
|
||||
tail -f <worker_dir>/logs/worker.log
|
||||
```
|
||||
|
||||
### Логи Observer'а
|
||||
|
||||
```bash
|
||||
tail -f <observer_dir>/logs/observer.log
|
||||
```
|
||||
|
||||
### Видео и скриншоты при сбое
|
||||
|
||||
Находятся в:
|
||||
```
|
||||
<worker_dir>/playwright/playwright-report/index.html
|
||||
```
|
||||
|
||||
Видео особенно полезно для понимания того, что произошло перед ошибкой.
|
||||
|
||||
## 🤝 Контрибьютинг
|
||||
|
||||
Приветствуются pull requests с:
|
||||
- Исправлениями ошибок
|
||||
- Новыми возможностями
|
||||
- Улучшениями документации
|
||||
- Примерами сценариев
|
||||
|
||||
## 📄 Лицензия
|
||||
|
||||
Проект распространяется под лицензией [укажите лицензию]
|
||||
|
||||
## ❓ Часто задаваемые вопросы
|
||||
|
||||
**Q: Можно ли использовать Ox для толстого клиента 1С?**
|
||||
A: Нет, Ox поддерживает только веб-клиент.
|
||||
|
||||
**Q: Сколько worker'ов можно запустить?**
|
||||
A: Неограниченно, но рекомендуется учитывать ресурсы машин и лимиты браузеров.
|
||||
|
||||
**Q: Как увеличить количество одновременных тестов?**
|
||||
A: Используйте слайдер в Observer'е для настройки параллелизма на каждом worker'е.
|
||||
|
||||
**Q: Нужна ли лицензия на Playwright?**
|
||||
A: Нет, Playwright распространяется бесплатно и с открытыми исходниками.
|
||||
|
||||
## 📞 Поддержка
|
||||
|
||||
Для сообщений об ошибках и вопросов используйте [GitHub Issues](https://github.com/LazarenkoA/ox/issues)
|
||||
BIN
docs/img/browser_z2xVfQLybR.png
Normal file
BIN
docs/img/browser_z2xVfQLybR.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
docs/img/logo.png
Normal file
BIN
docs/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 334 KiB |
21
go.mod
21
go.mod
@@ -5,21 +5,36 @@ go 1.25.0
|
||||
require (
|
||||
github.com/alecthomas/kingpin/v2 v2.4.0
|
||||
github.com/creasty/defaults v1.8.0
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/samber/lo v1.52.0
|
||||
github.com/sourcegraph/conc v0.3.0
|
||||
google.golang.org/grpc v1.76.0
|
||||
google.golang.org/protobuf v1.36.6
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
atomicgo.dev/cursor v0.2.0 // indirect
|
||||
atomicgo.dev/keyboard v0.2.9 // indirect
|
||||
atomicgo.dev/schedule v0.1.0 // indirect
|
||||
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect
|
||||
github.com/gorilla/mux v1.8.1 // indirect
|
||||
github.com/rs/cors v1.11.1 // indirect
|
||||
github.com/samber/lo v1.52.0 // indirect
|
||||
github.com/containerd/console v1.0.5 // indirect
|
||||
github.com/gookit/color v1.5.4 // indirect
|
||||
github.com/hashicorp/errwrap v1.0.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/lithammer/fuzzysearch v1.1.8 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/pterm/pterm v0.12.82 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/term v0.33.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
)
|
||||
|
||||
98
go.sum
98
go.sum
@@ -1,13 +1,30 @@
|
||||
atomicgo.dev/cursor v0.2.0 h1:H6XN5alUJ52FZZUkI7AlJbUc1aW38GWZalpYRPpoPOw=
|
||||
atomicgo.dev/cursor v0.2.0/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU=
|
||||
atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8=
|
||||
atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ=
|
||||
atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs=
|
||||
atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU=
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs=
|
||||
github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8=
|
||||
github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII=
|
||||
github.com/MarvinJWendt/testza v0.2.10/go.mod h1:pd+VWsoGUiFtq+hRKSU1Bktnn+DMCSrDrXDpX2bG66k=
|
||||
github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzXjB69adAhzZkI=
|
||||
github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/2oUqKc6bF2c=
|
||||
github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE=
|
||||
github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY=
|
||||
github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
|
||||
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc=
|
||||
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
|
||||
github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
|
||||
github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc=
|
||||
github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
|
||||
github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk=
|
||||
github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -37,18 +54,37 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
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/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ=
|
||||
github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo=
|
||||
github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
|
||||
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8=
|
||||
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
|
||||
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
|
||||
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
|
||||
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -56,23 +92,43 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
|
||||
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI=
|
||||
github.com/pterm/pterm v0.12.29/go.mod h1:WI3qxgvoQFFGKGjGnJR849gU0TsEOvKn5Q8LlY1U7lg=
|
||||
github.com/pterm/pterm v0.12.30/go.mod h1:MOqLIyMOgmTDz9yorcYbcw+HsgoZo3BQfg2wtl3HEFE=
|
||||
github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEejaWgXU=
|
||||
github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE=
|
||||
github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8=
|
||||
github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s=
|
||||
github.com/pterm/pterm v0.12.82 h1:+D9wYhCaeaK0FIQoZtqbNQuNpe2lB2tajKKsTd5paVQ=
|
||||
github.com/pterm/pterm v0.12.82/go.mod h1:TyuyrPjnxfwP+ccJdBTeWHtd/e0ybQHkOS/TakajZCw=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
|
||||
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc=
|
||||
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
|
||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
@@ -92,6 +148,7 @@ go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
|
||||
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.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
@@ -99,6 +156,8 @@ golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHl
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -107,6 +166,9 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
@@ -115,16 +177,38 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
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-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
|
||||
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
|
||||
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/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -136,6 +220,8 @@ golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtn
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -159,9 +245,13 @@ google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94U
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -3,12 +3,9 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"github.com/alecthomas/kingpin/v2"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/backoff"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"load_testing/observer/internal/app"
|
||||
"load_testing/observer/internal/config"
|
||||
"load_testing/observer/internal/http"
|
||||
"load_testing/worker/proto/gen"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -35,62 +32,25 @@ func main() {
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go shutdown(cancel)
|
||||
go grpcStart(ctx, cfg.Workers)
|
||||
|
||||
go func() {
|
||||
time.Sleep(time.Millisecond * 300)
|
||||
//utils.OpenBrowser(fmt.Sprintf("http://localhost:%d", cfg.Port))
|
||||
}()
|
||||
|
||||
srv, err := http.NewHTTP(cfg.Port, cfg.Workers)
|
||||
srv := http.NewHTTP(cfg.Port)
|
||||
observ := app.NewObserver(srv, cfg.Workers)
|
||||
err = srv.InitRouts(observ)
|
||||
check(err)
|
||||
|
||||
go observ.Run(ctx)
|
||||
check(srv.Run(ctx))
|
||||
}
|
||||
|
||||
func check(err error) {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
err = srv.Run(ctx)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func grpcStart(ctx context.Context, workers []config.WorkerConfig) {
|
||||
params := grpc.ConnectParams{
|
||||
Backoff: backoff.Config{
|
||||
BaseDelay: 1 * time.Second,
|
||||
Multiplier: 1.6,
|
||||
MaxDelay: 10 * time.Second,
|
||||
},
|
||||
MinConnectTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
withTransport := grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||
conn, err := grpc.NewClient(":63546", withTransport, grpc.WithConnectParams(params))
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client := gen.NewWorkerClient(conn)
|
||||
|
||||
for {
|
||||
stream, err := client.Health(context.Background(), &gen.Empty{})
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
for {
|
||||
msg, err := stream.Recv()
|
||||
if err == nil {
|
||||
log.Println(msg.Status)
|
||||
} else {
|
||||
log.Println("ERROR:", err)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shutdown(cancel context.CancelFunc) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
app_port: 8090
|
||||
app_port: 8091
|
||||
workers:
|
||||
- addr: "127.0.0.1:55555"
|
||||
#- addr: "127.0.0.1:55555"
|
||||
- addr: "127.0.0.1:55556"
|
||||
- addr: "127.0.0.1:55557"
|
||||
|
||||
1
observer/internal/app/model.go
Normal file
1
observer/internal/app/model.go
Normal file
@@ -0,0 +1 @@
|
||||
package app
|
||||
@@ -1,12 +1,100 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/samber/lo"
|
||||
"github.com/sourcegraph/conc"
|
||||
"load_testing/observer/internal/config"
|
||||
"load_testing/worker/proto/gen"
|
||||
"log"
|
||||
)
|
||||
|
||||
type state string
|
||||
|
||||
const (
|
||||
stateReady state = "ready"
|
||||
stateRunning state = "running"
|
||||
stateOffline state = "offline"
|
||||
stateError state = "error"
|
||||
)
|
||||
|
||||
type WS interface {
|
||||
WriteWSMessage(msg string) error
|
||||
}
|
||||
|
||||
type WorkerStatus struct {
|
||||
workerID int
|
||||
status gen.WorkerStatus
|
||||
}
|
||||
|
||||
type observer struct {
|
||||
workers []*Worker
|
||||
}
|
||||
|
||||
func NewObserver() *observer {
|
||||
return &observer{}
|
||||
func NewObserver(ws WS, workersConf []config.WorkerConfig) *observer {
|
||||
workers := lo.Map(workersConf, func(item config.WorkerConfig, index int) *Worker {
|
||||
return &Worker{
|
||||
Id: index + 1,
|
||||
ParallelTests: 1,
|
||||
Addr: item.Addr,
|
||||
Status: stateOffline,
|
||||
ws: ws,
|
||||
}
|
||||
})
|
||||
|
||||
return &observer{
|
||||
workers: workers,
|
||||
}
|
||||
}
|
||||
|
||||
func (o *observer) Run() {
|
||||
|
||||
func (o *observer) Run(ctx context.Context) {
|
||||
statusMap := map[gen.WorkerStatus]state{
|
||||
gen.WorkerStatus_STATE_READY: stateReady,
|
||||
gen.WorkerStatus_STATE_RUNNING: stateRunning,
|
||||
gen.WorkerStatus_STATE_ERROR: stateError,
|
||||
}
|
||||
|
||||
log.Printf("observer starting. Workers - %d", len(o.workers))
|
||||
|
||||
chanStatus := make(chan WorkerStatus)
|
||||
var wg conc.WaitGroup
|
||||
|
||||
for _, worker := range o.workers {
|
||||
wg.Go(func() {
|
||||
worker.grpcStart(ctx, chanStatus)
|
||||
})
|
||||
}
|
||||
|
||||
go func() {
|
||||
for state := range chanStatus {
|
||||
for _, worker := range o.workers {
|
||||
if worker.Id == state.workerID {
|
||||
newState := stateOffline
|
||||
if v, ok := statusMap[state.status]; ok {
|
||||
newState = v
|
||||
}
|
||||
|
||||
worker.ChangeState(newState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
close(chanStatus)
|
||||
}
|
||||
|
||||
func (o *observer) Workers() []*Worker {
|
||||
return o.workers
|
||||
}
|
||||
|
||||
func (o *observer) WorkerByID(id int) *Worker {
|
||||
w, ok := lo.Find(o.workers, func(item *Worker) bool {
|
||||
return item.Id == id
|
||||
})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
126
observer/internal/app/worker.go
Normal file
126
observer/internal/app/worker.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/pkg/errors"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/backoff"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"load_testing/worker/proto/gen"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Worker struct {
|
||||
Id int `json:"id"`
|
||||
Addr string `json:"addr"`
|
||||
Status state `json:"status"`
|
||||
Script string `json:"script"`
|
||||
ParallelTests int `json:"parallel_tests"`
|
||||
ws WS `json:"-"`
|
||||
client gen.WorkerClient `json:"-"`
|
||||
mx sync.Mutex `json:"-"`
|
||||
}
|
||||
|
||||
func (w *Worker) ChangeState(newState state) {
|
||||
w.mx.Lock()
|
||||
defer w.mx.Unlock()
|
||||
|
||||
w.Status = newState
|
||||
|
||||
data, _ := json.Marshal(w)
|
||||
if err := w.ws.WriteWSMessage(string(data)); err != nil {
|
||||
log.Println("WriteWSMessage error:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) Start(testCount int32) error {
|
||||
w.mx.Lock()
|
||||
|
||||
if w.Status != stateReady && w.Status != stateError {
|
||||
w.mx.Unlock()
|
||||
return fmt.Errorf("incorrect worker status. Current state is %s", w.Status)
|
||||
}
|
||||
w.mx.Unlock()
|
||||
|
||||
w.ChangeState(stateRunning)
|
||||
|
||||
go func() {
|
||||
_, err := w.client.Start(context.Background(), &gen.StartResp{TestCount: testCount})
|
||||
if err != nil {
|
||||
log.Println("start error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) Stop() error {
|
||||
_, err := w.client.Stop(context.Background(), &gen.Empty{})
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *Worker) SetTestScript(script string) error {
|
||||
w.Script = script
|
||||
_, err := w.client.SetTestScript(context.Background(), &gen.SetTestScriptReq{Script: script})
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *Worker) grpcStart(ctx context.Context, chanStatus chan<- WorkerStatus) {
|
||||
params := grpc.ConnectParams{
|
||||
Backoff: backoff.Config{
|
||||
BaseDelay: 1 * time.Second,
|
||||
Multiplier: 1.6,
|
||||
MaxDelay: 10 * time.Second,
|
||||
},
|
||||
MinConnectTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
log.Printf("start GRPC worker %s", w.Addr)
|
||||
|
||||
withTransport := grpc.WithTransportCredentials(insecure.NewCredentials())
|
||||
conn, err := grpc.NewClient(w.Addr, withTransport, grpc.WithConnectParams(params))
|
||||
if err != nil {
|
||||
log.Println(errors.Wrap(err, "grpc newClient").Error())
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
w.client = gen.NewWorkerClient(conn)
|
||||
w.grpcKeepalive(ctx, w.client, chanStatus)
|
||||
}
|
||||
|
||||
func (w *Worker) grpcKeepalive(ctx context.Context, client gen.WorkerClient, chanStatus chan<- WorkerStatus) {
|
||||
for {
|
||||
stream, err := client.ObserverChangeState(ctx, &gen.Empty{})
|
||||
if err != nil {
|
||||
log.Println("GRPC error:", err)
|
||||
} else {
|
||||
chanStatus <- WorkerStatus{workerID: w.Id, status: gen.WorkerStatus_STATE_READY}
|
||||
w.readStream(stream, w.Id, chanStatus)
|
||||
}
|
||||
|
||||
chanStatus <- WorkerStatus{workerID: w.Id, status: -1}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Println("GRPC: Context done")
|
||||
return
|
||||
case <-time.After(time.Second * 3):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) readStream(stream grpc.ServerStreamingClient[gen.StatusInfo], workerId int, chanStatus chan<- WorkerStatus) {
|
||||
for {
|
||||
msg, err := stream.Recv()
|
||||
if err == nil {
|
||||
chanStatus <- WorkerStatus{workerID: workerId, status: msg.Status}
|
||||
} else {
|
||||
log.Println("stream ERROR:", err)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,21 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"github.com/samber/lo"
|
||||
"fmt"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/pkg/errors"
|
||||
"html/template"
|
||||
"io"
|
||||
"load_testing/observer"
|
||||
"load_testing/observer/internal/config"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func (h *HttpSrv) workers(w http.ResponseWriter, r *http.Request) {
|
||||
workers := lo.Map(h.workersConf, func(item config.WorkerConfig, index int) WorkerDTO {
|
||||
return WorkerDTO{
|
||||
Id: index + 1,
|
||||
Addr: item.Addr,
|
||||
Status: "ready",
|
||||
Online: true,
|
||||
}
|
||||
})
|
||||
data, err := json.Marshal(workers)
|
||||
func (h *HttpSrv) workers(workers IObserver) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := json.Marshal(workers.Workers())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
@@ -26,14 +23,62 @@ func (h *HttpSrv) workers(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func (h *HttpSrv) openWS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
}
|
||||
|
||||
func (h *HttpSrv) index(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *HttpSrv) workerStart(workers IObserver) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
idStr := vars["id"]
|
||||
testCountStr := r.URL.Query().Get("testCount")
|
||||
id, _ := strconv.Atoi(idStr)
|
||||
testCount, _ := strconv.Atoi(testCountStr)
|
||||
|
||||
if worker := workers.WorkerByID(id); worker != nil {
|
||||
if err := worker.Start(int32(max(testCount, 1))); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
} else {
|
||||
http.Error(w, fmt.Sprintf("worker not found by ID: %s", idStr), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HttpSrv) workerStop(workers IObserver) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
idStr := vars["id"]
|
||||
id, _ := strconv.Atoi(vars["id"])
|
||||
|
||||
if worker := workers.WorkerByID(id); worker != nil {
|
||||
if err := worker.Stop(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
} else {
|
||||
http.Error(w, fmt.Sprintf("worker not found by ID: %s", idStr), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HttpSrv) openWS(w http.ResponseWriter, r *http.Request) {
|
||||
h.mx.Lock()
|
||||
defer h.mx.Unlock()
|
||||
|
||||
if h.ws != nil && !h.ws.closed.Load() {
|
||||
return
|
||||
}
|
||||
|
||||
h.ws = NewWSServer(context.Background())
|
||||
err := h.ws.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
http.Error(w, errors.Wrap(err, "ws open").Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HttpSrv) index(workers IObserver) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]any{
|
||||
"Workers": h.workersConf,
|
||||
"Workers": workers.Workers(),
|
||||
}
|
||||
|
||||
tmpl := template.Must(template.ParseFS(observer.StaticFS, "static/templates/index.html"))
|
||||
@@ -42,3 +87,28 @@ func (h *HttpSrv) index(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HttpSrv) setScript(workers IObserver) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data, _ := io.ReadAll(r.Body)
|
||||
defer r.Body.Close()
|
||||
|
||||
vars := mux.Vars(r)
|
||||
idStr := vars["id"]
|
||||
id, _ := strconv.Atoi(idStr)
|
||||
|
||||
if worker := workers.WorkerByID(id); worker != nil {
|
||||
if err := worker.SetTestScript(string(data)); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
} else {
|
||||
http.Error(w, fmt.Sprintf("worker not found by ID: %s", idStr), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ func loggingMiddleware(logger *slog.Logger) func(next http.Handler) http.Handler
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.String() == "/api/v1/ws" { // исключение для ws
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
logger.DebugContext(r.Context(), fmt.Sprintf("HTTP request started (method: %s, url: %s)", r.Method, r.URL.String()))
|
||||
|
||||
@@ -61,3 +66,17 @@ func (lw *loggingResponseWriter) WriteHeader(statusCode int) {
|
||||
lw.status = statusCode
|
||||
lw.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
func sendWorkersStatus(workers IObserver) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
next.ServeHTTP(w, r)
|
||||
|
||||
// после первого подключения ws отправляем статус по workers
|
||||
for _, worker := range workers.Workers() {
|
||||
worker.ChangeState(worker.Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,53 +7,64 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
"io/fs"
|
||||
"load_testing/observer"
|
||||
"load_testing/observer/internal/config"
|
||||
"load_testing/observer/internal/app"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HttpSrv struct {
|
||||
port int
|
||||
workersConf []config.WorkerConfig
|
||||
logger *slog.Logger
|
||||
httpServ *http.Server
|
||||
type IObserver interface {
|
||||
Workers() []*app.Worker
|
||||
WorkerByID(id int) *app.Worker
|
||||
}
|
||||
|
||||
func NewHTTP(port int, workers []config.WorkerConfig) (*HttpSrv, error) {
|
||||
mux := mux.NewRouter()
|
||||
type HttpSrv struct {
|
||||
port int
|
||||
logger *slog.Logger
|
||||
httpServ *http.Server
|
||||
ws *WSServer
|
||||
mx sync.Mutex
|
||||
router *mux.Router
|
||||
}
|
||||
|
||||
func NewHTTP(port int) *HttpSrv {
|
||||
router := mux.NewRouter()
|
||||
|
||||
resp := &HttpSrv{
|
||||
port: port,
|
||||
workersConf: workers,
|
||||
router: router,
|
||||
logger: slog.Default(),
|
||||
httpServ: &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", port),
|
||||
Handler: mux,
|
||||
Handler: router,
|
||||
ReadHeaderTimeout: 2 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
return resp, resp.initRouts(mux)
|
||||
return resp
|
||||
}
|
||||
|
||||
func (h *HttpSrv) initRouts(mux *mux.Router) error {
|
||||
func (h *HttpSrv) InitRouts(workers IObserver) error {
|
||||
staticFS, err := fs.Sub(observer.StaticFS, "static")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mux.Use(loggingMiddleware(h.logger))
|
||||
mux.Use(enableCORS)
|
||||
h.router.Use(loggingMiddleware(h.logger))
|
||||
h.router.Use(enableCORS)
|
||||
|
||||
// Отдаём статику (CSS/JS)
|
||||
mux.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||
mux.HandleFunc("/", h.index)
|
||||
h.router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||
h.router.HandleFunc("/", h.index(workers))
|
||||
|
||||
v1 := mux.PathPrefix("/api/v1").Subrouter()
|
||||
v1.HandleFunc("/workers", h.workers)
|
||||
v1.HandleFunc("/ws", h.openWS)
|
||||
v1 := h.router.PathPrefix("/api/v1").Subrouter()
|
||||
v1.HandleFunc("/workers", h.workers(workers))
|
||||
v1.HandleFunc("/workers/{id}/start", h.workerStart(workers))
|
||||
v1.HandleFunc("/workers/{id}/stop", h.workerStop(workers))
|
||||
v1.HandleFunc("/workers/{id}/set_script", h.setScript(workers))
|
||||
v1.Handle("/ws", sendWorkersStatus(workers)(http.HandlerFunc(h.openWS)))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -67,10 +78,17 @@ func (h *HttpSrv) Run(ctx context.Context) error {
|
||||
h.httpServ.Shutdown(sdCtx)
|
||||
}()
|
||||
|
||||
log.Printf("observer starting on port %d. Workers - %d", h.port, len(h.workersConf))
|
||||
log.Printf("http starting on port %d", h.port)
|
||||
if err := h.httpServ.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *HttpSrv) WriteWSMessage(msg string) error {
|
||||
if h.ws != nil && !h.ws.closed.Load() {
|
||||
return h.ws.WriteMsg(msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
144
observer/internal/http/websocketServer.go
Normal file
144
observer/internal/http/websocketServer.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type WSServer struct {
|
||||
upgrader *websocket.Upgrader
|
||||
conn *websocket.Conn
|
||||
ctx context.Context
|
||||
close context.CancelFunc
|
||||
fail atomic.Int32
|
||||
lastMsg time.Time
|
||||
mx sync.RWMutex
|
||||
writeMx sync.Mutex
|
||||
closed atomic.Bool
|
||||
}
|
||||
|
||||
const (
|
||||
failCount = 2
|
||||
)
|
||||
|
||||
func NewWSServer(pctx context.Context) *WSServer {
|
||||
upgrader := &websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
log.Println("WS: new ws conn")
|
||||
|
||||
ctx, cancel := context.WithCancel(pctx)
|
||||
srv := &WSServer{
|
||||
upgrader: upgrader,
|
||||
ctx: ctx,
|
||||
close: cancel,
|
||||
}
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
func (ws *WSServer) Upgrade(w http.ResponseWriter, r *http.Request, responseHeader http.Header) error {
|
||||
var err error
|
||||
|
||||
ws.conn, err = ws.upgrader.Upgrade(w, r, responseHeader)
|
||||
if err == nil {
|
||||
go ws.readMsg()
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (ws *WSServer) Close() {
|
||||
if ws.closed.CompareAndSwap(false, true) {
|
||||
ws.conn.Close()
|
||||
ws.close()
|
||||
}
|
||||
}
|
||||
|
||||
func (ws *WSServer) readMsg() {
|
||||
msgChan := make(chan struct{})
|
||||
defer close(msgChan)
|
||||
|
||||
// контроль сообщений
|
||||
go func(msg <-chan struct{}) {
|
||||
for {
|
||||
select {
|
||||
case <-ws.ctx.Done():
|
||||
log.Println("WS: context done")
|
||||
ws.Close()
|
||||
return
|
||||
case <-time.Tick(time.Second * 10):
|
||||
// если не получаем сообщения 10 секунд, закрываем конект (фронт переоткроет). Пинги должны приходить раз в 5 сек.
|
||||
|
||||
ws.mx.RLock()
|
||||
if time.Since(ws.lastMsg).Seconds() >= 10 {
|
||||
if count := ws.fail.Add(1); count >= failCount {
|
||||
log.Println("WS: forcibly closing the ws connection")
|
||||
ws.Close()
|
||||
}
|
||||
}
|
||||
ws.mx.RUnlock()
|
||||
case <-msg:
|
||||
ws.mx.Lock()
|
||||
ws.lastMsg = time.Now()
|
||||
ws.mx.Unlock()
|
||||
}
|
||||
}
|
||||
}(msgChan)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ws.ctx.Done():
|
||||
log.Println("WS: context done")
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
msgType, _, err := ws.conn.ReadMessage()
|
||||
if err != nil {
|
||||
log.Println(errors.Wrap(err, "ws read error"))
|
||||
ws.Close()
|
||||
break
|
||||
}
|
||||
|
||||
if msgType == -1 { // разрыв сокета
|
||||
ws.Close()
|
||||
log.Println("websocket closed")
|
||||
break
|
||||
}
|
||||
|
||||
msgChan <- struct{}{}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (ws *WSServer) WriteMsg(data string) error {
|
||||
ws.writeMx.Lock()
|
||||
defer ws.writeMx.Unlock()
|
||||
|
||||
if ws.conn == nil {
|
||||
return errors.New("ws connection not initialized")
|
||||
}
|
||||
|
||||
err := ws.conn.WriteMessage(websocket.TextMessage, []byte(data))
|
||||
return err
|
||||
}
|
||||
|
||||
func (ws *WSServer) WriteByteMsg(data []byte) error {
|
||||
if ws.conn == nil {
|
||||
return errors.New("ws connection not initialized")
|
||||
}
|
||||
|
||||
err := ws.conn.WriteMessage(websocket.BinaryMessage, data)
|
||||
return err
|
||||
}
|
||||
@@ -3,41 +3,42 @@ let workers = [];
|
||||
let testRunning = false;
|
||||
let testStartTime = null;
|
||||
let timerInterval = null;
|
||||
let back_api = "http://localhost:8090/api/v1"
|
||||
let back_api = "http://localhost:8091/api/v1"
|
||||
|
||||
// Initialize the application
|
||||
function init() {
|
||||
const ws = openWSConn([]);
|
||||
|
||||
getWorkers(() => {
|
||||
renderWorkers();
|
||||
renderWorkers(ws);
|
||||
populateWorkerSelects();
|
||||
setupEventListeners();
|
||||
updateGlobalMetrics();
|
||||
updateGlobalStatus();
|
||||
updateTotalCapacity();
|
||||
});
|
||||
}
|
||||
|
||||
// Render worker cards
|
||||
function renderWorkers() {
|
||||
function renderWorkers(ws) {
|
||||
const workersGrid = document.getElementById('workersGrid');
|
||||
workersGrid.innerHTML = '';
|
||||
|
||||
workers.forEach(worker => {
|
||||
const card = createWorkerCard(worker);
|
||||
const card = createWorkerCard(worker, ws);
|
||||
workersGrid.appendChild(card);
|
||||
|
||||
updateWorkerStatus(worker)
|
||||
updateButton(worker)
|
||||
updateLastHeartbeat(worker)
|
||||
});
|
||||
}
|
||||
|
||||
// Create a worker card element
|
||||
function createWorkerCard(worker) {
|
||||
function createWorkerCard(worker, ws) {
|
||||
const card = document.createElement('div');
|
||||
card.className = `worker-card status-${worker.status}`;
|
||||
card.dataset.workerId = worker.id;
|
||||
|
||||
const statusText = worker?.status?.charAt(0).toUpperCase() + worker?.status?.slice(1);
|
||||
const isRunning = worker?.status === 'running';
|
||||
const parallelValueClass = worker?.parallel_tests > 500 ? 'parallel-value warning' : 'parallel-value';
|
||||
const progressPercent = isRunning && worker?.parallel_tests > 0 ? (worker?.active_parallel / worker?.parallel_tests * 100) : 0;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="worker-header">
|
||||
@@ -45,15 +46,15 @@ function createWorkerCard(worker) {
|
||||
<h3>Worker ${worker.id}</h3>
|
||||
<div class="worker-ip">${worker.addr}</div>
|
||||
</div>
|
||||
<div class="worker-status-badge status-${worker?.status}">
|
||||
<div id="worker-state-${worker.id}" class="worker-status-badge status-${worker.status}">
|
||||
<span class="status-dot status-${worker.status}"></span>
|
||||
${statusText}
|
||||
${worker.status}
|
||||
</div>
|
||||
</div>
|
||||
<div class="worker-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Last Heartbeat:</span>
|
||||
<span class="detail-value">${worker.last_heartbeat}</span>
|
||||
<span class="detail-value" id="last_heartbeat-${worker.id}"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -68,18 +69,18 @@ function createWorkerCard(worker) {
|
||||
Parallel Tests
|
||||
</div>
|
||||
${isRunning ? `
|
||||
<div class="parallel-locked-message">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg>
|
||||
Stop worker to adjust settings
|
||||
</div>
|
||||
<!-- <div class="parallel-locked-message">-->
|
||||
<!-- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">-->
|
||||
<!-- <rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>-->
|
||||
<!-- <path d="M7 11V7a5 5 0 0 1 10 0v4"></path>-->
|
||||
<!-- </svg>-->
|
||||
<!-- Stop worker to adjust settings-->
|
||||
<!-- </div>-->
|
||||
` : ''}
|
||||
<div class="parallel-tests-adjuster">
|
||||
<button class="parallel-btn" data-action="decrement" data-worker-id="${worker.id}" ${isRunning || !worker.online ? 'disabled' : ''} title="Decrease parallel tests">−</button>
|
||||
<div class="${parallelValueClass}" title="Number of concurrent virtual users">${worker.parallel_tests}</div>
|
||||
<button class="parallel-btn" data-action="increment" data-worker-id="${worker.id}" ${isRunning || !worker.online ? 'disabled' : ''} title="Increase parallel tests">+</button>
|
||||
<button class="parallel-btn" data-action="decrement" data-worker-id="${worker.id}" ${isRunning ? 'disabled' : ''} title="Decrease parallel tests">−</button>
|
||||
<div id="parallel_tests-${worker.id}" class="${parallelValueClass}" title="Number of concurrent virtual users">${worker.parallel_tests}</div>
|
||||
<button class="parallel-btn" data-action="increment" data-worker-id="${worker.id}" ${isRunning ? 'disabled' : ''} title="Increase parallel tests">+</button>
|
||||
</div>
|
||||
${worker.parallel_tests > 500 ? `
|
||||
<div class="high-load-warning">
|
||||
@@ -91,47 +92,22 @@ function createWorkerCard(worker) {
|
||||
High load warning
|
||||
</div>
|
||||
` : ''}
|
||||
${isRunning ? `
|
||||
<div class="parallel-progress">
|
||||
<div class="parallel-progress-label">
|
||||
<span>Active Virtual Users</span>
|
||||
<span class="parallel-progress-fraction">${worker.active_parallel}/${worker.parallel_tests}</span>
|
||||
</div>
|
||||
<div class="parallel-progress-bar">
|
||||
<div class="parallel-progress-fill" style="width: ${progressPercent}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<div class="worker-metrics">
|
||||
<div class="metric">
|
||||
<div class="metric-label">Req/s</div>
|
||||
<div class="metric-value">${worker?.metrics?.requests_per_sec}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Avg RT</div>
|
||||
<div class="metric-value">${worker?.metrics?.avg_response_time}ms</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Errors</div>
|
||||
<div class="metric-value">${worker?.metrics?.error_rate}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="worker-controls">
|
||||
<button class="btn btn-success btn-small start-worker" data-worker-id="${worker.id}" ${worker.status === 'running' || !worker.online ? 'disabled' : ''}>
|
||||
<button id="button-start-${worker.id}" class="btn btn-success btn-small start-worker" data-worker-id="${worker.id}" } disabled>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3"></polygon>
|
||||
</svg>
|
||||
Start
|
||||
</button>
|
||||
<button class="btn btn-danger btn-small stop-worker" data-worker-id="${worker.id}" ${worker.status !== 'running' ? 'disabled' : ''}>
|
||||
<button id="button-stop-${worker.id}" class="btn btn-danger btn-small stop-worker" data-worker-id="${worker.id}" } disabled>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="6" y="6" width="12" height="12"></rect>
|
||||
</svg>
|
||||
Stop
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-small configure-worker" data-worker-id="${worker.id}" ${!worker.online ? 'disabled' : ''}>
|
||||
<button id="button-config-${worker.id}" class="btn btn-secondary btn-small configure-worker" data-worker-id="${worker.id}" } disabled>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M12 1v6m0 6v6m-6-6h6m6 0h6"></path>
|
||||
@@ -141,9 +117,81 @@ function createWorkerCard(worker) {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// подписываемся на сообщение в ws по текущему воркеру
|
||||
if (ws !== undefined) {
|
||||
ws((m) => {
|
||||
if (m?.id === worker.id) {
|
||||
updateWorkerStatus(m)
|
||||
updateButton(m)
|
||||
updateLastHeartbeat(m)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function updateLastHeartbeat(worker) {
|
||||
const now = new Date();
|
||||
const pad = n => n.toString().padStart(2, '0');
|
||||
|
||||
const lastHeartbeat = document.getElementById(`last_heartbeat-${worker.id}`);
|
||||
if (lastHeartbeat) {
|
||||
lastHeartbeat.textContent = `${pad(now.getDate())}-${pad(now.getMonth())}-${now.getFullYear()} ${pad(now.getHours())}:${pad(now.getMinutes())}`
|
||||
}
|
||||
}
|
||||
|
||||
function updateButton(worker) {
|
||||
const buttonConfig = document.getElementById(`button-config-${worker.id}`);
|
||||
const buttonStop = document.getElementById(`button-stop-${worker.id}`);
|
||||
const buttonStart = document.getElementById(`button-start-${worker.id}`);
|
||||
|
||||
if (buttonConfig) {
|
||||
buttonConfig.disabled = worker.status !== "ready" && worker.status !== "error"
|
||||
}
|
||||
if (buttonStop) {
|
||||
buttonStop.disabled = worker.status !== "running"
|
||||
}
|
||||
if (buttonStart) {
|
||||
buttonStart.disabled = worker.status !== "ready" && worker.status !== "error"
|
||||
}
|
||||
|
||||
// глобальные кнопки
|
||||
const allStart = document.getElementById(`startAllBtn`);
|
||||
const allStop = document.getElementById(`stopAllBtn`);
|
||||
if(allStart) {
|
||||
allStart.disabled = !workers.some(w => w.status === "ready" || w.status === "error")
|
||||
}
|
||||
if(allStop) {
|
||||
allStop.disabled = !workers.some(w => w.status === "running")
|
||||
}
|
||||
}
|
||||
|
||||
function updateWorkerStatus(worker) {
|
||||
const workerElem = document.getElementById(`worker-state-${worker.id}`);
|
||||
if (workerElem) {
|
||||
workerElem.textContent = worker.status;
|
||||
workerElem.className = `worker-status-badge status-${worker.status}`
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.className = `status-dot status-${worker.status}`;
|
||||
workerElem.prepend(span);
|
||||
|
||||
document.querySelectorAll(`div[class^="worker-card"][data-worker-id="${worker.id}"]`).forEach(e => {
|
||||
e.setAttribute('class', '');
|
||||
e.classList.add(`worker-card`);
|
||||
e.classList.add(`status-${worker.status}`);
|
||||
})
|
||||
|
||||
|
||||
workers.forEach(w => {
|
||||
if (w.id === worker.id) {
|
||||
w.status = worker.status
|
||||
}
|
||||
} )
|
||||
}
|
||||
}
|
||||
|
||||
// Populate worker selects and checkboxes
|
||||
function populateWorkerSelects() {
|
||||
const workerSelect = document.getElementById('workerSelect');
|
||||
@@ -155,10 +203,6 @@ function populateWorkerSelects() {
|
||||
const option = document.createElement('option');
|
||||
option.value = worker.id;
|
||||
option.textContent = `Worker ${worker.id} (${worker.addr})`;
|
||||
if (!worker.online) {
|
||||
option.disabled = true;
|
||||
option.textContent += ' - Offline';
|
||||
}
|
||||
workerSelect.appendChild(option);
|
||||
});
|
||||
|
||||
@@ -168,7 +212,7 @@ function populateWorkerSelects() {
|
||||
const checkboxDiv = document.createElement('div');
|
||||
checkboxDiv.className = 'worker-checkbox';
|
||||
checkboxDiv.innerHTML = `
|
||||
<input type="checkbox" id="worker-${worker.id}" value="${worker.id}" ${!worker.online ? 'disabled' : ''} class="worker-check">
|
||||
<input type="checkbox" id="worker-${worker.id}" value="${worker.id}" ${worker.status === "ready" ? '':'disabled'} class="worker-check">
|
||||
<label for="worker-${worker.id}">Worker ${worker.id} (${worker.addr})</label>
|
||||
`;
|
||||
workersChecklist.appendChild(checkboxDiv);
|
||||
@@ -192,19 +236,13 @@ function setupEventListeners() {
|
||||
btn.addEventListener('click', (e) => switchTab(e.target.dataset.tab));
|
||||
});
|
||||
|
||||
// Config tab switching
|
||||
// document.querySelectorAll('.config-tab-btn').forEach(btn => {
|
||||
// btn.addEventListener('click', (e) => switchConfigTab(e.target.dataset.configTab));
|
||||
// // Preset buttons
|
||||
// document.querySelectorAll('.preset-btn').forEach(btn => {
|
||||
// btn.addEventListener('click', (e) => {
|
||||
// const presetValue = parseInt(e.currentTarget.dataset.preset);
|
||||
// });
|
||||
|
||||
// Preset buttons
|
||||
document.querySelectorAll('.preset-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const presetValue = parseInt(e.currentTarget.dataset.preset);
|
||||
applyPreset(presetValue);
|
||||
});
|
||||
});
|
||||
|
||||
// });
|
||||
//
|
||||
// Apply to all button
|
||||
document.getElementById('applyToAllBtn').addEventListener('click', applyToAll);
|
||||
|
||||
@@ -267,19 +305,10 @@ function toggleParallelConfigSection() {
|
||||
toggleBtn.classList.toggle('collapsed');
|
||||
}
|
||||
|
||||
// // Switch config tabs
|
||||
// function switchConfigTab(tab) {
|
||||
// document.querySelectorAll('.config-tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||
// document.querySelectorAll('.config-tab-content').forEach(content => content.classList.remove('active'));
|
||||
//
|
||||
// document.querySelector(`[data-config-tab="${tab}"]`).classList.add('active');
|
||||
// document.getElementById(`${tab}Tab`).classList.add('active');
|
||||
// }
|
||||
|
||||
// Adjust parallel tests for a worker
|
||||
function adjustParallelTests(workerId, action) {
|
||||
const worker = workers.find(w => w.id === workerId);
|
||||
if (!worker || !worker.online || worker.status === 'running') return;
|
||||
if (!worker || worker.status !== 'ready') return;
|
||||
|
||||
const increment = action === 'increment' ? 1 : -1;
|
||||
const newValue = worker.parallel_tests + increment;
|
||||
@@ -290,8 +319,8 @@ function adjustParallelTests(workerId, action) {
|
||||
}
|
||||
|
||||
worker.parallel_tests = newValue;
|
||||
renderWorkers();
|
||||
updateTotalCapacity();
|
||||
const elem = document.getElementById(`parallel_tests-${worker.id}`);
|
||||
if (elem) elem.textContent = newValue
|
||||
|
||||
const card = document.querySelector(`[data-worker-id="${workerId}"]`);
|
||||
card.style.animation = 'none';
|
||||
@@ -300,26 +329,6 @@ function adjustParallelTests(workerId, action) {
|
||||
}, 10);
|
||||
}
|
||||
|
||||
// Apply preset to all workers
|
||||
function applyPreset(value) {
|
||||
const onlineWorkers = workers.filter(w => w.online).length;
|
||||
|
||||
showModal(
|
||||
'Apply Preset',
|
||||
`Set ${value} parallel tests for all ${onlineWorkers} online worker(s)?`,
|
||||
() => {
|
||||
workers.forEach(worker => {
|
||||
if (worker.online) {
|
||||
worker.parallel_tests = value;
|
||||
}
|
||||
});
|
||||
renderWorkers();
|
||||
updateTotalCapacity();
|
||||
showNotification(`Preset applied: ${value} parallel tests per worker`, 'success');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Apply custom value to all workers
|
||||
function applyToAll() {
|
||||
const input = document.getElementById('batchParallelInput');
|
||||
@@ -330,39 +339,15 @@ function applyToAll() {
|
||||
return;
|
||||
}
|
||||
|
||||
const onlineWorkers = workers.filter(w => w.online).length;
|
||||
|
||||
// showModal(
|
||||
// 'Apply to All Workers',
|
||||
// `Set ${value} parallel tests for all ${onlineWorkers} online worker(s)?`,
|
||||
// () => {
|
||||
// workers.forEach(worker => {
|
||||
// if (worker.online) {
|
||||
// worker.parallel_tests = value;
|
||||
// }
|
||||
// });
|
||||
// renderWorkers();
|
||||
// updateTotalCapacity();
|
||||
// showNotification(`Applied ${value} parallel tests to all online workers`, 'success');
|
||||
// }
|
||||
// );
|
||||
|
||||
workers.forEach(worker => {
|
||||
if (worker.online) {
|
||||
if (worker.status === "ready") {
|
||||
worker.parallel_tests = value;
|
||||
}
|
||||
});
|
||||
renderWorkers();
|
||||
//updateTotalCapacity();
|
||||
showNotification(`Applied ${value} parallel tests to all online workers`, 'success');
|
||||
}
|
||||
|
||||
// Update total capacity display
|
||||
// function updateTotalCapacity() {
|
||||
// const totalCapacity = workers.reduce((sum, w) => sum + w.parallel_tests, 0);
|
||||
// document.getElementById('totalCapacity').textContent = totalCapacity;
|
||||
// }
|
||||
|
||||
// Switch tabs
|
||||
function switchTab(tab) {
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||
@@ -378,96 +363,39 @@ function startAllWorkers() {
|
||||
const stopBtn = document.getElementById('stopAllBtn');
|
||||
|
||||
startBtn.disabled = true;
|
||||
startBtn.classList.add('loading');
|
||||
|
||||
setTimeout(() => {
|
||||
workers.forEach(worker => {
|
||||
if (worker.online && worker.status === 'ready') {
|
||||
worker.status = 'running';
|
||||
if (worker?.metrics?.requests_per_sec === 0) {
|
||||
worker.metrics.requests_per_sec = Math.floor(Math.random() * 150) + 100;
|
||||
worker.metrics.avg_response_time = Math.floor(Math.random() * 50) + 30;
|
||||
worker.metrics.error_rate = (Math.random() * 1).toFixed(1);
|
||||
}
|
||||
if (worker.status === 'ready' || worker.status === 'error') {
|
||||
startWorker(worker.id)
|
||||
}
|
||||
});
|
||||
|
||||
testRunning = true;
|
||||
testStartTime = Date.now();
|
||||
startTimer();
|
||||
|
||||
renderWorkers();
|
||||
updateGlobalStatus();
|
||||
updateGlobalMetrics();
|
||||
updateTotalCapacity();
|
||||
|
||||
startBtn.classList.remove('loading');
|
||||
stopBtn.disabled = false;
|
||||
|
||||
showNotification('All workers started successfully!', 'success');
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// Stop all workers
|
||||
function stopAllWorkers() {
|
||||
const startBtn = document.getElementById('startAllBtn');
|
||||
//const startBtn = document.getElementById('startAllBtn');
|
||||
const stopBtn = document.getElementById('stopAllBtn');
|
||||
|
||||
stopBtn.disabled = true;
|
||||
stopBtn.classList.add('loading');
|
||||
|
||||
setTimeout(() => {
|
||||
workers.forEach(worker => {
|
||||
if (worker.status === 'running') {
|
||||
worker.status = 'ready';
|
||||
stopWorker(worker.id)
|
||||
}
|
||||
});
|
||||
|
||||
testRunning = false;
|
||||
stopTimer();
|
||||
|
||||
renderWorkers();
|
||||
updateGlobalStatus();
|
||||
updateGlobalMetrics();
|
||||
updateTotalCapacity();
|
||||
|
||||
stopBtn.classList.remove('loading');
|
||||
startBtn.disabled = false;
|
||||
|
||||
showNotification('All workers stopped!', 'warning');
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Start individual worker
|
||||
function startWorker(workerId) {
|
||||
const worker = workers.find(w => w.id === workerId);
|
||||
if (!worker || !worker.online) return;
|
||||
if (!worker) return;
|
||||
|
||||
const button = document.querySelector(`.start-worker[data-worker-id="${workerId}"]`);
|
||||
button.disabled = true;
|
||||
button.classList.add('loading');
|
||||
const buttonStart = document.getElementById(`button-start-${worker.id}`);
|
||||
buttonStart.disabled = true
|
||||
|
||||
setTimeout(() => {
|
||||
worker.status = 'running';
|
||||
worker.metrics.requests_per_sec = Math.floor(Math.random() * 150) + 100;
|
||||
worker.metrics.avg_response_time = Math.floor(Math.random() * 50) + 30;
|
||||
worker.metrics.error_rate = (Math.random() * 1).toFixed(1);
|
||||
// Simulate active parallel tests (80-100% of configured)
|
||||
worker.active_parallel = Math.floor(worker.parallel_tests * (0.8 + Math.random() * 0.2));
|
||||
|
||||
if (!testRunning) {
|
||||
testRunning = true;
|
||||
testStartTime = Date.now();
|
||||
startTimer();
|
||||
}
|
||||
|
||||
renderWorkers();
|
||||
updateGlobalStatus();
|
||||
updateGlobalMetrics();
|
||||
updateTotalCapacity();
|
||||
|
||||
showNotification(`Worker ${workerId} started!`, 'success');
|
||||
}, 800);
|
||||
fetch(`${back_api}/workers/${workerId}/start?testCount=${worker.parallel_tests}`).then(r => buttonStart.disabled = false )
|
||||
}
|
||||
|
||||
// Stop individual worker
|
||||
@@ -475,27 +403,7 @@ function stopWorker(workerId) {
|
||||
const worker = workers.find(w => w.id === workerId);
|
||||
if (!worker) return;
|
||||
|
||||
const button = document.querySelector(`.stop-worker[data-worker-id="${workerId}"]`);
|
||||
button.disabled = true;
|
||||
button.classList.add('loading');
|
||||
|
||||
setTimeout(() => {
|
||||
worker.status = 'ready';
|
||||
|
||||
// Check if any workers are still running
|
||||
const anyRunning = workers.some(w => w.status === 'running');
|
||||
if (!anyRunning) {
|
||||
testRunning = false;
|
||||
stopTimer();
|
||||
}
|
||||
|
||||
renderWorkers();
|
||||
updateGlobalStatus();
|
||||
updateGlobalMetrics();
|
||||
updateTotalCapacity();
|
||||
|
||||
showNotification(`Worker ${workerId} stopped!`, 'warning');
|
||||
}, 800);
|
||||
fetch(`${back_api}/workers/${workerId}/stop`).then(r => {} )
|
||||
}
|
||||
|
||||
// Configure worker
|
||||
@@ -516,13 +424,16 @@ function configureWorker(workerId) {
|
||||
toggleScriptSection();
|
||||
}
|
||||
|
||||
const scriptElem = document.getElementById('individualScript');
|
||||
if(scriptElem) scriptElem.textContent = worker.script;
|
||||
|
||||
showNotification(`Ready to configure Worker ${workerId}`, 'info');
|
||||
}
|
||||
|
||||
// Deploy script to individual worker
|
||||
function deployIndividualScript() {
|
||||
const workerSelect = document.getElementById('workerSelect');
|
||||
const script = document.getElementById('individualScript').value;
|
||||
const script = document.getElementById('individualScript')?.value;
|
||||
const workerId = parseInt(workerSelect.value);
|
||||
|
||||
if (!workerId) {
|
||||
@@ -536,14 +447,12 @@ function deployIndividualScript() {
|
||||
}
|
||||
|
||||
const worker = workers.find(w => w.id === workerId);
|
||||
const scriptName = `Script_${Date.now()}`;
|
||||
|
||||
showModal(
|
||||
'Deploy Script',
|
||||
showModal('Deploy Script',
|
||||
`Deploy script to Worker ${workerId} (${worker.addr})?`,
|
||||
() => {
|
||||
worker.script_name = scriptName;
|
||||
renderWorkers();
|
||||
worker.script = script;
|
||||
setScript(worker.id, script);
|
||||
showNotification(`Script deployed to Worker ${workerId}!`, 'success');
|
||||
}
|
||||
);
|
||||
@@ -564,92 +473,19 @@ function deployBatchScript() {
|
||||
return;
|
||||
}
|
||||
|
||||
const scriptName = `Batch_Script_${Date.now()}`;
|
||||
const workerIds = Array.from(selectedCheckboxes).map(cb => parseInt(cb.value));
|
||||
|
||||
showModal(
|
||||
'Deploy Script',
|
||||
`Deploy script to ${workerIds.length} worker(s)?`,
|
||||
() => {
|
||||
workerIds.forEach(id => {
|
||||
const worker = workers.find(w => w.id === id);
|
||||
if (worker) {
|
||||
worker.script_name = scriptName;
|
||||
showModal('Deploy Script',`Deploy script to ${workerIds.length} worker(s)?`, () => {
|
||||
workers.forEach(w => {
|
||||
if(workerIds.some(id => w.id === id)) {
|
||||
w.script = script;
|
||||
setScript(w.id, script)
|
||||
}
|
||||
})
|
||||
});
|
||||
renderWorkers();
|
||||
|
||||
showNotification(`Script deployed to ${workerIds.length} worker(s)!`, 'success');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Update global status
|
||||
function updateGlobalStatus() {
|
||||
const globalStatus = document.getElementById('globalStatus');
|
||||
const runningCount = workers.filter(w => w.status === 'running').length;
|
||||
|
||||
if (testRunning && runningCount > 0) {
|
||||
globalStatus.innerHTML = `
|
||||
<span class="status-dot status-running"></span>
|
||||
<span class="status-text">Test Running (${runningCount} active)</span>
|
||||
`;
|
||||
} else {
|
||||
globalStatus.innerHTML = `
|
||||
<span class="status-dot status-ready"></span>
|
||||
<span class="status-text">System Ready</span>
|
||||
`;
|
||||
}
|
||||
|
||||
// Update master buttons
|
||||
const hasReadyWorkers = workers.some(w => w.online && w.status === 'ready');
|
||||
const hasRunningWorkers = workers.some(w => w.status === 'running');
|
||||
|
||||
document.getElementById('startAllBtn').disabled = !hasReadyWorkers || testRunning;
|
||||
document.getElementById('stopAllBtn').disabled = !hasRunningWorkers;
|
||||
}
|
||||
|
||||
// Update global metrics
|
||||
function updateGlobalMetrics() {
|
||||
const runningWorkers = workers.filter(w => w.status === 'running');
|
||||
|
||||
const totalRequests = runningWorkers.reduce((sum, w) => sum + w.metrics.requests_per_sec, 0);
|
||||
const avgResponseTime = runningWorkers.length > 0
|
||||
? Math.round(runningWorkers.reduce((sum, w) => sum + w?.metrics?.avg_response_time, 0) / runningWorkers.length)
|
||||
: 0;
|
||||
const avgErrorRate = runningWorkers.length > 0
|
||||
? (runningWorkers.reduce((sum, w) => sum + parseFloat(w?.metrics?.error_rate), 0) / runningWorkers.length).toFixed(1)
|
||||
: 0;
|
||||
|
||||
document.getElementById('totalRequests').textContent = totalRequests;
|
||||
document.getElementById('avgResponseTime').textContent = `${avgResponseTime} ms`;
|
||||
document.getElementById('errorRate').textContent = `${avgErrorRate}%`;
|
||||
}
|
||||
|
||||
// Timer functions
|
||||
function startTimer() {
|
||||
if (timerInterval) return;
|
||||
|
||||
timerInterval = setInterval(() => {
|
||||
if (!testStartTime) return;
|
||||
|
||||
const elapsed = Date.now() - testStartTime;
|
||||
const hours = Math.floor(elapsed / 3600000);
|
||||
const minutes = Math.floor((elapsed % 3600000) / 60000);
|
||||
const seconds = Math.floor((elapsed % 60000) / 1000);
|
||||
|
||||
const timeString = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
document.getElementById('testDuration').textContent = timeString;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = null;
|
||||
document.getElementById('testDuration').textContent = '00:00:00';
|
||||
testStartTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Modal functions
|
||||
function showModal(title, message, onConfirm) {
|
||||
@@ -678,15 +514,58 @@ function showNotification(message, type) {
|
||||
console.log(`[${type.toUpperCase()}] ${message}`);
|
||||
}
|
||||
|
||||
function request(method) {
|
||||
|
||||
}
|
||||
|
||||
function getWorkers(f) {
|
||||
fetch(`${back_api}/workers`).
|
||||
then(resp => resp.json()).
|
||||
then(w => { workers.push(...w); f() })
|
||||
}
|
||||
|
||||
function openWSConn(listeners) {
|
||||
const ws = new WebSocket(`${back_api}/ws`);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("✅ WS connected");
|
||||
|
||||
// запускаем keepalive
|
||||
let heartbeatRef = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "ping" }));
|
||||
return
|
||||
}
|
||||
|
||||
if (ws.readyState === WebSocket.CLOSED) {
|
||||
console.log("🔌 write to closed WS");
|
||||
clearInterval(heartbeatRef); // очистим старый
|
||||
setTimeout(() => openWSConn(listeners), 100)
|
||||
}
|
||||
}, 5_000); // каждые 5 сек
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
listeners.forEach((h) => h(JSON.parse(event.data))); // уведомляем подписчиков
|
||||
} catch (e) {
|
||||
console.error("Invalid WS message");
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
setTimeout(() => openWSConn(listeners), 5_000)
|
||||
console.error("❌ WS error", err);
|
||||
};
|
||||
|
||||
return (f) => {
|
||||
listeners.push(f)
|
||||
}
|
||||
}
|
||||
|
||||
function setScript(workerId, script) {
|
||||
fetch(`${back_api}/workers/${workerId}/set_script`, {
|
||||
method: 'POST',
|
||||
body: script
|
||||
}).then(w => {})
|
||||
}
|
||||
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
BIN
observer/static/favicon.ico
Normal file
BIN
observer/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Load Testing Control Panel</title>
|
||||
<link rel="stylesheet" href="static/style.css">
|
||||
<link rel="icon" href="static/favicon.ico" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header Section -->
|
||||
@@ -12,10 +13,6 @@
|
||||
<div class="header-content">
|
||||
<div class="header-left">
|
||||
<h1 class="title">Панель управление нагрузочным тестом</h1>
|
||||
<div class="global-status" id="globalStatus">
|
||||
<span class="status-dot status-ready"></span>
|
||||
<!-- <span class="status-text">System Ready</span>-->
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="workers-count">
|
||||
@@ -23,7 +20,7 @@
|
||||
<span class="count-value" id="totalWorkers">{{ len .Workers }}</span>
|
||||
</div>
|
||||
<div class="master-controls">
|
||||
<button class="btn btn-success btn-large" id="startAllBtn">
|
||||
<button class="btn btn-success btn-large" id="startAllBtn" disabled>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3"></polygon>
|
||||
</svg>
|
||||
@@ -58,12 +55,12 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="parallel-config-content" id="parallelConfigContent">
|
||||
<div class="parallel-config-content collapsed" id="parallelConfigContent">
|
||||
<!-- Batch Apply Tab -->
|
||||
<div class="config-tab-content.active" id="batchTab-">
|
||||
<div class="batch-apply-container">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Установите параллельные тесты для всех воркеров</label>
|
||||
<label class="form-label">Установите количество параллельных тестов на каждом воркере</label>
|
||||
<div class="batch-apply-controls">
|
||||
<input type="number" id="batchParallelInput" class="form-input" min="1" max="1000" value="20" placeholder="Enter value...">
|
||||
<button class="btn btn-primary" id="applyToAllBtn">
|
||||
@@ -107,8 +104,8 @@
|
||||
</div>
|
||||
<div class="script-content" id="scriptContent">
|
||||
<div class="script-tabs">
|
||||
<button class="tab-btn active" data-tab="individual">Individual</button>
|
||||
<button class="tab-btn" data-tab="batch">Batch Deploy</button>
|
||||
<button class="tab-btn active" data-tab="individual">Индивидуальный</button>
|
||||
<button class="tab-btn" data-tab="batch">Пакетное развертывание</button>
|
||||
</div>
|
||||
|
||||
<!-- Individual Mode -->
|
||||
@@ -121,15 +118,7 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">JavaScript Test Script</label>
|
||||
<textarea class="code-editor" id="individualScript" rows="12" placeholder="Enter your test script...">// Sample Load Test Script
|
||||
export default function() {
|
||||
const response = http.get('https://api.example.com/endpoint');
|
||||
check(response, {
|
||||
'status is 200': (r) => r.status === 200,
|
||||
'response time < 500ms': (r) => r.timings.duration < 500
|
||||
});
|
||||
sleep(1);
|
||||
}</textarea>
|
||||
<textarea class="code-editor" id="individualScript" rows="12" placeholder="Enter your test script...">// Вставить скрипт сюда</textarea>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="deployIndividualBtn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -157,15 +146,8 @@ export default function() {
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">JavaScript Test Script</label>
|
||||
<textarea class="code-editor" id="batchScript" rows="12" placeholder="Enter your test script...">// Sample Load Test Script
|
||||
export default function() {
|
||||
const response = http.get('https://api.example.com/endpoint');
|
||||
check(response, {
|
||||
'status is 200': (r) => r.status === 200,
|
||||
'response time < 500ms': (r) => r.timings.duration < 500
|
||||
});
|
||||
sleep(1);
|
||||
}</textarea>
|
||||
<textarea class="code-editor" id="batchScript" rows="12" placeholder="Enter your test script...">// Вставить скрипт сюда
|
||||
</textarea>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="deployBatchBtn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
@@ -179,34 +161,6 @@ export default function() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<section class="status-bar">
|
||||
<div class="status-metrics">
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Total Requests</div>
|
||||
<div class="metric-value" id="totalRequests">0</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Avg Response Time</div>
|
||||
<div class="metric-value" id="avgResponseTime">0 ms</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Error Rate</div>
|
||||
<div class="metric-value" id="errorRate">0%</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-label">Test Duration</div>
|
||||
<div class="metric-value" id="testDuration">00:00:00</div>
|
||||
</div>
|
||||
<div class="metric-card connection-status">
|
||||
<div class="metric-label">Observer Connection</div>
|
||||
<div class="connection-indicator">
|
||||
<span class="status-dot status-ready"></span>
|
||||
<span class="connection-text">Connected</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
@@ -217,11 +171,11 @@ export default function() {
|
||||
<button class="modal-close" id="modalClose">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modalBody">
|
||||
Are you sure you want to proceed?
|
||||
Хотите продолжить?
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" id="modalCancel">Cancel</button>
|
||||
<button class="btn btn-primary" id="modalConfirm">Confirm</button>
|
||||
<button class="btn btn-secondary" id="modalCancel">Отмена</button>
|
||||
<button class="btn btn-primary" id="modalConfirm">Продолжить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,12 +4,15 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"github.com/alecthomas/kingpin/v2"
|
||||
"github.com/pterm/pterm"
|
||||
"load_testing/worker/grpc"
|
||||
"load_testing/worker/internal/app"
|
||||
"load_testing/worker/internal/utils"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:generate protoc --proto_path=../../proto --go_out=../../proto --go-grpc_out=../../proto worker.proto
|
||||
@@ -35,9 +38,17 @@ func main() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go shutdown(cancel)
|
||||
|
||||
err := grpc.NewGRPCServer(ctx, port, app.NewWorker())
|
||||
worker := app.NewWorker()
|
||||
if err := worker.Init(ctx); err != nil {
|
||||
pterm.Fatal.WithFatal(true).Println(err.Error())
|
||||
}
|
||||
|
||||
time.AfterFunc(time.Second, func() {
|
||||
pterm.Info.Printf("worker запущен на порту %d", port)
|
||||
})
|
||||
err := grpc.NewGRPCServer(ctx, port, worker)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
pterm.Fatal.WithFatal(true).Println(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +58,6 @@ func shutdown(cancel context.CancelFunc) {
|
||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigs
|
||||
|
||||
log.Println("shutting down")
|
||||
utils.Logger().Info("shutting down")
|
||||
cancel()
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ import (
|
||||
"fmt"
|
||||
grpc_recovery "github.com/grpc-ecosystem/go-grpc-middleware/recovery"
|
||||
"google.golang.org/grpc"
|
||||
"load_testing/worker/internal/utils"
|
||||
"load_testing/worker/proto/gen"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net"
|
||||
)
|
||||
|
||||
@@ -17,7 +18,9 @@ func NewGRPCServer(ctx context.Context, port int, worker gen.WorkerServer) error
|
||||
}
|
||||
actualAddr := listener.Addr().String() // ip:port
|
||||
|
||||
srv := grpc.NewServer(grpc.ChainStreamInterceptor(grpc_recovery.StreamServerInterceptor()))
|
||||
logger := utils.Logger().With("name", "grpc")
|
||||
|
||||
srv := grpc.NewServer(grpc.ChainUnaryInterceptor(logInterceptor(logger)), grpc.ChainStreamInterceptor(grpc_recovery.StreamServerInterceptor()))
|
||||
gen.RegisterWorkerServer(srv, worker)
|
||||
|
||||
go func() {
|
||||
@@ -25,6 +28,19 @@ func NewGRPCServer(ctx context.Context, port int, worker gen.WorkerServer) error
|
||||
srv.Stop()
|
||||
}()
|
||||
|
||||
log.Println("сервер запущен на", actualAddr)
|
||||
logger.InfoContext(ctx, fmt.Sprintf("сервер запущен на %s", actualAddr))
|
||||
return srv.Serve(listener)
|
||||
}
|
||||
|
||||
func logInterceptor(logger *slog.Logger) func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) {
|
||||
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp any, err error) {
|
||||
_, err = handler(ctx, req)
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "grpc error", "error", err)
|
||||
} else {
|
||||
logger.InfoContext(ctx, fmt.Sprintf("grpc method %s", info.FullMethod))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
27
worker/internal/app/job.go
Normal file
27
worker/internal/app/job.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/sourcegraph/conc"
|
||||
)
|
||||
|
||||
func (w *Worker) startJob(ctx context.Context, testCount int32) error {
|
||||
w.logger.InfoContext(ctx, fmt.Sprintf("start worker, test count %d", testCount))
|
||||
|
||||
err := new(multierror.Error)
|
||||
var wg conc.WaitGroup
|
||||
for range testCount {
|
||||
wg.Go(func() {
|
||||
if e := w.runTest(ctx, w.playwrightDir); e != nil {
|
||||
err = multierror.Append(err, e)
|
||||
return
|
||||
}
|
||||
w.logger.InfoContext(ctx, "test is pass")
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
return err.ErrorOrNil()
|
||||
}
|
||||
136
worker/internal/app/playwright.go
Normal file
136
worker/internal/app/playwright.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"github.com/pkg/errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed resource/*
|
||||
var staticFS embed.FS
|
||||
|
||||
func (w *Worker) runTest(ctx context.Context, playwrightDir string) error {
|
||||
w.logger.InfoContext(ctx, "exec run playwright test")
|
||||
|
||||
if strings.TrimSpace(w.script) == "" {
|
||||
return errors.New("script not filled ")
|
||||
}
|
||||
|
||||
f, err := os.CreateTemp(filepath.Join(playwrightDir, "tests"), "*.spec.js")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "create temp error")
|
||||
}
|
||||
_, _ = f.WriteString(w.script)
|
||||
_ = f.Close()
|
||||
defer os.Remove(f.Name())
|
||||
|
||||
_, file := filepath.Split(f.Name())
|
||||
cmd := exec.CommandContext(ctx, "npx", "playwright", "test", "tests/"+file, "--project", "chromium")
|
||||
cmd.Dir = playwrightDir
|
||||
cmd.Env = append(os.Environ(), "PLAYWRIGHT_HTML_OPEN=never") // что б не открывался отчет в браузере
|
||||
|
||||
_, err = w.cmdRun(ctx, cmd)
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *Worker) checkInstall(ctx context.Context) (error, bool) {
|
||||
cmd := exec.CommandContext(ctx, "npx", "playwright", "test", "--version")
|
||||
|
||||
out, err := w.cmdRun(ctx, cmd)
|
||||
if err != nil {
|
||||
return err, false
|
||||
}
|
||||
|
||||
var re = regexp.MustCompile(`(?m)Version[\s]+[\d\.]+`)
|
||||
return nil, re.Match(out)
|
||||
}
|
||||
|
||||
func (w *Worker) install(ctx context.Context) error {
|
||||
w.logger.InfoContext(ctx, "exec install playwright")
|
||||
|
||||
cmd := exec.CommandContext(ctx, "npx", "playwright", "install")
|
||||
|
||||
_, err := w.cmdRun(ctx, cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) create(ctx context.Context, rootDir string) error {
|
||||
w.logger.InfoContext(ctx, "exec create-playwright")
|
||||
|
||||
if err := os.Mkdir(rootDir, os.ModeDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "npx", "create-playwright@latest", "--quiet", "--lang", "js", "--install-deps", "--gha")
|
||||
cmd.Dir = rootDir
|
||||
|
||||
_, err := w.cmdRun(ctx, cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Заменяем playwright.config.js на свой
|
||||
if err := replacePlaywrightConfig(rootDir); err != nil {
|
||||
return fmt.Errorf("failed to replace config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func replacePlaywrightConfig(rootDir string) error {
|
||||
data, err := staticFS.ReadFile("resource/playwright.config.js")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(rootDir, "playwright.config.js")
|
||||
return os.WriteFile(targetPath, data, 0o644)
|
||||
}
|
||||
func (w *Worker) cmdRun(ctx context.Context, cmd *exec.Cmd) ([]byte, error) {
|
||||
w.stopProcess(ctx, cmd)
|
||||
|
||||
//stdout, err := cmd.StdoutPipe()
|
||||
//if err != nil {
|
||||
// return errors.Wrap(err, "stdout pipe error")
|
||||
//}
|
||||
//
|
||||
//if err := cmd.Start(); err != nil {
|
||||
// return errors.Wrap(err, "command start error")
|
||||
//}
|
||||
//
|
||||
//go func() {
|
||||
// scanner := bufio.NewScanner(stdout)
|
||||
// for scanner.Scan() {
|
||||
// fmt.Println(scanner.Text())
|
||||
// }
|
||||
//}()
|
||||
//
|
||||
//if err := cmd.Wait(); err != nil {
|
||||
// return errors.Wrap(err, "the test failed with an error")
|
||||
//}
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
w.logger.ErrorContext(ctx, "run process error", "playwright error", string(out))
|
||||
return out, errors.Wrap(err, "the test failed with an error")
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// npx create-playwright@latest --quiet --lang=js --install-deps --gha
|
||||
// npx playwright install
|
||||
// npx playwright uninstall --all
|
||||
// npx playwright --version
|
||||
// npx playwright codegen http://localhost/bsp
|
||||
// npx playwright test ./tests/bsp.spec.js --project=chromium --ui
|
||||
87
worker/internal/app/resource/playwright.config.js
Normal file
87
worker/internal/app/resource/playwright.config.js
Normal file
@@ -0,0 +1,87 @@
|
||||
// @ts-check
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// import dotenv from 'dotenv';
|
||||
// import path from 'path';
|
||||
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
/**
|
||||
* @see https://playwright.dev/docs/test-configuration
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: [['html', { outputFolder: 'playwright-report', open: 'never' }]],
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('')`. */
|
||||
// baseURL: 'http://localhost:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
// Делать скриншот при падении теста
|
||||
|
||||
screenshot: 'only-on-failure',
|
||||
// или: 'on' — для каждого теста
|
||||
video: 'retain-on-failure', // можно и видео
|
||||
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: { ...devices['Pixel 5'] },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: { ...devices['iPhone 12'] },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
// webServer: {
|
||||
// command: 'npm run start',
|
||||
// url: 'http://localhost:3000',
|
||||
// reuseExistingServer: !process.env.CI,
|
||||
// },
|
||||
});
|
||||
|
||||
7
worker/internal/app/run_linux.go
Normal file
7
worker/internal/app/run_linux.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package app
|
||||
|
||||
import "context"
|
||||
|
||||
func stopProcess(ctx context.Context) {
|
||||
|
||||
}
|
||||
23
worker/internal/app/run_windows.go
Normal file
23
worker/internal/app/run_windows.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"golang.org/x/sys/windows"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// в windows отмена контекста не сразу останавливает процесс, поэтому написана такая функция
|
||||
func (w *Worker) stopProcess(ctx context.Context, cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP,
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
w.logger.WarnContext(ctx, "context canceled -> terminating process group")
|
||||
|
||||
// Отправляем Ctrl-Break всей группе процессов
|
||||
_ = windows.GenerateConsoleCtrlEvent(syscall.CTRL_BREAK_EVENT, uint32(cmd.Process.Pid))
|
||||
}()
|
||||
}
|
||||
@@ -2,43 +2,112 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/pterm/pterm"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"load_testing/worker/internal/utils"
|
||||
"load_testing/worker/proto/gen"
|
||||
"log"
|
||||
"time"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type Worker struct {
|
||||
gen.UnsafeWorkerServer
|
||||
|
||||
state chan gen.WorkerStatus
|
||||
cancelJob context.CancelFunc
|
||||
playwrightDir string
|
||||
script string
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewWorker() *Worker {
|
||||
return &Worker{}
|
||||
return &Worker{
|
||||
state: make(chan gen.WorkerStatus, 1),
|
||||
cancelJob: func() {},
|
||||
logger: utils.Logger().With("name", "worker"),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) SetTestScript(context.Context, *gen.SetTestScriptReq) (*gen.Empty, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SetTestScript not implemented")
|
||||
}
|
||||
func (w *Worker) Start(context.Context, *gen.Empty) (*gen.StartResp, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Start not implemented")
|
||||
}
|
||||
func (w *Worker) Stop(context.Context, *gen.Empty) (*gen.Empty, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Stop not implemented")
|
||||
}
|
||||
func (w *Worker) Health(_ *gen.Empty, stream grpc.ServerStreamingServer[gen.HealthResp]) error {
|
||||
for {
|
||||
err := stream.Send(&gen.HealthResp{
|
||||
Status: gen.WorkerStatus_READY,
|
||||
})
|
||||
func (w *Worker) Init(ctx context.Context) error {
|
||||
utils.Logger().Info("init worker")
|
||||
|
||||
if err != nil {
|
||||
log.Println("ERROR:", err)
|
||||
multi := pterm.DefaultMultiPrinter
|
||||
multi.Start()
|
||||
|
||||
spinner, _ := pterm.DefaultSpinner.WithWriter(multi.NewWriter()).Start("Установка playwright")
|
||||
if err := w.install(ctx); err != nil {
|
||||
spinner.Fail(err.Error())
|
||||
return err
|
||||
}
|
||||
spinner.Success("playwright установлен")
|
||||
|
||||
dir, _ := os.Getwd()
|
||||
w.playwrightDir = filepath.Join(dir, "playwright")
|
||||
if !dirExists(w.playwrightDir) {
|
||||
spinner, _ := pterm.DefaultSpinner.WithWriter(multi.NewWriter()).Start("Подготовка playwright")
|
||||
if err := w.create(ctx, w.playwrightDir); err != nil {
|
||||
spinner.Fail(err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
time.Sleep(time.Second)
|
||||
spinner.Success("playwright подготовлен")
|
||||
}
|
||||
|
||||
multi.Stop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func dirExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (w *Worker) SetTestScript(_ context.Context, req *gen.SetTestScriptReq) (*gen.Empty, error) {
|
||||
w.script = req.Script
|
||||
|
||||
return new(gen.Empty), nil
|
||||
}
|
||||
|
||||
func (w *Worker) Start(ctxParent context.Context, resp *gen.StartResp) (_ *gen.Empty, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
w.logger.ErrorContext(ctxParent, errors.Wrap(err, "start error").Error())
|
||||
w.state <- gen.WorkerStatus_STATE_ERROR
|
||||
} else {
|
||||
w.state <- gen.WorkerStatus_STATE_READY
|
||||
}
|
||||
}()
|
||||
|
||||
w.state <- gen.WorkerStatus_STATE_RUNNING
|
||||
|
||||
ctx, cancel := context.WithCancel(ctxParent)
|
||||
w.cancelJob = cancel
|
||||
|
||||
if err := w.startJob(ctx, resp.TestCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return new(gen.Empty), nil
|
||||
}
|
||||
|
||||
func (w *Worker) Stop(context.Context, *gen.Empty) (*gen.Empty, error) {
|
||||
w.cancelJob()
|
||||
return new(gen.Empty), nil
|
||||
}
|
||||
|
||||
func (w *Worker) ObserverChangeState(_ *gen.Empty, stream grpc.ServerStreamingServer[gen.StatusInfo]) error {
|
||||
for state := range w.state {
|
||||
err := stream.Send(&gen.StatusInfo{
|
||||
Status: state,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
w.logger.Error(errors.Wrap(err, "stream send error").Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
33
worker/internal/utils/logger.go
Normal file
33
worker/internal/utils/logger.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
logger *slog.Logger
|
||||
)
|
||||
|
||||
func init() {
|
||||
logDir, _ := os.Getwd()
|
||||
rotator := &lumberjack.Logger{
|
||||
Filename: filepath.Join(logDir, fmt.Sprintf("%s.log", time.Now().Format("2006-01-02 15-04-05"))), // путь к файлу
|
||||
MaxSize: 10, // мегабайты до ротации
|
||||
MaxBackups: 5, // сколько файлов хранить
|
||||
MaxAge: 30, // дней до удаления старых
|
||||
Compress: true, // gzip старые логи
|
||||
}
|
||||
|
||||
logger = slog.New(slog.NewJSONHandler(rotator, &slog.HandlerOptions{
|
||||
Level: slog.LevelDebug,
|
||||
}))
|
||||
}
|
||||
|
||||
func Logger() *slog.Logger {
|
||||
return logger
|
||||
}
|
||||
@@ -24,22 +24,25 @@ const (
|
||||
type WorkerStatus int32
|
||||
|
||||
const (
|
||||
WorkerStatus_READY WorkerStatus = 0
|
||||
WorkerStatus_DONE WorkerStatus = 1
|
||||
WorkerStatus_ERROR WorkerStatus = 2
|
||||
WorkerStatus_STATE_UNSPECIFIED WorkerStatus = 0
|
||||
WorkerStatus_STATE_READY WorkerStatus = 1
|
||||
WorkerStatus_STATE_RUNNING WorkerStatus = 2
|
||||
WorkerStatus_STATE_ERROR WorkerStatus = 3
|
||||
)
|
||||
|
||||
// Enum value maps for WorkerStatus.
|
||||
var (
|
||||
WorkerStatus_name = map[int32]string{
|
||||
0: "READY",
|
||||
1: "DONE",
|
||||
2: "ERROR",
|
||||
0: "STATE_UNSPECIFIED",
|
||||
1: "STATE_READY",
|
||||
2: "STATE_RUNNING",
|
||||
3: "STATE_ERROR",
|
||||
}
|
||||
WorkerStatus_value = map[string]int32{
|
||||
"READY": 0,
|
||||
"DONE": 1,
|
||||
"ERROR": 2,
|
||||
"STATE_UNSPECIFIED": 0,
|
||||
"STATE_READY": 1,
|
||||
"STATE_RUNNING": 2,
|
||||
"STATE_ERROR": 3,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -70,27 +73,27 @@ func (WorkerStatus) EnumDescriptor() ([]byte, []int) {
|
||||
return file_worker_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
type HealthResp struct {
|
||||
type StatusInfo struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Status WorkerStatus `protobuf:"varint,4,opt,name=status,proto3,enum=WorkerStatus" json:"status,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *HealthResp) Reset() {
|
||||
*x = HealthResp{}
|
||||
func (x *StatusInfo) Reset() {
|
||||
*x = StatusInfo{}
|
||||
mi := &file_worker_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *HealthResp) String() string {
|
||||
func (x *StatusInfo) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*HealthResp) ProtoMessage() {}
|
||||
func (*StatusInfo) ProtoMessage() {}
|
||||
|
||||
func (x *HealthResp) ProtoReflect() protoreflect.Message {
|
||||
func (x *StatusInfo) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_worker_proto_msgTypes[0]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
@@ -102,23 +105,21 @@ func (x *HealthResp) ProtoReflect() protoreflect.Message {
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use HealthResp.ProtoReflect.Descriptor instead.
|
||||
func (*HealthResp) Descriptor() ([]byte, []int) {
|
||||
// Deprecated: Use StatusInfo.ProtoReflect.Descriptor instead.
|
||||
func (*StatusInfo) Descriptor() ([]byte, []int) {
|
||||
return file_worker_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *HealthResp) GetStatus() WorkerStatus {
|
||||
func (x *StatusInfo) GetStatus() WorkerStatus {
|
||||
if x != nil {
|
||||
return x.Status
|
||||
}
|
||||
return WorkerStatus_READY
|
||||
return WorkerStatus_STATE_UNSPECIFIED
|
||||
}
|
||||
|
||||
type StartResp struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
|
||||
Ip string `protobuf:"bytes,2,opt,name=ip,proto3" json:"ip,omitempty"`
|
||||
Port int32 `protobuf:"varint,3,opt,name=port,proto3" json:"port,omitempty"`
|
||||
TestCount int32 `protobuf:"varint,4,opt,name=test_count,json=testCount,proto3" json:"test_count,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -153,23 +154,9 @@ func (*StartResp) Descriptor() ([]byte, []int) {
|
||||
return file_worker_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *StartResp) GetHost() string {
|
||||
func (x *StartResp) GetTestCount() int32 {
|
||||
if x != nil {
|
||||
return x.Host
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *StartResp) GetIp() string {
|
||||
if x != nil {
|
||||
return x.Ip
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *StartResp) GetPort() int32 {
|
||||
if x != nil {
|
||||
return x.Port
|
||||
return x.TestCount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -260,25 +247,25 @@ const file_worker_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\fworker.proto\"3\n" +
|
||||
"\n" +
|
||||
"HealthResp\x12%\n" +
|
||||
"\x06status\x18\x04 \x01(\x0e2\r.WorkerStatusR\x06status\"C\n" +
|
||||
"\tStartResp\x12\x12\n" +
|
||||
"\x04host\x18\x01 \x01(\tR\x04host\x12\x0e\n" +
|
||||
"\x02ip\x18\x02 \x01(\tR\x02ip\x12\x12\n" +
|
||||
"\x04port\x18\x03 \x01(\x05R\x04port\"*\n" +
|
||||
"StatusInfo\x12%\n" +
|
||||
"\x06status\x18\x04 \x01(\x0e2\r.WorkerStatusR\x06status\"*\n" +
|
||||
"\tStartResp\x12\x1d\n" +
|
||||
"\n" +
|
||||
"test_count\x18\x04 \x01(\x05R\ttestCount\"*\n" +
|
||||
"\x10SetTestScriptReq\x12\x16\n" +
|
||||
"\x06script\x18\x01 \x01(\tR\x06script\"\a\n" +
|
||||
"\x05Empty*.\n" +
|
||||
"\fWorkerStatus\x12\t\n" +
|
||||
"\x05READY\x10\x00\x12\b\n" +
|
||||
"\x04DONE\x10\x01\x12\t\n" +
|
||||
"\x05ERROR\x10\x022\x92\x01\n" +
|
||||
"\x05Empty*Z\n" +
|
||||
"\fWorkerStatus\x12\x15\n" +
|
||||
"\x11STATE_UNSPECIFIED\x10\x00\x12\x0f\n" +
|
||||
"\vSTATE_READY\x10\x01\x12\x11\n" +
|
||||
"\rSTATE_RUNNING\x10\x02\x12\x0f\n" +
|
||||
"\vSTATE_ERROR\x10\x032\x9f\x01\n" +
|
||||
"\x06Worker\x12,\n" +
|
||||
"\rSetTestScript\x12\x11.SetTestScriptReq\x1a\x06.Empty\"\x00\x12\x1d\n" +
|
||||
"\x05Start\x12\x06.Empty\x1a\n" +
|
||||
".StartResp\"\x00\x12\x18\n" +
|
||||
"\x04Stop\x12\x06.Empty\x1a\x06.Empty\"\x00\x12!\n" +
|
||||
"\x06Health\x12\x06.Empty\x1a\v.HealthResp\"\x000\x01B\aZ\x05./genb\x06proto3"
|
||||
"\x05Start\x12\n" +
|
||||
".StartResp\x1a\x06.Empty\"\x00\x12\x18\n" +
|
||||
"\x04Stop\x12\x06.Empty\x1a\x06.Empty\"\x00\x12.\n" +
|
||||
"\x13ObserverChangeState\x12\x06.Empty\x1a\v.StatusInfo\"\x000\x01B\aZ\x05./genb\x06proto3"
|
||||
|
||||
var (
|
||||
file_worker_proto_rawDescOnce sync.Once
|
||||
@@ -296,21 +283,21 @@ var file_worker_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
||||
var file_worker_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
|
||||
var file_worker_proto_goTypes = []any{
|
||||
(WorkerStatus)(0), // 0: WorkerStatus
|
||||
(*HealthResp)(nil), // 1: HealthResp
|
||||
(*StatusInfo)(nil), // 1: StatusInfo
|
||||
(*StartResp)(nil), // 2: StartResp
|
||||
(*SetTestScriptReq)(nil), // 3: SetTestScriptReq
|
||||
(*Empty)(nil), // 4: Empty
|
||||
}
|
||||
var file_worker_proto_depIdxs = []int32{
|
||||
0, // 0: HealthResp.status:type_name -> WorkerStatus
|
||||
0, // 0: StatusInfo.status:type_name -> WorkerStatus
|
||||
3, // 1: Worker.SetTestScript:input_type -> SetTestScriptReq
|
||||
4, // 2: Worker.Start:input_type -> Empty
|
||||
2, // 2: Worker.Start:input_type -> StartResp
|
||||
4, // 3: Worker.Stop:input_type -> Empty
|
||||
4, // 4: Worker.Health:input_type -> Empty
|
||||
4, // 4: Worker.ObserverChangeState:input_type -> Empty
|
||||
4, // 5: Worker.SetTestScript:output_type -> Empty
|
||||
2, // 6: Worker.Start:output_type -> StartResp
|
||||
4, // 6: Worker.Start:output_type -> Empty
|
||||
4, // 7: Worker.Stop:output_type -> Empty
|
||||
1, // 8: Worker.Health:output_type -> HealthResp
|
||||
1, // 8: Worker.ObserverChangeState:output_type -> StatusInfo
|
||||
5, // [5:9] is the sub-list for method output_type
|
||||
1, // [1:5] is the sub-list for method input_type
|
||||
1, // [1:1] is the sub-list for extension type_name
|
||||
|
||||
@@ -22,7 +22,7 @@ const (
|
||||
Worker_SetTestScript_FullMethodName = "/Worker/SetTestScript"
|
||||
Worker_Start_FullMethodName = "/Worker/Start"
|
||||
Worker_Stop_FullMethodName = "/Worker/Stop"
|
||||
Worker_Health_FullMethodName = "/Worker/Health"
|
||||
Worker_ObserverChangeState_FullMethodName = "/Worker/ObserverChangeState"
|
||||
)
|
||||
|
||||
// WorkerClient is the client API for Worker service.
|
||||
@@ -30,9 +30,9 @@ const (
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
type WorkerClient interface {
|
||||
SetTestScript(ctx context.Context, in *SetTestScriptReq, opts ...grpc.CallOption) (*Empty, error)
|
||||
Start(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*StartResp, error)
|
||||
Start(ctx context.Context, in *StartResp, opts ...grpc.CallOption) (*Empty, error)
|
||||
Stop(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Empty, error)
|
||||
Health(ctx context.Context, in *Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[HealthResp], error)
|
||||
ObserverChangeState(ctx context.Context, in *Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[StatusInfo], error)
|
||||
}
|
||||
|
||||
type workerClient struct {
|
||||
@@ -53,9 +53,9 @@ func (c *workerClient) SetTestScript(ctx context.Context, in *SetTestScriptReq,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *workerClient) Start(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*StartResp, error) {
|
||||
func (c *workerClient) Start(ctx context.Context, in *StartResp, opts ...grpc.CallOption) (*Empty, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(StartResp)
|
||||
out := new(Empty)
|
||||
err := c.cc.Invoke(ctx, Worker_Start_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -73,13 +73,13 @@ func (c *workerClient) Stop(ctx context.Context, in *Empty, opts ...grpc.CallOpt
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *workerClient) Health(ctx context.Context, in *Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[HealthResp], error) {
|
||||
func (c *workerClient) ObserverChangeState(ctx context.Context, in *Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[StatusInfo], error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
stream, err := c.cc.NewStream(ctx, &Worker_ServiceDesc.Streams[0], Worker_Health_FullMethodName, cOpts...)
|
||||
stream, err := c.cc.NewStream(ctx, &Worker_ServiceDesc.Streams[0], Worker_ObserverChangeState_FullMethodName, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x := &grpc.GenericClientStream[Empty, HealthResp]{ClientStream: stream}
|
||||
x := &grpc.GenericClientStream[Empty, StatusInfo]{ClientStream: stream}
|
||||
if err := x.ClientStream.SendMsg(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -90,16 +90,16 @@ func (c *workerClient) Health(ctx context.Context, in *Empty, opts ...grpc.CallO
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type Worker_HealthClient = grpc.ServerStreamingClient[HealthResp]
|
||||
type Worker_ObserverChangeStateClient = grpc.ServerStreamingClient[StatusInfo]
|
||||
|
||||
// WorkerServer is the server API for Worker service.
|
||||
// All implementations must embed UnimplementedWorkerServer
|
||||
// for forward compatibility.
|
||||
type WorkerServer interface {
|
||||
SetTestScript(context.Context, *SetTestScriptReq) (*Empty, error)
|
||||
Start(context.Context, *Empty) (*StartResp, error)
|
||||
Start(context.Context, *StartResp) (*Empty, error)
|
||||
Stop(context.Context, *Empty) (*Empty, error)
|
||||
Health(*Empty, grpc.ServerStreamingServer[HealthResp]) error
|
||||
ObserverChangeState(*Empty, grpc.ServerStreamingServer[StatusInfo]) error
|
||||
mustEmbedUnimplementedWorkerServer()
|
||||
}
|
||||
|
||||
@@ -113,14 +113,14 @@ type UnimplementedWorkerServer struct{}
|
||||
func (UnimplementedWorkerServer) SetTestScript(context.Context, *SetTestScriptReq) (*Empty, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SetTestScript not implemented")
|
||||
}
|
||||
func (UnimplementedWorkerServer) Start(context.Context, *Empty) (*StartResp, error) {
|
||||
func (UnimplementedWorkerServer) Start(context.Context, *StartResp) (*Empty, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Start not implemented")
|
||||
}
|
||||
func (UnimplementedWorkerServer) Stop(context.Context, *Empty) (*Empty, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method Stop not implemented")
|
||||
}
|
||||
func (UnimplementedWorkerServer) Health(*Empty, grpc.ServerStreamingServer[HealthResp]) error {
|
||||
return status.Errorf(codes.Unimplemented, "method Health not implemented")
|
||||
func (UnimplementedWorkerServer) ObserverChangeState(*Empty, grpc.ServerStreamingServer[StatusInfo]) error {
|
||||
return status.Errorf(codes.Unimplemented, "method ObserverChangeState not implemented")
|
||||
}
|
||||
func (UnimplementedWorkerServer) mustEmbedUnimplementedWorkerServer() {}
|
||||
func (UnimplementedWorkerServer) testEmbeddedByValue() {}
|
||||
@@ -162,7 +162,7 @@ func _Worker_SetTestScript_Handler(srv interface{}, ctx context.Context, dec fun
|
||||
}
|
||||
|
||||
func _Worker_Start_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(Empty)
|
||||
in := new(StartResp)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -174,7 +174,7 @@ func _Worker_Start_Handler(srv interface{}, ctx context.Context, dec func(interf
|
||||
FullMethod: Worker_Start_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WorkerServer).Start(ctx, req.(*Empty))
|
||||
return srv.(WorkerServer).Start(ctx, req.(*StartResp))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
@@ -197,16 +197,16 @@ func _Worker_Stop_Handler(srv interface{}, ctx context.Context, dec func(interfa
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _Worker_Health_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
func _Worker_ObserverChangeState_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||
m := new(Empty)
|
||||
if err := stream.RecvMsg(m); err != nil {
|
||||
return err
|
||||
}
|
||||
return srv.(WorkerServer).Health(m, &grpc.GenericServerStream[Empty, HealthResp]{ServerStream: stream})
|
||||
return srv.(WorkerServer).ObserverChangeState(m, &grpc.GenericServerStream[Empty, StatusInfo]{ServerStream: stream})
|
||||
}
|
||||
|
||||
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||
type Worker_HealthServer = grpc.ServerStreamingServer[HealthResp]
|
||||
type Worker_ObserverChangeStateServer = grpc.ServerStreamingServer[StatusInfo]
|
||||
|
||||
// Worker_ServiceDesc is the grpc.ServiceDesc for Worker service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
@@ -230,8 +230,8 @@ var Worker_ServiceDesc = grpc.ServiceDesc{
|
||||
},
|
||||
Streams: []grpc.StreamDesc{
|
||||
{
|
||||
StreamName: "Health",
|
||||
Handler: _Worker_Health_Handler,
|
||||
StreamName: "ObserverChangeState",
|
||||
Handler: _Worker_ObserverChangeState_Handler,
|
||||
ServerStreams: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,19 +4,17 @@ option go_package = "./gen";
|
||||
|
||||
service Worker {
|
||||
rpc SetTestScript(SetTestScriptReq) returns(Empty) {}
|
||||
rpc Start(Empty) returns(StartResp) {}
|
||||
rpc Start(StartResp) returns(Empty) {}
|
||||
rpc Stop(Empty) returns(Empty) {}
|
||||
rpc Health(Empty) returns(stream HealthResp) {}
|
||||
rpc ObserverChangeState(Empty) returns(stream StatusInfo) {}
|
||||
}
|
||||
|
||||
message HealthResp {
|
||||
message StatusInfo {
|
||||
WorkerStatus status = 4;
|
||||
}
|
||||
|
||||
message StartResp {
|
||||
string host = 1;
|
||||
string ip = 2;
|
||||
int32 port = 3;
|
||||
int32 test_count = 4;
|
||||
}
|
||||
|
||||
message SetTestScriptReq {
|
||||
@@ -27,8 +25,9 @@ message Empty {
|
||||
}
|
||||
|
||||
enum WorkerStatus {
|
||||
READY = 0;
|
||||
DONE = 1;
|
||||
ERROR = 2;
|
||||
STATE_UNSPECIFIED = 0;
|
||||
STATE_READY = 1;
|
||||
STATE_RUNNING = 2;
|
||||
STATE_ERROR = 3;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user