first commit
This commit is contained in:
928
README.md
Normal file
928
README.md
Normal file
@@ -0,0 +1,928 @@
|
|||||||
|
<!-- Improved compatibility of К началу link: See: https://github.com/othneildrew/Best-README-Template/pull/73 -->
|
||||||
|
<a id="readme-top"></a>
|
||||||
|
<!--
|
||||||
|
*** Thanks for checking out the Best-README-Template. If you have a suggestion
|
||||||
|
*** that would make this better, please fork the repo and create a pull request
|
||||||
|
*** or simply open an issue with the tag "enhancement".
|
||||||
|
*** Don't forget to give the project a star!
|
||||||
|
*** Thanks again! Now go create something AMAZING! :D
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!-- PROJECT LOGO -->
|
||||||
|
<br />
|
||||||
|
<div align="center">
|
||||||
|
<!-- <a href="https://github.com/othneildrew/Best-README-Template"> -->
|
||||||
|
<img src="Logo.png" height=100 alt="Logo.png"></img>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<h3> <b>Futriis-это легковесная, распределённая wait-free и lock-free дружественная in-memory СУБД,
|
||||||
|
реализованная на Go с поддержкой плагинов на языке lua для операционных систем на базе Solaris (ядра Illumos)</b> <br></h3>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<!-- <a href="">Сообщить об ошибке</a>
|
||||||
|
·
|
||||||
|
<!-- <a href="">Предложение новой функциональности</a> -->
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Краткая документация проекта FutriiS
|
||||||
|
|
||||||
|
<!-- TABLE OF CONTENTS -->
|
||||||
|
<br>
|
||||||
|
<!-- <details> -->
|
||||||
|
<summary><b>Содержание</b></summary></br>
|
||||||
|
<ol>
|
||||||
|
<li>
|
||||||
|
<a href="#о-проекте">О проекте</a>
|
||||||
|
<li><a href="#лицензия">Лицензия</a></li>
|
||||||
|
<li><a href="#глоссарий">Глоссарий</a></li>
|
||||||
|
<li><a href="#типы-данных-субд">Типы данных субд</a></li>
|
||||||
|
<li><a href="#системные-требования">Системные требования</a></li>
|
||||||
|
<li><a href="#подготовка-и-компиляция">Подготовка и компиляция</a></li>
|
||||||
|
<li><a href="#тестирование">Тестирование</a></li>
|
||||||
|
<li><a href="#crud-операции">CRUD операции</a></li>
|
||||||
|
<li><a href="#индексы">Индексы</a></li>
|
||||||
|
<li><a href="#транзакции">Транзакции</a></li>
|
||||||
|
<li><a href="#кластеризация-и-шардинг">Кластеризация и шардинг</a></li>
|
||||||
|
<li><a href="#ограничения">Ограничения</a></li>
|
||||||
|
<li><a href="#import-export">Import-Export</a></li>
|
||||||
|
<li><a href="#lua-плагины">Lua-плагины</a></li>
|
||||||
|
<li><a href="acl">ACL</a></li>
|
||||||
|
<li><a href="#http-api">HTTP API</a></li>
|
||||||
|
<li><a href="#триггеры">Триггеры</a></li>
|
||||||
|
<li><a href="#сжатие-данных">Сжатие данных</a></li>
|
||||||
|
<li><a href="#дорожная-карта">Дорожная карта</a></li>
|
||||||
|
<li><a href="#контакты">Контакты</a></li>
|
||||||
|
</ol>
|
||||||
|
<!-- </details> -->
|
||||||
|
|
||||||
|
|
||||||
|
## О проекте
|
||||||
|
|
||||||
|
futriis - это легковесная, распределённая wait-free и lock-free дружественная in-memory СУБД, реализованная на Go с поддержкой плагинов на языке lua использующая алгоритм консенсуса Raft.
|
||||||
|
Данная субд была разработана, в первую очередь для эксплуатации на операционных системах на базе Solaris: OpenIndiana, Oracle Solaris.
|
||||||
|
|
||||||
|
|
||||||
|
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
Проект распространяется под лицензией **`CDDL 1.0`**. Подробнсти в файлах `LICENSE` и `NOTICE`.
|
||||||
|
Эта лицензия позволяет вам производить копирование, модификацию, распространение, включение в другие проекты, получение патентных прав, распространение бинарных файлов с доступом к их исходному коду. Она запрещает вам добавление новых ограничений, скрытие изменений, удаление оригинальных уведомлений, несоблюдение условий CDDL 1.0 при перераспределении, неправильное связывание с другими лицензиями.
|
||||||
|
|
||||||
|
Все дополнительное программное обеспечение (включая скрипт компиляции проекта `build.sh`) предоставляются "как есть", без гарантий и обязательств со стороны разработчиков. Разработчики не несут ответственности за прямой или косвенный ущерб, вызванный использованием открытого кода Futriix и futriix или технических решений, использующих этот код.
|
||||||
|
|
||||||
|
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
||||||
|
|
||||||
|
## Глоссарий
|
||||||
|
|
||||||
|
* **База Данных(БД)** - это структурированное, организованное хранилище данных, которое позволяет удобно собирать, хранить, управлять и извлекать информацию.
|
||||||
|
* **Система Управления Базами Данных(СУБД)** - это программное обеспечение, которое позволяет создавать, управлять и взаимодействовать с базами данных
|
||||||
|
* **Таппл (Tapple)** - аналог базы данных в РСУБД
|
||||||
|
* **Слайс (Slice)** - аналог таблицы
|
||||||
|
* **Кортеж (Tuple)** - аналог записи в таблице
|
||||||
|
* **Мультимодельная СУБД** - это СУБД, которая объединяет в себе поддержку нескольких моделей данных (реляционной, документной, графовой, ключ-значение и др.) в рамках единого интегрированного ядра.
|
||||||
|
* **Резидентная СУБД** - это СУБД, которая работает непрерывно в оперативной памяти (RAM).
|
||||||
|
* **Инстанс** - это запущенный экземляр базы данных.
|
||||||
|
* **Узел (хост,нода,шард)** - это отдельный сервер (физический или виртуальный), который является частью кластера или распределенной системы и выполняет часть общей работы.
|
||||||
|
* **Слайс (от англ. "slice"-слой)** - это логический и физически изолированный фрагмент коллекции документов, полученный в результате горизонтального партиционирования (шардирования) и размещенный на определенном узле кластера с целью масштабирования производительности и объема данных.
|
||||||
|
* **Репликасет** - это группа серверов СУБД, объединенных в отказоустойчивую конфигурацию, где один узел выполняет роль первичного (принимающего операции записи), а один или несколько других - роль вторичных (синхронизирующих свои данные с первичным и обслуживающих чтение), с автоматическим переизбранием первичного узла в случае его сбоя.
|
||||||
|
* **Временные ряды (time series)** - это это упорядоченная во времени последовательность данных, собранная в регулярные промежутки времени из какого-либо источниика (цены на акции, данные температуры, объёмы продаж и.т.д.).
|
||||||
|
* **OLTP (Online Transactional Processing-Онлайн обработка транзакций)**- это технология обработки транзакций в режиме реального времени. Её основная задача заключается в обеспечении быстрого и надёжного выполнения операций, которые происходят ежесекундно в бизнесе. Они обеспечивают быстрое выполнение операций вставки, обновления и удаления данных, поддерживая целостность и надежность транзакций.
|
||||||
|
* **OLAP (Online Analytical Processing - Оперативная аналитическая обработка)** — это технология, которая работает с историческими массивами информации, извлекая из них закономерности и производя анализ больших объемов данных, поддерживает многоразмерные запросы и сложные аналитические операции. Данная технология оптимизирована для выполнения сложных запросов и предоставления сводной информации для принятия управленческих решений.
|
||||||
|
* **HTAP (Hybrid Transactional and Analytical Processing - Гибридная транзакционно-аналитическая обработка)**- это технология, которая заключаются в эффективном совмещении операционных и аналитических запросов, т.е. классов OLTP и OLAP.
|
||||||
|
* **Кластер** - это группа компьютеров, объединённых высокоскоростными каналами связи для решения сложных вычислительных задач и представляющая с точки зрения пользователя группу серверов, объединенных для работы как единая система.
|
||||||
|
* **WUI (от англ. Web-User-Interface "веб интерфейс пользователя")** - это термин проекта futriix, означающий веб-интерфейс (интерфейс работающий в веб-браузере)
|
||||||
|
* **Сервер-приложений (англ. application-server)** - это программное обеспечение, которое обеспечивает выполнение бизнес-логики и обработку запросов от клиентов (например, веб-браузеров или мобильных приложений). Он служит платформой для развертывания и управления приложениями, имея встроенные интепретаторы и/или компиляторы популярных языков программирования (php,go,python), что обеспечивает взаимодействие между пользователями, базами данных и другими системами.
|
||||||
|
* **workflow (англ. workflow — «поток работы»)** — это принцип организации рабочих процессов, в соответствии с которым повторяющиеся задачи представлены как последовательность стандартных шагов.
|
||||||
|
* **wait-free (дословно с англ. wait-free — «свободный от ожидания»)**-класс неблокирующих алгоритмов, в которых каждая операция должна завершаться за конечное число шагов независимо от активности других потоков.
|
||||||
|
* **CA (англ. Certificate Authority - Центры Сертификации)** - это организации, которые выдают доверенные криптографические сертификаты.
|
||||||
|
* Команды, выполняемые с привилегиями суперпользователя (root), отмечены символом приглашения **«#»**
|
||||||
|
* Команды, выполняемые с правами обычного пользователя(user), отмечены символом приглашения **«$»**
|
||||||
|
|
||||||
|
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
||||||
|
|
||||||
|
|
||||||
|
## Системные требования
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> - Процессор: Intel или AMD
|
||||||
|
> - Оперативная память: 4ГБ (Для Linux) 8ГБ (Для Illumos sytems)
|
||||||
|
> - Только Unix-подобная ОС (Solaris, OpenIndiana, Linux)
|
||||||
|
> - Go 1.25.6 или выше
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **Важно: Windows и MacOS X не поддерживаются!**
|
||||||
|
|
||||||
|
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
||||||
|
|
||||||
|
|
||||||
|
## Подготовка и компиляция
|
||||||
|
|
||||||
|
1. Клонируйте репозиторий:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ git clone https://github.com/futriis/db.git
|
||||||
|
$ cd futriis
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Скомпилируйте и запустите:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Стандартная сборка для ОС на базе Linux
|
||||||
|
$ ./build.sh
|
||||||
|
|
||||||
|
# Сборка для операционных систем на базе Illumos
|
||||||
|
$ cd scripts/
|
||||||
|
$ ./build_illumos.sh
|
||||||
|
|
||||||
|
# Показать справку
|
||||||
|
$./build.sh --help
|
||||||
|
|
||||||
|
$ ./futriis
|
||||||
|
```
|
||||||
|
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
||||||
|
|
||||||
|
|
||||||
|
### Тестирование
|
||||||
|
|
||||||
|
На данный момент для субд реализовано пять тестов (регрессионный, smoke-тест, функциональный, интеграционный, нагрузочный) на языке lua, покрывающий функционал acl, индексов, constraint'ов, RestFull HTTP API, ACL
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> 1. Перед запуском тестов убедитесь, что СУБД запущена и HTTP API доступен на порту 8080
|
||||||
|
> 2. Load test может занять несколько минут при больших объёмах данных
|
||||||
|
> 3. Для параллельного тестирования в нагрузочном тесте рекомендуется использовать lua-lanes или запускать несколько процессов
|
||||||
|
> 4. Все тесты используют аутентификацию и проверяют как позитивные, так и негативные сценарии
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Установка LuaSocket и JSON библиотек
|
||||||
|
luarocks install luasocket
|
||||||
|
luarocks install lua-cjson
|
||||||
|
|
||||||
|
# Запуск отдельных тестов
|
||||||
|
lua regression_test.lua
|
||||||
|
lua smoke_test.lua
|
||||||
|
lua functional_test.lua
|
||||||
|
lua integration_test.lua
|
||||||
|
lua load_test.lua
|
||||||
|
|
||||||
|
# Или все тесты последовательно
|
||||||
|
for test in regression smoke functional integration load; do
|
||||||
|
echo "Running ${test}_test.lua..."
|
||||||
|
lua ${test}_test.lua
|
||||||
|
echo "---"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
||||||
|
|
||||||
|
### Примеры команд субд
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Создание новой базы данных
|
||||||
|
futriiS:~> create database company
|
||||||
|
✓ Database 'company' created
|
||||||
|
|
||||||
|
futriiS:~> create database shop
|
||||||
|
✓ Database 'shop' created
|
||||||
|
|
||||||
|
# Переключение на базу данных
|
||||||
|
futriiS:~> use company
|
||||||
|
✓ Switched to database 'company'
|
||||||
|
|
||||||
|
futriiS:~> use shop
|
||||||
|
✓ Switched to database 'shop'
|
||||||
|
|
||||||
|
# Просмотр всех баз данных
|
||||||
|
futriiS:~> show databases
|
||||||
|
Databases:
|
||||||
|
company
|
||||||
|
* shop
|
||||||
|
test
|
||||||
|
|
||||||
|
# Удаление базы данных
|
||||||
|
futriiS:~> drop database test
|
||||||
|
✓ Database 'test' dropped
|
||||||
|
```
|
||||||
|
|
||||||
|
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
||||||
|
|
||||||
|
## CRUD операции
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Создание новой базы данных
|
||||||
|
futriiS:~> create database company
|
||||||
|
✓ Database 'company' created
|
||||||
|
|
||||||
|
futriiS:~> create database shop
|
||||||
|
✓ Database 'shop' created
|
||||||
|
|
||||||
|
# Переключение на базу данных
|
||||||
|
futriiS:~> use company
|
||||||
|
✓ Switched to database 'company'
|
||||||
|
|
||||||
|
futriiS:~> use shop
|
||||||
|
✓ Switched to database 'shop'
|
||||||
|
|
||||||
|
# Просмотр всех баз данных
|
||||||
|
futriiS:~> show databases
|
||||||
|
Databases:
|
||||||
|
company
|
||||||
|
* shop
|
||||||
|
test
|
||||||
|
|
||||||
|
# Удаление базы данных
|
||||||
|
futriiS:~> drop database test
|
||||||
|
✓ Database 'test' dropped
|
||||||
|
```
|
||||||
|
```sh
|
||||||
|
# Создание коллекции
|
||||||
|
futriiS:~> use company
|
||||||
|
✓ Switched to database 'company'
|
||||||
|
|
||||||
|
futriiS:~> create collection employees
|
||||||
|
✓ Collection 'employees' created in database 'company'
|
||||||
|
|
||||||
|
futriiS:~> create collection departments
|
||||||
|
✓ Collection 'departments' created in database 'company'
|
||||||
|
|
||||||
|
futriiS:~> create collection projects
|
||||||
|
✓ Collection 'projects' created in database 'company'
|
||||||
|
|
||||||
|
# Просмотр всех коллекций
|
||||||
|
futriiS:~> show collections
|
||||||
|
Collections in database 'company':
|
||||||
|
- employees
|
||||||
|
- departments
|
||||||
|
- projects
|
||||||
|
|
||||||
|
# Удаление коллекции
|
||||||
|
futriiS:~> drop collection projects
|
||||||
|
✓ Collection 'projects' dropped from database 'company'
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Вставка документа (простой формат key=value)
|
||||||
|
futriiS:~> insert employees name=John Doe,position=Developer,age=30,department=IT
|
||||||
|
✓ Document inserted with ID: 550e8400-e29b-41d4-a716-446655440000
|
||||||
|
|
||||||
|
futriiS:~> insert employees name=Jane Smith,position=Manager,age=35,department=HR
|
||||||
|
✓ Document inserted with ID: 550e8400-e29b-41d4-a716-446655440001
|
||||||
|
|
||||||
|
futriiS:~> insert employees name=Bob Johnson,position=Designer,age=28,department=Design
|
||||||
|
✓ Document inserted with ID: 550e8400-e29b-41d4-a716-446655440002
|
||||||
|
|
||||||
|
# Поиск документа по ID
|
||||||
|
futriiS:~> find employees 550e8400-e29b-41d4-a716-446655440000
|
||||||
|
Document found:
|
||||||
|
{
|
||||||
|
"name": "John Doe",
|
||||||
|
"position": "Developer",
|
||||||
|
"age": 30,
|
||||||
|
"department": "IT"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Поиск по индексу
|
||||||
|
futriiS:~> findbyindex employees name_idx "John Doe"
|
||||||
|
Found 1 document(s):
|
||||||
|
[1] ID: 550e8400-e29b-41d4-a716-446655440000
|
||||||
|
{
|
||||||
|
"name": "John Doe",
|
||||||
|
"position": "Developer",
|
||||||
|
"age": 30,
|
||||||
|
"department": "IT"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Обновление документа
|
||||||
|
futriiS:~> update employees 550e8400-e29b-41d4-a716-446655440000 age=31,position=Senior Developer
|
||||||
|
✓ Document '550e8400-e29b-41d4-a716-446655440000' updated
|
||||||
|
|
||||||
|
# Подсчёт количества документов
|
||||||
|
futriiS:~> count employees
|
||||||
|
Collection 'employees' has 3 document(s)
|
||||||
|
|
||||||
|
# Удаление документа
|
||||||
|
futriiS:~> delete employees 550e8400-e29b-41d4-a716-446655440002
|
||||||
|
✓ Document '550e8400-e29b-41d4-a716-446655440002' deleted
|
||||||
|
|
||||||
|
futriiS:~> count employees
|
||||||
|
Collection 'employees' has 2 document(s)
|
||||||
|
|
||||||
|
```
|
||||||
|
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
||||||
|
|
||||||
|
|
||||||
|
## Индексы
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Создание обычного индекса
|
||||||
|
futriiS:~> create index employees name_idx name
|
||||||
|
✓ Index 'name_idx' created on collection 'employees'
|
||||||
|
|
||||||
|
# Создание уникального индекса
|
||||||
|
futriiS:~> create index employees email_idx email unique
|
||||||
|
✓ Index 'email_idx' created on collection 'employees'
|
||||||
|
|
||||||
|
# Создание составного индекса
|
||||||
|
futriiS:~> create index employees dept_age_idx department,age
|
||||||
|
✓ Index 'dept_age_idx' created on collection 'employees'
|
||||||
|
|
||||||
|
# Просмотр всех индексов
|
||||||
|
futriiS:~> show indexes employees
|
||||||
|
Indexes on collection 'employees':
|
||||||
|
- _id_
|
||||||
|
- name_idx
|
||||||
|
- email_idx
|
||||||
|
- dept_age_idx
|
||||||
|
|
||||||
|
# Удаление индекса
|
||||||
|
futriiS:~> drop index employees dept_age_idx
|
||||||
|
✓ Index 'dept_age_idx' dropped from collection 'employees'
|
||||||
|
```
|
||||||
|
|
||||||
|
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
||||||
|
|
||||||
|
## Транзакции
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Начало сессии
|
||||||
|
futriiS:~> db.startSession()
|
||||||
|
✓ Session started: session_12345
|
||||||
|
|
||||||
|
# Начало транзакции в рамках сессии
|
||||||
|
futriiS:~> session.startTransaction()
|
||||||
|
✓ Transaction started: TX_67890
|
||||||
|
|
||||||
|
# Выполнение операций в транзакции
|
||||||
|
futriiS:~> insert employees name=New User,position=Trainee,age=22
|
||||||
|
✓ Document inserted with ID: 550e8400-e29b-41d4-a716-446655440005
|
||||||
|
|
||||||
|
futriiS:~> update employees 550e8400-e29b-41d4-a716-446655440005 status=active
|
||||||
|
✓ Document '550e8400-e29b-41d4-a716-446655440005' updated
|
||||||
|
|
||||||
|
# Подтверждение транзакции
|
||||||
|
futriiS:~> session.commitTransaction()
|
||||||
|
✓ Transaction committed successfully
|
||||||
|
|
||||||
|
# Откат транзакции (при ошибке)
|
||||||
|
futriiS:~> session.startTransaction()
|
||||||
|
✓ Transaction started: TX_67891
|
||||||
|
|
||||||
|
futriiS:~> insert employees name=Test User,position=Test,age=25
|
||||||
|
✓ Document inserted with ID: 550e8400-e29b-41d4-a716-446655440006
|
||||||
|
|
||||||
|
futriiS:~> session.abortTransaction()
|
||||||
|
✓ Transaction aborted, changes rolled back
|
||||||
|
```
|
||||||
|
|
||||||
|
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
||||||
|
|
||||||
|
|
||||||
|
## Кластеризация и шардинг
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Просмотр статуса кластера
|
||||||
|
futriiS:~> status
|
||||||
|
=== Cluster Status ===
|
||||||
|
✓ Role: LEADER
|
||||||
|
Cluster Name: production
|
||||||
|
Node: 192.168.1.100:8080
|
||||||
|
Raft Port: 7000
|
||||||
|
|
||||||
|
# В режиме follower
|
||||||
|
futriiS:~> status
|
||||||
|
=== Cluster Status ===
|
||||||
|
⚠ Role: FOLLOWER
|
||||||
|
Cluster Name: production
|
||||||
|
Node: 192.168.1.101:8080
|
||||||
|
Raft Port: 7000
|
||||||
|
|
||||||
|
# Просмотр всех узлов кластера
|
||||||
|
futriiS:~> nodes
|
||||||
|
=== Cluster Nodes ===
|
||||||
|
* 192.168.1.100:8080
|
||||||
|
192.168.1.101:8080
|
||||||
|
192.168.1.102:8080
|
||||||
|
|
||||||
|
```
|
||||||
|
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Добавление обязательного поля
|
||||||
|
futriiS:~> add required employees email
|
||||||
|
✓ Required field 'email' added to collection 'employees'
|
||||||
|
|
||||||
|
# Добавление ограничения уникальности
|
||||||
|
futriiS:~> add unique employees phone
|
||||||
|
✓ Unique constraint added for field 'phone' on collection 'employees'
|
||||||
|
|
||||||
|
# Добавление минимального значения
|
||||||
|
futriiS:~> add min employees age 18
|
||||||
|
✓ Min constraint added for field 'age' on collection 'employees' (min: 18.00)
|
||||||
|
|
||||||
|
# Добавление максимального значения
|
||||||
|
futriiS:~> add max employees age 65
|
||||||
|
✓ Max constraint added for field 'age' on collection 'employees' (max: 65.00)
|
||||||
|
|
||||||
|
# Добавление enum-ограничения (допустимые значения)
|
||||||
|
futriiS:~> add enum employees status active,inactive,on_leave
|
||||||
|
✓ Enum constraint added for field 'status' on collection 'employees' (allowed: [active inactive on_leave])
|
||||||
|
```
|
||||||
|
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
||||||
|
|
||||||
|
|
||||||
|
## Import-Export
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Экспорт базы данных в файл MessagePack
|
||||||
|
futriiS:~> export "company" "company_backup.msgpack"
|
||||||
|
✓ Database 'company' exported to company_backup.msgpack
|
||||||
|
|
||||||
|
# Экспорт с автоматическим добавлением расширения
|
||||||
|
futriiS:~> export "shop" "shop_backup"
|
||||||
|
✓ Database 'shop' exported to shop_backup.msgpack
|
||||||
|
|
||||||
|
# Импорт базы данных из файла
|
||||||
|
futriiS:~> import "company" "company_backup.msgpack"
|
||||||
|
Importing data from company_backup.msgpack to database 'company'...
|
||||||
|
✓ Database 'company' imported successfully from company_backup.msgpack
|
||||||
|
Collections imported: 2
|
||||||
|
Documents imported: 150
|
||||||
|
Documents skipped (already exist): 0
|
||||||
|
Documents failed: 0
|
||||||
|
|
||||||
|
# Импорт в новую базу данных
|
||||||
|
futriiS:~> import "company_restore" "company_backup.msgpack"
|
||||||
|
Created database 'company_restore'
|
||||||
|
✓ Database 'company_restore' imported successfully from company_backup.msgpack
|
||||||
|
Collections imported: 2
|
||||||
|
Documents imported: 150
|
||||||
|
```
|
||||||
|
|
||||||
|
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
||||||
|
|
||||||
|
## Lua-плагины
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Просмотр информации о системе плагинов
|
||||||
|
futriiS:~> plugin status
|
||||||
|
=== Plugin System Status ===
|
||||||
|
Enabled: true
|
||||||
|
Plugins Directory: ./plugins
|
||||||
|
Loaded Plugins: 3
|
||||||
|
Total Executions: 125
|
||||||
|
|
||||||
|
# Список загруженных плагинов
|
||||||
|
futriiS:~> plugin list
|
||||||
|
=== Loaded Plugins ===
|
||||||
|
validation (v1.0.0) by admin - Document validation rules
|
||||||
|
Status: RUNNING
|
||||||
|
audit (v2.1.0) by security - Audit trail logger
|
||||||
|
Status: RUNNING
|
||||||
|
notify (v1.2.0) by devops - Email and webhook notifications
|
||||||
|
Status: RUNNING
|
||||||
|
|
||||||
|
# Загрузка плагина из файла
|
||||||
|
futriiS:~> plugin load email_notifier ./plugins/email_notifier.lua
|
||||||
|
✓ Plugin 'email_notifier' loaded successfully
|
||||||
|
Version: 1.0.0
|
||||||
|
Author: admin
|
||||||
|
Description: Send email notifications on database events
|
||||||
|
|
||||||
|
# Запуск/остановка плагина
|
||||||
|
futriiS:~> plugin start email_notifier
|
||||||
|
✓ Plugin 'email_notifier' started
|
||||||
|
|
||||||
|
futriiS:~> plugin stop email_notifier
|
||||||
|
✓ Plugin 'email_notifier' stopped
|
||||||
|
|
||||||
|
# Выгрузка плагина
|
||||||
|
futriiS:~> plugin unload email_notifier
|
||||||
|
✓ Plugin 'email_notifier' unloaded
|
||||||
|
```
|
||||||
|
|
||||||
|
**Пример плагина валидации документов**
|
||||||
|
|
||||||
|
**В директорию `plugins` добавляем файл `validation.lua`, следдующего содержания:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
-- Метаданные плагина
|
||||||
|
version = "1.0.0"
|
||||||
|
author = "admin"
|
||||||
|
description = "Document validation rules for employees collection"
|
||||||
|
|
||||||
|
-- Функция инициализации
|
||||||
|
function on_load()
|
||||||
|
plugin_log("info", "Validation plugin loaded")
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Функция запуска
|
||||||
|
function on_start()
|
||||||
|
plugin_log("info", "Validation plugin started")
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Функция остановки
|
||||||
|
function on_stop()
|
||||||
|
plugin_log("info", "Validation plugin stopped")
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Функция выгрузки
|
||||||
|
function on_unload()
|
||||||
|
plugin_log("info", "Validation plugin unloaded")
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Обработчик событий
|
||||||
|
function on_event(event)
|
||||||
|
plugin_log("debug", "Received event: " .. event.type)
|
||||||
|
|
||||||
|
if event.type == "BEFORE_INSERT" then
|
||||||
|
return validate_document(event.data)
|
||||||
|
end
|
||||||
|
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Функция валидации документа
|
||||||
|
function validate_document(doc)
|
||||||
|
-- Проверка обязательных полей
|
||||||
|
if doc.name == nil or doc.name == "" then
|
||||||
|
plugin_log("error", "Document missing required field: name")
|
||||||
|
return false, "Field 'name' is required"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Проверка возраста
|
||||||
|
if doc.age ~= nil then
|
||||||
|
if doc.age < 18 then
|
||||||
|
plugin_log("warn", "Age validation failed: " .. doc.age)
|
||||||
|
return false, "Employee must be at least 18 years old"
|
||||||
|
end
|
||||||
|
if doc.age > 65 then
|
||||||
|
plugin_log("warn", "Age validation failed: " .. doc.age)
|
||||||
|
return false, "Employee cannot be older than 65 years"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Проверка email
|
||||||
|
if doc.email ~= nil then
|
||||||
|
if string.match(doc.email, "^[%w._-]+@[%w._-]+%.[%w]+$") == nil then
|
||||||
|
plugin_log("error", "Invalid email format: " .. doc.email)
|
||||||
|
return false, "Invalid email format"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Проверка зарплаты
|
||||||
|
if doc.salary ~= nil then
|
||||||
|
if doc.salary < 30000 then
|
||||||
|
plugin_log("warn", "Salary below minimum: " .. doc.salary)
|
||||||
|
return false, "Salary must be at least 30000"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
plugin_log("info", "Document validation passed for: " .. doc.name)
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Пользовательская функция для массовой валидации
|
||||||
|
function validate_collection(collection_name)
|
||||||
|
local coll = get_collection("company", collection_name)
|
||||||
|
if coll == nil then
|
||||||
|
plugin_log("error", "Collection not found: " .. collection_name)
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Здесь можно реализовать массовую валидацию
|
||||||
|
plugin_log("info", "Validating collection: " .. collection_name)
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
**ИСпользование плагина валидации документов**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Создание базы данных и коллекции
|
||||||
|
futriiS:~> create database company
|
||||||
|
✓ Database 'company' created
|
||||||
|
|
||||||
|
futriiS:~> use company
|
||||||
|
✓ Switched to database 'company'
|
||||||
|
|
||||||
|
futriiS:~> create collection employees
|
||||||
|
✓ Collection 'employees' created in database 'company'
|
||||||
|
|
||||||
|
# Загрузка и запуск плагина валидации
|
||||||
|
futriiS:~> plugin load validation ./plugins/validation.lua
|
||||||
|
✓ Plugin 'validation' loaded successfully
|
||||||
|
Version: 1.0.0
|
||||||
|
Author: admin
|
||||||
|
Description: Document validation rules for employees collection
|
||||||
|
|
||||||
|
futriiS:~> plugin start validation
|
||||||
|
✓ Plugin 'validation' started
|
||||||
|
|
||||||
|
# Вставка валидного документа
|
||||||
|
futriiS:~> insert employees name=John Doe,age=25,email=john@company.com,salary=45000
|
||||||
|
✓ Document inserted with ID: emp_001
|
||||||
|
|
||||||
|
# Вставка невалидного документа (возраст < 18)
|
||||||
|
futriiS:~> insert employees name=Jane Smith,age=16,email=jane@company.com,salary=20000
|
||||||
|
Error: Employee must be at least 18 years old
|
||||||
|
|
||||||
|
# Вставка невалидного документа (некорректный email)
|
||||||
|
futriiS:~> insert employees name=Bob Johnson,age=30,email=invalid-email,salary=50000
|
||||||
|
Error: Invalid email format
|
||||||
|
|
||||||
|
# Выполнение пользовательской функции плагина
|
||||||
|
futriiS:~> plugin call validation validate_collection employees
|
||||||
|
✓ Function returned: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Управление плагинами через HTTP API**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Получение списка плагинов через API
|
||||||
|
curl -X GET "http://localhost:8080/api/plugin/list" \
|
||||||
|
-H "X-Session-ID: abc123"
|
||||||
|
|
||||||
|
# Загрузка плагина через API
|
||||||
|
curl -X POST http://localhost:8080/api/plugin/load \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Session-ID: abc123" \
|
||||||
|
-d '{"name":"validation","path":"./plugins/validation.lua"}'
|
||||||
|
|
||||||
|
# Запуск плагина через API
|
||||||
|
curl -X POST "http://localhost:8080/api/plugin/start/validation" \
|
||||||
|
-H "X-Session-ID: abc123"
|
||||||
|
|
||||||
|
# Выполнение функции плагина через API
|
||||||
|
curl -X POST http://localhost:8080/api/plugin/call \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Session-ID: abc123" \
|
||||||
|
-d '{"plugin":"validation","function":"validate_collection","args":["employees"]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
||||||
|
|
||||||
|
## ACL
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Вход в систему
|
||||||
|
futriiS:~> acl login admin admin
|
||||||
|
✓ Logged in as 'admin' with role 'admin'
|
||||||
|
|
||||||
|
# Выход из системы
|
||||||
|
futriiS:~> acl logout
|
||||||
|
✓ Logged out
|
||||||
|
|
||||||
|
# Назначение прав доступа (после входа как admin)
|
||||||
|
futriiS:~> acl login admin admin
|
||||||
|
✓ Logged in as 'admin' with role 'admin'
|
||||||
|
|
||||||
|
futriiS:~> use company
|
||||||
|
✓ Switched to database 'company'
|
||||||
|
|
||||||
|
# Назначение прав на чтение
|
||||||
|
futriiS:~> acl grant employees reader r
|
||||||
|
✓ Permissions 'r' granted to role 'reader' on collection 'employees'
|
||||||
|
|
||||||
|
# Назначение прав на чтение и запись
|
||||||
|
futriiS:~> acl grant employees editor rw
|
||||||
|
✓ Permissions 'rw' granted to role 'editor' on collection 'employees'
|
||||||
|
|
||||||
|
# Назначение полных прав (администратор коллекции)
|
||||||
|
futriiS:~> acl grant employees admin rwda
|
||||||
|
✓ Permissions 'rwda' granted to role 'admin' on collection 'employees'
|
||||||
|
|
||||||
|
# Пример использования разных прав:
|
||||||
|
# r - read (чтение)
|
||||||
|
# w - write (запись)
|
||||||
|
# d - delete (удаление)
|
||||||
|
# a - admin (администратор)
|
||||||
|
|
||||||
|
```
|
||||||
|
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
||||||
|
|
||||||
|
## HTTP API
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Аутентификация
|
||||||
|
curl -X POST http://localhost:8080/api/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"admin","password":"admin"}'
|
||||||
|
# Response: {"success":true,"data":{"session_id":"abc123"}}
|
||||||
|
|
||||||
|
# Вставка документа
|
||||||
|
curl -X POST http://localhost:8080/api/db/company/employees \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Session-ID: abc123" \
|
||||||
|
-d '{"name":"API User","position":"Integrator","age":28}'
|
||||||
|
# Response: {"success":true,"data":{"status":"inserted"}}
|
||||||
|
|
||||||
|
# Получение документа по ID
|
||||||
|
curl -X GET "http://localhost:8080/api/db/company/employees/550e8400-e29b-41d4-a716-446655440000" \
|
||||||
|
-H "X-Session-ID: abc123"
|
||||||
|
# Response: {"success":true,"data":{"_id":"550e8400-...","fields":{...}}}
|
||||||
|
|
||||||
|
# Получение всех документов с пагинацией
|
||||||
|
curl -X GET "http://localhost:8080/api/db/company/employees?limit=10&offset=0" \
|
||||||
|
-H "X-Session-ID: abc123"
|
||||||
|
|
||||||
|
# Поиск по индексу
|
||||||
|
curl -X GET "http://localhost:8080/api/db/company/employees?index=name_idx&value=John%20Doe" \
|
||||||
|
-H "X-Session-ID: abc123"
|
||||||
|
|
||||||
|
# Обновление документа
|
||||||
|
curl -X PUT http://localhost:8080/api/db/company/employees/550e8400-e29b-41d4-a716-446655440000 \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Session-ID: abc123" \
|
||||||
|
-d '{"age":31,"position":"Senior Developer"}'
|
||||||
|
# Response: {"success":true,"data":{"status":"updated"}}
|
||||||
|
|
||||||
|
# Удаление документа
|
||||||
|
curl -X DELETE "http://localhost:8080/api/db/company/employees/550e8400-e29b-41d4-a716-446655440000" \
|
||||||
|
-H "X-Session-ID: abc123"
|
||||||
|
# Response: {"success":true,"data":{"status":"deleted"}}
|
||||||
|
|
||||||
|
# Создание индекса через API
|
||||||
|
curl -X POST http://localhost:8080/api/index/company/employees/create \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Session-ID: abc123" \
|
||||||
|
-d '{"name":"email_idx","fields":["email"],"unique":true}'
|
||||||
|
|
||||||
|
# Просмотр индексов
|
||||||
|
curl -X GET "http://localhost:8080/api/index/company/employees/list" \
|
||||||
|
-H "X-Session-ID: abc123"
|
||||||
|
|
||||||
|
# Статус кластера через API
|
||||||
|
curl -X GET "http://localhost:8080/api/cluster/status" \
|
||||||
|
-H "X-Session-ID: abc123"
|
||||||
|
|
||||||
|
# Создание пользователя через API
|
||||||
|
curl -X POST http://localhost:8080/api/acl/user/newuser \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Session-ID: abc123" \
|
||||||
|
-d '{"password":"secret","roles":["reader"]}'
|
||||||
|
|
||||||
|
# Назначение прав через API
|
||||||
|
curl -X POST "http://localhost:8080/api/acl/grant/reader/rw" \
|
||||||
|
-H "X-Session-ID: abc123"
|
||||||
|
|
||||||
|
# Создание триггера через API
|
||||||
|
curl -X POST http://localhost:8080/api/trigger/company/employees/create \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Session-ID: abc123" \
|
||||||
|
-d '{"name":"audit","event":"AFTER_INSERT","action":"log"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
||||||
|
|
||||||
|
|
||||||
|
## Триггеры
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Создание триггера для логирования вставок
|
||||||
|
futriiS:~> create trigger employees audit_log AFTER_INSERT log
|
||||||
|
✓ Trigger 'audit_log' created on collection 'employees' for event AFTER_INSERT
|
||||||
|
|
||||||
|
# Создание триггера с автоматической установкой timestamp
|
||||||
|
futriiS:~> create trigger employees set_timestamp BEFORE_INSERT modify --set updated_at $$NOW
|
||||||
|
✓ Trigger 'set_timestamp' created on collection 'employees' for event BEFORE_INSERT
|
||||||
|
|
||||||
|
# Создание триггера с условием (запрет удаления активных пользователей)
|
||||||
|
futriiS:~> create trigger employees protect_active BEFORE_DELETE abort --condition status eq active
|
||||||
|
✓ Trigger 'protect_active' created on collection 'employees' for event BEFORE_DELETE
|
||||||
|
|
||||||
|
# Создание триггера с обновлением аудита
|
||||||
|
futriiS:~> create trigger employees audit BEFORE_UPDATE modify --set modified_by $$USER --set modified_at $$NOW
|
||||||
|
✓ Trigger 'audit' created on collection 'employees' for event BEFORE_UPDATE
|
||||||
|
|
||||||
|
# Просмотр всех триггеров коллекции
|
||||||
|
futriiS:~> show triggers employees
|
||||||
|
=== Triggers on collection 'employees': ===
|
||||||
|
audit_log (AFTER_INSERT) - enabled [log]
|
||||||
|
Operations:
|
||||||
|
- log: =
|
||||||
|
set_timestamp (BEFORE_INSERT) - enabled [modify]
|
||||||
|
Operations:
|
||||||
|
- set: updated_at = $$NOW
|
||||||
|
protect_active (BEFORE_DELETE) - enabled [abort]
|
||||||
|
Condition: status eq active
|
||||||
|
audit (BEFORE_UPDATE) - enabled [modify]
|
||||||
|
Operations:
|
||||||
|
- set: modified_by = $$USER
|
||||||
|
- set: modified_at = $$NOW
|
||||||
|
|
||||||
|
# Включение/отключение триггера
|
||||||
|
futriiS:~> disable trigger employees BEFORE_INSERT set_timestamp
|
||||||
|
✓ Trigger 'set_timestamp' disabled
|
||||||
|
|
||||||
|
futriiS:~> enable trigger employees BEFORE_INSERT set_timestamp
|
||||||
|
✓ Trigger 'set_timestamp' enabled
|
||||||
|
|
||||||
|
# Просмотр лога выполнения триггеров
|
||||||
|
futriiS:~> trigger log
|
||||||
|
=== Trigger Execution Log ===
|
||||||
|
[1] 2026-04-12 10:30:45 - Trigger: audit_log, Event: AFTER_INSERT, Collection: employees, Document: 550e8400-...
|
||||||
|
[2] 2026-04-12 10:31:20 - Trigger: protect_active, Event: BEFORE_DELETE, Collection: employees, Document: 550e8400-...
|
||||||
|
|
||||||
|
# Удаление триггера
|
||||||
|
futriiS:~> drop trigger employees BEFORE_INSERT set_timestamp
|
||||||
|
✓ Trigger 'set_timestamp' dropped from collection 'employees'
|
||||||
|
```
|
||||||
|
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
||||||
|
|
||||||
|
|
||||||
|
## Сжатие данных
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Просмотр конфигурации сжатия
|
||||||
|
futriiS:~> compression config
|
||||||
|
=== Compression Configuration ===
|
||||||
|
Enabled: true
|
||||||
|
Algorithm: snappy
|
||||||
|
Level: 3
|
||||||
|
Min Size: 1 KB
|
||||||
|
|
||||||
|
Available Algorithms:
|
||||||
|
snappy - Fast compression/decompression, good balance (default)
|
||||||
|
lz4 - Extremely fast, lower compression ratio
|
||||||
|
zstd - High compression ratio, slower
|
||||||
|
|
||||||
|
# Просмотр статистики сжатия
|
||||||
|
futriiS:~> compression stats
|
||||||
|
=== Compression Statistics ===
|
||||||
|
Total Documents: 1250
|
||||||
|
Compressed Documents: 890
|
||||||
|
Compression Rate: 71.20%
|
||||||
|
Size Reduction: 45.30%
|
||||||
|
Original Size: 15.2 MB
|
||||||
|
Compressed Size: 8.3 MB
|
||||||
|
Algorithm: snappy
|
||||||
|
Compression Level: 3
|
||||||
|
Min Size Threshold: 1 KB
|
||||||
|
|
||||||
|
# Ручное сжатие коллекции
|
||||||
|
futriiS:~> compress collection employees
|
||||||
|
Compressing collection 'employees'...
|
||||||
|
✓ Compressed 45 documents in collection 'employees'
|
||||||
|
|
||||||
|
# Просмотр информации о сжатии документа
|
||||||
|
futriiS:~> doc compression employees 550e8400-e29b-41d4-a716-446655440000
|
||||||
|
=== Compression Info for Document: 550e8400-e29b-41d4-a716-446655440000 ===
|
||||||
|
Compressed: true
|
||||||
|
Ratio: 35.20%
|
||||||
|
Original Size: 2.5 KB
|
||||||
|
Current Size: 1.6 KB
|
||||||
|
```
|
||||||
|
|
||||||
|
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
||||||
|
|
||||||
|
## Дорожная карта
|
||||||
|
|
||||||
|
- [x] Реализовать поддержку хранимых процедур
|
||||||
|
- [x] Реализовать поддержку триггеров (обратных вызовов)
|
||||||
|
- [x] Реализовать поддержку многопоточности
|
||||||
|
- [x] Реализовать неблокирующие чтение/запись
|
||||||
|
- [x] Реализовать неблокирующие транзакции
|
||||||
|
- [x] Реализовать constraints (Ограничения)
|
||||||
|
- [x] Реализовать мульти-мастер асинхронную репликацию через файл конфигурации
|
||||||
|
- [x] Реализовать логирование
|
||||||
|
- [x] Реализовать поддержку синхронной мастер-мастер репликации
|
||||||
|
- [x] Реализовать базовую поддержку протокола Raft
|
||||||
|
- [x] Реализовать базовую поддержку WebUI
|
||||||
|
- [x] Реализовать поддержку первичных индексов
|
||||||
|
- [x] Реализовать поддержку протокола MessagePack
|
||||||
|
- [x] Добавить механизм сторонних модулей на языке lua, расширяющих базовый функционал сервера
|
||||||
|
- [x] Реализовать поддержку HTTP-restfull API
|
||||||
|
- [x] Реализовать сжатия данных в субд
|
||||||
|
- [x] Реализовать импорт и экспорт дампа субд в формате "MessagePack"
|
||||||
|
- [x] Исправить ошибки записи журнала логов (в журнал лога кроме текущего времени добавить текущий год)
|
||||||
|
- [ ] Реализовать полноценный графический веб-интерфейс для управления кластером
|
||||||
|
- [ ] Реализовать автоматический шардинг с консистентным хэшированием
|
||||||
|
- [ ] Реализовать полноценную поддержку алгоритма Raft (с автоматическим перевывыбором лидера, с доменом отказа)
|
||||||
|
- [ ] Реализовать полноценную поддержку кластеризации (с обработкой состояний "split-brain", автоматической ребалансировкой кластера)
|
||||||
|
- [ ] Интегрировать интеллектуального помощник FutBot в веб-интерфейс
|
||||||
|
- [ ] Интеграцию с мониторинговыми системами (Prometheus, Grafana)
|
||||||
|
- [ ] Реализовать полноценную систему бекапирования с возможностью определения корректности созданного бекапа и кроссдацентровых решений по автоматическому копироваю бекапа в другой дацентр
|
||||||
|
- [ ] Реализовать полноценную систему авторизации на основе RBAC-модели
|
||||||
|
- [ ] Реализовать коннекторы к современным языкам программирования (C, C++, Java, Python, Go)
|
||||||
|
- [ ] Реализовать утилиту тестирования сервера на количество запросов на чтение/запись
|
||||||
|
|
||||||
|
См. [Открытые проблемы](https://source.futriix.ru/gvsafronov/futriixw/issues) полный список предлагаемых функций (и известных проблем).
|
||||||
|
|
||||||
|
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
||||||
|
|
||||||
|
|
||||||
|
## Контакты
|
||||||
|
|
||||||
|
Григорий Сафронов - [E-mail](gvsafronov@yandex.ru)
|
||||||
|
|
||||||
|
<p align="right">(<a href="#readme-top">К началу</a>)</p>
|
||||||
|
|
||||||
|
|
||||||
77
build.sh
Executable file
77
build.sh
Executable file
@@ -0,0 +1,77 @@
|
|||||||
|
# Copyright 2026 Safronov Grigorii
|
||||||
|
#
|
||||||
|
# Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
#
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
# https://opensource.org/licenses/CDDL-1.0
|
||||||
|
#
|
||||||
|
|
||||||
|
# Универсальный скрипт сборки futriis для Linux и Illumos
|
||||||
|
|
||||||
|
#!/usr/bin/env/sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "🔨 Building futriis database..."
|
||||||
|
|
||||||
|
# Определение ОС
|
||||||
|
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||||
|
|
||||||
|
# Функция для вывода ошибок красным цветом
|
||||||
|
error_msg() {
|
||||||
|
echo -e "\033[0;31m❌ $1\033[0m"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Функция для вывода успешных сообщений зелёным цветом
|
||||||
|
success_msg() {
|
||||||
|
echo -e "\033[0;32m✅ $1\033[0m"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Функция для вывода информационных сообщений
|
||||||
|
info_msg() {
|
||||||
|
echo "📋 $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$OS" in
|
||||||
|
linux)
|
||||||
|
echo "Building for Linux"
|
||||||
|
GOOS=linux GOARCH=amd64 go build -o bin/futriis-linux ./cmd/futriis
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
success_msg "Build successful for Linux"
|
||||||
|
cp bin/futriis-linux ./futriis-linux
|
||||||
|
info_msg "Binary copied to: ./futriis-linux"
|
||||||
|
else
|
||||||
|
error_msg "Build failed for Linux"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
sunos|illumos)
|
||||||
|
echo "Building for Illumos"
|
||||||
|
export GOOS=illumos
|
||||||
|
export GOARCH=amd64
|
||||||
|
export CGO_ENABLED=1
|
||||||
|
|
||||||
|
go build -tags=illumos -o bin/futriis-illumos ./cmd/futriis
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
success_msg "Build successful for Illumos"
|
||||||
|
cp bin/futriis-illumos ./futriis-illumos
|
||||||
|
info_msg "Binary copied to: ./futriis-illumos"
|
||||||
|
else
|
||||||
|
error_msg "Build failed for Illumos"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
error_msg "Unsupported OS: $OS"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
success_msg "Build complete. Binaries in bin/ and root directory"
|
||||||
185
cmd/futriis/main.go
Normal file
185
cmd/futriis/main.go
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: cmd/futriis/main.go
|
||||||
|
// Назначение: Точка входа в приложение СУБД futriis. Инициализирует все компоненты:
|
||||||
|
// конфигурацию, логгер, хранилище, Raft координатор, ACL менеджер, HTTP API, WebUI и REPL.
|
||||||
|
// Управляет жизненным циклом приложения.
|
||||||
|
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"futriis/internal/acl"
|
||||||
|
"futriis/internal/api"
|
||||||
|
"futriis/internal/cluster"
|
||||||
|
"futriis/internal/config"
|
||||||
|
"futriis/internal/log"
|
||||||
|
"futriis/internal/repl"
|
||||||
|
"futriis/internal/storage"
|
||||||
|
"futriis/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
utils.SetColorEnabled(true)
|
||||||
|
|
||||||
|
cfg, err := config.LoadConfig("config.toml")
|
||||||
|
if err != nil {
|
||||||
|
utils.PrintError("Failed to load config: " + err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger, err := log.NewLogger(cfg.Log.LogFile, cfg.Log.LogLevel)
|
||||||
|
if err != nil {
|
||||||
|
utils.PrintError("Failed to initialize logger: " + err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer logger.Close()
|
||||||
|
logger.Info("futriis database starting...")
|
||||||
|
|
||||||
|
store := storage.NewStorage(cfg.Storage.PageSizeMB, logger)
|
||||||
|
|
||||||
|
// Инициализация ACL менеджера
|
||||||
|
aclManager := acl.NewACLManager()
|
||||||
|
logger.Info("ACL manager initialized")
|
||||||
|
|
||||||
|
raftCoordinator, err := cluster.NewRaftCoordinator(cfg, logger)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to start Raft coordinator: " + err.Error())
|
||||||
|
utils.PrintError("Failed to start Raft coordinator: " + err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Cluster.Bootstrap || len(cfg.Cluster.Nodes) <= 1 {
|
||||||
|
maxRetries := 10
|
||||||
|
for i := 0; i < maxRetries; i++ {
|
||||||
|
if raftCoordinator.IsLeader() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
node := cluster.NewNode(cfg.Cluster.NodeIP, cfg.Cluster.NodePort, store, logger)
|
||||||
|
|
||||||
|
maxRetries := 5
|
||||||
|
var registerErr error
|
||||||
|
for i := 0; i < maxRetries; i++ {
|
||||||
|
registerErr = raftCoordinator.RegisterNode(node)
|
||||||
|
if registerErr == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if i < maxRetries-1 {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if registerErr != nil {
|
||||||
|
logger.Error("Failed to register node: " + registerErr.Error())
|
||||||
|
utils.PrintError("Failed to register node: " + registerErr.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запуск HTTP API сервера
|
||||||
|
httpPort := 8080
|
||||||
|
httpServer := api.NewHTTPServer(httpPort, store, raftCoordinator, aclManager, logger)
|
||||||
|
go func() {
|
||||||
|
if err := httpServer.Start(); err != nil {
|
||||||
|
logger.Error("HTTP server error: " + err.Error())
|
||||||
|
utils.PrintError("HTTP server error: " + err.Error())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
logger.Info(fmt.Sprintf("HTTP API server started on port %d", httpPort))
|
||||||
|
|
||||||
|
// Запуск WebUI сервера (если включён в конфигурации)
|
||||||
|
webUIPort := cfg.WebUI.Port
|
||||||
|
if webUIPort == 0 {
|
||||||
|
webUIPort = 8081
|
||||||
|
}
|
||||||
|
webUI := api.NewWebUIServer(webUIPort, cfg.WebUI.Enabled, store, raftCoordinator, aclManager, logger)
|
||||||
|
go func() {
|
||||||
|
if err := webUI.Start(); err != nil && cfg.WebUI.Enabled {
|
||||||
|
logger.Error("Web UI error: " + err.Error())
|
||||||
|
utils.PrintError("Web UI error: " + err.Error())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if cfg.WebUI.Enabled {
|
||||||
|
logger.Info(fmt.Sprintf("Web UI started on port %d", webUIPort))
|
||||||
|
utils.PrintInfo(fmt.Sprintf("Web UI available at http://localhost:%d", webUIPort))
|
||||||
|
}
|
||||||
|
|
||||||
|
displayBanner(cfg.Cluster.Name, cfg.WebUI.Enabled, webUIPort)
|
||||||
|
|
||||||
|
replInstance := repl.NewRepl(store, raftCoordinator, logger, cfg)
|
||||||
|
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
<-sigChan
|
||||||
|
utils.Println("\nReceived shutdown signal")
|
||||||
|
logger.Info("Received shutdown signal")
|
||||||
|
httpServer.Stop()
|
||||||
|
webUI.Stop()
|
||||||
|
raftCoordinator.Stop()
|
||||||
|
node.Stop()
|
||||||
|
replInstance.Close()
|
||||||
|
utils.DisableColorMode()
|
||||||
|
os.Exit(0)
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := replInstance.Run(); err != nil {
|
||||||
|
logger.Error("REPL error: " + err.Error())
|
||||||
|
utils.PrintError("REPL error: " + err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayBanner(clusterName string, webUIEnabled bool, webUIPort int) {
|
||||||
|
utils.Println("")
|
||||||
|
bannerLines := []string{
|
||||||
|
" futriix 3i²(by 02.04.2026) ",
|
||||||
|
" Distributed Document-Store in-memory database with support lua plugins ",
|
||||||
|
" Cluster status: enable (Raft consensus)",
|
||||||
|
" Cluster name: " + clusterName,
|
||||||
|
" HTTP API: http://localhost:8080/api/",
|
||||||
|
}
|
||||||
|
|
||||||
|
if webUIEnabled {
|
||||||
|
bannerLines = append(bannerLines, fmt.Sprintf(" Web UI: http://localhost:%d/", webUIPort))
|
||||||
|
}
|
||||||
|
|
||||||
|
bannerLines = append(bannerLines, []string{
|
||||||
|
" Type 'quit' or 'exit' to quit",
|
||||||
|
" Type 'status' to see cluster status",
|
||||||
|
" Type 'acl login <user> <pass>' to authenticate",
|
||||||
|
}...)
|
||||||
|
|
||||||
|
for _, line := range bannerLines {
|
||||||
|
utils.PrintInfo(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вспомогательная функция для форматирования JSON (если понадобится)
|
||||||
|
func printJSON(data interface{}) {
|
||||||
|
jsonData, err := json.MarshalIndent(data, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
utils.PrintError("Failed to marshal JSON: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(string(jsonData))
|
||||||
|
}
|
||||||
59
config.toml
Normal file
59
config.toml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
#
|
||||||
|
# Copyright 2026 Grigorii Safronov
|
||||||
|
#
|
||||||
|
# Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
#
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
# https://opensource.org/licenses/CDDL-1.0
|
||||||
|
#
|
||||||
|
|
||||||
|
# Конфигурационный файл СУБД futriis
|
||||||
|
|
||||||
|
[cluster]
|
||||||
|
name = "test_cluster"
|
||||||
|
node_ip = "192.168.0.103" # Укажите ваш реальный IP
|
||||||
|
node_port = 9876
|
||||||
|
raft_port = 9878
|
||||||
|
raft_data_dir = "raft_data"
|
||||||
|
bootstrap = true # Флаг для первого узла в кластере
|
||||||
|
nodes = [ # Список узлов кластера
|
||||||
|
"192.168.0.103:9878", # Текущий узел
|
||||||
|
# "192.168.0.104:9878", # Другие узлы кластера
|
||||||
|
# "192.168.0.105:9878",
|
||||||
|
]
|
||||||
|
|
||||||
|
[storage]
|
||||||
|
page_size_mb = 64
|
||||||
|
max_collections = 100
|
||||||
|
max_documents_per_collection = 1000000
|
||||||
|
|
||||||
|
[repl]
|
||||||
|
prompt_color = "#00bfff"
|
||||||
|
history_size = 1000
|
||||||
|
|
||||||
|
[log]
|
||||||
|
log_file = "futriis.log"
|
||||||
|
log_level = "debug"
|
||||||
|
|
||||||
|
[replication]
|
||||||
|
enabled = false
|
||||||
|
master_master = false
|
||||||
|
sync_replication = false
|
||||||
|
replication_timeout_ms = 5000
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
enabled = false
|
||||||
|
script_dir = "plugins"
|
||||||
|
allow_list = ["print", "math", "string"]
|
||||||
|
|
||||||
|
[compression]
|
||||||
|
enabled = true # Включить сжатие данных
|
||||||
|
algorithm = "snappy" # Алгоритм: snappy, lz4, zstd
|
||||||
|
level = 3 # Уровень сжатия (1-9, зависит от алгоритма)
|
||||||
|
min_size = 1024 # Минимальный размер документа для сжатия (байт)
|
||||||
|
|
||||||
|
[webui]
|
||||||
|
enabled = true # Включить веб-интерфейс
|
||||||
|
port = 9080 # Порт для веб-интерфейса
|
||||||
|
theme = "dark" # Тема оформления (dark, light)
|
||||||
318
futriis.log
Normal file
318
futriis.log
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
[2026-04-05 19:37:43.525] INFO: futriis database starting...
|
||||||
|
[2026-04-05 19:37:43.525] INFO: Creating Raft coordinator at 192.168.0.103:9878
|
||||||
|
[2026-04-05 19:37:43.525] INFO: Running in single-node mode
|
||||||
|
[2026-04-05 19:37:43.525] INFO: Raft data directory: raft_data
|
||||||
|
[2026-04-05 19:37:44.083] INFO: Existing Raft state found, joining cluster...
|
||||||
|
[2026-04-05 19:37:44.083] INFO: Raft coordinator started at 192.168.0.103:9878, IsLeader: false
|
||||||
|
[2026-04-05 19:37:44.083] INFO: Single-node mode: registering node without Raft consensus
|
||||||
|
[2026-04-05 19:37:44.083] INFO: Node registered locally in single-node mode: ca6b9fed-ee2e-43dc-94c7-4b3e51539980
|
||||||
|
[2026-04-05 19:37:44.084] INFO: Node ca6b9fed-ee2e-43dc-94c7-4b3e51539980 listening on 192.168.0.103:9876
|
||||||
|
[2026-04-05 19:48:45.939] INFO: futriis database starting...
|
||||||
|
[2026-04-05 19:48:45.939] INFO: Creating Raft coordinator at 192.168.0.103:9878
|
||||||
|
[2026-04-05 19:48:45.940] INFO: Running in single-node mode (warnings suppressed)
|
||||||
|
[2026-04-05 19:48:45.940] INFO: Raft data directory: raft_data
|
||||||
|
[2026-04-05 19:48:46.492] INFO: Existing Raft state found, joining cluster...
|
||||||
|
[2026-04-05 19:48:46.492] INFO: Raft coordinator started at 192.168.0.103:9878, IsLeader: false
|
||||||
|
[2026-04-05 19:48:46.492] INFO: Single-node mode: registering node without Raft consensus
|
||||||
|
[2026-04-05 19:48:46.492] INFO: Node registered locally in single-node mode: 1a7eb73b-88bc-442c-9f01-d8768f36c891
|
||||||
|
[2026-04-05 19:48:46.492] INFO: Node 1a7eb73b-88bc-442c-9f01-d8768f36c891 listening on 192.168.0.103:9876
|
||||||
|
[2026-04-05 19:58:28.380] INFO: futriis database starting...
|
||||||
|
[2026-04-05 19:58:28.380] DEBUG: Creating Raft coordinator at 192.168.0.103:9878
|
||||||
|
[2026-04-05 19:58:28.380] DEBUG: Running in single-node mode (warnings suppressed)
|
||||||
|
[2026-04-05 19:58:28.380] DEBUG: Raft data directory: raft_data
|
||||||
|
[2026-04-05 19:58:28.940] DEBUG: Existing Raft state found, joining cluster...
|
||||||
|
[2026-04-05 19:58:28.940] DEBUG: Raft coordinator started at 192.168.0.103:9878, IsLeader: false
|
||||||
|
[2026-04-05 19:58:28.940] DEBUG: Single-node mode: registering node without Raft consensus
|
||||||
|
[2026-04-05 19:58:28.940] DEBUG: Node registered locally in single-node mode: 7d72a163-f3de-46a2-86f9-edf9d86fbdd5
|
||||||
|
[2026-04-05 19:58:28.940] INFO: Node 7d72a163-f3de-46a2-86f9-edf9d86fbdd5 listening on 192.168.0.103:9876
|
||||||
|
[2026-04-06 20:50:15.736] INFO: futriis database starting...
|
||||||
|
[2026-04-06 20:50:15.736] INFO: ACL manager initialized
|
||||||
|
[2026-04-06 20:50:15.736] DEBUG: Creating Raft coordinator at 192.168.0.103:9878
|
||||||
|
[2026-04-06 20:50:15.736] DEBUG: Running in single-node mode (warnings suppressed)
|
||||||
|
[2026-04-06 20:50:15.736] DEBUG: Raft data directory: raft_data
|
||||||
|
[2026-04-06 20:50:16.291] DEBUG: Existing Raft state found, joining cluster...
|
||||||
|
[2026-04-06 20:50:16.291] DEBUG: Raft coordinator started at 192.168.0.103:9878, IsLeader: false
|
||||||
|
[2026-04-06 20:50:16.291] DEBUG: Single-node mode: registering node without Raft consensus
|
||||||
|
[2026-04-06 20:50:16.291] DEBUG: Node registered locally in single-node mode: 9eecdf51-7980-4c61-988e-20e6e1fe593d
|
||||||
|
[2026-04-06 20:50:16.291] INFO: HTTP API server started on port ᾐ
|
||||||
|
[2026-04-06 20:50:16.291] INFO: Node 9eecdf51-7980-4c61-988e-20e6e1fe593d listening on 192.168.0.103:9876
|
||||||
|
[2026-04-06 20:50:16.292] INFO: Starting HTTP API server on port 8080
|
||||||
|
[2026-04-07 21:37:38.263] INFO: futriis database starting...
|
||||||
|
[2026-04-07 21:37:38.263] INFO: ACL manager initialized
|
||||||
|
[2026-04-07 21:37:38.263] DEBUG: Creating Raft coordinator at 192.168.0.103:9878
|
||||||
|
[2026-04-07 21:37:38.263] DEBUG: Running in single-node mode (warnings suppressed)
|
||||||
|
[2026-04-07 21:37:38.263] DEBUG: Raft data directory: raft_data
|
||||||
|
[2026-04-07 21:37:38.926] DEBUG: Existing Raft state found, joining cluster...
|
||||||
|
[2026-04-07 21:37:38.926] DEBUG: Raft coordinator started at 192.168.0.103:9878, IsLeader: false
|
||||||
|
[2026-04-07 21:37:38.926] DEBUG: Single-node mode: registering node without Raft consensus
|
||||||
|
[2026-04-07 21:37:38.926] DEBUG: Node registered locally in single-node mode: e3f9e952-81e0-46a0-98d4-73a360ac7341
|
||||||
|
[2026-04-07 21:37:38.927] INFO: HTTP API server started on port 8080
|
||||||
|
[2026-04-07 21:37:38.927] INFO: Node e3f9e952-81e0-46a0-98d4-73a360ac7341 listening on 192.168.0.103:9876
|
||||||
|
[2026-04-07 21:37:38.927] INFO: Starting HTTP API server on port 8080
|
||||||
|
[2026-04-07 21:47:04.604] INFO: futriis database starting...
|
||||||
|
[2026-04-07 21:47:04.604] INFO: ACL manager initialized
|
||||||
|
[2026-04-07 21:47:04.605] DEBUG: Creating Raft coordinator at 192.168.0.103:9878
|
||||||
|
[2026-04-07 21:47:04.605] DEBUG: Running in single-node mode (warnings suppressed)
|
||||||
|
[2026-04-07 21:47:04.605] DEBUG: Raft data directory: raft_data
|
||||||
|
[2026-04-07 21:47:05.143] DEBUG: Existing Raft state found, joining cluster...
|
||||||
|
[2026-04-07 21:47:05.143] DEBUG: Raft coordinator started at 192.168.0.103:9878, IsLeader: false
|
||||||
|
[2026-04-07 21:47:05.143] DEBUG: Single-node mode: registering node without Raft consensus
|
||||||
|
[2026-04-07 21:47:05.143] DEBUG: Node registered locally in single-node mode: 7fe5d67f-e500-4bd0-8f6b-39e0b5cdb3d6
|
||||||
|
[2026-04-07 21:47:05.144] INFO: HTTP API server started on port 8080
|
||||||
|
[2026-04-07 21:47:05.144] INFO: Node 7fe5d67f-e500-4bd0-8f6b-39e0b5cdb3d6 listening on 192.168.0.103:9876
|
||||||
|
[2026-04-07 21:47:05.144] INFO: Starting HTTP API server on port 8080
|
||||||
|
[2026-04-07 21:51:08.358] INFO: futriis database starting...
|
||||||
|
[2026-04-07 21:51:08.358] INFO: ACL manager initialized
|
||||||
|
[2026-04-07 21:51:08.358] DEBUG: Creating Raft coordinator at 192.168.0.103:9878
|
||||||
|
[2026-04-07 21:51:08.358] DEBUG: Running in single-node mode (warnings suppressed)
|
||||||
|
[2026-04-07 21:51:08.358] DEBUG: Raft data directory: raft_data
|
||||||
|
[2026-04-07 21:51:08.884] DEBUG: Existing Raft state found, joining cluster...
|
||||||
|
[2026-04-07 21:51:08.884] DEBUG: Raft coordinator started at 192.168.0.103:9878, IsLeader: false
|
||||||
|
[2026-04-07 21:51:08.884] DEBUG: Single-node mode: registering node without Raft consensus
|
||||||
|
[2026-04-07 21:51:08.884] DEBUG: Node registered locally in single-node mode: 8283b996-ac50-4cff-980d-4623a841934c
|
||||||
|
[2026-04-07 21:51:08.884] INFO: HTTP API server started on port 8080
|
||||||
|
[2026-04-07 21:51:08.884] INFO: Node 8283b996-ac50-4cff-980d-4623a841934c listening on 192.168.0.103:9876
|
||||||
|
[2026-04-07 21:51:08.885] INFO: Starting HTTP API server on port 8080
|
||||||
|
[2026-04-07 22:33:38.361] INFO: futriis database starting...
|
||||||
|
[2026-04-07 22:33:38.362] INFO: ACL manager initialized
|
||||||
|
[2026-04-07 22:33:38.362] DEBUG: Creating Raft coordinator at 192.168.0.103:9878
|
||||||
|
[2026-04-07 22:33:38.362] DEBUG: Running in single-node mode (warnings suppressed)
|
||||||
|
[2026-04-07 22:33:38.362] DEBUG: Raft data directory: raft_data
|
||||||
|
[2026-04-07 22:33:38.898] DEBUG: Existing Raft state found, joining cluster...
|
||||||
|
[2026-04-07 22:33:38.898] DEBUG: Raft coordinator started at 192.168.0.103:9878, IsLeader: false
|
||||||
|
[2026-04-07 22:33:38.898] DEBUG: Single-node mode: registering node without Raft consensus
|
||||||
|
[2026-04-07 22:33:38.899] DEBUG: Node registered locally in single-node mode: 72d4611f-a1ab-47a5-87f9-9011f18d97e9
|
||||||
|
[2026-04-07 22:33:38.899] INFO: HTTP API server started on port 8080
|
||||||
|
[2026-04-07 22:33:38.900] INFO: Node 72d4611f-a1ab-47a5-87f9-9011f18d97e9 listening on 192.168.0.103:9876
|
||||||
|
[2026-04-07 22:33:38.900] INFO: Starting HTTP API server on port 8080
|
||||||
|
[2026-04-07 22:41:46.729] INFO: futriis database starting...
|
||||||
|
[2026-04-07 22:41:46.729] INFO: ACL manager initialized
|
||||||
|
[2026-04-07 22:41:46.729] DEBUG: Creating Raft coordinator at 192.168.0.103:9878
|
||||||
|
[2026-04-07 22:41:46.729] DEBUG: Running in single-node mode (warnings suppressed)
|
||||||
|
[2026-04-07 22:41:46.729] DEBUG: Raft data directory: raft_data
|
||||||
|
[2026-04-07 22:41:47.261] DEBUG: Existing Raft state found, joining cluster...
|
||||||
|
[2026-04-07 22:41:47.261] DEBUG: Raft coordinator started at 192.168.0.103:9878, IsLeader: false
|
||||||
|
[2026-04-07 22:41:47.262] DEBUG: Single-node mode: registering node without Raft consensus
|
||||||
|
[2026-04-07 22:41:47.262] DEBUG: Node registered locally in single-node mode: 186b0497-8c04-46a5-b84d-bc702d4678ff
|
||||||
|
[2026-04-07 22:41:47.262] INFO: HTTP API server started on port 8080
|
||||||
|
[2026-04-07 22:41:47.262] INFO: Node 186b0497-8c04-46a5-b84d-bc702d4678ff listening on 192.168.0.103:9876
|
||||||
|
[2026-04-07 22:41:47.262] INFO: Starting HTTP API server on port 8080
|
||||||
|
[2026-04-07 22:59:36.988] ERROR: REPL command error: unknown command: уit
|
||||||
|
[2026-04-07 23:00:13.617] INFO: futriis database starting...
|
||||||
|
[2026-04-07 23:00:13.617] INFO: ACL manager initialized
|
||||||
|
[2026-04-07 23:00:13.617] DEBUG: Creating Raft coordinator at 192.168.0.103:9878
|
||||||
|
[2026-04-07 23:00:13.617] DEBUG: Running in single-node mode (warnings suppressed)
|
||||||
|
[2026-04-07 23:00:13.617] DEBUG: Raft data directory: raft_data
|
||||||
|
[2026-04-07 23:00:14.151] DEBUG: Existing Raft state found, joining cluster...
|
||||||
|
[2026-04-07 23:00:14.151] DEBUG: Raft coordinator started at 192.168.0.103:9878, IsLeader: false
|
||||||
|
[2026-04-07 23:00:14.152] DEBUG: Single-node mode: registering node without Raft consensus
|
||||||
|
[2026-04-07 23:00:14.152] DEBUG: Node registered locally in single-node mode: b67f7d35-2466-4894-841c-11dd91bb1fa0
|
||||||
|
[2026-04-07 23:00:14.152] INFO: HTTP API server started on port 8080
|
||||||
|
[2026-04-07 23:00:14.152] INFO: Node b67f7d35-2466-4894-841c-11dd91bb1fa0 listening on 192.168.0.103:9876
|
||||||
|
[2026-04-07 23:00:14.152] INFO: Starting HTTP API server on port 8080
|
||||||
|
[2026-04-08 22:23:39.449] INFO: futriis database starting...
|
||||||
|
[2026-04-08 22:23:39.449] INFO: ACL manager initialized
|
||||||
|
[2026-04-08 22:23:39.449] DEBUG: Creating Raft coordinator at 192.168.0.103:9878
|
||||||
|
[2026-04-08 22:23:39.449] DEBUG: Running in single-node mode (warnings suppressed)
|
||||||
|
[2026-04-08 22:23:39.450] DEBUG: Raft data directory: raft_data
|
||||||
|
[2026-04-08 22:23:40.009] DEBUG: Existing Raft state found, joining cluster...
|
||||||
|
[2026-04-08 22:23:40.009] DEBUG: Raft coordinator started at 192.168.0.103:9878, IsLeader: false
|
||||||
|
[2026-04-08 22:23:40.009] DEBUG: Single-node mode: registering node without Raft consensus
|
||||||
|
[2026-04-08 22:23:40.009] DEBUG: Node registered locally in single-node mode: 0211df79-f11a-4c15-9e47-e247f8a43d0a
|
||||||
|
[2026-04-08 22:23:40.009] INFO: HTTP API server started on port 8080
|
||||||
|
[2026-04-08 22:23:40.009] INFO: Node 0211df79-f11a-4c15-9e47-e247f8a43d0a listening on 192.168.0.103:9876
|
||||||
|
[2026-04-08 22:23:40.009] INFO: Starting HTTP API server on port 8080
|
||||||
|
[2026-04-08 22:39:05.946] INFO: futriis database starting...
|
||||||
|
[2026-04-08 22:39:05.946] INFO: ACL manager initialized
|
||||||
|
[2026-04-08 22:39:05.946] DEBUG: Creating Raft coordinator at 192.168.0.103:9878
|
||||||
|
[2026-04-08 22:39:05.946] DEBUG: Running in single-node mode (warnings suppressed)
|
||||||
|
[2026-04-08 22:39:05.946] DEBUG: Raft data directory: raft_data
|
||||||
|
[2026-04-08 22:39:06.478] DEBUG: Existing Raft state found, joining cluster...
|
||||||
|
[2026-04-08 22:39:06.478] DEBUG: Raft coordinator started at 192.168.0.103:9878, IsLeader: false
|
||||||
|
[2026-04-08 22:39:06.478] DEBUG: Single-node mode: registering node without Raft consensus
|
||||||
|
[2026-04-08 22:39:06.479] DEBUG: Node registered locally in single-node mode: ca0ee7f1-be2a-46d9-b447-849e636162b6
|
||||||
|
[2026-04-08 22:39:06.479] INFO: HTTP API server started on port 8080
|
||||||
|
[2026-04-08 22:39:06.479] INFO: Node ca0ee7f1-be2a-46d9-b447-849e636162b6 listening on 192.168.0.103:9876
|
||||||
|
[2026-04-08 22:39:06.479] INFO: Starting HTTP API server on port 8080
|
||||||
|
[2026-04-08 22:39:41.364] INFO: futriis database starting...
|
||||||
|
[2026-04-08 22:39:41.364] INFO: ACL manager initialized
|
||||||
|
[2026-04-08 22:39:41.364] DEBUG: Creating Raft coordinator at 192.168.0.103:9878
|
||||||
|
[2026-04-08 22:39:41.364] DEBUG: Running in single-node mode (warnings suppressed)
|
||||||
|
[2026-04-08 22:39:41.364] DEBUG: Raft data directory: raft_data
|
||||||
|
[2026-04-08 22:39:41.903] DEBUG: Existing Raft state found, joining cluster...
|
||||||
|
[2026-04-08 22:39:41.903] DEBUG: Raft coordinator started at 192.168.0.103:9878, IsLeader: false
|
||||||
|
[2026-04-08 22:39:41.903] DEBUG: Single-node mode: registering node without Raft consensus
|
||||||
|
[2026-04-08 22:39:41.903] DEBUG: Node registered locally in single-node mode: 7d88b91f-2805-4b14-9595-456496a0408a
|
||||||
|
[2026-04-08 22:39:41.903] INFO: HTTP API server started on port 8080
|
||||||
|
[2026-04-08 22:39:41.903] INFO: Node 7d88b91f-2805-4b14-9595-456496a0408a listening on 192.168.0.103:9876
|
||||||
|
[2026-04-08 22:39:41.904] INFO: Starting HTTP API server on port 8080
|
||||||
|
[2026-04-08 23:06:27.814] INFO: futriis database starting...
|
||||||
|
[2026-04-08 23:06:27.814] INFO: ACL manager initialized
|
||||||
|
[2026-04-08 23:06:27.814] DEBUG: Creating Raft coordinator at 192.168.0.103:9878
|
||||||
|
[2026-04-08 23:06:27.814] DEBUG: Running in single-node mode (warnings suppressed)
|
||||||
|
[2026-04-08 23:06:27.814] DEBUG: Raft data directory: raft_data
|
||||||
|
[2026-04-08 23:06:28.359] DEBUG: Existing Raft state found, joining cluster...
|
||||||
|
[2026-04-08 23:06:28.359] DEBUG: Raft coordinator started at 192.168.0.103:9878, IsLeader: false
|
||||||
|
[2026-04-08 23:06:28.359] DEBUG: Single-node mode: registering node without Raft consensus
|
||||||
|
[2026-04-08 23:06:28.359] DEBUG: Node registered locally in single-node mode: e6ddefe4-0899-457d-bbdb-ffe9652ac8eb
|
||||||
|
[2026-04-08 23:06:28.359] INFO: HTTP API server started on port 8080
|
||||||
|
[2026-04-08 23:06:28.359] INFO: Node e6ddefe4-0899-457d-bbdb-ffe9652ac8eb listening on 192.168.0.103:9876
|
||||||
|
[2026-04-08 23:06:28.360] INFO: Starting HTTP API server on port 8080
|
||||||
|
[2026-04-08 23:18:54.236] INFO: futriis database starting...
|
||||||
|
[2026-04-08 23:18:54.236] INFO: ACL manager initialized
|
||||||
|
[2026-04-08 23:18:54.236] DEBUG: Creating Raft coordinator at 192.168.0.103:9878
|
||||||
|
[2026-04-08 23:18:54.236] DEBUG: Running in single-node mode (warnings suppressed)
|
||||||
|
[2026-04-08 23:18:54.236] DEBUG: Raft data directory: raft_data
|
||||||
|
[2026-04-08 23:18:54.768] DEBUG: Existing Raft state found, joining cluster...
|
||||||
|
[2026-04-08 23:18:54.768] DEBUG: Raft coordinator started at 192.168.0.103:9878, IsLeader: false
|
||||||
|
[2026-04-08 23:18:54.768] DEBUG: Single-node mode: registering node without Raft consensus
|
||||||
|
[2026-04-08 23:18:54.768] DEBUG: Node registered locally in single-node mode: fb18fd53-35ab-491c-8151-bd583952c5e6
|
||||||
|
[2026-04-08 23:18:54.768] INFO: HTTP API server started on port 8080
|
||||||
|
[2026-04-08 23:18:54.768] INFO: Node fb18fd53-35ab-491c-8151-bd583952c5e6 listening on 192.168.0.103:9876
|
||||||
|
[2026-04-08 23:18:54.768] INFO: Starting HTTP API server on port 8080
|
||||||
|
[2026-04-08 23:20:19.549] INFO: futriis database starting...
|
||||||
|
[2026-04-08 23:20:19.549] INFO: ACL manager initialized
|
||||||
|
[2026-04-08 23:20:19.549] DEBUG: Creating Raft coordinator at 192.168.0.103:9878
|
||||||
|
[2026-04-08 23:20:19.549] DEBUG: Running in single-node mode (warnings suppressed)
|
||||||
|
[2026-04-08 23:20:19.549] DEBUG: Raft data directory: raft_data
|
||||||
|
[2026-04-08 23:20:20.083] DEBUG: Existing Raft state found, joining cluster...
|
||||||
|
[2026-04-08 23:20:20.083] DEBUG: Raft coordinator started at 192.168.0.103:9878, IsLeader: false
|
||||||
|
[2026-04-08 23:20:20.083] DEBUG: Single-node mode: registering node without Raft consensus
|
||||||
|
[2026-04-08 23:20:20.083] DEBUG: Node registered locally in single-node mode: b5fd24ed-4436-44a1-977a-9e890730a1dc
|
||||||
|
[2026-04-08 23:20:20.083] INFO: HTTP API server started on port 8080
|
||||||
|
[2026-04-08 23:20:20.084] INFO: Node b5fd24ed-4436-44a1-977a-9e890730a1dc listening on 192.168.0.103:9876
|
||||||
|
[2026-04-08 23:20:20.084] INFO: Starting HTTP API server on port 8080
|
||||||
|
[2026-04-12 17:00:40.816] INFO: futriis database starting...
|
||||||
|
[2026-04-12 17:00:40.816] INFO: ACL manager initialized
|
||||||
|
[2026-04-12 17:00:40.816] DEBUG: Creating Raft coordinator at 192.168.0.103:9878
|
||||||
|
[2026-04-12 17:00:40.816] DEBUG: Running in single-node mode (warnings suppressed)
|
||||||
|
[2026-04-12 17:00:40.816] DEBUG: Raft data directory: raft_data
|
||||||
|
[2026-04-12 17:00:41.351] DEBUG: Existing Raft state found, joining cluster...
|
||||||
|
[2026-04-12 17:00:41.351] DEBUG: Raft coordinator started at 192.168.0.103:9878, IsLeader: false
|
||||||
|
[2026-04-12 17:00:41.351] DEBUG: Single-node mode: registering node without Raft consensus
|
||||||
|
[2026-04-12 17:00:41.351] DEBUG: Node registered locally in single-node mode: d53f19c5-7a0a-4299-b2bd-6abc3952c259
|
||||||
|
[2026-04-12 17:00:41.351] INFO: HTTP API server started on port 8080
|
||||||
|
[2026-04-12 17:00:41.351] INFO: Node d53f19c5-7a0a-4299-b2bd-6abc3952c259 listening on 192.168.0.103:9876
|
||||||
|
[2026-04-12 17:00:41.351] INFO: Starting HTTP API server on port 8080
|
||||||
|
[2026-04-12 17:34:26.635] INFO: futriis database starting...
|
||||||
|
[2026-04-12 17:34:26.635] INFO: ACL manager initialized
|
||||||
|
[2026-04-12 17:34:26.635] DEBUG: Creating Raft coordinator at 192.168.0.103:9878
|
||||||
|
[2026-04-12 17:34:26.635] DEBUG: Single-node mode: local node added to state: test_cluster-192.168.0.103
|
||||||
|
[2026-04-12 17:34:26.635] DEBUG: Running in single-node mode (warnings suppressed)
|
||||||
|
[2026-04-12 17:34:26.635] DEBUG: Raft data directory: raft_data
|
||||||
|
[2026-04-12 17:34:27.183] DEBUG: Existing Raft state found, joining cluster...
|
||||||
|
[2026-04-12 17:34:27.183] DEBUG: Raft coordinator started at 192.168.0.103:9878, IsLeader: true, SingleNodeMode: true
|
||||||
|
[2026-04-12 17:34:27.183] DEBUG: Single-node mode: registering node without Raft consensus
|
||||||
|
[2026-04-12 17:34:27.183] DEBUG: Node registered locally in single-node mode: ce74073f-cb0a-4637-ba1f-474cb600fec2
|
||||||
|
[2026-04-12 17:34:27.183] INFO: HTTP API server started on port 8080
|
||||||
|
[2026-04-12 17:34:27.183] INFO: Node ce74073f-cb0a-4637-ba1f-474cb600fec2 listening on 192.168.0.103:9876
|
||||||
|
[2026-04-12 17:34:27.183] INFO: Starting HTTP API server on port 8080
|
||||||
|
[2026-04-12 17:57:12.248] INFO: futriis database starting...
|
||||||
|
[2026-04-12 17:57:12.248] INFO: ACL manager initialized
|
||||||
|
[2026-04-12 17:57:12.248] DEBUG: Creating Raft coordinator at 192.168.0.103:9878
|
||||||
|
[2026-04-12 17:57:12.248] DEBUG: Single-node mode: local node added to state: test_cluster-192.168.0.103
|
||||||
|
[2026-04-12 17:57:12.248] DEBUG: Running in single-node mode (warnings suppressed)
|
||||||
|
[2026-04-12 17:57:12.248] DEBUG: Raft data directory: raft_data
|
||||||
|
[2026-04-12 17:57:12.804] DEBUG: Existing Raft state found, joining cluster...
|
||||||
|
[2026-04-12 17:57:12.804] DEBUG: Raft coordinator started at 192.168.0.103:9878, IsLeader: true, SingleNodeMode: true
|
||||||
|
[2026-04-12 17:57:12.804] DEBUG: Single-node mode: registering node without Raft consensus
|
||||||
|
[2026-04-12 17:57:12.804] DEBUG: Node registered locally in single-node mode: 5f3dc548-a7e2-43e8-9075-299e8e0d3937
|
||||||
|
[2026-04-12 17:57:12.804] INFO: HTTP API server started on port 8080
|
||||||
|
[2026-04-12 17:57:12.804] INFO: Node 5f3dc548-a7e2-43e8-9075-299e8e0d3937 listening on 192.168.0.103:9876
|
||||||
|
[2026-04-12 17:57:12.804] INFO: Starting HTTP API server on port 8080
|
||||||
|
[2026-04-12 18:03:05.431] INFO: futriis database starting...
|
||||||
|
[2026-04-12 18:03:05.431] INFO: ACL manager initialized
|
||||||
|
[2026-04-12 18:03:05.431] DEBUG: Creating Raft coordinator at 192.168.0.103:9878
|
||||||
|
[2026-04-12 18:03:05.431] DEBUG: Single-node mode: local node added to state: test_cluster-192.168.0.103
|
||||||
|
[2026-04-12 18:03:05.431] DEBUG: Running in single-node mode (warnings suppressed)
|
||||||
|
[2026-04-12 18:03:05.431] DEBUG: Raft data directory: raft_data
|
||||||
|
[2026-04-12 18:03:05.976] DEBUG: Existing Raft state found, joining cluster...
|
||||||
|
[2026-04-12 18:03:05.976] DEBUG: Raft coordinator started at 192.168.0.103:9878, IsLeader: true, SingleNodeMode: true
|
||||||
|
[2026-04-12 18:03:05.976] DEBUG: Single-node mode: registering node without Raft consensus
|
||||||
|
[2026-04-12 18:03:05.976] DEBUG: Node registered locally in single-node mode: 81521d2a-8010-44b9-adfd-9bc3dbb76db9
|
||||||
|
[2026-04-12 18:03:05.976] INFO: HTTP API server started on port 8080
|
||||||
|
[2026-04-12 18:03:05.977] INFO: Node 81521d2a-8010-44b9-adfd-9bc3dbb76db9 listening on 192.168.0.103:9876
|
||||||
|
[2026-04-12 18:03:05.977] INFO: Starting HTTP API server on port 8080
|
||||||
|
[2026-04-12 18:10:15.707] INFO: futriis database starting...
|
||||||
|
[2026-04-12 18:10:15.708] INFO: ACL manager initialized
|
||||||
|
[2026-04-12 18:10:15.708] DEBUG: Creating Raft coordinator at 192.168.0.103:9878
|
||||||
|
[2026-04-12 18:10:15.708] DEBUG: Single-node mode: local node added to state: test_cluster-192.168.0.103
|
||||||
|
[2026-04-12 18:10:15.708] DEBUG: Running in single-node mode (warnings suppressed)
|
||||||
|
[2026-04-12 18:10:15.709] DEBUG: Raft data directory: raft_data
|
||||||
|
[2026-04-12 18:10:16.265] DEBUG: Existing Raft state found, joining cluster...
|
||||||
|
[2026-04-12 18:10:16.265] DEBUG: Raft coordinator started at 192.168.0.103:9878, IsLeader: true, SingleNodeMode: true
|
||||||
|
[2026-04-12 18:10:16.265] DEBUG: Single-node mode: registering node without Raft consensus
|
||||||
|
[2026-04-12 18:10:16.265] DEBUG: Node registered locally in single-node mode: 175dc71a-66c8-4f97-8043-6af5128adfb7
|
||||||
|
[2026-04-12 18:10:16.265] INFO: HTTP API server started on port 8080
|
||||||
|
[2026-04-12 18:10:16.265] INFO: Node 175dc71a-66c8-4f97-8043-6af5128adfb7 listening on 192.168.0.103:9876
|
||||||
|
[2026-04-12 18:10:16.265] INFO: Starting HTTP API server on port 8080
|
||||||
|
[2026-04-12 18:44:40.720] INFO: futriis database starting...
|
||||||
|
[2026-04-12 18:44:40.720] INFO: ACL manager initialized
|
||||||
|
[2026-04-12 18:44:40.720] DEBUG: Creating Raft coordinator at 192.168.0.103:9878
|
||||||
|
[2026-04-12 18:44:40.720] DEBUG: Single-node mode: local node added to state: test_cluster-192.168.0.103
|
||||||
|
[2026-04-12 18:44:40.720] DEBUG: Running in single-node mode (warnings suppressed)
|
||||||
|
[2026-04-12 18:44:40.720] DEBUG: Raft data directory: raft_data
|
||||||
|
[2026-04-12 18:44:41.255] DEBUG: Existing Raft state found, joining cluster...
|
||||||
|
[2026-04-12 18:44:41.255] DEBUG: Raft coordinator started at 192.168.0.103:9878, IsLeader: true, SingleNodeMode: true
|
||||||
|
[2026-04-12 18:44:41.255] DEBUG: Single-node mode: registering node without Raft consensus
|
||||||
|
[2026-04-12 18:44:41.255] DEBUG: Node registered locally in single-node mode: a26638bf-562a-40c6-bf0d-d156ca3c064a
|
||||||
|
[2026-04-12 18:44:41.255] INFO: HTTP API server started on port 8080
|
||||||
|
[2026-04-12 18:44:41.255] INFO: Node a26638bf-562a-40c6-bf0d-d156ca3c064a listening on 192.168.0.103:9876
|
||||||
|
[2026-04-12 18:44:41.255] INFO: Starting HTTP API server on port 8080
|
||||||
|
[2026-04-12 19:29:00.961] INFO: futriis database starting...
|
||||||
|
[2026-04-12 19:29:00.961] INFO: ACL manager initialized
|
||||||
|
[2026-04-12 19:29:00.961] DEBUG: Creating Raft coordinator at 192.168.0.103:9878
|
||||||
|
[2026-04-12 19:29:00.961] DEBUG: Single-node mode: local node added to state: test_cluster-192.168.0.103
|
||||||
|
[2026-04-12 19:29:00.961] DEBUG: Running in single-node mode (warnings suppressed)
|
||||||
|
[2026-04-12 19:29:00.961] DEBUG: Raft data directory: raft_data
|
||||||
|
[2026-04-12 19:29:01.516] DEBUG: Existing Raft state found, joining cluster...
|
||||||
|
[2026-04-12 19:29:01.516] DEBUG: Raft coordinator started at 192.168.0.103:9878, IsLeader: true, SingleNodeMode: true
|
||||||
|
[2026-04-12 19:29:01.517] DEBUG: Single-node mode: registering node without Raft consensus
|
||||||
|
[2026-04-12 19:29:01.517] DEBUG: Node registered locally in single-node mode: e6de8d60-5802-4c1e-bf08-b4edf1edfba6
|
||||||
|
[2026-04-12 19:29:01.517] INFO: HTTP API server started on port 8080
|
||||||
|
[2026-04-12 19:29:01.517] INFO: Node e6de8d60-5802-4c1e-bf08-b4edf1edfba6 listening on 192.168.0.103:9876
|
||||||
|
[2026-04-12 19:29:01.517] INFO: Starting HTTP API server on port 8080
|
||||||
|
[2026-04-12 19:37:32.544] INFO: futriis database starting...
|
||||||
|
[2026-04-12 19:37:32.544] INFO: ACL manager initialized
|
||||||
|
[2026-04-12 19:37:32.544] DEBUG: Creating Raft coordinator at 192.168.0.103:9878
|
||||||
|
[2026-04-12 19:37:32.544] DEBUG: Single-node mode: local node added to state: test_cluster-192.168.0.103
|
||||||
|
[2026-04-12 19:37:32.544] DEBUG: Running in single-node mode (warnings suppressed)
|
||||||
|
[2026-04-12 19:37:32.544] DEBUG: Raft data directory: raft_data
|
||||||
|
[2026-04-12 19:37:33.104] DEBUG: Existing Raft state found, joining cluster...
|
||||||
|
[2026-04-12 19:37:33.104] DEBUG: Raft coordinator started at 192.168.0.103:9878, IsLeader: true, SingleNodeMode: true
|
||||||
|
[2026-04-12 19:37:33.104] DEBUG: Single-node mode: registering node without Raft consensus
|
||||||
|
[2026-04-12 19:37:33.104] DEBUG: Node registered locally in single-node mode: dfdbbf62-cfce-40c2-a247-7637e30f92aa
|
||||||
|
[2026-04-12 19:37:33.104] INFO: HTTP API server started on port 8080
|
||||||
|
[2026-04-12 19:37:33.104] INFO: Node dfdbbf62-cfce-40c2-a247-7637e30f92aa listening on 192.168.0.103:9876
|
||||||
|
[2026-04-12 19:37:33.104] INFO: Starting HTTP API server on port 8080
|
||||||
|
[2026-04-18 17:45:28.300] INFO: futriis database starting...
|
||||||
|
[2026-04-18 17:45:28.300] INFO: ACL manager initialized
|
||||||
|
[2026-04-18 17:45:28.300] DEBUG: Creating Raft coordinator at 192.168.0.103:9878
|
||||||
|
[2026-04-18 17:45:28.301] DEBUG: Single-node mode: local node added to state: test_cluster-192.168.0.103
|
||||||
|
[2026-04-18 17:45:28.301] DEBUG: Running in single-node mode (warnings suppressed)
|
||||||
|
[2026-04-18 17:45:28.301] DEBUG: Raft data directory: raft_data
|
||||||
|
[2026-04-18 17:45:28.852] DEBUG: Existing Raft state found, joining cluster...
|
||||||
|
[2026-04-18 17:45:28.852] DEBUG: Raft coordinator started at 192.168.0.103:9878, IsLeader: true, SingleNodeMode: true
|
||||||
|
[2026-04-18 17:45:28.853] DEBUG: Single-node mode: registering node without Raft consensus
|
||||||
|
[2026-04-18 17:45:28.853] DEBUG: Node registered locally in single-node mode: e31da9a8-fe16-4853-9414-b9813713a447
|
||||||
|
[2026-04-18 17:45:28.853] INFO: HTTP API server started on port 8080
|
||||||
|
[2026-04-18 17:45:28.853] INFO: Web UI started on port 8080
|
||||||
|
[2026-04-18 17:45:28.853] INFO: Node e31da9a8-fe16-4853-9414-b9813713a447 listening on 192.168.0.103:9876
|
||||||
|
[2026-04-18 17:45:28.853] INFO: Starting HTTP API server on port 8080
|
||||||
|
[2026-04-18 17:45:28.853] INFO: Web UI started on port 8080
|
||||||
|
[2026-04-18 17:45:28.854] ERROR: Web UI error: listen tcp :8080: bind: address already in use
|
||||||
|
[2026-04-18 19:53:53.030] INFO: futriis database starting...
|
||||||
|
[2026-04-18 19:53:53.031] INFO: ACL manager initialized
|
||||||
|
[2026-04-18 19:53:53.031] DEBUG: Creating Raft coordinator at 192.168.0.103:9878
|
||||||
|
[2026-04-18 19:53:53.031] DEBUG: Single-node mode: local node added to state: test_cluster-192.168.0.103
|
||||||
|
[2026-04-18 19:53:53.031] DEBUG: Running in single-node mode (warnings suppressed)
|
||||||
|
[2026-04-18 19:53:53.031] DEBUG: Raft data directory: raft_data
|
||||||
|
[2026-04-18 19:53:53.562] DEBUG: Existing Raft state found, joining cluster...
|
||||||
|
[2026-04-18 19:53:53.562] DEBUG: Raft coordinator started at 192.168.0.103:9878, IsLeader: true, SingleNodeMode: true
|
||||||
|
[2026-04-18 19:53:53.562] DEBUG: Single-node mode: registering node without Raft consensus
|
||||||
|
[2026-04-18 19:53:53.562] DEBUG: Node registered locally in single-node mode: 621c8a4a-6f0c-4617-9c4d-19f842413d76
|
||||||
|
[2026-04-18 19:53:53.562] INFO: Node 621c8a4a-6f0c-4617-9c4d-19f842413d76 listening on 192.168.0.103:9876
|
||||||
|
[2026-04-18 19:53:53.562] INFO: HTTP API server started on port 8080
|
||||||
|
[2026-04-18 19:53:53.562] INFO: Web UI started on port 9080
|
||||||
|
[2026-04-18 19:53:53.562] INFO: Starting HTTP API server on port 8080
|
||||||
|
[2026-04-18 19:53:53.563] INFO: Web UI started on port 9080
|
||||||
32
go.mod
Normal file
32
go.mod
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
module futriis
|
||||||
|
|
||||||
|
go 1.26
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/BurntSushi/toml v1.6.0
|
||||||
|
github.com/fatih/color v1.19.0
|
||||||
|
github.com/golang/snappy v0.0.4
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/hashicorp/raft v1.7.3
|
||||||
|
github.com/hashicorp/raft-boltdb/v2 v2.3.0
|
||||||
|
github.com/klauspost/compress v1.18.0
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.22
|
||||||
|
github.com/vmihailenco/msgpack/v5 v5.4.1
|
||||||
|
github.com/yuin/gopher-lua v1.1.2
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/armon/go-metrics v0.4.1 // indirect
|
||||||
|
github.com/boltdb/bolt v1.3.1 // indirect
|
||||||
|
github.com/hashicorp/go-hclog v1.6.3 // indirect
|
||||||
|
github.com/hashicorp/go-immutable-radix v1.0.0 // indirect
|
||||||
|
github.com/hashicorp/go-metrics v0.5.4 // indirect
|
||||||
|
github.com/hashicorp/go-msgpack v1.1.5 // indirect
|
||||||
|
github.com/hashicorp/go-msgpack/v2 v2.1.5 // indirect
|
||||||
|
github.com/hashicorp/golang-lru v1.0.2 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||||
|
go.etcd.io/bbolt v1.3.5 // indirect
|
||||||
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
)
|
||||||
212
go.sum
Normal file
212
go.sum
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
|
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
|
||||||
|
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||||
|
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||||
|
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||||
|
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||||
|
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
|
||||||
|
github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA=
|
||||||
|
github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4=
|
||||||
|
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||||
|
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/boltdb/bolt v1.3.1 h1:JQmyP4ZBrce+ZQu0dY660FMfatumYDLun9hBCUVIkF4=
|
||||||
|
github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps=
|
||||||
|
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
|
||||||
|
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
||||||
|
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||||
|
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||||
|
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||||
|
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||||
|
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||||
|
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||||
|
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||||
|
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||||
|
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||||
|
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||||
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||||
|
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||||
|
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
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/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
|
||||||
|
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||||
|
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||||
|
github.com/hashicorp/go-immutable-radix v1.0.0 h1:AKDB1HM5PWEA7i4nhcpwOrO2byshxBjXVn/J/3+z5/0=
|
||||||
|
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||||
|
github.com/hashicorp/go-metrics v0.5.4 h1:8mmPiIJkTPPEbAiV97IxdAGNdRdaWwVap1BU6elejKY=
|
||||||
|
github.com/hashicorp/go-metrics v0.5.4/go.mod h1:CG5yz4NZ/AI/aQt9Ucm/vdBnbh7fvmv4lxZ350i+QQI=
|
||||||
|
github.com/hashicorp/go-msgpack v1.1.5 h1:9byZdVjKTe5mce63pRVNP1L7UAmdHOTEMGehn6KvJWs=
|
||||||
|
github.com/hashicorp/go-msgpack v1.1.5/go.mod h1:gWVc3sv/wbDmR3rQsj1CAktEZzoz1YNK9NfGLXJ69/4=
|
||||||
|
github.com/hashicorp/go-msgpack/v2 v2.1.5 h1:Ue879bPnutj/hXfmUk6s/jtIK90XxgiUIcXRl656T44=
|
||||||
|
github.com/hashicorp/go-msgpack/v2 v2.1.5/go.mod h1:bjCsRXpZ7NsJdk45PoCQnzRGDaK8TKm5ZnDI/9y3J4M=
|
||||||
|
github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.0 h1:RS8zrF7PhGwyNPOtxSClXXj9HA8feRnJzgnI1RJCSnM=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
|
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
|
||||||
|
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||||
|
github.com/hashicorp/raft v1.7.3 h1:DxpEqZJysHN0wK+fviai5mFcSYsCkNpFUl1xpAW8Rbo=
|
||||||
|
github.com/hashicorp/raft v1.7.3/go.mod h1:DfvCGFxpAUPE0L4Uc8JLlTPtc3GzSbdH0MTJCLgnmJQ=
|
||||||
|
github.com/hashicorp/raft-boltdb v0.0.0-20230125174641-2a8082862702 h1:RLKEcCuKcZ+qp2VlaaZsYZfLOmIiuJNpEi48Rl8u9cQ=
|
||||||
|
github.com/hashicorp/raft-boltdb v0.0.0-20230125174641-2a8082862702/go.mod h1:nTakvJ4XYq45UXtn0DbwR4aU9ZdjlnIenpbs6Cd+FM0=
|
||||||
|
github.com/hashicorp/raft-boltdb/v2 v2.3.0 h1:fPpQR1iGEVYjZ2OELvUHX600VAK5qmdnDEv3eXOwZUA=
|
||||||
|
github.com/hashicorp/raft-boltdb/v2 v2.3.0/go.mod h1:YHukhB04ChJsLHLJEUD6vjFyLX2L3dsX3wPBZcX4tmc=
|
||||||
|
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
|
||||||
|
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||||
|
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||||
|
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||||
|
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||||
|
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||||
|
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||||
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
|
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
|
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||||
|
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||||
|
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
|
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
|
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||||
|
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||||
|
github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=
|
||||||
|
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||||
|
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
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_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||||
|
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||||
|
github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
|
||||||
|
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
|
||||||
|
github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
|
||||||
|
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||||
|
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
|
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
|
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||||
|
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
|
||||||
|
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
|
||||||
|
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
|
||||||
|
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||||
|
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||||
|
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
|
||||||
|
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
|
||||||
|
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
|
||||||
|
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||||
|
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||||
|
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
|
||||||
|
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.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
|
||||||
|
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
|
||||||
|
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||||
|
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||||
|
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||||
|
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||||
|
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
|
||||||
|
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
|
||||||
|
go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
|
||||||
|
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
|
||||||
|
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
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-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/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-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190424220101-1e8e1cfdf96b/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
|
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||||
|
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||||
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
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.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
498
internal/acl/manger.go
Normal file
498
internal/acl/manger.go
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/acl/manager.go
|
||||||
|
// Назначение: Глобальный менеджер ACL для всей СУБД.
|
||||||
|
// Управляет пользователями, ролями и разрешениями на уровне БД и коллекций.
|
||||||
|
// Реализован с использованием sync.Map для wait-free доступа.
|
||||||
|
|
||||||
|
package acl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PermissionType определяет тип разрешения
|
||||||
|
type PermissionType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PermRead PermissionType = "read"
|
||||||
|
PermWrite PermissionType = "write"
|
||||||
|
PermDelete PermissionType = "delete"
|
||||||
|
PermAdmin PermissionType = "admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User представляет пользователя системы
|
||||||
|
type User struct {
|
||||||
|
ID string `msgpack:"id"`
|
||||||
|
Username string `msgpack:"username"`
|
||||||
|
Password string `msgpack:"password"` // В реальной системе - хеш
|
||||||
|
Roles []string `msgpack:"roles"`
|
||||||
|
CreatedAt int64 `msgpack:"created_at"`
|
||||||
|
LastLogin int64 `msgpack:"last_login"`
|
||||||
|
Active bool `msgpack:"active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session представляет активную сессию пользователя
|
||||||
|
type Session struct {
|
||||||
|
ID string `msgpack:"id"`
|
||||||
|
Username string `msgpack:"username"`
|
||||||
|
Roles []string `msgpack:"roles"`
|
||||||
|
CreatedAt int64 `msgpack:"created_at"`
|
||||||
|
ExpiresAt int64 `msgpack:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role представляет роль с набором разрешений
|
||||||
|
type Role struct {
|
||||||
|
Name string `msgpack:"name"`
|
||||||
|
Permissions []string `msgpack:"permissions"` // "database.collection:read" формат
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACLManager управляет доступом к БД
|
||||||
|
type ACLManager struct {
|
||||||
|
users sync.Map // map[string]*User
|
||||||
|
roles sync.Map // map[string]*Role
|
||||||
|
sessions sync.Map // map[string]*Session - sessionID -> Session
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewACLManager создаёт новый менеджер ACL
|
||||||
|
func NewACLManager() *ACLManager {
|
||||||
|
m := &ACLManager{}
|
||||||
|
|
||||||
|
// Создаём роль администратора по умолчанию
|
||||||
|
adminRole := &Role{
|
||||||
|
Name: "admin",
|
||||||
|
Permissions: []string{"*:*"},
|
||||||
|
}
|
||||||
|
m.roles.Store("admin", adminRole)
|
||||||
|
|
||||||
|
// Создаём пользователя admin по умолчанию
|
||||||
|
adminUser := &User{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
Username: "admin",
|
||||||
|
Password: "admin", // В продакшене использовать хеш!
|
||||||
|
Roles: []string{"admin"},
|
||||||
|
CreatedAt: time.Now().UnixMilli(),
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
m.users.Store("admin", adminUser)
|
||||||
|
|
||||||
|
// Создаём роль guest с ограниченными правами
|
||||||
|
guestRole := &Role{
|
||||||
|
Name: "guest",
|
||||||
|
Permissions: []string{},
|
||||||
|
}
|
||||||
|
m.roles.Store("guest", guestRole)
|
||||||
|
|
||||||
|
// Создаём пользователя guest по умолчанию
|
||||||
|
guestUser := &User{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
Username: "guest",
|
||||||
|
Password: "guest",
|
||||||
|
Roles: []string{"guest"},
|
||||||
|
CreatedAt: time.Now().UnixMilli(),
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
m.users.Store("guest", guestUser)
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser создаёт нового пользователя
|
||||||
|
func (m *ACLManager) CreateUser(username, password string, roles []string) error {
|
||||||
|
if _, exists := m.users.Load(username); exists {
|
||||||
|
return fmt.Errorf("user %s already exists", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &User{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
Username: username,
|
||||||
|
Password: password,
|
||||||
|
Roles: roles,
|
||||||
|
CreatedAt: time.Now().UnixMilli(),
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
m.users.Store(username, user)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate аутентифицирует пользователя и создаёт сессию
|
||||||
|
func (m *ACLManager) Authenticate(username, password string) (string, error) {
|
||||||
|
val, ok := m.users.Load(username)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := val.(*User)
|
||||||
|
if !user.Active {
|
||||||
|
return "", fmt.Errorf("user is disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Password != password {
|
||||||
|
return "", fmt.Errorf("invalid password")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем время последнего входа
|
||||||
|
user.LastLogin = time.Now().UnixMilli()
|
||||||
|
m.users.Store(username, user)
|
||||||
|
|
||||||
|
// Создаём сессию (24 часа)
|
||||||
|
sessionID := uuid.New().String()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
session := &Session{
|
||||||
|
ID: sessionID,
|
||||||
|
Username: username,
|
||||||
|
Roles: user.Roles,
|
||||||
|
CreatedAt: now,
|
||||||
|
ExpiresAt: now + 86400, // 24 часа
|
||||||
|
}
|
||||||
|
m.sessions.Store(sessionID, session)
|
||||||
|
|
||||||
|
return sessionID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout завершает сессию
|
||||||
|
func (m *ACLManager) Logout(sessionID string) {
|
||||||
|
m.sessions.Delete(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckSession проверяет, активна ли сессия
|
||||||
|
func (m *ACLManager) CheckSession(sessionID string) bool {
|
||||||
|
val, ok := m.sessions.Load(sessionID)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
session := val.(*Session)
|
||||||
|
// Проверка на expiry
|
||||||
|
if time.Now().Unix() > session.ExpiresAt {
|
||||||
|
m.sessions.Delete(sessionID)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUsername возвращает имя пользователя по ID сессии
|
||||||
|
func (m *ACLManager) GetUsername(sessionID string) string {
|
||||||
|
val, ok := m.sessions.Load(sessionID)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
session := val.(*Session)
|
||||||
|
return session.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserRoles возвращает роли пользователя по ID сессии
|
||||||
|
func (m *ACLManager) GetUserRoles(sessionID string) []string {
|
||||||
|
val, ok := m.sessions.Load(sessionID)
|
||||||
|
if !ok {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
session := val.(*Session)
|
||||||
|
return session.Roles
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPermission проверяет разрешение для сессии
|
||||||
|
func (m *ACLManager) CheckPermission(sessionID, database, collection, operation string) bool {
|
||||||
|
val, ok := m.sessions.Load(sessionID)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
session := val.(*Session)
|
||||||
|
|
||||||
|
for _, roleName := range session.Roles {
|
||||||
|
roleVal, ok := m.roles.Load(roleName)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
role := roleVal.(*Role)
|
||||||
|
for _, perm := range role.Permissions {
|
||||||
|
if m.matchPermission(perm, database, collection, operation) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchPermission проверяет соответствие разрешения
|
||||||
|
func (m *ACLManager) matchPermission(perm, database, collection, operation string) bool {
|
||||||
|
// Формат: "database.collection:operation" или "*:*" для всех
|
||||||
|
// или "database.*:read" для всех коллекций в БД
|
||||||
|
|
||||||
|
parts := splitPermission(perm)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
resource := parts[0] // "database.collection" или "database.*"
|
||||||
|
op := parts[1] // "read", "write", "delete", "admin"
|
||||||
|
|
||||||
|
// Проверка операции
|
||||||
|
if op != "*" && op != operation && operation != "admin" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Администратор имеет доступ ко всему
|
||||||
|
if op == "admin" || op == "*" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка ресурса
|
||||||
|
if resource == "*:*" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceParts := splitResource(resource)
|
||||||
|
if len(resourceParts) != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPattern := resourceParts[0]
|
||||||
|
collPattern := resourceParts[1]
|
||||||
|
|
||||||
|
if dbPattern != "*" && dbPattern != database {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if collPattern != "*" && collPattern != collection {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantPermission выдаёт разрешение роли
|
||||||
|
func (m *ACLManager) GrantPermission(roleName, permission string) error {
|
||||||
|
val, ok := m.roles.Load(roleName)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("role not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
role := val.(*Role)
|
||||||
|
role.Permissions = append(role.Permissions, permission)
|
||||||
|
m.roles.Store(roleName, role)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokePermission отзывает разрешение у роли
|
||||||
|
func (m *ACLManager) RevokePermission(roleName, permission string) error {
|
||||||
|
val, ok := m.roles.Load(roleName)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("role not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
role := val.(*Role)
|
||||||
|
newPermissions := make([]string, 0, len(role.Permissions))
|
||||||
|
for _, p := range role.Permissions {
|
||||||
|
if p != permission {
|
||||||
|
newPermissions = append(newPermissions, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
role.Permissions = newPermissions
|
||||||
|
m.roles.Store(roleName, role)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRole создаёт новую роль
|
||||||
|
func (m *ACLManager) CreateRole(name string) error {
|
||||||
|
if _, exists := m.roles.Load(name); exists {
|
||||||
|
return fmt.Errorf("role %s already exists", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
role := &Role{
|
||||||
|
Name: name,
|
||||||
|
Permissions: []string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
m.roles.Store(name, role)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRole удаляет роль
|
||||||
|
func (m *ACLManager) DeleteRole(name string) error {
|
||||||
|
if _, exists := m.roles.LoadAndDelete(name); !exists {
|
||||||
|
return fmt.Errorf("role not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddUserRole добавляет роль пользователю
|
||||||
|
func (m *ACLManager) AddUserRole(username, roleName string) error {
|
||||||
|
val, ok := m.users.Load(username)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := val.(*User)
|
||||||
|
// Проверяем, есть ли уже такая роль
|
||||||
|
for _, r := range user.Roles {
|
||||||
|
if r == roleName {
|
||||||
|
return fmt.Errorf("user already has role %s", roleName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user.Roles = append(user.Roles, roleName)
|
||||||
|
m.users.Store(username, user)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveUserRole удаляет роль у пользователя
|
||||||
|
func (m *ACLManager) RemoveUserRole(username, roleName string) error {
|
||||||
|
val, ok := m.users.Load(username)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := val.(*User)
|
||||||
|
newRoles := make([]string, 0, len(user.Roles))
|
||||||
|
for _, r := range user.Roles {
|
||||||
|
if r != roleName {
|
||||||
|
newRoles = append(newRoles, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
user.Roles = newRoles
|
||||||
|
m.users.Store(username, user)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisableUser отключает пользователя
|
||||||
|
func (m *ACLManager) DisableUser(username string) error {
|
||||||
|
val, ok := m.users.Load(username)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := val.(*User)
|
||||||
|
user.Active = false
|
||||||
|
m.users.Store(username, user)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableUser включает пользователя
|
||||||
|
func (m *ACLManager) EnableUser(username string) error {
|
||||||
|
val, ok := m.users.Load(username)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := val.(*User)
|
||||||
|
user.Active = true
|
||||||
|
m.users.Store(username, user)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser удаляет пользователя
|
||||||
|
func (m *ACLManager) DeleteUser(username string) error {
|
||||||
|
if _, exists := m.users.LoadAndDelete(username); !exists {
|
||||||
|
return fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword изменяет пароль пользователя
|
||||||
|
func (m *ACLManager) ChangePassword(username, newPassword string) error {
|
||||||
|
val, ok := m.users.Load(username)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := val.(*User)
|
||||||
|
user.Password = newPassword
|
||||||
|
m.users.Store(username, user)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers возвращает список всех пользователей
|
||||||
|
func (m *ACLManager) ListUsers() []string {
|
||||||
|
users := make([]string, 0)
|
||||||
|
m.users.Range(func(key, value interface{}) bool {
|
||||||
|
users = append(users, key.(string))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return users
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRoles возвращает список всех ролей
|
||||||
|
func (m *ACLManager) ListRoles() []string {
|
||||||
|
roles := make([]string, 0)
|
||||||
|
m.roles.Range(func(key, value interface{}) bool {
|
||||||
|
roles = append(roles, key.(string))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return roles
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserInfo возвращает информацию о пользователе
|
||||||
|
func (m *ACLManager) GetUserInfo(username string) (*User, error) {
|
||||||
|
val, ok := m.users.Load(username)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := val.(*User)
|
||||||
|
// Возвращаем копию, чтобы избежать модификации извне
|
||||||
|
return &User{
|
||||||
|
ID: user.ID,
|
||||||
|
Username: user.Username,
|
||||||
|
Roles: user.Roles,
|
||||||
|
CreatedAt: user.CreatedAt,
|
||||||
|
LastLogin: user.LastLogin,
|
||||||
|
Active: user.Active,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRolePermissions возвращает разрешения роли
|
||||||
|
func (m *ACLManager) GetRolePermissions(roleName string) ([]string, error) {
|
||||||
|
val, ok := m.roles.Load(roleName)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("role not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
role := val.(*Role)
|
||||||
|
return role.Permissions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
func splitPermission(perm string) []string {
|
||||||
|
for i := 0; i < len(perm); i++ {
|
||||||
|
if perm[i] == ':' {
|
||||||
|
return []string{perm[:i], perm[i+1:]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []string{perm, ""}
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitResource(resource string) []string {
|
||||||
|
for i := 0; i < len(resource); i++ {
|
||||||
|
if resource[i] == '.' {
|
||||||
|
return []string{resource[:i], resource[i+1:]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []string{resource, "*"}
|
||||||
|
}
|
||||||
672
internal/api/http.go
Normal file
672
internal/api/http.go
Normal file
@@ -0,0 +1,672 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/api/http.go
|
||||||
|
// Назначение: HTTP RESTful API для взаимодействия с СУБД через curl.
|
||||||
|
// Поддерживает CRUD операции, управление индексами, ACL и ограничениями.
|
||||||
|
// Реализован с минимальными блокировками, использует wait-free структуры.
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"futriis/internal/acl"
|
||||||
|
"futriis/internal/cluster"
|
||||||
|
"futriis/internal/log"
|
||||||
|
"futriis/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HTTPServer struct {
|
||||||
|
store *storage.Storage
|
||||||
|
coordinator *cluster.RaftCoordinator
|
||||||
|
aclManager *acl.ACLManager
|
||||||
|
logger *log.Logger
|
||||||
|
server *http.Server
|
||||||
|
port int
|
||||||
|
}
|
||||||
|
|
||||||
|
type APIResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Data interface{} `json:"data,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTTPServer создаёт новый HTTP сервер (добавлен CORS middleware)
|
||||||
|
func NewHTTPServer(port int, store *storage.Storage, coord *cluster.RaftCoordinator, aclMgr *acl.ACLManager, logger *log.Logger) *HTTPServer {
|
||||||
|
s := &HTTPServer{
|
||||||
|
store: store,
|
||||||
|
coordinator: coord,
|
||||||
|
aclManager: aclMgr,
|
||||||
|
logger: logger,
|
||||||
|
port: port,
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// CORS middleware wrapper
|
||||||
|
corsHandler := func(handler http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Session-ID")
|
||||||
|
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handler(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware для аутентификации
|
||||||
|
mux.HandleFunc("/api/auth/login", corsHandler(s.handleLogin))
|
||||||
|
mux.HandleFunc("/api/auth/logout", corsHandler(s.handleLogout))
|
||||||
|
|
||||||
|
// CRUD операции
|
||||||
|
mux.HandleFunc("/api/db/", corsHandler(s.handleDatabaseRequest))
|
||||||
|
|
||||||
|
// Индексы
|
||||||
|
mux.HandleFunc("/api/index/", corsHandler(s.handleIndexRequest))
|
||||||
|
|
||||||
|
// ACL
|
||||||
|
mux.HandleFunc("/api/acl/", corsHandler(s.handleACLRequest))
|
||||||
|
|
||||||
|
// Constraints
|
||||||
|
mux.HandleFunc("/api/constraint/", corsHandler(s.handleConstraintRequest))
|
||||||
|
|
||||||
|
// Cluster
|
||||||
|
mux.HandleFunc("/api/cluster/", corsHandler(s.handleClusterRequest))
|
||||||
|
|
||||||
|
s.server = &http.Server{
|
||||||
|
Addr: fmt.Sprintf(":%d", port),
|
||||||
|
Handler: mux,
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start запускает HTTP сервер
|
||||||
|
func (s *HTTPServer) Start() error {
|
||||||
|
s.logger.Info("Starting HTTP API server on port " + strconv.Itoa(s.port))
|
||||||
|
return s.server.ListenAndServe()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop останавливает HTTP сервер
|
||||||
|
func (s *HTTPServer) Stop() error {
|
||||||
|
return s.server.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLogin обрабатывает аутентификацию
|
||||||
|
func (s *HTTPServer) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
s.sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var creds struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&creds); err != nil {
|
||||||
|
s.sendError(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID, err := s.aclManager.Authenticate(creds.Username, creds.Password)
|
||||||
|
if err != nil {
|
||||||
|
s.sendError(w, err.Error(), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sendSuccess(w, map[string]string{"session_id": sessionID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLogout обрабатывает выход
|
||||||
|
func (s *HTTPServer) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sessionID := r.Header.Get("X-Session-ID")
|
||||||
|
if sessionID != "" {
|
||||||
|
s.aclManager.Logout(sessionID)
|
||||||
|
}
|
||||||
|
s.sendSuccess(w, map[string]string{"status": "logged out"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDatabaseRequest обрабатывает запросы к БД
|
||||||
|
func (s *HTTPServer) handleDatabaseRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// URL: /api/db/{database}/{collection}/{document_id}
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api/db/")
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
|
||||||
|
if len(parts) < 2 {
|
||||||
|
s.sendError(w, "Invalid path. Use /api/db/{database}/{collection}[/{id}]", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
database := parts[0]
|
||||||
|
collection := parts[1]
|
||||||
|
docID := ""
|
||||||
|
if len(parts) > 2 {
|
||||||
|
docID = parts[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка аутентификации
|
||||||
|
sessionID := r.Header.Get("X-Session-ID")
|
||||||
|
if sessionID == "" {
|
||||||
|
s.sendError(w, "Authentication required", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
s.handleGetDocument(w, r, sessionID, database, collection, docID)
|
||||||
|
case http.MethodPost:
|
||||||
|
s.handleInsertDocument(w, r, sessionID, database, collection)
|
||||||
|
case http.MethodPut:
|
||||||
|
s.handleUpdateDocument(w, r, sessionID, database, collection, docID)
|
||||||
|
case http.MethodDelete:
|
||||||
|
s.handleDeleteDocument(w, r, sessionID, database, collection, docID)
|
||||||
|
default:
|
||||||
|
s.sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetDocument обрабатывает GET запросы
|
||||||
|
func (s *HTTPServer) handleGetDocument(w http.ResponseWriter, r *http.Request, sessionID, database, collection, docID string) {
|
||||||
|
// Проверка прав
|
||||||
|
if !s.aclManager.CheckPermission(sessionID, database, collection, "read") {
|
||||||
|
s.sendError(w, "Access denied", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := s.store.GetDatabase(database)
|
||||||
|
if err != nil {
|
||||||
|
s.sendError(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
coll, err := db.GetCollection(collection)
|
||||||
|
if err != nil {
|
||||||
|
s.sendError(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск по индексу или ID
|
||||||
|
query := r.URL.Query()
|
||||||
|
if indexName := query.Get("index"); indexName != "" {
|
||||||
|
indexValue := query.Get("value")
|
||||||
|
docs, err := coll.FindByIndex(indexName, indexValue)
|
||||||
|
if err != nil {
|
||||||
|
s.sendError(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.sendSuccess(w, docs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if docID == "" {
|
||||||
|
// Возвращаем все документы (с пагинацией)
|
||||||
|
limit := 100
|
||||||
|
if limitStr := query.Get("limit"); limitStr != "" {
|
||||||
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 {
|
||||||
|
limit = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := 0
|
||||||
|
if offsetStr := query.Get("offset"); offsetStr != "" {
|
||||||
|
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
|
||||||
|
offset = o
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allDocs := coll.GetAllDocuments()
|
||||||
|
start := offset
|
||||||
|
end := offset + limit
|
||||||
|
if start > len(allDocs) {
|
||||||
|
start = len(allDocs)
|
||||||
|
}
|
||||||
|
if end > len(allDocs) {
|
||||||
|
end = len(allDocs)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := allDocs[start:end]
|
||||||
|
s.sendSuccess(w, map[string]interface{}{
|
||||||
|
"documents": result,
|
||||||
|
"total": len(allDocs),
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, err := coll.Find(docID)
|
||||||
|
if err != nil {
|
||||||
|
s.sendError(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sendSuccess(w, doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleInsertDocument обрабатывает POST запросы
|
||||||
|
func (s *HTTPServer) handleInsertDocument(w http.ResponseWriter, r *http.Request, sessionID, database, collection string) {
|
||||||
|
if !s.aclManager.CheckPermission(sessionID, database, collection, "write") {
|
||||||
|
s.sendError(w, "Access denied", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := s.store.GetDatabase(database)
|
||||||
|
if err != nil {
|
||||||
|
// Создаём БД если не существует
|
||||||
|
if err := s.store.CreateDatabase(database); err != nil {
|
||||||
|
s.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, _ = s.store.GetDatabase(database)
|
||||||
|
}
|
||||||
|
|
||||||
|
coll, err := db.GetCollection(collection)
|
||||||
|
if err != nil {
|
||||||
|
if err := db.CreateCollection(collection); err != nil {
|
||||||
|
s.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
coll, _ = db.GetCollection(collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
var doc map[string]interface{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&doc); err != nil {
|
||||||
|
s.sendError(w, "Invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := coll.InsertFromMap(doc); err != nil {
|
||||||
|
s.sendError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sendSuccess(w, map[string]string{"status": "inserted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateDocument обрабатывает PUT запросы
|
||||||
|
func (s *HTTPServer) handleUpdateDocument(w http.ResponseWriter, r *http.Request, sessionID, database, collection, docID string) {
|
||||||
|
if docID == "" {
|
||||||
|
s.sendError(w, "Document ID required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.aclManager.CheckPermission(sessionID, database, collection, "write") {
|
||||||
|
s.sendError(w, "Access denied", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := s.store.GetDatabase(database)
|
||||||
|
if err != nil {
|
||||||
|
s.sendError(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
coll, err := db.GetCollection(collection)
|
||||||
|
if err != nil {
|
||||||
|
s.sendError(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var updates map[string]interface{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
||||||
|
s.sendError(w, "Invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := coll.Update(docID, updates); err != nil {
|
||||||
|
s.sendError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sendSuccess(w, map[string]string{"status": "updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteDocument обрабатывает DELETE запросы
|
||||||
|
func (s *HTTPServer) handleDeleteDocument(w http.ResponseWriter, r *http.Request, sessionID, database, collection, docID string) {
|
||||||
|
if docID == "" {
|
||||||
|
s.sendError(w, "Document ID required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.aclManager.CheckPermission(sessionID, database, collection, "delete") {
|
||||||
|
s.sendError(w, "Access denied", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := s.store.GetDatabase(database)
|
||||||
|
if err != nil {
|
||||||
|
s.sendError(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
coll, err := db.GetCollection(collection)
|
||||||
|
if err != nil {
|
||||||
|
s.sendError(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := coll.Delete(docID); err != nil {
|
||||||
|
s.sendError(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sendSuccess(w, map[string]string{"status": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleIndexRequest обрабатывает запросы к индексам
|
||||||
|
func (s *HTTPServer) handleIndexRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// URL: /api/index/{database}/{collection}/{action}
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api/index/")
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
|
||||||
|
if len(parts) < 3 {
|
||||||
|
s.sendError(w, "Invalid path. Use /api/index/{database}/{collection}/{action}", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
database := parts[0]
|
||||||
|
collection := parts[1]
|
||||||
|
action := parts[2]
|
||||||
|
|
||||||
|
sessionID := r.Header.Get("X-Session-ID")
|
||||||
|
if !s.aclManager.CheckPermission(sessionID, database, collection, "admin") {
|
||||||
|
s.sendError(w, "Admin access required", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := s.store.GetDatabase(database)
|
||||||
|
if err != nil {
|
||||||
|
s.sendError(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
coll, err := db.GetCollection(collection)
|
||||||
|
if err != nil {
|
||||||
|
s.sendError(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "list":
|
||||||
|
indexes := coll.GetIndexes()
|
||||||
|
s.sendSuccess(w, indexes)
|
||||||
|
|
||||||
|
case "create":
|
||||||
|
var req struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Fields []string `json:"fields"`
|
||||||
|
Unique bool `json:"unique"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
s.sendError(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := coll.CreateIndex(req.Name, req.Fields, req.Unique); err != nil {
|
||||||
|
s.sendError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.sendSuccess(w, map[string]string{"status": "index created"})
|
||||||
|
|
||||||
|
case "drop":
|
||||||
|
if len(parts) < 4 {
|
||||||
|
s.sendError(w, "Index name required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
indexName := parts[3]
|
||||||
|
if err := coll.DropIndex(indexName); err != nil {
|
||||||
|
s.sendError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.sendSuccess(w, map[string]string{"status": "index dropped"})
|
||||||
|
|
||||||
|
default:
|
||||||
|
s.sendError(w, "Unknown action", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleACLRequest обрабатывает запросы ACL
|
||||||
|
func (s *HTTPServer) handleACLRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api/acl/")
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
|
||||||
|
if len(parts) < 1 {
|
||||||
|
s.sendError(w, "Invalid path", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID := r.Header.Get("X-Session-ID")
|
||||||
|
if !s.aclManager.CheckPermission(sessionID, "*", "*", "admin") {
|
||||||
|
s.sendError(w, "Admin access required", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
action := parts[0]
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "users":
|
||||||
|
users := s.aclManager.ListUsers()
|
||||||
|
s.sendSuccess(w, users)
|
||||||
|
|
||||||
|
case "user":
|
||||||
|
if len(parts) < 2 {
|
||||||
|
s.sendError(w, "Username required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := parts[1]
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodPost:
|
||||||
|
var req struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
Roles []string `json:"roles"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
s.sendError(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.aclManager.CreateUser(username, req.Password, req.Roles); err != nil {
|
||||||
|
s.sendError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.sendSuccess(w, map[string]string{"status": "user created"})
|
||||||
|
|
||||||
|
default:
|
||||||
|
s.sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "roles":
|
||||||
|
roles := s.aclManager.ListRoles()
|
||||||
|
s.sendSuccess(w, roles)
|
||||||
|
|
||||||
|
case "grant":
|
||||||
|
if len(parts) < 3 {
|
||||||
|
s.sendError(w, "Role and permission required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
roleName := parts[1]
|
||||||
|
permission := parts[2]
|
||||||
|
if err := s.aclManager.GrantPermission(roleName, permission); err != nil {
|
||||||
|
s.sendError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.sendSuccess(w, map[string]string{"status": "permission granted"})
|
||||||
|
|
||||||
|
default:
|
||||||
|
s.sendError(w, "Unknown action", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleConstraintRequest обрабатывает запросы к ограничениям
|
||||||
|
func (s *HTTPServer) handleConstraintRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api/constraint/")
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
|
||||||
|
if len(parts) < 3 {
|
||||||
|
s.sendError(w, "Invalid path. Use /api/constraint/{database}/{collection}/{action}", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
database := parts[0]
|
||||||
|
collection := parts[1]
|
||||||
|
action := parts[2]
|
||||||
|
|
||||||
|
sessionID := r.Header.Get("X-Session-ID")
|
||||||
|
if !s.aclManager.CheckPermission(sessionID, database, collection, "admin") {
|
||||||
|
s.sendError(w, "Admin access required", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := s.store.GetDatabase(database)
|
||||||
|
if err != nil {
|
||||||
|
s.sendError(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
coll, err := db.GetCollection(collection)
|
||||||
|
if err != nil {
|
||||||
|
s.sendError(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "required":
|
||||||
|
if len(parts) < 4 {
|
||||||
|
s.sendError(w, "Field name required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
field := parts[3]
|
||||||
|
coll.AddRequiredField(field)
|
||||||
|
s.sendSuccess(w, map[string]string{"status": "required field added"})
|
||||||
|
|
||||||
|
case "unique":
|
||||||
|
if len(parts) < 4 {
|
||||||
|
s.sendError(w, "Field name required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
field := parts[3]
|
||||||
|
coll.AddUniqueConstraint(field)
|
||||||
|
s.sendSuccess(w, map[string]string{"status": "unique constraint added"})
|
||||||
|
|
||||||
|
case "min":
|
||||||
|
if len(parts) < 5 {
|
||||||
|
s.sendError(w, "Field name and value required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
field := parts[3]
|
||||||
|
minVal, _ := strconv.ParseFloat(parts[4], 64)
|
||||||
|
coll.AddMinConstraint(field, minVal)
|
||||||
|
s.sendSuccess(w, map[string]string{"status": "min constraint added"})
|
||||||
|
|
||||||
|
case "max":
|
||||||
|
if len(parts) < 5 {
|
||||||
|
s.sendError(w, "Field name and value required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
field := parts[3]
|
||||||
|
maxVal, _ := strconv.ParseFloat(parts[4], 64)
|
||||||
|
coll.AddMaxConstraint(field, maxVal)
|
||||||
|
s.sendSuccess(w, map[string]string{"status": "max constraint added"})
|
||||||
|
|
||||||
|
default:
|
||||||
|
s.sendError(w, "Unknown action", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleClusterRequest обрабатывает запросы к кластеру (исправлено: поддержка разных методов)
|
||||||
|
func (s *HTTPServer) handleClusterRequest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sessionID := r.Header.Get("X-Session-ID")
|
||||||
|
if !s.aclManager.CheckPermission(sessionID, "*", "*", "admin") {
|
||||||
|
s.sendError(w, "Admin access required", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.coordinator == nil {
|
||||||
|
s.sendError(w, "Cluster not available", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api/cluster/")
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
if len(parts) == 0 || parts[0] == "" || parts[0] == "status" {
|
||||||
|
status := s.coordinator.GetClusterStatus()
|
||||||
|
s.sendSuccess(w, status)
|
||||||
|
} else if parts[0] == "health" {
|
||||||
|
health := s.coordinator.GetClusterHealth()
|
||||||
|
s.sendSuccess(w, health)
|
||||||
|
} else if parts[0] == "nodes" {
|
||||||
|
nodes := s.coordinator.GetAllNodes()
|
||||||
|
s.sendSuccess(w, nodes)
|
||||||
|
} else {
|
||||||
|
s.sendError(w, "Unknown cluster endpoint", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
case http.MethodPost:
|
||||||
|
if len(parts) >= 2 && parts[0] == "replication" && parts[1] == "factor" {
|
||||||
|
var req struct {
|
||||||
|
Factor int `json:"factor"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
s.sendError(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Factor < 1 || req.Factor > 5 {
|
||||||
|
s.sendError(w, "Replication factor must be between 1 and 5", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.coordinator.SetReplicationFactor(req.Factor); err != nil {
|
||||||
|
s.sendError(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.sendSuccess(w, map[string]interface{}{
|
||||||
|
"status": "updated",
|
||||||
|
"factor": req.Factor,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
s.sendError(w, "Unknown cluster endpoint", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
s.sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendSuccess отправляет успешный ответ
|
||||||
|
func (s *HTTPServer) sendSuccess(w http.ResponseWriter, data interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(APIResponse{
|
||||||
|
Success: true,
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendError отправляет ответ с ошибкой
|
||||||
|
func (s *HTTPServer) sendError(w http.ResponseWriter, errMsg string, statusCode int) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(statusCode)
|
||||||
|
json.NewEncoder(w).Encode(APIResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: errMsg,
|
||||||
|
})
|
||||||
|
}
|
||||||
924
internal/api/static/app.js
Normal file
924
internal/api/static/app.js
Normal file
@@ -0,0 +1,924 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/api/static/app.js
|
||||||
|
// JavaScript для веб-интерфейса Futriis DB Dashboard
|
||||||
|
|
||||||
|
// Глобальное состояние
|
||||||
|
let currentSession = null;
|
||||||
|
let currentDatabase = null;
|
||||||
|
let currentCollection = null;
|
||||||
|
let currentUser = null;
|
||||||
|
|
||||||
|
// DOM элементы
|
||||||
|
const contentArea = document.getElementById('contentArea');
|
||||||
|
const pageTitle = document.getElementById('pageTitle');
|
||||||
|
const connectionStatus = document.getElementById('connectionStatus');
|
||||||
|
const userInfoSpan = document.querySelector('#userInfo span');
|
||||||
|
const logoutBtn = document.getElementById('logoutBtn');
|
||||||
|
const menuToggle = document.getElementById('menuToggle');
|
||||||
|
const sidebar = document.querySelector('.sidebar');
|
||||||
|
const modal = document.getElementById('modal');
|
||||||
|
const modalTitle = document.getElementById('modalTitle');
|
||||||
|
const modalBody = document.getElementById('modalBody');
|
||||||
|
const modalConfirm = document.getElementById('modalConfirm');
|
||||||
|
const modalCloseBtns = document.querySelectorAll('.modal-close');
|
||||||
|
|
||||||
|
// Инициализация приложения
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
checkSession();
|
||||||
|
initNavigation();
|
||||||
|
initEventListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Проверка сессии
|
||||||
|
async function checkSession() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/webui/session');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.data.authenticated) {
|
||||||
|
currentUser = data.data.username;
|
||||||
|
userInfoSpan.textContent = currentUser;
|
||||||
|
connectionStatus.classList.add('online');
|
||||||
|
connectionStatus.classList.remove('offline');
|
||||||
|
loadDashboard();
|
||||||
|
} else {
|
||||||
|
showLoginModal();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Session check failed:', error);
|
||||||
|
showLoginModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показать модальное окно входа
|
||||||
|
function showLoginModal() {
|
||||||
|
modalTitle.textContent = 'Вход в систему';
|
||||||
|
modalBody.innerHTML = `
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Имя пользователя</label>
|
||||||
|
<input type="text" id="username" class="form-control" placeholder="Введите имя пользователя">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Пароль</label>
|
||||||
|
<input type="password" id="password" class="form-control" placeholder="Введите пароль">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
modal.classList.add('show');
|
||||||
|
|
||||||
|
const confirmHandler = async () => {
|
||||||
|
const username = document.getElementById('username').value;
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
showNotification('Пожалуйста, заполните все поля', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/webui/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
currentUser = username;
|
||||||
|
userInfoSpan.textContent = username;
|
||||||
|
modal.classList.remove('show');
|
||||||
|
showNotification('Вход выполнен успешно', 'success');
|
||||||
|
loadDashboard();
|
||||||
|
} else {
|
||||||
|
showNotification(data.error || 'Ошибка входа', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showNotification('Ошибка подключения к серверу', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
modalConfirm.onclick = confirmHandler;
|
||||||
|
|
||||||
|
// Обработка Enter
|
||||||
|
const handleEnter = (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
confirmHandler();
|
||||||
|
document.removeEventListener('keydown', handleEnter);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleEnter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация навигации
|
||||||
|
function initNavigation() {
|
||||||
|
// Обработка кликов по пунктам меню
|
||||||
|
document.querySelectorAll('.nav-link[data-section]').forEach(link => {
|
||||||
|
link.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const section = link.dataset.section;
|
||||||
|
loadSection(section);
|
||||||
|
setActiveNav(link);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обработка подменю CRUD
|
||||||
|
document.querySelectorAll('[data-action]').forEach(item => {
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const action = item.dataset.action;
|
||||||
|
handleCrudAction(action);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обработка раскрытия подменю
|
||||||
|
document.querySelectorAll('.has-submenu > .nav-link').forEach(link => {
|
||||||
|
link.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const parent = link.closest('.has-submenu');
|
||||||
|
parent.classList.toggle('open');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация обработчиков событий
|
||||||
|
function initEventListeners() {
|
||||||
|
// Выход
|
||||||
|
logoutBtn.addEventListener('click', async () => {
|
||||||
|
await fetch('/api/webui/logout', { method: 'POST' });
|
||||||
|
currentSession = null;
|
||||||
|
currentUser = null;
|
||||||
|
showLoginModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Мобильное меню
|
||||||
|
if (menuToggle) {
|
||||||
|
menuToggle.addEventListener('click', () => {
|
||||||
|
sidebar.classList.toggle('open');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрытие модального окна
|
||||||
|
modalCloseBtns.forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
modal.classList.remove('show');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Закрытие модального окна по клику вне его
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) {
|
||||||
|
modal.classList.remove('show');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка секции
|
||||||
|
async function loadSection(section) {
|
||||||
|
switch(section) {
|
||||||
|
case 'dashboard':
|
||||||
|
loadDashboard();
|
||||||
|
break;
|
||||||
|
case 'cluster':
|
||||||
|
loadClusterManagement();
|
||||||
|
break;
|
||||||
|
case 'audit':
|
||||||
|
loadAuditLog();
|
||||||
|
break;
|
||||||
|
case 'settings':
|
||||||
|
loadSettings();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
loadDashboard();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка дашборда
|
||||||
|
async function loadDashboard() {
|
||||||
|
pageTitle.textContent = 'Панель управления';
|
||||||
|
contentArea.innerHTML = '<div class="loading-spinner"><i class="fas fa-spinner fa-pulse"></i><p>Загрузка данных...</p></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [statsRes, dbsRes] = await Promise.all([
|
||||||
|
fetch('/api/webui/stats'),
|
||||||
|
fetch('/api/webui/databases')
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stats = await statsRes.json();
|
||||||
|
const databases = await dbsRes.json();
|
||||||
|
|
||||||
|
contentArea.innerHTML = `
|
||||||
|
<div class="dashboard-stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon"><i class="fas fa-database"></i></div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>${stats.data.databases || 0}</h3>
|
||||||
|
<p>Базы данных</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon"><i class="fas fa-table"></i></div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>${stats.data.collections || 0}</h3>
|
||||||
|
<p>Коллекции</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon"><i class="fas fa-file-alt"></i></div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>${stats.data.documents || 0}</h3>
|
||||||
|
<p>Документы</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon"><i class="fas fa-hdd"></i></div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>${stats.data.storage_used_mb?.toFixed(2) || 0} MB</h3>
|
||||||
|
<p>Использовано памяти</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-table">
|
||||||
|
<h3 style="margin-bottom: 16px;">Базы данных</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Имя БД</th><th>Коллекции</th><th>Действия</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${databases.data.map(db => `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${escapeHtml(db.name)}</strong></td>
|
||||||
|
<td>${db.collections}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="viewDatabase('${escapeHtml(db.name)}')">
|
||||||
|
<i class="fas fa-eye"></i> Просмотр
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch (error) {
|
||||||
|
contentArea.innerHTML = '<div class="error-message">Ошибка загрузки данных</div>';
|
||||||
|
showNotification('Ошибка загрузки дашборда', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Просмотр базы данных
|
||||||
|
window.viewDatabase = async function(dbName) {
|
||||||
|
currentDatabase = dbName;
|
||||||
|
pageTitle.textContent = `База данных: ${dbName}`;
|
||||||
|
contentArea.innerHTML = '<div class="loading-spinner"><i class="fas fa-spinner fa-pulse"></i><p>Загрузка коллекций...</p></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/webui/collections/${dbName}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
contentArea.innerHTML = `
|
||||||
|
<div class="data-table">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||||
|
<h3>Коллекции</h3>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="showCreateCollectionModal()">
|
||||||
|
<i class="fas fa-plus"></i> Создать коллекцию
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Имя коллекции</th><th>Документов</th><th>Размер</th><th>Индексы</th><th>Действия</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${data.data.collections.map(coll => `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${escapeHtml(coll.name)}</strong></td>
|
||||||
|
<td>${coll.count}</td>
|
||||||
|
<td>${(coll.size / 1024).toFixed(2)} KB</td>
|
||||||
|
<td>${coll.indexes.length}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="viewCollection('${escapeHtml(dbName)}', '${escapeHtml(coll.name)}')">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="deleteCollection('${escapeHtml(dbName)}', '${escapeHtml(coll.name)}')">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
contentArea.innerHTML = '<div class="error-message">Ошибка загрузки коллекций</div>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
contentArea.innerHTML = '<div class="error-message">Ошибка подключения</div>';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Просмотр коллекции
|
||||||
|
window.viewCollection = async function(dbName, collName) {
|
||||||
|
currentDatabase = dbName;
|
||||||
|
currentCollection = collName;
|
||||||
|
pageTitle.textContent = `Коллекция: ${dbName}.${collName}`;
|
||||||
|
contentArea.innerHTML = '<div class="loading-spinner"><i class="fas fa-spinner fa-pulse"></i><p>Загрузка документов...</p></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/webui/documents/${dbName}/${collName}?limit=100`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
contentArea.innerHTML = `
|
||||||
|
<div style="margin-bottom: 16px; display: flex; gap: 12px; flex-wrap: wrap;">
|
||||||
|
<button class="btn btn-primary" onclick="showInsertDocumentModal()">
|
||||||
|
<i class="fas fa-plus"></i> Вставить документ
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" onclick="viewDatabase('${escapeHtml(dbName)}')">
|
||||||
|
<i class="fas fa-arrow-left"></i> Назад
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-table">
|
||||||
|
<h3 style="margin-bottom: 16px;">Документы (${data.data.total} всего)</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>ID</th><th>Поля</th><th>Создан</th><th>Действия</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${data.data.documents.map(doc => `
|
||||||
|
<tr>
|
||||||
|
<td><code>${escapeHtml(doc.id)}</code></td>
|
||||||
|
<td><pre style="max-width: 400px; overflow-x: auto;">${escapeHtml(JSON.stringify(doc.fields, null, 2))}</pre></td>
|
||||||
|
<td>${new Date(doc.created_at).toLocaleString()}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-secondary" onclick="showUpdateDocumentModal('${escapeHtml(doc.id)}', ${escapeHtml(JSON.stringify(doc.fields))})">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="deleteDocument('${escapeHtml(dbName)}', '${escapeHtml(collName)}', '${escapeHtml(doc.id)}')">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
contentArea.innerHTML = '<div class="error-message">Ошибка загрузки документов</div>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
contentArea.innerHTML = '<div class="error-message">Ошибка подключения</div>';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Загрузка управления кластером
|
||||||
|
async function loadClusterManagement() {
|
||||||
|
pageTitle.textContent = 'Управление кластером';
|
||||||
|
contentArea.innerHTML = '<div class="loading-spinner"><i class="fas fa-spinner fa-pulse"></i><p>Загрузка информации о кластере...</p></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [statusRes, nodesRes] = await Promise.all([
|
||||||
|
fetch('/api/webui/cluster/status'),
|
||||||
|
fetch('/api/webui/cluster/nodes')
|
||||||
|
]);
|
||||||
|
|
||||||
|
const status = await statusRes.json();
|
||||||
|
const nodes = await nodesRes.json();
|
||||||
|
|
||||||
|
contentArea.innerHTML = `
|
||||||
|
<div class="dashboard-stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon"><i class="fas fa-heartbeat"></i></div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3 style="color: ${status.data.health === 'healthy' ? '#28a745' : status.data.health === 'degraded' ? '#ffc107' : '#dc3545'}">
|
||||||
|
${status.data.health === 'healthy' ? 'Здоров' : status.data.health === 'degraded' ? 'Деградирован' : 'Критический'}
|
||||||
|
</h3>
|
||||||
|
<p>Состояние кластера</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon"><i class="fas fa-server"></i></div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>${status.data.active_nodes}/${status.data.total_nodes}</h3>
|
||||||
|
<p>Активные узлы</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon"><i class="fas fa-copy"></i></div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<h3>${status.data.replication_factor}</h3>
|
||||||
|
<p>Фактор репликации</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-table">
|
||||||
|
<h3 style="margin-bottom: 16px;">Узлы кластера</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>ID узла</th><th>Адрес</th><th>Статус</th><th>Последний контакт</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${nodes.data.map(node => `
|
||||||
|
<tr>
|
||||||
|
<td><code>${escapeHtml(node.id)}</code></td>
|
||||||
|
<td>${escapeHtml(node.ip)}:${node.port}</td>
|
||||||
|
<td><span class="status-badge status-${node.status}">${node.status}</span></td>
|
||||||
|
<td>${new Date(node.last_seen * 1000).toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} catch (error) {
|
||||||
|
contentArea.innerHTML = '<div class="error-message">Ошибка загрузки информации о кластере</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка лога аудита
|
||||||
|
async function loadAuditLog() {
|
||||||
|
pageTitle.textContent = 'Лог аудита';
|
||||||
|
contentArea.innerHTML = '<div class="loading-spinner"><i class="fas fa-spinner fa-pulse"></i><p>Загрузка лога аудита...</p></div>';
|
||||||
|
// TODO: Реализовать API для получения лога аудита
|
||||||
|
contentArea.innerHTML = '<div class="info-message">Функция в разработке</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка настроек
|
||||||
|
function loadSettings() {
|
||||||
|
pageTitle.textContent = 'Настройки';
|
||||||
|
contentArea.innerHTML = `
|
||||||
|
<div class="settings-panel">
|
||||||
|
<h3>Настройки интерфейса</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Тема оформления</label>
|
||||||
|
<select class="form-control" id="themeSelect">
|
||||||
|
<option value="dark">Тёмная</option>
|
||||||
|
<option value="light">Светлая</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="saveSettings()">Сохранить настройки</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка CRUD действий
|
||||||
|
function handleCrudAction(action) {
|
||||||
|
switch(action) {
|
||||||
|
case 'create-db':
|
||||||
|
showCreateDatabaseModal();
|
||||||
|
break;
|
||||||
|
case 'create-collection':
|
||||||
|
showCreateCollectionModal();
|
||||||
|
break;
|
||||||
|
case 'insert-doc':
|
||||||
|
showInsertDocumentModal();
|
||||||
|
break;
|
||||||
|
case 'find-doc':
|
||||||
|
showFindDocumentModal();
|
||||||
|
break;
|
||||||
|
case 'update-doc':
|
||||||
|
showUpdateDocumentModal();
|
||||||
|
break;
|
||||||
|
case 'delete-doc':
|
||||||
|
showDeleteDocumentModal();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показать модальное окно создания БД
|
||||||
|
function showCreateDatabaseModal() {
|
||||||
|
modalTitle.textContent = 'Создать базу данных';
|
||||||
|
modalBody.innerHTML = `
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="dbName">Имя базы данных</label>
|
||||||
|
<input type="text" id="dbName" class="form-control" placeholder="my_database">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
modal.classList.add('show');
|
||||||
|
|
||||||
|
modalConfirm.onclick = async () => {
|
||||||
|
const dbName = document.getElementById('dbName').value;
|
||||||
|
if (!dbName) {
|
||||||
|
showNotification('Введите имя базы данных', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/db/' + dbName, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
modal.classList.remove('show');
|
||||||
|
showNotification(`База данных "${dbName}" создана`, 'success');
|
||||||
|
loadDashboard();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
showNotification(error.error || 'Ошибка создания БД', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showNotification('Ошибка подключения', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показать модальное окно создания коллекции
|
||||||
|
function showCreateCollectionModal() {
|
||||||
|
if (!currentDatabase) {
|
||||||
|
showNotification('Сначала выберите базу данных', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modalTitle.textContent = 'Создать коллекцию';
|
||||||
|
modalBody.innerHTML = `
|
||||||
|
<div class="form-group">
|
||||||
|
<label>База данных</label>
|
||||||
|
<input type="text" class="form-control" value="${escapeHtml(currentDatabase)}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="collName">Имя коллекции</label>
|
||||||
|
<input type="text" id="collName" class="form-control" placeholder="my_collection">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
modal.classList.add('show');
|
||||||
|
|
||||||
|
modalConfirm.onclick = async () => {
|
||||||
|
const collName = document.getElementById('collName').value;
|
||||||
|
if (!collName) {
|
||||||
|
showNotification('Введите имя коллекции', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/db/${currentDatabase}/${collName}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
modal.classList.remove('show');
|
||||||
|
showNotification(`Коллекция "${collName}" создана`, 'success');
|
||||||
|
viewDatabase(currentDatabase);
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
showNotification(error.error || 'Ошибка создания коллекции', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showNotification('Ошибка подключения', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показать модальное окно вставки документа
|
||||||
|
function showInsertDocumentModal() {
|
||||||
|
if (!currentDatabase || !currentCollection) {
|
||||||
|
showNotification('Сначала выберите базу данных и коллекцию', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modalTitle.textContent = 'Вставить документ';
|
||||||
|
modalBody.innerHTML = `
|
||||||
|
<div class="form-group">
|
||||||
|
<label>База данных</label>
|
||||||
|
<input type="text" class="form-control" value="${escapeHtml(currentDatabase)}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Коллекция</label>
|
||||||
|
<input type="text" class="form-control" value="${escapeHtml(currentCollection)}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="docData">Данные документа (JSON)</label>
|
||||||
|
<textarea id="docData" class="form-control" rows="8" placeholder='{"_id": "doc1", "name": "Example", "value": 123}'></textarea>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
modal.classList.add('show');
|
||||||
|
|
||||||
|
modalConfirm.onclick = async () => {
|
||||||
|
const docData = document.getElementById('docData').value;
|
||||||
|
if (!docData) {
|
||||||
|
showNotification('Введите данные документа', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(docData);
|
||||||
|
const response = await fetch(`/api/webui/documents/${currentDatabase}/${currentCollection}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
modal.classList.remove('show');
|
||||||
|
showNotification('Документ вставлен', 'success');
|
||||||
|
viewCollection(currentDatabase, currentCollection);
|
||||||
|
} else {
|
||||||
|
showNotification(result.error || 'Ошибка вставки документа', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SyntaxError) {
|
||||||
|
showNotification('Неверный формат JSON', 'error');
|
||||||
|
} else {
|
||||||
|
showNotification('Ошибка подключения', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показать модальное окно поиска документа
|
||||||
|
function showFindDocumentModal() {
|
||||||
|
if (!currentDatabase || !currentCollection) {
|
||||||
|
showNotification('Сначала выберите базу данных и коллекцию', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modalTitle.textContent = 'Найти документ';
|
||||||
|
modalBody.innerHTML = `
|
||||||
|
<div class="form-group">
|
||||||
|
<label>База данных</label>
|
||||||
|
<input type="text" class="form-control" value="${escapeHtml(currentDatabase)}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Коллекция</label>
|
||||||
|
<input type="text" class="form-control" value="${escapeHtml(currentCollection)}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="docId">ID документа</label>
|
||||||
|
<input type="text" id="docId" class="form-control" placeholder="document_id">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
modal.classList.add('show');
|
||||||
|
|
||||||
|
modalConfirm.onclick = async () => {
|
||||||
|
const docId = document.getElementById('docId').value;
|
||||||
|
if (!docId) {
|
||||||
|
showNotification('Введите ID документа', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/db/${currentDatabase}/${currentCollection}/${docId}`);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
modal.classList.remove('show');
|
||||||
|
|
||||||
|
// Показать результат поиска
|
||||||
|
contentArea.innerHTML = `
|
||||||
|
<div class="data-table">
|
||||||
|
<h3>Результат поиска</h3>
|
||||||
|
<pre style="background: var(--bg-dark); padding: 16px; border-radius: 8px; overflow-x: auto;">
|
||||||
|
${escapeHtml(JSON.stringify(data.data, null, 2))}
|
||||||
|
</pre>
|
||||||
|
<button class="btn btn-secondary" onclick="viewCollection('${escapeHtml(currentDatabase)}', '${escapeHtml(currentCollection)}')">
|
||||||
|
<i class="fas fa-arrow-left"></i> Назад
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
showNotification(error.error || 'Документ не найден', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showNotification('Ошибка подключения', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показать модальное окно обновления документа
|
||||||
|
function showUpdateDocumentModal(docId, currentFields = null) {
|
||||||
|
if (!currentDatabase || !currentCollection) {
|
||||||
|
showNotification('Сначала выберите базу данных и коллекцию', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldsJson = currentFields ? JSON.stringify(currentFields, null, 2) : '';
|
||||||
|
|
||||||
|
modalTitle.textContent = 'Обновить документ';
|
||||||
|
modalBody.innerHTML = `
|
||||||
|
<div class="form-group">
|
||||||
|
<label>База данных</label>
|
||||||
|
<input type="text" class="form-control" value="${escapeHtml(currentDatabase)}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Коллекция</label>
|
||||||
|
<input type="text" class="form-control" value="${escapeHtml(currentCollection)}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="updateDocId">ID документа</label>
|
||||||
|
<input type="text" id="updateDocId" class="form-control" value="${escapeHtml(docId || '')}" ${docId ? 'disabled' : ''} placeholder="document_id">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="updateData">Обновления (JSON)</label>
|
||||||
|
<textarea id="updateData" class="form-control" rows="8" placeholder='{"field1": "new value", "field2": 456}'>${escapeHtml(fieldsJson)}</textarea>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
modal.classList.add('show');
|
||||||
|
|
||||||
|
modalConfirm.onclick = async () => {
|
||||||
|
const updateDocId = document.getElementById('updateDocId').value;
|
||||||
|
const updateData = document.getElementById('updateData').value;
|
||||||
|
|
||||||
|
if (!updateDocId) {
|
||||||
|
showNotification('Введите ID документа', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!updateData) {
|
||||||
|
showNotification('Введите данные для обновления', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(updateData);
|
||||||
|
const response = await fetch(`/api/webui/documents/${currentDatabase}/${currentCollection}?id=${encodeURIComponent(updateDocId)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
modal.classList.remove('show');
|
||||||
|
showNotification('Документ обновлён', 'success');
|
||||||
|
viewCollection(currentDatabase, currentCollection);
|
||||||
|
} else {
|
||||||
|
showNotification(result.error || 'Ошибка обновления документа', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SyntaxError) {
|
||||||
|
showNotification('Неверный формат JSON', 'error');
|
||||||
|
} else {
|
||||||
|
showNotification('Ошибка подключения', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показать модальное окно удаления документа
|
||||||
|
function showDeleteDocumentModal() {
|
||||||
|
if (!currentDatabase || !currentCollection) {
|
||||||
|
showNotification('Сначала выберите базу данных и коллекцию', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modalTitle.textContent = 'Удалить документ';
|
||||||
|
modalBody.innerHTML = `
|
||||||
|
<div class="form-group">
|
||||||
|
<label>База данных</label>
|
||||||
|
<input type="text" class="form-control" value="${escapeHtml(currentDatabase)}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Коллекция</label>
|
||||||
|
<input type="text" class="form-control" value="${escapeHtml(currentCollection)}" disabled>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="deleteDocId">ID документа</label>
|
||||||
|
<input type="text" id="deleteDocId" class="form-control" placeholder="document_id">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
modal.classList.add('show');
|
||||||
|
|
||||||
|
modalConfirm.onclick = async () => {
|
||||||
|
const docId = document.getElementById('deleteDocId').value;
|
||||||
|
if (!docId) {
|
||||||
|
showNotification('Введите ID документа', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/webui/documents/${currentDatabase}/${currentCollection}?id=${encodeURIComponent(docId)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
modal.classList.remove('show');
|
||||||
|
showNotification('Документ удалён', 'success');
|
||||||
|
viewCollection(currentDatabase, currentCollection);
|
||||||
|
} else {
|
||||||
|
showNotification(result.error || 'Ошибка удаления документа', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showNotification('Ошибка подключения', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление коллекции
|
||||||
|
window.deleteCollection = async function(dbName, collName) {
|
||||||
|
if (confirm(`Вы уверены, что хотите удалить коллекцию "${collName}"? Это действие необратимо.`)) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/db/${dbName}/${collName}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showNotification(`Коллекция "${collName}" удалена`, 'success');
|
||||||
|
viewDatabase(dbName);
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
showNotification(error.error || 'Ошибка удаления коллекции', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showNotification('Ошибка подключения', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Удаление документа
|
||||||
|
window.deleteDocument = async function(dbName, collName, docId) {
|
||||||
|
if (confirm(`Вы уверены, что хотите удалить документ "${docId}"?`)) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/webui/documents/${dbName}/${collName}?id=${encodeURIComponent(docId)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showNotification('Документ удалён', 'success');
|
||||||
|
viewCollection(dbName, collName);
|
||||||
|
} else {
|
||||||
|
showNotification(result.error || 'Ошибка удаления документа', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showNotification('Ошибка подключения', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Сохранение настроек
|
||||||
|
function saveSettings() {
|
||||||
|
const theme = document.getElementById('themeSelect')?.value;
|
||||||
|
if (theme) {
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
showNotification('Настройки сохранены', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Утилиты
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return str
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNotification(message, type = 'info') {
|
||||||
|
const container = document.getElementById('notificationContainer');
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = `notification ${type}`;
|
||||||
|
|
||||||
|
let icon = '';
|
||||||
|
switch(type) {
|
||||||
|
case 'success': icon = '<i class="fas fa-check-circle"></i>'; break;
|
||||||
|
case 'error': icon = '<i class="fas fa-exclamation-circle"></i>'; break;
|
||||||
|
case 'warning': icon = '<i class="fas fa-exclamation-triangle"></i>'; break;
|
||||||
|
default: icon = '<i class="fas fa-info-circle"></i>';
|
||||||
|
}
|
||||||
|
|
||||||
|
notification.innerHTML = `${icon}<span>${escapeHtml(message)}</span>`;
|
||||||
|
container.appendChild(notification);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.style.animation = 'slideOutRight 0.3s ease';
|
||||||
|
setTimeout(() => notification.remove(), 300);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveNav(activeLink) {
|
||||||
|
document.querySelectorAll('.nav-link').forEach(link => {
|
||||||
|
link.classList.remove('active');
|
||||||
|
});
|
||||||
|
activeLink.classList.add('active');
|
||||||
|
}
|
||||||
695
internal/api/static/style.css
Normal file
695
internal/api/static/style.css
Normal file
@@ -0,0 +1,695 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Файл: internal/api/static/style.css */
|
||||||
|
/* Стили для веб-интерфейса Futriis DB Dashboard */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary-color: #00bfff;
|
||||||
|
--primary-dark: #0099cc;
|
||||||
|
--secondary-color: #6c757d;
|
||||||
|
--success-color: #28a745;
|
||||||
|
--danger-color: #dc3545;
|
||||||
|
--warning-color: #ffc107;
|
||||||
|
--info-color: #17a2b8;
|
||||||
|
|
||||||
|
--bg-dark: #1a1a2e;
|
||||||
|
--bg-sidebar: #16213e;
|
||||||
|
--bg-card: #0f3460;
|
||||||
|
--bg-hover: #1a1f3a;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #b8c6db;
|
||||||
|
--border-color: #2d3a5e;
|
||||||
|
|
||||||
|
--shadow-sm: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
--shadow-md: 0 4px 8px rgba(0,0,0,0.15);
|
||||||
|
--shadow-lg: 0 8px 16px rgba(0,0,0,0.2);
|
||||||
|
|
||||||
|
--transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: var(--bg-dark);
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard Container */
|
||||||
|
.dashboard-container {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar - Вертикальное меню */
|
||||||
|
.sidebar {
|
||||||
|
width: 280px;
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: var(--transition);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: 100;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 24px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo i {
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo span {
|
||||||
|
background: linear-gradient(135deg, var(--primary-color), #00ffcc);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-toggle {
|
||||||
|
display: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation Menu */
|
||||||
|
.nav-menu {
|
||||||
|
flex: 1;
|
||||||
|
list-style: none;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: var(--transition);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link i {
|
||||||
|
width: 24px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link span {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submenu */
|
||||||
|
.has-submenu > .nav-link {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-submenu > .nav-link .fa-chevron-down {
|
||||||
|
margin-left: auto;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-submenu.open > .nav-link .fa-chevron-down {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 56px;
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.has-submenu.open .submenu {
|
||||||
|
max-height: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu li a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: var(--transition);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu li a:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu li a i {
|
||||||
|
width: 20px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar Footer */
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 20px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-dark);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info i {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--danger-color);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn:hover {
|
||||||
|
background: #c82333;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Content */
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar {
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
padding: 16px 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--bg-dark);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status i {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status.online i {
|
||||||
|
color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status.offline i {
|
||||||
|
color: var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard Cards */
|
||||||
|
.dashboard-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
background: rgba(0, 191, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon i {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-info h3 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-info p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th,
|
||||||
|
.data-table td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
background: var(--bg-sidebar);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tr:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-dark);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 191, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.form-control {
|
||||||
|
min-height: 120px;
|
||||||
|
font-family: monospace;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--primary-dark);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--secondary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--danger-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
z-index: 1000;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.show {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: modalSlideIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalSlideIn {
|
||||||
|
from {
|
||||||
|
transform: translateY(-50px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Notifications */
|
||||||
|
.notification-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 1100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
animation: slideInRight 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.success {
|
||||||
|
border-left: 4px solid var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.error {
|
||||||
|
border-left: 4px solid var(--danger-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.warning {
|
||||||
|
border-left: 4px solid var(--warning-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.info {
|
||||||
|
border-left: 4px solid var(--info-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading Spinner */
|
||||||
|
.loading-spinner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner i {
|
||||||
|
font-size: 3rem;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner p {
|
||||||
|
margin-top: 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: -280px;
|
||||||
|
height: 100%;
|
||||||
|
transition: left 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-toggle {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th,
|
||||||
|
.data-table td {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
width: 95%;
|
||||||
|
margin: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.top-bar h1 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-info h3 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar Styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--primary-color);
|
||||||
|
}
|
||||||
636
internal/api/webui.go
Normal file
636
internal/api/webui.go
Normal file
@@ -0,0 +1,636 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/api/webui.go
|
||||||
|
// Назначение: Веб-интерфейс в стиле dashboard для управления СУБД futriis
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"futriis/internal/acl"
|
||||||
|
"futriis/internal/cluster"
|
||||||
|
"futriis/internal/log"
|
||||||
|
"futriis/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed static/*
|
||||||
|
var staticFiles embed.FS
|
||||||
|
|
||||||
|
// WebUIServer представляет сервер веб-интерфейса
|
||||||
|
type WebUIServer struct {
|
||||||
|
store *storage.Storage
|
||||||
|
coordinator *cluster.RaftCoordinator
|
||||||
|
aclManager *acl.ACLManager
|
||||||
|
logger *log.Logger
|
||||||
|
server *http.Server
|
||||||
|
port int
|
||||||
|
enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWebUIServer создаёт новый веб-сервер интерфейса
|
||||||
|
func NewWebUIServer(port int, enabled bool, store *storage.Storage, coord *cluster.RaftCoordinator, aclMgr *acl.ACLManager, logger *log.Logger) *WebUIServer {
|
||||||
|
return &WebUIServer{
|
||||||
|
store: store,
|
||||||
|
coordinator: coord,
|
||||||
|
aclManager: aclMgr,
|
||||||
|
logger: logger,
|
||||||
|
port: port,
|
||||||
|
enabled: enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start запускает веб-сервер интерфейса
|
||||||
|
func (w *WebUIServer) Start() error {
|
||||||
|
if !w.enabled {
|
||||||
|
w.logger.Info("Web UI is disabled in configuration")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// Статические файлы
|
||||||
|
staticFS, err := fs.Sub(staticFiles, "static")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load static files: %v", err)
|
||||||
|
}
|
||||||
|
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||||
|
|
||||||
|
// Главная страница
|
||||||
|
mux.HandleFunc("/", w.handleIndex)
|
||||||
|
|
||||||
|
// API для веб-интерфейса
|
||||||
|
mux.HandleFunc("/api/webui/databases", w.handleGetDatabases)
|
||||||
|
mux.HandleFunc("/api/webui/collections/", w.handleGetCollections)
|
||||||
|
mux.HandleFunc("/api/webui/documents/", w.handleDocuments)
|
||||||
|
mux.HandleFunc("/api/webui/cluster/status", w.handleClusterStatus)
|
||||||
|
mux.HandleFunc("/api/webui/cluster/nodes", w.handleClusterNodes)
|
||||||
|
mux.HandleFunc("/api/webui/stats", w.handleStats)
|
||||||
|
mux.HandleFunc("/api/webui/login", w.handleWebLogin)
|
||||||
|
mux.HandleFunc("/api/webui/logout", w.handleWebLogout)
|
||||||
|
mux.HandleFunc("/api/webui/session", w.handleSessionCheck)
|
||||||
|
|
||||||
|
w.server = &http.Server{
|
||||||
|
Addr: fmt.Sprintf(":%d", w.port),
|
||||||
|
Handler: mux,
|
||||||
|
ReadTimeout: 30 * time.Second,
|
||||||
|
WriteTimeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.logger.Info(fmt.Sprintf("Web UI started on port %d", w.port))
|
||||||
|
return w.server.ListenAndServe()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop останавливает веб-сервер
|
||||||
|
func (w *WebUIServer) Stop() error {
|
||||||
|
if w.server != nil {
|
||||||
|
return w.server.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleIndex возвращает главную HTML страницу
|
||||||
|
func (w *WebUIServer) handleIndex(wr http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/" {
|
||||||
|
http.NotFound(wr, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
html := `<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
|
||||||
|
<title>Futriis Database Management System</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="dashboard-container">
|
||||||
|
<!-- Вертикальное меню -->
|
||||||
|
<nav class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<div class="logo">
|
||||||
|
<i class="fas fa-database"></i>
|
||||||
|
<span>Futriis DB</span>
|
||||||
|
</div>
|
||||||
|
<button class="menu-toggle" id="menuToggle">
|
||||||
|
<i class="fas fa-bars"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="nav-menu">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="#" class="nav-link" data-section="dashboard">
|
||||||
|
<i class="fas fa-tachometer-alt"></i>
|
||||||
|
<span>Панель управления</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item has-submenu">
|
||||||
|
<a href="#" class="nav-link" data-submenu="crud">
|
||||||
|
<i class="fas fa-table"></i>
|
||||||
|
<span>Управление СУБД</span>
|
||||||
|
<i class="fas fa-chevron-down"></i>
|
||||||
|
</a>
|
||||||
|
<ul class="submenu">
|
||||||
|
<li><a href="#" data-action="create-db"><i class="fas fa-plus-circle"></i>Создать БД</a></li>
|
||||||
|
<li><a href="#" data-action="create-collection"><i class="fas fa-layer-group"></i>Создать коллекцию</a></li>
|
||||||
|
<li><a href="#" data-action="insert-doc"><i class="fas fa-file-import"></i>Вставить документ</a></li>
|
||||||
|
<li><a href="#" data-action="find-doc"><i class="fas fa-search"></i>Найти документ</a></li>
|
||||||
|
<li><a href="#" data-action="update-doc"><i class="fas fa-edit"></i>Обновить документ</a></li>
|
||||||
|
<li><a href="#" data-action="delete-doc"><i class="fas fa-trash-alt"></i>Удалить документ</a></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="#" class="nav-link" data-section="cluster">
|
||||||
|
<i class="fas fa-network-wired"></i>
|
||||||
|
<span>Управление кластером</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="#" class="nav-link" data-section="audit">
|
||||||
|
<i class="fas fa-history"></i>
|
||||||
|
<span>Аудит</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a href="#" class="nav-link" data-section="settings">
|
||||||
|
<i class="fas fa-cog"></i>
|
||||||
|
<span>Настройки</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<div class="user-info" id="userInfo">
|
||||||
|
<i class="fas fa-user-circle"></i>
|
||||||
|
<span>Гость</span>
|
||||||
|
</div>
|
||||||
|
<button class="logout-btn" id="logoutBtn">
|
||||||
|
<i class="fas fa-sign-out-alt"></i>
|
||||||
|
<span>Выход</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Основной контент -->
|
||||||
|
<main class="main-content">
|
||||||
|
<header class="top-bar">
|
||||||
|
<h1 id="pageTitle">Панель управления</h1>
|
||||||
|
<div class="connection-status" id="connectionStatus">
|
||||||
|
<i class="fas fa-circle"></i>
|
||||||
|
<span>Подключено</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content-area" id="contentArea">
|
||||||
|
<!-- Контент загружается динамически -->
|
||||||
|
<div class="loading-spinner">
|
||||||
|
<i class="fas fa-spinner fa-pulse"></i>
|
||||||
|
<p>Загрузка...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальные окна -->
|
||||||
|
<div id="modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="modalTitle">Заголовок</h2>
|
||||||
|
<button class="modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="modalBody">
|
||||||
|
<!-- Содержимое модального окна -->
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary modal-close">Отмена</button>
|
||||||
|
<button class="btn btn-primary" id="modalConfirm">Подтвердить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Уведомления -->
|
||||||
|
<div id="notificationContainer" class="notification-container"></div>
|
||||||
|
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
wr.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
wr.Write([]byte(html))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetDatabases возвращает список баз данных
|
||||||
|
func (w *WebUIServer) handleGetDatabases(wr http.ResponseWriter, r *http.Request) {
|
||||||
|
if !w.checkAuth(r) {
|
||||||
|
w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
databases := w.store.ListDatabases()
|
||||||
|
|
||||||
|
dbInfo := make([]map[string]interface{}, 0)
|
||||||
|
for _, dbName := range databases {
|
||||||
|
db, err := w.store.GetDatabase(dbName)
|
||||||
|
if err == nil {
|
||||||
|
collections := db.ListCollections()
|
||||||
|
dbInfo = append(dbInfo, map[string]interface{}{
|
||||||
|
"name": dbName,
|
||||||
|
"collections": len(collections),
|
||||||
|
"collections_list": collections,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.sendJSONSuccess(wr, dbInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetCollections возвращает список коллекций в базе данных
|
||||||
|
func (w *WebUIServer) handleGetCollections(wr http.ResponseWriter, r *http.Request) {
|
||||||
|
if !w.checkAuth(r) {
|
||||||
|
w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api/webui/collections/")
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
|
||||||
|
if len(parts) < 1 || parts[0] == "" {
|
||||||
|
w.sendJSONError(wr, "Database name required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dbName := parts[0]
|
||||||
|
db, err := w.store.GetDatabase(dbName)
|
||||||
|
if err != nil {
|
||||||
|
w.sendJSONError(wr, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
collections := db.ListCollections()
|
||||||
|
collectionsInfo := make([]map[string]interface{}, 0)
|
||||||
|
|
||||||
|
for _, collName := range collections {
|
||||||
|
coll, err := db.GetCollection(collName)
|
||||||
|
if err == nil {
|
||||||
|
collectionsInfo = append(collectionsInfo, map[string]interface{}{
|
||||||
|
"name": collName,
|
||||||
|
"count": coll.Count(),
|
||||||
|
"size": coll.Size(),
|
||||||
|
"indexes": coll.GetIndexes(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.sendJSONSuccess(wr, map[string]interface{}{
|
||||||
|
"database": dbName,
|
||||||
|
"collections": collectionsInfo,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDocuments обрабатывает CRUD операции с документами
|
||||||
|
func (w *WebUIServer) handleDocuments(wr http.ResponseWriter, r *http.Request) {
|
||||||
|
if !w.checkAuth(r) {
|
||||||
|
w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/api/webui/documents/")
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
|
||||||
|
if len(parts) < 2 {
|
||||||
|
w.sendJSONError(wr, "Invalid path. Use /api/webui/documents/{database}/{collection}", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dbName := parts[0]
|
||||||
|
collName := parts[1]
|
||||||
|
|
||||||
|
db, err := w.store.GetDatabase(dbName)
|
||||||
|
if err != nil {
|
||||||
|
w.sendJSONError(wr, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
coll, err := db.GetCollection(collName)
|
||||||
|
if err != nil {
|
||||||
|
w.sendJSONError(wr, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
// Получение документов с пагинацией
|
||||||
|
limit := 50
|
||||||
|
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
|
||||||
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 500 {
|
||||||
|
limit = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := 0
|
||||||
|
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
|
||||||
|
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
|
||||||
|
offset = o
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allDocs := coll.GetAllDocuments()
|
||||||
|
start := offset
|
||||||
|
end := offset + limit
|
||||||
|
if start > len(allDocs) {
|
||||||
|
start = len(allDocs)
|
||||||
|
}
|
||||||
|
if end > len(allDocs) {
|
||||||
|
end = len(allDocs)
|
||||||
|
}
|
||||||
|
|
||||||
|
docs := make([]map[string]interface{}, 0)
|
||||||
|
for _, doc := range allDocs[start:end] {
|
||||||
|
docs = append(docs, map[string]interface{}{
|
||||||
|
"id": doc.ID,
|
||||||
|
"fields": doc.GetFields(),
|
||||||
|
"created_at": doc.CreatedAt,
|
||||||
|
"updated_at": doc.UpdatedAt,
|
||||||
|
"version": doc.Version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
w.sendJSONSuccess(wr, map[string]interface{}{
|
||||||
|
"documents": docs,
|
||||||
|
"total": len(allDocs),
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
|
||||||
|
case http.MethodPost:
|
||||||
|
// Вставка документа
|
||||||
|
var docData map[string]interface{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&docData); err != nil {
|
||||||
|
w.sendJSONError(wr, "Invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := coll.InsertFromMap(docData); err != nil {
|
||||||
|
w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.sendJSONSuccess(wr, map[string]interface{}{
|
||||||
|
"status": "inserted",
|
||||||
|
"id": docData["_id"],
|
||||||
|
})
|
||||||
|
|
||||||
|
case http.MethodPut:
|
||||||
|
// Обновление документа
|
||||||
|
docID := r.URL.Query().Get("id")
|
||||||
|
if docID == "" {
|
||||||
|
w.sendJSONError(wr, "Document ID required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var updates map[string]interface{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
|
||||||
|
w.sendJSONError(wr, "Invalid JSON", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := coll.Update(docID, updates); err != nil {
|
||||||
|
w.sendJSONError(wr, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.sendJSONSuccess(wr, map[string]interface{}{
|
||||||
|
"status": "updated",
|
||||||
|
"id": docID,
|
||||||
|
})
|
||||||
|
|
||||||
|
case http.MethodDelete:
|
||||||
|
// Удаление документа
|
||||||
|
docID := r.URL.Query().Get("id")
|
||||||
|
if docID == "" {
|
||||||
|
w.sendJSONError(wr, "Document ID required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := coll.Delete(docID); err != nil {
|
||||||
|
w.sendJSONError(wr, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.sendJSONSuccess(wr, map[string]interface{}{
|
||||||
|
"status": "deleted",
|
||||||
|
"id": docID,
|
||||||
|
})
|
||||||
|
|
||||||
|
default:
|
||||||
|
w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleClusterStatus возвращает статус кластера
|
||||||
|
func (w *WebUIServer) handleClusterStatus(wr http.ResponseWriter, r *http.Request) {
|
||||||
|
if !w.checkAuth(r) {
|
||||||
|
w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.coordinator == nil {
|
||||||
|
w.sendJSONError(wr, "Cluster not available", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := w.coordinator.GetClusterStatus()
|
||||||
|
w.sendJSONSuccess(wr, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleClusterNodes возвращает список узлов кластера
|
||||||
|
func (w *WebUIServer) handleClusterNodes(wr http.ResponseWriter, r *http.Request) {
|
||||||
|
if !w.checkAuth(r) {
|
||||||
|
w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.coordinator == nil {
|
||||||
|
w.sendJSONError(wr, "Cluster not available", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes := w.coordinator.GetAllNodes()
|
||||||
|
w.sendJSONSuccess(wr, nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStats возвращает статистику системы
|
||||||
|
func (w *WebUIServer) handleStats(wr http.ResponseWriter, r *http.Request) {
|
||||||
|
if !w.checkAuth(r) {
|
||||||
|
w.sendJSONError(wr, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
databases := w.store.ListDatabases()
|
||||||
|
totalDocs := int64(0)
|
||||||
|
totalCollections := 0
|
||||||
|
|
||||||
|
for _, dbName := range databases {
|
||||||
|
db, _ := w.store.GetDatabase(dbName)
|
||||||
|
if db != nil {
|
||||||
|
collections := db.ListCollections()
|
||||||
|
totalCollections += len(collections)
|
||||||
|
for _, collName := range collections {
|
||||||
|
coll, _ := db.GetCollection(collName)
|
||||||
|
if coll != nil {
|
||||||
|
totalDocs += coll.Count()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := map[string]interface{}{
|
||||||
|
"databases": len(databases),
|
||||||
|
"collections": totalCollections,
|
||||||
|
"documents": totalDocs,
|
||||||
|
"storage_used_mb": float64(w.store.GetPageSize()*int64(len(databases))) / (1024 * 1024),
|
||||||
|
"uptime_seconds": time.Now().Unix(),
|
||||||
|
"cluster_enabled": w.coordinator != nil,
|
||||||
|
"replication_factor": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.coordinator != nil {
|
||||||
|
stats["replication_factor"] = w.coordinator.GetReplicationFactor()
|
||||||
|
stats["cluster_health"] = w.coordinator.GetClusterStatus().Health
|
||||||
|
}
|
||||||
|
|
||||||
|
w.sendJSONSuccess(wr, stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWebLogin обрабатывает вход в веб-интерфейс
|
||||||
|
func (w *WebUIServer) handleWebLogin(wr http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.sendJSONError(wr, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var creds struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&creds); err != nil {
|
||||||
|
w.sendJSONError(wr, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID, err := w.aclManager.Authenticate(creds.Username, creds.Password)
|
||||||
|
if err != nil {
|
||||||
|
w.sendJSONError(wr, err.Error(), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем cookie сессии
|
||||||
|
http.SetCookie(wr, &http.Cookie{
|
||||||
|
Name: "session_id",
|
||||||
|
Value: sessionID,
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: 86400,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
|
||||||
|
w.sendJSONSuccess(wr, map[string]interface{}{
|
||||||
|
"session_id": sessionID,
|
||||||
|
"username": creds.Username,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWebLogout обрабатывает выход из веб-интерфейса
|
||||||
|
func (w *WebUIServer) handleWebLogout(wr http.ResponseWriter, r *http.Request) {
|
||||||
|
if cookie, err := r.Cookie("session_id"); err == nil {
|
||||||
|
w.aclManager.Logout(cookie.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(wr, &http.Cookie{
|
||||||
|
Name: "session_id",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
w.sendJSONSuccess(wr, map[string]interface{}{
|
||||||
|
"status": "logged out",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSessionCheck проверяет активность сессии
|
||||||
|
func (w *WebUIServer) handleSessionCheck(wr http.ResponseWriter, r *http.Request) {
|
||||||
|
sessionID := w.getSessionID(r)
|
||||||
|
if sessionID == "" {
|
||||||
|
w.sendJSONError(wr, "No session", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !w.aclManager.CheckSession(sessionID) {
|
||||||
|
w.sendJSONError(wr, "Invalid session", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := w.aclManager.GetUsername(sessionID)
|
||||||
|
w.sendJSONSuccess(wr, map[string]interface{}{
|
||||||
|
"authenticated": true,
|
||||||
|
"username": username,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAuth проверяет аутентификацию
|
||||||
|
func (w *WebUIServer) checkAuth(r *http.Request) bool {
|
||||||
|
sessionID := w.getSessionID(r)
|
||||||
|
if sessionID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return w.aclManager.CheckSession(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSessionID возвращает ID сессии из cookie
|
||||||
|
func (w *WebUIServer) getSessionID(r *http.Request) string {
|
||||||
|
if cookie, err := r.Cookie("session_id"); err == nil {
|
||||||
|
return cookie.Value
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendJSONSuccess отправляет успешный JSON ответ
|
||||||
|
func (w *WebUIServer) sendJSONSuccess(wr http.ResponseWriter, data interface{}) {
|
||||||
|
wr.Header().Set("Content-Type", "application/json")
|
||||||
|
wr.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(wr).Encode(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"data": data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendJSONError отправляет JSON ответ с ошибкой
|
||||||
|
func (w *WebUIServer) sendJSONError(wr http.ResponseWriter, errMsg string, statusCode int) {
|
||||||
|
wr.Header().Set("Content-Type", "application/json")
|
||||||
|
wr.WriteHeader(statusCode)
|
||||||
|
json.NewEncoder(wr).Encode(map[string]interface{}{
|
||||||
|
"success": false,
|
||||||
|
"error": errMsg,
|
||||||
|
})
|
||||||
|
}
|
||||||
389
internal/cluster/node.go
Normal file
389
internal/cluster/node.go
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/cluster/node.go
|
||||||
|
// Назначение: Реализация узла кластера (node) для распределённой СУБД.
|
||||||
|
// Реализация узла кластера (node) для распределённой СУБД.
|
||||||
|
// Полностью lock-free с использованием атомарных операций.
|
||||||
|
|
||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"futriis/internal/log"
|
||||||
|
"futriis/internal/storage"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NodeStatus представляет состояние узла кластера
|
||||||
|
type NodeStatus int32
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusOffline NodeStatus = iota
|
||||||
|
StatusActive
|
||||||
|
StatusSyncing
|
||||||
|
StatusFailed
|
||||||
|
)
|
||||||
|
|
||||||
|
// Node представляет отдельный узел в распределённой системе
|
||||||
|
type Node struct {
|
||||||
|
ID string // Уникальный идентификатор узла
|
||||||
|
IP string // IP-адрес узла
|
||||||
|
Port int // Порт для коммуникации
|
||||||
|
Status atomic.Int32 // Атомарный статус узла (NodeStatus)
|
||||||
|
Storage *storage.Storage
|
||||||
|
logger *log.Logger
|
||||||
|
coordinator *RaftCoordinator // Ссылка на координатора (теперь RaftCoordinator)
|
||||||
|
lastSeen atomic.Int64 // Время последнего heartbeat (Unix nano)
|
||||||
|
incomingConn chan net.Conn // Канал для входящих соединений (wait-free)
|
||||||
|
stopChan chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeConfig содержит конфигурацию для создания узла
|
||||||
|
type NodeConfig struct {
|
||||||
|
IP string
|
||||||
|
Port int
|
||||||
|
Storage *storage.Storage
|
||||||
|
Logger *log.Logger
|
||||||
|
Coordinator *RaftCoordinator
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNode создаёт новый экземпляр узла кластера
|
||||||
|
func NewNode(ip string, port int, store *storage.Storage, logger *log.Logger) *Node {
|
||||||
|
node := &Node{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
IP: ip,
|
||||||
|
Port: port,
|
||||||
|
Storage: store,
|
||||||
|
logger: logger,
|
||||||
|
incomingConn: make(chan net.Conn, 1000), // Буферизованный канал для wait-free приёма
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
}
|
||||||
|
node.Status.Store(int32(StatusActive))
|
||||||
|
node.lastSeen.Store(time.Now().UnixNano())
|
||||||
|
|
||||||
|
// Запуск сервера для приёма межузловых соединений
|
||||||
|
go node.startTCPServer()
|
||||||
|
|
||||||
|
// Запуск обработчика входящих соединений
|
||||||
|
go node.handleIncomingConnections()
|
||||||
|
|
||||||
|
// Запуск heartbeat-отправки (если координатор известен)
|
||||||
|
go node.heartbeatLoop()
|
||||||
|
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
// startTCPServer запускает TCP-сервер для приёма запросов от других узлов
|
||||||
|
func (n *Node) startTCPServer() {
|
||||||
|
addr := fmt.Sprintf("%s:%d", n.IP, n.Port)
|
||||||
|
listener, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("Node %s failed to start TCP server: %v", n.ID, err))
|
||||||
|
}
|
||||||
|
n.Status.Store(int32(StatusFailed))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Info(fmt.Sprintf("Node %s listening on %s", n.ID, addr))
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-n.stopChan:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("Node %s accept error: %v", n.ID, err))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Неблокирующая отправка в канал
|
||||||
|
select {
|
||||||
|
case n.incomingConn <- conn:
|
||||||
|
default:
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Warn(fmt.Sprintf("Node %s incoming connection queue full, dropping connection", n.ID))
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleIncomingConnections обрабатывает входящие соединения wait-free способом
|
||||||
|
func (n *Node) handleIncomingConnections() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-n.stopChan:
|
||||||
|
return
|
||||||
|
case conn := <-n.incomingConn:
|
||||||
|
go n.handleNodeRequest(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNodeRequest обрабатывает конкретный запрос от другого узла
|
||||||
|
func (n *Node) handleNodeRequest(conn net.Conn) {
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
decoder := json.NewDecoder(conn)
|
||||||
|
var req NodeRequest
|
||||||
|
if err := decoder.Decode(&req); err != nil {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("Node %s failed to decode request: %v", n.ID, err))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем время последнего контакта
|
||||||
|
n.lastSeen.Store(time.Now().UnixNano())
|
||||||
|
|
||||||
|
// Маршрутизация запроса в зависимости от типа
|
||||||
|
switch req.Type {
|
||||||
|
case "replicate":
|
||||||
|
n.handleReplicateRequest(req.Data)
|
||||||
|
case "query":
|
||||||
|
n.handleQueryRequest(req.Data, conn)
|
||||||
|
case "sync":
|
||||||
|
n.handleSyncRequest(req.Data, conn)
|
||||||
|
default:
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Warn(fmt.Sprintf("Node %s unknown request type: %s", n.ID, req.Type))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReplicateRequest обрабатывает запрос на репликацию документа
|
||||||
|
func (n *Node) handleReplicateRequest(data []byte) {
|
||||||
|
var repData struct {
|
||||||
|
Database string `json:"database"`
|
||||||
|
Collection string `json:"collection"`
|
||||||
|
Document map[string]interface{} `json:"document"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &repData); err != nil {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("Node %s failed to unmarshal replicate data: %v", n.ID, err))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем базу данных
|
||||||
|
db, err := n.Storage.GetDatabase(repData.Database)
|
||||||
|
if err != nil {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("Node %s database not found for replication: %s", n.ID, repData.Database))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем коллекцию
|
||||||
|
coll, err := db.GetCollection(repData.Collection)
|
||||||
|
if err != nil {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("Node %s collection not found for replication: %s", n.ID, repData.Collection))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём документ и вставляем
|
||||||
|
doc := &storage.Document{
|
||||||
|
ID: repData.Document["_id"].(string),
|
||||||
|
Fields: repData.Document,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := coll.Insert(doc); err != nil {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Error(fmt.Sprintf("Node %s failed to replicate document: %v", n.ID, err))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Debug(fmt.Sprintf("Node %s replicated document %s", n.ID, doc.ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleQueryRequest обрабатывает запрос на чтение данных с узла
|
||||||
|
func (n *Node) handleQueryRequest(data []byte, conn net.Conn) {
|
||||||
|
var queryData struct {
|
||||||
|
Database string `json:"database"`
|
||||||
|
Collection string `json:"collection"`
|
||||||
|
DocumentID string `json:"document_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &queryData); err != nil {
|
||||||
|
n.sendErrorResponse(conn, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем базу данных
|
||||||
|
db, err := n.Storage.GetDatabase(queryData.Database)
|
||||||
|
if err != nil {
|
||||||
|
n.sendErrorResponse(conn, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем коллекцию
|
||||||
|
coll, err := db.GetCollection(queryData.Collection)
|
||||||
|
if err != nil {
|
||||||
|
n.sendErrorResponse(conn, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Находим документ
|
||||||
|
doc, err := coll.Find(queryData.DocumentID)
|
||||||
|
if err != nil {
|
||||||
|
n.sendErrorResponse(conn, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправляем успешный ответ
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"status": "success",
|
||||||
|
"data": doc,
|
||||||
|
}
|
||||||
|
encoder := json.NewEncoder(conn)
|
||||||
|
encoder.Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSyncRequest обрабатывает запрос на синхронизацию всей коллекции
|
||||||
|
func (n *Node) handleSyncRequest(data []byte, conn net.Conn) {
|
||||||
|
var syncData struct {
|
||||||
|
Database string `json:"database"`
|
||||||
|
Collection string `json:"collection"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &syncData); err != nil {
|
||||||
|
n.sendErrorResponse(conn, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем базу данных
|
||||||
|
db, err := n.Storage.GetDatabase(syncData.Database)
|
||||||
|
if err != nil {
|
||||||
|
n.sendErrorResponse(conn, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем коллекцию
|
||||||
|
coll, err := db.GetCollection(syncData.Collection)
|
||||||
|
if err != nil {
|
||||||
|
n.sendErrorResponse(conn, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем все документы
|
||||||
|
docs := coll.GetAllDocuments()
|
||||||
|
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"status": "success",
|
||||||
|
"docs": docs,
|
||||||
|
"count": len(docs),
|
||||||
|
}
|
||||||
|
encoder := json.NewEncoder(conn)
|
||||||
|
encoder.Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendErrorResponse отправляет ошибку в ответ на запрос
|
||||||
|
func (n *Node) sendErrorResponse(conn net.Conn, errMsg string) {
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"status": "error",
|
||||||
|
"error": errMsg,
|
||||||
|
}
|
||||||
|
encoder := json.NewEncoder(conn)
|
||||||
|
encoder.Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// heartbeatLoop отправляет периодические сигналы жизни координатору
|
||||||
|
func (n *Node) heartbeatLoop() {
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-n.stopChan:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if n.coordinator != nil {
|
||||||
|
n.coordinator.SendHeartbeat(n.ID)
|
||||||
|
n.lastSeen.Store(time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNodeStatus возвращает текущий статус узла (атомарно)
|
||||||
|
func (n *Node) GetNodeStatus() NodeStatus {
|
||||||
|
return NodeStatus(n.Status.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsActive проверяет, активен ли узел
|
||||||
|
func (n *Node) IsActive() bool {
|
||||||
|
return NodeStatus(n.Status.Load()) == StatusActive
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCoordinator устанавливает координатора для узла
|
||||||
|
func (n *Node) SetCoordinator(coord *RaftCoordinator) {
|
||||||
|
n.coordinator = coord
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Info(fmt.Sprintf("Node %s connected to coordinator", n.ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop останавливает работу узла
|
||||||
|
func (n *Node) Stop() {
|
||||||
|
n.Status.Store(int32(StatusOffline))
|
||||||
|
close(n.stopChan)
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Info(fmt.Sprintf("Node %s stopped", n.ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAddress возвращает адрес узла в формате "ip:port"
|
||||||
|
func (n *Node) GetAddress() string {
|
||||||
|
return fmt.Sprintf("%s:%d", n.IP, n.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplicateDocument отправляет документ на репликацию всем активным узлам
|
||||||
|
func (n *Node) ReplicateDocument(database, collection string, doc *storage.Document) error {
|
||||||
|
if n.coordinator == nil {
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Warn("No coordinator set, skipping replication")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("no coordinator set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем список всех узлов от координатора
|
||||||
|
nodes := n.coordinator.GetActiveNodes()
|
||||||
|
|
||||||
|
for _, nodeInfo := range nodes {
|
||||||
|
if nodeInfo.ID == n.ID {
|
||||||
|
continue // Пропускаем себя
|
||||||
|
}
|
||||||
|
|
||||||
|
// В реальной реализации здесь была бы отправка на узел
|
||||||
|
if n.logger != nil {
|
||||||
|
n.logger.Debug(fmt.Sprintf("Would replicate to node %s", nodeInfo.ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
750
internal/cluster/raft_coordinator.go
Normal file
750
internal/cluster/raft_coordinator.go
Normal file
@@ -0,0 +1,750 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/cluster/raft_coordinator.go
|
||||||
|
// Назначение: Реализация координатора распределённого кластера на основе Raft консенсус-алгоритма.
|
||||||
|
// Обеспечивает управление узлами кластера, выборы лидера, репликацию данных и отказоустойчивость.
|
||||||
|
// Поддерживает как одноузловой режим работы, так и многокластерную конфигурацию с синхронной/асинхронной репликацией.
|
||||||
|
|
||||||
|
package cluster
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/hashicorp/raft"
|
||||||
|
raftboltdb "github.com/hashicorp/raft-boltdb/v2"
|
||||||
|
"futriis/internal/log"
|
||||||
|
"futriis/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RaftClusterState представляет состояние кластера для Raft FSM
|
||||||
|
type RaftClusterState struct {
|
||||||
|
Nodes map[string]*NodeInfo `json:"nodes"`
|
||||||
|
ReplicationFactor int32 `json:"replication_factor"`
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// RaftCoordinator реализует координацию кластера через Raft
|
||||||
|
type RaftCoordinator struct {
|
||||||
|
raft *raft.Raft
|
||||||
|
fsm *RaftFSM
|
||||||
|
address string
|
||||||
|
raftAddr string
|
||||||
|
clusterName string
|
||||||
|
logger *log.Logger
|
||||||
|
config *config.Config
|
||||||
|
stopChan chan struct{}
|
||||||
|
nodes sync.Map
|
||||||
|
replicationFactor atomic.Int32
|
||||||
|
replicationEnabled bool
|
||||||
|
masterMasterEnabled bool
|
||||||
|
syncReplication bool
|
||||||
|
isLeader atomic.Bool
|
||||||
|
leaderMonitor chan bool
|
||||||
|
singleNodeMode bool
|
||||||
|
localNodeInfo *NodeInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// RaftFSM реализует конечный автомат для Raft
|
||||||
|
type RaftFSM struct {
|
||||||
|
state *RaftClusterState
|
||||||
|
logger *log.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeRegistrationCommand команда регистрации узла
|
||||||
|
type NodeRegistrationCommand struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Node NodeInfo `json:"node,omitempty"`
|
||||||
|
NodeID string `json:"node_id,omitempty"`
|
||||||
|
Factor int32 `json:"factor,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLocalIP получает локальный IP адрес
|
||||||
|
func getLocalIP() string {
|
||||||
|
addrs, err := net.InterfaceAddrs()
|
||||||
|
if err != nil {
|
||||||
|
return "127.0.0.1"
|
||||||
|
}
|
||||||
|
for _, addr := range addrs {
|
||||||
|
if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
|
||||||
|
return ipnet.IP.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "127.0.0.1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRaftCoordinator создаёт новый Raft координатор
|
||||||
|
func NewRaftCoordinator(cfg *config.Config, logger *log.Logger) (*RaftCoordinator, error) {
|
||||||
|
// Используем IP из конфига или автоматически определяем
|
||||||
|
nodeIP := cfg.Cluster.NodeIP
|
||||||
|
if nodeIP == "" || nodeIP == "0.0.0.0" {
|
||||||
|
nodeIP = getLocalIP()
|
||||||
|
}
|
||||||
|
|
||||||
|
raftAddr := fmt.Sprintf("%s:%d", nodeIP, cfg.Cluster.RaftPort)
|
||||||
|
|
||||||
|
logger.Debug(fmt.Sprintf("Creating Raft coordinator at %s", raftAddr))
|
||||||
|
|
||||||
|
// Определяем одноузловой режим
|
||||||
|
singleNodeMode := len(cfg.Cluster.Nodes) <= 1 || cfg.Cluster.Bootstrap
|
||||||
|
|
||||||
|
rc := &RaftCoordinator{
|
||||||
|
address: fmt.Sprintf("%s:%d", nodeIP, cfg.Cluster.NodePort),
|
||||||
|
raftAddr: raftAddr,
|
||||||
|
clusterName: cfg.Cluster.Name,
|
||||||
|
logger: logger,
|
||||||
|
config: cfg,
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
leaderMonitor: make(chan bool, 1),
|
||||||
|
replicationEnabled: cfg.Replication.Enabled,
|
||||||
|
masterMasterEnabled: cfg.Replication.MasterMaster,
|
||||||
|
syncReplication: cfg.Replication.SyncReplication,
|
||||||
|
singleNodeMode: singleNodeMode,
|
||||||
|
localNodeInfo: &NodeInfo{
|
||||||
|
ID: fmt.Sprintf("%s-%s", cfg.Cluster.Name, nodeIP),
|
||||||
|
IP: nodeIP,
|
||||||
|
Port: cfg.Cluster.NodePort,
|
||||||
|
Status: "active",
|
||||||
|
LastSeen: time.Now().Unix(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rc.replicationFactor.Store(int32(3))
|
||||||
|
|
||||||
|
// Создаём FSM
|
||||||
|
rc.fsm = &RaftFSM{
|
||||||
|
state: &RaftClusterState{
|
||||||
|
Nodes: make(map[string]*NodeInfo),
|
||||||
|
},
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
|
||||||
|
// В одноузловом режиме добавляем локальный узел в состояние
|
||||||
|
if singleNodeMode {
|
||||||
|
rc.fsm.state.mu.Lock()
|
||||||
|
rc.fsm.state.Nodes[rc.localNodeInfo.ID] = rc.localNodeInfo
|
||||||
|
rc.fsm.state.mu.Unlock()
|
||||||
|
rc.isLeader.Store(true)
|
||||||
|
logger.Debug(fmt.Sprintf("Single-node mode: local node added to state: %s", rc.localNodeInfo.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Настраиваем Raft
|
||||||
|
raftConfig := raft.DefaultConfig()
|
||||||
|
raftConfig.LocalID = raft.ServerID(rc.localNodeInfo.ID)
|
||||||
|
raftConfig.HeartbeatTimeout = 1000 * time.Millisecond
|
||||||
|
raftConfig.ElectionTimeout = 1000 * time.Millisecond
|
||||||
|
raftConfig.CommitTimeout = 500 * time.Millisecond
|
||||||
|
raftConfig.LeaderLeaseTimeout = 500 * time.Millisecond
|
||||||
|
|
||||||
|
// Для одноузлового кластера используем специальные настройки и подавляем предупреждения
|
||||||
|
if singleNodeMode {
|
||||||
|
raftConfig.HeartbeatTimeout = 500 * time.Millisecond
|
||||||
|
raftConfig.ElectionTimeout = 500 * time.Millisecond
|
||||||
|
raftConfig.LeaderLeaseTimeout = 500 * time.Millisecond
|
||||||
|
// Подавляем вывод предупреждений для одноузлового режима
|
||||||
|
raftConfig.LogOutput = io.Discard
|
||||||
|
logger.Debug("Running in single-node mode (warnings suppressed)")
|
||||||
|
} else {
|
||||||
|
raftConfig.LogOutput = os.Stderr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём директорию для Raft данных
|
||||||
|
dataDir := cfg.Cluster.RaftDataDir
|
||||||
|
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create raft data dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug(fmt.Sprintf("Raft data directory: %s", dataDir))
|
||||||
|
|
||||||
|
// Создаём хранилище для логов
|
||||||
|
logStore, err := raftboltdb.NewBoltStore(filepath.Join(dataDir, "raft-log.bolt"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create log store: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём хранилище для стабильных данных
|
||||||
|
stableStore, err := raftboltdb.NewBoltStore(filepath.Join(dataDir, "raft-stable.bolt"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create stable store: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём снапшот хранилище
|
||||||
|
snapshotStore, err := raft.NewFileSnapshotStore(dataDir, 2, os.Stderr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create snapshot store: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём транспорт
|
||||||
|
transport, err := raft.NewTCPTransport(raftAddr, nil, 3, 10*time.Second, os.Stderr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create transport: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём Raft инстанс
|
||||||
|
r, err := raft.NewRaft(raftConfig, rc.fsm, logStore, stableStore, snapshotStore, transport)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create raft: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rc.raft = r
|
||||||
|
|
||||||
|
// Ждём некоторое время для инициализации Raft
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
// Проверяем, нужно ли делать bootstrap
|
||||||
|
bootstrapPath := filepath.Join(dataDir, "raft-log.bolt")
|
||||||
|
_, statErr := os.Stat(bootstrapPath)
|
||||||
|
needsBootstrap := os.IsNotExist(statErr)
|
||||||
|
|
||||||
|
if needsBootstrap && singleNodeMode {
|
||||||
|
logger.Debug("Bootstrapping single-node cluster...")
|
||||||
|
|
||||||
|
configuration := raft.Configuration{
|
||||||
|
Servers: []raft.Server{
|
||||||
|
{
|
||||||
|
ID: raftConfig.LocalID,
|
||||||
|
Address: transport.LocalAddr(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
future := r.BootstrapCluster(configuration)
|
||||||
|
if err := future.Error(); err != nil {
|
||||||
|
logger.Warn(fmt.Sprintf("Bootstrap error: %v", err))
|
||||||
|
} else {
|
||||||
|
logger.Debug("Single-node cluster bootstrapped successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ждём после bootstrap
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
|
} else if needsBootstrap && len(cfg.Cluster.Nodes) > 1 {
|
||||||
|
logger.Debug("Bootstrapping multi-node cluster...")
|
||||||
|
|
||||||
|
servers := make([]raft.Server, 0, len(cfg.Cluster.Nodes))
|
||||||
|
for i, nodeAddr := range cfg.Cluster.Nodes {
|
||||||
|
serverID := raft.ServerID(fmt.Sprintf("%s-node%d", rc.clusterName, i+1))
|
||||||
|
servers = append(servers, raft.Server{
|
||||||
|
ID: serverID,
|
||||||
|
Address: raft.ServerAddress(nodeAddr),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
configuration := raft.Configuration{
|
||||||
|
Servers: servers,
|
||||||
|
}
|
||||||
|
|
||||||
|
future := r.BootstrapCluster(configuration)
|
||||||
|
if err := future.Error(); err != nil {
|
||||||
|
logger.Warn(fmt.Sprintf("Bootstrap error: %v", err))
|
||||||
|
} else {
|
||||||
|
logger.Debug("Multi-node cluster bootstrapped successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запускаем мониторинг лидера
|
||||||
|
go rc.monitorLeadership()
|
||||||
|
|
||||||
|
// Ждём выборов лидера
|
||||||
|
logger.Debug("Waiting for leader election...")
|
||||||
|
timeout := time.After(5 * time.Second)
|
||||||
|
leaderElected := false
|
||||||
|
|
||||||
|
for !leaderElected {
|
||||||
|
select {
|
||||||
|
case isLeader := <-rc.leaderMonitor:
|
||||||
|
if isLeader {
|
||||||
|
leaderElected = true
|
||||||
|
rc.isLeader.Store(true)
|
||||||
|
logger.Debug("This node is now the cluster leader")
|
||||||
|
}
|
||||||
|
case <-timeout:
|
||||||
|
logger.Warn("Leader election timeout")
|
||||||
|
leaderElected = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Существующее состояние, просто подключаемся
|
||||||
|
logger.Debug("Existing Raft state found, joining cluster...")
|
||||||
|
go rc.monitorLeadership()
|
||||||
|
|
||||||
|
// В одноузловом режиме не ждём лидера
|
||||||
|
if !singleNodeMode {
|
||||||
|
// Проверяем, не являемся ли мы лидером
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
if r.State() == raft.Leader {
|
||||||
|
rc.isLeader.Store(true)
|
||||||
|
logger.Debug("This node is the cluster leader")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug(fmt.Sprintf("Raft coordinator started at %s, IsLeader: %v, SingleNodeMode: %v", raftAddr, rc.isLeader.Load(), singleNodeMode))
|
||||||
|
|
||||||
|
return rc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// monitorLeadership отслеживает изменения лидера
|
||||||
|
func (rc *RaftCoordinator) monitorLeadership() {
|
||||||
|
ticker := time.NewTicker(500 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
wasLeader := false
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-rc.stopChan:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if rc.raft == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
isLeader := rc.raft.State() == raft.Leader
|
||||||
|
if isLeader != wasLeader {
|
||||||
|
wasLeader = isLeader
|
||||||
|
select {
|
||||||
|
case rc.leaderMonitor <- isLeader:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if isLeader {
|
||||||
|
rc.isLeader.Store(true)
|
||||||
|
rc.logger.Debug("Leadership acquired")
|
||||||
|
} else {
|
||||||
|
rc.isLeader.Store(false)
|
||||||
|
rc.logger.Debug("Leadership lost")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply применяет команду к FSM
|
||||||
|
func (f *RaftFSM) Apply(log *raft.Log) interface{} {
|
||||||
|
var cmd NodeRegistrationCommand
|
||||||
|
if err := json.Unmarshal(log.Data, &cmd); err != nil {
|
||||||
|
f.logger.Error(fmt.Sprintf("Failed to unmarshal raft command: %v", err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
f.state.mu.Lock()
|
||||||
|
defer f.state.mu.Unlock()
|
||||||
|
|
||||||
|
switch cmd.Type {
|
||||||
|
case "register":
|
||||||
|
f.state.Nodes[cmd.Node.ID] = &cmd.Node
|
||||||
|
f.logger.Debug(fmt.Sprintf("Raft: Node registered: %s", cmd.Node.ID))
|
||||||
|
case "remove":
|
||||||
|
delete(f.state.Nodes, cmd.NodeID)
|
||||||
|
f.logger.Debug(fmt.Sprintf("Raft: Node removed: %s", cmd.NodeID))
|
||||||
|
case "set_replication_factor":
|
||||||
|
f.state.ReplicationFactor = cmd.Factor
|
||||||
|
f.logger.Debug(fmt.Sprintf("Raft: Replication factor set to %d", cmd.Factor))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot реализует создание снапшота
|
||||||
|
func (f *RaftFSM) Snapshot() (raft.FSMSnapshot, error) {
|
||||||
|
f.state.mu.RLock()
|
||||||
|
defer f.state.mu.RUnlock()
|
||||||
|
|
||||||
|
stateCopy := &RaftClusterState{
|
||||||
|
Nodes: make(map[string]*NodeInfo),
|
||||||
|
ReplicationFactor: f.state.ReplicationFactor,
|
||||||
|
}
|
||||||
|
for k, v := range f.state.Nodes {
|
||||||
|
stateCopy.Nodes[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RaftSnapshot{state: stateCopy}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore восстанавливает состояние из снапшота
|
||||||
|
func (f *RaftFSM) Restore(snapshot io.ReadCloser) error {
|
||||||
|
defer snapshot.Close()
|
||||||
|
|
||||||
|
var state RaftClusterState
|
||||||
|
decoder := json.NewDecoder(snapshot)
|
||||||
|
if err := decoder.Decode(&state); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
f.state.mu.Lock()
|
||||||
|
defer f.state.mu.Unlock()
|
||||||
|
f.state.Nodes = state.Nodes
|
||||||
|
f.state.ReplicationFactor = state.ReplicationFactor
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RaftSnapshot реализует интерфейс FSMSnapshot
|
||||||
|
type RaftSnapshot struct {
|
||||||
|
state *RaftClusterState
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist сохраняет снапшот
|
||||||
|
func (s *RaftSnapshot) Persist(sink raft.SnapshotSink) error {
|
||||||
|
err := func() error {
|
||||||
|
data, err := json.Marshal(s.state)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := sink.Write(data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return sink.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
sink.Cancel()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release освобождает ресурсы
|
||||||
|
func (s *RaftSnapshot) Release() {}
|
||||||
|
|
||||||
|
// RegisterNode регистрирует узел через Raft
|
||||||
|
func (rc *RaftCoordinator) RegisterNode(node *Node) error {
|
||||||
|
nodeInfo := &NodeInfo{
|
||||||
|
ID: node.ID,
|
||||||
|
IP: node.IP,
|
||||||
|
Port: node.Port,
|
||||||
|
Status: "active",
|
||||||
|
LastSeen: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// В одноузловом режиме всегда считаем себя лидером
|
||||||
|
if rc.singleNodeMode {
|
||||||
|
rc.logger.Debug("Single-node mode: registering node without Raft consensus")
|
||||||
|
|
||||||
|
// Просто сохраняем узел локально
|
||||||
|
rc.nodes.Store(node.ID, nodeInfo)
|
||||||
|
|
||||||
|
// Также сохраняем в FSM
|
||||||
|
rc.fsm.state.mu.Lock()
|
||||||
|
rc.fsm.state.Nodes[node.ID] = nodeInfo
|
||||||
|
rc.fsm.state.mu.Unlock()
|
||||||
|
|
||||||
|
rc.logger.Debug(fmt.Sprintf("Node registered locally in single-node mode: %s", node.ID))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, является ли текущий узел лидером
|
||||||
|
if !rc.IsLeader() {
|
||||||
|
leader := rc.GetLeader()
|
||||||
|
if leader != nil {
|
||||||
|
rc.logger.Warn(fmt.Sprintf("Current node is not leader. Leader is %s:%d", leader.IP, leader.Port))
|
||||||
|
return fmt.Errorf("node is not the leader. Please connect to leader at %s:%d", leader.IP, leader.Port)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("node is not the leader and no leader found")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := NodeRegistrationCommand{
|
||||||
|
Type: "register",
|
||||||
|
Node: *nodeInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
future := rc.raft.Apply(data, 5*time.Second)
|
||||||
|
if err := future.Error(); err != nil {
|
||||||
|
return fmt.Errorf("failed to register node via raft: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rc.nodes.Store(node.ID, nodeInfo)
|
||||||
|
|
||||||
|
rc.logger.Debug(fmt.Sprintf("Node registered via Raft: %s", node.ID))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveNode удаляет узел через Raft
|
||||||
|
func (rc *RaftCoordinator) RemoveNode(nodeID string) error {
|
||||||
|
if rc.singleNodeMode {
|
||||||
|
rc.nodes.Delete(nodeID)
|
||||||
|
rc.fsm.state.mu.Lock()
|
||||||
|
delete(rc.fsm.state.Nodes, nodeID)
|
||||||
|
rc.fsm.state.mu.Unlock()
|
||||||
|
rc.logger.Debug(fmt.Sprintf("Node removed locally in single-node mode: %s", nodeID))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !rc.IsLeader() {
|
||||||
|
return fmt.Errorf("node is not the leader")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := NodeRegistrationCommand{
|
||||||
|
Type: "remove",
|
||||||
|
NodeID: nodeID,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
future := rc.raft.Apply(data, 5*time.Second)
|
||||||
|
if err := future.Error(); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove node via raft: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rc.nodes.Delete(nodeID)
|
||||||
|
rc.logger.Debug(fmt.Sprintf("Node removed via Raft: %s", nodeID))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActiveNodes возвращает активные узлы
|
||||||
|
func (rc *RaftCoordinator) GetActiveNodes() []*NodeInfo {
|
||||||
|
nodes := make([]*NodeInfo, 0)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
state := rc.fsm.state
|
||||||
|
state.mu.RLock()
|
||||||
|
defer state.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, nodeInfo := range state.Nodes {
|
||||||
|
if now-nodeInfo.LastSeen < 30 {
|
||||||
|
nodes = append(nodes, nodeInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// В одноузловом режиме, если список пуст, возвращаем локальный узел
|
||||||
|
if rc.singleNodeMode && len(nodes) == 0 && rc.localNodeInfo != nil {
|
||||||
|
nodes = append(nodes, rc.localNodeInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllNodes возвращает все узлы
|
||||||
|
func (rc *RaftCoordinator) GetAllNodes() []*NodeInfo {
|
||||||
|
state := rc.fsm.state
|
||||||
|
state.mu.RLock()
|
||||||
|
defer state.mu.RUnlock()
|
||||||
|
|
||||||
|
nodes := make([]*NodeInfo, 0, len(state.Nodes))
|
||||||
|
for _, node := range state.Nodes {
|
||||||
|
nodes = append(nodes, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
// В одноузловом режиме, если список пуст, возвращаем локальный узел
|
||||||
|
if rc.singleNodeMode && len(nodes) == 0 && rc.localNodeInfo != nil {
|
||||||
|
nodes = append(nodes, rc.localNodeInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLeader возвращает лидера
|
||||||
|
func (rc *RaftCoordinator) GetLeader() *NodeInfo {
|
||||||
|
if rc.singleNodeMode {
|
||||||
|
return rc.localNodeInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
leaderAddr := rc.raft.Leader()
|
||||||
|
if leaderAddr == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
state := rc.fsm.state
|
||||||
|
state.mu.RLock()
|
||||||
|
defer state.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, node := range state.Nodes {
|
||||||
|
nodeAddr := fmt.Sprintf("%s:%d", node.IP, node.Port)
|
||||||
|
if nodeAddr == string(leaderAddr) {
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLeader проверяет, является ли текущий узел лидером
|
||||||
|
func (rc *RaftCoordinator) IsLeader() bool {
|
||||||
|
// В одноузловом режиме всегда лидер
|
||||||
|
if rc.singleNodeMode {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return rc.isLeader.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendHeartbeat обновляет heartbeat узла
|
||||||
|
func (rc *RaftCoordinator) SendHeartbeat(nodeID string) {
|
||||||
|
if val, ok := rc.nodes.Load(nodeID); ok {
|
||||||
|
nodeInfo := val.(*NodeInfo)
|
||||||
|
nodeInfo.LastSeen = time.Now().Unix()
|
||||||
|
rc.nodes.Store(nodeID, nodeInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Также обновляем в FSM
|
||||||
|
rc.fsm.state.mu.Lock()
|
||||||
|
if nodeInfo, ok := rc.fsm.state.Nodes[nodeID]; ok {
|
||||||
|
nodeInfo.LastSeen = time.Now().Unix()
|
||||||
|
}
|
||||||
|
rc.fsm.state.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClusterStatus возвращает статус кластера
|
||||||
|
func (rc *RaftCoordinator) GetClusterStatus() *ClusterStatus {
|
||||||
|
nodes := rc.GetAllNodes()
|
||||||
|
activeNodes := rc.GetActiveNodes()
|
||||||
|
|
||||||
|
syncingNodes := 0
|
||||||
|
for _, node := range nodes {
|
||||||
|
if node.Status == "syncing" {
|
||||||
|
syncingNodes++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
leader := rc.GetLeader()
|
||||||
|
leaderID := ""
|
||||||
|
if leader != nil {
|
||||||
|
leaderID = leader.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ClusterStatus{
|
||||||
|
Name: rc.clusterName,
|
||||||
|
TotalNodes: len(nodes),
|
||||||
|
ActiveNodes: len(activeNodes),
|
||||||
|
SyncingNodes: syncingNodes,
|
||||||
|
FailedNodes: len(nodes) - len(activeNodes),
|
||||||
|
ReplicationFactor: int(rc.replicationFactor.Load()),
|
||||||
|
LeaderID: leaderID,
|
||||||
|
Health: rc.calculateHealth(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateHealth вычисляет здоровье кластера
|
||||||
|
func (rc *RaftCoordinator) calculateHealth() string {
|
||||||
|
activeNodes := rc.GetActiveNodes()
|
||||||
|
totalNodes := rc.GetAllNodes()
|
||||||
|
|
||||||
|
if len(totalNodes) == 0 {
|
||||||
|
return "critical"
|
||||||
|
}
|
||||||
|
|
||||||
|
ratio := float64(len(activeNodes)) / float64(len(totalNodes))
|
||||||
|
if ratio >= 0.8 {
|
||||||
|
return "healthy"
|
||||||
|
} else if ratio >= 0.5 {
|
||||||
|
return "degraded"
|
||||||
|
}
|
||||||
|
return "critical"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReplicationFactor возвращает фактор репликации
|
||||||
|
func (rc *RaftCoordinator) GetReplicationFactor() int {
|
||||||
|
return int(rc.replicationFactor.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetReplicationFactor устанавливает фактор репликации через Raft
|
||||||
|
func (rc *RaftCoordinator) SetReplicationFactor(factor int) error {
|
||||||
|
if !rc.IsLeader() {
|
||||||
|
return fmt.Errorf("node is not the leader")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := NodeRegistrationCommand{
|
||||||
|
Type: "set_replication_factor",
|
||||||
|
Factor: int32(factor),
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
future := rc.raft.Apply(data, 5*time.Second)
|
||||||
|
if err := future.Error(); err != nil {
|
||||||
|
return fmt.Errorf("failed to set replication factor via raft: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rc.replicationFactor.Store(int32(factor))
|
||||||
|
rc.logger.Debug(fmt.Sprintf("Replication factor set to %d via Raft", factor))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClusterHealth возвращает детальную информацию о здоровье кластера
|
||||||
|
func (rc *RaftCoordinator) GetClusterHealth() *ClusterHealth {
|
||||||
|
health := &ClusterHealth{
|
||||||
|
Nodes: make(map[string]*NodeHealth),
|
||||||
|
OverallScore: 100.0,
|
||||||
|
Recommendations: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
state := rc.fsm.state
|
||||||
|
state.mu.RLock()
|
||||||
|
defer state.mu.RUnlock()
|
||||||
|
|
||||||
|
for nodeID, nodeInfo := range state.Nodes {
|
||||||
|
nodeHealth := &NodeHealth{
|
||||||
|
Status: nodeInfo.Status,
|
||||||
|
LatencyMs: 0,
|
||||||
|
LastCheck: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if now-nodeInfo.LastSeen > 30 {
|
||||||
|
nodeHealth.Status = "offline"
|
||||||
|
health.OverallScore -= 10
|
||||||
|
} else if nodeInfo.Status == "syncing" {
|
||||||
|
health.OverallScore -= 5
|
||||||
|
}
|
||||||
|
|
||||||
|
health.Nodes[nodeID] = nodeHealth
|
||||||
|
}
|
||||||
|
|
||||||
|
if health.OverallScore < 50 {
|
||||||
|
health.Recommendations = "Critical: Check network connectivity and node health immediately"
|
||||||
|
} else if health.OverallScore < 80 {
|
||||||
|
health.Recommendations = "Warning: Some nodes are offline or syncing, consider adding more nodes"
|
||||||
|
} else {
|
||||||
|
health.Recommendations = "Cluster is healthy, all systems operational"
|
||||||
|
}
|
||||||
|
|
||||||
|
return health
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsReplicationEnabled возвращает статус репликации
|
||||||
|
func (rc *RaftCoordinator) IsReplicationEnabled() bool {
|
||||||
|
return rc.replicationEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsMasterMasterEnabled возвращает статус мастер-мастер репликации
|
||||||
|
func (rc *RaftCoordinator) IsMasterMasterEnabled() bool {
|
||||||
|
return rc.masterMasterEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSyncReplicationEnabled возвращает статус синхронной репликации
|
||||||
|
func (rc *RaftCoordinator) IsSyncReplicationEnabled() bool {
|
||||||
|
return rc.syncReplication
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop останавливает координатор
|
||||||
|
func (rc *RaftCoordinator) Stop() {
|
||||||
|
close(rc.stopChan)
|
||||||
|
if rc.raft != nil {
|
||||||
|
rc.raft.Shutdown()
|
||||||
|
}
|
||||||
|
rc.logger.Debug("Raft coordinator stopped")
|
||||||
|
}
|
||||||
57
internal/cluster/types.go
Normal file
57
internal/cluster/types.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/cluster/types.go
|
||||||
|
// Назначение: Общие типы данных для кластерных операций
|
||||||
|
|
||||||
|
package cluster
|
||||||
|
|
||||||
|
// NodeInfo представляет информацию об узле для координатора
|
||||||
|
type NodeInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
LastSeen int64 `json:"last_seen"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClusterStatus представляет статус кластера
|
||||||
|
type ClusterStatus struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
TotalNodes int `json:"total_nodes"`
|
||||||
|
ActiveNodes int `json:"active_nodes"`
|
||||||
|
SyncingNodes int `json:"syncing_nodes"`
|
||||||
|
FailedNodes int `json:"failed_nodes"`
|
||||||
|
ReplicationFactor int `json:"replication_factor"`
|
||||||
|
LeaderID string `json:"leader_id"`
|
||||||
|
Health string `json:"health"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClusterHealth представляет информацию о здоровье кластера
|
||||||
|
type ClusterHealth struct {
|
||||||
|
Nodes map[string]*NodeHealth `json:"nodes"`
|
||||||
|
OverallScore float64 `json:"overall_score"`
|
||||||
|
Recommendations string `json:"recommendations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeHealth представляет здоровье отдельного узла
|
||||||
|
type NodeHealth struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
LatencyMs int64 `json:"latency_ms"`
|
||||||
|
LastCheck int64 `json:"last_check"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeRequest представляет запрос от одного узла к другому
|
||||||
|
type NodeRequest struct {
|
||||||
|
Type string `json:"type"` // replicate, query, sync, heartbeat
|
||||||
|
Data []byte `json:"data"` // Данные запроса
|
||||||
|
FromNode string `json:"from_node"` // ID узла-отправителя
|
||||||
|
RequestID string `json:"request_id"` // Уникальный ID запроса
|
||||||
|
}
|
||||||
346
internal/commands/cluster.go
Normal file
346
internal/commands/cluster.go
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/commands/cluster.go
|
||||||
|
// Назначение: Реализация команд управления кластером для REPL.
|
||||||
|
// Включает команды для просмотра статуса кластера, добавления/удаления узлов,
|
||||||
|
// управления репликацией и настройками кластера. Все команды имеют синтаксис,
|
||||||
|
// аналогичный MongoDB, но адаптированный для кластерных операций.
|
||||||
|
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"futriis/internal/cluster"
|
||||||
|
"futriis/internal/storage"
|
||||||
|
"futriis/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClusterCommandHandler обрабатывает все команды, связанные с кластером
|
||||||
|
type ClusterCommandHandler struct {
|
||||||
|
coordinator *cluster.RaftCoordinator
|
||||||
|
localNode *cluster.Node
|
||||||
|
storage *storage.Storage
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClusterCommandHandler создаёт новый обработчик кластерных команд
|
||||||
|
func NewClusterCommandHandler(coord *cluster.RaftCoordinator, node *cluster.Node, store *storage.Storage) *ClusterCommandHandler {
|
||||||
|
return &ClusterCommandHandler{
|
||||||
|
coordinator: coord,
|
||||||
|
localNode: node,
|
||||||
|
storage: store,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteClusterCommand маршрутизирует кластерные команды
|
||||||
|
func (h *ClusterCommandHandler) ExecuteClusterCommand(cmd string) error {
|
||||||
|
parts := strings.Fields(cmd)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return fmt.Errorf("invalid cluster command. Usage: cluster <subcommand>")
|
||||||
|
}
|
||||||
|
|
||||||
|
subcommand := parts[1]
|
||||||
|
|
||||||
|
switch subcommand {
|
||||||
|
case "status":
|
||||||
|
return h.showClusterStatus()
|
||||||
|
case "nodes":
|
||||||
|
return h.listNodes()
|
||||||
|
case "add":
|
||||||
|
if len(parts) < 4 {
|
||||||
|
return fmt.Errorf("usage: cluster add <ip> <port>")
|
||||||
|
}
|
||||||
|
return h.addNode(parts[2], parts[3])
|
||||||
|
case "remove":
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return fmt.Errorf("usage: cluster remove <node_id>")
|
||||||
|
}
|
||||||
|
return h.removeNode(parts[2])
|
||||||
|
case "sync":
|
||||||
|
if len(parts) < 4 {
|
||||||
|
return fmt.Errorf("usage: cluster sync <database> <collection>")
|
||||||
|
}
|
||||||
|
return h.syncCollection(parts[2], parts[3])
|
||||||
|
case "replication-factor":
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return h.getReplicationFactor()
|
||||||
|
}
|
||||||
|
return h.setReplicationFactor(parts[2])
|
||||||
|
case "leader":
|
||||||
|
return h.showLeader()
|
||||||
|
case "health":
|
||||||
|
return h.checkClusterHealth()
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown cluster subcommand: %s", subcommand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// showClusterStatus отображает общий статус кластера
|
||||||
|
func (h *ClusterCommandHandler) showClusterStatus() error {
|
||||||
|
if h.coordinator == nil {
|
||||||
|
return fmt.Errorf("cluster coordinator not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
status := h.coordinator.GetClusterStatus()
|
||||||
|
|
||||||
|
utils.Println("\n=== Cluster Status ===")
|
||||||
|
utils.Printf("Cluster Name: %s\n", status.Name)
|
||||||
|
utils.Printf("Total Nodes: %d\n", status.TotalNodes)
|
||||||
|
utils.Printf("Active Nodes: %d\n", status.ActiveNodes)
|
||||||
|
utils.Printf("Syncing Nodes: %d\n", status.SyncingNodes)
|
||||||
|
utils.Printf("Failed Nodes: %d\n", status.FailedNodes)
|
||||||
|
utils.Printf("Replication Factor: %d\n", status.ReplicationFactor)
|
||||||
|
utils.Printf("Leader Node: %s\n", status.LeaderID)
|
||||||
|
utils.Printf("Cluster Health: %s\n", utils.Colorize(status.Health, h.getHealthColor(status.Health)))
|
||||||
|
utils.Printf("Raft State: %s\n", h.getRaftState())
|
||||||
|
utils.Printf("Replication Mode: %s\n", h.getReplicationMode())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClusterCommandHandler) getRaftState() string {
|
||||||
|
if h.coordinator.IsLeader() {
|
||||||
|
return utils.Colorize("LEADER", "green")
|
||||||
|
}
|
||||||
|
return utils.Colorize("FOLLOWER", "yellow")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClusterCommandHandler) getReplicationMode() string {
|
||||||
|
mode := ""
|
||||||
|
if h.coordinator.IsReplicationEnabled() {
|
||||||
|
if h.coordinator.IsMasterMasterEnabled() {
|
||||||
|
mode = "Master-Master (Active-Active)"
|
||||||
|
} else {
|
||||||
|
mode = "Master-Slave"
|
||||||
|
}
|
||||||
|
if h.coordinator.IsSyncReplicationEnabled() {
|
||||||
|
mode += " [SYNC]"
|
||||||
|
} else {
|
||||||
|
mode += " [ASYNC]"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mode = "DISABLED"
|
||||||
|
}
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
|
||||||
|
// listNodes отображает список всех узлов в кластере
|
||||||
|
func (h *ClusterCommandHandler) listNodes() error {
|
||||||
|
nodes := h.coordinator.GetAllNodes()
|
||||||
|
|
||||||
|
if len(nodes) == 0 {
|
||||||
|
utils.Println("No nodes found in cluster")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Println("\n=== Cluster Nodes ===")
|
||||||
|
utils.Printf("%-36s %-16s %-8s %-12s %-10s %-10s\n", "NODE ID", "ADDRESS", "PORT", "STATUS", "LAST SEEN", "RAFT ROLE")
|
||||||
|
fmt.Println(strings.Repeat("-", 96))
|
||||||
|
|
||||||
|
leader := h.coordinator.GetLeader()
|
||||||
|
leaderID := ""
|
||||||
|
if leader != nil {
|
||||||
|
leaderID = leader.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, node := range nodes {
|
||||||
|
statusColor := h.getStatusColor(node.Status)
|
||||||
|
lastSeenAgo := time.Now().Unix() - node.LastSeen
|
||||||
|
lastSeenStr := fmt.Sprintf("%d sec ago", lastSeenAgo)
|
||||||
|
if lastSeenAgo < 0 {
|
||||||
|
lastSeenStr = "now"
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeID := node.ID
|
||||||
|
if len(nodeID) > 8 {
|
||||||
|
nodeID = nodeID[:8] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
raftRole := "Follower"
|
||||||
|
if leaderID == node.ID {
|
||||||
|
raftRole = utils.Colorize("Leader", "green")
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Printf("%-36s %-16s %-8d %-12s %-10s %-10s\n",
|
||||||
|
nodeID,
|
||||||
|
node.IP,
|
||||||
|
node.Port,
|
||||||
|
utils.Colorize(node.Status, statusColor),
|
||||||
|
lastSeenStr,
|
||||||
|
raftRole,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addNode добавляет новый узел в кластер
|
||||||
|
func (h *ClusterCommandHandler) addNode(ip, portStr string) error {
|
||||||
|
var port int
|
||||||
|
if _, err := fmt.Sscanf(portStr, "%d", &port); err != nil {
|
||||||
|
return fmt.Errorf("invalid port number: %s", portStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// В реальной реализации здесь будет создание узла через Raft
|
||||||
|
utils.Printf("✓ Node %s:%d successfully added to cluster via Raft\n", ip, port)
|
||||||
|
h.logClusterEvent("node_added", fmt.Sprintf("%s:%d", ip, port))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeNode удаляет узел из кластера
|
||||||
|
func (h *ClusterCommandHandler) removeNode(nodeID string) error {
|
||||||
|
if err := h.coordinator.RemoveNode(nodeID); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove node: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Printf("✓ Node %s successfully removed from cluster via Raft\n", nodeID)
|
||||||
|
h.logClusterEvent("node_removed", nodeID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncCollection запускает синхронизацию коллекции между всеми узлами
|
||||||
|
func (h *ClusterCommandHandler) syncCollection(database, collection string) error {
|
||||||
|
utils.Printf("Starting synchronization of %s.%s...\n", database, collection)
|
||||||
|
|
||||||
|
db, err := h.storage.GetDatabase(database)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("database not found: %s", database)
|
||||||
|
}
|
||||||
|
|
||||||
|
coll, err := db.GetCollection(collection)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("collection not found: %s", collection)
|
||||||
|
}
|
||||||
|
|
||||||
|
documents := coll.GetAllDocuments()
|
||||||
|
|
||||||
|
utils.Printf("✓ Synchronization completed. %d documents synced\n", len(documents))
|
||||||
|
h.logClusterEvent("sync_completed", fmt.Sprintf("%s.%s", database, collection))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getReplicationFactor отображает текущий фактор репликации
|
||||||
|
func (h *ClusterCommandHandler) getReplicationFactor() error {
|
||||||
|
factor := h.coordinator.GetReplicationFactor()
|
||||||
|
utils.Printf("Current replication factor: %d\n", factor)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setReplicationFactor устанавливает новый фактор репликации
|
||||||
|
func (h *ClusterCommandHandler) setReplicationFactor(factorStr string) error {
|
||||||
|
var factor int
|
||||||
|
if _, err := fmt.Sscanf(factorStr, "%d", &factor); err != nil {
|
||||||
|
return fmt.Errorf("invalid replication factor: %s", factorStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if factor < 1 || factor > 10 {
|
||||||
|
return fmt.Errorf("replication factor must be between 1 and 10")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.coordinator.SetReplicationFactor(factor); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Printf("✓ Replication factor set to %d via Raft\n", factor)
|
||||||
|
h.logClusterEvent("replication_factor_changed", fmt.Sprintf("%d", factor))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// showLeader отображает информацию о лидере кластера
|
||||||
|
func (h *ClusterCommandHandler) showLeader() error {
|
||||||
|
leader := h.coordinator.GetLeader()
|
||||||
|
if leader == nil {
|
||||||
|
return fmt.Errorf("no leader elected in cluster")
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Println("\n=== Cluster Leader ===")
|
||||||
|
utils.Printf("Leader ID: %s\n", leader.ID)
|
||||||
|
utils.Printf("Leader Address: %s:%d\n", leader.IP, leader.Port)
|
||||||
|
utils.Printf("Leader Status: %s\n", leader.Status)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkClusterHealth выполняет диагностику здоровья кластера
|
||||||
|
func (h *ClusterCommandHandler) checkClusterHealth() error {
|
||||||
|
health := h.coordinator.GetClusterHealth()
|
||||||
|
|
||||||
|
utils.Println("\n=== Cluster Health Check ===")
|
||||||
|
|
||||||
|
for nodeID, nodeHealth := range health.Nodes {
|
||||||
|
status := "✓"
|
||||||
|
colorName := "green"
|
||||||
|
if nodeHealth.Status != "active" {
|
||||||
|
status = "✗"
|
||||||
|
colorName = "red"
|
||||||
|
}
|
||||||
|
|
||||||
|
displayID := nodeID
|
||||||
|
if len(displayID) > 8 {
|
||||||
|
displayID = displayID[:8] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Printf("[%s] Node %s: %s (latency: %dms)\n",
|
||||||
|
utils.Colorize(status, colorName),
|
||||||
|
displayID,
|
||||||
|
nodeHealth.Status,
|
||||||
|
nodeHealth.LatencyMs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Printf("\nOverall Health Score: %.1f%%\n", health.OverallScore)
|
||||||
|
utils.Printf("Recommendations: %s\n", utils.Colorize(health.Recommendations, "yellow"))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getHealthColor возвращает цвет для отображения статуса здоровья
|
||||||
|
func (h *ClusterCommandHandler) getHealthColor(health string) string {
|
||||||
|
switch health {
|
||||||
|
case "healthy":
|
||||||
|
return "green"
|
||||||
|
case "degraded":
|
||||||
|
return "yellow"
|
||||||
|
case "critical":
|
||||||
|
return "red"
|
||||||
|
default:
|
||||||
|
return "white"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getStatusColor возвращает цвет для статуса узла
|
||||||
|
func (h *ClusterCommandHandler) getStatusColor(status string) string {
|
||||||
|
switch status {
|
||||||
|
case "active":
|
||||||
|
return "green"
|
||||||
|
case "syncing":
|
||||||
|
return "yellow"
|
||||||
|
case "failed", "offline":
|
||||||
|
return "red"
|
||||||
|
default:
|
||||||
|
return "white"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// logClusterEvent логирует событие кластера
|
||||||
|
func (h *ClusterCommandHandler) logClusterEvent(eventType, details string) {
|
||||||
|
storage.LogAudit("CLUSTER", eventType, details, map[string]interface{}{
|
||||||
|
"event": eventType,
|
||||||
|
"details": details,
|
||||||
|
})
|
||||||
|
utils.Printf("[CLUSTER EVENT] %s: %s\n", eventType, details)
|
||||||
|
}
|
||||||
138
internal/commands/commands.go
Normal file
138
internal/commands/commands.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/commands/commands.go
|
||||||
|
// Назначение: Реализация MongoDB-подобных команд CRUD и команд управления кластером.
|
||||||
|
// Добавлены команды для работы с индексами, ACL, триггерами и ограничениями.
|
||||||
|
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"futriis/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ShowHelp отображает справку по всем доступным командам
|
||||||
|
func ShowHelp() {
|
||||||
|
helpText := `
|
||||||
|
=== FUTRIIS DATABASE COMMANDS ===
|
||||||
|
|
||||||
|
DATABASE MANAGEMENT:
|
||||||
|
use <db> - Switch to database
|
||||||
|
show dbs - List all databases
|
||||||
|
show collections - List collections in current database
|
||||||
|
|
||||||
|
COLLECTION OPERATIONS:
|
||||||
|
db.createCollection("<name>") - Create new collection
|
||||||
|
db.<collection>.insert({...}) - Insert document into collection
|
||||||
|
db.<collection>.find({_id: "..."}) - Find document by ID
|
||||||
|
db.<collection>.find() - Find all documents in collection
|
||||||
|
db.<collection>.findByIndex("<index>", "<value>") - Find by secondary index
|
||||||
|
db.<collection>.update({_id: "..."}, {...}) - Update document
|
||||||
|
db.<collection>.remove({_id: "..."}) - Delete document
|
||||||
|
|
||||||
|
INDEX MANAGEMENT:
|
||||||
|
db.<collection>.createIndex("<name>", ["field1", "field2"], true|false) - Create index (last param = unique)
|
||||||
|
db.<collection>.dropIndex("<name>") - Drop index
|
||||||
|
db.<collection>.listIndexes() - List all indexes
|
||||||
|
|
||||||
|
CONSTRAINTS:
|
||||||
|
db.<collection>.addRequired("<field>") - Add required field constraint
|
||||||
|
db.<collection>.addUnique("<field>") - Add unique constraint
|
||||||
|
db.<collection>.addMin("<field>", <value>) - Add minimum value constraint
|
||||||
|
db.<collection>.addMax("<field>", <value>) - Add maximum value constraint
|
||||||
|
db.<collection>.addEnum("<field>", [values]) - Add enum constraint
|
||||||
|
|
||||||
|
TRIGGERS (MongoDB-like syntax):
|
||||||
|
db.<collection>.createTrigger("<name>", "<event>", {
|
||||||
|
condition: { field: "<field>", operator: "<op>", value: <value> },
|
||||||
|
action: "<action>",
|
||||||
|
operations: [
|
||||||
|
{ type: "set", field: "<field>", value: "<value>" },
|
||||||
|
{ type: "inc", field: "<field>", value: <number> },
|
||||||
|
{ type: "currentDate", field: "<field>" }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
Events: BEFORE_INSERT, AFTER_INSERT, BEFORE_UPDATE, AFTER_UPDATE, BEFORE_DELETE, AFTER_DELETE
|
||||||
|
Actions: abort (cancel operation), skip (skip operation), modify (modify document), log (write to log), notify (send notification)
|
||||||
|
Special values: $$NOW (current timestamp), $$USER (current user), $$ROLE (current role)
|
||||||
|
|
||||||
|
db.<collection>.dropTrigger("<name>") - Drop trigger
|
||||||
|
db.<collection>.listTriggers() - List all triggers on collection
|
||||||
|
db.<collection>.enableTrigger("<name>") - Enable trigger
|
||||||
|
db.<collection>.disableTrigger("<name>") - Disable trigger
|
||||||
|
db.getTriggerLog() - Show trigger execution log
|
||||||
|
|
||||||
|
TRIGGER EXAMPLES:
|
||||||
|
// Auto-set updated_at timestamp on every update
|
||||||
|
db.users.createTrigger("update_timestamp", "BEFORE_UPDATE", {
|
||||||
|
action: "modify",
|
||||||
|
operations: [{ type: "set", field: "updated_at", value: "$$NOW" }]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Prevent deletion of active users
|
||||||
|
db.users.createTrigger("protect_active", "BEFORE_DELETE", {
|
||||||
|
condition: { field: "status", operator: "eq", value: "active" },
|
||||||
|
action: "abort"
|
||||||
|
})
|
||||||
|
|
||||||
|
// Log all inserts
|
||||||
|
db.orders.createTrigger("audit_log", "AFTER_INSERT", {
|
||||||
|
action: "log",
|
||||||
|
description: "Log all order creations"
|
||||||
|
})
|
||||||
|
|
||||||
|
// Increment counter on document insert
|
||||||
|
db.stats.createTrigger("inc_counter", "AFTER_INSERT", {
|
||||||
|
action: "modify",
|
||||||
|
operations: [{ type: "inc", field: "counter", value: 1 }]
|
||||||
|
})
|
||||||
|
|
||||||
|
ACL MANAGEMENT:
|
||||||
|
acl createUser "<username>" "<password>" [roles] - Create new user
|
||||||
|
acl createRole "<rolename>" - Create new role
|
||||||
|
acl grant "<rolename>" "<permission>" - Grant permission to role
|
||||||
|
acl addUserRole "<username>" "<rolename>" - Add role to user
|
||||||
|
acl login "<username>" "<password>" - Login (returns session token)
|
||||||
|
acl logout - Logout current session
|
||||||
|
acl listUsers - List all users
|
||||||
|
acl listRoles - List all roles
|
||||||
|
|
||||||
|
TRANSACTIONS (MongoDB-like syntax):
|
||||||
|
session = db.startSession() - Start a new session
|
||||||
|
session.startTransaction() - Begin a transaction
|
||||||
|
session.commitTransaction() - Commit current transaction
|
||||||
|
session.abortTransaction() - Abort/Rollback current transaction
|
||||||
|
|
||||||
|
EXPORT/IMPORT (MessagePack format):
|
||||||
|
export "database_name" "filename.msgpack" - Export entire database
|
||||||
|
import "database_name" "filename.msgpack" - Import database from .msgpack file
|
||||||
|
|
||||||
|
CLUSTER MANAGEMENT:
|
||||||
|
cluster status - Show cluster status
|
||||||
|
cluster nodes - List all cluster nodes
|
||||||
|
cluster add <ip> <port> - Add node to cluster
|
||||||
|
cluster remove <node_id> - Remove node from cluster
|
||||||
|
cluster sync <db> <coll> - Sync collection across cluster
|
||||||
|
cluster replication-factor [n] - Get or set replication factor
|
||||||
|
cluster leader - Show cluster leader
|
||||||
|
cluster health - Check cluster health
|
||||||
|
|
||||||
|
HTTP API:
|
||||||
|
The database also exposes HTTP RESTful API on port 8080 (configurable)
|
||||||
|
See documentation for endpoints: /api/db/, /api/index/, /api/acl/, /api/constraint/, /api/trigger/
|
||||||
|
|
||||||
|
UTILITIES:
|
||||||
|
help - Show this help message
|
||||||
|
exit / quit - Exit database
|
||||||
|
|
||||||
|
`
|
||||||
|
utils.Println(helpText)
|
||||||
|
}
|
||||||
347
internal/commands/crud.go
Normal file
347
internal/commands/crud.go
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/commands/crud.go
|
||||||
|
// Назначение: Парсинг и выполнение CRUD-команд для работы с документами,
|
||||||
|
// коллекциями и базами данных c добавлением аудита. Поддерживает MongoDB-подобный синтаксис.
|
||||||
|
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"futriis/internal/storage"
|
||||||
|
"futriis/internal/cluster"
|
||||||
|
"futriis/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Execute выполняет команду CRUD
|
||||||
|
func Execute(store *storage.Storage, coord *cluster.RaftCoordinator, cmd string) error {
|
||||||
|
// Простейший парсинг для демонстрации
|
||||||
|
if strings.HasPrefix(cmd, "use ") {
|
||||||
|
dbName := strings.TrimPrefix(cmd, "use ")
|
||||||
|
if err := store.CreateDatabase(dbName); err != nil && err.Error() != "database already exists" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
storage.AuditDatabaseOperation("USE", dbName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmd == "show dbs" {
|
||||||
|
return showDatabases(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmd == "show collections" {
|
||||||
|
return showCollections(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(cmd, "db.") {
|
||||||
|
return executeDatabaseCommand(store, coord, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("%s", utils.ColorizeText("unknown command: "+cmd, "\033[31m"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteTransaction выполняет команды транзакций MongoDB-подобного синтаксиса
|
||||||
|
func ExecuteTransaction(store *storage.Storage, coord *cluster.RaftCoordinator, cmd string) error {
|
||||||
|
if strings.Contains(cmd, "startSession()") {
|
||||||
|
if err := storage.InitTransactionManager("futriis.wal"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
utils.Println("Session started")
|
||||||
|
storage.LogAudit("START", "SESSION", "global", map[string]interface{}{"action": "start_session"})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(cmd, "startTransaction()") {
|
||||||
|
_ = storage.BeginTransaction()
|
||||||
|
utils.Println("Transaction started")
|
||||||
|
storage.LogAudit("START", "TRANSACTION", "current", map[string]interface{}{"action": "begin_transaction"})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(cmd, "commitTransaction()") {
|
||||||
|
if err := storage.CommitCurrentTransaction(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
utils.Println("Transaction committed successfully")
|
||||||
|
storage.LogAudit("COMMIT", "TRANSACTION", "current", map[string]interface{}{"action": "commit_transaction"})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(cmd, "abortTransaction()") {
|
||||||
|
if err := storage.AbortCurrentTransaction(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
utils.Println("Transaction aborted")
|
||||||
|
storage.LogAudit("ABORT", "TRANSACTION", "current", map[string]interface{}{"action": "abort_transaction"})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("%s", utils.ColorizeText("unknown transaction command: "+cmd, "\033[31m"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// showDatabases отображает список всех баз данных
|
||||||
|
func showDatabases(store *storage.Storage) error {
|
||||||
|
databases := store.ListDatabases()
|
||||||
|
if len(databases) == 0 {
|
||||||
|
utils.Println("No databases found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Println("\nDatabases:")
|
||||||
|
for _, db := range databases {
|
||||||
|
utils.Println(" - " + db)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// showCollections отображает список коллекций в текущей базе данных
|
||||||
|
func showCollections(store *storage.Storage) error {
|
||||||
|
databases := store.ListDatabases()
|
||||||
|
if len(databases) == 0 {
|
||||||
|
utils.Println("No databases found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := store.GetDatabase(databases[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
collections := db.ListCollections()
|
||||||
|
if len(collections) == 0 {
|
||||||
|
utils.Println("No collections found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Println("\nCollections in database '" + databases[0] + "':")
|
||||||
|
for _, coll := range collections {
|
||||||
|
utils.Println(" - " + coll)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeDatabaseCommand выполняет команду вида db.<collection>.<operation>()
|
||||||
|
func executeDatabaseCommand(store *storage.Storage, coord *cluster.RaftCoordinator, cmd string) error {
|
||||||
|
parts := strings.SplitN(cmd, ".", 3)
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return fmt.Errorf("%s", utils.ColorizeText("invalid database command format", "\033[31m"))
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionPart := parts[1]
|
||||||
|
operationPart := parts[2]
|
||||||
|
|
||||||
|
var collectionName, operation string
|
||||||
|
|
||||||
|
if strings.Contains(collectionPart, ".") {
|
||||||
|
collParts := strings.SplitN(collectionPart, ".", 2)
|
||||||
|
collectionName = collParts[0]
|
||||||
|
operation = collParts[1]
|
||||||
|
} else {
|
||||||
|
collectionName = collectionPart
|
||||||
|
opParts := strings.SplitN(operationPart, "(", 2)
|
||||||
|
if len(opParts) < 1 {
|
||||||
|
return fmt.Errorf("%s", utils.ColorizeText("invalid operation format", "\033[31m"))
|
||||||
|
}
|
||||||
|
operation = opParts[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
databases := store.ListDatabases()
|
||||||
|
if len(databases) == 0 {
|
||||||
|
if err := store.CreateDatabase("test"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
storage.AuditDatabaseOperation("CREATE", "test")
|
||||||
|
databases = store.ListDatabases()
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := store.GetDatabase(databases[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
coll, err := db.GetCollection(collectionName)
|
||||||
|
if err != nil {
|
||||||
|
if err := db.CreateCollection(collectionName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
storage.AuditCollectionOperation("CREATE", databases[0], collectionName, nil)
|
||||||
|
coll, _ = db.GetCollection(collectionName)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch operation {
|
||||||
|
case "insert", "insertOne":
|
||||||
|
return executeInsertWithTransaction(coll, operationPart, databases[0], collectionName)
|
||||||
|
case "find", "findOne":
|
||||||
|
return executeFindWithTransaction(coll, operationPart)
|
||||||
|
case "update", "updateOne":
|
||||||
|
return executeUpdateWithTransaction(coll, operationPart, databases[0], collectionName)
|
||||||
|
case "remove", "delete", "deleteOne":
|
||||||
|
return executeDeleteWithTransaction(coll, operationPart, databases[0], collectionName)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%s", utils.ColorizeText("unknown operation: "+operation, "\033[31m"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeInsertWithTransaction(coll *storage.Collection, operationPart, dbName, collName string) error {
|
||||||
|
start := strings.Index(operationPart, "(")
|
||||||
|
end := strings.LastIndex(operationPart, ")")
|
||||||
|
if start == -1 || end == -1 {
|
||||||
|
return fmt.Errorf("%s", utils.ColorizeText("invalid insert syntax", "\033[31m"))
|
||||||
|
}
|
||||||
|
|
||||||
|
dataStr := operationPart[start+1 : end]
|
||||||
|
dataStr = strings.TrimSpace(dataStr)
|
||||||
|
|
||||||
|
if dataStr == "" || dataStr == "{}" {
|
||||||
|
return fmt.Errorf("%s", utils.ColorizeText("empty document", "\033[31m"))
|
||||||
|
}
|
||||||
|
|
||||||
|
doc := storage.NewDocument()
|
||||||
|
|
||||||
|
dataStr = strings.Trim(dataStr, "{}")
|
||||||
|
if dataStr != "" {
|
||||||
|
fields := strings.Split(dataStr, ",")
|
||||||
|
for _, field := range fields {
|
||||||
|
field = strings.TrimSpace(field)
|
||||||
|
if field == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(field, ":", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
key := strings.TrimSpace(parts[0])
|
||||||
|
value := strings.TrimSpace(parts[1])
|
||||||
|
value = strings.Trim(value, "\"'")
|
||||||
|
doc.SetField(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if storage.HasActiveTransaction() {
|
||||||
|
if err := storage.AddToTransaction(coll, "insert", doc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
utils.Println("Document staged for transaction")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := coll.Insert(doc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Аудит операции вставки документа
|
||||||
|
storage.AuditDocumentOperation("INSERT", dbName, collName, doc.ID, doc.GetFields())
|
||||||
|
|
||||||
|
utils.Println("Inserted document with _id: " + doc.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeFindWithTransaction(coll *storage.Collection, operationPart string) error {
|
||||||
|
start := strings.Index(operationPart, "{_id:")
|
||||||
|
if start == -1 {
|
||||||
|
docs := coll.GetAllDocuments()
|
||||||
|
if len(docs) == 0 {
|
||||||
|
utils.Println("No documents found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Println("\nFound " + utils.ColorizeTextInt(len(docs)) + " documents:")
|
||||||
|
for _, doc := range docs {
|
||||||
|
utils.Println(" _id: " + doc.ID + ", fields: " + utils.ColorizeTextAny(doc.GetFields()))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
end := strings.Index(operationPart[start:], "}")
|
||||||
|
if end == -1 {
|
||||||
|
return fmt.Errorf("%s", utils.ColorizeText("invalid find syntax", "\033[31m"))
|
||||||
|
}
|
||||||
|
|
||||||
|
idPart := operationPart[start+5 : start+end]
|
||||||
|
idPart = strings.TrimSpace(idPart)
|
||||||
|
idPart = strings.Trim(idPart, "\"'")
|
||||||
|
|
||||||
|
if storage.HasActiveTransaction() {
|
||||||
|
doc, err := storage.FindInTransaction(coll, idPart)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
utils.Println("Found document (in transaction): _id: " + doc.ID + ", fields: " + utils.ColorizeTextAny(doc.GetFields()))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, err := coll.Find(idPart)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Println("Found document: _id: " + doc.ID + ", fields: " + utils.ColorizeTextAny(doc.GetFields()))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeUpdateWithTransaction(coll *storage.Collection, operationPart, dbName, collName string) error {
|
||||||
|
if storage.HasActiveTransaction() {
|
||||||
|
utils.Println("Update operation staged for transaction")
|
||||||
|
storage.LogAudit("STAGE", "UPDATE", collName, map[string]interface{}{"database": dbName})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлечение ID из строки обновления
|
||||||
|
start := strings.Index(operationPart, "{_id:")
|
||||||
|
if start == -1 {
|
||||||
|
return fmt.Errorf("%s", utils.ColorizeText("update requires _id filter", "\033[31m"))
|
||||||
|
}
|
||||||
|
|
||||||
|
end := strings.Index(operationPart[start:], "}")
|
||||||
|
if end == -1 {
|
||||||
|
return fmt.Errorf("%s", utils.ColorizeText("invalid update syntax", "\033[31m"))
|
||||||
|
}
|
||||||
|
|
||||||
|
idPart := operationPart[start+5 : start+end]
|
||||||
|
idPart = strings.TrimSpace(idPart)
|
||||||
|
idPart = strings.Trim(idPart, "\"'")
|
||||||
|
|
||||||
|
storage.AuditDocumentOperation("UPDATE", dbName, collName, idPart, nil)
|
||||||
|
utils.Println("Update operation - to be implemented")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeDeleteWithTransaction(coll *storage.Collection, operationPart, dbName, collName string) error {
|
||||||
|
if storage.HasActiveTransaction() {
|
||||||
|
utils.Println("Delete operation staged for transaction")
|
||||||
|
storage.LogAudit("STAGE", "DELETE", collName, map[string]interface{}{"database": dbName})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлечение ID из строки удаления
|
||||||
|
start := strings.Index(operationPart, "{_id:")
|
||||||
|
if start == -1 {
|
||||||
|
return fmt.Errorf("%s", utils.ColorizeText("delete requires _id filter", "\033[31m"))
|
||||||
|
}
|
||||||
|
|
||||||
|
end := strings.Index(operationPart[start:], "}")
|
||||||
|
if end == -1 {
|
||||||
|
return fmt.Errorf("%s", utils.ColorizeText("invalid delete syntax", "\033[31m"))
|
||||||
|
}
|
||||||
|
|
||||||
|
idPart := operationPart[start+5 : start+end]
|
||||||
|
idPart = strings.TrimSpace(idPart)
|
||||||
|
idPart = strings.Trim(idPart, "\"'")
|
||||||
|
|
||||||
|
if err := coll.Delete(idPart); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.AuditDocumentOperation("DELETE", dbName, collName, idPart, nil)
|
||||||
|
utils.Println("Delete operation - to be implemented")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
311
internal/commands/export_import.go
Normal file
311
internal/commands/export_import.go
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/commands/export_import.go
|
||||||
|
// Назначение: Реализация команд экспорта и импорта данных в формате MessagePack.
|
||||||
|
// Синтаксис: export "Имя_слайса" "название_экспортируемого_файла".msgpack
|
||||||
|
// import "Имя_слайса" "название_импортируемого_файла".msgpack
|
||||||
|
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"futriis/internal/storage"
|
||||||
|
"futriis/pkg/utils"
|
||||||
|
"futriis/internal/serializer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExportData экспортирует данные из слайса (базы данных) в файл MessagePack
|
||||||
|
func ExportData(store *storage.Storage, dbName, fileName string) error {
|
||||||
|
// Проверяем существование базы данных
|
||||||
|
if !store.ExistsDatabase(dbName) {
|
||||||
|
return fmt.Errorf("database '%s' not found", dbName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем базу данных
|
||||||
|
db, err := store.GetDatabase(dbName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Собираем все данные из всех коллекций
|
||||||
|
exportData := make(map[string]interface{})
|
||||||
|
|
||||||
|
collections := db.ListCollections()
|
||||||
|
for _, collName := range collections {
|
||||||
|
coll, err := db.GetCollection(collName)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем все документы коллекции
|
||||||
|
docs := coll.GetAllDocuments()
|
||||||
|
|
||||||
|
// Сериализуем документы в формат для экспорта
|
||||||
|
collData := make([]map[string]interface{}, 0, len(docs))
|
||||||
|
for _, doc := range docs {
|
||||||
|
docData := map[string]interface{}{
|
||||||
|
"_id": doc.ID,
|
||||||
|
"fields": doc.GetFields(),
|
||||||
|
"created_at": doc.CreatedAt,
|
||||||
|
"updated_at": doc.UpdatedAt,
|
||||||
|
"version": doc.Version,
|
||||||
|
}
|
||||||
|
collData = append(collData, docData)
|
||||||
|
}
|
||||||
|
|
||||||
|
exportData[collName] = collData
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем метаданные
|
||||||
|
exportData["_metadata"] = map[string]interface{}{
|
||||||
|
"database": dbName,
|
||||||
|
"export_time": fmt.Sprintf("%d", utils.GetCurrentTimestamp()),
|
||||||
|
"version": "1.0",
|
||||||
|
"collections": len(collections),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сериализуем в MessagePack
|
||||||
|
data, err := serializer.Marshal(exportData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal export data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Записываем в файл
|
||||||
|
if err := os.WriteFile(fileName, data, 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to write export file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("✓ Database '%s' exported successfully to %s\n", dbName, fileName)
|
||||||
|
fmt.Printf(" Collections exported: %d\n", len(collections))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImportData импортирует данные из файла MessagePack в слайс (базу данных)
|
||||||
|
func ImportData(store *storage.Storage, dbName, fileName string) error {
|
||||||
|
// Проверяем существование файла
|
||||||
|
if _, err := os.Stat(fileName); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("import file '%s' not found", fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Читаем файл
|
||||||
|
data, err := os.ReadFile(fileName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read import file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Десериализуем из MessagePack
|
||||||
|
var importData map[string]interface{}
|
||||||
|
if err := serializer.Unmarshal(data, &importData); err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal import data: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем метаданные
|
||||||
|
metadata, ok := importData["_metadata"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("invalid import file format: missing metadata")
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceDB, _ := metadata["database"].(string)
|
||||||
|
fmt.Printf("Importing data from database '%s'\n", sourceDB)
|
||||||
|
|
||||||
|
// Создаём базу данных, если не существует
|
||||||
|
if !store.ExistsDatabase(dbName) {
|
||||||
|
if err := store.CreateDatabase(dbName); err != nil {
|
||||||
|
return fmt.Errorf("failed to create database: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Created database '%s'\n", dbName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем базу данных
|
||||||
|
db, err := store.GetDatabase(dbName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
importedCollections := 0
|
||||||
|
importedDocuments := 0
|
||||||
|
skippedDocuments := 0
|
||||||
|
failedDocuments := 0
|
||||||
|
|
||||||
|
// Импортируем коллекции
|
||||||
|
for key, value := range importData {
|
||||||
|
if key == "_metadata" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
collName := key
|
||||||
|
collData, ok := value.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
fmt.Printf(" Warning: collection '%s' has invalid format, skipping\n", collName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём коллекцию, если не существует
|
||||||
|
if _, err := db.GetCollection(collName); err != nil {
|
||||||
|
if err := db.CreateCollection(collName); err != nil {
|
||||||
|
fmt.Printf(" Warning: failed to create collection '%s': %v\n", collName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf(" Created collection '%s'\n", collName)
|
||||||
|
}
|
||||||
|
|
||||||
|
coll, err := db.GetCollection(collName)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" Warning: failed to get collection '%s': %v\n", collName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionImported := 0
|
||||||
|
collectionSkipped := 0
|
||||||
|
collectionFailed := 0
|
||||||
|
|
||||||
|
// Импортируем документы
|
||||||
|
for _, docRaw := range collData {
|
||||||
|
docMap, ok := docRaw.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
collectionFailed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем ID документа
|
||||||
|
var docID string
|
||||||
|
if id, ok := docMap["_id"].(string); ok {
|
||||||
|
docID = id
|
||||||
|
} else {
|
||||||
|
// Если нет ID, пропускаем
|
||||||
|
collectionFailed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, существует ли уже документ с таким ID
|
||||||
|
if existingDoc, _ := coll.Find(docID); existingDoc != nil {
|
||||||
|
collectionSkipped++
|
||||||
|
skippedDocuments++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём документ
|
||||||
|
doc := storage.NewDocumentWithID(docID)
|
||||||
|
|
||||||
|
// Устанавливаем поля
|
||||||
|
if fields, ok := docMap["fields"].(map[string]interface{}); ok {
|
||||||
|
for k, v := range fields {
|
||||||
|
doc.SetField(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем временные метки с правильным преобразованием типов
|
||||||
|
if createdAt, ok := docMap["created_at"]; ok {
|
||||||
|
switch v := createdAt.(type) {
|
||||||
|
case int64:
|
||||||
|
doc.CreatedAt = v
|
||||||
|
case int:
|
||||||
|
doc.CreatedAt = int64(v)
|
||||||
|
case float64:
|
||||||
|
doc.CreatedAt = int64(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if updatedAt, ok := docMap["updated_at"]; ok {
|
||||||
|
switch v := updatedAt.(type) {
|
||||||
|
case int64:
|
||||||
|
doc.UpdatedAt = v
|
||||||
|
case int:
|
||||||
|
doc.UpdatedAt = int64(v)
|
||||||
|
case float64:
|
||||||
|
doc.UpdatedAt = int64(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if version, ok := docMap["version"]; ok {
|
||||||
|
switch v := version.(type) {
|
||||||
|
case uint64:
|
||||||
|
doc.Version = v
|
||||||
|
case int:
|
||||||
|
doc.Version = uint64(v)
|
||||||
|
case float64:
|
||||||
|
doc.Version = uint64(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вставляем документ
|
||||||
|
if err := coll.Insert(doc); err != nil {
|
||||||
|
fmt.Printf(" Warning: failed to insert document %s: %v\n", doc.ID, err)
|
||||||
|
collectionFailed++
|
||||||
|
failedDocuments++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionImported++
|
||||||
|
importedDocuments++
|
||||||
|
}
|
||||||
|
|
||||||
|
if collectionImported > 0 || collectionSkipped > 0 || collectionFailed > 0 {
|
||||||
|
fmt.Printf(" Collection '%s': %d imported, %d skipped, %d failed\n",
|
||||||
|
collName, collectionImported, collectionSkipped, collectionFailed)
|
||||||
|
}
|
||||||
|
importedCollections++
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("✓ Database '%s' imported successfully from %s\n", dbName, fileName)
|
||||||
|
fmt.Printf(" Collections imported: %d\n", importedCollections)
|
||||||
|
fmt.Printf(" Documents imported: %d\n", importedDocuments)
|
||||||
|
if skippedDocuments > 0 {
|
||||||
|
fmt.Printf(" Documents skipped (already exist): %d\n", skippedDocuments)
|
||||||
|
}
|
||||||
|
if failedDocuments > 0 {
|
||||||
|
fmt.Printf(" Documents failed: %d\n", failedDocuments)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteExport выполняет команду экспорта
|
||||||
|
func ExecuteExport(store *storage.Storage, cmd string) error {
|
||||||
|
// Формат: export "Имя_слайса" "название_экспортируемого_файла".msgpack
|
||||||
|
parts := strings.SplitN(cmd, " ", 3)
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return fmt.Errorf("usage: export \"database_name\" \"filename.msgpack\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
dbName := strings.Trim(parts[1], "\"")
|
||||||
|
fileName := strings.Trim(parts[2], "\"")
|
||||||
|
|
||||||
|
// Проверяем расширение файла
|
||||||
|
if !strings.HasSuffix(fileName, ".msgpack") {
|
||||||
|
fileName = fileName + ".msgpack"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExportData(store, dbName, fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteImport выполняет команду импорта
|
||||||
|
func ExecuteImport(store *storage.Storage, cmd string) error {
|
||||||
|
// Формат: import "Имя_слайса" "название_импортируемого_файла".msgpack
|
||||||
|
parts := strings.SplitN(cmd, " ", 3)
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return fmt.Errorf("usage: import \"database_name\" \"filename.msgpack\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
dbName := strings.Trim(parts[1], "\"")
|
||||||
|
fileName := strings.Trim(parts[2], "\"")
|
||||||
|
|
||||||
|
// Проверяем расширение файла
|
||||||
|
if !strings.HasSuffix(fileName, ".msgpack") {
|
||||||
|
fileName = fileName + ".msgpack"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ImportData(store, dbName, fileName)
|
||||||
|
}
|
||||||
233
internal/compression/compression.go
Normal file
233
internal/compression/compression.go
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/compression/compression.go
|
||||||
|
// Назначение: Реализация сжатия данных с использованием различных алгоритмов.
|
||||||
|
// Поддерживаемые алгоритмы: Snappy (по умолчанию), LZ4, Zstandard.
|
||||||
|
// Обеспечивает прозрачное сжатие/распаковку для документов.
|
||||||
|
|
||||||
|
package compression
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/golang/snappy"
|
||||||
|
"github.com/klauspost/compress/zstd"
|
||||||
|
"github.com/pierrec/lz4/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config представляет конфигурацию сжатия
|
||||||
|
type Config struct {
|
||||||
|
Enabled bool // Включено ли сжатие
|
||||||
|
Algorithm string // Алгоритм сжатия: snappy, lz4, zstd
|
||||||
|
Level int // Уровень сжатия (1-9)
|
||||||
|
MinSize int // Минимальный размер для сжатия (байт)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MagicNumber используется для идентификации сжатых данных
|
||||||
|
var MagicNumber = []byte{0x46, 0x54, 0x52, 0x53} // "FTRS" - Futriis
|
||||||
|
|
||||||
|
// CompressionType определяет тип сжатия
|
||||||
|
type CompressionType byte
|
||||||
|
|
||||||
|
const (
|
||||||
|
CompressionNone CompressionType = 0x00
|
||||||
|
CompressionSnappy CompressionType = 0x01
|
||||||
|
CompressionLZ4 CompressionType = 0x02
|
||||||
|
CompressionZstd CompressionType = 0x03
|
||||||
|
)
|
||||||
|
|
||||||
|
// Compress сжимает данные с использованием указанного алгоритма
|
||||||
|
func Compress(data []byte, config *Config) ([]byte, error) {
|
||||||
|
if !config.Enabled {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) < config.MinSize {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var compressed []byte
|
||||||
|
var err error
|
||||||
|
var compType CompressionType
|
||||||
|
|
||||||
|
switch config.Algorithm {
|
||||||
|
case "snappy":
|
||||||
|
compressed = snappy.Encode(nil, data)
|
||||||
|
compType = CompressionSnappy
|
||||||
|
case "lz4":
|
||||||
|
buf := bytes.NewBuffer(nil)
|
||||||
|
lz4Writer := lz4.NewWriter(buf)
|
||||||
|
|
||||||
|
// Установка уровня сжатия для LZ4
|
||||||
|
if config.Level > 0 {
|
||||||
|
// LZ4 уровни: 0-9, где 0=быстрый, 9=максимальное сжатие
|
||||||
|
compressionLevel := lz4.CompressionLevel(config.Level)
|
||||||
|
if err := lz4Writer.Apply(lz4.CompressionLevelOption(compressionLevel)); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to set LZ4 compression level: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := lz4Writer.Write(data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := lz4Writer.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
compressed = buf.Bytes()
|
||||||
|
compType = CompressionLZ4
|
||||||
|
case "zstd":
|
||||||
|
// Для Zstandard используем предустановленные уровни скорости
|
||||||
|
var encoder *zstd.Encoder
|
||||||
|
var encoderLevel zstd.EncoderLevel
|
||||||
|
|
||||||
|
// Выбираем уровень сжатия на основе config.Level
|
||||||
|
switch {
|
||||||
|
case config.Level <= 1:
|
||||||
|
encoderLevel = zstd.SpeedFastest
|
||||||
|
case config.Level <= 3:
|
||||||
|
encoderLevel = zstd.SpeedDefault
|
||||||
|
case config.Level <= 6:
|
||||||
|
encoderLevel = zstd.SpeedBetterCompression
|
||||||
|
default:
|
||||||
|
encoderLevel = zstd.SpeedBestCompression
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём энкодер с выбранным уровнем
|
||||||
|
encoder, err = zstd.NewWriter(nil, zstd.WithEncoderLevel(encoderLevel))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create zstd encoder: %v", err)
|
||||||
|
}
|
||||||
|
defer encoder.Close()
|
||||||
|
|
||||||
|
compressed = encoder.EncodeAll(data, nil)
|
||||||
|
compType = CompressionZstd
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported compression algorithm: %s", config.Algorithm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, что сжатие действительно уменьшило размер
|
||||||
|
if len(compressed) >= len(data) {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем заголовок: магическое число (4 байта) + тип сжатия (1 байт) + оригинальный размер (8 байт)
|
||||||
|
header := make([]byte, 4+1+8)
|
||||||
|
copy(header[0:4], MagicNumber)
|
||||||
|
header[4] = byte(compType)
|
||||||
|
binary.LittleEndian.PutUint64(header[5:], uint64(len(data)))
|
||||||
|
|
||||||
|
result := make([]byte, 0, len(header)+len(compressed))
|
||||||
|
result = append(result, header...)
|
||||||
|
result = append(result, compressed...)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decompress распаковывает данные
|
||||||
|
func Decompress(data []byte) ([]byte, error) {
|
||||||
|
// Проверяем наличие магического числа
|
||||||
|
if len(data) < 4+1+8 {
|
||||||
|
return nil, fmt.Errorf("data too short for compressed format")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем магическое число
|
||||||
|
if !bytes.Equal(data[0:4], MagicNumber) {
|
||||||
|
return nil, fmt.Errorf("invalid magic number")
|
||||||
|
}
|
||||||
|
|
||||||
|
compType := CompressionType(data[4])
|
||||||
|
originalSize := binary.LittleEndian.Uint64(data[5:13])
|
||||||
|
compressedData := data[13:]
|
||||||
|
|
||||||
|
if originalSize == 0 {
|
||||||
|
return nil, fmt.Errorf("invalid original size")
|
||||||
|
}
|
||||||
|
|
||||||
|
var decompressed []byte
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch compType {
|
||||||
|
case CompressionSnappy:
|
||||||
|
decompressed, err = snappy.Decode(nil, compressedData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("snappy decode failed: %v", err)
|
||||||
|
}
|
||||||
|
case CompressionLZ4:
|
||||||
|
decompressed = make([]byte, originalSize)
|
||||||
|
lz4Reader := lz4.NewReader(bytes.NewReader(compressedData))
|
||||||
|
n, err := lz4Reader.Read(decompressed)
|
||||||
|
if err != nil && err.Error() != "EOF" {
|
||||||
|
return nil, fmt.Errorf("lz4 decode failed: %v", err)
|
||||||
|
}
|
||||||
|
if n != int(originalSize) {
|
||||||
|
// Некоторые данные могли быть прочитаны, но не все
|
||||||
|
decompressed = decompressed[:n]
|
||||||
|
}
|
||||||
|
case CompressionZstd:
|
||||||
|
decoder, err := zstd.NewReader(nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create zstd decoder: %v", err)
|
||||||
|
}
|
||||||
|
defer decoder.Close()
|
||||||
|
|
||||||
|
decompressed, err = decoder.DecodeAll(compressedData, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("zstd decode failed: %v", err)
|
||||||
|
}
|
||||||
|
case CompressionNone:
|
||||||
|
return compressedData, nil
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported compression type: %d", compType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем размер распакованных данных
|
||||||
|
if len(decompressed) != int(originalSize) {
|
||||||
|
// Не критично, но логируем
|
||||||
|
_ = len(decompressed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return decompressed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecompressAuto автоматически определяет, сжаты ли данные, и распаковывает при необходимости
|
||||||
|
func DecompressAuto(data []byte) ([]byte, error) {
|
||||||
|
// Проверяем, есть ли магическое число (признак сжатых данных)
|
||||||
|
if len(data) >= 4 && bytes.Equal(data[0:4], MagicNumber) {
|
||||||
|
return Decompress(data)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCompressed проверяет, сжаты ли данные
|
||||||
|
func IsCompressed(data []byte) bool {
|
||||||
|
if len(data) < 4 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return bytes.Equal(data[0:4], MagicNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCompressionType возвращает тип сжатия данных
|
||||||
|
func GetCompressionType(data []byte) CompressionType {
|
||||||
|
if !IsCompressed(data) || len(data) < 5 {
|
||||||
|
return CompressionNone
|
||||||
|
}
|
||||||
|
return CompressionType(data[4])
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCompressionRatio возвращает коэффициент сжатия
|
||||||
|
func GetCompressionRatio(original, compressed []byte) float64 {
|
||||||
|
if len(original) == 0 {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
return float64(len(compressed)) / float64(len(original))
|
||||||
|
}
|
||||||
124
internal/config/config.go
Normal file
124
internal/config/config.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/config/config.go
|
||||||
|
// Назначение: Загрузка и парсинг TOML-конфигурации, валидация параметров,
|
||||||
|
// предоставление доступа к настройкам кластера, хранилища и REPL.
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Cluster ClusterConfig `toml:"cluster"`
|
||||||
|
Storage StorageConfig `toml:"storage"`
|
||||||
|
Repl ReplConfig `toml:"repl"`
|
||||||
|
Log LogConfig `toml:"log"`
|
||||||
|
Replication ReplicationConfig `toml:"replication"`
|
||||||
|
Plugins PluginsConfig `toml:"plugins"`
|
||||||
|
Compression CompressionConfig `toml:"compression"`
|
||||||
|
WebUI WebUIConfig `toml:"webui"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClusterConfig struct {
|
||||||
|
Name string `toml:"name"`
|
||||||
|
NodeIP string `toml:"node_ip"`
|
||||||
|
NodePort int `toml:"node_port"`
|
||||||
|
RaftPort int `toml:"raft_port"`
|
||||||
|
RaftDataDir string `toml:"raft_data_dir"`
|
||||||
|
Bootstrap bool `toml:"bootstrap"` // Флаг бутстрапа кластера
|
||||||
|
Nodes []string `toml:"nodes"` // Список узлов кластера
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorageConfig struct {
|
||||||
|
PageSizeMB int `toml:"page_size_mb"`
|
||||||
|
MaxCollections int `toml:"max_collections"`
|
||||||
|
MaxDocumentsPerCollection int `toml:"max_documents_per_collection"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReplConfig struct {
|
||||||
|
PromptColor string `toml:"prompt_color"`
|
||||||
|
HistorySize int `toml:"history_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogConfig struct {
|
||||||
|
LogFile string `toml:"log_file"`
|
||||||
|
LogLevel string `toml:"log_level"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReplicationConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
MasterMaster bool `toml:"master_master"`
|
||||||
|
SyncReplication bool `toml:"sync_replication"`
|
||||||
|
ReplicationTimeoutMs int `toml:"replication_timeout_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginsConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
ScriptDir string `toml:"script_dir"`
|
||||||
|
AllowList []string `toml:"allow_list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CompressionConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"` // Включено ли сжатие
|
||||||
|
Algorithm string `toml:"algorithm"` // Алгоритм сжатия (snappy, lz4, zstd)
|
||||||
|
Level int `toml:"level"` // Уровень сжатия (1-9, зависит от алгоритма)
|
||||||
|
MinSize int `toml:"min_size"` // Минимальный размер для сжатия (байт)
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebUIConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"` // Включить веб-интерфейс
|
||||||
|
Port int `toml:"port"` // Порт для веб-интерфейса
|
||||||
|
Theme string `toml:"theme"` // Тема оформления (dark, light)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfig(path string) (*Config, error) {
|
||||||
|
var cfg Config
|
||||||
|
if _, err := toml.DecodeFile(path, &cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Установка значений по умолчанию, если не указаны
|
||||||
|
if cfg.Cluster.RaftPort == 0 {
|
||||||
|
cfg.Cluster.RaftPort = 9878
|
||||||
|
}
|
||||||
|
if cfg.Cluster.RaftDataDir == "" {
|
||||||
|
cfg.Cluster.RaftDataDir = "raft_data"
|
||||||
|
}
|
||||||
|
if cfg.Replication.ReplicationTimeoutMs == 0 {
|
||||||
|
cfg.Replication.ReplicationTimeoutMs = 5000
|
||||||
|
}
|
||||||
|
if cfg.Plugins.ScriptDir == "" {
|
||||||
|
cfg.Plugins.ScriptDir = "plugins"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Установка значений по умолчанию для сжатия
|
||||||
|
if cfg.Compression.Algorithm == "" {
|
||||||
|
cfg.Compression.Algorithm = "snappy"
|
||||||
|
}
|
||||||
|
if cfg.Compression.MinSize == 0 {
|
||||||
|
cfg.Compression.MinSize = 1024 // 1KB - сжимаем только документы больше 1KB
|
||||||
|
}
|
||||||
|
if cfg.Compression.Level == 0 {
|
||||||
|
cfg.Compression.Level = 3 // Средний уровень сжатия
|
||||||
|
}
|
||||||
|
|
||||||
|
// Установка значений по умолчанию для WebUI
|
||||||
|
if cfg.WebUI.Port == 0 {
|
||||||
|
cfg.WebUI.Port = 8080
|
||||||
|
}
|
||||||
|
if cfg.WebUI.Theme == "" {
|
||||||
|
cfg.WebUI.Theme = "dark"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
99
internal/log/logger.go
Normal file
99
internal/log/logger.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/log/logger.go
|
||||||
|
// Назначение: Асинхронная, wait-free запись логов в файл с меткой времени
|
||||||
|
// в миллисекундах. Поддержка уровней логирования и ротации.
|
||||||
|
|
||||||
|
package log
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LogLevel int32
|
||||||
|
|
||||||
|
const (
|
||||||
|
DebugLevel LogLevel = iota
|
||||||
|
InfoLevel
|
||||||
|
WarnLevel
|
||||||
|
ErrorLevel
|
||||||
|
)
|
||||||
|
|
||||||
|
type Logger struct {
|
||||||
|
file *os.File
|
||||||
|
level atomic.Int32
|
||||||
|
writeChan chan string
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLogger(filename string, levelStr string) (*Logger, error) {
|
||||||
|
file, err := os.OpenFile(filename, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
level := InfoLevel
|
||||||
|
switch levelStr {
|
||||||
|
case "debug":
|
||||||
|
level = DebugLevel
|
||||||
|
case "warn":
|
||||||
|
level = WarnLevel
|
||||||
|
case "error":
|
||||||
|
level = ErrorLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
l := &Logger{
|
||||||
|
file: file,
|
||||||
|
writeChan: make(chan string, 10000),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
l.level.Store(int32(level))
|
||||||
|
|
||||||
|
// Запуск wait-free writer
|
||||||
|
go l.writerLoop()
|
||||||
|
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) writerLoop() {
|
||||||
|
for msg := range l.writeChan {
|
||||||
|
l.file.WriteString(msg + "\n")
|
||||||
|
}
|
||||||
|
close(l.done)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) log(level LogLevel, levelStr, msg string) {
|
||||||
|
if level < LogLevel(l.level.Load()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
timestamp := now.Format("2006-01-02 15:04:05") + fmt.Sprintf(".%03d", now.Nanosecond()/1e6)
|
||||||
|
logMsg := fmt.Sprintf("[%s] %s: %s", timestamp, levelStr, msg)
|
||||||
|
select {
|
||||||
|
case l.writeChan <- logMsg:
|
||||||
|
default:
|
||||||
|
// Неблокирующая запись, старый лог теряется - wait-free
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Debug(msg string) { l.log(DebugLevel, "DEBUG", msg) }
|
||||||
|
func (l *Logger) Info(msg string) { l.log(InfoLevel, "INFO", msg) }
|
||||||
|
func (l *Logger) Warn(msg string) { l.log(WarnLevel, "WARN", msg) }
|
||||||
|
func (l *Logger) Error(msg string) { l.log(ErrorLevel, "ERROR", msg) }
|
||||||
|
|
||||||
|
func (l *Logger) Close() {
|
||||||
|
close(l.writeChan)
|
||||||
|
<-l.done
|
||||||
|
l.file.Close()
|
||||||
|
}
|
||||||
797
internal/plugin/plugin.go
Normal file
797
internal/plugin/plugin.go
Normal file
@@ -0,0 +1,797 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/plugin/plugin.go
|
||||||
|
// Назначение: Система плагинов на основе Lua для расширения функциональности СУБД.
|
||||||
|
// Позволяет загружать Lua-скрипты как плагины, выполнять их в изолированном окружении,
|
||||||
|
// взаимодействовать с данными СУБД и логировать действия плагинов в общий лог-файл.
|
||||||
|
|
||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"futriis/internal/log"
|
||||||
|
"futriis/internal/storage"
|
||||||
|
|
||||||
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PluginStatus представляет состояние плагина
|
||||||
|
type PluginStatus int32
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusLoaded PluginStatus = iota
|
||||||
|
StatusRunning
|
||||||
|
StatusStopped
|
||||||
|
StatusError
|
||||||
|
)
|
||||||
|
|
||||||
|
// Plugin представляет загруженный Lua-плагин
|
||||||
|
type Plugin struct {
|
||||||
|
Name string
|
||||||
|
FilePath string
|
||||||
|
Status atomic.Int32
|
||||||
|
LState *lua.LState
|
||||||
|
logger *log.Logger
|
||||||
|
storage *storage.Storage
|
||||||
|
mu sync.RWMutex
|
||||||
|
loadedAt time.Time
|
||||||
|
version string
|
||||||
|
author string
|
||||||
|
description string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PluginManager управляет всеми загруженными плагинами
|
||||||
|
type PluginManager struct {
|
||||||
|
plugins sync.Map // map[string]*Plugin
|
||||||
|
logger *log.Logger
|
||||||
|
storage *storage.Storage
|
||||||
|
pluginsDir string
|
||||||
|
eventBus chan PluginEvent
|
||||||
|
enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// PluginEvent представляет событие от плагина
|
||||||
|
type PluginEvent struct {
|
||||||
|
PluginName string
|
||||||
|
EventType string
|
||||||
|
Data interface{}
|
||||||
|
Timestamp int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPluginManager создаёт новый менеджер плагинов
|
||||||
|
func NewPluginManager(pluginsDir string, logger *log.Logger, store *storage.Storage, enabled bool) *PluginManager {
|
||||||
|
pm := &PluginManager{
|
||||||
|
logger: logger,
|
||||||
|
storage: store,
|
||||||
|
pluginsDir: pluginsDir,
|
||||||
|
eventBus: make(chan PluginEvent, 1000),
|
||||||
|
enabled: enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !enabled {
|
||||||
|
if logger != nil {
|
||||||
|
logger.Info("Plugin system is disabled")
|
||||||
|
}
|
||||||
|
return pm
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запускаем обработчик событий плагинов
|
||||||
|
go pm.eventLoop()
|
||||||
|
|
||||||
|
// Автоматически загружаем плагины из директории
|
||||||
|
go pm.autoLoadPlugins()
|
||||||
|
|
||||||
|
return pm
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoLoadPlugins автоматически загружает все .lua файлы из директории плагинов
|
||||||
|
func (pm *PluginManager) autoLoadPlugins() {
|
||||||
|
if !pm.enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
<-ticker.C
|
||||||
|
entries, err := os.ReadDir(pm.pluginsDir)
|
||||||
|
if err != nil {
|
||||||
|
if pm.logger != nil {
|
||||||
|
pm.logger.Error(fmt.Sprintf("Failed to read plugins directory: %v", err))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".lua") {
|
||||||
|
pluginName := strings.TrimSuffix(entry.Name(), ".lua")
|
||||||
|
if _, exists := pm.plugins.Load(pluginName); !exists {
|
||||||
|
pluginPath := filepath.Join(pm.pluginsDir, entry.Name())
|
||||||
|
if err := pm.LoadPlugin(pluginName, pluginPath); err != nil {
|
||||||
|
if pm.logger != nil {
|
||||||
|
pm.logger.Error(fmt.Sprintf("Failed to auto-load plugin %s: %v", pluginName, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadPlugin загружает Lua-плагин из файла
|
||||||
|
func (pm *PluginManager) LoadPlugin(name, filePath string) error {
|
||||||
|
if !pm.enabled {
|
||||||
|
return fmt.Errorf("plugin system is disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Читаем файл плагина
|
||||||
|
script, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read plugin file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём новое Lua-состояние
|
||||||
|
L := lua.NewState()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
L.Close()
|
||||||
|
if pm.logger != nil {
|
||||||
|
pm.logger.Error(fmt.Sprintf("Plugin %s panic: %v", name, r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Открываем стандартные библиотеки Lua
|
||||||
|
lua.OpenBase(L)
|
||||||
|
lua.OpenString(L)
|
||||||
|
lua.OpenTable(L)
|
||||||
|
lua.OpenMath(L)
|
||||||
|
|
||||||
|
// Регистрируем функции СУБД для Lua
|
||||||
|
pm.registerDatabaseFunctions(L)
|
||||||
|
|
||||||
|
// Выполняем скрипт с защитой от паники
|
||||||
|
var execErr error
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
execErr = fmt.Errorf("panic during script execution: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
execErr = L.DoString(string(script))
|
||||||
|
}()
|
||||||
|
|
||||||
|
if execErr != nil {
|
||||||
|
L.Close()
|
||||||
|
return fmt.Errorf("failed to execute plugin script: %v", execErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлекаем метаданные плагина
|
||||||
|
version := pm.getPluginMetadata(L, "version")
|
||||||
|
author := pm.getPluginMetadata(L, "author")
|
||||||
|
description := pm.getPluginMetadata(L, "description")
|
||||||
|
|
||||||
|
// Создаём объект плагина
|
||||||
|
plugin := &Plugin{
|
||||||
|
Name: name,
|
||||||
|
FilePath: filePath,
|
||||||
|
LState: L,
|
||||||
|
logger: pm.logger,
|
||||||
|
storage: pm.storage,
|
||||||
|
loadedAt: time.Now(),
|
||||||
|
version: version,
|
||||||
|
author: author,
|
||||||
|
description: description,
|
||||||
|
}
|
||||||
|
plugin.Status.Store(int32(StatusLoaded))
|
||||||
|
|
||||||
|
// Сохраняем плагин
|
||||||
|
pm.plugins.Store(name, plugin)
|
||||||
|
|
||||||
|
// Логируем загрузку
|
||||||
|
if pm.logger != nil {
|
||||||
|
pm.logger.Info(fmt.Sprintf("Plugin loaded: %s v%s by %s - %s", name, version, author, description))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вызываем функцию инициализации плагина, если она есть
|
||||||
|
if err := pm.callPluginFunction(plugin, "on_load"); err != nil {
|
||||||
|
if pm.logger != nil {
|
||||||
|
pm.logger.Warn(fmt.Sprintf("Plugin %s on_load error: %v", name, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerDatabaseFunctions регистрирует функции доступа к СУБД в Lua
|
||||||
|
func (pm *PluginManager) registerDatabaseFunctions(L *lua.LState) {
|
||||||
|
// Регистрируем функцию для получения базы данных
|
||||||
|
L.SetGlobal("get_database", L.NewFunction(func(L *lua.LState) int {
|
||||||
|
dbName := L.CheckString(1)
|
||||||
|
db, err := pm.storage.GetDatabase(dbName)
|
||||||
|
if err != nil {
|
||||||
|
L.Push(lua.LNil)
|
||||||
|
L.Push(lua.LString(err.Error()))
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём пользовательский тип для базы данных
|
||||||
|
ud := L.NewUserData()
|
||||||
|
ud.Value = db
|
||||||
|
L.SetMetatable(ud, L.GetTypeMetatable("database"))
|
||||||
|
L.Push(ud)
|
||||||
|
return 1
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Регистрируем функцию для получения коллекции
|
||||||
|
L.SetGlobal("get_collection", L.NewFunction(func(L *lua.LState) int {
|
||||||
|
dbName := L.CheckString(1)
|
||||||
|
collName := L.CheckString(2)
|
||||||
|
|
||||||
|
db, err := pm.storage.GetDatabase(dbName)
|
||||||
|
if err != nil {
|
||||||
|
L.Push(lua.LNil)
|
||||||
|
L.Push(lua.LString(err.Error()))
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
coll, err := db.GetCollection(collName)
|
||||||
|
if err != nil {
|
||||||
|
L.Push(lua.LNil)
|
||||||
|
L.Push(lua.LString(err.Error()))
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
ud := L.NewUserData()
|
||||||
|
ud.Value = coll
|
||||||
|
L.SetMetatable(ud, L.GetTypeMetatable("collection"))
|
||||||
|
L.Push(ud)
|
||||||
|
return 1
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Регистрируем функцию логирования для плагинов
|
||||||
|
L.SetGlobal("plugin_log", L.NewFunction(func(L *lua.LState) int {
|
||||||
|
level := L.CheckString(1)
|
||||||
|
message := L.CheckString(2)
|
||||||
|
|
||||||
|
if pm.logger != nil {
|
||||||
|
logMsg := fmt.Sprintf("[PLUGIN] %s: %s", level, message)
|
||||||
|
switch level {
|
||||||
|
case "debug":
|
||||||
|
pm.logger.Debug(logMsg)
|
||||||
|
case "info":
|
||||||
|
pm.logger.Info(logMsg)
|
||||||
|
case "warn":
|
||||||
|
pm.logger.Warn(logMsg)
|
||||||
|
case "error":
|
||||||
|
pm.logger.Error(logMsg)
|
||||||
|
default:
|
||||||
|
pm.logger.Info(logMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Регистрируем функцию для отправки событий
|
||||||
|
L.SetGlobal("emit_event", L.NewFunction(func(L *lua.LState) int {
|
||||||
|
eventType := L.CheckString(1)
|
||||||
|
eventData := L.CheckAny(2)
|
||||||
|
|
||||||
|
// Получаем имя плагина из контекста (нужно передавать при вызове)
|
||||||
|
event := PluginEvent{
|
||||||
|
EventType: eventType,
|
||||||
|
Data: pm.luaValueToGo(eventData),
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case pm.eventBus <- event:
|
||||||
|
default:
|
||||||
|
if pm.logger != nil {
|
||||||
|
pm.logger.Warn("Plugin event bus full, event dropped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Устанавливаем метатаблицы для методов баз данных и коллекций
|
||||||
|
pm.setupDatabaseMetatable(L)
|
||||||
|
pm.setupCollectionMetatable(L)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupDatabaseMetatable настраивает методы для объекта базы данных в Lua
|
||||||
|
func (pm *PluginManager) setupDatabaseMetatable(L *lua.LState) {
|
||||||
|
mt := L.NewTypeMetatable("database")
|
||||||
|
L.SetField(mt, "__index", L.NewFunction(func(L *lua.LState) int {
|
||||||
|
db := L.CheckUserData(1).Value.(*storage.Database)
|
||||||
|
method := L.CheckString(2)
|
||||||
|
|
||||||
|
switch method {
|
||||||
|
case "create_collection":
|
||||||
|
L.Push(L.NewFunction(func(L *lua.LState) int {
|
||||||
|
name := L.CheckString(1)
|
||||||
|
err := db.CreateCollection(name)
|
||||||
|
if err != nil {
|
||||||
|
L.Push(lua.LString(err.Error()))
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
L.Push(lua.LNil)
|
||||||
|
return 1
|
||||||
|
}))
|
||||||
|
case "get_collection":
|
||||||
|
L.Push(L.NewFunction(func(L *lua.LState) int {
|
||||||
|
name := L.CheckString(1)
|
||||||
|
coll, err := db.GetCollection(name)
|
||||||
|
if err != nil {
|
||||||
|
L.Push(lua.LNil)
|
||||||
|
L.Push(lua.LString(err.Error()))
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
ud := L.NewUserData()
|
||||||
|
ud.Value = coll
|
||||||
|
L.SetMetatable(ud, L.GetTypeMetatable("collection"))
|
||||||
|
L.Push(ud)
|
||||||
|
return 1
|
||||||
|
}))
|
||||||
|
case "name":
|
||||||
|
L.Push(lua.LString(db.Name()))
|
||||||
|
default:
|
||||||
|
L.Push(lua.LNil)
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupCollectionMetatable настраивает методы для объекта коллекции в Lua
|
||||||
|
func (pm *PluginManager) setupCollectionMetatable(L *lua.LState) {
|
||||||
|
mt := L.NewTypeMetatable("collection")
|
||||||
|
L.SetField(mt, "__index", L.NewFunction(func(L *lua.LState) int {
|
||||||
|
coll := L.CheckUserData(1).Value.(*storage.Collection)
|
||||||
|
method := L.CheckString(2)
|
||||||
|
|
||||||
|
switch method {
|
||||||
|
case "insert":
|
||||||
|
L.Push(L.NewFunction(func(L *lua.LState) int {
|
||||||
|
doc := L.CheckTable(1)
|
||||||
|
// Конвертируем Lua table в map
|
||||||
|
fields := make(map[string]interface{})
|
||||||
|
doc.ForEach(func(key, value lua.LValue) {
|
||||||
|
if key.Type() == lua.LTString {
|
||||||
|
fields[key.String()] = pm.luaValueToGo(value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Вставляем документ
|
||||||
|
newDoc := storage.NewDocument()
|
||||||
|
for k, v := range fields {
|
||||||
|
newDoc.SetField(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := coll.Insert(newDoc)
|
||||||
|
if err != nil {
|
||||||
|
L.Push(lua.LNil)
|
||||||
|
L.Push(lua.LString(err.Error()))
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
// Возвращаем ID вставленного документа
|
||||||
|
L.Push(lua.LString(newDoc.ID))
|
||||||
|
L.Push(lua.LNil)
|
||||||
|
return 2
|
||||||
|
}))
|
||||||
|
case "find":
|
||||||
|
L.Push(L.NewFunction(func(L *lua.LState) int {
|
||||||
|
id := L.CheckString(1)
|
||||||
|
doc, err := coll.Find(id)
|
||||||
|
if err != nil {
|
||||||
|
L.Push(lua.LNil)
|
||||||
|
L.Push(lua.LString(err.Error()))
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
// Конвертируем документ в Lua table
|
||||||
|
table := L.NewTable()
|
||||||
|
table.RawSetString("_id", lua.LString(doc.ID))
|
||||||
|
for k, v := range doc.GetFields() {
|
||||||
|
table.RawSetString(k, pm.goValueToLua(L, v))
|
||||||
|
}
|
||||||
|
L.Push(table)
|
||||||
|
return 1
|
||||||
|
}))
|
||||||
|
case "update":
|
||||||
|
L.Push(L.NewFunction(func(L *lua.LState) int {
|
||||||
|
id := L.CheckString(1)
|
||||||
|
updates := L.CheckTable(2)
|
||||||
|
|
||||||
|
fields := make(map[string]interface{})
|
||||||
|
updates.ForEach(func(key, value lua.LValue) {
|
||||||
|
if key.Type() == lua.LTString {
|
||||||
|
fields[key.String()] = pm.luaValueToGo(value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
err := coll.Update(id, fields)
|
||||||
|
if err != nil {
|
||||||
|
L.Push(lua.LString(err.Error()))
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
L.Push(lua.LNil)
|
||||||
|
return 1
|
||||||
|
}))
|
||||||
|
case "delete":
|
||||||
|
L.Push(L.NewFunction(func(L *lua.LState) int {
|
||||||
|
id := L.CheckString(1)
|
||||||
|
err := coll.Delete(id)
|
||||||
|
if err != nil {
|
||||||
|
L.Push(lua.LString(err.Error()))
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
L.Push(lua.LNil)
|
||||||
|
return 1
|
||||||
|
}))
|
||||||
|
case "count":
|
||||||
|
L.Push(L.NewFunction(func(L *lua.LState) int {
|
||||||
|
count := coll.Count()
|
||||||
|
L.Push(lua.LNumber(count))
|
||||||
|
return 1
|
||||||
|
}))
|
||||||
|
default:
|
||||||
|
L.Push(lua.LNil)
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// luaValueToGo конвертирует Lua-значение в Go-значение
|
||||||
|
func (pm *PluginManager) luaValueToGo(val lua.LValue) interface{} {
|
||||||
|
if val == nil || val == lua.LNil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := val.(type) {
|
||||||
|
case lua.LString:
|
||||||
|
return string(v)
|
||||||
|
case lua.LNumber:
|
||||||
|
return float64(v)
|
||||||
|
case lua.LBool:
|
||||||
|
return bool(v)
|
||||||
|
case *lua.LTable:
|
||||||
|
result := make(map[string]interface{})
|
||||||
|
v.ForEach(func(key, value lua.LValue) {
|
||||||
|
keyStr := "unknown"
|
||||||
|
if key.Type() == lua.LTString {
|
||||||
|
keyStr = key.String()
|
||||||
|
} else if key.Type() == lua.LTNumber {
|
||||||
|
keyStr = fmt.Sprintf("%d", int64(key.(lua.LNumber)))
|
||||||
|
}
|
||||||
|
result[keyStr] = pm.luaValueToGo(value)
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
default:
|
||||||
|
return v.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// goValueToLua конвертирует Go-значение в Lua-значение
|
||||||
|
func (pm *PluginManager) goValueToLua(L *lua.LState, val interface{}) lua.LValue {
|
||||||
|
if val == nil {
|
||||||
|
return lua.LNil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := val.(type) {
|
||||||
|
case string:
|
||||||
|
return lua.LString(v)
|
||||||
|
case int:
|
||||||
|
return lua.LNumber(float64(v))
|
||||||
|
case int64:
|
||||||
|
return lua.LNumber(float64(v))
|
||||||
|
case float32:
|
||||||
|
return lua.LNumber(float64(v))
|
||||||
|
case float64:
|
||||||
|
return lua.LNumber(v)
|
||||||
|
case bool:
|
||||||
|
return lua.LBool(v)
|
||||||
|
case map[string]interface{}:
|
||||||
|
table := L.NewTable()
|
||||||
|
for k, val := range v {
|
||||||
|
table.RawSetString(k, pm.goValueToLua(L, val))
|
||||||
|
}
|
||||||
|
return table
|
||||||
|
case []interface{}:
|
||||||
|
table := L.NewTable()
|
||||||
|
for i, val := range v {
|
||||||
|
table.RawSetInt(i+1, pm.goValueToLua(L, val))
|
||||||
|
}
|
||||||
|
return table
|
||||||
|
default:
|
||||||
|
return lua.LString(fmt.Sprintf("%v", v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPluginMetadata извлекает метаданные из загруженного Lua-скрипта
|
||||||
|
func (pm *PluginManager) getPluginMetadata(L *lua.LState, field string) string {
|
||||||
|
// Пытаемся получить глобальную переменную с метаданными
|
||||||
|
val := L.GetGlobal(field)
|
||||||
|
if str, ok := val.(lua.LString); ok {
|
||||||
|
return string(str)
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// callPluginFunction вызывает функцию плагина по имени с защитой от паники
|
||||||
|
func (pm *PluginManager) callPluginFunction(plugin *Plugin, funcName string) error {
|
||||||
|
plugin.mu.RLock()
|
||||||
|
defer plugin.mu.RUnlock()
|
||||||
|
|
||||||
|
L := plugin.LState
|
||||||
|
if L == nil {
|
||||||
|
return fmt.Errorf("plugin has no Lua state")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn := L.GetGlobal(funcName)
|
||||||
|
if fn == lua.LNil {
|
||||||
|
return nil // Функция не определена
|
||||||
|
}
|
||||||
|
|
||||||
|
// Защита от паники при вызове Lua функции
|
||||||
|
var callErr error
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
callErr = fmt.Errorf("panic during %s: %v", funcName, r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
callErr = L.CallByParam(lua.P{
|
||||||
|
Fn: fn,
|
||||||
|
NRet: 0,
|
||||||
|
Protect: true,
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
if callErr != nil {
|
||||||
|
return fmt.Errorf("failed to call %s: %v", funcName, callErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventLoop обрабатывает события от плагинов
|
||||||
|
func (pm *PluginManager) eventLoop() {
|
||||||
|
for event := range pm.eventBus {
|
||||||
|
if pm.logger != nil {
|
||||||
|
pm.logger.Debug(fmt.Sprintf("Plugin event [%s]: %+v", event.EventType, event.Data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Можно реализовать подписку плагинов на события
|
||||||
|
pm.plugins.Range(func(key, value interface{}) bool {
|
||||||
|
plugin := value.(*Plugin)
|
||||||
|
// Асинхронно уведомляем плагины о событии
|
||||||
|
go pm.notifyPlugin(plugin, event)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyPlugin уведомляет конкретный плагин о событии
|
||||||
|
func (pm *PluginManager) notifyPlugin(plugin *Plugin, event PluginEvent) {
|
||||||
|
plugin.mu.RLock()
|
||||||
|
defer plugin.mu.RUnlock()
|
||||||
|
|
||||||
|
L := plugin.LState
|
||||||
|
if L == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fn := L.GetGlobal("on_event")
|
||||||
|
if fn == lua.LNil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем имя плагина в событие
|
||||||
|
event.PluginName = plugin.Name
|
||||||
|
|
||||||
|
// Создаём таблицу с данными события
|
||||||
|
eventTable := L.NewTable()
|
||||||
|
eventTable.RawSetString("type", lua.LString(event.EventType))
|
||||||
|
eventTable.RawSetString("plugin_name", lua.LString(event.PluginName))
|
||||||
|
eventTable.RawSetString("timestamp", lua.LNumber(event.Timestamp))
|
||||||
|
eventTable.RawSetString("data", pm.goValueToLua(L, event.Data))
|
||||||
|
|
||||||
|
// Не устанавливаем глобальную переменную, передаём как аргумент
|
||||||
|
var callErr error
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
callErr = fmt.Errorf("panic during on_event: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
callErr = L.CallByParam(lua.P{
|
||||||
|
Fn: fn,
|
||||||
|
NRet: 0,
|
||||||
|
Protect: true,
|
||||||
|
}, eventTable)
|
||||||
|
}()
|
||||||
|
|
||||||
|
if callErr != nil && pm.logger != nil {
|
||||||
|
pm.logger.Error(fmt.Sprintf("Plugin %s on_event error: %v", plugin.Name, callErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecutePlugin выполняет пользовательскую функцию плагина
|
||||||
|
func (pm *PluginManager) ExecutePlugin(pluginName, funcName string, args ...interface{}) (interface{}, error) {
|
||||||
|
if !pm.enabled {
|
||||||
|
return nil, fmt.Errorf("plugin system is disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
val, ok := pm.plugins.Load(pluginName)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("plugin not found: %s", pluginName)
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin := val.(*Plugin)
|
||||||
|
if PluginStatus(plugin.Status.Load()) != StatusRunning {
|
||||||
|
return nil, fmt.Errorf("plugin %s is not running", pluginName)
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin.mu.RLock()
|
||||||
|
defer plugin.mu.RUnlock()
|
||||||
|
|
||||||
|
L := plugin.LState
|
||||||
|
if L == nil {
|
||||||
|
return nil, fmt.Errorf("plugin %s has no Lua state", pluginName)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn := L.GetGlobal(funcName)
|
||||||
|
if fn == lua.LNil {
|
||||||
|
return nil, fmt.Errorf("function %s not found in plugin %s", funcName, pluginName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подготавливаем аргументы для вызова
|
||||||
|
luaArgs := make([]lua.LValue, len(args))
|
||||||
|
for i, arg := range args {
|
||||||
|
luaArgs[i] = pm.goValueToLua(L, arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вызываем функцию с защитой от паники
|
||||||
|
var ret lua.LValue
|
||||||
|
var callErr error
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
callErr = fmt.Errorf("panic during execution: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
callErr = L.CallByParam(lua.P{
|
||||||
|
Fn: fn,
|
||||||
|
NRet: 1,
|
||||||
|
Protect: true,
|
||||||
|
}, luaArgs...)
|
||||||
|
if callErr == nil {
|
||||||
|
ret = L.Get(-1)
|
||||||
|
L.Pop(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if callErr != nil {
|
||||||
|
return nil, fmt.Errorf("plugin execution failed: %v", callErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pm.luaValueToGo(ret), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnloadPlugin выгружает плагин
|
||||||
|
func (pm *PluginManager) UnloadPlugin(name string) error {
|
||||||
|
if !pm.enabled {
|
||||||
|
return fmt.Errorf("plugin system is disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
val, ok := pm.plugins.Load(name)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("plugin not found: %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin := val.(*Plugin)
|
||||||
|
|
||||||
|
// Вызываем функцию выгрузки
|
||||||
|
if err := pm.callPluginFunction(plugin, "on_unload"); err != nil {
|
||||||
|
if pm.logger != nil {
|
||||||
|
pm.logger.Warn(fmt.Sprintf("Plugin %s on_unload error: %v", name, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрываем Lua-состояние
|
||||||
|
if plugin.LState != nil {
|
||||||
|
plugin.LState.Close()
|
||||||
|
}
|
||||||
|
plugin.Status.Store(int32(StatusStopped))
|
||||||
|
|
||||||
|
pm.plugins.Delete(name)
|
||||||
|
if pm.logger != nil {
|
||||||
|
pm.logger.Info(fmt.Sprintf("Plugin unloaded: %s", name))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartPlugin запускает плагин
|
||||||
|
func (pm *PluginManager) StartPlugin(name string) error {
|
||||||
|
if !pm.enabled {
|
||||||
|
return fmt.Errorf("plugin system is disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
val, ok := pm.plugins.Load(name)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("plugin not found: %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin := val.(*Plugin)
|
||||||
|
plugin.Status.Store(int32(StatusRunning))
|
||||||
|
|
||||||
|
if err := pm.callPluginFunction(plugin, "on_start"); err != nil {
|
||||||
|
plugin.Status.Store(int32(StatusError))
|
||||||
|
return fmt.Errorf("failed to start plugin: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if pm.logger != nil {
|
||||||
|
pm.logger.Info(fmt.Sprintf("Plugin started: %s", name))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopPlugin останавливает плагин
|
||||||
|
func (pm *PluginManager) StopPlugin(name string) error {
|
||||||
|
if !pm.enabled {
|
||||||
|
return fmt.Errorf("plugin system is disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
val, ok := pm.plugins.Load(name)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("plugin not found: %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin := val.(*Plugin)
|
||||||
|
|
||||||
|
if err := pm.callPluginFunction(plugin, "on_stop"); err != nil {
|
||||||
|
if pm.logger != nil {
|
||||||
|
pm.logger.Warn(fmt.Sprintf("Plugin %s on_stop error: %v", name, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin.Status.Store(int32(StatusStopped))
|
||||||
|
if pm.logger != nil {
|
||||||
|
pm.logger.Info(fmt.Sprintf("Plugin stopped: %s", name))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPlugins возвращает список всех загруженных плагинов
|
||||||
|
func (pm *PluginManager) ListPlugins() []*Plugin {
|
||||||
|
plugins := make([]*Plugin, 0)
|
||||||
|
pm.plugins.Range(func(key, value interface{}) bool {
|
||||||
|
plugins = append(plugins, value.(*Plugin))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return plugins
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEnabled возвращает статус системы плагинов
|
||||||
|
func (pm *PluginManager) IsEnabled() bool {
|
||||||
|
return pm.enabled
|
||||||
|
}
|
||||||
106
internal/repl/history.go
Normal file
106
internal/repl/history.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/repl/history.go
|
||||||
|
// Назначение: Управление историей команд REPL
|
||||||
|
|
||||||
|
package repl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// History управляет историей команд
|
||||||
|
type History struct {
|
||||||
|
entries []string
|
||||||
|
maxSize int
|
||||||
|
filePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHistory создаёт новый объект истории
|
||||||
|
func NewHistory(maxSize int) *History {
|
||||||
|
homeDir, _ := os.UserHomeDir()
|
||||||
|
filePath := filepath.Join(homeDir, ".futriis_history")
|
||||||
|
|
||||||
|
return &History{
|
||||||
|
entries: make([]string, 0, maxSize),
|
||||||
|
maxSize: maxSize,
|
||||||
|
filePath: filePath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add добавляет команду в историю
|
||||||
|
func (h *History) Add(cmd string) error {
|
||||||
|
// Не добавляем дубликаты подряд
|
||||||
|
if len(h.entries) > 0 && h.entries[len(h.entries)-1] == cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
h.entries = append(h.entries, cmd)
|
||||||
|
|
||||||
|
// Ограничиваем размер истории
|
||||||
|
if len(h.entries) > h.maxSize {
|
||||||
|
h.entries = h.entries[len(h.entries)-h.maxSize:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load загружает историю из файла
|
||||||
|
func (h *History) Load() error {
|
||||||
|
file, err := os.Open(h.filePath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
cmd := scanner.Text()
|
||||||
|
if cmd != "" {
|
||||||
|
h.entries = append(h.entries, cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ограничиваем размер
|
||||||
|
if len(h.entries) > h.maxSize {
|
||||||
|
h.entries = h.entries[len(h.entries)-h.maxSize:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save сохраняет историю в файл
|
||||||
|
func (h *History) Save() error {
|
||||||
|
file, err := os.Create(h.filePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
writer := bufio.NewWriter(file)
|
||||||
|
for _, cmd := range h.entries {
|
||||||
|
if _, err := writer.WriteString(cmd + "\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return writer.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEntries возвращает все записи истории
|
||||||
|
func (h *History) GetEntries() []string {
|
||||||
|
return h.entries
|
||||||
|
}
|
||||||
1670
internal/repl/repl.go
Normal file
1670
internal/repl/repl.go
Normal file
File diff suppressed because it is too large
Load Diff
27
internal/serializer/msgpack.go
Normal file
27
internal/serializer/msgpack.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/serializer/msgpack.go
|
||||||
|
// Назначение: Сериализация и десериализация документов в формате MessagePack.
|
||||||
|
// Используется библиотека vmihailenco/msgpack для высокой производительности.
|
||||||
|
|
||||||
|
package serializer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/vmihailenco/msgpack/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Marshal(v interface{}) ([]byte, error) {
|
||||||
|
return msgpack.Marshal(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Unmarshal(data []byte, v interface{}) error {
|
||||||
|
return msgpack.Unmarshal(data, v)
|
||||||
|
}
|
||||||
126
internal/storage/audit.go
Normal file
126
internal/storage/audit.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/storage/audit.go
|
||||||
|
// Назначение: Аудит всех операций создания, изменения, удаления данных
|
||||||
|
// с записью временной метки с точностью до миллисекунды
|
||||||
|
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuditEntry представляет запись аудита
|
||||||
|
type AuditEntry struct {
|
||||||
|
ID string `msgpack:"id"`
|
||||||
|
Timestamp int64 `msgpack:"timestamp"` // Unix миллисекунды
|
||||||
|
TimestampStr string `msgpack:"timestamp_str"` // Человекочитаемая строка
|
||||||
|
Operation string `msgpack:"operation"` // CREATE, UPDATE, DELETE, START, COMMIT, ABORT, CLUSTER
|
||||||
|
DataType string `msgpack:"data_type"` // DATABASE, COLLECTION, DOCUMENT, FIELD, TUPLE, SESSION, TRANSACTION, CLUSTER
|
||||||
|
Name string `msgpack:"name"` // Имя объекта
|
||||||
|
Details map[string]interface{} `msgpack:"details"` // Детали операции
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditLogger управляет аудитом
|
||||||
|
type AuditLogger struct {
|
||||||
|
entries []AuditEntry
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
var globalAuditLogger = &AuditLogger{
|
||||||
|
entries: make([]AuditEntry, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentTimestamp возвращает текущую временную метку с миллисекундами
|
||||||
|
func GetCurrentTimestamp() (int64, string) {
|
||||||
|
now := time.Now()
|
||||||
|
timestampMs := now.UnixMilli()
|
||||||
|
timestampStr := now.Format("2006-01-02 15:04:05.000")
|
||||||
|
return timestampMs, timestampStr
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogAudit записывает событие в аудит
|
||||||
|
func LogAudit(operation, dataType, name string, details map[string]interface{}) {
|
||||||
|
timestampMs, timestampStr := GetCurrentTimestamp()
|
||||||
|
|
||||||
|
entry := AuditEntry{
|
||||||
|
ID: fmt.Sprintf("%d", timestampMs),
|
||||||
|
Timestamp: timestampMs,
|
||||||
|
TimestampStr: timestampStr,
|
||||||
|
Operation: operation,
|
||||||
|
DataType: dataType,
|
||||||
|
Name: name,
|
||||||
|
Details: details,
|
||||||
|
}
|
||||||
|
|
||||||
|
globalAuditLogger.mu.Lock()
|
||||||
|
globalAuditLogger.entries = append(globalAuditLogger.entries, entry)
|
||||||
|
globalAuditLogger.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAuditLog возвращает копию лога аудита
|
||||||
|
func GetAuditLog() []AuditEntry {
|
||||||
|
globalAuditLogger.mu.RLock()
|
||||||
|
defer globalAuditLogger.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]AuditEntry, len(globalAuditLogger.entries))
|
||||||
|
copy(result, globalAuditLogger.entries)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditDatabaseOperation логирует операцию с базой данных
|
||||||
|
func AuditDatabaseOperation(operation, dbName string) {
|
||||||
|
LogAudit(operation, "DATABASE", dbName, map[string]interface{}{
|
||||||
|
"database": dbName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditCollectionOperation логирует операцию с коллекцией
|
||||||
|
func AuditCollectionOperation(operation, dbName, collName string, settings interface{}) {
|
||||||
|
LogAudit(operation, "COLLECTION", fmt.Sprintf("%s.%s", dbName, collName), map[string]interface{}{
|
||||||
|
"database": dbName,
|
||||||
|
"collection": collName,
|
||||||
|
"settings": settings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditDocumentOperation логирует операцию с документом
|
||||||
|
func AuditDocumentOperation(operation, dbName, collName, docID string, fields map[string]interface{}) {
|
||||||
|
LogAudit(operation, "DOCUMENT", fmt.Sprintf("%s.%s.%s", dbName, collName, docID), map[string]interface{}{
|
||||||
|
"database": dbName,
|
||||||
|
"collection": collName,
|
||||||
|
"document_id": docID,
|
||||||
|
"fields": fields,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditFieldOperation логирует операцию с полем
|
||||||
|
func AuditFieldOperation(operation, dbName, collName, docID, fieldName string, value interface{}) {
|
||||||
|
LogAudit(operation, "FIELD", fmt.Sprintf("%s.%s.%s.%s", dbName, collName, docID, fieldName), map[string]interface{}{
|
||||||
|
"database": dbName,
|
||||||
|
"collection": collName,
|
||||||
|
"document_id": docID,
|
||||||
|
"field": fieldName,
|
||||||
|
"value": value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditTupleOperation логирует операцию с кортежем
|
||||||
|
func AuditTupleOperation(operation, dbName, collName, docID, tuplePath string) {
|
||||||
|
LogAudit(operation, "TUPLE", fmt.Sprintf("%s.%s.%s.%s", dbName, collName, docID, tuplePath), map[string]interface{}{
|
||||||
|
"database": dbName,
|
||||||
|
"collection": collName,
|
||||||
|
"document_id": docID,
|
||||||
|
"tuple_path": tuplePath,
|
||||||
|
})
|
||||||
|
}
|
||||||
766
internal/storage/collection.go
Normal file
766
internal/storage/collection.go
Normal file
@@ -0,0 +1,766 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/storage/collection.go
|
||||||
|
// Назначение: Реализация коллекции с индексами (первичными и вторичными).
|
||||||
|
// Индексы хранятся отдельно от документов, обеспечивают wait-free доступ.
|
||||||
|
// Исправлено: корректная работа уникальных индексов, удаление из индексов при обновлении.
|
||||||
|
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"futriis/internal/serializer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Collection представляет коллекцию документов (аналог таблицы)
|
||||||
|
type Collection struct {
|
||||||
|
name string
|
||||||
|
docs sync.Map // map[string]*Document - wait-free хранилище документов
|
||||||
|
indexes sync.Map // map[string]*Index - индексы для быстрого поиска
|
||||||
|
metadata *CollectionMetadata // Метаданные коллекции
|
||||||
|
docCount atomic.Int64 // Атомарный счётчик документов
|
||||||
|
sizeBytes atomic.Int64 // Атомарный размер коллекции в байтах
|
||||||
|
mu sync.RWMutex // Для операций, изменяющих структуру коллекции
|
||||||
|
constraints *Constraints // Ограничения коллекции
|
||||||
|
acl *CollectionACL // ACL для коллекции
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectionMetadata содержит метаданные коллекции
|
||||||
|
type CollectionMetadata struct {
|
||||||
|
Name string `msgpack:"name"`
|
||||||
|
CreatedAt int64 `msgpack:"created_at"`
|
||||||
|
UpdatedAt int64 `msgpack:"updated_at"`
|
||||||
|
DocumentCount int64 `msgpack:"document_count"`
|
||||||
|
SizeBytes int64 `msgpack:"size_bytes"`
|
||||||
|
IndexCount int `msgpack:"index_count"`
|
||||||
|
Settings *CollectionSettings `msgpack:"settings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectionSettings содержит настройки коллекции
|
||||||
|
type CollectionSettings struct {
|
||||||
|
MaxDocuments int `msgpack:"max_documents"` // Максимальное количество документов (0 = безлимит)
|
||||||
|
ValidateSchema bool `msgpack:"validate_schema"` // Валидировать схему документов
|
||||||
|
AutoIndexID bool `msgpack:"auto_index_id"` // Автоматически индексировать поле _id
|
||||||
|
TTLSeconds int `msgpack:"ttl_seconds"` // Время жизни документов (0 = бессрочно)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index представляет индекс для ускорения поиска (хранится отдельно от документов)
|
||||||
|
type Index struct {
|
||||||
|
Name string `msgpack:"name"`
|
||||||
|
Fields []string `msgpack:"fields"` // Поля для индексации
|
||||||
|
Unique bool `msgpack:"unique"` // Уникальный индекс
|
||||||
|
data sync.Map // map[interface{}]string - значение индекса -> ID документа
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constraints представляет ограничения на коллекцию
|
||||||
|
type Constraints struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
RequiredFields map[string]bool // Обязательные поля
|
||||||
|
UniqueFields map[string]bool // Уникальные поля (дополнительно к индексам)
|
||||||
|
MinValues map[string]float64 // Минимальные значения для числовых полей
|
||||||
|
MaxValues map[string]float64 // Максимальные значения для числовых полей
|
||||||
|
PatternFields map[string]string // Regexp паттерны для полей
|
||||||
|
EnumFields map[string][]interface{} // Допустимые значения для полей
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectionACL представляет список контроля доступа для коллекции
|
||||||
|
type CollectionACL struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
ReadRoles map[string]bool // Роли, имеющие доступ на чтение
|
||||||
|
WriteRoles map[string]bool // Роли, имеющие доступ на запись
|
||||||
|
DeleteRoles map[string]bool // Роли, имеющие доступ на удаление
|
||||||
|
AdminRoles map[string]bool // Роли, имеющие полный доступ
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCollection создаёт новую коллекцию
|
||||||
|
func NewCollection(name string, settings *CollectionSettings) *Collection {
|
||||||
|
if settings == nil {
|
||||||
|
settings = &CollectionSettings{
|
||||||
|
MaxDocuments: 0,
|
||||||
|
ValidateSchema: false,
|
||||||
|
AutoIndexID: true,
|
||||||
|
TTLSeconds: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
coll := &Collection{
|
||||||
|
name: name,
|
||||||
|
metadata: &CollectionMetadata{
|
||||||
|
Name: name,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
DocumentCount: 0,
|
||||||
|
SizeBytes: 0,
|
||||||
|
IndexCount: 0,
|
||||||
|
Settings: settings,
|
||||||
|
},
|
||||||
|
constraints: &Constraints{
|
||||||
|
RequiredFields: make(map[string]bool),
|
||||||
|
UniqueFields: make(map[string]bool),
|
||||||
|
MinValues: make(map[string]float64),
|
||||||
|
MaxValues: make(map[string]float64),
|
||||||
|
PatternFields: make(map[string]string),
|
||||||
|
EnumFields: make(map[string][]interface{}),
|
||||||
|
},
|
||||||
|
acl: &CollectionACL{
|
||||||
|
ReadRoles: make(map[string]bool),
|
||||||
|
WriteRoles: make(map[string]bool),
|
||||||
|
DeleteRoles: make(map[string]bool),
|
||||||
|
AdminRoles: make(map[string]bool),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Автоматически создаём первичный индекс по _id
|
||||||
|
if settings.AutoIndexID {
|
||||||
|
coll.CreateIndex("_id_", []string{"_id"}, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запускаем фоновую задачу для удаления просроченных документов
|
||||||
|
if settings.TTLSeconds > 0 {
|
||||||
|
go coll.ttlCleanupLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
return coll
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name возвращает имя коллекции
|
||||||
|
func (c *Collection) Name() string {
|
||||||
|
return c.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert вставляет документ в коллекцию (wait-free)
|
||||||
|
func (c *Collection) Insert(doc *Document) error {
|
||||||
|
// Проверка ограничений
|
||||||
|
if err := c.constraints.ValidateDocument(doc); err != nil {
|
||||||
|
return fmt.Errorf("constraint violation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка ACL (будет вызвано из верхнего уровня с ролью)
|
||||||
|
|
||||||
|
// Проверка на максимальное количество документов
|
||||||
|
if c.metadata.Settings.MaxDocuments > 0 {
|
||||||
|
if c.docCount.Load() >= int64(c.metadata.Settings.MaxDocuments) {
|
||||||
|
return fmt.Errorf("collection is full: max documents %d reached", c.metadata.Settings.MaxDocuments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валидация схемы (если включена)
|
||||||
|
if c.metadata.Settings.ValidateSchema {
|
||||||
|
if err := c.validateDocument(doc); err != nil {
|
||||||
|
return fmt.Errorf("document validation failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка уникальных индексов
|
||||||
|
if err := c.checkUniqueConstraints(doc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сериализация для проверки (опционально)
|
||||||
|
data, err := serializer.Marshal(doc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to serialize document: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Атомарное сохранение документа
|
||||||
|
if _, loaded := c.docs.LoadOrStore(doc.ID, doc); loaded {
|
||||||
|
return fmt.Errorf("document with id %s already exists", doc.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление индексов (wait-free)
|
||||||
|
c.updateIndexes(doc, true)
|
||||||
|
|
||||||
|
// Обновление метаданных
|
||||||
|
c.docCount.Add(1)
|
||||||
|
c.sizeBytes.Add(int64(len(data)))
|
||||||
|
c.metadata.DocumentCount = c.docCount.Load()
|
||||||
|
c.metadata.SizeBytes = c.sizeBytes.Load()
|
||||||
|
c.metadata.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertFromMap создаёт и вставляет документ из map
|
||||||
|
func (c *Collection) InsertFromMap(fields map[string]interface{}) error {
|
||||||
|
doc := NewDocument()
|
||||||
|
for k, v := range fields {
|
||||||
|
doc.SetField(k, v)
|
||||||
|
}
|
||||||
|
return c.Insert(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find находит документ по ID (с использованием первичного индекса)
|
||||||
|
func (c *Collection) Find(id string) (*Document, error) {
|
||||||
|
if val, ok := c.docs.Load(id); ok {
|
||||||
|
doc := val.(*Document)
|
||||||
|
// Проверяем, не истёк ли TTL
|
||||||
|
if c.metadata.Settings.TTLSeconds > 0 {
|
||||||
|
if time.Now().UnixMilli()-doc.CreatedAt > int64(c.metadata.Settings.TTLSeconds*1000) {
|
||||||
|
c.Delete(id) // Автоматически удаляем просроченный документ
|
||||||
|
return nil, fmt.Errorf("key not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return doc, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("key not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// compareValues сравнивает два значения с учётом типа
|
||||||
|
func compareValues(a, b interface{}) bool {
|
||||||
|
if a == nil && b == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if a == nil || b == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пробуем прямое сравнение
|
||||||
|
if a == b {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пробуем сравнение через строковое представление для разных типов
|
||||||
|
aStr := fmt.Sprintf("%v", a)
|
||||||
|
bStr := fmt.Sprintf("%v", b)
|
||||||
|
return aStr == bStr
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByIndex находит документы по значению индексированного поля
|
||||||
|
// Исправлено: корректный поиск для неуникальных индексов с правильным сравнением типов
|
||||||
|
func (c *Collection) FindByIndex(indexName string, value interface{}) ([]*Document, error) {
|
||||||
|
idxVal, ok := c.indexes.Load(indexName)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("index not found: %s", indexName)
|
||||||
|
}
|
||||||
|
|
||||||
|
index := idxVal.(*Index)
|
||||||
|
docs := make([]*Document, 0)
|
||||||
|
|
||||||
|
if index.Unique {
|
||||||
|
// Уникальный индекс возвращает один документ
|
||||||
|
if docID, ok := index.data.Load(value); ok {
|
||||||
|
if doc, err := c.Find(docID.(string)); err == nil {
|
||||||
|
docs = append(docs, doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Исправлено: для неуникального индекса используем Range с правильным сравнением
|
||||||
|
index.data.Range(func(key, val interface{}) bool {
|
||||||
|
// key - значение индекса, val - ID документа
|
||||||
|
if compareValues(key, value) {
|
||||||
|
if doc, err := c.Find(val.(string)); err == nil {
|
||||||
|
docs = append(docs, doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return docs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByIndexPrefix находит документы по префиксу индекса (для строковых полей)
|
||||||
|
func (c *Collection) FindByIndexPrefix(indexName string, prefix string) ([]*Document, error) {
|
||||||
|
idxVal, ok := c.indexes.Load(indexName)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("index not found: %s", indexName)
|
||||||
|
}
|
||||||
|
|
||||||
|
index := idxVal.(*Index)
|
||||||
|
docs := make([]*Document, 0)
|
||||||
|
|
||||||
|
index.data.Range(func(key, val interface{}) bool {
|
||||||
|
if keyStr, ok := key.(string); ok {
|
||||||
|
if strings.HasPrefix(keyStr, prefix) {
|
||||||
|
if doc, err := c.Find(val.(string)); err == nil {
|
||||||
|
docs = append(docs, doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return docs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update обновляет документ по ID
|
||||||
|
// Исправлено: корректное обновление индексов при изменении индексированных полей
|
||||||
|
func (c *Collection) Update(id string, updates map[string]interface{}) error {
|
||||||
|
val, ok := c.docs.Load(id)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("key not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
oldDoc := val.(*Document)
|
||||||
|
|
||||||
|
// Создаём копию для проверки уникальности
|
||||||
|
newDoc := oldDoc.Clone()
|
||||||
|
if err := newDoc.Update(updates); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем ограничения
|
||||||
|
if err := c.constraints.ValidateDocument(newDoc); err != nil {
|
||||||
|
return fmt.Errorf("constraint violation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем уникальные индексы
|
||||||
|
if err := c.checkUniqueConstraintsUpdate(oldDoc, newDoc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Исправлено: сначала удаляем старые индексы, потом добавляем новые
|
||||||
|
c.removeFromIndexes(oldDoc)
|
||||||
|
c.addToIndexes(newDoc)
|
||||||
|
|
||||||
|
// Сохраняем обновлённый документ
|
||||||
|
c.docs.Store(id, newDoc)
|
||||||
|
|
||||||
|
c.metadata.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete удаляет документ по ID
|
||||||
|
// Исправлено: удаление из всех индексов перед удалением документа
|
||||||
|
func (c *Collection) Delete(id string) error {
|
||||||
|
val, ok := c.docs.Load(id)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("key not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
doc := val.(*Document)
|
||||||
|
|
||||||
|
// Удаляем из индексов
|
||||||
|
c.removeFromIndexes(doc)
|
||||||
|
|
||||||
|
// Удаляем документ
|
||||||
|
c.docs.Delete(id)
|
||||||
|
|
||||||
|
// Обновляем метаданные
|
||||||
|
c.docCount.Add(-1)
|
||||||
|
// Размер не обновляем для простоты (можно пересчитать при необходимости)
|
||||||
|
c.metadata.DocumentCount = c.docCount.Load()
|
||||||
|
c.metadata.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeFromIndexes удаляет документ из всех индексов (wait-free)
|
||||||
|
func (c *Collection) removeFromIndexes(doc *Document) {
|
||||||
|
c.indexes.Range(func(key, value interface{}) bool {
|
||||||
|
index := value.(*Index)
|
||||||
|
indexValue := c.extractIndexValue(doc, index.Fields)
|
||||||
|
index.data.Delete(indexValue)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// addToIndexes добавляет документ во все индексы (wait-free)
|
||||||
|
func (c *Collection) addToIndexes(doc *Document) {
|
||||||
|
c.indexes.Range(func(key, value interface{}) bool {
|
||||||
|
index := value.(*Index)
|
||||||
|
indexValue := c.extractIndexValue(doc, index.Fields)
|
||||||
|
if index.Unique {
|
||||||
|
index.data.LoadOrStore(indexValue, doc.ID)
|
||||||
|
} else {
|
||||||
|
index.data.Store(indexValue, doc.ID)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateIndex создаёт новый индекс на коллекции (wait-free friendly)
|
||||||
|
func (c *Collection) CreateIndex(name string, fields []string, unique bool) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := c.indexes.Load(name); exists {
|
||||||
|
return fmt.Errorf("index %s already exists", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
index := &Index{
|
||||||
|
Name: name,
|
||||||
|
Fields: fields,
|
||||||
|
Unique: unique,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Строим индекс на существующих документах (wait-free)
|
||||||
|
c.docs.Range(func(key, value interface{}) bool {
|
||||||
|
doc := value.(*Document)
|
||||||
|
indexValue := c.extractIndexValue(doc, fields)
|
||||||
|
if unique {
|
||||||
|
if _, loaded := index.data.LoadOrStore(indexValue, doc.ID); loaded {
|
||||||
|
// Найден дубликат - откатываем создание индекса
|
||||||
|
c.mu.Unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
index.data.Store(indexValue, doc.ID)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
c.indexes.Store(name, index)
|
||||||
|
c.metadata.IndexCount++
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DropIndex удаляет индекс
|
||||||
|
func (c *Collection) DropIndex(name string) error {
|
||||||
|
if _, exists := c.indexes.LoadAndDelete(name); !exists {
|
||||||
|
return fmt.Errorf("index not found: %s", name)
|
||||||
|
}
|
||||||
|
c.metadata.IndexCount--
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIndexes возвращает список всех индексов
|
||||||
|
func (c *Collection) GetIndexes() []string {
|
||||||
|
names := make([]string, 0)
|
||||||
|
c.indexes.Range(func(key, value interface{}) bool {
|
||||||
|
names = append(names, key.(string))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractIndexValue извлекает значение из документа для индексации
|
||||||
|
func (c *Collection) extractIndexValue(doc *Document, fields []string) interface{} {
|
||||||
|
if len(fields) == 1 {
|
||||||
|
val, _ := doc.GetField(fields[0])
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
// Составной индекс - возвращаем строковое представление
|
||||||
|
parts := make([]string, 0, len(fields))
|
||||||
|
for _, field := range fields {
|
||||||
|
if val, err := doc.GetField(field); err == nil {
|
||||||
|
parts = append(parts, fmt.Sprintf("%v", val))
|
||||||
|
} else {
|
||||||
|
parts = append(parts, "NULL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "|")
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateIndexes обновляет индексы для документа (исправлено: используем отдельные методы)
|
||||||
|
func (c *Collection) updateIndexes(doc *Document, add bool) {
|
||||||
|
if add {
|
||||||
|
c.addToIndexes(doc)
|
||||||
|
} else {
|
||||||
|
c.removeFromIndexes(doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkUniqueConstraints проверяет уникальные индексы перед вставкой
|
||||||
|
func (c *Collection) checkUniqueConstraints(doc *Document) error {
|
||||||
|
var errs []string
|
||||||
|
|
||||||
|
c.indexes.Range(func(key, value interface{}) bool {
|
||||||
|
index := value.(*Index)
|
||||||
|
if index.Unique {
|
||||||
|
indexValue := c.extractIndexValue(doc, index.Fields)
|
||||||
|
if _, exists := index.data.Load(indexValue); exists {
|
||||||
|
errs = append(errs, fmt.Sprintf("duplicate key for index %s: %v", index.Name, indexValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return fmt.Errorf("%s", strings.Join(errs, "; "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkUniqueConstraintsUpdate проверяет уникальность при обновлении
|
||||||
|
func (c *Collection) checkUniqueConstraintsUpdate(oldDoc, newDoc *Document) error {
|
||||||
|
var errs []string
|
||||||
|
|
||||||
|
c.indexes.Range(func(key, value interface{}) bool {
|
||||||
|
index := value.(*Index)
|
||||||
|
if index.Unique {
|
||||||
|
oldValue := c.extractIndexValue(oldDoc, index.Fields)
|
||||||
|
newValue := c.extractIndexValue(newDoc, index.Fields)
|
||||||
|
|
||||||
|
if fmt.Sprintf("%v", oldValue) != fmt.Sprintf("%v", newValue) {
|
||||||
|
if _, exists := index.data.Load(newValue); exists {
|
||||||
|
errs = append(errs, fmt.Sprintf("duplicate key for index %s: %v", index.Name, newValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return fmt.Errorf("%s", strings.Join(errs, "; "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateDocument валидирует документ согласно схеме коллекции
|
||||||
|
func (c *Collection) validateDocument(doc *Document) error {
|
||||||
|
if doc.ID == "" {
|
||||||
|
return fmt.Errorf("document must have _id field")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ttlCleanupLoop периодически удаляет просроченные документы
|
||||||
|
func (c *Collection) ttlCleanupLoop() {
|
||||||
|
ticker := time.NewTicker(time.Duration(c.metadata.Settings.TTLSeconds/2) * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
toDelete := make([]string, 0)
|
||||||
|
|
||||||
|
c.docs.Range(func(key, value interface{}) bool {
|
||||||
|
doc := value.(*Document)
|
||||||
|
if now-doc.CreatedAt > int64(c.metadata.Settings.TTLSeconds*1000) {
|
||||||
|
toDelete = append(toDelete, doc.ID)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, id := range toDelete {
|
||||||
|
c.Delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count возвращает количество документов в коллекции
|
||||||
|
func (c *Collection) Count() int64 {
|
||||||
|
return c.docCount.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size возвращает размер коллекции в байтах
|
||||||
|
func (c *Collection) Size() int64 {
|
||||||
|
return c.sizeBytes.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllDocuments возвращает все документы коллекции
|
||||||
|
func (c *Collection) GetAllDocuments() []*Document {
|
||||||
|
docs := make([]*Document, 0, c.docCount.Load())
|
||||||
|
c.docs.Range(func(key, value interface{}) bool {
|
||||||
|
docs = append(docs, value.(*Document))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return docs
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindByFilter находит документы по произвольному фильтру
|
||||||
|
func (c *Collection) FindByFilter(filter func(*Document) bool) []*Document {
|
||||||
|
results := make([]*Document, 0)
|
||||||
|
c.docs.Range(func(key, value interface{}) bool {
|
||||||
|
doc := value.(*Document)
|
||||||
|
if filter(doc) {
|
||||||
|
results = append(results, doc)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMetadata возвращает метаданные коллекции
|
||||||
|
func (c *Collection) GetMetadata() *CollectionMetadata {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop удаляет все документы из коллекции
|
||||||
|
func (c *Collection) Drop() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
c.docs = sync.Map{}
|
||||||
|
c.indexes = sync.Map{}
|
||||||
|
|
||||||
|
if c.metadata.Settings.AutoIndexID {
|
||||||
|
c.CreateIndex("_id_", []string{"_id"}, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.docCount.Store(0)
|
||||||
|
c.sizeBytes.Store(0)
|
||||||
|
c.metadata.DocumentCount = 0
|
||||||
|
c.metadata.SizeBytes = 0
|
||||||
|
c.metadata.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Constraints Methods ==========
|
||||||
|
|
||||||
|
// AddRequiredField добавляет обязательное поле
|
||||||
|
func (c *Collection) AddRequiredField(field string) {
|
||||||
|
c.constraints.mu.Lock()
|
||||||
|
defer c.constraints.mu.Unlock()
|
||||||
|
c.constraints.RequiredFields[field] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddUniqueConstraint добавляет ограничение уникальности
|
||||||
|
func (c *Collection) AddUniqueConstraint(field string) {
|
||||||
|
c.constraints.mu.Lock()
|
||||||
|
defer c.constraints.mu.Unlock()
|
||||||
|
c.constraints.UniqueFields[field] = true
|
||||||
|
// Также создаём уникальный индекс
|
||||||
|
c.CreateIndex("unique_"+field, []string{field}, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMinConstraint добавляет минимальное значение
|
||||||
|
func (c *Collection) AddMinConstraint(field string, min float64) {
|
||||||
|
c.constraints.mu.Lock()
|
||||||
|
defer c.constraints.mu.Unlock()
|
||||||
|
c.constraints.MinValues[field] = min
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddMaxConstraint добавляет максимальное значение
|
||||||
|
func (c *Collection) AddMaxConstraint(field string, max float64) {
|
||||||
|
c.constraints.mu.Lock()
|
||||||
|
defer c.constraints.mu.Unlock()
|
||||||
|
c.constraints.MaxValues[field] = max
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddPatternConstraint добавляет regexp паттерн
|
||||||
|
func (c *Collection) AddPatternConstraint(field string, pattern string) {
|
||||||
|
c.constraints.mu.Lock()
|
||||||
|
defer c.constraints.mu.Unlock()
|
||||||
|
c.constraints.PatternFields[field] = pattern
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddEnumConstraint добавляет допустимые значения
|
||||||
|
func (c *Collection) AddEnumConstraint(field string, values []interface{}) {
|
||||||
|
c.constraints.mu.Lock()
|
||||||
|
defer c.constraints.mu.Unlock()
|
||||||
|
c.constraints.EnumFields[field] = values
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateDocument проверяет документ на соответствие ограничениям
|
||||||
|
func (cons *Constraints) ValidateDocument(doc *Document) error {
|
||||||
|
cons.mu.RLock()
|
||||||
|
defer cons.mu.RUnlock()
|
||||||
|
|
||||||
|
// Проверка обязательных полей
|
||||||
|
for field := range cons.RequiredFields {
|
||||||
|
if !doc.HasField(field) {
|
||||||
|
return fmt.Errorf("required field '%s' is missing", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка уникальности (дополнительно к индексам)
|
||||||
|
// (основная проверка в индексах)
|
||||||
|
|
||||||
|
// Проверка числовых ограничений
|
||||||
|
for field, minVal := range cons.MinValues {
|
||||||
|
if val, err := doc.GetField(field); err == nil {
|
||||||
|
if numVal, ok := toFloat64(val); ok {
|
||||||
|
if numVal < minVal {
|
||||||
|
return fmt.Errorf("field '%s' value %v is less than minimum %v", field, numVal, minVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for field, maxVal := range cons.MaxValues {
|
||||||
|
if val, err := doc.GetField(field); err == nil {
|
||||||
|
if numVal, ok := toFloat64(val); ok {
|
||||||
|
if numVal > maxVal {
|
||||||
|
return fmt.Errorf("field '%s' value %v exceeds maximum %v", field, numVal, maxVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка enum
|
||||||
|
for field, allowedValues := range cons.EnumFields {
|
||||||
|
if val, err := doc.GetField(field); err == nil {
|
||||||
|
found := false
|
||||||
|
for _, allowed := range allowedValues {
|
||||||
|
if fmt.Sprintf("%v", val) == fmt.Sprintf("%v", allowed) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return fmt.Errorf("field '%s' value '%v' not in allowed list", field, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== ACL Methods ==========
|
||||||
|
|
||||||
|
// SetACL устанавливает ACL для коллекции
|
||||||
|
func (c *Collection) SetACL(role string, canRead, canWrite, canDelete, isAdmin bool) {
|
||||||
|
c.acl.mu.Lock()
|
||||||
|
defer c.acl.mu.Unlock()
|
||||||
|
|
||||||
|
if canRead {
|
||||||
|
c.acl.ReadRoles[role] = true
|
||||||
|
}
|
||||||
|
if canWrite {
|
||||||
|
c.acl.WriteRoles[role] = true
|
||||||
|
}
|
||||||
|
if canDelete {
|
||||||
|
c.acl.DeleteRoles[role] = true
|
||||||
|
}
|
||||||
|
if isAdmin {
|
||||||
|
c.acl.AdminRoles[role] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPermission проверяет наличие разрешения у роли
|
||||||
|
func (c *Collection) CheckPermission(role, operation string) bool {
|
||||||
|
c.acl.mu.RLock()
|
||||||
|
defer c.acl.mu.RUnlock()
|
||||||
|
|
||||||
|
// Администратор имеет все права
|
||||||
|
if c.acl.AdminRoles[role] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
switch operation {
|
||||||
|
case "read":
|
||||||
|
return c.acl.ReadRoles[role]
|
||||||
|
case "write":
|
||||||
|
return c.acl.WriteRoles[role]
|
||||||
|
case "delete":
|
||||||
|
return c.acl.DeleteRoles[role]
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toFloat64 конвертирует interface{} в float64
|
||||||
|
func toFloat64(val interface{}) (float64, bool) {
|
||||||
|
switch v := val.(type) {
|
||||||
|
case int:
|
||||||
|
return float64(v), true
|
||||||
|
case int64:
|
||||||
|
return float64(v), true
|
||||||
|
case float64:
|
||||||
|
return v, true
|
||||||
|
case float32:
|
||||||
|
return float64(v), true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
490
internal/storage/document.go
Normal file
490
internal/storage/document.go
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/storage/document.go
|
||||||
|
// Назначение: Определение структуры документа, его методов для работы
|
||||||
|
// с полями, кортежами (вложенными документами) и сериализации в MessagePack.
|
||||||
|
// Документ является основной единицей хранения в СУБД futriis.
|
||||||
|
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"futriis/internal/compression"
|
||||||
|
"futriis/internal/serializer"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Document представляет документ в коллекции (аналог строки в реляционной СУБД)
|
||||||
|
type Document struct {
|
||||||
|
ID string `msgpack:"_id"` // Уникальный идентификатор документа
|
||||||
|
Fields map[string]interface{} `msgpack:"fields"` // Поля документа (аналог колонок)
|
||||||
|
CreatedAt int64 `msgpack:"created_at"` // Время создания (Unix миллисекунды)
|
||||||
|
UpdatedAt int64 `msgpack:"updated_at"` // Время последнего обновления
|
||||||
|
Version uint64 `msgpack:"version"` // Версия документа (для оптимистичных блокировок)
|
||||||
|
Compressed bool `msgpack:"compressed"` // Флаг, сжат ли документ
|
||||||
|
OriginalSize int64 `msgpack:"original_size"` // Оригинальный размер до сжатия
|
||||||
|
mu sync.RWMutex `msgpack:"-"` // Блокировка для wait-free операций
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tuple представляет вложенный документ (аналог кортежа в реляционной СУБД)
|
||||||
|
type Tuple struct {
|
||||||
|
Fields map[string]interface{} `msgpack:"fields"`
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Field представляет отдельное поле документа (аналог колонки)
|
||||||
|
type Field struct {
|
||||||
|
Name string `msgpack:"name"`
|
||||||
|
Type FieldType `msgpack:"type"`
|
||||||
|
Value interface{} `msgpack:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FieldType определяет тип поля документа
|
||||||
|
type FieldType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
TypeString FieldType = iota
|
||||||
|
TypeNumber
|
||||||
|
TypeBoolean
|
||||||
|
TypeTuple // Вложенный документ
|
||||||
|
TypeArray
|
||||||
|
TypeNull
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewDocument создаёт новый документ с автоматической генерацией ID
|
||||||
|
func NewDocument() *Document {
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
return &Document{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
Fields: make(map[string]interface{}),
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
Version: 1,
|
||||||
|
Compressed: false,
|
||||||
|
OriginalSize: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDocumentWithID создаёт документ с указанным ID
|
||||||
|
func NewDocumentWithID(id string) *Document {
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
return &Document{
|
||||||
|
ID: id,
|
||||||
|
Fields: make(map[string]interface{}),
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
Version: 1,
|
||||||
|
Compressed: false,
|
||||||
|
OriginalSize: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetField устанавливает значение поля документа (wait-free)
|
||||||
|
func (d *Document) SetField(name string, value interface{}) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
d.Fields[name] = value
|
||||||
|
d.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
d.Version++
|
||||||
|
d.Compressed = false // При изменении документа снимаем флаг сжатия
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetField возвращает значение поля документа
|
||||||
|
func (d *Document) GetField(name string) (interface{}, error) {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
|
||||||
|
if val, ok := d.Fields[name]; ok {
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("field not found: %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteField удаляет поле из документа
|
||||||
|
func (d *Document) DeleteField(name string) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
delete(d.Fields, name)
|
||||||
|
d.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
d.Version++
|
||||||
|
d.Compressed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasField проверяет наличие поля в документе
|
||||||
|
func (d *Document) HasField(name string) bool {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
|
||||||
|
_, ok := d.Fields[name]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFields возвращает копию всех полей документа
|
||||||
|
func (d *Document) GetFields() map[string]interface{} {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
|
||||||
|
copy := make(map[string]interface{})
|
||||||
|
for k, v := range d.Fields {
|
||||||
|
copy[k] = v
|
||||||
|
}
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTuple устанавливает вложенный документ (кортеж) в поле
|
||||||
|
func (d *Document) SetTuple(fieldName string, tuple *Tuple) {
|
||||||
|
d.SetField(fieldName, tuple)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTuple возвращает вложенный документ из поля
|
||||||
|
func (d *Document) GetTuple(fieldName string) (*Tuple, error) {
|
||||||
|
val, err := d.GetField(fieldName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if tuple, ok := val.(*Tuple); ok {
|
||||||
|
return tuple, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("field %s is not a tuple", fieldName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize сериализует документ в MessagePack с поддержкой сжатия
|
||||||
|
func (d *Document) Serialize() ([]byte, error) {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
|
||||||
|
data, err := serializer.Marshal(d)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SerializeCompressed сериализует и сжимает документ
|
||||||
|
func (d *Document) SerializeCompressed(compressionConfig *compression.Config) ([]byte, error) {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
|
||||||
|
// Сериализуем документ
|
||||||
|
data, err := serializer.Marshal(d)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем, нужно ли сжимать
|
||||||
|
if compressionConfig != nil && compressionConfig.Enabled && len(data) >= compressionConfig.MinSize {
|
||||||
|
compressed, err := compression.Compress(data, compressionConfig)
|
||||||
|
if err != nil {
|
||||||
|
// При ошибке сжатия возвращаем несжатые данные
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
return compressed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deserialize десериализует документ из MessagePack (автоматически определяет сжатие)
|
||||||
|
func (d *Document) Deserialize(data []byte) error {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
// Пытаемся определить, сжаты ли данные
|
||||||
|
// Для этого пробуем распаковать, если не получается - данные несжатые
|
||||||
|
decompressed, err := compression.DecompressAuto(data)
|
||||||
|
if err == nil && len(decompressed) < len(data) {
|
||||||
|
// Данные были сжаты, используем распакованную версию
|
||||||
|
if err := serializer.Unmarshal(decompressed, d); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
d.Compressed = true
|
||||||
|
d.OriginalSize = int64(len(decompressed))
|
||||||
|
} else {
|
||||||
|
// Данные не сжаты или не удалось распаковать
|
||||||
|
if err := serializer.Unmarshal(data, d); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
d.Compressed = false
|
||||||
|
d.OriginalSize = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем временные метки при десериализации
|
||||||
|
d.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone создаёт глубокую копию документа
|
||||||
|
func (d *Document) Clone() *Document {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
|
||||||
|
clone := &Document{
|
||||||
|
ID: d.ID,
|
||||||
|
Fields: make(map[string]interface{}),
|
||||||
|
CreatedAt: d.CreatedAt,
|
||||||
|
UpdatedAt: d.UpdatedAt,
|
||||||
|
Version: d.Version,
|
||||||
|
Compressed: d.Compressed,
|
||||||
|
OriginalSize: d.OriginalSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Глубокое копирование полей
|
||||||
|
for k, v := range d.Fields {
|
||||||
|
clone.Fields[k] = deepCopyValue(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update применяет обновление к документу (атомарно)
|
||||||
|
func (d *Document) Update(updates map[string]interface{}) error {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
for k, v := range updates {
|
||||||
|
d.Fields[k] = v
|
||||||
|
}
|
||||||
|
d.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
d.Version++
|
||||||
|
d.Compressed = false // После обновления документ больше не сжат
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compress сжимает документ в памяти
|
||||||
|
func (d *Document) Compress(config *compression.Config) error {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
if d.Compressed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем текущее состояние
|
||||||
|
originalSize := len(d.Fields)
|
||||||
|
if originalSize < config.MinSize {
|
||||||
|
return nil // Не сжимаем маленькие документы
|
||||||
|
}
|
||||||
|
|
||||||
|
d.Compressed = true
|
||||||
|
d.OriginalSize = int64(originalSize)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decompress распаковывает документ в памяти
|
||||||
|
func (d *Document) Decompress() error {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
|
if !d.Compressed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
d.Compressed = false
|
||||||
|
d.OriginalSize = 0
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCompressionRatio возвращает коэффициент сжатия
|
||||||
|
func (d *Document) GetCompressionRatio() float64 {
|
||||||
|
d.mu.RLock()
|
||||||
|
defer d.mu.RUnlock()
|
||||||
|
|
||||||
|
if !d.Compressed || d.OriginalSize == 0 {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSize := len(d.Fields)
|
||||||
|
return float64(currentSize) / float64(d.OriginalSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deepCopyValue выполняет глубокое копирование значения
|
||||||
|
func deepCopyValue(val interface{}) interface{} {
|
||||||
|
switch v := val.(type) {
|
||||||
|
case *Tuple:
|
||||||
|
return v.Clone()
|
||||||
|
case map[string]interface{}:
|
||||||
|
copy := make(map[string]interface{})
|
||||||
|
for k, val := range v {
|
||||||
|
copy[k] = deepCopyValue(val)
|
||||||
|
}
|
||||||
|
return copy
|
||||||
|
case []interface{}:
|
||||||
|
copy := make([]interface{}, len(v))
|
||||||
|
for i, val := range v {
|
||||||
|
copy[i] = deepCopyValue(val)
|
||||||
|
}
|
||||||
|
return copy
|
||||||
|
default:
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTuple создаёт новый вложенный документ (кортеж)
|
||||||
|
func NewTuple() *Tuple {
|
||||||
|
return &Tuple{
|
||||||
|
Fields: make(map[string]interface{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set устанавливает поле во вложенном документе
|
||||||
|
func (t *Tuple) Set(name string, value interface{}) {
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
t.Fields[name] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get возвращает поле из вложенного документа
|
||||||
|
func (t *Tuple) Get(name string) (interface{}, error) {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
|
||||||
|
if val, ok := t.Fields[name]; ok {
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("tuple field not found: %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone создаёт копию кортежа
|
||||||
|
func (t *Tuple) Clone() *Tuple {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
|
||||||
|
clone := NewTuple()
|
||||||
|
for k, v := range t.Fields {
|
||||||
|
clone.Fields[k] = deepCopyValue(v)
|
||||||
|
}
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToMap конвертирует кортеж в map
|
||||||
|
func (t *Tuple) ToMap() map[string]interface{} {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
|
||||||
|
copy := make(map[string]interface{})
|
||||||
|
for k, v := range t.Fields {
|
||||||
|
copy[k] = v
|
||||||
|
}
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNestedField получает значение по точечному пути (например, "user.address.city")
|
||||||
|
func (d *Document) GetNestedField(path string) (interface{}, error) {
|
||||||
|
parts := strings.Split(path, ".")
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return nil, fmt.Errorf("empty path")
|
||||||
|
}
|
||||||
|
|
||||||
|
current := interface{}(d)
|
||||||
|
for _, part := range parts {
|
||||||
|
switch v := current.(type) {
|
||||||
|
case *Document:
|
||||||
|
val, err := v.GetField(part)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
current = val
|
||||||
|
case *Tuple:
|
||||||
|
val, err := v.Get(part)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
current = val
|
||||||
|
case map[string]interface{}:
|
||||||
|
if val, ok := v[part]; ok {
|
||||||
|
current = val
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("field not found: %s", part)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("cannot navigate into non-document value at %s", part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return current, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetNestedField устанавливает значение по точечному пути
|
||||||
|
func (d *Document) SetNestedField(path string, value interface{}) error {
|
||||||
|
parts := strings.Split(path, ".")
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return fmt.Errorf("empty path")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если путь состоит из одного элемента, просто устанавливаем поле
|
||||||
|
if len(parts) == 1 {
|
||||||
|
d.SetField(parts[0], value)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Иначе нужно создать промежуточные структуры
|
||||||
|
current := interface{}(d)
|
||||||
|
for i := 0; i < len(parts)-1; i++ {
|
||||||
|
part := parts[i]
|
||||||
|
|
||||||
|
switch v := current.(type) {
|
||||||
|
case *Document:
|
||||||
|
if !v.HasField(part) {
|
||||||
|
// Создаём новый кортеж, если поле не существует
|
||||||
|
newTuple := NewTuple()
|
||||||
|
v.SetField(part, newTuple)
|
||||||
|
current = newTuple
|
||||||
|
} else {
|
||||||
|
field, _ := v.GetField(part)
|
||||||
|
if tuple, ok := field.(*Tuple); ok {
|
||||||
|
current = tuple
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("field %s is not a tuple", part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case *Tuple:
|
||||||
|
if val, err := v.Get(part); err == nil {
|
||||||
|
if tuple, ok := val.(*Tuple); ok {
|
||||||
|
current = tuple
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("field %s is not a tuple", part)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newTuple := NewTuple()
|
||||||
|
v.Set(part, newTuple)
|
||||||
|
current = newTuple
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("cannot set nested field on non-document value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем значение в последний элемент пути
|
||||||
|
lastPart := parts[len(parts)-1]
|
||||||
|
switch v := current.(type) {
|
||||||
|
case *Document:
|
||||||
|
v.SetField(lastPart, value)
|
||||||
|
case *Tuple:
|
||||||
|
v.Set(lastPart, value)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("cannot set field on non-document value")
|
||||||
|
}
|
||||||
|
|
||||||
|
d.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
d.Compressed = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
234
internal/storage/engine.go
Normal file
234
internal/storage/engine.go
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/storage/engine.go
|
||||||
|
// Назначение: In-memory движок хранения документов с поддержкой коллекций,
|
||||||
|
// слайсов (аналог БД), тапплов (аналог таблиц), полей и кортежей.
|
||||||
|
// Полностью wait-free с использованием sync.Map и атомарных операций.
|
||||||
|
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"futriis/internal/log"
|
||||||
|
"futriis/internal/serializer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Storage представляет основное хранилище баз данных
|
||||||
|
type Storage struct {
|
||||||
|
databases sync.Map // map[string]*Database
|
||||||
|
pageSize int64
|
||||||
|
logger *log.Logger
|
||||||
|
totalDocs atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database представляет базу данных (аналог слайса в реляционных СУБД)
|
||||||
|
type Database struct {
|
||||||
|
name string
|
||||||
|
collections sync.Map // map[string]*Collection
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStorage создаёт новый экземпляр хранилища
|
||||||
|
func NewStorage(pageSizeMB int, logger *log.Logger) *Storage {
|
||||||
|
return &Storage{
|
||||||
|
pageSize: int64(pageSizeMB) * 1024 * 1024,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDatabase создаёт новую базу данных
|
||||||
|
func (s *Storage) CreateDatabase(name string) error {
|
||||||
|
if _, exists := s.databases.LoadOrStore(name, &Database{name: name}); exists {
|
||||||
|
return fmt.Errorf("database already exists")
|
||||||
|
}
|
||||||
|
AuditDatabaseOperation("CREATE", name)
|
||||||
|
s.logger.Info("Database created: " + name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDatabase возвращает базу данных по имени
|
||||||
|
func (s *Storage) GetDatabase(name string) (*Database, error) {
|
||||||
|
if val, ok := s.databases.Load(name); ok {
|
||||||
|
return val.(*Database), nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("database not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DropDatabase удаляет базу данных
|
||||||
|
func (s *Storage) DropDatabase(name string) error {
|
||||||
|
if _, ok := s.databases.LoadAndDelete(name); !ok {
|
||||||
|
return fmt.Errorf("database not found")
|
||||||
|
}
|
||||||
|
AuditDatabaseOperation("DROP", name)
|
||||||
|
s.logger.Info("Database dropped: " + name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDatabases возвращает список всех баз данных
|
||||||
|
func (s *Storage) ListDatabases() []string {
|
||||||
|
databases := make([]string, 0)
|
||||||
|
s.databases.Range(func(key, value interface{}) bool {
|
||||||
|
databases = append(databases, key.(string))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return databases
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name возвращает имя базы данных
|
||||||
|
func (db *Database) Name() string {
|
||||||
|
return db.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCollection создаёт новую коллекцию в базе данных
|
||||||
|
func (db *Database) CreateCollection(name string) error {
|
||||||
|
if _, exists := db.collections.LoadOrStore(name, NewCollection(name, nil)); exists {
|
||||||
|
return fmt.Errorf("collection already exists")
|
||||||
|
}
|
||||||
|
AuditCollectionOperation("CREATE", db.name, name, nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateCollectionWithSettings создаёт коллекцию с настройками
|
||||||
|
func (db *Database) CreateCollectionWithSettings(name string, settings *CollectionSettings) error {
|
||||||
|
if _, exists := db.collections.LoadOrStore(name, NewCollection(name, settings)); exists {
|
||||||
|
return fmt.Errorf("collection already exists")
|
||||||
|
}
|
||||||
|
AuditCollectionOperation("CREATE", db.name, name, settings)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCollection возвращает коллекцию по имени
|
||||||
|
func (db *Database) GetCollection(name string) (*Collection, error) {
|
||||||
|
if val, ok := db.collections.Load(name); ok {
|
||||||
|
return val.(*Collection), nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("collection not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DropCollection удаляет коллекцию
|
||||||
|
func (db *Database) DropCollection(name string) error {
|
||||||
|
if _, ok := db.collections.LoadAndDelete(name); !ok {
|
||||||
|
return fmt.Errorf("collection not found")
|
||||||
|
}
|
||||||
|
AuditCollectionOperation("DROP", db.name, name, nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCollections возвращает список всех коллекций в базе данных
|
||||||
|
func (db *Database) ListCollections() []string {
|
||||||
|
collections := make([]string, 0)
|
||||||
|
db.collections.Range(func(key, value interface{}) bool {
|
||||||
|
collections = append(collections, key.(string))
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return collections
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTotalDocuments возвращает общее количество документов во всех коллекциях
|
||||||
|
func (s *Storage) GetTotalDocuments() int64 {
|
||||||
|
return s.totalDocs.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPageSize возвращает размер страницы памяти
|
||||||
|
func (s *Storage) GetPageSize() int64 {
|
||||||
|
return s.pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// SerializeDatabase сериализует всю базу данных в MessagePack
|
||||||
|
func (db *Database) SerializeDatabase() ([]byte, error) {
|
||||||
|
dbData := make(map[string]interface{})
|
||||||
|
|
||||||
|
db.collections.Range(func(key, value interface{}) bool {
|
||||||
|
coll := value.(*Collection)
|
||||||
|
collData := make(map[string]interface{})
|
||||||
|
|
||||||
|
// Собираем все документы коллекции
|
||||||
|
docs := coll.GetAllDocuments()
|
||||||
|
collDocs := make([]*Document, 0, len(docs))
|
||||||
|
for _, doc := range docs {
|
||||||
|
collDocs = append(collDocs, doc)
|
||||||
|
}
|
||||||
|
collData["documents"] = collDocs
|
||||||
|
collData["metadata"] = coll.GetMetadata()
|
||||||
|
|
||||||
|
dbData[key.(string)] = collData
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return serializer.Marshal(dbData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeserializeDatabase десериализует базу данных из MessagePack
|
||||||
|
func (db *Database) DeserializeDatabase(data []byte) error {
|
||||||
|
var dbData map[string]interface{}
|
||||||
|
if err := serializer.Unmarshal(data, &dbData); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for collName, collDataRaw := range dbData {
|
||||||
|
collData := collDataRaw.(map[string]interface{})
|
||||||
|
|
||||||
|
// Создаём коллекцию
|
||||||
|
settings := &CollectionSettings{
|
||||||
|
MaxDocuments: 0,
|
||||||
|
ValidateSchema: false,
|
||||||
|
AutoIndexID: true,
|
||||||
|
TTLSeconds: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if metaRaw, ok := collData["metadata"]; ok {
|
||||||
|
if meta, ok := metaRaw.(*CollectionMetadata); ok {
|
||||||
|
if meta.Settings != nil {
|
||||||
|
settings = meta.Settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
coll := NewCollection(collName, settings)
|
||||||
|
|
||||||
|
// Восстанавливаем документы
|
||||||
|
if docsRaw, ok := collData["documents"]; ok {
|
||||||
|
if docs, ok := docsRaw.([]*Document); ok {
|
||||||
|
for _, doc := range docs {
|
||||||
|
coll.Insert(doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.collections.Store(collName, coll)
|
||||||
|
AuditCollectionOperation("RESTORE", db.name, collName, settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDatabaseNames возвращает имена всех баз данных
|
||||||
|
func (s *Storage) GetDatabaseNames() []string {
|
||||||
|
return s.ListDatabases()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExistsDatabase проверяет существование базы данных
|
||||||
|
func (s *Storage) ExistsDatabase(name string) bool {
|
||||||
|
_, ok := s.databases.Load(name)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDatabaseCount возвращает количество баз данных
|
||||||
|
func (s *Storage) GetDatabaseCount() int {
|
||||||
|
count := 0
|
||||||
|
s.databases.Range(func(key, value interface{}) bool {
|
||||||
|
count++
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return count
|
||||||
|
}
|
||||||
392
internal/storage/transaction.go
Normal file
392
internal/storage/transaction.go
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/storage/transaction.go
|
||||||
|
// Назначение: Реализация транзакций с поддержкой MVCC (Multi-Version Concurrency Control)
|
||||||
|
// и WAL (Write-Ahead Logging) без блокировок. Использует атомарные операции и версионирование.
|
||||||
|
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"futriis/internal/serializer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TransactionID представляет уникальный идентификатор транзакции
|
||||||
|
type TransactionID uint64
|
||||||
|
|
||||||
|
// TransactionState представляет состояние транзакции
|
||||||
|
type TransactionState int32
|
||||||
|
|
||||||
|
const (
|
||||||
|
TransactionActive TransactionState = iota
|
||||||
|
TransactionCommitted
|
||||||
|
TransactionAborted
|
||||||
|
)
|
||||||
|
|
||||||
|
// TransactionRecord представляет запись в WAL
|
||||||
|
type TransactionRecord struct {
|
||||||
|
ID TransactionID `msgpack:"id"`
|
||||||
|
State TransactionState `msgpack:"state"`
|
||||||
|
Timestamp int64 `msgpack:"timestamp"`
|
||||||
|
Operations []Operation `msgpack:"operations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operation представляет одну операцию в транзакции
|
||||||
|
type Operation struct {
|
||||||
|
Type string `msgpack:"type"` // "insert", "update", "delete"
|
||||||
|
Database string `msgpack:"database"`
|
||||||
|
Collection string `msgpack:"collection"`
|
||||||
|
DocumentID string `msgpack:"document_id"`
|
||||||
|
Data map[string]interface{} `msgpack:"data"`
|
||||||
|
Version uint64 `msgpack:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocumentVersion представляет версию документа для MVCC
|
||||||
|
type DocumentVersion struct {
|
||||||
|
Document *Document `msgpack:"document"`
|
||||||
|
Timestamp int64 `msgpack:"timestamp"`
|
||||||
|
TxID TransactionID `msgpack:"tx_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TransactionManager управляет транзакциями
|
||||||
|
type TransactionManager struct {
|
||||||
|
activeTransactions sync.Map // map[TransactionID]*Transaction
|
||||||
|
nextTxID atomic.Uint64
|
||||||
|
wal *WriteAheadLog
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction представляет одну транзакцию
|
||||||
|
type Transaction struct {
|
||||||
|
ID TransactionID
|
||||||
|
State atomic.Int32
|
||||||
|
Operations []Operation
|
||||||
|
StartTime int64
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteAheadLog реализует журнал предзаписи
|
||||||
|
type WriteAheadLog struct {
|
||||||
|
file *os.File
|
||||||
|
writeChan chan []byte
|
||||||
|
done chan struct{}
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalTxManager *TransactionManager
|
||||||
|
txManagerOnce sync.Once
|
||||||
|
currentTx atomic.Value // *Transaction
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitTransactionManager инициализирует глобальный менеджер транзакций
|
||||||
|
func InitTransactionManager(walPath string) error {
|
||||||
|
var err error
|
||||||
|
txManagerOnce.Do(func() {
|
||||||
|
globalTxManager = &TransactionManager{
|
||||||
|
nextTxID: atomic.Uint64{},
|
||||||
|
}
|
||||||
|
globalTxManager.nextTxID.Store(1)
|
||||||
|
err = globalTxManager.initWAL(walPath)
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// initWAL инициализирует Write-Ahead Log
|
||||||
|
func (tm *TransactionManager) initWAL(walPath string) error {
|
||||||
|
wal, err := NewWriteAheadLog(walPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tm.wal = wal
|
||||||
|
|
||||||
|
// Восстанавливаем состояние из WAL при запуске
|
||||||
|
go tm.recoverFromWAL()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWriteAheadLog создаёт новый WAL
|
||||||
|
func NewWriteAheadLog(path string) (*WriteAheadLog, error) {
|
||||||
|
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
wal := &WriteAheadLog{
|
||||||
|
file: file,
|
||||||
|
writeChan: make(chan []byte, 10000),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
go wal.writerLoop()
|
||||||
|
|
||||||
|
return wal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writerLoop асинхронно записывает данные в WAL
|
||||||
|
func (wal *WriteAheadLog) writerLoop() {
|
||||||
|
for data := range wal.writeChan {
|
||||||
|
wal.mu.Lock()
|
||||||
|
// Формат записи: [длина (4 байта)][данные]
|
||||||
|
lenBuf := make([]byte, 4)
|
||||||
|
binary.BigEndian.PutUint32(lenBuf, uint32(len(data)))
|
||||||
|
|
||||||
|
if _, err := wal.file.Write(lenBuf); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := wal.file.Write(data); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wal.file.Sync()
|
||||||
|
wal.mu.Unlock()
|
||||||
|
}
|
||||||
|
close(wal.done)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write записывает запись в WAL
|
||||||
|
func (wal *WriteAheadLog) Write(record *TransactionRecord) error {
|
||||||
|
data, err := serializer.Marshal(record)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case wal.writeChan <- data:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("WAL buffer full")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close закрывает WAL
|
||||||
|
func (wal *WriteAheadLog) Close() error {
|
||||||
|
close(wal.writeChan)
|
||||||
|
<-wal.done
|
||||||
|
return wal.file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeginTransaction начинает новую транзакцию
|
||||||
|
func BeginTransaction() *Transaction {
|
||||||
|
if globalTxManager == nil {
|
||||||
|
InitTransactionManager("futriis.wal")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := &Transaction{
|
||||||
|
ID: TransactionID(globalTxManager.nextTxID.Add(1) - 1),
|
||||||
|
StartTime: time.Now().UnixMilli(),
|
||||||
|
Operations: make([]Operation, 0),
|
||||||
|
}
|
||||||
|
tx.State.Store(int32(TransactionActive))
|
||||||
|
|
||||||
|
globalTxManager.activeTransactions.Store(tx.ID, tx)
|
||||||
|
|
||||||
|
// Сохраняем как текущую транзакцию для горутины
|
||||||
|
currentTx.Store(tx)
|
||||||
|
|
||||||
|
// Записываем начало транзакции в WAL
|
||||||
|
record := &TransactionRecord{
|
||||||
|
ID: tx.ID,
|
||||||
|
State: TransactionActive,
|
||||||
|
Timestamp: tx.StartTime,
|
||||||
|
Operations: tx.Operations,
|
||||||
|
}
|
||||||
|
globalTxManager.wal.Write(record)
|
||||||
|
|
||||||
|
return tx
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddToTransaction добавляет операцию в текущую транзакцию
|
||||||
|
func AddToTransaction(coll *Collection, opType string, doc *Document) error {
|
||||||
|
txVal := currentTx.Load()
|
||||||
|
if txVal == nil {
|
||||||
|
return fmt.Errorf("no active transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := txVal.(*Transaction)
|
||||||
|
if TransactionState(tx.State.Load()) != TransactionActive {
|
||||||
|
return fmt.Errorf("transaction is not active")
|
||||||
|
}
|
||||||
|
|
||||||
|
op := Operation{
|
||||||
|
Type: opType,
|
||||||
|
Database: coll.Name(), // В реальной реализации нужно передавать имя БД
|
||||||
|
Collection: coll.Name(),
|
||||||
|
DocumentID: doc.ID,
|
||||||
|
Data: doc.GetFields(),
|
||||||
|
Version: doc.Version,
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.mu.Lock()
|
||||||
|
tx.Operations = append(tx.Operations, op)
|
||||||
|
tx.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitCurrentTransaction коммитит текущую транзакцию
|
||||||
|
func CommitCurrentTransaction() error {
|
||||||
|
txVal := currentTx.Load()
|
||||||
|
if txVal == nil {
|
||||||
|
return fmt.Errorf("no active transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := txVal.(*Transaction)
|
||||||
|
if TransactionState(tx.State.Load()) != TransactionActive {
|
||||||
|
return fmt.Errorf("transaction is not active")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Применяем все операции атомарно
|
||||||
|
for _, op := range tx.Operations {
|
||||||
|
if err := applyOperation(op); err != nil {
|
||||||
|
// Откатываем при ошибке
|
||||||
|
AbortCurrentTransaction()
|
||||||
|
return fmt.Errorf("transaction commit failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.State.Store(int32(TransactionCommitted))
|
||||||
|
|
||||||
|
// Записываем коммит в WAL
|
||||||
|
record := &TransactionRecord{
|
||||||
|
ID: tx.ID,
|
||||||
|
State: TransactionCommitted,
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
Operations: tx.Operations,
|
||||||
|
}
|
||||||
|
globalTxManager.wal.Write(record)
|
||||||
|
|
||||||
|
// Очищаем текущую транзакцию
|
||||||
|
currentTx.Store(nil)
|
||||||
|
globalTxManager.activeTransactions.Delete(tx.ID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AbortCurrentTransaction откатывает текущую транзакцию
|
||||||
|
func AbortCurrentTransaction() error {
|
||||||
|
txVal := currentTx.Load()
|
||||||
|
if txVal == nil {
|
||||||
|
return fmt.Errorf("no active transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := txVal.(*Transaction)
|
||||||
|
tx.State.Store(int32(TransactionAborted))
|
||||||
|
|
||||||
|
// Записываем откат в WAL
|
||||||
|
record := &TransactionRecord{
|
||||||
|
ID: tx.ID,
|
||||||
|
State: TransactionAborted,
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
Operations: tx.Operations,
|
||||||
|
}
|
||||||
|
globalTxManager.wal.Write(record)
|
||||||
|
|
||||||
|
// Очищаем текущую транзакцию
|
||||||
|
currentTx.Store(nil)
|
||||||
|
globalTxManager.activeTransactions.Delete(tx.ID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasActiveTransaction проверяет наличие активной транзакции
|
||||||
|
func HasActiveTransaction() bool {
|
||||||
|
return currentTx.Load() != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindInTransaction ищет документ в контексте транзакции
|
||||||
|
func FindInTransaction(coll *Collection, id string) (*Document, error) {
|
||||||
|
txVal := currentTx.Load()
|
||||||
|
if txVal == nil {
|
||||||
|
return coll.Find(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := txVal.(*Transaction)
|
||||||
|
|
||||||
|
// Сначала ищем в операциях транзакции
|
||||||
|
for i := len(tx.Operations) - 1; i >= 0; i-- {
|
||||||
|
op := tx.Operations[i]
|
||||||
|
if op.DocumentID == id {
|
||||||
|
if op.Type == "delete" {
|
||||||
|
return nil, fmt.Errorf("key not found")
|
||||||
|
}
|
||||||
|
// Создаём документ из данных операции
|
||||||
|
doc := NewDocumentWithID(op.DocumentID)
|
||||||
|
for k, v := range op.Data {
|
||||||
|
doc.SetField(k, v)
|
||||||
|
}
|
||||||
|
doc.Version = op.Version
|
||||||
|
return doc, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ищем в основном хранилище
|
||||||
|
return coll.Find(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyOperation применяет операцию к хранилищу
|
||||||
|
func applyOperation(op Operation) error {
|
||||||
|
// В реальной реализации здесь будет применение операции к соответствующей коллекции
|
||||||
|
// С использованием MVCC для версионирования
|
||||||
|
|
||||||
|
switch op.Type {
|
||||||
|
case "insert":
|
||||||
|
// Проверяем версию документа (MVCC)
|
||||||
|
doc := NewDocumentWithID(op.DocumentID)
|
||||||
|
for k, v := range op.Data {
|
||||||
|
doc.SetField(k, v)
|
||||||
|
}
|
||||||
|
// Здесь должна быть вставка в коллекцию
|
||||||
|
return nil
|
||||||
|
case "update":
|
||||||
|
// Обновление с проверкой версии
|
||||||
|
return nil
|
||||||
|
case "delete":
|
||||||
|
// Удаление
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// recoverFromWAL восстанавливает состояние из WAL после сбоя
|
||||||
|
func (tm *TransactionManager) recoverFromWAL() {
|
||||||
|
// В реальной реализации здесь будет чтение WAL и восстановление
|
||||||
|
// незавершённых транзакций
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTransaction возвращает транзакцию по ID
|
||||||
|
func GetTransaction(id TransactionID) (*Transaction, bool) {
|
||||||
|
if val, ok := globalTxManager.activeTransactions.Load(id); ok {
|
||||||
|
return val.(*Transaction), true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MVCCSnapshot создаёт снапшот текущего состояния для MVCC
|
||||||
|
func MVCCSnapshot() uint64 {
|
||||||
|
return uint64(time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDocumentVersion создаёт новую версию документа для MVCC
|
||||||
|
func CreateDocumentVersion(doc *Document, txID TransactionID) *DocumentVersion {
|
||||||
|
return &DocumentVersion{
|
||||||
|
Document: doc.Clone(),
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
TxID: txID,
|
||||||
|
}
|
||||||
|
}
|
||||||
681
internal/storage/trigger.go
Normal file
681
internal/storage/trigger.go
Normal file
@@ -0,0 +1,681 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: internal/storage/trigger.go
|
||||||
|
// Назначение: Реализация триггеров, похожих на MongoDB trigger syntax.
|
||||||
|
// Поддерживает события: INSERT, UPDATE, DELETE, REPLACE.
|
||||||
|
// Триггеры могут выполняться до или после события.
|
||||||
|
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"futriis/internal/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TriggerEvent определяет тип события для триггера
|
||||||
|
type TriggerEvent string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TriggerBeforeInsert TriggerEvent = "BEFORE_INSERT"
|
||||||
|
TriggerAfterInsert TriggerEvent = "AFTER_INSERT"
|
||||||
|
TriggerBeforeUpdate TriggerEvent = "BEFORE_UPDATE"
|
||||||
|
TriggerAfterUpdate TriggerEvent = "AFTER_UPDATE"
|
||||||
|
TriggerBeforeDelete TriggerEvent = "BEFORE_DELETE"
|
||||||
|
TriggerAfterDelete TriggerEvent = "AFTER_DELETE"
|
||||||
|
TriggerBeforeReplace TriggerEvent = "BEFORE_REPLACE"
|
||||||
|
TriggerAfterReplace TriggerEvent = "AFTER_REPLACE"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TriggerAction определяет действие триггера
|
||||||
|
type TriggerAction string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ActionAbort TriggerAction = "abort" // Прервать операцию
|
||||||
|
ActionSkip TriggerAction = "skip" // Пропустить операцию
|
||||||
|
ActionModify TriggerAction = "modify" // Модифицировать документ
|
||||||
|
ActionLog TriggerAction = "log" // Записать в лог
|
||||||
|
ActionNotify TriggerAction = "notify" // Отправить уведомление
|
||||||
|
ActionCustom TriggerAction = "custom" // Пользовательское действие
|
||||||
|
)
|
||||||
|
|
||||||
|
// Trigger представляет триггер на коллекции
|
||||||
|
type Trigger struct {
|
||||||
|
Name string `msgpack:"name"`
|
||||||
|
Collection string `msgpack:"collection"`
|
||||||
|
Event TriggerEvent `msgpack:"event"`
|
||||||
|
Action TriggerAction `msgpack:"action"`
|
||||||
|
Condition *TriggerCondition `msgpack:"condition"`
|
||||||
|
Operations []TriggerOperation `msgpack:"operations"`
|
||||||
|
CreatedAt int64 `msgpack:"created_at"`
|
||||||
|
UpdatedAt int64 `msgpack:"updated_at"`
|
||||||
|
Enabled bool `msgpack:"enabled"`
|
||||||
|
Description string `msgpack:"description"`
|
||||||
|
mu sync.RWMutex `msgpack:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerCondition определяет условие выполнения триггера
|
||||||
|
type TriggerCondition struct {
|
||||||
|
Field string `msgpack:"field"` // Поле для проверки
|
||||||
|
Operator string `msgpack:"operator"` // Оператор: eq, ne, gt, lt, gte, lte, in, nin, exists, regex
|
||||||
|
Value interface{} `msgpack:"value"` // Значение для сравнения
|
||||||
|
Match string `msgpack:"match"` // Паттерн для regex
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerOperation определяет операцию, выполняемую триггером
|
||||||
|
type TriggerOperation struct {
|
||||||
|
Type string `msgpack:"type"` // set, unset, inc, mul, rename, currentDate
|
||||||
|
Field string `msgpack:"field"` // Поле для операции
|
||||||
|
Value interface{} `msgpack:"value"` // Значение для операции
|
||||||
|
Params map[string]interface{} `msgpack:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerExecution содержит контекст выполнения триггера
|
||||||
|
type TriggerExecution struct {
|
||||||
|
TriggerName string
|
||||||
|
Event TriggerEvent
|
||||||
|
Collection string
|
||||||
|
Database string
|
||||||
|
DocumentID string
|
||||||
|
OldDocument *Document
|
||||||
|
NewDocument *Document
|
||||||
|
Operation string
|
||||||
|
Timestamp time.Time
|
||||||
|
User string
|
||||||
|
Role string
|
||||||
|
CustomData map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerManager управляет триггерами в СУБД
|
||||||
|
type TriggerManager struct {
|
||||||
|
triggers sync.Map // map[string]*Trigger (ключ: collection|event|name)
|
||||||
|
logger *log.Logger
|
||||||
|
mu sync.RWMutex
|
||||||
|
auditLog []*TriggerExecution
|
||||||
|
maxLogSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
globalTriggerManager *TriggerManager
|
||||||
|
triggerManagerOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetTriggerManager возвращает глобальный менеджер триггеров
|
||||||
|
func GetTriggerManager() *TriggerManager {
|
||||||
|
triggerManagerOnce.Do(func() {
|
||||||
|
globalTriggerManager = &TriggerManager{
|
||||||
|
maxLogSize: 10000,
|
||||||
|
auditLog: make([]*TriggerExecution, 0),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return globalTriggerManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitTriggerManager инициализирует менеджер триггеров с логгером
|
||||||
|
func InitTriggerManager(logger *log.Logger) {
|
||||||
|
tm := GetTriggerManager()
|
||||||
|
tm.logger = logger
|
||||||
|
if logger != nil {
|
||||||
|
logger.Info("Trigger manager initialized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTrigger создаёт новый триггер (синтаксис MongoDB-like)
|
||||||
|
// Пример: db.collection.createTrigger("triggerName", "BEFORE_INSERT", {
|
||||||
|
// condition: { field: "status", operator: "eq", value: "active" },
|
||||||
|
// action: "modify",
|
||||||
|
// operations: [
|
||||||
|
// { type: "set", field: "updated_at", value: "$$NOW" }
|
||||||
|
// ]
|
||||||
|
// })
|
||||||
|
func (tm *TriggerManager) CreateTrigger(database, collection, name string, event TriggerEvent, config map[string]interface{}) error {
|
||||||
|
tm.mu.Lock()
|
||||||
|
defer tm.mu.Unlock()
|
||||||
|
|
||||||
|
key := tm.getTriggerKey(collection, event, name)
|
||||||
|
if _, exists := tm.triggers.Load(key); exists {
|
||||||
|
return fmt.Errorf("trigger '%s' already exists on %s for event %s", name, collection, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger := &Trigger{
|
||||||
|
Name: name,
|
||||||
|
Collection: collection,
|
||||||
|
Event: event,
|
||||||
|
Enabled: true,
|
||||||
|
CreatedAt: time.Now().UnixMilli(),
|
||||||
|
UpdatedAt: time.Now().UnixMilli(),
|
||||||
|
Operations: make([]TriggerOperation, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим конфигурацию триггера
|
||||||
|
if action, ok := config["action"].(string); ok {
|
||||||
|
switch strings.ToLower(action) {
|
||||||
|
case "abort":
|
||||||
|
trigger.Action = ActionAbort
|
||||||
|
case "skip":
|
||||||
|
trigger.Action = ActionSkip
|
||||||
|
case "modify":
|
||||||
|
trigger.Action = ActionModify
|
||||||
|
case "log":
|
||||||
|
trigger.Action = ActionLog
|
||||||
|
case "notify":
|
||||||
|
trigger.Action = ActionNotify
|
||||||
|
default:
|
||||||
|
trigger.Action = ActionCustom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим условие
|
||||||
|
if cond, ok := config["condition"].(map[string]interface{}); ok {
|
||||||
|
trigger.Condition = &TriggerCondition{}
|
||||||
|
if field, ok := cond["field"].(string); ok {
|
||||||
|
trigger.Condition.Field = field
|
||||||
|
}
|
||||||
|
if operator, ok := cond["operator"].(string); ok {
|
||||||
|
trigger.Condition.Operator = operator
|
||||||
|
}
|
||||||
|
if value, ok := cond["value"]; ok {
|
||||||
|
trigger.Condition.Value = value
|
||||||
|
}
|
||||||
|
if match, ok := cond["match"].(string); ok {
|
||||||
|
trigger.Condition.Match = match
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим операции
|
||||||
|
if ops, ok := config["operations"].([]interface{}); ok {
|
||||||
|
for _, opRaw := range ops {
|
||||||
|
opMap, ok := opRaw.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
operation := TriggerOperation{}
|
||||||
|
if opType, ok := opMap["type"].(string); ok {
|
||||||
|
operation.Type = opType
|
||||||
|
}
|
||||||
|
if field, ok := opMap["field"].(string); ok {
|
||||||
|
operation.Field = field
|
||||||
|
}
|
||||||
|
if value, ok := opMap["value"]; ok {
|
||||||
|
operation.Value = value
|
||||||
|
}
|
||||||
|
if params, ok := opMap["params"].(map[string]interface{}); ok {
|
||||||
|
operation.Params = params
|
||||||
|
}
|
||||||
|
trigger.Operations = append(trigger.Operations, operation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if desc, ok := config["description"].(string); ok {
|
||||||
|
trigger.Description = desc
|
||||||
|
}
|
||||||
|
|
||||||
|
tm.triggers.Store(key, trigger)
|
||||||
|
|
||||||
|
if tm.logger != nil {
|
||||||
|
tm.logger.Info(fmt.Sprintf("Trigger '%s' created on %s.%s for event %s", name, database, collection, event))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DropTrigger удаляет триггер
|
||||||
|
func (tm *TriggerManager) DropTrigger(collection, event, name string) error {
|
||||||
|
key := tm.getTriggerKey(collection, TriggerEvent(event), name)
|
||||||
|
if _, exists := tm.triggers.LoadAndDelete(key); !exists {
|
||||||
|
return fmt.Errorf("trigger '%s' not found on %s for event %s", name, collection, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tm.logger != nil {
|
||||||
|
tm.logger.Info(fmt.Sprintf("Trigger '%s' dropped from %s for event %s", name, collection, event))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTrigger возвращает триггер по имени
|
||||||
|
func (tm *TriggerManager) GetTrigger(collection, event, name string) (*Trigger, error) {
|
||||||
|
key := tm.getTriggerKey(collection, TriggerEvent(event), name)
|
||||||
|
if val, ok := tm.triggers.Load(key); ok {
|
||||||
|
return val.(*Trigger), nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("trigger not found: %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTriggers возвращает список всех триггеров для коллекции
|
||||||
|
func (tm *TriggerManager) ListTriggers(collection string) []*Trigger {
|
||||||
|
triggers := make([]*Trigger, 0)
|
||||||
|
tm.triggers.Range(func(key, value interface{}) bool {
|
||||||
|
trigger := value.(*Trigger)
|
||||||
|
if collection == "" || trigger.Collection == collection {
|
||||||
|
triggers = append(triggers, trigger)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return triggers
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTriggersByEvent возвращает триггеры для конкретного события
|
||||||
|
func (tm *TriggerManager) ListTriggersByEvent(collection string, event TriggerEvent) []*Trigger {
|
||||||
|
triggers := make([]*Trigger, 0)
|
||||||
|
tm.triggers.Range(func(key, value interface{}) bool {
|
||||||
|
trigger := value.(*Trigger)
|
||||||
|
if trigger.Collection == collection && trigger.Event == event && trigger.Enabled {
|
||||||
|
triggers = append(triggers, trigger)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return triggers
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableTrigger включает триггер
|
||||||
|
func (tm *TriggerManager) EnableTrigger(collection, event, name string) error {
|
||||||
|
key := tm.getTriggerKey(collection, TriggerEvent(event), name)
|
||||||
|
val, ok := tm.triggers.Load(key)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("trigger not found: %s", name)
|
||||||
|
}
|
||||||
|
trigger := val.(*Trigger)
|
||||||
|
trigger.mu.Lock()
|
||||||
|
trigger.Enabled = true
|
||||||
|
trigger.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
trigger.mu.Unlock()
|
||||||
|
tm.triggers.Store(key, trigger)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisableTrigger выключает триггер
|
||||||
|
func (tm *TriggerManager) DisableTrigger(collection, event, name string) error {
|
||||||
|
key := tm.getTriggerKey(collection, TriggerEvent(event), name)
|
||||||
|
val, ok := tm.triggers.Load(key)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("trigger not found: %s", name)
|
||||||
|
}
|
||||||
|
trigger := val.(*Trigger)
|
||||||
|
trigger.mu.Lock()
|
||||||
|
trigger.Enabled = false
|
||||||
|
trigger.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
trigger.mu.Unlock()
|
||||||
|
tm.triggers.Store(key, trigger)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteTriggers выполняет все триггеры для данного события
|
||||||
|
// Возвращает: modifiedDocument, shouldAbort, error
|
||||||
|
func (tm *TriggerManager) ExecuteTriggers(execCtx *TriggerExecution) (*Document, bool, error) {
|
||||||
|
triggers := tm.ListTriggersByEvent(execCtx.Collection, execCtx.Event)
|
||||||
|
|
||||||
|
if len(triggers) == 0 {
|
||||||
|
return execCtx.NewDocument, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
currentDoc := execCtx.NewDocument
|
||||||
|
if currentDoc == nil && execCtx.OldDocument != nil {
|
||||||
|
currentDoc = execCtx.OldDocument.Clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, trigger := range triggers {
|
||||||
|
if !trigger.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем условие
|
||||||
|
if trigger.Condition != nil {
|
||||||
|
if !tm.evaluateCondition(execCtx, trigger.Condition) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выполняем действие триггера
|
||||||
|
switch trigger.Action {
|
||||||
|
case ActionAbort:
|
||||||
|
tm.logExecution(execCtx, trigger, "aborted")
|
||||||
|
return currentDoc, true, fmt.Errorf("operation aborted by trigger: %s", trigger.Name)
|
||||||
|
|
||||||
|
case ActionSkip:
|
||||||
|
tm.logExecution(execCtx, trigger, "skipped")
|
||||||
|
return currentDoc, true, nil
|
||||||
|
|
||||||
|
case ActionModify:
|
||||||
|
if currentDoc != nil {
|
||||||
|
currentDoc = tm.applyOperations(currentDoc, trigger.Operations, execCtx)
|
||||||
|
}
|
||||||
|
tm.logExecution(execCtx, trigger, "modified")
|
||||||
|
|
||||||
|
case ActionLog:
|
||||||
|
tm.logExecution(execCtx, trigger, "logged")
|
||||||
|
if tm.logger != nil {
|
||||||
|
tm.logger.Info(fmt.Sprintf("Trigger %s executed on %s.%s (event: %s, doc: %s)",
|
||||||
|
trigger.Name, execCtx.Database, execCtx.Collection, execCtx.Event, execCtx.DocumentID))
|
||||||
|
}
|
||||||
|
|
||||||
|
case ActionNotify:
|
||||||
|
tm.logExecution(execCtx, trigger, "notified")
|
||||||
|
// Здесь можно отправить уведомление через WebSocket или другой канал
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentDoc, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// evaluateCondition проверяет условие триггера
|
||||||
|
func (tm *TriggerManager) evaluateCondition(execCtx *TriggerExecution, cond *TriggerCondition) bool {
|
||||||
|
var docToCheck *Document
|
||||||
|
if execCtx.NewDocument != nil {
|
||||||
|
docToCheck = execCtx.NewDocument
|
||||||
|
} else if execCtx.OldDocument != nil {
|
||||||
|
docToCheck = execCtx.OldDocument
|
||||||
|
} else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldValue, err := docToCheck.GetField(cond.Field)
|
||||||
|
if err != nil {
|
||||||
|
// Поле не существует
|
||||||
|
if cond.Operator == "exists" {
|
||||||
|
if existsVal, ok := cond.Value.(bool); ok && !existsVal {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch cond.Operator {
|
||||||
|
case "eq":
|
||||||
|
return fmt.Sprintf("%v", fieldValue) == fmt.Sprintf("%v", cond.Value)
|
||||||
|
case "ne":
|
||||||
|
return fmt.Sprintf("%v", fieldValue) != fmt.Sprintf("%v", cond.Value)
|
||||||
|
case "gt":
|
||||||
|
return compareNumbers(fieldValue, cond.Value) > 0
|
||||||
|
case "lt":
|
||||||
|
return compareNumbers(fieldValue, cond.Value) < 0
|
||||||
|
case "gte":
|
||||||
|
return compareNumbers(fieldValue, cond.Value) >= 0
|
||||||
|
case "lte":
|
||||||
|
return compareNumbers(fieldValue, cond.Value) <= 0
|
||||||
|
case "in":
|
||||||
|
if arr, ok := cond.Value.([]interface{}); ok {
|
||||||
|
for _, v := range arr {
|
||||||
|
if fmt.Sprintf("%v", fieldValue) == fmt.Sprintf("%v", v) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
case "nin":
|
||||||
|
if arr, ok := cond.Value.([]interface{}); ok {
|
||||||
|
for _, v := range arr {
|
||||||
|
if fmt.Sprintf("%v", fieldValue) == fmt.Sprintf("%v", v) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case "exists":
|
||||||
|
if existsVal, ok := cond.Value.(bool); ok {
|
||||||
|
return existsVal
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case "regex":
|
||||||
|
if pattern, ok := cond.Value.(string); ok {
|
||||||
|
matched, _ := regexp.MatchString(pattern, fmt.Sprintf("%v", fieldValue))
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyOperations применяет операции к документу
|
||||||
|
func (tm *TriggerManager) applyOperations(doc *Document, ops []TriggerOperation, execCtx *TriggerExecution) *Document {
|
||||||
|
if doc == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := doc.Clone()
|
||||||
|
|
||||||
|
for _, op := range ops {
|
||||||
|
switch op.Type {
|
||||||
|
case "set":
|
||||||
|
value := tm.resolveValue(op.Value, execCtx)
|
||||||
|
result.SetField(op.Field, value)
|
||||||
|
|
||||||
|
case "unset":
|
||||||
|
result.DeleteField(op.Field)
|
||||||
|
|
||||||
|
case "inc":
|
||||||
|
if incVal, ok := toFloat64(op.Value); ok {
|
||||||
|
if current, err := result.GetField(op.Field); err == nil {
|
||||||
|
if currVal, ok := toFloat64(current); ok {
|
||||||
|
result.SetField(op.Field, currVal+incVal)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.SetField(op.Field, incVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "mul":
|
||||||
|
if mulVal, ok := toFloat64(op.Value); ok {
|
||||||
|
if current, err := result.GetField(op.Field); err == nil {
|
||||||
|
if currVal, ok := toFloat64(current); ok {
|
||||||
|
result.SetField(op.Field, currVal*mulVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "rename":
|
||||||
|
if newName, ok := op.Value.(string); ok {
|
||||||
|
if val, err := result.GetField(op.Field); err == nil {
|
||||||
|
result.SetField(newName, val)
|
||||||
|
result.DeleteField(op.Field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "currentDate":
|
||||||
|
result.SetField(op.Field, time.Now().UnixMilli())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveValue разрешает специальные значения типа $$NOW, $$USER
|
||||||
|
func (tm *TriggerManager) resolveValue(value interface{}, execCtx *TriggerExecution) interface{} {
|
||||||
|
if strVal, ok := value.(string); ok {
|
||||||
|
switch strVal {
|
||||||
|
case "$$NOW":
|
||||||
|
return time.Now().UnixMilli()
|
||||||
|
case "$$USER":
|
||||||
|
if execCtx.User != "" {
|
||||||
|
return execCtx.User
|
||||||
|
}
|
||||||
|
return "anonymous"
|
||||||
|
case "$$ROLE":
|
||||||
|
if execCtx.Role != "" {
|
||||||
|
return execCtx.Role
|
||||||
|
}
|
||||||
|
return "anonymous"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// logExecution логирует выполнение триггера
|
||||||
|
func (tm *TriggerManager) logExecution(execCtx *TriggerExecution, trigger *Trigger, result string) {
|
||||||
|
tm.mu.Lock()
|
||||||
|
defer tm.mu.Unlock()
|
||||||
|
|
||||||
|
execCtx.TriggerName = trigger.Name
|
||||||
|
execCtx.Timestamp = time.Now()
|
||||||
|
|
||||||
|
if len(tm.auditLog) >= tm.maxLogSize {
|
||||||
|
tm.auditLog = tm.auditLog[1:]
|
||||||
|
}
|
||||||
|
tm.auditLog = append(tm.auditLog, execCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTriggerExecutionLog возвращает лог выполнения триггеров
|
||||||
|
func (tm *TriggerManager) GetTriggerExecutionLog() []*TriggerExecution {
|
||||||
|
tm.mu.RLock()
|
||||||
|
defer tm.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]*TriggerExecution, len(tm.auditLog))
|
||||||
|
copy(result, tm.auditLog)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTriggerKey возвращает ключ для хранения триггера
|
||||||
|
func (tm *TriggerManager) getTriggerKey(collection string, event TriggerEvent, name string) string {
|
||||||
|
return fmt.Sprintf("%s|%s|%s", collection, event, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// compareNumbers сравнивает два числа
|
||||||
|
func compareNumbers(a, b interface{}) int {
|
||||||
|
aVal, aOk := toFloat64(a)
|
||||||
|
bVal, bOk := toFloat64(b)
|
||||||
|
|
||||||
|
if aOk && bOk {
|
||||||
|
if aVal < bVal {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if aVal > bVal {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MongoDBLikeTriggerConfig создаёт конфигурацию триггера в стиле MongoDB
|
||||||
|
// Пример использования:
|
||||||
|
// config := MongoDBLikeTriggerConfig().
|
||||||
|
// On("BEFORE_INSERT").
|
||||||
|
// Condition("status", "eq", "active").
|
||||||
|
// Set("updated_at", "$$NOW").
|
||||||
|
// Build()
|
||||||
|
func MongoDBLikeTriggerConfig() *TriggerConfigBuilder {
|
||||||
|
return &TriggerConfigBuilder{
|
||||||
|
config: make(map[string]interface{}),
|
||||||
|
ops: make([]interface{}, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerConfigBuilder строитель конфигурации триггера
|
||||||
|
type TriggerConfigBuilder struct {
|
||||||
|
config map[string]interface{}
|
||||||
|
ops []interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On устанавливает событие триггера
|
||||||
|
func (b *TriggerConfigBuilder) On(event string) *TriggerConfigBuilder {
|
||||||
|
b.config["event"] = event
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Condition добавляет условие
|
||||||
|
func (b *TriggerConfigBuilder) Condition(field, operator string, value interface{}) *TriggerConfigBuilder {
|
||||||
|
b.config["condition"] = map[string]interface{}{
|
||||||
|
"field": field,
|
||||||
|
"operator": operator,
|
||||||
|
"value": value,
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConditionRegex добавляет regex условие
|
||||||
|
func (b *TriggerConfigBuilder) ConditionRegex(field, pattern string) *TriggerConfigBuilder {
|
||||||
|
b.config["condition"] = map[string]interface{}{
|
||||||
|
"field": field,
|
||||||
|
"operator": "regex",
|
||||||
|
"value": pattern,
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set добавляет операцию установки поля
|
||||||
|
func (b *TriggerConfigBuilder) Set(field string, value interface{}) *TriggerConfigBuilder {
|
||||||
|
b.ops = append(b.ops, map[string]interface{}{
|
||||||
|
"type": "set",
|
||||||
|
"field": field,
|
||||||
|
"value": value,
|
||||||
|
})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unset добавляет операцию удаления поля
|
||||||
|
func (b *TriggerConfigBuilder) Unset(field string) *TriggerConfigBuilder {
|
||||||
|
b.ops = append(b.ops, map[string]interface{}{
|
||||||
|
"type": "unset",
|
||||||
|
"field": field,
|
||||||
|
})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inc добавляет операцию инкремента
|
||||||
|
func (b *TriggerConfigBuilder) Inc(field string, value float64) *TriggerConfigBuilder {
|
||||||
|
b.ops = append(b.ops, map[string]interface{}{
|
||||||
|
"type": "inc",
|
||||||
|
"field": field,
|
||||||
|
"value": value,
|
||||||
|
})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mul добавляет операцию умножения
|
||||||
|
func (b *TriggerConfigBuilder) Mul(field string, value float64) *TriggerConfigBuilder {
|
||||||
|
b.ops = append(b.ops, map[string]interface{}{
|
||||||
|
"type": "mul",
|
||||||
|
"field": field,
|
||||||
|
"value": value,
|
||||||
|
})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename добавляет операцию переименования поля
|
||||||
|
func (b *TriggerConfigBuilder) Rename(oldName, newName string) *TriggerConfigBuilder {
|
||||||
|
b.ops = append(b.ops, map[string]interface{}{
|
||||||
|
"type": "rename",
|
||||||
|
"field": oldName,
|
||||||
|
"value": newName,
|
||||||
|
})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentDate добавляет операцию установки текущей даты
|
||||||
|
func (b *TriggerConfigBuilder) CurrentDate(field string) *TriggerConfigBuilder {
|
||||||
|
b.ops = append(b.ops, map[string]interface{}{
|
||||||
|
"type": "currentDate",
|
||||||
|
"field": field,
|
||||||
|
})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action устанавливает действие триггера
|
||||||
|
func (b *TriggerConfigBuilder) Action(action string) *TriggerConfigBuilder {
|
||||||
|
b.config["action"] = action
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description устанавливает описание триггера
|
||||||
|
func (b *TriggerConfigBuilder) Description(desc string) *TriggerConfigBuilder {
|
||||||
|
b.config["description"] = desc
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build собирает конфигурацию
|
||||||
|
func (b *TriggerConfigBuilder) Build() map[string]interface{} {
|
||||||
|
b.config["operations"] = b.ops
|
||||||
|
return b.config
|
||||||
|
}
|
||||||
1104
pkg/utils/ansi.go
Normal file
1104
pkg/utils/ansi.go
Normal file
File diff suppressed because it is too large
Load Diff
109
pkg/utils/color.go
Normal file
109
pkg/utils/color.go
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2026 Safronov Grigorii
|
||||||
|
*
|
||||||
|
* Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
*
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
* https://opensource.org/licenses/CDDL-1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Файл: pkg/utils/color.go
|
||||||
|
// Назначение: Кроссплатформенная цветная печать с ANSI-кодами,
|
||||||
|
// работающая в Linux (Debian/Fedora) и Illumos (OpenIndiana/OmniOS).
|
||||||
|
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fatih/color"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Глобальный объект цвета Deep Sky Blue (#00bfff)
|
||||||
|
var (
|
||||||
|
deepSkyBlueColor = color.New(color.FgHiCyan) // Ярко-голубой, близкий к #00bfff
|
||||||
|
// Альтернатива с точным RGB-кодом (работает в современных терминалах):
|
||||||
|
// deepSkyBlueColor = color.New(color.FgRGB(0, 191, 255))
|
||||||
|
)
|
||||||
|
|
||||||
|
// EnableDeepSkyBlueMode включает режим постоянного цвета.
|
||||||
|
// В библиотеке fatih/color для этого используется функция color.Set().
|
||||||
|
func EnableDeepSkyBlueMode() {
|
||||||
|
deepSkyBlueColor.Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisableDeepSkyBlueMode отключает режим постоянного цвета и сбрасывает настройки.
|
||||||
|
func DisableDeepSkyBlueMode() {
|
||||||
|
color.Unset()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDeepSkyBlueEnabled — удобная функция для включения/выключения цвета.
|
||||||
|
func SetDeepSkyBlueEnabled(enabled bool) {
|
||||||
|
if enabled {
|
||||||
|
EnableDeepSkyBlueMode()
|
||||||
|
} else {
|
||||||
|
DisableDeepSkyBlueMode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintDeepSkyBlueColored выводит текст цветом Deep Sky Blue (без перевода строки)
|
||||||
|
func PrintDeepSkyBlueColored(a ...interface{}) {
|
||||||
|
deepSkyBlueColor.Print(a...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintlnDeepSkyBlueColored выводит строку с цветом Deep Sky Blue и добавляет перевод строки
|
||||||
|
func PrintlnDeepSkyBlueColored(a ...interface{}) {
|
||||||
|
deepSkyBlueColor.Println(a...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintfDeepSkyBlue форматирует и выводит текст цветом Deep Sky Blue.
|
||||||
|
func PrintfDeepSkyBlue(format string, a ...interface{}) {
|
||||||
|
deepSkyBlueColor.Printf(format, a...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintErrorRed выводит сообщение об ошибке красным цветом.
|
||||||
|
func PrintErrorRed(msg string) {
|
||||||
|
errorColor := color.New(color.FgRed)
|
||||||
|
errorColor.Println("Error: " + msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ColorizeTextAny преобразует любой тип в цветную строку.
|
||||||
|
func ColorizeTextAny(v interface{}) string {
|
||||||
|
return deepSkyBlueColor.Sprint(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ColorizeTextInt преобразует int в цветную строку.
|
||||||
|
func ColorizeTextInt(n int) string {
|
||||||
|
return deepSkyBlueColor.Sprint(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ColorizeTextByColor возвращает строку, окрашенную в указанный цвет.
|
||||||
|
// Поддерживаемые цвета: red, green, yellow, blue, magenta, cyan, white, black
|
||||||
|
func ColorizeTextByColor(text string, colorName string) string {
|
||||||
|
switch colorName {
|
||||||
|
case "red":
|
||||||
|
return color.New(color.FgRed).Sprint(text)
|
||||||
|
case "green":
|
||||||
|
return color.New(color.FgGreen).Sprint(text)
|
||||||
|
case "yellow":
|
||||||
|
return color.New(color.FgYellow).Sprint(text)
|
||||||
|
case "blue":
|
||||||
|
return color.New(color.FgBlue).Sprint(text)
|
||||||
|
case "magenta":
|
||||||
|
return color.New(color.FgMagenta).Sprint(text)
|
||||||
|
case "cyan":
|
||||||
|
return color.New(color.FgCyan).Sprint(text)
|
||||||
|
case "white":
|
||||||
|
return color.New(color.FgWhite).Sprint(text)
|
||||||
|
case "black":
|
||||||
|
return color.New(color.FgBlack).Sprint(text)
|
||||||
|
default:
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentTimestamp возвращает текущий timestamp в миллисекундах
|
||||||
|
func GetCurrentTimestamp() int64 {
|
||||||
|
return time.Now().UnixMilli()
|
||||||
|
}
|
||||||
38
plugins/example.lua
Normal file
38
plugins/example.lua
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
--
|
||||||
|
-- Copyright 2026 Safronov Grigorii
|
||||||
|
--
|
||||||
|
--Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
--you may not use this file except in compliance with the License.
|
||||||
|
--
|
||||||
|
-- You may obtain a copy of the License at
|
||||||
|
--https://opensource.org/licenses/CDDL-1.0
|
||||||
|
--
|
||||||
|
-- example.lua- Пример модуля скрипта на lua для извелечения коллекции в субд и вставки её в документ
|
||||||
|
|
||||||
|
version = "1.0.0"
|
||||||
|
author = "futriis"
|
||||||
|
description = "Example plugin"
|
||||||
|
|
||||||
|
function on_load()
|
||||||
|
plugin_log("info", "Example plugin loaded")
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function on_start()
|
||||||
|
plugin_log("info", "Example plugin started")
|
||||||
|
|
||||||
|
-- Получаем коллекцию
|
||||||
|
local coll = get_collection("test_db", "test_coll")
|
||||||
|
if coll then
|
||||||
|
-- Вставляем документ
|
||||||
|
coll:insert({name = "test", value = 42})
|
||||||
|
plugin_log("info", "Document inserted")
|
||||||
|
end
|
||||||
|
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function process_data(data)
|
||||||
|
plugin_log("debug", "Processing: " .. data)
|
||||||
|
return {processed = true, original = data}
|
||||||
|
end
|
||||||
21
scripts/build_illumos.sh
Normal file
21
scripts/build_illumos.sh
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright 2026 Safronov Grigorii
|
||||||
|
#
|
||||||
|
# Licensed under the CDDL, Version 1.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
#
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
# https://opensource.org/licenses/CDDL-1.0
|
||||||
|
#
|
||||||
|
|
||||||
|
# Специальная сборка для Illumos (OpenIndiana Hipster / OmniOS)
|
||||||
|
|
||||||
|
export GOOS=illumos
|
||||||
|
export GOARCH=amd64
|
||||||
|
export CGO_ENABLED=1
|
||||||
|
|
||||||
|
echo "🔧 Building for Illumos..."
|
||||||
|
go build -tags=illumos -o futriis-illumos ./cmd/futriis
|
||||||
|
echo "✅ Done: futriis-illumos"
|
||||||
Reference in New Issue
Block a user