1c/Ox
1
0
mirror of https://github.com/LazarenkoA/Ox.git synced 2025-11-23 21:33:13 +02:00
This commit is contained in:
Артем
2025-11-04 18:58:12 +03:00
parent 4dca5d34e3
commit 84b51ad85f
30 changed files with 1736 additions and 600 deletions

10
.gitignore vendored
View File

@@ -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
View File

@@ -0,0 +1,367 @@
# 🐂 Ox — Нагрузочное тестирование 1С
![logo.png](docs/img/logo.png)
> Кроссплатформенный инструмент для нагрузочного тестирования веб-клиента 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**:
![browser_z2xVfQLybR.png](docs/img/browser_z2xVfQLybR.png)
#### Устранение проблем с подключением
**Если 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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
docs/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

21
go.mod
View File

@@ -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
View File

@@ -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=

View File

@@ -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) {

View File

@@ -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"

View File

@@ -0,0 +1 @@
package app

View File

@@ -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
}

View 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
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
})
}
}

View File

@@ -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
}

View 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
}

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -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) =&gt; r.status === 200,
'response time &lt; 500ms': (r) =&gt; r.timings.duration &lt; 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) =&gt; r.status === 200,
'response time &lt; 500ms': (r) =&gt; r.timings.duration &lt; 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">&times;</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>

View File

@@ -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()
}

View File

@@ -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
}
}

View 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()
}

View 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

View 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,
// },
});

View File

@@ -0,0 +1,7 @@
package app
import "context"
func stopProcess(ctx context.Context) {
}

View 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))
}()
}

View File

@@ -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

View 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
}

View File

@@ -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

View File

@@ -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,
},
},

View File

@@ -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;
}