Реструктуризация: два глобальных раздела — Яша и Семен
Весь контент перенесён в content/yasha/ (7 подразделов). Создан content/semen/ с подразделами: changelog, kb, diary. Навигация — dropdown в навбаре, двухуровневое меню в hugo.toml. Шаблоны, сайдбар, поиск обновлены под вложенные секции.
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
+++
|
||||
title = "Яша"
|
||||
draft = false
|
||||
+++
|
||||
@@ -0,0 +1,4 @@
|
||||
+++
|
||||
title = "Креды"
|
||||
draft = false
|
||||
+++
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: "docs.a2v.space Basic Auth"
|
||||
date: 2026-03-29
|
||||
tags: ["docs", "authentication", "nginx"]
|
||||
description: "Учетные данные для доступа к docs.a2v.space"
|
||||
---
|
||||
|
||||
## Basic Authentication
|
||||
|
||||
```bash
|
||||
Username: agaltsovav
|
||||
Password: Magamet787!
|
||||
```
|
||||
|
||||
## Конфигурация Nginx
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 8091;
|
||||
server_name docs.a2v.space;
|
||||
|
||||
auth_basic "Restricted Access";
|
||||
auth_basic_user_file /etc/nginx/.htpasswd;
|
||||
}
|
||||
```
|
||||
|
||||
## Доступ
|
||||
|
||||
- **URL**: https://docs.a2v.space (через reverse proxy)
|
||||
- **Local**: http://localhost:8091 (для разработки)
|
||||
- **Auth**: всегда требуется
|
||||
|
||||
## Мастер-пароль для шифрования
|
||||
|
||||
**Важно**: Пароль для расшифровки файлов в разделе `creds/`
|
||||
|
||||
```bash
|
||||
Master Password: Popoloka16!
|
||||
```
|
||||
|
||||
## Шифрование
|
||||
|
||||
- Алгоритм: AES-256-GCM
|
||||
- Хранилище: client-side encryption
|
||||
- Файлы шифруются при сохранении
|
||||
- Расшифровываются при чтении
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: "Eversolo Companion PostgreSQL"
|
||||
date: 2026-03-29
|
||||
tags: ["eversolo", "companion", "postgresql", "docker"]
|
||||
description: "Учетные данные для базы данных Eversolo Companion"
|
||||
---
|
||||
|
||||
## Docker Compose креды (~/Projects/eversolo-companion/docker-compose.yml)
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: password
|
||||
POSTGRES_DB: eversolo
|
||||
```
|
||||
|
||||
## Локальные креды (.env файл)
|
||||
|
||||
```bash
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=password
|
||||
POSTGRES_DB=eversolo
|
||||
```
|
||||
|
||||
## Хостинг
|
||||
|
||||
- **Host**: localhost
|
||||
- **Port**: 5433
|
||||
- **Database**: eversolo
|
||||
|
||||
## Безопасность
|
||||
|
||||
- Используйте разные пароли для production
|
||||
- Включите SSL для удаленного подключения
|
||||
- Регулярно делайте бэкапы
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: "Mac Studio — leadera-api БД (docker-compose)"
|
||||
description: "PostgreSQL (docker-compose) для Leadera"
|
||||
service: "PostgreSQL"
|
||||
date: 2026-03-29
|
||||
tags: ["postgresql", "docker-compose", "leadera", "database"]
|
||||
---
|
||||
|
||||
{{< encrypted-creds >}}
|
||||
NYVK4PEGwmOHmAnGk8IAUc+lyFJtnEAvX6ZddBro/ILRWHOXBGYywgglFvQZ18BLDLb5DuA8GPAIdB/LwTHRXYO0KsmtlZQVWGEeeXlXAoEXpa5theoOQhebkW6LmiKyNGAVW2z9rK3nz6NnztYdn49LvGMjrHuR4IwCLbl/pYa96CPNRnxGDFlduFi8lMSspM0rBFi/bZhNGhzy36ViCl+aKr5P9cle0ZvM8E8av/6c5JjgDt88QFI3VYdTaTCE0+uHsXlnJN7Qgg/KH7kD
|
||||
{{< /encrypted-creds >}}
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: "Mac Studio — leadera-api БД (локальная)"
|
||||
description: "PostgreSQL (Docker) для Leadera"
|
||||
service: "PostgreSQL"
|
||||
date: 2026-03-29
|
||||
tags: ["postgresql", "docker", "leadera", "database"]
|
||||
---
|
||||
|
||||
{{< encrypted-creds >}}
|
||||
p7Iw7Y3zbj1kLBcbOXGoO27oDwF85AhxBW7saT538hxi0ogJpyFeM49GDuLHzxuyknvdNC/rx3tb4AFnGkCeHSrrCmj+VK8Zxt7ujeujVJSRuOeeUlsqkHdlmaiXl2KQknrIsqVCi3Tnn0V5qcFxvo+1Ktc+surF6bBpWrTHzWV5mLtWVfD6C6kvvP/UFldFBD6v+FQs79yfoOe/j/bUrtpt6v5VndbbBmhUAQcy1BV2rpXxO373dTcLQ6coRWkt/RFo
|
||||
{{< /encrypted-creds >}}
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: "VPN VPS"
|
||||
description: "VPN сервер (Amnezia)"
|
||||
service: "VPN"
|
||||
date: 2026-03-29
|
||||
tags: ["vpn", "amnezia", "infrastructure"]
|
||||
---
|
||||
|
||||
{{< encrypted-creds >}}
|
||||
YEI16NAJ78d3lmSa/lca8Zxczt+u6GyMUYHduX47VoFQdj0Ixozd8R6yXoB7eM+AjhCtNGkOxGlOj2s73ABOBjLEIVIhD4Z+xTyzdtah4VpyLoltYOeNYsnbIIFARdF/FBYq5COXohhXs33K5ozznrtDd/awbKMjl/sbQqjXAZ+A3ZSob6/5uU+fFKetXLR6v4ZLAYQ57iiD21ImSLSggwv+rJgK5XyPDKEPD4N5nOsiDrLgoukVBMvgJ54PqfPp2Hni+2tdKOlMnOG2npTuu4SkyALcfFar
|
||||
{{< /encrypted-creds >}}
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: "VPS reg.ru"
|
||||
description: "VPS хостинг (реверс-прокси)"
|
||||
service: "VPS"
|
||||
date: 2026-03-29
|
||||
tags: ["vps", "reg.ru", "infrastructure"]
|
||||
---
|
||||
|
||||
{{< encrypted-creds >}}
|
||||
IpYXLMtgCFlGQ550DZCLjq37VsnNqL08Rn03iRS7kQ7ozBZNsiZySFzDAvYh4xIHytRDy0Sz0KGUXekr0S+cNiJsmtZ8kj0Pd0JhevhxTMZF2khFvs/LFWoTL5ZwAJ34y/qfLw2gaWX90VZDu0wO+KeAQeXhrWmol/2zUlfWX1AihyYqD2vRz+h2FcjD8ubYOXG35PyX7OlV1HPXgDv5X/bFNrxzPc867UOwB4aGxY9a
|
||||
{{< /encrypted-creds >}}
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: "29 марта 2026"
|
||||
date: 2026-03-29
|
||||
tags: ["angular", "go", "docker", "eversolo", "hugo", "docs", "identity", "infrastructure", "companion"]
|
||||
---
|
||||
|
||||
## Timeline
|
||||
|
||||
- **20:03** — Настроили Angular + Go проекты (leadera) через pm2, удалили proxy.conf, подняли PostgreSQL в Docker
|
||||
- **20:31** — Изучили API Eversolo T8 — полный список REST-эндпоинтов для управления музыкой
|
||||
- **20:49** — Обсудили важность записи контекста в файлы для сохранения памяти между сессиями
|
||||
- **21:13** — Начали проект docs.a2v.space — персональная документация на Hugo
|
||||
- **21:44** — Завершили настройку Identity (Братан, SOUL.md, USER.md, IDENTITY.md)
|
||||
- **22:15** — Документировали инфраструктуру: VPS reverse proxy (claw.a2v.space, code.a2v.space, sand.a2v.space, docs.a2v.space)
|
||||
- **22:31** — Настроили docs.a2v.space: Hugo сайт, 83 страницы, Bootstrap 5 тёмная тема, AES-256-GCM шифрование кредов (мастер-пароль: Popoloka16!)
|
||||
- **22:47** — Добавили Basic auth: agaltsovav / Magamet787! для доступа к документации
|
||||
- **23:02** — Создали cron задачу docs-a2v-daily-sync на ежедневное обновление в 03:00 МСК
|
||||
- **23:18** — Полностью задокументировали Eversolo T8 API (воспроизведение, громкость, дисплей, питание, поиск и т.д.)
|
||||
- **23:45** — Переписали TOOLS.md как полный справочник по инфраструктуре
|
||||
|
||||
## Eversolo Companion — статус проекта
|
||||
|
||||
**Backend (Go + Gin):**
|
||||
- Репозиторий: ~/Projects/eversolo-companion
|
||||
- Состояние: собран, 17+ REST API эндпоинтов, WebSocket, CORS, state poller
|
||||
|
||||
**Frontend (Angular 17):**
|
||||
- Состояние: собран, тёмная тема (Spotify-like), 5 экранов, 5 сервисов
|
||||
- Dev server: pm2 (eversolo-fe), порт 8096
|
||||
|
||||
**Инфраструктура:**
|
||||
- PostgreSQL: Docker, порт 5433, миграции готовы
|
||||
- Docker Compose: рабочий (postgres + api + frontend)
|
||||
|
||||
**Проблема:** нет доступа к Eversolo из OpenClaw sandbox (192.168.0.156)
|
||||
- Решение: настройка tools.elevated.allowFrom.webchat или запуск через launchd
|
||||
|
||||
**Следующие шаги:** после решения сети — этапы 3-9 (библиотека sync, плейлисты, scrobbler, обложки)
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: "30 марта 2026"
|
||||
date: 2026-03-30
|
||||
tags: ["vikunja", "leadera", "tasktracker", "ollama", "qwen", "mtproxy", "infrastructure"]
|
||||
---
|
||||
|
||||
## Timeline
|
||||
|
||||
- **Утро** — Установлен Vikunja (таск-трекер) на Mac Studio через Docker/Colima
|
||||
- **День** — Создан проект «Leadera» в Vikunja (ID=2), добавлено 27 задач из аудита Leadera
|
||||
- **День** — Начата реализация Leadera TaskTracker: миграции, модели, DTOs, repositories (бекенд), фронтенд (kanban, task-detail)
|
||||
- **Вечер** — TaskTracker **откачён** по просьбе Антона — git checkout + git clean. Причина: Claude Code падал (SIGKILL), sub-agents не справлялись, Plane не запустился
|
||||
- **Вечер** — Настроен MT Proxy на VPN VPS (185.234.64.52): порт 8443, секрет 9e40ae…
|
||||
- **Вечер** — Qwen 3.5:9b подключён к OpenClaw через `openclaw models set ollama/qwen3.5:9b`, доступен для cron/spawn задач
|
||||
- **Вечер** — Антон подключился к claw.a2v.space с мобильного, устройство одобрено
|
||||
|
||||
## Vikunja
|
||||
|
||||
- URL: http://192.168.0.37:8095
|
||||
- Проект Leadera: ID=2
|
||||
- API token: `tk_635fb7ac…`
|
||||
- 27 задач из аудита, приоритеты P0–P3
|
||||
|
||||
## Leadera TaskTracker
|
||||
|
||||
- ТЗ: `leadera-tasktracker-spec.md`
|
||||
- Реализация через sub-agents (GLM-5.1)
|
||||
- **Откачено** — проекты возвращены к предыдущему коммиту
|
||||
|
||||
## MT Proxy
|
||||
|
||||
- Сервер: 185.234.64.52, порт 8443
|
||||
- Docker контейнер: `mtproxy`
|
||||
|
||||
## Инфраструктура
|
||||
|
||||
- Qwen 3.5:9b доступен как дешёвая локальная модель для cron/spawn
|
||||
- Мобильное подключение к claw.a2v.space подтверждено
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: "31 марта 2026"
|
||||
date: 2026-03-31
|
||||
tags: ["system-utils", "opencode", "leadera", "gantt", "canvas", "github", "roadmap"]
|
||||
---
|
||||
|
||||
## Timeline
|
||||
|
||||
- **Утро** — Обсуждали аналоги htop: порекомендовал btm, btop, glances. Создан конфиг `~/.config/bottom/bottom.toml`
|
||||
- **День** — Разбирались со скиллами OpenCode: как работают, автоматический подбор по description
|
||||
- **День** — Созданы скиллы для Leadera проектов (Go: build-and-fix, restart-api; Angular: build-and-fix)
|
||||
- **День** — Обсудили миграцию Ганта с DOM на Canvas 2D (Konva.js) — real-time коллаборация, экспорт PDF/PNG, 500+ задач
|
||||
- **Вечер** — Установлен gh CLI, авторизован как agaltsovav
|
||||
- **Вечер** — Созданы GitHub Projects: Leadera Roadmap (21 задача, P0–P3), agaltsovav.ru Roadmap (7 задач)
|
||||
|
||||
## Системные утилиты
|
||||
|
||||
- **bottom** (btm) — выбранный мониторинг, конфиг в `~/.config/bottom/bottom.toml`
|
||||
- Альтернативы: btop, glances
|
||||
|
||||
## OpenCode Skills
|
||||
|
||||
Созданы скиллы:
|
||||
- `app-leadera-api/.opencode/skills/build-and-fix/` — сборка Go проекта + автофикс ошибок (до 10 итераций)
|
||||
- `app-leadera-api/.opencode/skills/restart-api/` — swagger генерация + pm2 restart
|
||||
- `app-leadera-fe/.opencode/skills/build-and-fix/` — сборка Angular + автофикс
|
||||
|
||||
## Gantt Canvas миграция
|
||||
|
||||
- Переход с DOM на Canvas 2D через **Konva.js**
|
||||
- Цели: real-time коллаборация, экспорт PDF/PNG, 500+ задач
|
||||
- Промт: `GANTT_CANVAS_MIGRATION_PROMPT.md` в app-leadera-fe
|
||||
|
||||
## GitHub
|
||||
|
||||
- CLI: `gh` авторизован (agaltsovav)
|
||||
- Репо: app-leadera-fe, app-leadera-api, agaltsovav.ru
|
||||
- Старые: leadera-fe/leadera-api (на VPS, leadera.ru)
|
||||
- modece.ru — заброшен, планируется возвращение
|
||||
|
||||
## GitHub Projects — Roadmaps
|
||||
|
||||
### Leadera Roadmap (project #2) — 21 задача
|
||||
- **P0:** JWT secret, CORS, bcrypt, ContextRole fallback, RegisterProtectedRoutes
|
||||
- **P1:** Rate limiting, editor role check, MembersModal rewrite
|
||||
- **P2:** HttpOnly cookies, CSRF, CSP, password policy, audit log, health-check, CI/CD, Canvas миграция, DnD
|
||||
- **P3:** Code cleanup, тесты, email verification, поиск
|
||||
|
||||
### agaltsovav.ru Roadmap (project #1) — 7 задач
|
||||
- Редизайн, мобильная версия, мультиязычность, блог, CI/CD, тёмная тема, форма обратной связи
|
||||
@@ -0,0 +1,4 @@
|
||||
+++
|
||||
title = "Дневник"
|
||||
draft = false
|
||||
+++
|
||||
@@ -0,0 +1,4 @@
|
||||
+++
|
||||
title = "Инструкции"
|
||||
draft = false
|
||||
+++
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: "Подключение claw.a2v.space (OpenClaw)"
|
||||
date: 2026-03-29
|
||||
tags: ["openclaw", "ssh", "nginx", "vps", "reverse-proxy"]
|
||||
---
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
Mac Studio (192.168.0.37:18789)
|
||||
→ autossh SSH-туннель
|
||||
→ VPS 95.163.232.9:8080
|
||||
→ nginx HTTPS
|
||||
→ https://claw.a2v.space
|
||||
```
|
||||
|
||||
## Шаги настройки
|
||||
|
||||
### 1. DNS
|
||||
|
||||
A-запись `claw.a2v.space` → `95.163.232.9`
|
||||
|
||||
NS-серверы: `ns1.hosting.reg.ru`, `ns2.hosting.reg.ru`
|
||||
|
||||
### 2. Mac Studio — OpenClaw
|
||||
|
||||
- Порт: 18789
|
||||
- Конфиг: `~/.openclaw/openclaw.json`
|
||||
|
||||
### 3. SSH-туннель (launchd)
|
||||
|
||||
Plist: `~/Library/LaunchAgents/com.openclaw.sshtunnel.plist`
|
||||
|
||||
Ключевой флаг: `-R 8080:localhost:18789`
|
||||
|
||||
### 4. VPS — nginx конфиг
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name claw.a2v.space;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name claw.a2v.space;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/claw.a2v.space/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/claw.a2v.space/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. SSL-сертификат
|
||||
|
||||
```bash
|
||||
certbot certonly --nginx -d claw.a2v.space
|
||||
```
|
||||
|
||||
### 6. OpenClaw config
|
||||
|
||||
```json
|
||||
{
|
||||
"allowedOrigins": ["https://claw.a2v.space"],
|
||||
"trustedProxies": ["127.0.0.1"]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
title: "Подключение code.a2v.space (OpenCode)"
|
||||
date: 2026-03-29
|
||||
tags: ["opencode", "ssh", "nginx", "vps", "reverse-proxy"]
|
||||
---
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
Mac Studio (localhost:4096)
|
||||
→ autossh SSH-туннель (-R 8081:localhost:4096)
|
||||
→ VPS 95.163.232.9:8081
|
||||
→ nginx HTTPS
|
||||
→ https://code.a2v.space
|
||||
```
|
||||
|
||||
## Параметры
|
||||
|
||||
| Параметр | Значение |
|
||||
|----------|----------|
|
||||
| Локальный порт | 4096 |
|
||||
| Туннель порт | 8081 |
|
||||
| pm2 процесс | `opencode` |
|
||||
| SSL сертификат | `/etc/letsencrypt/live/code.a2v.space/` |
|
||||
|
||||
## nginx конфиг
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name code.a2v.space;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name code.a2v.space;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/code.a2v.space/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/code.a2v.space/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## SSL-сертификат
|
||||
|
||||
```bash
|
||||
certbot certonly --nginx -d code.a2v.space
|
||||
```
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
title: "Подключение sand.a2v.space (Leadera Project)"
|
||||
date: 2026-03-29
|
||||
tags: ["angular", "go", "docker", "nginx", "vps", "reverse-proxy"]
|
||||
---
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
Mac Studio
|
||||
├─ Frontend (Angular) :4200 (pm2: leadera-fe)
|
||||
├─ Backend (Go) :8080 (pm2: leadera-api)
|
||||
└─ PostgreSQL 16 в Docker (Colima, container: app-leadera-db)
|
||||
→ autossh SSH-туннель (-R 8082:localhost:4200 -R 8083:localhost:8080)
|
||||
→ VPS 95.163.232.9
|
||||
→ nginx HTTPS
|
||||
→ https://sand.a2v.space
|
||||
```
|
||||
|
||||
## Параметры
|
||||
|
||||
| Компонент | Локальный порт | Туннель порт | pm2 имя |
|
||||
|-----------|----------------|--------------|---------|
|
||||
| Frontend (Angular) | 4200 | 8082 | leadera-fe |
|
||||
| Backend (Go) | 8080 | 8083 | leadera-api |
|
||||
|
||||
SSL сертификат: `/etc/letsencrypt/live/sand.a2v.space/`
|
||||
|
||||
## nginx конфиг
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name sand.a2v.space;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name sand.a2v.space;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/sand.a2v.space/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/sand.a2v.space/privkey.pem;
|
||||
|
||||
location /api {
|
||||
proxy_pass http://127.0.0.1:8083;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8082;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## База данных
|
||||
|
||||
PostgreSQL 16 в Docker через Colima:
|
||||
- Container: `app-leadera-db`
|
||||
- Port: 5432
|
||||
|
||||
## SSL-сертификат
|
||||
|
||||
```bash
|
||||
certbot certonly --nginx -d sand.a2v.space
|
||||
```
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
title: "SSH туннели для VPS reverse proxy"
|
||||
date: 2026-03-29
|
||||
tags: ["ssh", "tunnel", "vps", "reverse-proxy"]
|
||||
description: "Настройка SSH туннелей для доступа к сервисам на Mac Studio через VPS"
|
||||
---
|
||||
|
||||
## Настройка SSH туннелей
|
||||
|
||||
### VPS reg.ru (95.163.232.9)
|
||||
|
||||
```bash
|
||||
# На Mac Studio: создать туннель для каждого сервиса
|
||||
ssh -N -R 8080:localhost:8080 root@95.163.232.9 # leadera-api
|
||||
ssh -N -R 8081:localhost:4096 root@95.163.232.9 # opencode
|
||||
ssh -N -R 8082:localhost:4200 root@95.163.232.9 # leadera-fe
|
||||
ssh -N -R 8083:localhost:8080 root@95.163.232.9 # docs-a2v
|
||||
```
|
||||
|
||||
### Настройка launchd plist
|
||||
|
||||
**Файл**: `~/Library/LaunchAgents/com.openclaw.sshtunnel.plist`
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.openclaw.sshtunnel</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>ssh</string>
|
||||
<string>-N</string>
|
||||
<string>-R</string>
|
||||
<string>8080:localhost:8080</string>
|
||||
<string>root@95.163.232.9</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
|
||||
### Автозапуск
|
||||
|
||||
```bash
|
||||
# Загрузить plist
|
||||
launchctl load ~/Library/LaunchAgents/com.openclaw.sshtunnel.plist
|
||||
|
||||
# Проверить статус
|
||||
launchctl list | grep sshtunnel
|
||||
|
||||
# Удалить (при необходимости)
|
||||
launchctl unload ~/Library/LaunchAgents/com.openclaw.sshtunnel.plist
|
||||
```
|
||||
|
||||
## Доступ к сервисам
|
||||
|
||||
Через reverse proxy на VPS:
|
||||
- **code.a2v.space**: порт 8080 → Mac Studio:4096
|
||||
- **claw.a2v.space**: порт 8081 → Mac Studio:8080 (leadera-api)
|
||||
- **sand.a2v.space**: порт 8082 → Mac Studio:4200 (leadera-fe)
|
||||
- **docs.a2v.space**: порт 8083 → Mac Studio:8080 (docs-a2v)
|
||||
|
||||
## SSL
|
||||
|
||||
Все сервисы используют Let's Encrypt сертификаты:
|
||||
- Путь: `/etc/letsencrypt/live/{claw,code,sand}.a2v.space/`
|
||||
- Автопродление через certbot
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: "Настройка VPS как реверс-прокси"
|
||||
date: 2026-03-29
|
||||
tags: ["vps", "nginx", "ssh", "reverse-proxy", "devops"]
|
||||
---
|
||||
|
||||
## Общая схема
|
||||
|
||||
```
|
||||
Mac Studio (локальная сеть)
|
||||
→ autossh (SSH-туннели)
|
||||
→ VPS 95.163.232.9 (Debian)
|
||||
→ nginx (HTTPS reverse proxy)
|
||||
→ интернет
|
||||
```
|
||||
|
||||
## Что нужно на VPS
|
||||
|
||||
### 1. Установленные пакеты
|
||||
|
||||
```bash
|
||||
apt update
|
||||
apt install nginx certbot python3-certbot-nginx
|
||||
```
|
||||
|
||||
### 2. SSH-доступ
|
||||
|
||||
- Авторизация только по ключам
|
||||
- Публичный ключ с Mac Studio добавлен в `/root/.ssh/authorized_keys`
|
||||
|
||||
### 3. nginx
|
||||
|
||||
Для каждого домена создать server block:
|
||||
|
||||
- HTTP → редирект на HTTPS
|
||||
- HTTPS с SSL-сертификатом Let's Encrypt
|
||||
- `proxy_pass` на localhost-порт туннеля
|
||||
|
||||
Пример структуры:
|
||||
|
||||
```
|
||||
/etc/nginx/sites-available/
|
||||
claw.a2v.space.conf
|
||||
code.a2v.space.conf
|
||||
sand.a2v.space.conf
|
||||
```
|
||||
|
||||
### 4. SSL-сертификаты
|
||||
|
||||
```bash
|
||||
certbot certonly --nginx -d <domain>
|
||||
certbot renew --dry-run # проверить автообновление
|
||||
```
|
||||
|
||||
## Что нужно на Mac Studio
|
||||
|
||||
### autossh через launchd
|
||||
|
||||
Plist: `~/Library/LaunchAgents/com.openclaw.sshtunnel.plist`
|
||||
|
||||
Один plist с несколькими `-R` флагами:
|
||||
|
||||
```xml
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/usr/local/bin/autossh</string>
|
||||
<string>-M</string>
|
||||
<string>0</string>
|
||||
<string>-R</string>
|
||||
<string>8080:localhost:18789</string>
|
||||
<string>-R</string>
|
||||
<string>8081:localhost:4096</string>
|
||||
<string>-R</string>
|
||||
<string>8082:localhost:4200</string>
|
||||
<string>-R</string>
|
||||
<string>8083:localhost:8080</string>
|
||||
<string>root@95.163.232.9</string>
|
||||
<string>-N</string>
|
||||
<string>-i</string>
|
||||
<string>/Users/<user>/.ssh/id_rsa</string>
|
||||
</array>
|
||||
```
|
||||
|
||||
### Управление launchd
|
||||
|
||||
```bash
|
||||
launchctl load ~/Library/LaunchAgents/com.openclaw.sshtunnel.plist
|
||||
launchctl start com.openclaw.sshtunnel
|
||||
launchctl stop com.openclaw.sshtunnel
|
||||
```
|
||||
|
||||
## Проверка
|
||||
|
||||
```bash
|
||||
# На VPS
|
||||
ss -tlnp | grep -E '808[0-3]'
|
||||
|
||||
# Снаружи
|
||||
curl -I https://claw.a2v.space
|
||||
curl -I https://code.a2v.space
|
||||
curl -I https://sand.a2v.space
|
||||
```
|
||||
@@ -0,0 +1,4 @@
|
||||
+++
|
||||
title = "База знаний"
|
||||
draft = false
|
||||
+++
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
title: "Eversolo T8 API"
|
||||
date: 2026-03-29
|
||||
tags: ["api", "eversolo", "music", "streamer"]
|
||||
---
|
||||
|
||||
Eversolo T8 — музыкальный стример.
|
||||
|
||||
**IP:** 192.168.0.156
|
||||
**Порт:** 9529
|
||||
**Авторизация:** нет
|
||||
|
||||
Базовый URL: `http://192.168.0.156:9529`
|
||||
|
||||
## Воспроизведение
|
||||
|
||||
| Действие | URL |
|
||||
|----------|-----|
|
||||
| Play/Pause | `/ZidooMusicControl/v2/playOrPause` |
|
||||
| Следующий трек | `/ZidooMusicControl/v2/playNext` |
|
||||
| Предыдущий трек | `/ZidooMusicControl/v2/playLast` |
|
||||
| Перемотка (ms) | `/ZidooMusicControl/v2/seekTo?time=<ms>` |
|
||||
| Воспроизвести по ID | `/ZidooMusicControl/v2/playMusic?type=4&id=<id>` |
|
||||
| Состояние | `/ZidooMusicControl/v2/getState` |
|
||||
|
||||
## Громкость
|
||||
|
||||
| Действие | URL |
|
||||
|----------|-----|
|
||||
| Громкость вверх | `/ZidooControlCenter/RemoteControl/sendkey?key=Key.VolumeUp` |
|
||||
| Громкость вниз | `/ZidooControlCenter/RemoteControl/sendkey?key=Key.VolumeDown` |
|
||||
| Точная громкость | `/ZidooMusicControl/v2/setDevicesVolume?volume=<0-200>` |
|
||||
| Mute | `/ZidooMusicControl/v2/setMuteVolume?isMute=1` |
|
||||
| Unmute | `/ZidooMusicControl/v2/setMuteVolume?isMute=0` |
|
||||
|
||||
## Входы/Выходы
|
||||
|
||||
| Действие | URL |
|
||||
|----------|-----|
|
||||
| Получить список | `/ZidooMusicControl/v2/getInputAndOutputList` |
|
||||
| Установить вход | `/ZidooMusicControl/v2/setInputList?tag=<tag>&index=<n>` |
|
||||
| Установить выход | `/ZidooMusicControl/v2/setOutInputList?tag=<tag>&index=<n>` |
|
||||
|
||||
Теги выхода: RCA, XLRRCA, XLR, HDMI, SPDIF, USB
|
||||
|
||||
## Дисплей
|
||||
|
||||
| Действие | URL |
|
||||
|----------|-----|
|
||||
| Яркость экрана (get) | `/SystemSettings/displaySettings/getScreenBrightness` |
|
||||
| Яркость экрана (set) | `/SystemSettings/displaySettings/setScreenBrightness?index=<0-115>` |
|
||||
| Яркость ручки (get) | `/SystemSettings/displaySettings/getKnobBrightness` |
|
||||
| Яркость ручки (set) | `/SystemSettings/displaySettings/setKnobBrightness?index=<0-255>` |
|
||||
| VU-режим (get) | `/SystemSettings/displaySettings/getVUModeList` |
|
||||
| VU-режим (set) | `/SystemSettings/displaySettings/setVUMode?index=<n>` |
|
||||
| Спектр (get) | `/SystemSettings/displaySettings/getSpPlayModeList` |
|
||||
| Спектр (set) | `/SystemSettings/displaySettings/setSpPlayModeList?index=<n>` |
|
||||
| Переключить VU/Спектр | `/ZidooMusicControl/v2/changVUDisplay?openType=<0|1>` |
|
||||
|
||||
## Питание
|
||||
|
||||
| Действие | URL |
|
||||
|----------|-----|
|
||||
| Параметры питания | `/ZidooMusicControl/v2/getPowerOption` |
|
||||
| Перезагрузка | `/ZidooMusicControl/v2/setPowerOption?tag=reboot` |
|
||||
| Выключение | `/ZidooMusicControl/v2/setPowerOption?tag=poweroff` |
|
||||
| Переключить экран | `/ZidooMusicControl/v2/setPowerOption?tag=screen` |
|
||||
| Включить экран | `/ZidooControlCenter/RemoteControl/sendkey?key=Key.Screen.ON` |
|
||||
| Выключить экран | `/ZidooControlCenter/RemoteControl/sendkey?key=Key.Screen.OFF` |
|
||||
|
||||
## Поиск
|
||||
|
||||
| Поиск | URL |
|
||||
|-------|-----|
|
||||
| По трекам | `/ZidooMusicControl/v2/searchMusicV2?key=<text>&start=0&count=100` |
|
||||
| По артистам | `/ZidooMusicControl/v2/searchArtistV2?key=<text>&start=0&count=100` |
|
||||
| По альбомам | `/ZidooMusicControl/v2/searchAlbumV2?key=<text>&start=0&count=100` |
|
||||
|
||||
## Обложки и устройство
|
||||
|
||||
| Действие | URL |
|
||||
|----------|-----|
|
||||
| Обложка по ID | `/ZidooMusicControl/v2/getImage?id=<song_id>&target=16` |
|
||||
| Модель устройства | `/ControlCenter/getModel` |
|
||||
|
||||
## Источники
|
||||
|
||||
- [Zidoo Forum](https://forum.zidoo.tv/index.php?threads/some-api.96983/)
|
||||
- [Home Assistant Integration](https://github.com/hchris1/Eversolo/blob/main/custom_components/eversolo/api.py)
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
title: "Leadera"
|
||||
weight: 1
|
||||
---
|
||||
|
||||
# Leadera
|
||||
|
||||
SaaS-система управления IT-ландшафтом с диаграммами Ганта.
|
||||
|
||||
- **Прод:** [leadera.pro](https://leadera.pro)
|
||||
- **Дев:** [sand.a2v.space](https://sand.a2v.space)
|
||||
@@ -0,0 +1,294 @@
|
||||
---
|
||||
title: "Аудит Leadera — полный отчёт"
|
||||
date: 2026-03-30
|
||||
lastmod: 2026-03-30
|
||||
tags: ["audit", "leadera", "security", "architecture"]
|
||||
weight: 100
|
||||
---
|
||||
|
||||
# Аудит проекта Leadera
|
||||
|
||||
**Дата:** 30.03.2026
|
||||
**Версия:** pre-production, миграции 1–5
|
||||
|
||||
---
|
||||
|
||||
## 1. Обзор проекта
|
||||
|
||||
Leadera — SaaS-система управления IT-ландшафтом с диаграммами Ганта.
|
||||
|
||||
### Стек
|
||||
|
||||
| Компонент | Технологии |
|
||||
|-----------|-----------|
|
||||
| Backend | Go 1.21+, Gin, PostgreSQL 16, pgx/v5 + sqlx, JWT (HMAC-SHA256) |
|
||||
| Frontend | Angular 21.1, Bootstrap 5, NgBootstrap, RxJS, Signals |
|
||||
| Инфраструктура | Docker (Colima), pm2, nginx reverse proxy, VPS reg.ru |
|
||||
|
||||
### Доменная модель
|
||||
|
||||
```
|
||||
User ──┬── Space (через space_members с role)
|
||||
│
|
||||
└── GanttChart (принадлежит Space)
|
||||
├── GanttSection
|
||||
└── GanttTask
|
||||
```
|
||||
|
||||
**Роли:** admin, editor, viewer — назначаются **на уровне Space** через таблицу `space_members`.
|
||||
|
||||
### Что реализовано (endpoints)
|
||||
|
||||
**Auth (5):** register, login, refresh, change-password, logout
|
||||
**Spaces (9):** CRUD + members CRUD
|
||||
**Gantt Charts (9):** CRUD + archive + members (добавлен, но удалён в миграции 5)
|
||||
**Gantt Sections (5):** CRUD + reorder
|
||||
**Gantt Tasks (4):** CRUD
|
||||
|
||||
**Итого: ~32 endpoint**
|
||||
|
||||
---
|
||||
|
||||
## 2. Архитектура и качество кода
|
||||
|
||||
### 2.1. Backend — структура
|
||||
|
||||
```
|
||||
cmd/server/main.go — точка входа
|
||||
internal/
|
||||
domain/ — модели (User, Space, GanttChart, etc.)
|
||||
dto/ — request/response DTO (auth, space, gantt)
|
||||
handlers/ — HTTP handlers (auth, space, gantt)
|
||||
middleware/ — auth, cors, space, gantt, role, logging, recovery
|
||||
repository/ — слой доступа к данным
|
||||
service/ — бизнес-логика
|
||||
config/ — конфигурация (.env)
|
||||
utils/ — jwt, password, response, validation
|
||||
migrations/ — SQL миграции (1-5)
|
||||
```
|
||||
|
||||
**Оценка:** ✅ Чистая layered архитектура. Разделение ответственности чёткое.
|
||||
|
||||
### 2.2. Frontend — структура
|
||||
|
||||
```
|
||||
src/app/
|
||||
core/auth/ — auth service, interceptor
|
||||
core/models/ — app.model, space.model
|
||||
core/services/ — space.service, space-storage.service
|
||||
pages/auth/ — sign-in, sign-up, reset-pw, etc.
|
||||
pages/gantt/ — компоненты, модалки, сервисы, модели
|
||||
pages/dashboards/ — spaces list, dashboard
|
||||
layouts/ — navbar, topbar, main-layout
|
||||
```
|
||||
|
||||
**Оценка:** ✅ Стандартная Angular-структура. Signals для state management.
|
||||
|
||||
### 2.3. Проблемы кода
|
||||
|
||||
| # | Проблема | Критичность | Описание |
|
||||
|---|----------|-------------|----------|
|
||||
| C1 | `\r` в service-файлах | Medium | gantt_service.go, gantt_section_service.go содержат CRLF |
|
||||
| C2 | `main.go:238` — мёртвый код `getUserHandler` | Low | Неиспользуемая функция |
|
||||
| C3 | Swagger не сгенерирован | Medium | Аннотации есть, но `api/` директории нет |
|
||||
| C4 | `RegisterProtectedRoutes` не вызывается в main.go | **Critical** | GET/PUT/DELETE `/spaces/:id` не зарегистрированы |
|
||||
|
||||
---
|
||||
|
||||
## 3. Права доступа — главный вопрос
|
||||
|
||||
### 3.1. Текущая реализация
|
||||
|
||||
Права назначаются **исключительно через Space**:
|
||||
|
||||
1. Таблица `space_members` — связка user ↔ space с role (admin/editor/viewer)
|
||||
2. `SpaceMiddleware.RequireSpace()` — проверяет членство и записывает `ContextSpaceRole` в context
|
||||
3. `RoleMiddleware.RequireMinRole()` — проверяет иерархию ролей (admin > editor > viewer)
|
||||
|
||||
**Диаграммы Ганта НЕ имеют собственной системы ролей.** Таблица `gantt_chart_members` была создана (миграция 4) и тут же удалена (миграция 5). Все проверки доступа к диаграммам идут через space membership.
|
||||
|
||||
### 3.2. Как это работает
|
||||
|
||||
```
|
||||
Запрос → AuthMiddleware (JWT → userID)
|
||||
→ SpaceMiddleware.RequireSpace() (проверяет space_members → ContextSpaceRole)
|
||||
→ GanttMiddleware.RequireGanttChart() (проверяет chart.space_id == spaceID)
|
||||
→ RoleMiddleware.RequireMinRole("editor") (проверяет ContextSpaceRole)
|
||||
```
|
||||
|
||||
**Это правильно.** Любой член Space с ролью editor+ может создавать/редактировать диаграммы. Viewers — только читать.
|
||||
|
||||
### 3.3. 🔴 Критическая проблема: Frontend не соответствует Backend
|
||||
|
||||
**Frontend `MembersModalComponent`** пытается управлять участниками **диаграммы**:
|
||||
- `ganttService.addMember(chartId, dto)` → `/api/v1/spaces/{spaceId}/gantt-charts/{chartId}/members`
|
||||
- `ganttService.updateMemberRole(chartId, userId, dto)` → `.../members/{userId}`
|
||||
- `ganttService.removeMember(chartId, userId)` → `.../members/{userId}`
|
||||
|
||||
**Backend `GanttHandler.RegisterRoutes()` НЕ регистрирует маршруты для members диаграмм.** После удаления `gantt_chart_members` (миграция 5) эти endpoints не существуют.
|
||||
|
||||
**Результат:** модальное окно «Участники диаграммы» на фронтенде **не работает** — все вызовы вернут 404.
|
||||
|
||||
### 3.4. 🔴 Проблема: Role fallback
|
||||
|
||||
В `role_middleware.go:28-31`:
|
||||
```go
|
||||
role := c.GetString(ContextSpaceRole)
|
||||
if role == "" {
|
||||
role = c.GetString(ContextRole) // fallback на глобальную роль User
|
||||
}
|
||||
```
|
||||
|
||||
Если `ContextSpaceRole` не установлен, используется **глобальная роль пользователя** (из JWT). Это значит:
|
||||
- Пользователь с `role=admin` в таблице `users` получит admin-доступ к **любому** space, даже если он не член этого space
|
||||
- Но `SpaceMiddleware` проверяет членство раньше, так что в цепочке `RequireSpace() → RequireMinRole()` это не эксплуатируется
|
||||
- Однако если `RequireMinRole()` используется **без** `RequireSpace()` — это дыра
|
||||
|
||||
**Рекомендация:** Удалить fallback на `ContextRole`. Если `ContextSpaceRole` пуст — отказывать.
|
||||
|
||||
### 3.5. Рекомендации по правам доступа
|
||||
|
||||
1. **Удалить модалку участников диаграммы** на фронтенде — она мертва
|
||||
2. **Или** добавить раздел «Участники Space» с возможностью управления ролями через `/spaces/:space_id/members` (эти endpoints работают)
|
||||
3. Удалить fallback на `ContextRole` в `role_middleware.go`
|
||||
4. Добавить проверку: editor не может повысить кого-то до admin (сейчас может — `AddMember` разрешает editor+ назначать любую роль)
|
||||
|
||||
---
|
||||
|
||||
## 4. Безопасность
|
||||
|
||||
### 4.1. Критические
|
||||
|
||||
| # | Проблема | Файл | Описание |
|
||||
|---|----------|------|----------|
|
||||
| S1 | JWT Secret = test | `.env` | `JWT_SECRET=test_secret_key_change_in_production` — **это прод-конфиг!** |
|
||||
| S2 | CORS: `*` | `.env` | `CORS_ALLOWED_ORIGINS=*` — любой домен может обращаться к API |
|
||||
| S3 | Пароль БД в .env | `.env` | `DB_PASSWORD=popoloka16` — creds в открытом виде |
|
||||
| S4 | Refresh tokens: SHA256 | `utils/password.go` | SHA256 вместо bcrypt для refresh token hash — быстро брутфорсится |
|
||||
|
||||
### 4.2. Высокие
|
||||
|
||||
| # | Проблема | Описание |
|
||||
|---|----------|----------|
|
||||
| S5 | Нет rate limiting | Brute-force на `/auth/login` без ограничений |
|
||||
| S6 | Нет CSRF protection | API принимает POST/PUT/DELETE без CSRF-токена |
|
||||
| S7 | Нет CSP заголовков | X-Frame-Options, Content-Security-Policy не настроены |
|
||||
| S8 | JWT в localStorage | XSS может украсть access + refresh tokens |
|
||||
|
||||
### 4.3. Средние
|
||||
|
||||
| # | Проблема | Описание |
|
||||
|---|----------|----------|
|
||||
| S9 | Нет password policy | Минимальные требования к паролю не enforced |
|
||||
| S10 | Нет audit log | Действия с spaces/charts не логируются для аудита |
|
||||
| S11 | Нет блокировки аккаунта | Неограниченные попытки входа |
|
||||
|
||||
---
|
||||
|
||||
## 5. Инфраструктура
|
||||
|
||||
### 5.1. Текущее состояние
|
||||
|
||||
| Компонент | Статус | Примечание |
|
||||
|-----------|--------|------------|
|
||||
| Go API (pm2: leadera-api) | ⚠️ | Порт 8080, хост 0.0.0.0 |
|
||||
| Angular FE (pm2: leadera-fe) | ⚠️ | Порт 4200, хост 0.0.0.0 |
|
||||
| PostgreSQL (Docker) | ✅ | Порт 5432, контейнер app-leadera-db |
|
||||
| VPS reverse proxy | ⚠️ | sand.a2v.space → 8082/8083, но 502 если сервисы не запущены |
|
||||
| SSL | ✅ | Let's Encrypt на VPS |
|
||||
|
||||
### 5.2. Проблемы
|
||||
|
||||
| # | Проблема | Описание |
|
||||
|---|----------|----------|
|
||||
| I1 | Docker для БД, нативно для API | Несогласованность — API и FE не в Docker |
|
||||
| I2 | Нет health-check в pm2 | API может упасть без перезапуска |
|
||||
| I3 | Нет CI/CD | Ручной деплой через `make run` + pm2 |
|
||||
| I4 | Нет nginx на Mac Studio | FE и API торчат наружу напрямую |
|
||||
|
||||
---
|
||||
|
||||
## 6. Функциональная оценка
|
||||
|
||||
### 6.1. Что работает
|
||||
|
||||
| Функция | Backend | Frontend | Статус |
|
||||
|---------|---------|----------|--------|
|
||||
| Регистрация/логин | ✅ | ✅ | Работает |
|
||||
| JWT auth + refresh | ✅ | ✅ | Работает |
|
||||
| Spaces CRUD | ⚠️ | ✅ | `RegisterProtectedRoutes` не вызван (C4) |
|
||||
| Space members | ✅ | ✅ | Работает |
|
||||
| Gantt charts CRUD | ✅ | ✅ | Работает |
|
||||
| Gantt sections | ✅ | ✅ | Работает |
|
||||
| Gantt tasks | ✅ | ⚠️ | Нет UI для задач внутри диаграммы |
|
||||
| Архивация диаграмм | ✅ | ✅ | Работает |
|
||||
| Members диаграммы | ❌ | ✅ (мёртвый UI) | Backend endpoints не существуют |
|
||||
|
||||
### 6.2. Что НЕ работает или отсутствует
|
||||
|
||||
| Функция | Статус |
|
||||
|---------|--------|
|
||||
| Визуализация Ганта (таймлайн) | ❌ Нет компонента отрисовки |
|
||||
| Drag & Drop задач | ❌ |
|
||||
| Real-time collaboration | ❌ |
|
||||
| Уведомления | ❌ |
|
||||
| Поиск | ❌ |
|
||||
| Экспорт (PDF/Excel) | ❌ |
|
||||
| Email verification | ❌ |
|
||||
| Password reset | Backend есть, UI есть — не проверено |
|
||||
|
||||
### 6.3. Оценка готовности
|
||||
|
||||
**Система на стадии каркаса.** Backend API функционален для CRUD операций, но:
|
||||
- Визуализация Ганта — основная фича — не реализована (нет canvas/SVG рендеринга)
|
||||
- Управление участниками только на уровне Space
|
||||
- Нет CI/CD, тестов, мониторинга
|
||||
|
||||
---
|
||||
|
||||
## 7. Приоритизированный план исправлений
|
||||
|
||||
### Неделя 1 — Критическое (безопасность)
|
||||
|
||||
| Приоритет | Задача | Трудоёмкость |
|
||||
|-----------|--------|--------------|
|
||||
| P0 | Заменить JWT secret на криптостойкий | 1 час |
|
||||
| P0 | Настроить CORS (только sand.a2v.space) | 1 час |
|
||||
| P0 | bcrypt для refresh tokens вместо SHA256 | 2 часа |
|
||||
| P0 | Удалить fallback на ContextRole в role_middleware | 30 мин |
|
||||
| P0 | Исправить `RegisterProtectedRoutes` — зарегистрировать маршруты | 1 час |
|
||||
| P1 | Rate limiting на auth endpoints | 4 часа |
|
||||
| P1 | Editor не может назначать admin роль | 2 часа |
|
||||
|
||||
### Неделя 2 — Архитектура прав
|
||||
|
||||
| Приоритет | Задача | Трудоёмкость |
|
||||
|-----------|--------|--------------|
|
||||
| P1 | Удалить/переписать MembersModalComponent → Space members | 4 часа |
|
||||
| P1 | Добавить страницу управления участниками Space | 8 часов |
|
||||
| P2 | HttpOnly cookies для JWT (вместо localStorage) | 8 часов |
|
||||
| P2 | CSRF protection | 4 часа |
|
||||
|
||||
### Неделя 3 — Функциональность
|
||||
|
||||
| Приоритет | Задача | Трудоёмкость |
|
||||
|-----------|--------|--------------|
|
||||
| P2 | Визуализация Ганта (canvas/d3) | 40+ часов |
|
||||
| P2 | Drag & Drop для задач | 16 часов |
|
||||
| P3 | Тесты (unit + integration) | 24 часа |
|
||||
| P3 | CI/CD pipeline | 8 часов |
|
||||
|
||||
---
|
||||
|
||||
## 8. Резюме
|
||||
|
||||
### Ключевые выводы
|
||||
|
||||
1. **Права доступа** — архитектурно верно: роль назначается через Space, не через диаграмму. Но фронтенд ещё не обновлён после удаления `gantt_chart_members`.
|
||||
2. **Безопасность** — критические уязвимости в конфигурации (JWT secret, CORS, bcrypt). Требуют немедленного исправления до любого публичного деплоя.
|
||||
3. **Функциональность** — CRUD работает, но основная ценность (визуализация Ганта) не реализована.
|
||||
4. **Код** — чистый, хорошая архитектура, но есть мёртвый код и незарегистрированные маршруты.
|
||||
|
||||
### Общая оценка: 🔴 Pre-alpha
|
||||
|
||||
Проект готов к внутренней демонстрации CRUD, но не готов к продакшену. Первоочередное — безопасность и исправление мёртвых endpoints.
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
title: "Leadera — Экспорт диаграммы Ганта в Excel"
|
||||
date: 2026-04-09
|
||||
lastmod: 2026-04-09
|
||||
tags: ["leadera", "gantt", "excel", "export"]
|
||||
weight: 120
|
||||
---
|
||||
|
||||
# Экспорт диаграммы Ганта в Excel
|
||||
|
||||
**Дата:** 09.04.2026
|
||||
**Статус:** Требования
|
||||
**Проект:** Leadera
|
||||
|
||||
---
|
||||
|
||||
## Общее описание
|
||||
|
||||
Пользователь нажимает кнопку экспорта в интерфейсе диаграммы Ганта. Система запрашивает диапазон дат выгрузки (по умолчанию — текущий видимый диапазон из интерфейса). После указания диапазона скачивается Excel-файл с визуальным представлением диаграммы Ганта.
|
||||
|
||||
---
|
||||
|
||||
## Вводные данные
|
||||
|
||||
- **Источник:** текущая диаграмма Ганта проекта (один проект = одна диаграмма)
|
||||
- **Диапазон дат:** пользователь указывает начальную и конечную дату. По умолчанию подставляется видимый в момент запроса диапазон из интерфейса
|
||||
- **Масштаб таймлайна:** берётся тот, который выбран пользователем в интерфейсе в момент запроса (не сохранённый в системе, а текущий). Варианты: по ячейкам или по дням
|
||||
|
||||
---
|
||||
|
||||
## Структура Excel-файла
|
||||
|
||||
### Шапка (первые строки)
|
||||
- Название диаграммы (название проекта)
|
||||
- Диапазон дат, в рамках которого сделана выгрузка
|
||||
|
||||
### Лист — один
|
||||
|
||||
Вся диаграмма размещается на одном листе.
|
||||
|
||||
### Секции
|
||||
- Секции отображаются как **визуальная группировка строк** (не отдельные листы)
|
||||
- Название секции — строка-заголовок над группой задач
|
||||
|
||||
### Задачи
|
||||
Каждая задача — отдельная строка. Данные задачи:
|
||||
- **Название** (текстовая колонка слева)
|
||||
- **Дата начала** (текстовая колонка слева)
|
||||
- **Дата конца** (текстовая колонка слева)
|
||||
- **Цвет** — ячейки таймлайна, соответствующие задаче, закрашены в цвет задачи
|
||||
|
||||
### Таймлайн
|
||||
- Таймлайн представлен колонками дат в верхней части (горизонтальная ось времени)
|
||||
- Масштаб колонок соответствует выбранному пользователем в момент экспорта (по ячейкам / по дням)
|
||||
- Задачи отображаются как **merged cells** по соответствующим колонкам дат — визуальная полоса диаграммы Ганта
|
||||
|
||||
### Вехи
|
||||
- Отображаются **отдельной строкой** (как задача, но с маркировкой вехи)
|
||||
- Позиционируются на соответствующей дате в таймлайне
|
||||
|
||||
### Зависимости
|
||||
- **Не отображаются** (стрелочки/связи между задачами не нужны)
|
||||
|
||||
---
|
||||
|
||||
## Пользовательский сценарий
|
||||
|
||||
1. Пользователь открывает диаграмму Ганта проекта
|
||||
2. Настраивает масштаб таймлайна (по ячейкам / по дням)
|
||||
3. Нажимает кнопку «Экспорт в Excel»
|
||||
4. Система показывает диалог с выбором диапазона дат (предзаполнен текущим видимым диапазоном)
|
||||
5. Пользователь подтверждает / корректирует диапазон
|
||||
6. Система генерирует Excel-файл и инициирует скачивание
|
||||
|
||||
---
|
||||
|
||||
## Технические требования
|
||||
|
||||
### Backend (Go API)
|
||||
- Новый endpoint для генерации Excel (например, `GET /api/v1/gantt/:projectId/export`)
|
||||
- Query-параметры: `dateFrom`, `dateTo`, `scale` (cell|day)
|
||||
- Ответ: binary Excel-файл (`application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`)
|
||||
- Авторизация: обязательна (только участники проекта)
|
||||
- Библиотека для генерации Excel: `excelize` (Go)
|
||||
|
||||
### Frontend (Angular)
|
||||
- Кнопка экспорта в тулбаре диаграммы Ганта
|
||||
- Модальное окно выбора диапазона дат с datepicker'ами
|
||||
- Предзаполнение диапазона из текущего видимого viewport'а
|
||||
- Скачивание файла через `Blob` / `saveAs`
|
||||
|
||||
### Фильтрация данных
|
||||
- Выгружаются все задачи и вехи, которые:
|
||||
- Начинаются в диапазоне, ИЛИ
|
||||
- Заканчиваются в диапазоне, ИЛИ
|
||||
- Начинаются до диапазона и заканчиваются после (перекрывают целиком)
|
||||
- Секции включаются, если в них есть хотя бы одна попавшая задача
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
_(Нет)_
|
||||
@@ -0,0 +1,278 @@
|
||||
---
|
||||
title: "Leadera — Фильтры и реорганизация списка диаграмм Ганта"
|
||||
date: 2026-04-07
|
||||
lastmod: 2026-04-07
|
||||
tags: ["leadera", "gantt", "api", "frontend", "requirements"]
|
||||
weight: 120
|
||||
---
|
||||
|
||||
# Фильтры и реорганизация списка диаграмм Ганта
|
||||
|
||||
**Дата:** 07.04.2026
|
||||
**Статус:** Требования
|
||||
**Проект:** Leadera (dev — sand.a2v.space)
|
||||
|
||||
---
|
||||
|
||||
## 1. Постановка задачи
|
||||
|
||||
Список диаграмм Ганта должен показывать **все доступные пользователю диаграммы** из всех пространств (spaces), с возможностью фильтрации и поиска. Сейчас список привязан к одному пространству — нужно убрать эту зависимость и добавить удобный toolbar.
|
||||
|
||||
---
|
||||
|
||||
## 2. Изменения API
|
||||
|
||||
### 2.1. Новый endpoint списка диаграмм
|
||||
|
||||
**Было:**
|
||||
```
|
||||
GET /api/v1/spaces/{spaceId}/gantt-charts?page=1&per_page=10
|
||||
```
|
||||
|
||||
**Стало:**
|
||||
```
|
||||
GET /api/v1/gantt-charts?page=1&per_page=10
|
||||
```
|
||||
|
||||
Пользователь видит все диаграммы из **всех пространств**, где он является подтверждённым участником (статус `accepted`).
|
||||
|
||||
### 2.2. Параметры фильтрации и поиска
|
||||
|
||||
| Параметр | Тип | Описание |
|
||||
|----------|-----|----------|
|
||||
| `page` | int | Номер страницы (default: 1) |
|
||||
| `per_page` | int | Записей на странице (default: 10) |
|
||||
| `space_id` | UUID (optional) | Фильтр по конкретному пространству |
|
||||
| `created_by` | UUID (optional) | Фильтр по создателю диаграммы |
|
||||
| `search` | string (optional) | Поиск по назвению и описанию (ILIKE, case-insensitive) |
|
||||
| `sort` | string (optional) | Сортировка: `created_at`, `updated_at`, `title` (default: `updated_at`) |
|
||||
| `order` | string (optional) | `asc` / `desc` (default: `desc`) |
|
||||
|
||||
**Примеры:**
|
||||
```
|
||||
GET /api/v1/gantt-charts?search=миграция
|
||||
GET /api/v1/gantt-charts?space_id=0b334ded-...&created_by=a1b2c3d4-...
|
||||
GET /api/v1/gantt-charts?page=2&per_page=20&sort=title&order=asc
|
||||
```
|
||||
|
||||
### 2.3. Изменение структуры ответа
|
||||
|
||||
Добавить поле `space` в каждую диаграмму:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "Миграция backend",
|
||||
"description": "...",
|
||||
"created_by": "uuid",
|
||||
"created_by_name": "Антон Агальцов",
|
||||
"created_at": "2026-04-01T10:00:00Z",
|
||||
"updated_at": "2026-04-07T09:00:00Z",
|
||||
"space": {
|
||||
"id": "uuid",
|
||||
"name": "IT Ландшафт",
|
||||
"color": "#c4623a"
|
||||
},
|
||||
"is_archived": false
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"per_page": 10,
|
||||
"total": 42,
|
||||
"total_pages": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4. Endpoint для списка фильтров
|
||||
|
||||
Вспомогательный endpoint для заполнения dropdown-фильтров:
|
||||
|
||||
```
|
||||
GET /api/v1/gantt-charts/filters
|
||||
```
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
{
|
||||
"spaces": [
|
||||
{ "id": "uuid", "name": "IT Ландшафт", "color": "#c4623a" },
|
||||
{ "id": "uuid", "name": "DevOps", "color": "#27ae60" }
|
||||
],
|
||||
"creators": [
|
||||
{ "id": "uuid", "display_name": "Антон Агальцов" },
|
||||
{ "id": "uuid", "display_name": "Иван Петров" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Логика `creators`:** уникальные создатели всех диаграмм, доступных текущему пользователю. Получается через DISTINCT-запрос по `created_by` с JOIN на users.
|
||||
|
||||
### 2.5. Backend — изменения в Go
|
||||
|
||||
**Файлы для изменения:**
|
||||
|
||||
#### `internal/handlers/gantt_handler.go`
|
||||
|
||||
- Добавить новый handler `ListAllGanttCharts` — без привязки к space
|
||||
- Зарегистрировать маршрут `GET /gantt-charts` (без `/spaces/:id/`)
|
||||
- Парсить query-параметры: `space_id`, `created_by`, `search`, `sort`, `order`
|
||||
- Старый маршрут `GET /spaces/:id/gantt-charts` — оставить для обратной совместимости (или удалить, если не используется)
|
||||
|
||||
#### `internal/service/gantt_service.go`
|
||||
|
||||
- Новый метод `ListAll(ctx, userId, filters)` — запрашивает все spaces пользователя (через `space_member` с `status=accepted`), затем выбирает все gantt_charts из этих spaces с фильтрами
|
||||
|
||||
#### `internal/repository/gantt_repository.go`
|
||||
|
||||
- Новый метод с динамическим SQL:
|
||||
- Базовый JOIN: `gantt_charts` → `spaces` → `space_members`
|
||||
- WHERE: `space_members.user_id = $1 AND space_members.status = 'accepted'`
|
||||
- Опциональные фильтры: `AND space_id = $N`, `AND created_by = $N`, `AND (title ILIKE '%' || $N || '%' OR description ILIKE '%' || $N || '%')`
|
||||
- Возвращать поле `space` (id, name, color) через JOIN
|
||||
|
||||
#### `internal/dto/gantt/`
|
||||
|
||||
- Новые DTO: `GanttChartListFilters`, `GanttChartWithSpaceResponse`, `GanttFiltersResponse`
|
||||
|
||||
**SQL (пример):**
|
||||
```sql
|
||||
SELECT gc.*, s.id as space_id, s.name as space_name, s.color as space_color,
|
||||
u.display_name as created_by_name
|
||||
FROM gantt_charts gc
|
||||
JOIN spaces s ON s.id = gc.space_id
|
||||
JOIN space_members sm ON sm.space_id = s.id
|
||||
LEFT JOIN users u ON u.id = gc.created_by
|
||||
WHERE sm.user_id = $1
|
||||
AND sm.status = 'accepted'
|
||||
AND ($2::uuid IS NULL OR gc.space_id = $2)
|
||||
AND ($3::uuid IS NULL OR gc.created_by = $3)
|
||||
AND ($4::text IS NULL OR gc.title ILIKE '%' || $4 || '%'
|
||||
OR gc.description ILIKE '%' || $4 || '%')
|
||||
ORDER BY
|
||||
CASE WHEN $5 = 'title' THEN gc.title END,
|
||||
CASE WHEN $5 = 'created_at' THEN gc.created_at::text END,
|
||||
gc.updated_at DESC
|
||||
LIMIT $6 OFFSET $7;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Изменения Frontend (Angular)
|
||||
|
||||
### 3.1. Toolbar фильтров
|
||||
|
||||
Добавить toolbar над списком диаграмм:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ [🔍 Поиск... ] [▼ Пространство] │
|
||||
│ [▼ Создатель] [▼ Сортировка] [Сбросить фильтры] │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Компоненты:**
|
||||
- **Строка поиска** — input с debounce (300ms), поиск по назвению и описанию
|
||||
- **Dropdown «Пространство»** — список из `/gantt-charts/filters`, с цветовой меткой
|
||||
- **Dropdown «Создатель»** — список уникальных создателей
|
||||
- **Dropdown «Сортировка»** — по дате обновления, дате создания, названию
|
||||
- **Кнопка «Сбросить»** — очищает все фильтры
|
||||
|
||||
**Состояние фильтров в URL** (query params):
|
||||
```
|
||||
/gantt?search=миграция&space_id=xxx&sort=updated_at
|
||||
```
|
||||
Это позволяет делиться ссылкой с уже настроенными фильтрами.
|
||||
|
||||
### 3.2. Карточка диаграммы — бейдж пространства
|
||||
|
||||
На каждой карточке добавить бейдж с названием пространства:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 🟠 IT Ландшафт │ ← бейдж с цветом пространства
|
||||
│ Миграция backend │
|
||||
│ Перевод PHP→Go, этап 2 │
|
||||
│ Создал: Антон А. · 07.04.2026 │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.3. Форма создания диаграммы — выбор пространства
|
||||
|
||||
В модальном окне создания:
|
||||
- Dropdown со списком пространств, где пользователь имеет роль **editor** или выше
|
||||
- Валидация: если пользователь не editor ни в одном пространстве — показать сообщение «Нет доступных пространств для создания»
|
||||
|
||||
**Запрос доступных пространств:** переиспользовать существующий `GET /api/v1/spaces` с фильтрацией на клиенте по `role ∈ [owner, admin, editor]`, либо добавить параметр `?min_role=editor` на backend.
|
||||
|
||||
### 3.4. Изменение routing
|
||||
|
||||
**Было:**
|
||||
```
|
||||
/gantt → список гантов текущего пространства
|
||||
```
|
||||
|
||||
**Стало:**
|
||||
```
|
||||
/gantt → список всех доступных гантов (из всех пространств)
|
||||
```
|
||||
|
||||
Убрать зависимость от `SpaceStorageService.getSelectedSpaceId()` в списке гантов.
|
||||
|
||||
### 3.5. Frontend — файлы для изменения
|
||||
|
||||
| Файл | Изменение |
|
||||
|------|-----------|
|
||||
| `pages/gantt/services/gantt.service.ts` | Новый метод `listAll(filters)`, `getFilters()` |
|
||||
| `pages/gantt/gantt.component.ts` | Подключить toolbar, парсинг URL-параметров |
|
||||
| `pages/gantt/gantt.component.html` | Добавить toolbar, бейджи на карточках |
|
||||
| `pages/gantt/components/gantt-card/` | Добавить бейдж пространства |
|
||||
| `pages/gantt/components/create-chart-modal/` | Dropdown выбора пространства |
|
||||
| `core/models/` | Обновить модели: добавить `space` в GanttChart |
|
||||
|
||||
---
|
||||
|
||||
## 4. Обратная совместимость
|
||||
|
||||
| Endpoint | Статус |
|
||||
|----------|--------|
|
||||
| `GET /api/v1/spaces/{id}/gantt-charts` | Сохранить (для детального view внутри пространства) |
|
||||
| `GET /api/v1/gantt-charts` | **Новый** — глобальный список |
|
||||
| `GET /api/v1/gantt-charts/filters` | **Новый** — данные для dropdown'ов |
|
||||
| `POST /api/v1/spaces/{id}/gantt-charts` | Сохранить (создание в контексте пространства) |
|
||||
| Все CRUD для sections/tasks | Без изменений |
|
||||
|
||||
---
|
||||
|
||||
## 5. Критерии приёмки
|
||||
|
||||
- [ ] Endpoint `GET /api/v1/gantt-charts` возвращает все доступные диаграммы
|
||||
- [ ] Фильтр по `space_id` — показывает только диаграммы конкретного пространства
|
||||
- [ ] Фильтр по `created_by` — показывает только диаграммы выбранного создателя
|
||||
- [ ] Поиск `search` — ищет по title и description (case-insensitive)
|
||||
- [ ] Dropdown фильтров заполняется реальными данными из `/filters`
|
||||
- [ ] Карточка диаграммы показывает бейдж пространства с цветом
|
||||
- [ ] Форма создания предлагает только пространства с ролью ≥ editor
|
||||
- [ ] Состояние фильтров сохраняется в URL (можно поделиться ссылкой)
|
||||
- [ ] Старые endpoints (`/spaces/{id}/gantt-charts`) продолжают работать
|
||||
- [ ] Swagger документация обновлена
|
||||
|
||||
---
|
||||
|
||||
## 6. Порядок реализации
|
||||
|
||||
1. Backend: DTO для новых запросов/ответов
|
||||
2. Backend: Repository — SQL-запрос с фильтрами + JOIN на spaces
|
||||
3. Backend: Service — `ListAll`, `GetFilters`
|
||||
4. Backend: Handler — `ListAllGanttCharts`, `GetGanttFilters`, регистрация маршрутов
|
||||
5. Backend: Swagger — обновить документацию
|
||||
6. Frontend: Модели — обновить GanttChart, добавить Space в ответ
|
||||
7. Frontend: Service — `listAll()`, `getFilters()`
|
||||
8. Frontend: Toolbar компонент — поиск + dropdown'ы
|
||||
9. Frontend: Карточка — бейдж пространства
|
||||
10. Frontend: Модальное окно создания — dropdown пространства
|
||||
11. Frontend: URL-параметры — синхронизация фильтров с router
|
||||
12. Тестирование
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
title: "Leadera — Публичный доступ к диаграмме Ганта"
|
||||
date: 2026-04-09
|
||||
lastmod: 2026-04-09
|
||||
tags: ["leadera", "gantt", "public-access", "sharing"]
|
||||
weight: 130
|
||||
---
|
||||
|
||||
# Публичный доступ к диаграмме Ганта (Share)
|
||||
|
||||
**Дата:** 09.04.2026
|
||||
**Статус:** Требования
|
||||
**Проект:** Leadera
|
||||
|
||||
---
|
||||
|
||||
## Общее описание
|
||||
|
||||
Пользователь может создать публичную ссылку на фрагмент диаграммы Ганта, доступную для просмотра без авторизации. Открывший ссылку видит диаграмму в read-only режиме с возможностью масштабирования таймлайна, но без каких-либо элементов управления (создание, редактирование, удаление задач/секций/вех).
|
||||
|
||||
---
|
||||
|
||||
## Пользовательский сценарий (создатель ссылки)
|
||||
|
||||
1. Пользователь открывает диаграмму Ганта проекта
|
||||
2. Нажимает кнопку «Экспорт» / «Общий доступ»
|
||||
3. Указывает диапазон дат, который должен отображаться на диаграмме
|
||||
4. Система генерирует уникальную ссылку (UUID) и показывает её пользователю
|
||||
5. Пользователь копирует ссылку и передаёт заинтересованным лицам
|
||||
|
||||
### Управление ссылками
|
||||
- Вызывается через меню экспорта → модальное окно «Общий доступ»
|
||||
- В модальном окне отображается список созданных ссылок с указанием диапазона дат
|
||||
- Каждую ссылку можно **удалить** (полное удаление из БД, не soft delete)
|
||||
- Ссылки **нельзя редактировать** — только создавать и удалять
|
||||
- Ограничений на количество активных ссылок нет
|
||||
- Статистика просмотров (сколько раз открывали) — **не нужна**
|
||||
|
||||
---
|
||||
|
||||
## Пользовательский сценарий (зритель по ссылке)
|
||||
|
||||
1. Зритель переходит по ссылке (без авторизации)
|
||||
2. Открывается отдельная страница с диаграммой Ганта
|
||||
3. Зритель видит:
|
||||
- Задачи и вехи, попадающие в заданный диапазон
|
||||
- Секции (заголовки группировок)
|
||||
- Таймлайн с цветовой раскраской задач
|
||||
4. Зритель может:
|
||||
- Масштабировать таймлайн
|
||||
- Переключать отображение «по ячейкам» / «по дням»
|
||||
5. Зритель **не может**:
|
||||
- Создавать, редактировать, удалять задачи / секции / вехи
|
||||
- Видеть любые контролы управления (кнопки создания, контекстные меню, drag-and-drop и т.д.)
|
||||
- Авторизоваться через эту страницу
|
||||
|
||||
---
|
||||
|
||||
## Данные и фильтрация
|
||||
|
||||
### Что попадает в представление
|
||||
Задачи и вехи, у которых хотя бы одно из условий верно:
|
||||
- Дата начала попадает в диапазон ссылки
|
||||
- Дата конца попадает в диапазон ссылки
|
||||
- Задача начинается до диапазона и заканчивается после (полностью перекрывает)
|
||||
|
||||
Секции включаются, если в них есть хотя бы одна попавшая задача.
|
||||
|
||||
### API возвращает
|
||||
- Только задачи и вехи, попадающие в диапазон (не всю диаграмму)
|
||||
- Информацию о секциях, содержащих попавшие задачи
|
||||
- Минимальный набор полей, необходимый для отрисовки (без внутренних ID пользователей и т.п.)
|
||||
|
||||
---
|
||||
|
||||
## Технические требования
|
||||
|
||||
### Ссылки
|
||||
- Формат: UUID v4 (каждый раз новый)
|
||||
- Срок действия: бессрочно (пока не удалена)
|
||||
- Удаление: **жёсткое** (hard delete из БД)
|
||||
|
||||
### Backend (Go API)
|
||||
|
||||
#### Таблица БД: `gantt_public_links`
|
||||
- `id` (UUID, PK)
|
||||
- `project_id` (FK → projects)
|
||||
- `date_from` (date)
|
||||
- `date_to` (date)
|
||||
- `created_by` (FK → users)
|
||||
- `created_at` (timestamp)
|
||||
|
||||
#### Endpoints
|
||||
- `POST /api/v1/gantt/:projectId/public-links` — создать ссылку (авторизован, участник проекта)
|
||||
- Body: `{ dateFrom, dateTo }`
|
||||
- Response: `{ url: "https://leadera.pro/share/<uuid>" }`
|
||||
- `GET /api/v1/gantt/:projectId/public-links` — список ссылок проекта (авторизован, участник)
|
||||
- `DELETE /api/v1/gantt/public-links/:uuid` — удалить ссылку (авторизован, создатель)
|
||||
- `GET /api/v1/public/gantt/:uuid` — получить данные диаграммы по ссылке (без авторизации)
|
||||
- Response: задачи, вехи, секции в рамках диапазона
|
||||
|
||||
#### Rate limiting
|
||||
- На публичный endpoint `GET /api/v1/public/gantt/:uuid` — rate limiting для защиты от перебора
|
||||
- Рекомендация: 60 запросов в минуту на IP
|
||||
|
||||
### Frontend (Angular)
|
||||
|
||||
#### Модальное окно «Общий доступ»
|
||||
- Вызывается из меню экспорта
|
||||
- Содержит:
|
||||
- Форму создания ссылки (диапазон дат + кнопка «Создать»)
|
||||
- Список существующих ссылок (диапазон + URL + кнопка «Копировать» + кнопка «Удалить»)
|
||||
|
||||
#### Публичная страница `/share/:uuid`
|
||||
- Отдельный route, **не требует авторизации**
|
||||
- Отображает диаграмму Ганта в read-only режиме
|
||||
- Доступны только: масштабирование таймлайна, переключение ячейки/дни
|
||||
- Все контролы управления скрыты (удалены из шаблона, не просто disabled)
|
||||
- Минимальный layout — без навигации проекта, без сайдбара
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
_(Нет)_
|
||||
@@ -0,0 +1,329 @@
|
||||
---
|
||||
title: "Leadera: Вехи (Milestones) на диаграмме Ганта"
|
||||
date: 2026-04-08
|
||||
tags: ["leadera", "gantt", "angular", "go", "api", "ux"]
|
||||
---
|
||||
|
||||
## Бизнес-требования
|
||||
|
||||
### BR-1: Сущность «Веха» (Milestone)
|
||||
|
||||
Веха — это маркер на таймлайне диаграммы Ганта, обозначающий важную дату в проекте. В отличие от задач, веха не имеет длительности и привязана к конкретной дате.
|
||||
|
||||
**Поля вехи:**
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| id | UUID | Идентификатор |
|
||||
| gantt_chart_id | UUID | Принадлежность к диаграмме (не к секции!) |
|
||||
| title | string | Название вехи (обязательное, 1–255 символов) |
|
||||
| date | date | Дата вехи (без времени, обязательная) |
|
||||
| color | string | Цвет маркера (HEX, по умолчанию `#dc3545` — красный) |
|
||||
| created_by | UUID | Кто создал (не отображается в UI, хранится для аудита) |
|
||||
| updated_by | UUID | Кто обновил (не отображается в UI) |
|
||||
| created_at | timestamp | Дата создания |
|
||||
| updated_at | timestamp | Дата обновления |
|
||||
| deleted_at | timestamp | Soft delete |
|
||||
|
||||
**Права доступа:** Создание, редактирование и удаление вех доступно участникам с правами выше «только чтение» (аналогично задачам).
|
||||
|
||||
**Особенности:**
|
||||
- Вех может быть несколько в проекте
|
||||
- Несколько вех могут иметь одну и ту же дату
|
||||
- Вехи сортируются строго по дате (по возрастанию)
|
||||
- Вехи не привязаны к секциям — они отображаются **сквозь все секции**
|
||||
|
||||
---
|
||||
|
||||
### BR-2: Управление вехами (модальное окно)
|
||||
|
||||
**Вызов:** Кнопка «Вехи» рядом с кнопкой «Создать секцию» на странице диаграммы.
|
||||
|
||||
**Модальное окно содержит:**
|
||||
- Заголовок: «Вехи проекта»
|
||||
- Список всех вех текущей диаграммы (сортировка по дате по возрастанию)
|
||||
- Каждая веха в списке показывает: цвет (индикатор), название, дату, кнопки «Редактировать» и «Удалить»
|
||||
- Кнопка «Добавить веху»
|
||||
|
||||
**Форма добавления/редактирования вехи:**
|
||||
- Название — текстовый ввод (обязательное, 1–255 символов)
|
||||
- Дата — datepicker (обязательное)
|
||||
- Цвет — выбор цвета (по аналогии с палитрой цветов в задачах)
|
||||
- По умолчанию цвет = красный (`#dc3545`)
|
||||
|
||||
**Удаление:** с подтверждением (confirm dialog).
|
||||
|
||||
---
|
||||
|
||||
### BR-3: Отображение вех на таймлайне
|
||||
|
||||
**Визуальное представление:**
|
||||
- **Маркер-флажок** (flag marker) на шкале таймлайна
|
||||
- От маркера вниз идёт **пунктирная вертикальная линия** через все секции (на всю высоту диаграммы)
|
||||
- Цвет линии и маркера = цвет вехи
|
||||
|
||||
**Позиционирование:**
|
||||
- Веха ставится ровно на свою дату на шкале времени
|
||||
- Если дата вехи выходит за рамки текущего диапазона задач — таймлайн **расширяется** (аналогично задачам)
|
||||
- При изменении масштаба (days/weeks/months/quarters) позиция вехи пересчитывается
|
||||
|
||||
**Tooltip при наведении:**
|
||||
- При наведении на маркер/линию — показывается tooltip с названием вехи
|
||||
- Если на одну дату приходится несколько вех — в tooltip показываются **все** вехи этой даты с их названиями (каждая с индикацией своего цвета)
|
||||
|
||||
---
|
||||
|
||||
## Технические требования
|
||||
|
||||
### База данных
|
||||
|
||||
#### Новая таблица: gantt_milestones
|
||||
|
||||
```sql
|
||||
CREATE TABLE gantt_milestones (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
gantt_chart_id UUID NOT NULL REFERENCES gantt_charts(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
color VARCHAR(20) NOT NULL DEFAULT '#dc3545',
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
updated_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
deleted_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_gantt_milestones_chart ON gantt_milestones(gantt_chart_id);
|
||||
CREATE INDEX idx_gantt_milestones_date ON gantt_milestones(date);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Backend (Go API)
|
||||
|
||||
#### Этап 1: Миграция + Домен
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> Добавь в проект новую таблицу `gantt_milestones`.
|
||||
>
|
||||
> 1. Создай миграцию в `cmd/migrate/`:
|
||||
> - Новая таблица `gantt_milestones` с полями: id (UUID PK), gantt_chart_id (UUID FK → gantt_charts, ON DELETE CASCADE), title (VARCHAR 255 NOT NULL), date (DATE NOT NULL), color (VARCHAR 20 NOT NULL DEFAULT '#dc3545'), created_by (UUID FK → users), updated_by (UUID FK → users), created_at, updated_at, deleted_at. Индексы по gantt_chart_id и date.
|
||||
>
|
||||
> 2. Создай доменную модель `GanttMilestone` в `internal/domain/gantt.go`:
|
||||
> ```go
|
||||
> type GanttMilestone struct {
|
||||
> ID UUID `json:"id"`
|
||||
> GanttChartID UUID `json:"gantt_chart_id"`
|
||||
> Title string `json:"title"`
|
||||
> Date time.Time `json:"date"`
|
||||
> Color string `json:"color"`
|
||||
> CreatedBy UUID `json:"created_by"`
|
||||
> UpdatedBy UUID `json:"updated_by"`
|
||||
> CreatedAt time.Time `json:"created_at"`
|
||||
> UpdatedAt time.Time `json:"updated_at"`
|
||||
> DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
> }
|
||||
> ```
|
||||
|
||||
#### Этап 2: CRUD для GanttMilestones
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> Реализуй полный CRUD для сущности `GanttMilestone` по аналогии с существующими handlers/services/repositories в проекте.
|
||||
>
|
||||
> **Роуты:**
|
||||
> - `GET /api/gantt-charts/:chartId/milestones` — список вех диаграммы
|
||||
> - `POST /api/gantt-charts/:chartId/milestones` — создать веху
|
||||
> - `PUT /api/gantt-charts/:chartId/milestones/:milestoneId` — обновить веху
|
||||
> - `DELETE /api/gantt-charts/:chartId/milestones/:milestoneId` — удалить веху (soft delete)
|
||||
>
|
||||
> **Repository:** `internal/repository/gantt_milestone_repository.go`
|
||||
> - `GetByChartID(ctx, chartID) ([]domain.GanttMilestone, error)` — все неудалённые вехи диаграммы, отсортированные по date ASC
|
||||
> - `GetByID(ctx, id) (*domain.GanttMilestone, error)`
|
||||
> - `Create(ctx, milestone) error`
|
||||
> - `Update(ctx, milestone) error`
|
||||
> - `Delete(ctx, id) error` — soft delete (set deleted_at)
|
||||
> - Все запросы — raw SQL через pgx, как в остальных репозиториях проекта
|
||||
>
|
||||
> **Service:** `internal/service/gantt_milestone_service.go`
|
||||
> - Валидация: title (1–255 символов), date (обязательное), color (валидный HEX, default '#dc3545')
|
||||
> - Проверка прав доступа: пользователь должен иметь права выше «только чтение» в пространстве (space) диаграммы
|
||||
> - Проверка что chartId существует и не архивирован
|
||||
>
|
||||
> **Handler:** `internal/handler/gantt_milestone_handler.go`
|
||||
> - Swagger-аннотации для всех эндпоинтов
|
||||
> - Валидация входных данных
|
||||
> - Ошибки в формате проекта
|
||||
>
|
||||
> **DTO:** `internal/dto/gantt/milestones_dto.go`
|
||||
> ```go
|
||||
> type CreateGanttMilestoneRequest struct {
|
||||
> Title string `json:"title" binding:"required,min=1,max=255"`
|
||||
> Date string `json:"date" binding:"required"`
|
||||
> Color *string `json:"color" binding:"omitempty,max=20"`
|
||||
> }
|
||||
>
|
||||
> type UpdateGanttMilestoneRequest struct {
|
||||
> Title *string `json:"title" binding:"omitempty,min=1,max=255"`
|
||||
> Date *string `json:"date" binding:"omitempty"`
|
||||
> Color *string `json:"color" binding:"omitempty,max=20"`
|
||||
> }
|
||||
>
|
||||
> type GanttMilestoneResponse struct {
|
||||
> ID domain.UUID `json:"id"`
|
||||
> GanttChartID domain.UUID `json:"gantt_chart_id"`
|
||||
> Title string `json:"title"`
|
||||
> Date string `json:"date"`
|
||||
> Color string `json:"color"`
|
||||
> CreatedByUser *CreatedByUpdatedUser `json:"created_by_user"`
|
||||
> UpdatedByUser *CreatedByUpdatedUser `json:"updated_by_user"`
|
||||
> CreatedAt time.Time `json:"created_at"`
|
||||
> UpdatedAt time.Time `json:"updated_at"`
|
||||
> }
|
||||
>
|
||||
> type GanttMilestonesListResponse struct {
|
||||
> Milestones []GanttMilestoneResponse `json:"milestones"`
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> **Регистрация роутов:** Добавь группу milestone-роутов в роутер рядом с существующими роутами секций/задач. Проверка прав через существующий middleware.
|
||||
|
||||
#### Этап 3: Включение вех в ответ диаграммы
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> При запросе данных диаграммы Ганта (полная загрузка) включай связанные вехи в ответ.
|
||||
>
|
||||
> 1. Добавь эндпоинт или расширь существующий `GET /api/gantt-charts/:chartId` — включить поле `milestones` с массивом `GanttMilestoneResponse`
|
||||
> 2. Вехи с `deleted_at IS NOT NULL` не возвращаются
|
||||
> 3. Вехи сортируются по `date` ASC
|
||||
> 4. Вехи загружаются вместе с данными диаграммы (tasks, sections) — один запрос при открытии Ганта
|
||||
|
||||
---
|
||||
|
||||
### Frontend (Angular)
|
||||
|
||||
#### Этап FE-1: Модели + Сервис
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> Добавь в проект поддержку вех (milestones) на диаграмме Ганта.
|
||||
>
|
||||
> 1. В `pages/gantt/models/` (или соответствующем файле моделей) добавь:
|
||||
>
|
||||
> ```typescript
|
||||
> export interface GanttMilestone {
|
||||
> id: string;
|
||||
> gantt_chart_id: string;
|
||||
> title: string;
|
||||
> date: string;
|
||||
> color: string;
|
||||
> created_by_user?: { id: string; full_name: string };
|
||||
> updated_by_user?: { id: string; full_name: string };
|
||||
> created_at: string;
|
||||
> updated_at: string;
|
||||
> }
|
||||
>
|
||||
> export interface GanttMilestoneCreateRequest {
|
||||
> title: string;
|
||||
> date: string;
|
||||
> color?: string;
|
||||
> }
|
||||
>
|
||||
> export interface GanttMilestoneUpdateRequest {
|
||||
> title?: string;
|
||||
> date?: string;
|
||||
> color?: string;
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> 2. Добавь `milestones: GanttMilestone[]` в интерфейс/модель полной загрузки данных диаграммы
|
||||
>
|
||||
> 3. Создай сервис `pages/gantt/services/gantt-milestone.service.ts`:
|
||||
> - Inject HttpClient
|
||||
> - Методы:
|
||||
> - `getMilestones(chartId: string): Observable<GanttMilestone[]>`
|
||||
> - `createMilestone(chartId: string, data: GanttMilestoneCreateRequest): Observable<GanttMilestone>`
|
||||
> - `updateMilestone(chartId: string, milestoneId: string, data: GanttMilestoneUpdateRequest): Observable<GanttMilestone>`
|
||||
> - `deleteMilestone(chartId: string, milestoneId: string): Observable<void>`
|
||||
> - Все методы возвращают Observable с типизированными ответами (по аналогии с GanttChartService)
|
||||
|
||||
#### Этап FE-2: Модальное окно управления вехами
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> Создай компонент модального окна для управления вехами диаграммы Ганта.
|
||||
>
|
||||
> 1. Создай компонент `pages/gantt/components/milestones-modal/`
|
||||
>
|
||||
> 2. **Кнопка вызова:** Добавь кнопку «Вехи» рядом с кнопкой «Создать секцию» в toolbar диаграммы. По клику — открытие модального окна через ng-bootstrap NgbModal.
|
||||
>
|
||||
> 3. **Модальное окно** (ng-bootstrap modal):
|
||||
> - Заголовок: «Вехи проекта»
|
||||
> - Список вех (сортировка по date ASC):
|
||||
> - Каждый элемент: цветной кружок (color), название, дата (форматированная), кнопки ✏️ (редактировать) и 🗑️ (удалить с confirm)
|
||||
> - Кнопка «Добавить веху» внизу списка
|
||||
> - Если вех нет — сообщение «Вех пока нет. Добавьте первую!»
|
||||
>
|
||||
> 4. **Форма добавления/редактирования** (inline в модальном окне):
|
||||
> - Название — текстовый ввод (обязательное)
|
||||
> - Дата — ng-bootstrap datepicker (обязательное)
|
||||
> - Цвет — палитра цветов (использовать тот же компонент/подход, что и для задач)
|
||||
> - По умолчанию цвет = `#dc3545` (красный)
|
||||
> - Кнопки «Сохранить» и «Отмена»
|
||||
>
|
||||
> 5. При сохранении — вызов соответствующего метода сервиса (create/update)
|
||||
> 6. При удалении — confirm dialog → вызов deleteMilestone
|
||||
> 7. После любого изменения — обновить список вех и обновить данные на канвасе (через сервис/GanttStore)
|
||||
>
|
||||
> 8. Инжектировать GanttMilestoneService и NgbModal
|
||||
|
||||
#### Этап FE-3: Отображение вех на таймлайне (Konva canvas)
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> Отобрази вехи (milestones) на canvas-диаграмме Ганта.
|
||||
>
|
||||
> Проект использует Konva.js для рендеринга.
|
||||
>
|
||||
> 1. При загрузке данных диаграммы — извлечь массив `milestones`
|
||||
>
|
||||
> 2. Для каждой вехи нарисовать на canvas:
|
||||
> - **Маркер-флажок** (flag marker) на позиции даты вехи на шкале времени
|
||||
> - Стиль: маленький треугольник или флаг на верхней границе таймлайна
|
||||
> - Цвет = milestone.color
|
||||
> - **Пунктирная вертикальная линия** от маркера вниз через все секции (на всю высоту области секций)
|
||||
> - Стиль: Konva.Line, dash: [5, 5], strokeWidth: 1.5, color = milestone.color, opacity: 0.7
|
||||
>
|
||||
> 3. **Позиционирование:**
|
||||
> - Рассчитать X-позицию по дате вехи с использованием текущей шкалы времени (как для задач)
|
||||
> - Если дата вехи выходит за текущий диапазон — расширить диапазон (влияет на общую ширину таймлайна)
|
||||
> - При изменении масштаба (days/weeks/months/quarters) — пересчитать позиции всех вех
|
||||
> - При скролле — вехи скроллятся вместе с задачами
|
||||
>
|
||||
> 4. **Tooltip при наведении:**
|
||||
> - При наведении на маркер или пунктирную линию — показать Konva.Tooltip или HTML tooltip:
|
||||
> - Если веха одна на эту дату — показать название
|
||||
> - Если несколько вех на одну дату — показать список: цветной кружок + название для каждой
|
||||
>
|
||||
> 5. **Z-index:**
|
||||
> - Линии вех рисуются **поверх** фона секций, но **под** задачами
|
||||
> - Маркеры-флажки — поверх всего (самый высокий z-index)
|
||||
>
|
||||
> 6. **Реактивность:**
|
||||
> - При добавлении/удалении/редактировании вехи — перерисовать только вехи (без полного ре рендера задач)
|
||||
> - Подписаться на изменения массива milestones через сервис/store
|
||||
|
||||
---
|
||||
|
||||
## Порядок реализации
|
||||
|
||||
| # | Этап | Сторона | Зависимости |
|
||||
|---|------|---------|-------------|
|
||||
| 1 | Миграция + Домен | Backend | — |
|
||||
| 2 | CRUD вех | Backend | Этап 1 |
|
||||
| 3 | Включение вех в ответ диаграммы | Backend | Этап 2 |
|
||||
| 4 | Модели + Сервис | Frontend | — (параллельно с backend) |
|
||||
| 5 | Модальное окно управления вехами | Frontend | Этапы 4 + backend 2 |
|
||||
| 6 | Отображение вех на таймлайне | Frontend | Этапы 4 + backend 3 |
|
||||
@@ -0,0 +1,407 @@
|
||||
---
|
||||
title: "Leadera: Ограничения секций в диаграмме Ганта"
|
||||
date: 2026-04-06
|
||||
tags: ["leadera", "gantt", "angular", "go", "api", "ux"]
|
||||
---
|
||||
|
||||
## Бизнес-требования
|
||||
|
||||
### BR-1: Лимит одновременных задач (Concurrency Limit)
|
||||
|
||||
У каждой секции появляется параметр **max_concurrent_tasks** (целое число ≥ 0, по умолчанию 0).
|
||||
|
||||
**Поведение:**
|
||||
|
||||
- Если `max_concurrent_tasks = 3` — в любой данный день в этой секции не может быть больше 3 активных задач одновременно
|
||||
- Если `max_concurrent_tasks = 0` — ограничение отключено, лимита нет
|
||||
- Пересечение считается по дате: задача занимает дни с `start_date` по `end_date` включительно. Две задачи «пересекаются», если у них есть хотя бы один общий день
|
||||
- Ограничение работает **только в рамках одной секции**. Задачи из разных секций не влияют друг на друга
|
||||
|
||||
**Срабатывание при:**
|
||||
|
||||
1. Создании задачи — если добавление нарушает лимит → Alert
|
||||
2. Перетаскивании задачи (drag) — если новая позиция нарушает лимит → Alert
|
||||
3. Растягивании задачи (resize) — если новый размер нарушает лимит → Alert
|
||||
4. Редактировании задачи (форма) — если изменённые даты нарушают лимит → Alert
|
||||
|
||||
**Важно:** Действие **НЕ отменяется**. Задача создаётся/перемещается в любом случае, но пользователь видит предупреждение.
|
||||
|
||||
**Текст Alert:** «В секции "[название секции]" на [дата/диапазон] превышен лимит одновременных задач (максимум: N)»
|
||||
|
||||
---
|
||||
|
||||
### BR-2: Блоки секций (Section Blocks)
|
||||
|
||||
У каждой секции появляется набор **блоков** — особый вид записей, отображаемых на таймлайне как заштрихованные области.
|
||||
|
||||
**Сущность GanttSectionBlock:**
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| id | UUID | Идентификатор |
|
||||
| section_id | UUID | Принадлежность к секции |
|
||||
| title | string | Название (например: «Отпуск», «Командировка») |
|
||||
| start_date | date | Начало интервала (минимум 1 день) |
|
||||
| end_date | date | Конец интервала |
|
||||
| block_concurrency | boolean | Запрещать ли пересечение обычных задач с данным блоком |
|
||||
|
||||
**Валидация:**
|
||||
|
||||
- `end_date` не может быть раньше `start_date`
|
||||
- Все поля обязательны
|
||||
- Блок может быть в прошлом
|
||||
|
||||
**Визуальное отображение на Ганте:**
|
||||
|
||||
- Заштрихованная область на **всю высоту секции** (включая все строки задач в секции)
|
||||
- Название блока отображается **поверх штриховки с подложкой** (для читаемости)
|
||||
|
||||
**Поведение block_concurrency:**
|
||||
|
||||
- Если `block_concurrency = true` — при пересечении задачи с данным блоком показывается Alert (аналогично BR-1), но задача **всё равно создаётся/перемещается**
|
||||
- Если `block_concurrency = false` — блок отображается визуально, но не влияет на задачи
|
||||
|
||||
**Срабатывание при:**
|
||||
|
||||
1. Создании задачи — пересекает блок с `block_concurrency = true` → Alert
|
||||
2. Перетаскивании задачи (drag) → Alert
|
||||
3. Растягивании задачи (resize) → Alert
|
||||
4. Редактировании задачи (форма) → Alert
|
||||
|
||||
**Текст Alert:** «В секции "[название секции]" задача пересекает блок "[название блока]" ([дата] — [дата])»
|
||||
|
||||
---
|
||||
|
||||
### BR-3: Система Alert-ов
|
||||
|
||||
Все предупреждения (от BR-1 и BR-2) выводятся в **единую область** над диаграммой Ганта.
|
||||
|
||||
**Поведение:**
|
||||
|
||||
- Alert-ы — Bootstrap Alert (warning), **постоянные**
|
||||
- Пользователь **НЕ может** закрыть Alert вручную
|
||||
- Alert исчезает **автоматически**, когда текущая дата прошла дату конфликта (конфликт больше не актуален)
|
||||
- Проверка конфликтов — **только на фронтенде** (на основе загруженных данных: задачи секции, блоки, max_concurrent_tasks)
|
||||
- Если Alert-ов много — область **сворачиваемая** с счётчиком в бейдже (например: «⚠️ 3 предупреждения»)
|
||||
- По умолчанию — развёрнуто
|
||||
- Сворачивание/разворачивание — клик по заголовку области
|
||||
|
||||
---
|
||||
|
||||
### BR-4: Настройки секции
|
||||
|
||||
- Настройки совмещены с **существующим модальным окном редактирования секции** (не отдельное окно)
|
||||
|
||||
**Окно создания секции** — добавляется:
|
||||
- Поле «Макс. одновременных задач» (числовой ввод, 0 = без лимита, по умолчанию 0)
|
||||
|
||||
**Окно редактирования секции** — добавляется:
|
||||
- Поле «Макс. одновременных задач» (числовой ввод, 0 = без лимита)
|
||||
- Список блоков (таблица/список с CRUD: добавить, редактировать, удалить)
|
||||
- **Сортировка:** по `start_date` по убыванию (будущее сверху)
|
||||
- Каждый блок — форма: title, start_date, end_date, block_concurrency (чекбокс: «Запретить пересечение задач»)
|
||||
- Валидация: end_date >= start_date, все поля заполнены
|
||||
|
||||
---
|
||||
|
||||
### BR-5: Наследование
|
||||
|
||||
- При появлении функционала копирования/дублирования диаграммы — настройки секций (лимит + блоки) наследуются
|
||||
|
||||
---
|
||||
|
||||
## Технические требования
|
||||
|
||||
### База данных
|
||||
|
||||
#### Новая таблица: gantt_section_blocks
|
||||
|
||||
```sql
|
||||
CREATE TABLE gantt_section_blocks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
section_id UUID NOT NULL REFERENCES gantt_sections(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
block_concurrency BOOLEAN NOT NULL DEFAULT false,
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
updated_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
deleted_at TIMESTAMP WITH TIME ZONE,
|
||||
CONSTRAINT chk_block_dates CHECK (end_date >= start_date)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_gantt_section_blocks_section ON gantt_section_blocks(section_id);
|
||||
CREATE INDEX idx_gantt_section_blocks_dates ON gantt_section_blocks(start_date, end_date);
|
||||
```
|
||||
|
||||
#### Изменение таблицы: gantt_sections
|
||||
|
||||
```sql
|
||||
ALTER TABLE gantt_sections ADD COLUMN max_concurrent_tasks INTEGER NOT NULL DEFAULT 0;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Backend (Go API)
|
||||
|
||||
#### Этап 1: Миграция + Домен
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> Добавь в проект новую таблицу `gantt_section_blocks` и колонку `max_concurrent_tasks` в `gantt_sections`.
|
||||
>
|
||||
> 1. Создай миграцию в `cmd/migrate/`:
|
||||
> - Новая таблица `gantt_section_blocks` с полями: id (UUID PK), section_id (UUID FK → gantt_sections, ON DELETE CASCADE), title (VARCHAR 255 NOT NULL), start_date (DATE NOT NULL), end_date (DATE NOT NULL), block_concurrency (BOOLEAN DEFAULT false), created_by (UUID FK → users), updated_by (UUID FK → users), created_at, updated_at, deleted_at. CHECK constraint: end_date >= start_date. Индексы по section_id и (start_date, end_date).
|
||||
> - ALTER TABLE gantt_sections ADD COLUMN max_concurrent_tasks INTEGER NOT NULL DEFAULT 0.
|
||||
> 2. Создай доменную модель `GanttSectionBlock` в `internal/domain/gantt.go` со всеми полями таблицы.
|
||||
> 3. Добавь поле `MaxConcurrentTasks int` в структуру `GanttSection`.
|
||||
> 4. Обнови маппинг в relevant repository файлах, чтобы новое поле `max_concurrent_tasks` читалось/писалось.
|
||||
|
||||
#### Этап 2: CRUD для GanttSectionBlocks
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> Реализуй полный CRUD для сущности `GanttSectionBlock` по аналогии с существующими handlers/services/repositories в проекте.
|
||||
>
|
||||
> **Роуты (внутри группы секции):**
|
||||
> - `GET /api/gantt-charts/:chartId/sections/:sectionId/blocks` — список блоков секции
|
||||
> - `POST /api/gantt-charts/:chartId/sections/:sectionId/blocks` — создать блок
|
||||
> - `PUT /api/gantt-charts/:chartId/sections/:sectionId/blocks/:blockId` — обновить блок
|
||||
> - `DELETE /api/gantt-charts/:chartId/sections/:sectionId/blocks/:blockId` — удалить блок (soft delete)
|
||||
>
|
||||
> **Repository:** `internal/repository/gantt_section_block_repository.go`
|
||||
> - `GetBySectionID(ctx, sectionID) ([]domain.GanttSectionBlock, error)`
|
||||
> - `GetByID(ctx, id) (*domain.GanttSectionBlock, error)`
|
||||
> - `Create(ctx, block) error`
|
||||
> - `Update(ctx, block) error`
|
||||
> - `Delete(ctx, id) error` — soft delete (set deleted_at)
|
||||
> - Все запросы — raw SQL через pgx, как в остальных репозиториях проекта
|
||||
>
|
||||
> **Service:** `internal/service/gantt_section_block_service.go`
|
||||
> - Бизнес-логика: валидация dates (end >= start), проверка прав доступа к секции
|
||||
> - Inject repository
|
||||
>
|
||||
> **Handler:** `internal/handler/gantt_section_block_handler.go`
|
||||
> - Swagger-аннотации для всех эндпоинтов
|
||||
> - Валидация входных данных
|
||||
> - Ошибки в формате проекта (GanttErrorResponse)
|
||||
>
|
||||
> **Модели запросов:**
|
||||
> - Create: title (required), start_date (required), end_date (required), block_concurrency (bool, default false)
|
||||
> - Update: title, start_date, end_date, block_concurrency — все опциональны
|
||||
|
||||
#### Этап 3: Обновление настроек секции
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> Обнови CRUD секций (gantt_sections) для поддержки нового поля `max_concurrent_tasks`.
|
||||
>
|
||||
> 1. Добавь `max_concurrent_tasks` в `GanttSectionCreateRequest` (опциональное, int ≥ 0, default 0)
|
||||
> 2. Добавь `max_concurrent_tasks` в `GanttSectionUpdateRequest` (опциональное, int ≥ 0)
|
||||
> 3. Обнови SQL-запросы в repository: INSERT и UPDATE должны включать `max_concurrent_tasks`
|
||||
> 4. При GET секции (или списке секций) — возвращать `max_concurrent_tasks` в ответе
|
||||
> 5. Валидация: значение должно быть ≥ 0
|
||||
|
||||
#### Этап 4: Включение блоков в ответ секций
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> При запросе списка секций или одной секции включай связанные блоки в ответ.
|
||||
>
|
||||
> 1. Обнови `GET /api/gantt-charts/:chartId/sections/:sectionId` — добавить поле `blocks` с массивом GanttSectionBlock
|
||||
> 2. Обнови `GET /api/gantt-charts/:chartId/sections` — добавить поле `blocks` для каждой секции
|
||||
> 3. Блоки с `deleted_at IS NOT NULL` не возвращаются
|
||||
> 4. Блоки сортируются по start_date по убыванию (будущее сверху)
|
||||
|
||||
---
|
||||
|
||||
### Frontend (Angular)
|
||||
|
||||
#### Этап FE-1: Модели + Сервис
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> Добавь в проект поддержку ограничений секций Ганта.
|
||||
>
|
||||
> 1. В `pages/gantt/models/gantt-chart.model.ts` добавь:
|
||||
>
|
||||
> ```typescript
|
||||
> export interface GanttSectionBlock {
|
||||
> id: string;
|
||||
> section_id: string;
|
||||
> title: string;
|
||||
> start_date: string;
|
||||
> end_date: string;
|
||||
> block_concurrency: boolean;
|
||||
> created_by_user: UserInfo;
|
||||
> updated_by_user: UserInfo;
|
||||
> created_at: string;
|
||||
> updated_at: string;
|
||||
> }
|
||||
>
|
||||
> export interface GanttSectionBlockCreateRequest {
|
||||
> title: string;
|
||||
> start_date: string;
|
||||
> end_date: string;
|
||||
> block_concurrency?: boolean;
|
||||
> }
|
||||
>
|
||||
> export interface GanttSectionBlockUpdateRequest {
|
||||
> title?: string;
|
||||
> start_date?: string;
|
||||
> end_date?: string;
|
||||
> block_concurrency?: boolean;
|
||||
> }
|
||||
>
|
||||
> export interface ConflictCheck {
|
||||
> has_conflicts: boolean;
|
||||
> conflicts: ConflictItem[];
|
||||
> }
|
||||
>
|
||||
> export interface ConflictItem {
|
||||
> type: 'concurrency' | 'block';
|
||||
> section_id: string;
|
||||
> section_title: string;
|
||||
> block_title?: string;
|
||||
> start_date?: string;
|
||||
> end_date?: string;
|
||||
> message: string;
|
||||
> dates?: string[];
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> 2. Добавь `max_concurrent_tasks: number` в интерфейс `GanttSection`
|
||||
> 3. Добавь `blocks: GanttSectionBlock[]` в интерфейс `GanttSection`
|
||||
> 4. Добавь `max_concurrent_tasks?: number` в `GanttSectionCreateRequest` и `GanttSectionUpdateRequest`
|
||||
>
|
||||
> 5. Создай сервис `pages/gantt/services/gantt-section-block.service.ts`:
|
||||
> - Inject HttpClient
|
||||
> - Методы: getBlocks(chartId, sectionId), createBlock(chartId, sectionId, data), updateBlock(chartId, sectionId, blockId, data), deleteBlock(chartId, sectionId, blockId)
|
||||
> - Все методы возвращают Observable с типизированными ответами (по аналогии с GanttChartService)
|
||||
|
||||
#### Этап FE-2: Интеграция в модальные окна секции
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> Интегрируй настройки ограничений в существующие модальные окна создания и редактирования секции Ганта.
|
||||
>
|
||||
> 1. Найди существующие компоненты модальных окон секции (создание/редактирование).
|
||||
>
|
||||
> **Окно создания секции** — добавить:
|
||||
> - Числовой ввод: «Максимум одновременных задач»
|
||||
> - Подсказка: «0 = без ограничений»
|
||||
> - Валидация: число ≥ 0
|
||||
> - Значение по умолчанию: 0
|
||||
> - Отправляется в `GanttSectionCreateRequest` как `max_concurrent_tasks`
|
||||
>
|
||||
> **Окно редактирования секции** — добавить:
|
||||
> - Числовой ввод: «Максимум одновременных задач» (аналогично созданию)
|
||||
> - Секция «Блоки (запретные интервалы)»:
|
||||
> - Таблица/список существующих блоков с колонками: Название, Начало, Конец, Блокировка (чекбокс/иконка), Действия (редактировать/удалить)
|
||||
> - **Сортировка:** по `start_date` по убыванию (будущее сверху)
|
||||
> - Кнопка «Добавить блок» — inline-форма:
|
||||
> - title (текстовый ввод, обязательный)
|
||||
> - start_date (datepicker, обязательный)
|
||||
> - end_date (datepicker, обязательный)
|
||||
> - block_concurrency (чекбокс: «Запретить пересечение задач»)
|
||||
> - Валидация: end_date >= start_date, все поля заполнены
|
||||
> - Редактирование — inline или через ту же форму
|
||||
> - Удаление — с подтверждением
|
||||
>
|
||||
> 2. При открытии окна редактирования — загрузить текущие настройки секции + блоки
|
||||
> 3. При сохранении — обновить max_concurrent_tasks и сохранить изменения блоков
|
||||
> 4. Инжектировать GanttChartService и GanttSectionBlockService
|
||||
|
||||
#### Этап FE-3: Визуализация блоков на Ганте
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> Отобрази блоки секций (GanttSectionBlock) на canvas-диаграмме Ганта.
|
||||
>
|
||||
> Проект использует Konva.js для рендеринга. Основной компонент: `gantt-chart-canvas`.
|
||||
>
|
||||
> 1. При загрузке данных диаграммы — извлечь блоки из каждой секции
|
||||
> 2. В рендерере секций (canvas/timeline-renderer.ts или аналогичный) — для каждого блока:
|
||||
> - Нарисовать заштрихованную область (diagonal hatch pattern) на **всю высоту секции** (все строки задач в этой секции)
|
||||
> - Область ограничена по горизонтали: от start_date до end_date (в пикселях по шкале времени)
|
||||
> - Цвет штриховки: полупрозрачный серый или configurable
|
||||
> - Поверх штриховки — текст с названием блока (block.title) на подложке (прямоугольник с фоном, чтобы текст читался)
|
||||
> 3. Блоки рендерятся **до** задач (z-index ниже), чтобы задачи были поверх
|
||||
> 4. Блоки с `block_concurrency = true` и `false` визуально не отличаются (штриховка одинаковая)
|
||||
> 5. При скролле/зуме — блоки пересчитываются как обычные элементы таймлайна
|
||||
|
||||
#### Этап FE-4: Система Alert-ов
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> Создай компонент для отображения Alert-ов о конфликтах на диаграмме Ганта.
|
||||
>
|
||||
> 1. Создай компонент `pages/gantt/components/gantt-conflict-alerts/`
|
||||
>
|
||||
> 2. Компонент размещается **над** canvas-диаграммой Ганта
|
||||
>
|
||||
> 3. Входные данные: массив `ConflictItem[]` (из модели)
|
||||
>
|
||||
> 4. Визуал:
|
||||
> - Bootstrap Alert (warning) для каждого конфликта
|
||||
> - Каждый Alert показывает message из ConflictItem
|
||||
> - **Без кнопки закрытия** — Alert нельзя закрыть вручную
|
||||
> - Alert исчезает автоматически, когда текущая дата прошла дату конфликта
|
||||
> - Область **сворачиваемая**: заголовок «⚠️ Предупреждения (N)» — клик сворачивает/разворачивает список
|
||||
> - По умолчанию — развёрнуто
|
||||
> - При 0 конфликтов — область скрыта
|
||||
>
|
||||
> 5. Состояние хранится в сервисе или в компоненте диаграммы:
|
||||
> - Добавление нового конфликта → area auto-expands
|
||||
> - Alert удаляется из массива **автоматически**, когда текущая дата прошла дату конфликта
|
||||
> - Проверка актуальности конфликтов — при каждой отрисовке / изменении данных
|
||||
> - Не использовать toast — Alert-ы постоянные
|
||||
|
||||
#### Этап FE-5: Проверка конфликтов при операциях с задачами
|
||||
|
||||
**Промт для агента:**
|
||||
|
||||
> Интегрируй проверку конфликтов во все операции с задачами на диаграмме Ганта.
|
||||
>
|
||||
> Проект использует interaction-handler.ts для drag/resize и формы для создания/редактирования.
|
||||
>
|
||||
> **Важно:** Проверка конфликтов — **только на фронтенде**, без вызова API. Используются загруженные данные секции (задачи, блоки, max_concurrent_tasks).
|
||||
>
|
||||
> 1. Вынеси логику проверки конфликтов `checkConflicts()` в отдельный метод в gantt-canvas.service.ts:
|
||||
> - Принимает: section (GanttSection — содержит max_concurrent_tasks и blocks), tasks (GanttTask[] — задачи секции), task_id (nullable — исключить текущую задачу), start_date, end_date
|
||||
> - Логика:
|
||||
> a. Если section.max_concurrent_tasks > 0: посчитать кол-во задач (исключая task_id), пересекающихся с [start_date, end_date] на каждый день. Если в любой день count > max_concurrent_tasks — добавить конфликт concurrency
|
||||
> b. Для каждого блока секции с block_concurrency = true, пересекающегося с [start_date, end_date] — добавить конфликт block
|
||||
> - Возвращает ConflictCheck
|
||||
>
|
||||
> 2. После **каждой** успешной операции с задачей (создание, обновление, drag, resize):
|
||||
> - Вызвать checkConflicts с параметрами задачи
|
||||
> - Если has_conflicts = true — передать конфликты в GanttConflictAlertsComponent
|
||||
> - Alert-ы показываются, действие **НЕ отменяется**
|
||||
>
|
||||
> 3. Точки интеграции:
|
||||
> - **Создание задачи** (форма): после успешного POST → checkConflicts → show alerts
|
||||
> - **Редактирование задачи** (форма): после успешного PUT → checkConflicts → show alerts
|
||||
> - **Drag задачи** (interaction-handler.ts): после успешного обновления позиции → checkConflicts → show alerts
|
||||
> - **Resize задачи** (interaction-handler.ts): после успешного обновления размера → checkConflicts → show alerts
|
||||
>
|
||||
> 4. При переключении диаграммы или секции — очищать Alert-ы
|
||||
> 5. Alert-ы автоматически удаляются, когда текущая дата прошла дату конфликта — область скрывается, когда конфликтов не осталось
|
||||
|
||||
---
|
||||
|
||||
## Порядок реализации
|
||||
|
||||
| # | Этап | Сторона | Зависимости |
|
||||
|---|------|---------|-------------|
|
||||
| 1 | Миграция + Домен | Backend | — |
|
||||
| 2 | CRUD блоков | Backend | Этап 1 |
|
||||
| 3 | Обновление настроек секции | Backend | Этап 1 |
|
||||
| 4 | Включение блоков в ответ секций | Backend | Этап 2 |
|
||||
| 5 | Модели + Сервис | Frontend | — (можно параллельно с backend) |
|
||||
| 6 | Интеграция в модальные окна | Frontend | Этапы 5 + backend 2,3 |
|
||||
| 7 | Визуализация блоков на Ганте | Frontend | Этапы 5 + backend 4 |
|
||||
| 8 | Система Alert-ов | Frontend | Этап 5 |
|
||||
| 9 | Проверка конфликтов при операциях | Frontend | Этапы 5,7,8 |
|
||||
@@ -0,0 +1,746 @@
|
||||
---
|
||||
title: "Leadera — WebSocket для диаграмм Ганта"
|
||||
date: 2026-04-07
|
||||
lastmod: 2026-04-07
|
||||
tags: ["leadera", "websocket", "realtime", "gantt", "architecture"]
|
||||
weight: 110
|
||||
---
|
||||
|
||||
# WebSocket для диаграмм Ганта — realtime-совместное редактирование
|
||||
|
||||
**Дата:** 07.04.2026
|
||||
**Статус:** Планирование
|
||||
**Проект:** Leadera (dev — sand.a2v.space)
|
||||
|
||||
---
|
||||
|
||||
## 1. Постановка задачи
|
||||
|
||||
Диаграммы Ганта должны обновляться в реальном времени при совместной работе: если два или более пользователей редактируют один Гант, изменения каждого мгновенно отображаются у остальных — без перезагрузки страницы и ручного обновления.
|
||||
|
||||
---
|
||||
|
||||
## 2. Архитектура
|
||||
|
||||
```
|
||||
Angular frontend ←── WebSocket ──→ Go backend (Hub) ──→ PostgreSQL
|
||||
│ │
|
||||
│ HTTP (мутации) │ Broadcast
|
||||
└──→ REST API ──→ Service ──→ DB ──→ Hub ──→ все клиенты комнаты
|
||||
```
|
||||
|
||||
**Ключевой принцип:** мутации данных идут через HTTP (REST), WebSocket используется **только для доставки уведомлений**. Сервер — источник истины, клиент не может обойти проверки через WS.
|
||||
|
||||
### Комнаты
|
||||
|
||||
Каждый открытый Гант — это «комната» с идентификатором `chartId`. При подключении клиент подписывается на комнату. Hub рассылает события всем подписчикам комнаты.
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend (Go)
|
||||
|
||||
### 3.1. Зависимости
|
||||
|
||||
```bash
|
||||
go get github.com/gorilla/websocket
|
||||
```
|
||||
|
||||
### 3.2. Новые файлы — `internal/ws/`
|
||||
|
||||
#### `message.go` — типы сообщений
|
||||
|
||||
```go
|
||||
package ws
|
||||
|
||||
import "time"
|
||||
|
||||
// EventType — тип события
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
// Данные
|
||||
EventSectionCreated EventType = "section_created"
|
||||
EventSectionUpdated EventType = "section_updated"
|
||||
EventSectionDeleted EventType = "section_deleted"
|
||||
EventSectionsReordered EventType = "sections_reordered"
|
||||
EventTaskCreated EventType = "task_created"
|
||||
EventTaskUpdated EventType = "task_updated"
|
||||
EventTaskDeleted EventType = "task_deleted"
|
||||
|
||||
// Присутствие
|
||||
EventUserJoined EventType = "user_joined"
|
||||
EventUserLeft EventType = "user_left"
|
||||
|
||||
// Безопасность / доступ
|
||||
EventAccessRevoked EventType = "access_revoked"
|
||||
EventChartArchived EventType = "chart_archived"
|
||||
EventRoleChanged EventType = "role_changed"
|
||||
EventTokenExpired EventType = "token_expired"
|
||||
)
|
||||
|
||||
// Event — WS-событие, отправляемое клиентам
|
||||
type Event struct {
|
||||
Type EventType `json:"type"`
|
||||
ChartID string `json:"chartId"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
Payload interface{} `json:"payload,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// IncomingMessage — сообщение от клиента (для ping/pong)
|
||||
type IncomingMessage struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
```
|
||||
|
||||
#### `hub.go` — центральный брокер
|
||||
|
||||
```go
|
||||
package ws
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Hub управляет комнатами и рассылкой событий
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
rooms map[string]map[*Client]bool // chartId → клиенты
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
rooms: make(map[string]map[*Client]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe добавляет клиента в комнату
|
||||
func (h *Hub) Subscribe(chartID string, client *Client) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if h.rooms[chartID] == nil {
|
||||
h.rooms[chartID] = make(map[*Client]bool)
|
||||
}
|
||||
h.rooms[chartID][client] = true
|
||||
|
||||
// Уведомить остальных: user_joined
|
||||
h.broadcastExcept(chartID, client, Event{
|
||||
Type: EventUserJoined,
|
||||
ChartID: chartID,
|
||||
UserID: client.UserID,
|
||||
Payload: map[string]string{"username": client.Username},
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
// Unsubscribe удаляет клиента из комнаты
|
||||
func (h *Hub) Unsubscribe(chartID string, client *Client) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if clients, ok := h.rooms[chartID]; ok {
|
||||
delete(clients, client)
|
||||
if len(clients) == 0 {
|
||||
delete(h.rooms, chartID)
|
||||
}
|
||||
}
|
||||
|
||||
// Уведомить остальных: user_left
|
||||
h.broadcastExcept(chartID, client, Event{
|
||||
Type: EventUserLeft,
|
||||
ChartID: chartID,
|
||||
UserID: client.UserID,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
// Broadcast рассылает событие всем клиентам в комнате
|
||||
func (h *Hub) Broadcast(chartID string, event Event) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
if clients, ok := h.rooms[chartID]; ok {
|
||||
for client := range clients {
|
||||
client.Send(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendToUser отправляет событие конкретному пользователю во всех его комнатах
|
||||
func (h *Hub) SendToUser(userID string, event Event) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
for _, clients := range h.rooms {
|
||||
for client := range clients {
|
||||
if client.UserID == userID {
|
||||
client.Send(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastExcept рассылает всем, кроме отправителя
|
||||
func (h *Hub) broadcastExcept(chartID string, sender *Client, event Event) {
|
||||
if clients, ok := h.rooms[chartID]; ok {
|
||||
for client := range clients {
|
||||
if client != sender {
|
||||
client.Send(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `client.go` — обёртка WS-соединения
|
||||
|
||||
```go
|
||||
package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
writeWait = 10 * time.Second
|
||||
pongWait = 60 * time.Second
|
||||
pingPeriod = (pongWait * 9) / 10
|
||||
maxMessageSize = 1024
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
Hub *Hub
|
||||
Conn *websocket.Conn
|
||||
UserID string
|
||||
Username string
|
||||
ChartID string
|
||||
SendCh chan Event
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewClient(hub *Hub, conn *websocket.Conn, userID, username, chartID string) *Client {
|
||||
return &Client{
|
||||
Hub: hub,
|
||||
Conn: conn,
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
ChartID: chartID,
|
||||
SendCh: make(chan Event, 64),
|
||||
}
|
||||
}
|
||||
|
||||
// ReadPump читает входящие сообщения (ping/pong)
|
||||
func (c *Client) ReadPump() {
|
||||
defer func() {
|
||||
c.Hub.Unsubscribe(c.ChartID, c)
|
||||
c.Conn.Close()
|
||||
}()
|
||||
|
||||
c.Conn.SetReadLimit(maxMessageSize)
|
||||
c.Conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
c.Conn.SetPongHandler(func(string) error {
|
||||
c.Conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
return nil
|
||||
})
|
||||
|
||||
for {
|
||||
_, message, err := c.Conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
// Клиент может слать только ping, данные игнорируем
|
||||
_ = message
|
||||
}
|
||||
}
|
||||
|
||||
// WritePump отправляет события клиенту
|
||||
func (c *Client) WritePump() {
|
||||
ticker := time.NewTicker(pingPeriod)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
c.Conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-c.SendCh:
|
||||
c.Conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
if !ok {
|
||||
c.Conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
data, _ := json.Marshal(event)
|
||||
c.mu.Lock()
|
||||
c.Conn.WriteMessage(websocket.TextMessage, data)
|
||||
c.mu.Unlock()
|
||||
|
||||
case <-ticker.C:
|
||||
c.Conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send ставит событие в очередь отправки
|
||||
func (c *Client) Send(event Event) {
|
||||
select {
|
||||
case c.SendCh <- event:
|
||||
default:
|
||||
// Очередь полна — отключаем медленного клиента
|
||||
close(c.SendCh)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3. WS endpoint — `internal/handlers/ws_handler.go`
|
||||
|
||||
```go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"app-leadera-api/internal/middleware"
|
||||
"app-leadera-api/internal/ws"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool { return true }, // nginx проксирует
|
||||
}
|
||||
|
||||
type WSHandler struct {
|
||||
hub *ws.Hub
|
||||
ganttMW *middleware.GanttMiddleware
|
||||
}
|
||||
|
||||
func NewWSHandler(hub *ws.Hub, ganttMW *middleware.GanttMiddleware) *WSHandler {
|
||||
return &WSHandler{hub: hub, ganttMW: ganttMW}
|
||||
}
|
||||
|
||||
func (h *WSHandler) HandleGanttWS(c *gin.Context) {
|
||||
// 1. Получить пользователя из контекста (auth middleware уже отработал)
|
||||
userID, exists := c.Get("userID")
|
||||
if !exists {
|
||||
c.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Проверить доступ к chartId (gantt middleware)
|
||||
chartID := c.Param("chartId")
|
||||
if _, err := uuid.Parse(chartID); err != nil {
|
||||
c.AbortWithStatusJSON(400, gin.H{"error": "invalid chart id"})
|
||||
return
|
||||
}
|
||||
// TODO: проверить через ganttMW что userID имеет доступ к этому chart
|
||||
|
||||
// 3. Upgrade до WebSocket
|
||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Создать клиент и подписать на комнату
|
||||
client := ws.NewClient(
|
||||
h.hub, conn,
|
||||
userID.(string),
|
||||
c.GetString("username"),
|
||||
chartID,
|
||||
)
|
||||
h.hub.Subscribe(chartID, client)
|
||||
|
||||
// 5. Запустить pump'ы
|
||||
go client.WritePump()
|
||||
go client.ReadPump()
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4. Регистрация маршрута
|
||||
|
||||
В `cmd/server/main.go` или где регистрируются routes:
|
||||
|
||||
```go
|
||||
// После создания hub
|
||||
wsHub := ws.NewHub()
|
||||
|
||||
// WS endpoint
|
||||
wsHandler := handlers.NewWSHandler(wsHub, ganttMW)
|
||||
api.GET("/ws/gantt/:chartId", authMW.RequireAuth(), wsHandler.HandleGanttWS)
|
||||
|
||||
// Передать hub в ganttHandler для broadcast'ов
|
||||
ganttHandler := handlers.NewGanttHandler(ganttService, sectionService, taskService, blockService, wsHub)
|
||||
```
|
||||
|
||||
### 3.5. Интеграция broadcast в handlers
|
||||
|
||||
В каждый write-handler — одна строка после успешной операции:
|
||||
|
||||
```go
|
||||
// Пример: после успешного UpdateTask
|
||||
h.hub.Broadcast(chartID, ws.Event{
|
||||
Type: ws.EventTaskUpdated,
|
||||
ChartID: chartID,
|
||||
UserID: userID,
|
||||
Payload: updatedTask,
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
```
|
||||
|
||||
Точки интеграции в `gantt_handler.go`:
|
||||
- `CreateGanttChart` → не нужен (пользователь только что создал, он один)
|
||||
- `UpdateGanttChart` / `ArchiveGanttChart` → `chart_archived`
|
||||
- `CreateSection` → `section_created`
|
||||
- `UpdateSection` → `section_updated`
|
||||
- `DeleteSection` → `section_deleted`
|
||||
- `ReorderSections` → `sections_reordered`
|
||||
- `CreateTask` → `task_created`
|
||||
- `UpdateTask` → `task_updated`
|
||||
- `DeleteTask` → `task_deleted`
|
||||
|
||||
### 3.6. Интеграция событий безопасности
|
||||
|
||||
В соответствующих service-методах:
|
||||
|
||||
```go
|
||||
// При исключении из space (SpaceService.RemoveMember)
|
||||
wsHub.SendToUser(removedUserID, ws.Event{
|
||||
Type: ws.EventAccessRevoked,
|
||||
Payload: map[string]string{"spaceId": spaceID},
|
||||
})
|
||||
|
||||
// При архивации ганта
|
||||
wsHub.Broadcast(chartID, ws.Event{
|
||||
Type: ws.EventChartArchived,
|
||||
ChartID: chartID,
|
||||
})
|
||||
|
||||
// При изменении роли
|
||||
wsHub.SendToUser(userID, ws.Event{
|
||||
Type: ws.EventRoleChanged,
|
||||
Payload: map[string]string{"role": newRole},
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Frontend (Angular)
|
||||
|
||||
### 4.1. WebSocket сервис — `gantt-ws.service.ts`
|
||||
|
||||
```typescript
|
||||
// src/app/pages/gantt/services/gantt-ws.service.ts
|
||||
|
||||
import { Injectable, inject, OnDestroy } from '@angular/core';
|
||||
import { Subject, Observable } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { SpaceStorageService } from '../../../core/services/space-storage.service';
|
||||
import { AuthService } from '../../../core/auth/services/auth.service';
|
||||
|
||||
export interface WSEvent {
|
||||
type: string;
|
||||
chartId?: string;
|
||||
userId?: string;
|
||||
payload?: any;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class GanttWSService implements OnDestroy {
|
||||
private spaceStorage = inject(SpaceStorageService);
|
||||
private auth = inject(AuthService);
|
||||
|
||||
private socket: WebSocket | null = null;
|
||||
private eventSubject = new Subject<WSEvent>();
|
||||
private reconnectTimer: any;
|
||||
private chartId: string | null = null;
|
||||
|
||||
/** Observable для подписки на события */
|
||||
get events$(): Observable<WSEvent> {
|
||||
return this.eventSubject.asObservable();
|
||||
}
|
||||
|
||||
/** Подключиться к комнате ганта */
|
||||
connect(chartId: string): void {
|
||||
this.disconnect();
|
||||
this.chartId = chartId;
|
||||
|
||||
const token = this.auth.getAccessToken();
|
||||
const wsUrl = this.buildWSUrl(chartId, token);
|
||||
|
||||
this.socket = new WebSocket(wsUrl);
|
||||
|
||||
this.socket.onopen = () => {
|
||||
console.log(`[WS] Connected to gantt ${chartId}`);
|
||||
};
|
||||
|
||||
this.socket.onmessage = (msg) => {
|
||||
try {
|
||||
const event: WSEvent = JSON.parse(msg.data);
|
||||
this.eventSubject.next(event);
|
||||
} catch (e) {
|
||||
console.error('[WS] Parse error', e);
|
||||
}
|
||||
};
|
||||
|
||||
this.socket.onclose = (event) => {
|
||||
console.log(`[WS] Closed: ${event.code}`);
|
||||
if (event.code !== 1000) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
this.socket.onerror = () => {
|
||||
this.socket?.close();
|
||||
};
|
||||
}
|
||||
|
||||
/** Отключиться */
|
||||
disconnect(): void {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
if (this.socket) {
|
||||
this.socket.close(1000, 'manual');
|
||||
this.socket = null;
|
||||
}
|
||||
this.chartId = null;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.disconnect();
|
||||
}
|
||||
|
||||
private buildWSUrl(chartId: string, token: string): string {
|
||||
const base = environment.wsUrl ||
|
||||
location.protocol.replace('http', 'ws') + '//' + location.host;
|
||||
return `${base}/api/v1/ws/gantt/${chartId}?token=${token}`;
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (!this.chartId) return;
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
console.log('[WS] Reconnecting...');
|
||||
if (this.chartId) this.connect(this.chartId);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2. Интеграция в gantt-chart-canvas компонент
|
||||
|
||||
```typescript
|
||||
// В gantt-chart-canvas.component.ts
|
||||
|
||||
export class GanttChartCanvasComponent implements OnInit, OnDestroy {
|
||||
private ws = inject(GanttWSService);
|
||||
private onlineUsers: Set<string> = new Set();
|
||||
private wsSubscription: Subscription | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
// Подключиться к WS при загрузке ганта
|
||||
this.ws.connect(this.chartId);
|
||||
this.wsSubscription = this.ws.events$.subscribe(event => {
|
||||
this.handleWSEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.wsSubscription?.unsubscribe();
|
||||
this.ws.disconnect();
|
||||
}
|
||||
|
||||
private handleWSEvent(event: WSEvent): void {
|
||||
// Если это событие от текущего пользователя — пропускаем
|
||||
// (мы уже обновили UI оптимистично или после HTTP-ответа)
|
||||
if (event.userId === this.currentUserId) return;
|
||||
|
||||
switch (event.type) {
|
||||
case 'section_created':
|
||||
this.addSectionLocal(event.payload);
|
||||
this.redrawCanvas();
|
||||
break;
|
||||
|
||||
case 'section_updated':
|
||||
this.updateSectionLocal(event.payload);
|
||||
this.redrawCanvas();
|
||||
break;
|
||||
|
||||
case 'section_deleted':
|
||||
this.removeSectionLocal(event.payload.id);
|
||||
this.redrawCanvas();
|
||||
break;
|
||||
|
||||
case 'sections_reordered':
|
||||
this.reorderSectionsLocal(event.payload.ordered_ids);
|
||||
this.redrawCanvas();
|
||||
break;
|
||||
|
||||
case 'task_created':
|
||||
this.addTaskLocal(event.payload);
|
||||
this.redrawCanvas();
|
||||
break;
|
||||
|
||||
case 'task_updated':
|
||||
this.updateTaskLocal(event.payload);
|
||||
this.redrawCanvas();
|
||||
break;
|
||||
|
||||
case 'task_deleted':
|
||||
this.removeTaskLocal(event.payload.id);
|
||||
this.redrawCanvas();
|
||||
break;
|
||||
|
||||
case 'user_joined':
|
||||
this.onlineUsers.add(event.userId!);
|
||||
this.showOnlineIndicator();
|
||||
break;
|
||||
|
||||
case 'user_left':
|
||||
this.onlineUsers.delete(event.userId!);
|
||||
this.showOnlineIndicator();
|
||||
break;
|
||||
|
||||
// === Безопасность ===
|
||||
case 'access_revoked':
|
||||
this.router.navigate(['/gantt'], {
|
||||
state: { alert: 'Доступ к пространству был отозван' }
|
||||
});
|
||||
break;
|
||||
|
||||
case 'chart_archived':
|
||||
this.router.navigate(['/gantt'], {
|
||||
state: { alert: 'Диаграмма была заархивирована' }
|
||||
});
|
||||
break;
|
||||
|
||||
case 'role_changed':
|
||||
this.currentRole = event.payload.role;
|
||||
this.updateEditableState();
|
||||
break;
|
||||
|
||||
case 'token_expired':
|
||||
this.router.navigate(['/auth/login']);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private redrawCanvas(): void {
|
||||
// Перерисовать Konva canvas с обновлёнными данными
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3. Индикатор онлайн-пользователей
|
||||
|
||||
Добавить в шаблон canvas-компонента:
|
||||
|
||||
```html
|
||||
<!-- Онлайн-индикатор -->
|
||||
<div class="online-indicator" *ngIf="onlineUsers.size > 0">
|
||||
<span class="pulse-dot"></span>
|
||||
{{ onlineUsers.size }} онлайн
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Безопасность
|
||||
|
||||
### 5.1. Авторизация при подключении
|
||||
|
||||
| Этап | Проверка |
|
||||
|------|----------|
|
||||
| WS Handshake | JWT в query-параметре `?token=...` |
|
||||
| Подписка на комнату | Проверка membership в space |
|
||||
| Каждое broadcast-событие | Hub знает userId каждого клиента |
|
||||
|
||||
### 5.2. Сценарии отзыва доступа
|
||||
|
||||
| Событие | Триггер | Действие на клиенте |
|
||||
|---------|---------|---------------------|
|
||||
| `access_revoked` | Исключение из space | Редирект на список гантов |
|
||||
| `chart_archived` | Архивация ганта | Редирект на список гантов |
|
||||
| `role_changed` | Смена роли (editor→viewer) | Отключить редактирование |
|
||||
| `token_expired` | Токен протух | Редирект на логин |
|
||||
|
||||
### 5.3. Защита от злоупотреблений
|
||||
|
||||
| Угроза | Мера |
|
||||
|--------|------|
|
||||
| Спам WS-сообщений | Rate limit: 30 msg/min на клиента, `ReadLimit` в gorilla/websocket |
|
||||
| Подключение без прав | Проверка space membership при handshake |
|
||||
| Истёкший токен | Ping/pong + периодическая проверка JWT (раз в 5 мин) |
|
||||
| XSS через WS | Payload — JSON, sanitize имён на frontend |
|
||||
| Мутации через WS | WS **только для чтения** (уведомления). Мутации — через HTTP API с полной валидацией |
|
||||
|
||||
### 5.4. Nginx конфигурация
|
||||
|
||||
В конфиг `sand.a2v.space` добавить:
|
||||
|
||||
```nginx
|
||||
# WebSocket проксирование
|
||||
location /api/v1/ws/ {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 86400s; # 24h для long-lived WS
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Оценка трудозатрат
|
||||
|
||||
| Компонент | Строки кода | Время |
|
||||
|-----------|-------------|-------|
|
||||
| `internal/ws/` (hub, client, message) | ~200 | 2-3 часа |
|
||||
| `ws_handler.go` (endpoint) | ~60 | 30 мин |
|
||||
| Интеграция broadcast в handlers | ~20 (по строке на endpoint) | 30 мин |
|
||||
| События безопасности в services | ~30 | 30 мин |
|
||||
| `gantt-ws.service.ts` (Angular) | ~80 | 1 час |
|
||||
| Интеграция в canvas-компонент | ~80 | 1-2 часа |
|
||||
| Индикатор онлайн | ~30 | 30 мин |
|
||||
| Nginx конфигурация | ~10 | 10 мин |
|
||||
| Тестирование | — | 2-3 часа |
|
||||
| **Итого** | **~510 строк** | **~1-2 дня** |
|
||||
|
||||
---
|
||||
|
||||
## 7. Порядок реализации
|
||||
|
||||
1. **Backend: `internal/ws/`** — hub, client, message types
|
||||
2. **Backend: WS endpoint** — handler + route
|
||||
3. **Backend: Broadcast в handlers** — интеграция в существующие CRUD-эндпоинты
|
||||
4. **Backend: События безопасности** — access_revoked, chart_archived, role_changed
|
||||
5. **Nginx: WS location** — проксирование WebSocket
|
||||
6. **Frontend: WS service** — подключение, реконнект, парсинг событий
|
||||
7. **Frontend: Интеграция в canvas** — обработка событий, обновление Konva
|
||||
8. **Frontend: Индикатор онлайн** — визуальная обратная связь
|
||||
9. **Тестирование** — многопользовательский сценарий, отзыв прав, обрывы связи
|
||||
|
||||
---
|
||||
|
||||
## 8. Дальнейшие улучшения (после MVP)
|
||||
|
||||
- **Cursor других пользователей** — показывать позицию курсора на таймлайне
|
||||
- **Блокировка элемента** — «кто-то редактирует эту задачу» (optimistic locking через WS)
|
||||
- **Чат в ганте** — комментарии к задачам прямо на канвасе
|
||||
- **History/undo** — откат изменений с визуальной историей
|
||||
- **Уведомления** — push-уведомления при @mention в комментариях к задачам
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: "Личный сайт (agaltsovav.ru)"
|
||||
date: 2026-03-31
|
||||
tags: ["personal-site", "hugo", "audit"]
|
||||
weight: 90
|
||||
---
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: "Бинарный файл в репозитории"
|
||||
date: 2026-03-31
|
||||
tags: ["audit", "personal-site"]
|
||||
---
|
||||
|
||||
## Проблема
|
||||
|
||||
В корне репозитория лежат два файла, которые не должны быть в git:
|
||||
|
||||
- **`Шаблоны для сайта.key`** — Apple Keynote файл (бинарный)
|
||||
- **`logo.psd`** — Photoshop файл (бинарный)
|
||||
|
||||
### Что не так
|
||||
|
||||
- Увеличивают размер репозитория
|
||||
- Git неэффективно хранит бинарные файлы (каждая версия — полностью)
|
||||
- `.key` файлы могут содержать конфиденциальные данные шаблонов
|
||||
|
||||
### Рекомендация
|
||||
|
||||
1. Перенести в отдельное хранилище (Google Drive, облако)
|
||||
2. Удалить из git-истории через `git filter-repo`
|
||||
3. Добавить в `.gitignore`: `*.key`, `*.psd`, `*.sketch`
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: "Copyright без 2026 года"
|
||||
date: 2026-03-31
|
||||
tags: ["audit", "personal-site"]
|
||||
---
|
||||
|
||||
## Проблема
|
||||
|
||||
В `params.toml`:
|
||||
```
|
||||
copyRight = "Copyright (c) 2020-2025 Антон Агальцов"
|
||||
```
|
||||
|
||||
Сейчас 2026 год — нужно обновить.
|
||||
|
||||
### Рекомендация
|
||||
|
||||
Обновить на `2020-2026`. Или сделать динамическим через Hugo template:
|
||||
```
|
||||
Copyright (c) 2020-{{ now.Year }} Антон Агальцов
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: "Digest не обновлялся с февраля 2025"
|
||||
date: 2026-03-31
|
||||
tags: ["audit", "personal-site"]
|
||||
---
|
||||
|
||||
## Проблема
|
||||
|
||||
Последний дайджест — **февраль 2025**. С тех пор 12+ месяцев без новых выпусков.
|
||||
|
||||
### Что есть
|
||||
|
||||
- 27 файлов дайджестов (январь 2023 — февраль 2025)
|
||||
- Автоматизация через `digest.js` — скрипт для генерации
|
||||
- Но скрипт не запускается автоматически
|
||||
|
||||
### Риск
|
||||
|
||||
Low — контент устаревает, SEO-позиции падают для страниц дайджестов.
|
||||
|
||||
### Рекомендация
|
||||
|
||||
1. Настроить cron/CI для ежемесячной генерации дайджеста
|
||||
2. Или интегрировать с OpenClaw — авто-сборка дайджеста на основе новых публикаций
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
title: "Устаревший Node.js 16 в Netlify"
|
||||
date: 2026-03-31
|
||||
tags: ["audit", "personal-site", "infrastructure"]
|
||||
severity: high
|
||||
status: open
|
||||
---
|
||||
|
||||
# Устаревший Node.js 16 в Netlify
|
||||
|
||||
**Файл:** `netlify.toml:5`
|
||||
|
||||
```
|
||||
NODE_VERSION = "16.16.0"
|
||||
```
|
||||
|
||||
Node.js 16 вышел из поддержки (EOL) в сентябре 2023. Сборка на Netlify может начать падать или работать некорректно.
|
||||
|
||||
**Рекомендация:** Обновить до Node.js 20 LTS:
|
||||
```toml
|
||||
NODE_VERSION = "20"
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: "Yandex Metrika отключена"
|
||||
date: 2026-03-31
|
||||
tags: ["audit", "personal-site"]
|
||||
---
|
||||
|
||||
## Проблема
|
||||
|
||||
В `params.toml`: `yandexMetrika = false`
|
||||
|
||||
Аналитика с сайта не собирается. Непонятно, есть ли вообще посетители и какие страницы популярны.
|
||||
|
||||
### Рекомендация
|
||||
|
||||
1. Включить Yandex Metrika или перейти на **Plausible** / **Umami** (privacy-friendly)
|
||||
2. Если сайт не нуждается в аналитике — оставить как есть
|
||||
3. Альтернатива: Netlify Analytics (уже встроен в хостинг)
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
title: "Отсутствуют node_modules — нужна установка"
|
||||
date: 2026-03-31
|
||||
tags: ["audit", "personal-site"]
|
||||
---
|
||||
|
||||
## Проблема
|
||||
|
||||
`node_modules/` отсутствует — проект нельзя собрать без `npm install`.
|
||||
|
||||
### Риск
|
||||
|
||||
Low — стандартная ситуация. Но:
|
||||
|
||||
- `package-lock.json` привязан к Node 16, а на Mac Studio текущий Node v24
|
||||
- Могут быть конфликты зависимостей (canvas, babel, postcss)
|
||||
- Hugo 0.148.2 ставится через `hugo-installer` в npm — может не совпасть с глобальным Hugo
|
||||
|
||||
### Рекомендация
|
||||
|
||||
1. Убедиться что `npm install` проходит без ошибок на текущем Node
|
||||
2. Если нет — обновить зависимости или использовать nvm для Node 16
|
||||
3. Рассмотреть переход на глобальный Hugo вместо `exec-bin`
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: "Hugo Doks 0.5 — сильно устарел"
|
||||
date: 2026-03-31
|
||||
tags: ["audit", "personal-site"]
|
||||
---
|
||||
|
||||
## Проблема
|
||||
|
||||
Проект использует тему **Hyas/Doks v0.5.0** (package.json). Актуальная версия Doks — **1.x** с полной переработкой архитектуры.
|
||||
|
||||
### Что не так
|
||||
|
||||
- Doks 0.5 использует устаревший Bootstrap 5.2.0-beta1
|
||||
- Hugo 0.148.2 в зависимостях — новый, но интеграция с темой старая
|
||||
- Конфигурация через `theme.toml` — в новых версиях перешли на другой формат
|
||||
- `exec-bin` для вызова Hugo — устаревший подход, текущие версии Hugo ставятся глобально
|
||||
|
||||
### Риск
|
||||
|
||||
Medium — работает, но миграция на Doks 1.x потребует значительного рефакторинга layout-ов и конфигурации. Чем дольше откладывать — тем сложнее.
|
||||
|
||||
### Рекомендация
|
||||
|
||||
Запланировать миграцию на Doks 1.x или перейти на чистый Hugo + Bootstrap без зависимости от темы-фреймворка.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
title: "Twitter handle не соответствует имени"
|
||||
date: 2026-03-31
|
||||
tags: ["audit", "personal-site"]
|
||||
---
|
||||
|
||||
## Проблема
|
||||
|
||||
В `params.toml` указан Twitter: `@AntonTotoshkin`
|
||||
|
||||
Но владелец сайта — **Антон Агальцов**. Ники не совпадают.
|
||||
|
||||
### Два варианта
|
||||
|
||||
1. Это твой старый/другой аккаунт — тогда ок, но стоит указать актуальный
|
||||
2. Это чужой аккаунт — тогда нужно убрать или исправить
|
||||
|
||||
### Затронутые места
|
||||
|
||||
- `twitterSite = "@AntonTotoshkin"`
|
||||
- `schemaAuthorTwitter = "https://twitter.com/AntonTotoshkin"`
|
||||
- `schemaTwitter = "https://twitter.com/AntonTotoshkin"`
|
||||
|
||||
### Рекомендация
|
||||
|
||||
Указать актуальный Twitter/X или убрать, если соцсеть не используется.
|
||||
@@ -0,0 +1,4 @@
|
||||
+++
|
||||
title = "Планы"
|
||||
draft = false
|
||||
+++
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
title: "Завершить Eversolo Companion - этапы 3-9"
|
||||
date: 2026-03-29
|
||||
plan_date:
|
||||
status: planned
|
||||
done_date:
|
||||
context_quote: "UI готов, API готов, БД готова — но API не может достучаться до Eversolo из-за ограничений OpenClaw sandbox"
|
||||
tags: ["eversolo", "companion", "angular", "go", "docker"]
|
||||
---
|
||||
|
||||
## Описание
|
||||
|
||||
Завершить разработку Eversolo Companion после решения проблемы с сетевым доступом.
|
||||
|
||||
## Что сделано
|
||||
|
||||
- **Backend (Go + Gin)**: собран, 17+ REST API эндпоинтов, WebSocket, CORS, state poller
|
||||
- **Frontend (Angular 17)**: собран, тёмная тема (Spotify-like), 5 экранов, 5 сервисов
|
||||
- **PostgreSQL**: Docker, порт 5433, миграции готовы
|
||||
- **Docker Compose**: рабочий (postgres + api + frontend)
|
||||
- **Спецификация**: полная в ~/Projects/eversolo-companion/SPEC.md
|
||||
|
||||
## Проблема
|
||||
|
||||
API не может достучаться до Eversolo T8 (192.168.0.156:9529) из-за ограничений OpenClaw sandbox.
|
||||
|
||||
## Возможные решения
|
||||
|
||||
1. Настроить `tools.elevated.allowFrom.webchat` для разрешения сетевых соединений
|
||||
2. Запустить API через launchd plist (как SSH туннели)
|
||||
3. Использовать отдельный контейнер без ограничений
|
||||
|
||||
## Этапы 3-9 (после решения сети)
|
||||
|
||||
1. **Библиотека синхронизации** — двухсторонний sync с Eversolo
|
||||
2. **Плейлисты** — импорт/экспорт, редактирование
|
||||
3. **Scrobbler** — Last.fm / ListenBrainz
|
||||
4. **Обложки** — кэширование, отображение в UI
|
||||
5. **Плеер** — улучшенный интерфейс управления
|
||||
6. **Система уведомлений** — события от Eversolo
|
||||
7. **Администрирование** — настройка подключений
|
||||
|
||||
## Связанные ресурсы
|
||||
|
||||
- [Спецификация проекта](/docs/eversolo-companion-spec/)
|
||||
- [API документация](/kb/eversolo-api/)
|
||||
- [Дневник разработки](/diary/2026-03-29/)
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: "Реализовать управление Eversolo из OpenClaw"
|
||||
date: 2026-03-29
|
||||
plan_date:
|
||||
status: planned
|
||||
done_date:
|
||||
context_quote: "API изучен, записан в TOOLS.md. Проблема: нет доступа к LAN из sandbox OpenClaw."
|
||||
tags: ["eversolo", "openclaw", "api"]
|
||||
---
|
||||
|
||||
## Описание
|
||||
|
||||
Добавить инструменты управления Eversolo T8 в OpenClaw.
|
||||
|
||||
## Что сделано
|
||||
|
||||
- Изучен полный API Eversolo T8 (см. [Базу знаний](/kb/eversolo-api/))
|
||||
|
||||
## Проблема
|
||||
|
||||
Sandbox OpenClaw не имеет доступа к локальной сети, где находится Eversolo.
|
||||
|
||||
## Возможные решения
|
||||
|
||||
1. Настроить проброс портов
|
||||
2. Запустить OpenClaw без sandbox-ограничений
|
||||
3. Использовать промежуточный proxy-сервис
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: "Настроить интеграцию Ollama с OpenClaw"
|
||||
date: 2026-03-29
|
||||
plan_date:
|
||||
status: planned
|
||||
done_date:
|
||||
context_quote: "Добавлен qwen3.5:9b в openclaw.json, но модель не появилась в openclaw models list. Отложено по решению Антона."
|
||||
tags: ["ollama", "openclaw", "llm"]
|
||||
---
|
||||
|
||||
## Описание
|
||||
|
||||
Добавить поддержку Ollama в OpenClaw для использования локальных LLM моделей.
|
||||
|
||||
## Проблема
|
||||
|
||||
Модель `qwen3.5:9b` добавлена в конфиг `openclaw.json`, но не отображается в `openclaw models list`.
|
||||
|
||||
## Статус
|
||||
|
||||
Отложено по решению Антона.
|
||||
Reference in New Issue
Block a user