Compare commits

...
Author SHA1 Message Date
“Naeel” de2598f449 admin-console: bump to v0.1.1 2026-05-25 10:51:26 +04:00
“Naeel” 35c276c7a9 admin-console: add proper Ingress resource, fix namespace detail routing 2026-05-25 10:47:02 +04:00
“Naeel” 21ab7b9d6b admin-console: fast namespace list + async detail loading per ns 2026-05-25 10:40:49 +04:00
“Naeel” d570a6b8a5 admin-console: fix duplicate package main, fix Dockerfile build order 2026-05-25 10:21:13 +04:00
“Naeel” 190361ab0c admin-console: implement Dynamic client CRD counting, S3 storage metrics, CSV export 2026-05-25 10:11:13 +04:00
“Naeel” 55758b6280 admin-console: add DeepSeek handoff instructions 2026-05-25 10:04:23 +04:00
“Naeel” 34a8068da5 restructure: console→client-console, add admin-console skeleton, move docs to doc/ 2026-05-25 09:48:55 +04:00
“Naeel” 3e36ff13a8 docs: fix outdated references — versions, S3/PVC, triggers status 2026-05-21 09:24:42 +04:00
“Naeel” 53ef35968e fix: billing.NoopStore in clone test + repo analysis doc 2026-05-21 09:14:31 +04:00
“Naeel” ab128515ab docs: README — storagesvc S3/PV deploy instructions + comparison table 2026-05-20 09:09:11 +04:00
“Naeel” 296b538d20 bench: S3 results 2026-05-20 PASS=20/20 + PV vs S3 comparison
RUNID=9251970 (S3 ngcloud msk-1):
- PASS=20/20 — все тесты прошли
- Upload: мелкие файлы медленнее (+1-3s), крупные — сопоставимо
- Cold start: S3 ≈ PV (python быстрее, ruby small медленнее, PHP одинаково)
- Hot avg: практически идентично на обоих бэкендах

ruby/medium cold=80568ms — аномалия воспроизвелась 4-й раз подряд на S3.
Подтверждено: не связана с типом хранилища, это поведение poolmgr.

Вердикт: S3 == PV по производительности. S3 предпочтительнее.
2026-05-20 08:45:40 +04:00
“Naeel” 7539680ae0 chore: switch storagesvc to S3 backend (ngcloud msk-1)
- helm/fission-values.yaml: storageType=s3, bucket=sless-functions, endPoint=https://s3.msk-1.ngcloud.ru
- helm/fission-values-pv.yaml: сохранён PV конфиг для быстрого возврата
- helm/fission-s3-secret.yaml: credentials (в .gitignore, не в репо)
- .gitignore: добавлен helm/fission-s3-secret.yaml

Возврат на PV: kubectl delete deploy executor router storagesvc -n fission
  helm upgrade fission ... -f helm/fission-values-pv.yaml --no-hooks
2026-05-20 08:39:51 +04:00
“Naeel” 005b4786c0 docs: bench PV results 2026-05-20 PASS=20/20; misc updates 2026-05-20 08:30:26 +04:00
“Naeel” a2e1723e33 bench: fix PHP arrow functions (fn()=> requires 7.4+), PASS=20/20 on PV
- Replace fn()=> with traditional function() syntax in php_medium and php_large
- 8 arrow function occurrences fixed across processWords/statsArr/caesar/lookupOps/nested array_map
- Added diag_bugs.sh: diagnostic script for PHP pool exhaustion + ruby/medium cold anomaly
- bench_full.sh RUNID=9250986: PASS=20 FAIL=0 (was PASS=18 FAIL=2)
- ruby/medium cold=81549ms anomaly confirmed reproducible (3rd time), not a FAIL
- Added doc/report-2026-05-20-bench-php-analysis.md
2026-05-20 08:27:32 +04:00
“Naeel” dd7e42caf4 fix: ExpiryReaper orphan race min-age 2m + Ruby handler format (v1.3.96) 2026-05-20 07:37:04 +04:00
“Naeel” fe8870af71 fix: nodejs wrapper передаёт require в new Function (v1.3.95) 2026-05-19 16:48:19 +04:00
“Naeel” 27dbe3bccb docs: add FISSION_IMAGES.md — image tags and rebuild instructions 2026-05-19 11:33:58 +04:00
“Naeel” 090a7afdd7 fix: nodejs entrypoint back to 'main' — node-env splits on '.' giving funcname='js' 2026-05-19 11:05:07 +04:00
“Naeel” 042b1e5046 fix: PHP env PSR-7 format — getBody()->write() instead of return array 2026-05-19 10:41:46 +04:00
“Naeel” 72e5d50cd0 fix: ExpiryReaper orphan race + nodejs ESM entrypoint (v1.3.93) 2026-05-19 10:30:28 +04:00
“Naeel” fd075a7f80 test: add test_heavy.sh — big code, CPU, mem, archive, rapid, parallel, slow 2026-05-19 10:16:09 +04:00
“Naeel” 78f1a2cfc4 doc: progress 2026-05-19 PV migration + fetcher v1.23.0, PASS=8 FAIL=0 2026-05-19 09:59:41 +04:00
“Naeel” 2aadae339e chore: rename storage-check → storage-bench-s3-archived (S3 отключён) 2026-05-19 09:50:33 +04:00
“Naeel” 3eb4f5669b feat: PV storage backend + fix fetcher image v1.23.0
- helm/fission-values.yaml: storagesvc на vcd-disk-ext4 PV 2Gi
  fetcher image: naeel/fetcher:v1.23.0 (собран из fission-src/cmd/fetcher)
- scripts/migrate_s3_to_pv.py: скрипт перелива S3→storagesvc (no-op: 0 пакетов с s3://)
- doc/report-2026-05-19-pv-migration-bugs.md: баги helm upgrade, FETCHER_IMAGE, field manager
2026-05-19 09:46:57 +04:00
“Naeel” 865d33a0e1 feat: storage benchmark — local-path PV, vcd-disk-ext4 PV, S3 (10 iter each) 2026-05-19 07:28:45 +04:00
“Naeel” 1d81ca34e1 doc: план и разъяснения по устранению 401 'key is of invalid type' (root cause, порядок исправления, volume-mount secret, отказ от fallback на SA token) 2026-05-18 19:51:59 +04:00
“Naeel” 8374254020 chore: gitignore scripts/test_layer1.sh 2026-05-15 14:16:30 +04:00
“Naeel” ae913f7ad6 chore: gitignore token.txt and test-results/ 2026-05-15 14:16:03 +04:00
“Naeel” 68296d8fe0 fix: update paths from ~/IoT and ~/fission to ~/fission-console in .github docs 2026-05-15 14:14:45 +04:00
“Naeel” 949b0b52d6 fix(console): v1.3.92 — EnsureEnvironment обновляет образ если устарел
Вместо костыля (kubectl patch вручную) — системный фикс:
EnsureEnvironment теперь проверяет spec.runtime.image у существующего env
и обновляет его если отличается от LangEnvMap[lang].Image.

Это автоматически мигрирует все namespace-ы при следующем создании функции.
2026-05-13 10:55:47 +04:00
“Naeel” 6a8c3a27e2 fix(console): v1.3.91 — deps сохраняются в аннотации и восстанавливаются при edit
- handleCreateFunction: deps → аннотация fission-console/deps
- handleUpdateFunctionCode: deps → аннотация fission-console/deps (или удаление если пусто)
- GET /functions/:name: возвращает deps из аннотации
- openEdit: заполняет e-deps.value = fn.deps (вместо пустого поля)
2026-05-13 10:43:59 +04:00
“Naeel” 75532d8d8e fix(console): v1.3.90 — deps textarea compact, linter внешние импорты, python env v1.1
- deps textarea: rows=3, resize:none, overflow-y:auto
- aiCheck (Python): после линтера проверяет внешние импорты
  если deps поле пустое — предупреждение с именами модулей
- Python env: v1.0 → v1.1 (содержит boto3+requests+psycopg2-binary)
2026-05-13 10:37:14 +04:00
“Naeel” 10b2b4a8e0 feat(console): поле зависимостей для функций из кода (deps → zip)
- UI: textarea 'Зависимости' в формах создания и редактирования
  - плейсхолдер меняется по языку (requirements.txt / package.json / Gemfile / composer.json)
- Python: если deps заполнен → zip(main.py + requirements.txt), иначе raw bytes
- PHP: если deps → zip(main.php + composer.json)
- Ruby: если deps → zip(handler.rb + Gemfile)
- Node.js: пока без изменений (сложная структура package.json)
- runtime: BuildPythonZip, BuildScriptZipWithDeps, buildZipTwo
- model: Deps string в CreateFunctionRequest и UpdateCodeRequest
- v1.3.89
2026-05-13 10:22:45 +04:00
“Naeel” 17f1c5d46f feat: стартовая точка для weather pipeline без Terraform (ветка feat/weather-pipeline-no-tf) 2026-05-13 07:44:38 +04:00
“Naeel” 4ef480d1f8 chore: ignore *.tfvars (contains secrets) 2026-05-12 11:22:44 +04:00
“Naeel” df16b40a9d feat: weather-demo MQ pipeline + sqs-consumer v1.2 with JWT auto-refresh
- sqs-consumer: new Go binary v1.2 with tokenManager (auto-login /auth/login, 5min cache, retry on 401)
- console: add MQ/KW triggers UI (mqtriggers.go, kwtriggers.go, mq.js)
- console: update python-env image to v1.1 (boto3+psycopg2+requests)
- weather-demo: fix consumer/main.py (Flask Request.get_json instead of dict access)
- weather-demo: fix fetcher/main.py (boto3 SQS publish, 5 cities)
- weather-demo: update main.tf (python-env v1.1, deploy_type=literal)
- python-env: add psycopg2-binary to Dockerfile (v1.1)
- terraform provider: client auth fix
2026-05-12 11:19:30 +04:00
“Naeel” fbd565651a feat(provider): add fission_mq_trigger, fission_cron_trigger, fission_iot_device + weather-demo example 2026-05-11 09:37:20 +04:00
“Naeel” fe8f6a871b feat: plan.md + minor fixes (stats, server, copilot rules) 2026-05-11 07:46:14 +04:00
“Naeel” c9f97e2f4c feat: v1.3.87 — Grafana Organizations per namespace (StatsProvider + public dashboards + UI button) 2026-05-10 18:47:21 +04:00
“Naeel” 4b8c776357 feat: grafana subpath /grafana on fission.kube5s.ru (temp, easy migration to grafana.kube5s.ru) 2026-05-10 09:12:21 +04:00
176 changed files with 9271 additions and 333 deletions
+3 -3
View File
@@ -28,16 +28,16 @@
1. Не трогать рабочий код без явного указания. 1. Не трогать рабочий код без явного указания.
2. Файлы редактируются локально: 2. Файлы редактируются локально:
~/fission ~/fission-console
После ЛЮБЫХ изменений ОБЯЗАТЕЛЬНО синхронизировать на ВМ командой: После ЛЮБЫХ изменений ОБЯЗАТЕЛЬНО синхронизировать на ВМ командой:
rsync -az \ rsync -az \
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \ -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
~/fission/ \ ~/fission-console/ \
naeel@5.172.178.213:~/terra/fission/ naeel@5.172.178.213:~/terra/fission/
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission 3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission-console
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ: 4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ:
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА' ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
+5 -5
View File
@@ -13,14 +13,14 @@
## Файловая система (актуально) ## Файловая система (актуально)
1. Все файлы редактируются локально: `~/fission` 1. Все файлы редактируются локально: `~/fission-console`
2. После любых изменений — обязательно rsync на ВМ: 2. После любых изменений — обязательно rsync на ВМ:
rsync -az \ rsync -az \
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \ -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
~/fission/ \ ~/fission-console/ \
naeel@5.172.178.213:~/terra/fission/ naeel@5.172.178.213:~/terra/fission/
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission 3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission-console
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ 4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ
5. Перед запуском любой команды на ВМ обязательно убедиться, что синхронизация (rsync) выполнена 5. Перед запуском любой команды на ВМ обязательно убедиться, что синхронизация (rsync) выполнена
6. SCP, sshfs, remote_dev и маунты больше НЕ используются 6. SCP, sshfs, remote_dev и маунты больше НЕ используются
@@ -50,7 +50,7 @@ ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=
## Git ## Git
⛔⛔⛔ АБСОЛЮТНОЕ ПРАВИЛО: ⛔⛔⛔ АБСОЛЮТНОЕ ПРАВИЛО:
- Git — ТОЛЬКО ЛОКАЛЬНО в `~/fission`. НИКОГДА через SSH на VM. - Git — ТОЛЬКО ЛОКАЛЬНО в `~/fission-console`. НИКОГДА через SSH на VM.
- Разрешены ТОЛЬКО две операции: `git commit` и `git push`. - Разрешены ТОЛЬКО две операции: `git commit` и `git push`.
- ЗАПРЕЩЕНО: git pull, git fetch, git rebase, git merge, git reset, git stash, git checkout — что угодно кроме commit и push. - ЗАПРЕЩЕНО: git pull, git fetch, git rebase, git merge, git reset, git stash, git checkout — что угодно кроме commit и push.
- Если push отклонён — СТОП, доложить пользователю. Не лезть в pull/merge/rebase самостоятельно. - Если push отклонён — СТОП, доложить пользователю. Не лезть в pull/merge/rebase самостоятельно.
@@ -86,7 +86,7 @@ LOG="test-results/$(date +%Y-%m-%d_%H-%M).log"
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 \ ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 \
"bash ~/terra/fission/scripts/run_all.sh 2>&1 | tee ~/terra/fission/${LOG}" "bash ~/terra/fission/scripts/run_all.sh 2>&1 | tee ~/terra/fission/${LOG}"
rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no" \ rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no" \
naeel@5.172.178.213:~/terra/fission/test-results/ ~/fission/test-results/ naeel@5.172.178.213:~/terra/fission/test-results/ ~/fission-console/test-results/
``` ```
**Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.** **Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.**
+9
View File
@@ -2,6 +2,7 @@
.terraform/ .terraform/
*.tfstate *.tfstate
*.tfstate.* *.tfstate.*
*.tfvars
# Go # Go
bin/ bin/
@@ -15,3 +16,11 @@ examples/*/dist/
terraform-provider-fission terraform-provider-fission
terraform-provider-fission_* terraform-provider-fission_*
console/fission-console console/fission-console
# Secrets / tokens
token.txt
helm/fission-s3-secret.yaml
# Test logs
test-results/
scripts/test_layer1.sh
+56
View File
@@ -235,3 +235,59 @@
### Вывод ### Вывод
- Текущий prompt уже работает на коротких и простых кейсах. - Текущий prompt уже работает на коротких и простых кейсах.
- Следующий шаг — проверить его на более разнообразных задачах, чтобы убедиться, что шаблон не ломается за пределами приветствий. - Следующий шаг — проверить его на более разнообразных задачах, чтобы убедиться, что шаблон не ломается за пределами приветствий.
---
## Итерация 9 (2026-05-19) — RUNTIME BUG DISCOVERY
### Источник
Реальный прогон `test_heavy.sh` на кластере. Обнаружены критические баги в PHP и Node.js runtime-контракте.
### БАГ C: PHP `return array` → пустой response
**Задача LLM:** написать PHP-функцию.
**Сгенерированный код (типичный):**
```php
<?php
function handler(array $ctx): array {
return ['statusCode' => 200, 'body' => json_encode(['tag' => 'ok'])];
}
```
**Результат invoke:** `{"status":200,"response_raw":""}` — тело пустое.
**Причина:** PHP env (`ghcr.io/fission/php-env:latest`, server.php) создаёт `$response = new Response()`,
вызывает функцию и возвращает оригинальный объект. Return value функции игнорируется.
**Правильный код:**
```php
<?php
function handler($ctx) {
$ctx["response"]->getBody()->write(json_encode(['tag' => 'ok']));
}
```
**Что изменено в промпте:**
- PHP секция переписана: запрет на `return array`, требование писать через `$ctx["response"]->getBody()->write(...)`.
- Добавлен пример с JSON-ответом и PSR-7 объектами.
---
### БАГ B: Node.js entrypoint `"main.js"` — split на точке → funcname="js"
**Проблема консоли, не пользовательского кода.**
Node-env `specializeV2` (строка 160 server.js) интерпретирует functionName через `split(".")`:
- `"main.js"``{filename:"main", funcname:"js"}` → ищет `module["js"]` → undefined → specialize 500.
- `"main"``{filename:"main", funcname:undefined}` → берёт `default export` → работает.
**Fix в консоли:** `console/internal/runtime/entrypoint.go`, `DefaultEntrypoint("nodejs")` = `"main"`.
**Влияние на промпт:** никакого — это внутренняя проблема консоли. Пользовательский Node.js код пишется как обычно.
---
### Итог итерации 9
- PHP промпт обновлён: PSR-7 `getBody()->write()` вместо `return array`.
- Node.js промпт уточнён: `module.exports = async function(ctx) {...}`, CJS стиль.
- Документация: `doc/RUNTIME_FUNCTION_GUIDE.md` — полный справочник контрактов.
+25 -3
View File
@@ -54,9 +54,31 @@
- Не ломай контракт функции выдуманными обертками или неподходящими типами. - Не ломай контракт функции выдуманными обертками или неподходящими типами.
### PHP ### PHP
- Возвращай корректный PHP-код функции. - **КРИТИЧНО:** PHP env НЕ читает return value функции. `return ['statusCode'=>200,'body'=>...]` — тело ответа будет ПУСТЫМ.
- Следи за открывающим тегом и синтаксисом массива/строк. - Запись в ответ — ТОЛЬКО через `$ctx["response"]->getBody()->write(...)`:
- Не выдумывай несуществующие `Fission\\Contract` классы или SDK, если задача про обычную функцию. ```php
<?php
function handler($ctx) {
$ctx["response"]->getBody()->write(json_encode(['key' => 'value']));
}
```
- Сигнатура: `function handler($ctx)` — без type hint `array` и без `: array` return type.
- Доступ к запросу: `$ctx["request"]`, к логгеру: `$ctx["logger"]`.
- Открывающий тег `<?php` обязателен.
- Не выдумывай несуществующие `Fission\\Contract` классы или SDK.
- Альтернатива (только если нет доступа к request): простой `echo` без объявления функции handler:
```php
<?php
echo json_encode(['key' => 'value']);
```
### Node.js
- Пользовательский код оборачивается консолью в CJS wrapper. Писать как обычный CommonJS.
- Экспорт: `module.exports = async function(ctx) { return { status: 200, body: "..." }; }`
- Формат ответа: объект `{ status: число, body: строка }`.
- **НЕ** использовать `export default` — код запускается в CJS контексте.
- **НЕ** указывать `.js` в entrypoint — это внутренняя настройка консоли, не пользовательский код.
- Не вставляй ESM import/export обвязку в пользовательский код.
### Ruby ### Ruby
- Возвращай корректный Ruby-код функции. - Возвращай корректный Ruby-код функции.
+52
View File
@@ -102,6 +102,58 @@ Backend сервис для управления serverless-функциями
## Деплой ## Деплой
### Хранилище архивов функций (storagesvc)
Fission хранит ZIP-архивы функций через `storagesvc`. Поддерживается два бэкенда:
#### S3 (рекомендуется)
```bash
# 1. Создать файл с credentials (НЕ коммитить в git):
cat > helm/fission-s3-secret.yaml << 'EOF'
persistence:
s3:
accessKeyId: <ACCESS_KEY>
secretAccessKey: <SECRET_KEY>
EOF
# 2. Применить:
kubectl delete deploy executor router storagesvc -n fission # убрать field manager конфликты
helm upgrade fission fission-charts/fission-all --version 1.22.1 \
-n fission \
-f helm/fission-values.yaml \
-f helm/fission-s3-secret.yaml \
--no-hooks --timeout 180s
```
Параметры S3 (bucket, endpoint, region) — в `helm/fission-values.yaml`.
Credentials — только в `helm/fission-s3-secret.yaml` (файл в `.gitignore`).
#### PV (возврат/альтернатива)
```bash
kubectl delete deploy executor router storagesvc -n fission
helm upgrade fission fission-charts/fission-all --version 1.22.1 \
-n fission \
-f helm/fission-values-pv.yaml \
--no-hooks --timeout 180s
```
Никаких секретов не нужно. StorageClass `vcd-disk-ext4` (VMware VCD), 2Gi.
#### Сравнение
| | S3 | PV |
|---|---|---|
| Upload скорость | ~1-3s (сеть) | ~0.3-2s (кэш) |
| Cold start | ≈ одинаково | ≈ одинаково |
| Hot (основная нагрузка) | идентично | идентично |
| Масштабирование storagesvc | ✅ N реплик | ❌ ReadWriteOnce |
| Переезд кластера | ✅ просто бакет | ❌ надо мигрировать PV |
| Backup | ✅ встроен в S3 | ручной |
---
| Параметр | Значение | | Параметр | Значение |
|---|---| |---|---|
| Кластер | `kube5s.ru` | | Кластер | `kube5s.ru` |
-20
View File
@@ -1,20 +0,0 @@
MQ-триггер (MessageQueue) — запуск при появлении сообщения в очереди (Kafka, NATS, RabbitMQ и т.д.)
Kube-Watch триггер — запуск при изменении объектов в Kubernetes - а это разве может быть реализовано ?
В Fission — да, это штатные встроенные типы триггеров. Fission из коробки поддерживает MQ (Kafka, NATS, Azure Queue) и Kube-Watch через отдельные компоненты (kubewatcher, mqtrigger).
Но в вашей консоли это не реализовано — нет UI для создания/управления этими триггерами. Только HTTP и Cron. Если потребуется — можно добавить.
от что реально полезно и не очень сложно:
Для пользователя:
История вызовов — последние N вызовов функции: время, статус, время ответа. Сейчас вызвал и забыл.
Метрики — счётчик вызовов, средний latency, % ошибок. У вас уже есть metrics-collector — данные есть, нужен только UI.
Автообновление логов — сейчас нужно жать "Обновить" вручную. Можно автополлинг каждые 3-5 сек.
Копировать маршрут одной кнопкой — иконка 📋 рядом с маршрутом в таблице.
Статус функции — Ready/NotReady/Error прямо в таблице (сейчас не видно).
Для управления:
6. Клонировать функцию — создать копию с новым именем, сохранив код и настройки.
7. Экспорт/импорт — скачать функцию как zip, загрузить обратно.
8. Переменные окружения — env vars для функции через UI (сейчас нельзя задать).
+164
View File
@@ -0,0 +1,164 @@
# Handoff: admin-console — задание для DeepSeek
## Контекст
Репозиторий `fission-console`, ветка `restructure/repo-layout`.
Это **Fission Serverless Platform** — кастомная установка. Клиентская консоль (`client-console/`) уже готова и работает в проде. Нужно реализовать **admin-console** (`admin-console/`) — панель для оператора платформы (просмотр всех клиентов, использование ресурсов, данные для бухгалтерии).
---
## Что уже сделано (скелет)
```
admin-console/
cmd/server/main.go — точка входа, порт 8091, требует ADMIN_TOKEN
internal/api/server.go — роуты: /admin/api/namespaces, /usage, /users
internal/metrics/k8s.go — k8s-клиент (in-cluster), заглушки для реальных данных
internal/model/types.go — типы NamespaceInfo, UsageEntry, UserInfo
deploy/admin.yaml — K8s Deployment + ClusterRole (read-only всё)
ui/index.html — базовый UI (тёмная тема, 3 вкладки)
go.mod — module admin-console, зависит от k8s.io/client-go v0.31.0
```
---
## Что нужно реализовать
### 1. `internal/metrics/k8s.go` — подсчёт Fission CRD
Метод `ListClientNamespaces` должен для каждого namespace считать объекты через Dynamic client:
```go
import (
"k8s.io/client-go/dynamic"
"k8s.io/apimachinery/pkg/runtime/schema"
)
dynamicClient, _ := dynamic.NewForConfig(cfg)
gvr := schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "functions"}
list, err := dynamicClient.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{})
info.Functions = len(list.Items)
```
Аналогично для: `packages`, `environments`, `httptriggers`, `timetriggers`.
Сохрани `dynamicClient` в структуру `K8sClient` и используй его в обоих методах.
### 2. `internal/metrics/k8s.go` — S3-хранилище в `CollectUsage`
Поле `StorageGB` — читать из S3. Конфигурация через env:
- `S3_ENDPOINT` = `https://s3.msk-1.ngcloud.ru`
- `S3_BUCKET` = `sless-functions`
- `S3_ACCESS_KEY`, `S3_SECRET_KEY`
Использовать `github.com/aws/aws-sdk-go-v2`. Для каждого namespace prefix = `{namespace}/`, суммировать размер всех объектов.
```go
import (
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
awscfg "github.com/aws/aws-sdk-go-v2/config"
)
cfg, _ := awscfg.LoadDefaultConfig(ctx,
awscfg.WithRegion("ru-msk-1"),
awscfg.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
awscfg.WithEndpointResolverWithOptions(...), // custom endpoint
)
client := s3.NewFromConfig(cfg, func(o *s3.Options) { o.UsePathStyle = true })
paginator := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{
Bucket: aws.String(bucket),
Prefix: aws.String(namespace + "/"),
})
var totalBytes int64
for paginator.HasMorePages() {
page, _ := paginator.NextPage(ctx)
for _, obj := range page.Contents {
totalBytes += aws.ToInt64(obj.Size)
}
}
entry.StorageGB = float64(totalBytes) / (1024 * 1024 * 1024)
```
Поля `Invocations`, `CPUMilliCores`, `MemoryMB` — оставить 0 (TODO).
### 3. `go.mod` — добавить зависимости
```
github.com/aws/aws-sdk-go-v2 v1.30.0
github.com/aws/aws-sdk-go-v2/config v1.27.0
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.0
github.com/aws/aws-sdk-go-v2/credentials v1.17.0
```
Запустить `go mod tidy` после добавления.
### 4. `deploy/admin.yaml` — добавить env в контейнер
```yaml
env:
- name: ADMIN_TOKEN
valueFrom:
secretKeyRef:
name: fission-admin-token
key: token
- name: S3_ENDPOINT
value: "https://s3.msk-1.ngcloud.ru"
- name: S3_BUCKET
value: "sless-functions"
- name: S3_ACCESS_KEY
valueFrom:
secretKeyRef:
name: fission-s3-credentials
key: access_key
- name: S3_SECRET_KEY
valueFrom:
secretKeyRef:
name: fission-s3-credentials
key: secret_key
```
### 5. `ui/index.html` — доработать UI
- В таблице "Использование" показывать реальное поле `storage_gb` (уже есть в шаблоне).
- Добавить кнопку **"Экспорт CSV"** над таблицей Usage — генерировать CSV из текущих данных и скачивать через `Blob`:
```js
function exportCSV(rows) {
const header = 'Namespace,Email,Функции,Вызовы,Хранилище GB,Период\n';
const body = rows.map(u =>
[u.namespace, u.user_email, u.functions, u.invocations_total,
u.storage_gb.toFixed(3), `${u.period_start}${u.period_end}`].join(',')
).join('\n');
const blob = new Blob([header + body], { type: 'text/csv;charset=utf-8;' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `usage-${new Date().toISOString().slice(0,10)}.csv`;
a.click();
}
```
---
## Правила среды
- **Go version**: `1.25.0` (не менять в go.mod)
- **Образ**: `naeel/fission-admin-console:v0.1.0`
- **Build + push**: только через SSH на ВМ `naeel@5.172.178.213` (ключ у оператора)
- **Namespace деплоя**: `fission`
- **S3-секрет** `fission-s3-credentials` уже существует в namespace `fission`
- **Admin-токен** создать вручную перед деплоем:
```bash
kubectl create secret generic fission-admin-token -n fission --from-literal=token=<случайная_строка>
```
- Деплой: `kubectl apply -f ~/terra/fission/admin-console/deploy/admin.yaml`
## Что НЕ трогать
- `client-console/` — рабочий код в проде, **не трогать вообще**
- `helm/`, `terraform/`, `scripts/` — не трогать
- Любой файл вне `admin-console/` — без явного разрешения не трогать
+12
View File
@@ -0,0 +1,12 @@
FROM golang:1.26-alpine AS builder
WORKDIR /build
COPY . .
RUN go mod tidy
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o admin-console ./cmd/server/
FROM alpine:3.20
RUN apk add --no-cache ca-certificates
COPY --from=builder /build/admin-console /admin-console
COPY ui/ /ui/
EXPOSE 8091
ENTRYPOINT ["/admin-console"]
+27
View File
@@ -0,0 +1,27 @@
package main
import (
"log"
"net/http"
"os"
"admin-console/internal/api"
)
func main() {
adminToken := os.Getenv("ADMIN_TOKEN")
if adminToken == "" {
log.Fatal("ADMIN_TOKEN env variable is required")
}
srv := api.NewServer(adminToken)
mux := http.NewServeMux()
srv.RegisterRoutes(mux)
addr := ":8091"
log.Printf("admin-console starting on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("server error: %v", err)
}
}
+120
View File
@@ -0,0 +1,120 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: fission-admin-console
namespace: fission
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: fission-admin-console
rules:
# читать ВСЕ namespaces (для отображения клиентов)
- apiGroups: [""]
resources: ["namespaces"]
verbs: ["get", "list"]
# читать секреты в клиентских namespaces (для списка пользователей)
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list"]
# читать pods (для метрик потребления)
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
# читать Fission CRD
- apiGroups: ["fission.io"]
resources: ["environments", "packages", "functions", "httptriggers", "timetriggers"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: fission-admin-console
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: fission-admin-console
subjects:
- kind: ServiceAccount
name: fission-admin-console
namespace: fission
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: fission-admin-console
namespace: fission
spec:
replicas: 1
selector:
matchLabels:
app: fission-admin-console
template:
metadata:
labels:
app: fission-admin-console
spec:
serviceAccountName: fission-admin-console
containers:
- name: fission-admin-console
image: naeel/fission-admin-console:v0.1.1
imagePullPolicy: Always
ports:
- containerPort: 8091
env:
- name: ADMIN_TOKEN
valueFrom:
secretKeyRef:
name: fission-admin-token
key: token
- name: S3_ENDPOINT
value: "https://s3.msk-1.ngcloud.ru"
- name: S3_BUCKET
value: "sless-functions"
- name: S3_ACCESS_KEY
valueFrom:
secretKeyRef:
name: fission-s3-credentials
key: access_key
- name: S3_SECRET_KEY
valueFrom:
secretKeyRef:
name: fission-s3-credentials
key: secret_key
---
apiVersion: v1
kind: Service
metadata:
name: fission-admin-console
namespace: fission
spec:
selector:
app: fission-admin-console
ports:
- port: 8091
targetPort: 8091
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: fission-admin-console
namespace: fission
annotations:
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
spec:
ingressClassName: nginx
rules:
- host: fission.kube5s.ru
http:
paths:
- backend:
service:
name: fission-admin-console
port:
number: 8091
path: /admin
pathType: Prefix
tls:
- hosts:
- fission.kube5s.ru
secretName: fission-tls
+15
View File
@@ -0,0 +1,15 @@
module admin-console
go 1.25.0
require (
github.com/aws/aws-sdk-go-v2 v1.30.0
github.com/aws/aws-sdk-go-v2/config v1.27.0
github.com/aws/aws-sdk-go-v2/credentials v1.17.0
github.com/aws/aws-sdk-go-v2/service/s3 v1.58.0
k8s.io/api v0.31.0
k8s.io/apimachinery v0.31.0
k8s.io/client-go v0.31.0
)
+129
View File
@@ -0,0 +1,129 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strings"
"time"
"admin-console/internal/metrics"
"admin-console/internal/model"
)
// Server — HTTP-сервер admin-console.
type Server struct {
adminToken string
k8s *metrics.K8sClient
}
func NewServer(adminToken string) *Server {
k8s, err := metrics.NewK8sClient()
if err != nil {
// вне кластера — работаем без k8s (для локальной разработки)
k8s = nil
}
return &Server{adminToken: adminToken, k8s: k8s}
}
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
// статика UI
mux.Handle("/admin/", http.StripPrefix("/admin/", http.FileServer(http.Dir("ui"))))
// API
mux.HandleFunc("/admin/api/health", s.handleHealth)
mux.HandleFunc("/admin/api/namespaces", s.authMiddleware(s.handleNamespaces))
mux.HandleFunc("/admin/api/namespaces/", s.authMiddleware(s.handleNamespaceDetail))
mux.HandleFunc("/admin/api/usage", s.authMiddleware(s.handleUsage))
mux.HandleFunc("/admin/api/users", s.authMiddleware(s.handleUsers))
}
// authMiddleware проверяет Bearer-токен из заголовка Authorization.
func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != s.adminToken {
writeJSON(w, http.StatusUnauthorized, model.ErrResp("unauthorized"))
return
}
next(w, r)
}
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// handleNamespaces возвращает список клиентских namespace-ов (быстро, без счётчиков).
func (s *Server) handleNamespaces(w http.ResponseWriter, r *http.Request) {
if s.k8s == nil {
writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable"))
return
}
ns, err := s.k8s.ListClientNamespaces(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, model.ErrResp(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]any{"namespaces": ns})
}
// handleNamespaceDetail возвращает детальную информацию об одном namespace (с CRD-счётчиками).
func (s *Server) handleNamespaceDetail(w http.ResponseWriter, r *http.Request) {
if s.k8s == nil {
writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable"))
return
}
// URL: /admin/api/namespaces/{name}
name := strings.TrimPrefix(r.URL.Path, "/admin/api/namespaces/")
if name == "" {
writeJSON(w, http.StatusBadRequest, model.ErrResp("missing namespace name"))
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
info, err := s.k8s.GetNamespaceDetail(ctx, name)
if err != nil {
writeJSON(w, http.StatusNotFound, model.ErrResp(err.Error()))
return
}
writeJSON(w, http.StatusOK, info)
}
// handleUsage возвращает агрегированные метрики использования для бухгалтерии.
func (s *Server) handleUsage(w http.ResponseWriter, r *http.Request) {
if s.k8s == nil {
writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable"))
return
}
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
usage, err := s.k8s.CollectUsage(ctx)
if err != nil {
writeJSON(w, http.StatusInternalServerError, model.ErrResp(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]any{"usage": usage})
}
// handleUsers возвращает список пользователей из secrets в клиентских namespace-ах.
func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) {
if s.k8s == nil {
writeJSON(w, http.StatusServiceUnavailable, model.ErrResp("k8s unavailable"))
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
users, err := s.k8s.ListUsers(ctx)
if err != nil {
writeJSON(w, http.StatusInternalServerError, model.ErrResp(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]any{"users": users})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
+254
View File
@@ -0,0 +1,254 @@
package metrics
import (
"context"
"fmt"
"log"
"os"
"strings"
"sync"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"admin-console/internal/model"
)
// fission CRD group-version-resources.
var (
gvrFunctions = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "functions"}
gvrPackages = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "packages"}
gvrEnvironments = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "environments"}
gvrHTTPTriggers = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "httptriggers"}
gvrTimeTriggers = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "timetriggers"}
)
// K8sClient — обёртка над kubernetes.Clientset для нужд admin-console.
type K8sClient struct {
cs *kubernetes.Clientset
dc dynamic.Interface
s3 *s3.Client
s3Bucket string
}
// NewK8sClient создаёт клиента из in-cluster конфига, с fallback на ~/.kube/config.
func NewK8sClient() (*K8sClient, error) {
cfg, err := rest.InClusterConfig()
if err != nil {
cfg, err = clientcmd.BuildConfigFromFlags("", clientcmd.RecommendedHomeFile)
if err != nil {
return nil, fmt.Errorf("no k8s config: %w", err)
}
}
cs, err := kubernetes.NewForConfig(cfg)
if err != nil {
return nil, err
}
dc, err := dynamic.NewForConfig(cfg)
if err != nil {
return nil, fmt.Errorf("dynamic client: %w", err)
}
k := &K8sClient{cs: cs, dc: dc}
// S3 — опционально (не падаем если нет кредов)
k.initS3()
return k, nil
}
// initS3 создаёт S3-клиент из переменных окружения.
func (k *K8sClient) initS3() {
endpoint := os.Getenv("S3_ENDPOINT")
bucket := os.Getenv("S3_BUCKET")
accessKey := os.Getenv("S3_ACCESS_KEY")
secretKey := os.Getenv("S3_SECRET_KEY")
if endpoint == "" || bucket == "" || accessKey == "" || secretKey == "" {
log.Println("[admin-console] S3 env vars not set — storage metrics disabled")
return
}
cfg, err := config.LoadDefaultConfig(context.Background(),
config.WithRegion("ru-msk-1"),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
)
if err != nil {
log.Printf("[admin-console] S3 config error: %v", err)
return
}
k.s3 = s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(endpoint)
o.UsePathStyle = true
})
k.s3Bucket = bucket
log.Printf("[admin-console] S3 client ready, bucket=%s", bucket)
}
// ListClientNamespaces возвращает список namespace-ов БЕЗ CRD-счётчиков (быстро).
func (k *K8sClient) ListClientNamespaces(ctx context.Context) ([]model.NamespaceInfo, error) {
nsList, err := k.cs.CoreV1().Namespaces().List(ctx, metav1.ListOptions{
LabelSelector: "managed-by=fission-console",
})
if err != nil {
return nil, err
}
result := make([]model.NamespaceInfo, 0, len(nsList.Items))
for _, ns := range nsList.Items {
result = append(result, model.NamespaceInfo{
Name: ns.Name,
UserEmail: ns.Labels["user"],
CreatedAt: ns.CreationTimestamp.Format(time.RFC3339),
})
}
return result, nil
}
// GetNamespaceDetail возвращает информацию об одном namespace с CRD-счётчиками.
func (k *K8sClient) GetNamespaceDetail(ctx context.Context, nsName string) (*model.NamespaceInfo, error) {
ns, err := k.cs.CoreV1().Namespaces().Get(ctx, nsName, metav1.GetOptions{})
if err != nil {
return nil, err
}
info := &model.NamespaceInfo{
Name: ns.Name,
UserEmail: ns.Labels["user"],
CreatedAt: ns.CreationTimestamp.Format(time.RFC3339),
}
k.fillCRDCounts(ctx, nsName, info)
return info, nil
}
// fillCRDCounts заполняет счётчики Fission-ресурсов для одного namespace.
func (k *K8sClient) fillCRDCounts(ctx context.Context, ns string, info *model.NamespaceInfo) {
count := func(gvr schema.GroupVersionResource) int {
list, err := k.dc.Resource(gvr).Namespace(ns).List(ctx, metav1.ListOptions{Limit: 1})
if err != nil {
return 0
}
if rem := list.GetRemainingItemCount(); rem != nil {
return int(*rem) + len(list.Items)
}
return len(list.Items)
}
var wg sync.WaitGroup
wg.Add(5)
go func() { defer wg.Done(); info.Functions = count(gvrFunctions) }()
go func() { defer wg.Done(); info.Packages = count(gvrPackages) }()
go func() { defer wg.Done(); info.Environments = count(gvrEnvironments) }()
go func() { defer wg.Done(); info.HTTPTriggers = count(gvrHTTPTriggers) }()
go func() { defer wg.Done(); info.TimeTriggers = count(gvrTimeTriggers) }()
wg.Wait()
}
// CollectUsage возвращает агрегированную статистику по всем клиентским namespace-ам.
func (k *K8sClient) CollectUsage(ctx context.Context) ([]model.UsageEntry, error) {
nsList, err := k.cs.CoreV1().Namespaces().List(ctx, metav1.ListOptions{
LabelSelector: "managed-by=fission-console",
})
if err != nil {
return nil, err
}
now := time.Now().UTC()
startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
result := make([]model.UsageEntry, 0, len(nsList.Items))
for _, ns := range nsList.Items {
entry := model.UsageEntry{
Namespace: ns.Name,
UserEmail: ns.Labels["user"],
PeriodStart: startOfMonth.Format("2006-01-02"),
PeriodEnd: now.Format("2006-01-02"),
}
// Считаем функции через Dynamic client.
if list, err := k.dc.Resource(gvrFunctions).Namespace(ns.Name).List(ctx, metav1.ListOptions{Limit: 1}); err == nil {
entry.Functions = len(list.Items)
if rem := list.GetRemainingItemCount(); rem != nil {
entry.Functions += int(*rem)
}
}
// Считаем S3-хранилище.
entry.StorageGB = k.namespaceStorage(ctx, ns.Name)
// Invocations / CPU / Memory — TODO.
result = append(result, entry)
}
return result, nil
}
// namespaceStorage возвращает суммарный размер объектов S3 с префиксом "{ns}/".
func (k *K8sClient) namespaceStorage(ctx context.Context, ns string) float64 {
if k.s3 == nil {
return 0
}
var totalBytes int64
paginator := s3.NewListObjectsV2Paginator(k.s3, &s3.ListObjectsV2Input{
Bucket: aws.String(k.s3Bucket),
Prefix: aws.String(ns + "/"),
})
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
log.Printf("[admin-console] S3 list error ns=%s: %v", ns, err)
return 0
}
for _, obj := range page.Contents {
if obj.Size != nil {
totalBytes += *obj.Size
}
}
}
return float64(totalBytes) / (1024 * 1024 * 1024)
}
// ListUsers собирает пользователей из secret fission-console-users в каждом
// клиентском namespace.
func (k *K8sClient) ListUsers(ctx context.Context) ([]model.UserInfo, error) {
nsList, err := k.cs.CoreV1().Namespaces().List(ctx, metav1.ListOptions{
LabelSelector: "managed-by=fission-console",
})
if err != nil {
return nil, err
}
var users []model.UserInfo
for _, ns := range nsList.Items {
email := ns.Labels["user"]
if email == "" {
secret, err := k.cs.CoreV1().Secrets(ns.Name).Get(ctx, "fission-console-users", metav1.GetOptions{})
if err == nil {
for key := range secret.Data {
if strings.Contains(key, "@") {
email = key
break
}
}
}
}
users = append(users, model.UserInfo{
Email: email,
Namespace: ns.Name,
CreatedAt: ns.CreationTimestamp.Format(time.RFC3339),
})
}
return users, nil
}
+40
View File
@@ -0,0 +1,40 @@
package model
// ErrResp — стандартный ответ с ошибкой.
func ErrResp(msg string) map[string]string {
return map[string]string{"error": msg}
}
// NamespaceInfo — информация об одном клиентском namespace.
type NamespaceInfo struct {
Name string `json:"name"`
UserEmail string `json:"user_email,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
// счётчики ресурсов Fission
Functions int `json:"functions"`
Packages int `json:"packages"`
Environments int `json:"environments"`
HTTPTriggers int `json:"http_triggers"`
TimeTriggers int `json:"time_triggers"`
}
// UsageEntry — строка использования для одного namespace (для бухгалтерии).
type UsageEntry struct {
Namespace string `json:"namespace"`
UserEmail string `json:"user_email,omitempty"`
Functions int `json:"functions"`
Invocations int64 `json:"invocations_total"` // TODO: из Prometheus/logs
StorageGB float64 `json:"storage_gb"` // TODO: из storagesvc/S3
CPUMilliCores int64 `json:"cpu_millicores"` // TODO: из metrics-server
MemoryMB int64 `json:"memory_mb"` // TODO: из metrics-server
PeriodStart string `json:"period_start"`
PeriodEnd string `json:"period_end"`
}
// UserInfo — пользователь из secret fission-console-users.
type UserInfo struct {
Email string `json:"email"`
Namespace string `json:"namespace"`
CreatedAt string `json:"created_at,omitempty"`
}
+219
View File
@@ -0,0 +1,219 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fission Admin Console v0.1.1</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #0f1117; color: #e0e0e0; }
header { background: #1a1d27; padding: 16px 24px; border-bottom: 1px solid #2a2d3a;
display: flex; align-items: center; gap: 12px; }
header h1 { font-size: 18px; font-weight: 600; color: #fff; }
header .badge { background: #e53e3e; color: #fff; font-size: 10px; font-weight: 700;
padding: 2px 6px; border-radius: 4px; letter-spacing: .5px; }
#login { display: flex; align-items: center; justify-content: center; height: calc(100vh - 57px); }
#login form { background: #1a1d27; border: 1px solid #2a2d3a; border-radius: 8px;
padding: 32px; display: flex; flex-direction: column; gap: 12px; width: 320px; }
#login h2 { font-size: 16px; color: #fff; margin-bottom: 4px; }
input { background: #0f1117; border: 1px solid #2a2d3a; border-radius: 6px;
color: #e0e0e0; padding: 10px 12px; font-size: 14px; outline: none; }
input:focus { border-color: #4a90d9; }
button { background: #2563eb; color: #fff; border: none; border-radius: 6px;
padding: 10px; font-size: 14px; cursor: pointer; font-weight: 600; }
button:hover { background: #1d4ed8; }
#app { display: none; }
nav { background: #1a1d27; border-bottom: 1px solid #2a2d3a;
display: flex; gap: 4px; padding: 0 24px; }
nav button { background: none; border: none; color: #9ca3af; font-size: 14px;
padding: 12px 16px; cursor: pointer; border-bottom: 2px solid transparent; }
nav button.active { color: #fff; border-bottom-color: #2563eb; }
main { padding: 24px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { color: #6b7280; font-weight: 500; text-align: left; padding: 8px 12px;
border-bottom: 1px solid #2a2d3a; }
td { padding: 10px 12px; border-bottom: 1px solid #1a1d27; }
tr:hover td { background: #1a1d27; }
.pill { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; }
.pill-blue { background: #1e3a5f; color: #60a5fa; }
.err { color: #f87171; font-size: 13px; padding: 16px 0; }
.loader { color: #6b7280; font-size: 13px; padding: 16px 0; }
.cnt { color: #6b7280; font-size: 12px; }
.cnt.loading { animation: pulse 1.5s infinite; }
@keyframes pulse { 0%,100%{opacity:.3} 50%{opacity:1} }
</style>
</head>
<body>
<header>
<h1>Fission Admin Console</h1>
<span class="badge">ADMIN</span>
</header>
<div id="login">
<form id="loginForm">
<h2>Вход в Admin Console</h2>
<input type="password" id="tokenInput" placeholder="Admin token" autocomplete="current-password">
<button type="submit">Войти</button>
<div id="loginErr" class="err" style="display:none"></div>
</form>
</div>
<div id="app">
<nav>
<button class="active" onclick="showTab('namespaces', this)">Клиенты</button>
<button onclick="showTab('usage', this)">Использование</button>
<button onclick="showTab('users', this)">Пользователи</button>
</nav>
<main id="content"></main>
</div>
<script>
const API = '/admin/api';
let TOKEN = '';
let NS_LIST = [];
document.getElementById('loginForm').addEventListener('submit', async e => {
e.preventDefault();
TOKEN = document.getElementById('tokenInput').value.trim();
try {
const r = await fetch(`${API}/namespaces`, { headers: { Authorization: `Bearer ${TOKEN}` } });
if (r.ok) {
const data = await r.json();
NS_LIST = data.namespaces || [];
document.getElementById('login').style.display = 'none';
document.getElementById('app').style.display = 'block';
showTab('namespaces', document.querySelector('nav button'));
} else {
const err = document.getElementById('loginErr');
err.style.display = 'block';
err.textContent = 'Неверный токен';
}
} catch(e) {
const err = document.getElementById('loginErr');
err.style.display = 'block';
err.textContent = 'Ошибка соединения: ' + e.message;
}
});
function showTab(tab, btn) {
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
loadTab(tab);
}
async function loadTab(tab) {
const content = document.getElementById('content');
if (tab === 'namespaces') {
content.innerHTML = renderNamespaces(NS_LIST);
// загружаем CRD-счётчики асинхронно
loadAllDetails();
return;
}
content.innerHTML = '<p class="loader">Загрузка…</p>';
try {
const r = await fetch(`${API}/${tab}`, { headers: { Authorization: `Bearer ${TOKEN}` } });
const data = await r.json();
if (!r.ok) { content.innerHTML = `<p class="err">${data.error}</p>`; return; }
content.innerHTML = renderTab(tab, data);
} catch(e) {
content.innerHTML = `<p class="err">Ошибка: ${e.message}</p>`;
}
}
function renderTab(tab, data) {
if (tab === 'usage') return renderUsage(data.usage);
if (tab === 'users') return renderUsers(data.users);
return '';
}
function renderNamespaces(rows) {
if (!rows?.length) return '<p class="loader">Нет namespace-ов</p>';
return `<table>
<tr><th>Namespace</th><th>Email</th><th>Fn</th><th>Pkg</th><th>Env</th><th>HTTP</th><th>Time</th><th>Создан</th></tr>
${rows.map((n,i) => `<tr>
<td><span class="pill pill-blue">${n.name}</span></td>
<td>${n.user_email || '—'}</td>
<td class="cnt loading" id="ns${i}-fn">…</td>
<td class="cnt loading" id="ns${i}-pkg">…</td>
<td class="cnt loading" id="ns${i}-env">…</td>
<td class="cnt loading" id="ns${i}-http">…</td>
<td class="cnt loading" id="ns${i}-time">…</td>
<td>${n.created_at ? n.created_at.slice(0,10) : '—'}</td>
</tr>`).join('')}
</table>`;
}
async function loadAllDetails() {
for (let i = 0; i < NS_LIST.length; i++) {
loadDetail(i, NS_LIST[i].name);
}
}
async function loadDetail(idx, name) {
try {
const r = await fetch(`${API}/namespaces/${encodeURIComponent(name)}`, {
headers: { Authorization: `Bearer ${TOKEN}` }
});
if (!r.ok) return;
const d = await r.json();
setCnt(idx, 'fn', d.functions);
setCnt(idx, 'pkg', d.packages);
setCnt(idx, 'env', d.environments);
setCnt(idx, 'http', d.http_triggers);
setCnt(idx, 'time', d.time_triggers);
} catch(e) {}
}
function setCnt(idx, type, val) {
const el = document.getElementById(`ns${idx}-${type}`);
if (el) { el.textContent = val ?? 0; el.classList.remove('loading'); }
}
function renderUsage(rows) {
if (!rows?.length) return '<p class="loader">Нет данных</p>';
const btn = `<div style="margin-bottom:12px">
<button onclick="exportCSV()">📥 Экспорт CSV</button>
</div>`;
return btn + `<table>
<tr><th>Namespace</th><th>Email</th><th>Функции</th><th>Вызовы</th><th>Хранилище GB</th><th>Период</th></tr>
${rows.map(u => `<tr>
<td><span class="pill pill-blue">${u.namespace}</span></td>
<td>${u.user_email || '—'}</td>
<td>${u.functions}</td>
<td>${u.invocations_total}</td>
<td>${(u.storage_gb || 0).toFixed(3)}</td>
<td>${u.period_start}${u.period_end}</td>
</tr>`).join('')}
</table>`;
}
function exportCSV() {
const table = document.querySelector('table');
if (!table) return;
let csv = '\uFEFF';
table.querySelectorAll('tr').forEach(row => {
const cells = [];
row.querySelectorAll('th,td').forEach(c => cells.push('"' + c.textContent.replace(/"/g, '""') + '"'));
csv += cells.join(',') + '\n';
});
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `usage-${new Date().toISOString().slice(0,10)}.csv`;
a.click();
}
function renderUsers(rows) {
if (!rows?.length) return '<p class="loader">Нет пользователей</p>';
return `<table>
<tr><th>Email</th><th>Namespace</th><th>Создан</th></tr>
${rows.map(u => `<tr>
<td>${u.email || '—'}</td>
<td><span class="pill pill-blue">${u.namespace}</span></td>
<td>${u.created_at ? u.created_at.slice(0,10) : '—'}</td>
</tr>`).join('')}
</table>`;
}
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
#!/bin/bash
# Бенчмарк: upload, deploy, cold/hot start для простой функции
# Все результаты пишутся в test-results/bench-func
set -e
RESULTS_DIR="$(dirname "$0")/../test-results/bench-func"
FUNC_DIR="$(dirname "$0")"
FUNC_NAME="bench-hello"
ENV_NAME="bench-python-env"
NS="bench-func-$(date +%s)"
# Donor namespace с готовым RBAC
DONOR_NS=$(kubectl get ns --no-headers | awk '{print $1}' | grep -E '^fission-[a-f0-9]{16}$' | head -1)
LOGFILE="$RESULTS_DIR/bench_func_$(date +%Y-%m-%d_%H-%M-%S).log"
mkdir -p "$RESULTS_DIR"
{
echo "# Bench: $(date)"
echo "Namespace: $NS"
echo "Donor NS: $DONOR_NS"
} | tee "$LOGFILE"
# 1. Create namespace + copy RBAC from donor
echo -n "[1] Create namespace + RBAC... " | tee -a "$LOGFILE"
START_NS=$(date +%s%3N)
kubectl create ns "$NS" >/dev/null
# Копируем ServiceAccount-ы
for sa in fission-builder fission-fetcher; do
kubectl get sa "$sa" -n "$DONOR_NS" -o json 2>/dev/null \
| jq 'del(.metadata.resourceVersion,.metadata.uid,.metadata.creationTimestamp,.metadata.annotations,.metadata.ownerReferences) | .metadata.namespace = $ns' --arg ns "$NS" \
| kubectl apply -f - >/dev/null 2>&1 || true
done
# Копируем RoleBindings из donor
kubectl get rolebinding -n "$DONOR_NS" -o json 2>/dev/null \
| jq -r '.items[] | select(.roleRef.kind=="ClusterRole") | del(.metadata.resourceVersion,.metadata.uid,.metadata.creationTimestamp,.metadata.annotations,.metadata.ownerReferences) | .metadata.namespace = $ns' --arg ns "$NS" \
| kubectl apply -f - >/dev/null 2>&1 || true
END_NS=$(date +%s%3N)
echo "$((END_NS-START_NS)) ms" | tee -a "$LOGFILE"
# 2. Create environment
echo -n "[2] Create environment... " | tee -a "$LOGFILE"
START_ENV=$(date +%s%3N)
fission env create --name "$ENV_NAME" --image naeel/fission-python-env:v1.1 --namespace "$NS" >/dev/null 2>&1
END_ENV=$(date +%s%3N)
echo "$((END_ENV-START_ENV)) ms" | tee -a "$LOGFILE"
# 3. Upload function
echo -n "[3] Upload function... " | tee -a "$LOGFILE"
START_UP=$(date +%s%3N)
fission fn create --name "$FUNC_NAME" --env "$ENV_NAME" --code "$FUNC_DIR/hello.py" --namespace "$NS" >/dev/null 2>&1
END_UP=$(date +%s%3N)
echo "$((END_UP-START_UP)) ms" | tee -a "$LOGFILE"
# 4. Cold start invoke (retry до 60 раз по 2 сек = 2 мин максимум)
echo -n "[4] Cold start invoke... " | tee -a "$LOGFILE"
START_COLD=$(date +%s%3N)
COLD_OK=0
for i in {1..60}; do
RESULT=$(fission fn test --name "$FUNC_NAME" --namespace "$NS" 2>&1 || true)
if echo "$RESULT" | grep -qE "hello|200|statusCode"; then
COLD_OK=1
break
fi
sleep 2
done
END_COLD=$(date +%s%3N)
if [[ $COLD_OK -eq 1 ]]; then
echo "$((END_COLD-START_COLD)) ms" | tee -a "$LOGFILE"
else
echo "FAILED" | tee -a "$LOGFILE"
{
echo "[DEBUG] pods in $NS:"
kubectl get pods -n "$NS" 2>&1
echo "[DEBUG] events in $NS:"
kubectl get events -n "$NS" --sort-by=.metadata.creationTimestamp 2>&1 | tail -20
} >> "$LOGFILE"
exit 1
fi
# 5. Hot start invoke (pod уже прогрет, retry до 5 раз)
echo -n "[5] Hot start invoke... " | tee -a "$LOGFILE"
START_HOT=$(date +%s%3N)
HOT_OK=0
for i in {1..5}; do
RESULT=$(fission fn test --name "$FUNC_NAME" --namespace "$NS" 2>&1 || true)
if echo "$RESULT" | grep -qE "hello|200|statusCode"; then
HOT_OK=1; break
fi
sleep 2
done
END_HOT=$(date +%s%3N)
if [[ $HOT_OK -eq 1 ]]; then
echo "$((END_HOT-START_HOT)) ms" | tee -a "$LOGFILE"
else
echo "FAILED (pod не ответил)" | tee -a "$LOGFILE"
fi
# 6. Hot x5
echo "[6] Hot start x5:" | tee -a "$LOGFILE"
for i in {1..5}; do
START_H=$(date +%s%3N)
RESULT=$(fission fn test --name "$FUNC_NAME" --namespace "$NS" 2>&1 || true)
END_H=$(date +%s%3N)
if echo "$RESULT" | grep -qE "hello|200|statusCode"; then
echo " invoke #$i: $((END_H-START_H)) ms" | tee -a "$LOGFILE"
else
echo " invoke #$i: FAILED" | tee -a "$LOGFILE"
fi
done
echo "---" | tee -a "$LOGFILE"
echo "DONE. Log: $LOGFILE"
+263
View File
@@ -0,0 +1,263 @@
#!/usr/bin/env bash
# diag_bugs.sh — диагностика двух аномалий из bench_full run=9248232
# BUG-1: php/medium + php/large COLD-TIMEOUT (гипотеза: arrow functions PHP 7.4+)
# BUG-2: ruby/medium cold=80451ms (гипотеза: poolmgr eviction — проверяем воспроизводимость)
set -uo pipefail
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
ROOT_DIR=$(cd "${SCRIPT_DIR}/.." && pwd)
source "${ROOT_DIR}/scripts/lib.sh"
SUB="bench@bench.local"
RUNID="diag$(date +%s | tail -c 6)"
TMP=$(mktemp -d)
TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S)
RESULTS_DIR="${ROOT_DIR}/test-results/bench-func"
LOGFILE="${RESULTS_DIR}/diag_bugs_${TIMESTAMP}.log"
mkdir -p "$RESULTS_DIR"
declare -a ALL_FNS=()
PASS_CNT=0
FAIL_CNT=0
cleanup() {
rm -rf "$TMP" 2>/dev/null || true
for fn in "${ALL_FNS[@]:-}"; do
curl -s -X DELETE "${BASE}/functions/${fn}" \
-H "X-Auth-Token: ${SUB}" >/dev/null 2>&1 || true
done
}
trap cleanup EXIT
log() { echo "$@" | tee -a "$LOGFILE"; }
escape_code() { python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'; }
get_status() {
python3 -c "import json,sys
try: d=json.load(sys.stdin); print(d.get('status',''))
except: print('')" 2>/dev/null
}
get_field() {
local f="$1"
python3 -c "import json,sys
try: d=json.load(sys.stdin); print(d.get('$f',''))
except: print('')" 2>/dev/null
}
log "════════════════════════════════════════════════════════════════"
log " diag_bugs.sh run=${RUNID} $(date)"
log " API: ${BASE} SUB: ${SUB}"
log "════════════════════════════════════════════════════════════════"
# ═══════════════════════════════════════════════════════════════════
# Вспомогательная: загрузить функцию, сделать cold+hot invoke, вернуть результат
# bench_one LABEL LANG CODEFILE [EXPECTED_STR]
# ═══════════════════════════════════════════════════════════════════
bench_one() {
local label="$1" lang="$2" codefile="$3" expected="${4:-}"
local fn="bench-${label}-${RUNID}"
ALL_FNS+=("$fn")
# Upload
local code_esc t0 t1 R CODE BODY UPLOAD_MS
code_esc=$(cat "$codefile" | escape_code)
t0=$(date +%s%3N)
R=$(curl -s -w "\n%{http_code}" -X POST "${BASE}/functions" \
-H "X-Auth-Token: ${SUB}" -H "Content-Type: application/json" \
-d "{\"name\":\"${fn}\",\"language\":\"${lang}\",\"code\":${code_esc}}" 2>/dev/null)
t1=$(date +%s%3N)
CODE=$(echo "$R" | tail -1)
BODY=$(echo "$R" | head -n -1)
UPLOAD_MS=$((t1-t0))
if [ "$CODE" != "201" ]; then
log " [${label}] UPLOAD FAIL HTTP=${CODE}: $(echo "$BODY" | head -c 200)"
FAIL_CNT=$((FAIL_CNT+1))
return 1
fi
log " [${label}] upload OK ${UPLOAD_MS}ms"
# Cold start (retry 20×5s)
local tc0 COLD_MS=0 cold_ok=0 cold_body=""
tc0=$(date +%s%3N)
for i in $(seq 1 20); do
local resp s raw
resp=$(curl -s --max-time 35 -X POST "${BASE}/functions/${fn}/invoke" \
-H "X-Auth-Token: ${SUB}" -H "Content-Type: application/json" -d '{}' 2>/dev/null)
s=$(echo "$resp" | get_status)
raw=$(echo "$resp" | get_field "response_raw")
if [ "$s" = "200" ]; then
if [ -z "$expected" ] || echo "$raw" | grep -qF "$expected"; then
local tc1; tc1=$(date +%s%3N)
COLD_MS=$((tc1-tc0))
cold_ok=1
cold_body="$raw"
break
fi
fi
sleep 5
done
if [ $cold_ok -eq 0 ]; then
log " [${label}] COLD TIMEOUT"
FAIL_CNT=$((FAIL_CNT+1))
return 1
fi
# Hot ×3
local HOT_TOTAL=0 hot_ok=0
for i in 1 2 3; do
local th0 th1 resp s
th0=$(date +%s%3N)
resp=$(curl -s --max-time 35 -X POST "${BASE}/functions/${fn}/invoke" \
-H "X-Auth-Token: ${SUB}" -H "Content-Type: application/json" -d '{}' 2>/dev/null)
th1=$(date +%s%3N)
s=$(echo "$resp" | get_status)
[ "$s" = "200" ] && { HOT_TOTAL=$((HOT_TOTAL+th1-th0)); hot_ok=$((hot_ok+1)); }
done
local HOT_AVG=0; [ $hot_ok -gt 0 ] && HOT_AVG=$((HOT_TOTAL/hot_ok))
log " [${label}] OK cold=${COLD_MS}ms hot_avg=${HOT_AVG}ms (${hot_ok}/3)"
[ -n "$cold_body" ] && log " [${label}] body=$(echo "$cold_body" | head -c 120)"
PASS_CNT=$((PASS_CNT+1))
}
# ═══════════════════════════════════════════════════════════════════
# BUG-1: PHP arrow functions
# ═══════════════════════════════════════════════════════════════════
log ""
log "┌─────────────────────────────────────────────────────────────────┐"
log "│ BUG-1: PHP arrow functions (fn()=> требует PHP 7.4+) │"
log "└─────────────────────────────────────────────────────────────────┘"
# Тест 1а: минимальный PHP с arrow function — поддерживается?
cat > "${TMP}/php_arrow_min.php" << 'EOF'
<?php
function main($event, $context) {
$nums = [1,2,3,4,5];
$doubled = array_map(fn($x) => $x * 2, $nums);
return "arrow-ok:" . implode(",", $doubled);
}
EOF
log ""
log " [php-arrow-min] Тест: arrow function fn(\$x)=>\$x*2 (PHP 7.4+)"
bench_one "php-arrow-min" "php" "${TMP}/php_arrow_min.php" "arrow-ok"
# Тест 1б: PHP version info через phpinfo-like
cat > "${TMP}/php_version.php" << 'EOF'
<?php
function main($event, $context) {
return "php-version:" . PHP_VERSION . " php-major:" . PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;
}
EOF
log ""
log " [php-version] Тест: получить версию PHP runtime"
bench_one "php-version" "php" "${TMP}/php_version.php" "php-version"
# Тест 1в: PHP medium без arrow functions (обычные function)
cat > "${TMP}/php_medium_noarrow.php" << 'EOF'
<?php
function fib($n){$a=0;$b=1;for($i=0;$i<$n;$i++)[$a,$b]=[$b,$a+$b];return $a;}
function sieve($n){$s=array_fill(0,$n+1,true);$s[0]=$s[1]=false;for($i=2;$i*$i<=$n;$i++)if($s[$i])for($j=$i*$i;$j<=$n;$j+=$i)$s[$j]=false;return array_keys(array_filter($s));}
function sqrtSum($n){$s=0;for($i=1;$i<=$n;$i++)$s+=sqrt($i);return $s;}
function bubbleSort($arr){$n=count($arr);for($i=0;$i<$n;$i++)for($j=0;$j<$n-$i-1;$j++)if($arr[$j]>$arr[$j+1]){$t=$arr[$j];$arr[$j]=$arr[$j+1];$arr[$j+1]=$t;}return $arr;}
function matMul($a,$b){$ra=count($a);$ca=count($a[0]);$cb=count($b[0]);$c=array_fill(0,$ra,array_fill(0,$cb,0));for($i=0;$i<$ra;$i++)for($j=0;$j<$cb;$j++)for($k=0;$k<$ca;$k++)$c[$i][$j]+=$a[$i][$k]*$b[$k][$j];return $c;}
function processWordsNoArrow($words){
$out=[];
foreach($words as $s){
if(strlen($s)>3) $out[]=strrev(strtoupper($s));
}
return $out;
}
function statsArrNoArrow($arr){
$n=count($arr);$m=array_sum($arr)/$n;
$v=0; foreach($arr as $x) $v+=($x-$m)*($x-$m); $v/=$n;
return['n'=>$n,'mean'=>round($m,4),'std'=>round(sqrt($v),4)];
}
function main($event,$context){
$t0=microtime(true);
$words=["alpha","beta","gamma","delta","epsilon","zeta","eta","theta","iota","kappa","lambda","mu"];
$f=fib(35);$primes=sieve(500);$s=sqrtSum(1000);
$nums=range(50,1);$sorted=bubbleSort($nums);
$a=[[1,2,3],[4,5,6]];$b=[[7,8],[9,10],[11,12]];$c=matMul($a,$b);
$pw=processWordsNoArrow($words);$st=statsArrNoArrow($primes);
$h=substr(hash('sha256','bench-medium-php'),0,16);
$elapsed=microtime(true)-$t0;
return json_encode(['tag'=>'bench-php-medium-noarrow-ok','fib35'=>$f,'primes'=>count($primes),
'sqrt'=>round($s,1),'sorted0'=>$sorted[0],'mat_c00'=>$c[0][0],
'words'=>count($pw),'stats'=>$st,'hash'=>$h,'elapsed'=>round($elapsed,5)]);
}
EOF
log ""
log " [php-medium-noarrow] PHP medium логика без arrow functions (foreach вместо fn()=>)"
bench_one "php-med-noarr" "php" "${TMP}/php_medium_noarrow.php" "bench-php-medium-noarrow-ok"
# ═══════════════════════════════════════════════════════════════════
# BUG-2: ruby/medium cold=80451ms — воспроизводимость
# ═══════════════════════════════════════════════════════════════════
log ""
log "┌─────────────────────────────────────────────────────────────────┐"
log "│ BUG-2: ruby/medium cold=80451ms — воспроизводимость (×3) │"
log "└─────────────────────────────────────────────────────────────────┘"
cat > "${TMP}/ruby_medium_diag.rb" << 'EOF'
require 'json'
require 'digest'
WORDS=%w[alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega]
def fib(n); a,b=0,1; n.times{a,b=b,a+b}; a; end
def sieve(n)
s=Array.new(n+1,true); s[0]=s[1]=false
(2..Math.sqrt(n).to_i).each{|i|(i*i..n).step(i){|j|s[j]=false} if s[i]}
(0..n).select{|i|s[i]}
end
def sqrt_sum(n); (1..n).sum{|i|Math.sqrt(i)}; end
def bubble_sort(arr)
arr=arr.dup; n=arr.length
n.times{(n-1).times{|j|arr[j],arr[j+1]=arr[j+1],arr[j] if arr[j]>arr[j+1]}}
arr
end
def mat_mul(a,b)
ra,ca,cb=a.length,a[0].length,b[0].length
Array.new(ra){|i|Array.new(cb){|j|(0...ca).sum{|k|a[i][k]*b[k][j]}}}
end
def process_words(w); w.select{|s|s.length>3}.map{|s|s.upcase.reverse}; end
def get_stats(arr)
n=arr.length; m=arr.sum.to_f/n; v=arr.sum{|x|(x-m)**2}/n
{n:n, mean:m.round(4), std:Math.sqrt(v).round(4)}
end
def handler(ctx)
t0=Time.now
f=fib(35); primes=sieve(500); s=sqrt_sum(1000)
nums=(1..50).to_a.reverse; sorted=bubble_sort(nums)
a=[[1,2,3],[4,5,6]]; b=[[7,8],[9,10],[11,12]]; c=mat_mul(a,b)
pw=process_words(WORDS); st=get_stats(primes)
h=Digest::SHA256.hexdigest('bench-medium-ruby')[0,16]
elapsed=Time.now-t0
{ statusCode: 200, body: JSON.generate({tag:"bench-ruby-medium-ok", fib35:f, primes:primes.length,
sqrt:s.round(1), sorted0:sorted[0], mat_c00:c[0][0],
words:pw.length, stats:st, hash:h, elapsed:elapsed.round(5)}) }
end
EOF
log ""
log " ruby/medium × 3 независимых функции (разные имена → чистый cold start каждый раз)"
for attempt in 1 2 3; do
log ""
log " --- Попытка ${attempt}/3 ---"
bench_one "ruby-med-${attempt}" "ruby" "${TMP}/ruby_medium_diag.rb" "bench-ruby-medium-ok"
done
# ═══════════════════════════════════════════════════════════════════
# ИТОГ
# ═══════════════════════════════════════════════════════════════════
log ""
log "════════════════════════════════════════════════════════════════"
log " ИТОГ: PASS=${PASS_CNT} FAIL=${FAIL_CNT} TOTAL=$((PASS_CNT+FAIL_CNT))"
log " Лог: ${LOGFILE}"
log " Готово: $(date)"
log "════════════════════════════════════════════════════════════════"
+5
View File
@@ -0,0 +1,5 @@
def main(event, context):
return {
"statusCode": 200,
"body": "hello, world!"
}
@@ -13,6 +13,7 @@ import (
"fission-console/internal/api" "fission-console/internal/api"
"fission-console/internal/auth" "fission-console/internal/auth"
"fission-console/internal/billing" "fission-console/internal/billing"
"fission-console/internal/stats"
"k8s.io/client-go/dynamic" "k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes"
@@ -50,6 +51,7 @@ func main() {
} }
billingStore := billing.NewStore() billingStore := billing.NewStore()
statsProvider := stats.NewProvider()
srv := api.NewServer(api.Config{ srv := api.NewServer(api.Config{
Dyn: dyn, Dyn: dyn,
@@ -68,6 +70,7 @@ func main() {
LLMKey: os.Getenv("FISSION_LLM_KEY"), LLMKey: os.Getenv("FISSION_LLM_KEY"),
// --- end ai/ask feature --- // --- end ai/ask feature ---
Billing: billingStore, Billing: billingStore,
Stats: statsProvider,
}) })
// Запускаем фоновые горутины: reaper истёкших функций // Запускаем фоновые горутины: reaper истёкших функций
@@ -15,9 +15,12 @@ rules:
- apiGroups: [""] - apiGroups: [""]
resources: ["pods/log"] resources: ["pods/log"]
verbs: ["get"] verbs: ["get"]
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "create", "delete"]
- apiGroups: ["apps"] - apiGroups: ["apps"]
resources: ["deployments"] resources: ["deployments"]
verbs: ["get", "list", "update", "patch"] verbs: ["get", "list", "create", "update", "patch", "delete"]
- apiGroups: ["fission.io"] - apiGroups: ["fission.io"]
resources: ["environments", "packages", "functions", "httptriggers", "timetriggers"] resources: ["environments", "packages", "functions", "httptriggers", "timetriggers"]
verbs: ["get", "list", "create", "update", "patch", "delete"] verbs: ["get", "list", "create", "update", "patch", "delete"]
@@ -55,7 +58,7 @@ spec:
serviceAccountName: fission-console serviceAccountName: fission-console
containers: containers:
- name: console - name: console
image: naeel/fission-console:v1.3.86 image: naeel/fission-console:v1.3.96
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
- containerPort: 8090 - containerPort: 8090
@@ -84,6 +87,14 @@ spec:
value: "http://storagesvc.fission.svc.cluster.local" value: "http://storagesvc.fission.svc.cluster.local"
- name: BILLING_DSN - name: BILLING_DSN
value: "postgres://super:BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif@postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local:5432/sqsdb" value: "postgres://super:BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif@postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local:5432/sqsdb"
- name: GRAFANA_INTERNAL_URL
value: "http://grafana.grafana.svc.cluster.local:3000"
- name: GRAFANA_PUBLIC_URL
value: "https://fission.kube5s.ru/grafana"
- name: GRAFANA_ADMIN_USER
value: "admin"
- name: GRAFANA_ADMIN_PASS
value: "GrafanaAdmin2026!"
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /health path: /health
@@ -13,6 +13,7 @@ import (
"strings" "strings"
"testing" "testing"
"fission-console/internal/billing"
"fission-console/internal/fission" "fission-console/internal/fission"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -51,6 +52,7 @@ func newCloneTestServer(t *testing.T, storagesvcHandler http.HandlerFunc, objs .
dyn: dynClient, dyn: dynClient,
ns: "fission-test", ns: "fission-test",
http: httpClient, http: httpClient,
billing: billing.NoopStore{},
} }
return s, storagesvc return s, storagesvc
} }
@@ -35,17 +35,26 @@ import (
// buildDeployArchive упаковывает исходный код в байты для deployment Package. // buildDeployArchive упаковывает исходный код в байты для deployment Package.
// Для nodejs — ESM-обёртка (package.json + main.js). // Для nodejs — ESM-обёртка (package.json + main.js).
// Для php/ruby — zip с одним файлом скрипта. // Для php/ruby — zip с одним файлом скрипта.
// Для остальных (python) — raw bytes кода. // Для остальных (python) — raw bytes кода (или zip если есть deps).
func buildDeployArchive(lang, code string) ([]byte, error) { // deps — содержимое файла зависимостей (requirements.txt, Gemfile, composer.json).
// Если deps пустой — поведение как раньше.
func buildDeployArchive(lang, code, deps string) ([]byte, error) {
switch lang { switch lang {
case "nodejs": case "nodejs":
// TODO: поддержка package.json с deps для nodejs — пока игнорируем deps
return runtime.BuildJSDeployZip(code) return runtime.BuildJSDeployZip(code)
case "php": case "php":
if deps != "" {
return runtime.BuildScriptZipWithDeps(code, "main.php", deps, "composer.json")
}
return runtime.BuildScriptZip(code, "main.php") return runtime.BuildScriptZip(code, "main.php")
case "ruby": case "ruby":
if deps != "" {
return runtime.BuildScriptZipWithDeps(code, "handler.rb", deps, "Gemfile")
}
return runtime.BuildScriptZip(code, "handler.rb") return runtime.BuildScriptZip(code, "handler.rb")
default: default: // python
return []byte(code), nil return runtime.BuildPythonZip(code, deps)
} }
} }
@@ -173,7 +182,7 @@ func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
"buildcommand": "build", "buildcommand": "build",
} }
} else { } else {
deployBytes, archiveErr := buildDeployArchive(req.Language, req.Code) deployBytes, archiveErr := buildDeployArchive(req.Language, req.Code, req.Deps)
if archiveErr != nil { if archiveErr != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", req.Language, archiveErr)) writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", req.Language, archiveErr))
return return
@@ -205,6 +214,9 @@ func (s *Server) handleCreateFunction(w http.ResponseWriter, r *http.Request) {
now := time.Now().UTC() now := time.Now().UTC()
fnAnnotations[functionCreatedAtAnnotation] = now.Format(time.RFC3339) fnAnnotations[functionCreatedAtAnnotation] = now.Format(time.RFC3339)
fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339) fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339)
if req.Deps != "" {
fnAnnotations["fission-console/deps"] = req.Deps
}
if req.TTL != "" { if req.TTL != "" {
expiresAt, ttlErr := parseTTL(req.TTL) expiresAt, ttlErr := parseTTL(req.TTL)
if ttlErr != nil { if ttlErr != nil {
@@ -329,7 +341,7 @@ func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request
// Определяем язык из аннотации — нужен для правильной упаковки // Определяем язык из аннотации — нужен для правильной упаковки
lang, _, _ := unstructured.NestedString(fn.Object, "metadata", "annotations", "fission-console/language") lang, _, _ := unstructured.NestedString(fn.Object, "metadata", "annotations", "fission-console/language")
deployBytes, archiveErr := buildDeployArchive(lang, req.Code) deployBytes, archiveErr := buildDeployArchive(lang, req.Code, req.Deps)
if archiveErr != nil { if archiveErr != nil {
writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", lang, archiveErr)) writeJSONError(w, http.StatusInternalServerError, fmt.Sprintf("build %s archive: %v", lang, archiveErr))
return return
@@ -393,6 +405,11 @@ func (s *Server) handleUpdateFunctionCode(w http.ResponseWriter, r *http.Request
} }
fnAnnotations[functionCreatedAtAnnotation] = createdAt.UTC().Format(time.RFC3339) fnAnnotations[functionCreatedAtAnnotation] = createdAt.UTC().Format(time.RFC3339)
fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339) fnAnnotations[functionUpdatedAtAnnotation] = now.Format(time.RFC3339)
if req.Deps != "" {
fnAnnotations["fission-console/deps"] = req.Deps
} else {
delete(fnAnnotations, "fission-console/deps")
}
fn.SetAnnotations(fnAnnotations) fn.SetAnnotations(fnAnnotations)
if err := unstructured.SetNestedField(fn.Object, map[string]any{ if err := unstructured.SetNestedField(fn.Object, map[string]any{
"name": newPkgName, "name": newPkgName,
@@ -83,6 +83,7 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name
// Читаем source-type аннотацию (code / archive) // Читаем source-type аннотацию (code / archive)
sourceType := "code" sourceType := "code"
archiveFilename := "" archiveFilename := ""
deps := ""
if ann := fn.GetAnnotations(); ann != nil { if ann := fn.GetAnnotations(); ann != nil {
if v := ann[fissionSourceTypeAnnotation]; v != "" { if v := ann[fissionSourceTypeAnnotation]; v != "" {
sourceType = v sourceType = v
@@ -90,6 +91,9 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name
if v := ann["fission-console/archive-filename"]; v != "" { if v := ann["fission-console/archive-filename"]; v != "" {
archiveFilename = v archiveFilename = v
} }
if v := ann["fission-console/deps"]; v != "" {
deps = v
}
} }
writeAnyJSON(w, http.StatusOK, map[string]any{ writeAnyJSON(w, http.StatusOK, map[string]any{
@@ -102,6 +106,7 @@ func (s *Server) handleGetFunction(w http.ResponseWriter, r *http.Request, name
"created_at": functionTimestampResponse(fn)["created_at"], "created_at": functionTimestampResponse(fn)["created_at"],
"updated_at": functionTimestampResponse(fn)["updated_at"], "updated_at": functionTimestampResponse(fn)["updated_at"],
"code": code, "code": code,
"deps": deps,
"source_type": sourceType, "source_type": sourceType,
"archive_filename": archiveFilename, "archive_filename": archiveFilename,
"route": route, "route": route,
@@ -181,6 +181,13 @@ func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) {
fmt.Printf("handleAuth: ensureUserNS %s: %v\n", ns, ensureErr) fmt.Printf("handleAuth: ensureUserNS %s: %v\n", ns, ensureErr)
} }
// Провизируем Grafana Org для этого namespace (fire-and-forget, идемпотентно).
go func() {
if err := s.stats.EnsureOrgForNamespace(context.Background(), ns, identity.Email); err != nil {
fmt.Printf("handleAuth: EnsureOrgForNamespace %s: %v\n", ns, err)
}
}()
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "env": env, "namespace": ns, "email": identity.Email}) _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "env": env, "namespace": ns, "email": identity.Email})
} }
+241
View File
@@ -0,0 +1,241 @@
package api
// kwtriggers.go — CRUD хендлеры для KubernetesWatchTrigger (Fission KW Trigger).
//
// РЕШЕНИЕ ПО АРХИТЕКТУРЕ (2026-05-11):
// KubernetesWatchTrigger позволяет вызывать функцию при изменении K8s объектов.
// spec.type — тип ресурса: Pod, Service, Deployment, ConfigMap, и т.д.
// spec.namespace — namespace для слежения (по умолчанию = namespace пользователя)
// spec.labelselector — label selector в формате "key=value,key2=value2"
// spec.functionref — ссылка на функцию
//
// ОСОБЕННОСТИ:
// - Fission kubewatcher компонент должен быть задеплоен.
// - namespace в spec — это WATCHED namespace (не namespace триггера).
// Для безопасности ограничиваем: только namespace пользователя или пустое (тогда = userNS).
// - labelselector опционален, "" = смотрим на все ресурсы типа resourceType в namespace.
//
// ОШИБКИ В ПРОЦЕССЕ:
// - (нет, первая реализация)
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"fission-console/internal/fission"
"fission-console/internal/model"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
// validKWResourceTypes — поддерживаемые типы ресурсов для слежения.
// Расширяемо — это не ограничение CRD, просто UI-валидация.
var validKWResourceTypes = map[string]struct{}{
"pod": {},
"service": {},
"deployment": {},
"configmap": {},
"secret": {},
"namespace": {},
"replicaset": {},
"statefulset": {},
"daemonset": {},
"job": {},
}
func (s *Server) handleKWTriggersRoot(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.handleList(fission.KWTrigGVR)(w, r)
case http.MethodPost:
s.handleCreateKWTrigger(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleKWTriggersAction(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/console/api/kwtriggers/")
path = strings.TrimPrefix(path, "/api/kwtriggers/")
name := strings.Trim(path, "/")
if name == "" || strings.Contains(name, "/") {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
s.handleGetKWTrigger(w, r, name)
case http.MethodDelete:
s.handleDeleteKWTrigger(w, r, name)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleCreateKWTrigger(w http.ResponseWriter, r *http.Request) {
var req model.CreateKWTriggerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
return
}
ns := s.userNS(r)
// Если namespace не задан — используем namespace пользователя
if strings.TrimSpace(req.Namespace) == "" {
req.Namespace = ns
}
// Безопасность: нельзя смотреть за чужим namespace
if req.Namespace != ns {
writeJSONError(w, http.StatusForbidden, "can only watch your own namespace")
return
}
if err := validateKWTriggerRequest(req); err != nil {
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
// Проверяем что функция существует
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, req.FunctionName, metav1.GetOptions{}); err != nil {
if apierrors.IsNotFound(err) {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("function %q not found", req.FunctionName))
return
}
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function: %v", err))
return
}
obj := buildKWTriggerObject(ns, req)
created, err := s.dyn.Resource(fission.KWTrigGVR).Namespace(ns).Create(ctx, obj, metav1.CreateOptions{})
if err != nil {
if apierrors.IsAlreadyExists(err) {
writeJSONError(w, http.StatusConflict, fmt.Sprintf("kwtrigger %q already exists", req.Name))
return
}
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create kwtrigger: %v", err))
return
}
writeAnyJSON(w, http.StatusCreated, kwTriggerResponse(created))
}
func (s *Server) handleGetKWTrigger(w http.ResponseWriter, r *http.Request, name string) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
obj, err := s.dyn.Resource(fission.KWTrigGVR).Namespace(s.userNS(r)).Get(ctx, name, metav1.GetOptions{})
if err != nil {
status := http.StatusBadGateway
if apierrors.IsNotFound(err) {
status = http.StatusNotFound
}
writeJSONError(w, status, fmt.Sprintf("get kwtrigger %q: %v", name, err))
return
}
writeAnyJSON(w, http.StatusOK, kwTriggerResponse(obj))
}
func (s *Server) handleDeleteKWTrigger(w http.ResponseWriter, r *http.Request, name string) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := s.dyn.Resource(fission.KWTrigGVR).Namespace(s.userNS(r)).Delete(ctx, name, metav1.DeleteOptions{}); err != nil {
status := http.StatusBadGateway
if apierrors.IsNotFound(err) {
status = http.StatusNotFound
}
writeJSONError(w, status, fmt.Sprintf("delete kwtrigger %q: %v", name, err))
return
}
writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name})
}
// --- Вспомогательные функции ---
func validateKWTriggerRequest(req model.CreateKWTriggerRequest) error {
if strings.TrimSpace(req.Name) == "" {
return fmt.Errorf("name is required")
}
if strings.TrimSpace(req.FunctionName) == "" {
return fmt.Errorf("functionName is required")
}
rt := strings.ToLower(strings.TrimSpace(req.ResourceType))
if _, ok := validKWResourceTypes[rt]; !ok {
return fmt.Errorf("resourceType must be one of: Pod, Service, Deployment, ConfigMap, Secret, Namespace, ReplicaSet, StatefulSet, DaemonSet, Job")
}
return nil
}
func buildKWTriggerObject(ns string, req model.CreateKWTriggerRequest) *unstructured.Unstructured {
// Fission ожидает capitalize: Pod, Service, Deployment
resourceType := capitalize(strings.TrimSpace(req.ResourceType))
spec := map[string]any{
"type": resourceType,
"namespace": req.Namespace,
"functionref": map[string]any{
"type": "name",
"name": req.FunctionName,
},
}
if req.LabelSelector != "" {
spec["labelselector"] = req.LabelSelector
}
return &unstructured.Unstructured{Object: map[string]any{
"apiVersion": "fission.io/v1",
"kind": "KubernetesWatchTrigger",
"metadata": map[string]any{
"name": req.Name,
"namespace": ns,
},
"spec": spec,
}}
}
func kwTriggerResponse(obj *unstructured.Unstructured) map[string]any {
spec, _ := obj.Object["spec"].(map[string]any)
if spec == nil {
spec = map[string]any{}
}
fnref, _ := spec["functionref"].(map[string]any)
fnName := ""
if fnref != nil {
fnName, _ = fnref["name"].(string)
}
return map[string]any{
"metadata": map[string]any{
"name": obj.GetName(),
"namespace": obj.GetNamespace(),
},
"spec": map[string]any{
"resourceType": spec["type"],
"namespace": spec["namespace"],
"labelSelector": spec["labelselector"],
"functionName": fnName,
},
}
}
// capitalize приводит первый символ к верхнему регистру, остальное без изменений.
// "pod" → "Pod", "deployment" → "Deployment"
func capitalize(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + strings.ToLower(s[1:])
}
+286
View File
@@ -0,0 +1,286 @@
package api
// mqtriggers.go — CRUD для MQ-триггеров через K8s Deployment + Secret.
//
// АРХИТЕКТУРА (2026-05-11):
// Вместо Fission MessageQueueTrigger CRD (требует mqtrigger компонент Kafka/NATS)
// Console деплоит собственный sqs-consumer Deployment в namespace пользователя.
//
// При CREATE:
// 1. Создаём K8s Secret (sqs-mq-<name>) с SQS credentials
// 2. Создаём K8s Deployment (mq-<name>) с образом naeel/sqs-consumer:v1.0
// FUNCTION_URL = http://router.fission.svc.cluster.local/<functionName>
// Лейблы: app.kubernetes.io/managed-by=fission-console, component=mq-trigger
//
// При LIST: deployments -n <ns> -l component=mq-trigger
// При DELETE: удаляем Deployment + Secret
//
// ИЗМЕНЕНИЯ:
// v1 — использовал Fission MQ CRD (компонент отсутствует в кластере)
// v2 — K8s Deployment + наш sqs-consumer образ
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"fission-console/internal/model"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
const (
sqsConsumerImage = "naeel/sqs-consumer:v1.0"
sqsDefaultEndpoint = "http://shared-sqs.shared-sqs.svc.cluster.local:4100"
fissionRouterBase = "http://router.fission.svc.cluster.local"
mqTriggerLabelKey = "component"
mqTriggerLabelVal = "mq-trigger"
mqManagedByLabel = "app.kubernetes.io/managed-by"
mqManagedByVal = "fission-console"
)
func mqSecretName(name string) string { return "sqs-mq-" + name }
func mqDeployName(name string) string { return "mq-" + name }
// ── HTTP хендлеры ────────────────────────────────────────────────────
func (s *Server) handleMQTriggersRoot(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.handleListMQTriggers(w, r)
case http.MethodPost:
s.handleCreateMQTrigger(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleMQTriggersAction(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/console/api/mqtriggers/")
name := strings.Trim(path, "/")
if name == "" || strings.Contains(name, "/") {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodDelete:
s.handleDeleteMQTrigger(w, r, name)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// ── LIST ────────────────────────────────────────────────────────────
func (s *Server) handleListMQTriggers(w http.ResponseWriter, r *http.Request) {
ns := s.userNS(r)
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
labelSel := fmt.Sprintf("%s=%s,%s=%s", mqManagedByLabel, mqManagedByVal, mqTriggerLabelKey, mqTriggerLabelVal)
deployList, err := s.kube.AppsV1().Deployments(ns).List(ctx, metav1.ListOptions{LabelSelector: labelSel})
if err != nil {
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("list mq deployments: %v", err))
return
}
items := make([]map[string]any, 0, len(deployList.Items))
for i := range deployList.Items {
items = append(items, mqDeployToResponse(&deployList.Items[i]))
}
writeAnyJSON(w, http.StatusOK, map[string]any{"items": items})
}
// ── CREATE ──────────────────────────────────────────────────────────
func (s *Server) handleCreateMQTrigger(w http.ResponseWriter, r *http.Request) {
var req model.CreateMQTriggerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "decode request: "+err.Error())
return
}
if err := validateMQRequest(req); err != nil {
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
ns := s.userNS(r)
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
endpoint := strings.TrimSpace(req.SqsEndpoint)
if endpoint == "" {
endpoint = sqsDefaultEndpoint
}
functionURL := fissionRouterBase + "/" + req.FunctionName
secretName := mqSecretName(req.Name)
deployName := mqDeployName(req.Name)
// 1. Secret с SQS credentials
secret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: secretName,
Namespace: ns,
Labels: map[string]string{
mqManagedByLabel: mqManagedByVal,
mqTriggerLabelKey: mqTriggerLabelVal,
"mq-trigger-name": req.Name,
},
},
StringData: map[string]string{
"SQS_ACCESS_KEY": req.AccessKey,
"SQS_SECRET_KEY": req.SecretKey,
"SQS_ENDPOINT": endpoint,
},
}
if _, err := s.kube.CoreV1().Secrets(ns).Create(ctx, secret, metav1.CreateOptions{}); err != nil {
if apierrors.IsAlreadyExists(err) {
writeJSONError(w, http.StatusConflict, fmt.Sprintf("mq trigger %q already exists", req.Name))
return
}
writeJSONError(w, http.StatusBadGateway, "create secret: "+err.Error())
return
}
// 2. Deployment (sqs-consumer)
replicas := int32(1)
deploy := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: deployName,
Namespace: ns,
Labels: map[string]string{
mqManagedByLabel: mqManagedByVal,
mqTriggerLabelKey: mqTriggerLabelVal,
"mq-trigger-name": req.Name,
},
Annotations: map[string]string{
"fission-console/mq-trigger-name": req.Name,
"fission-console/function": req.FunctionName,
"fission-console/queue": req.Queue,
"fission-console/sqs-endpoint": endpoint,
},
},
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"mq-trigger-name": req.Name},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
"mq-trigger-name": req.Name,
mqTriggerLabelKey: mqTriggerLabelVal,
},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Name: "sqs-consumer",
Image: sqsConsumerImage,
ImagePullPolicy: corev1.PullAlways,
Env: []corev1.EnvVar{
{Name: "SQS_QUEUE_NAME", Value: req.Queue},
{Name: "SQS_REGION", Value: "us-east-1"},
{Name: "FUNCTION_URL", Value: functionURL},
{Name: "POLL_INTERVAL", Value: "5"},
{Name: "MAX_MESSAGES", Value: "1"},
{Name: "MAX_RETRIES", Value: "3"},
},
EnvFrom: []corev1.EnvFromSource{{
SecretRef: &corev1.SecretEnvSource{
LocalObjectReference: corev1.LocalObjectReference{Name: secretName},
},
}},
Resources: corev1.ResourceRequirements{
Limits: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("50m"),
corev1.ResourceMemory: resource.MustParse("32Mi"),
},
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("10m"),
corev1.ResourceMemory: resource.MustParse("16Mi"),
},
},
}},
},
},
},
}
created, err := s.kube.AppsV1().Deployments(ns).Create(ctx, deploy, metav1.CreateOptions{})
if err != nil {
_ = s.kube.CoreV1().Secrets(ns).Delete(ctx, secretName, metav1.DeleteOptions{})
writeJSONError(w, http.StatusBadGateway, "create deployment: "+err.Error())
return
}
writeAnyJSON(w, http.StatusCreated, mqDeployToResponse(created))
}
// ── DELETE ──────────────────────────────────────────────────────────
func (s *Server) handleDeleteMQTrigger(w http.ResponseWriter, r *http.Request, name string) {
ns := s.userNS(r)
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
dErr := s.kube.AppsV1().Deployments(ns).Delete(ctx, mqDeployName(name), metav1.DeleteOptions{})
sErr := s.kube.CoreV1().Secrets(ns).Delete(ctx, mqSecretName(name), metav1.DeleteOptions{})
if dErr != nil && !apierrors.IsNotFound(dErr) {
writeJSONError(w, http.StatusBadGateway, "delete deployment: "+dErr.Error())
return
}
if sErr != nil && !apierrors.IsNotFound(sErr) {
writeJSONError(w, http.StatusBadGateway, "delete secret: "+sErr.Error())
return
}
writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name})
}
// ── Вспомогательные ─────────────────────────────────────────────────
func validateMQRequest(req model.CreateMQTriggerRequest) error {
if strings.TrimSpace(req.Name) == "" {
return fmt.Errorf("name is required")
}
if strings.TrimSpace(req.FunctionName) == "" {
return fmt.Errorf("functionName is required")
}
if strings.TrimSpace(req.Queue) == "" {
return fmt.Errorf("queue is required")
}
if strings.TrimSpace(req.AccessKey) == "" {
return fmt.Errorf("accessKey is required")
}
if strings.TrimSpace(req.SecretKey) == "" {
return fmt.Errorf("secretKey is required")
}
return nil
}
func mqDeployToResponse(d *appsv1.Deployment) map[string]any {
ann := d.Annotations
if ann == nil {
ann = map[string]string{}
}
triggerName := ann["fission-console/mq-trigger-name"]
if triggerName == "" {
triggerName = strings.TrimPrefix(d.Name, "mq-")
}
return map[string]any{
"name": triggerName,
"deployName": d.Name,
"functionName": ann["fission-console/function"],
"queue": ann["fission-console/queue"],
"sqsEndpoint": ann["fission-console/sqs-endpoint"],
"ready": d.Status.ReadyReplicas > 0,
"replicas": d.Status.ReadyReplicas,
}
}
@@ -113,7 +113,7 @@ func decodeArchiveBytesToSource(decoded []byte) (string, error) {
return "", io.ErrUnexpectedEOF return "", io.ErrUnexpectedEOF
} }
var nodeJSWrapperPattern = regexp.MustCompile(`(?s)^const __mod = \{ exports: \{\} \};\s*\(new Function\('module', 'exports', (.+?)\)\)\(__mod, __mod\.exports\);\s*const _fn = __mod\.exports;`) var nodeJSWrapperPattern = regexp.MustCompile(`(?s)^const __mod = \{ exports: \{\} \};\s*\(new Function\('module', 'exports', (?:'require', )?(.+?)\)\)\(__mod, __mod\.exports(?:, require)?\);\s*const _fn = __mod\.exports;`)
func decodeNodeJSWrapperSource(decoded []byte) (string, bool) { func decodeNodeJSWrapperSource(decoded []byte) (string, bool) {
text := string(decoded) text := string(decoded)
@@ -16,6 +16,7 @@ import (
"fission-console/internal/billing" "fission-console/internal/billing"
"fission-console/internal/cloud" "fission-console/internal/cloud"
"fission-console/internal/fission" "fission-console/internal/fission"
"fission-console/internal/stats"
"fission-console/ui" "fission-console/ui"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -63,6 +64,9 @@ type Server struct {
// billing — слой записи статистики вызовов. NoopStore если BILLING_DSN не задан. // billing — слой записи статистики вызовов. NoopStore если BILLING_DSN не задан.
billing billing.Store billing billing.Store
// stats — аналитический слой (Grafana Organizations). NoopProvider если не настроен.
stats stats.StatsProvider
} }
// Config содержит все параметры для создания Server. // Config содержит все параметры для создания Server.
@@ -81,6 +85,7 @@ type Config struct {
LLMUrl string LLMUrl string
LLMKey string LLMKey string
Billing billing.Store // слой статистики (NoopStore если не задан) Billing billing.Store // слой статистики (NoopStore если не задан)
Stats stats.StatsProvider // аналитика (NoopProvider если не настроен)
} }
// NewServer создаёт и настраивает HTTP Server со всеми зависимостями. // NewServer создаёт и настраивает HTTP Server со всеми зависимостями.
@@ -102,6 +107,7 @@ func NewServer(cfg Config) *Server {
llmKey: cfg.LLMKey, llmKey: cfg.LLMKey,
nsManager: cloud.NewNSManager(cfg.Dyn), nsManager: cloud.NewNSManager(cfg.Dyn),
billing: cfg.Billing, billing: cfg.Billing,
stats: cfg.Stats,
} }
} }
@@ -163,8 +169,13 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/console/api/httptriggers", auth(s.handleList(fission.HTTPTrigGVR))) mux.HandleFunc("/console/api/httptriggers", auth(s.handleList(fission.HTTPTrigGVR)))
mux.HandleFunc("/console/api/timetriggers", auth(s.handleTimeTriggersRoot)) mux.HandleFunc("/console/api/timetriggers", auth(s.handleTimeTriggersRoot))
mux.HandleFunc("/console/api/timetriggers/", auth(s.handleTimeTriggersAction)) mux.HandleFunc("/console/api/timetriggers/", auth(s.handleTimeTriggersAction))
mux.HandleFunc("/console/api/mqtriggers", auth(s.handleMQTriggersRoot))
mux.HandleFunc("/console/api/mqtriggers/", auth(s.handleMQTriggersAction))
mux.HandleFunc("/console/api/kwtriggers", auth(s.handleKWTriggersRoot))
mux.HandleFunc("/console/api/kwtriggers/", auth(s.handleKWTriggersAction))
mux.HandleFunc("/console/api/ns/status", auth(s.handleNSStatus)) mux.HandleFunc("/console/api/ns/status", auth(s.handleNSStatus))
mux.HandleFunc("/console/api/ns/debug", auth(s.handleNSDebug)) mux.HandleFunc("/console/api/ns/debug", auth(s.handleNSDebug))
mux.HandleFunc("/console/api/stats/dashboard-url", auth(s.handleStatsDashboard))
mux.HandleFunc("/console/api/ai/check", auth(s.handleAICheck)) mux.HandleFunc("/console/api/ai/check", auth(s.handleAICheck))
mux.HandleFunc("/console/api/ai/lint-archive", auth(s.handleLintArchive)) mux.HandleFunc("/console/api/ai/lint-archive", auth(s.handleLintArchive))
mux.HandleFunc("/console/api/ai/explain-archive", auth(s.handleExplainArchive)) mux.HandleFunc("/console/api/ai/explain-archive", auth(s.handleExplainArchive))
+25
View File
@@ -0,0 +1,25 @@
package api
import (
"encoding/json"
"net/http"
)
// handleStatsDashboard GET /console/api/stats/dashboard-url
// Возвращает публичный URL дашборда Grafana для текущего namespace пользователя.
// Если аналитика не настроена — возвращает {"url":""}.
func (s *Server) handleStatsDashboard(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
ns := s.userNS(r)
url := s.stats.DashboardURL(r.Context(), ns)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{
"url": url,
"namespace": ns,
})
}
@@ -393,7 +393,17 @@ func (m *NSManager) reapExpiredFunctionsInNS(ctx context.Context, ns string, now
} }
} }
// Orphan packages — пакеты без соответствующей функции // Orphan packages — пакеты без соответствующей функции.
// Перечитываем Functions заново (fresh LIST) чтобы избежать race condition:
// новая функция может быть создана между снятием activeFunctions и этой проверкой.
freshFunctions, freshErr := m.dyn.Resource(fission.FunctionGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
if freshErr != nil {
return
}
freshFunctionSet := make(map[string]struct{}, len(freshFunctions.Items))
for _, fn := range freshFunctions.Items {
freshFunctionSet[fn.GetName()] = struct{}{}
}
packages, pkgListErr := m.dyn.Resource(fission.PackageGVR).Namespace(ns).List(ctx, metav1.ListOptions{}) packages, pkgListErr := m.dyn.Resource(fission.PackageGVR).Namespace(ns).List(ctx, metav1.ListOptions{})
if pkgListErr == nil { if pkgListErr == nil {
for _, pkg := range packages.Items { for _, pkg := range packages.Items {
@@ -401,8 +411,13 @@ func (m *NSManager) reapExpiredFunctionsInNS(ctx context.Context, ns string, now
if !strings.HasSuffix(pkgName, "-pkg") { if !strings.HasSuffix(pkgName, "-pkg") {
continue continue
} }
// Пропускаем свежесозданные пакеты: Function CRD может ещё не успеть
// создаться после загрузки Package (race condition при быстром ExpiryReaper).
if age := time.Since(pkg.GetCreationTimestamp().Time); age < 2*time.Minute {
continue
}
fnName := strings.TrimSuffix(pkgName, "-pkg") fnName := strings.TrimSuffix(pkgName, "-pkg")
if _, exists := activeFunctions[fnName]; !exists { if _, exists := freshFunctionSet[fnName]; !exists {
log.Printf("cloud.ExpiryReaper: deleting orphan package %s/%s", ns, pkgName) log.Printf("cloud.ExpiryReaper: deleting orphan package %s/%s", ns, pkgName)
_ = m.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{}) _ = m.dyn.Resource(fission.PackageGVR).Namespace(ns).Delete(ctx, pkgName, metav1.DeleteOptions{})
} }
@@ -12,6 +12,8 @@ var (
FunctionGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "functions"} FunctionGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "functions"}
HTTPTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "httptriggers"} HTTPTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "httptriggers"}
TimeTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "timetriggers"} TimeTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "timetriggers"}
MQTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "messagequeuetriggers"}
KWTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "kuberneteswatchtriggers"}
NamespaceGVR = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"} NamespaceGVR = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"}
DeploymentGVR = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"} DeploymentGVR = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}
@@ -28,9 +28,20 @@ func EnsureEnvironment(ctx context.Context, dyn dynamic.Interface, ns, lang stri
envName := "console-" + lang + "-env" envName := "console-" + lang + "-env"
// Проверяем существование — Get быстрее чем Create+IsAlreadyExists // Проверяем существование — Get быстрее чем Create+IsAlreadyExists
_, getErr := dyn.Resource(EnvironmentGVR).Namespace(ns).Get(ctx, envName, metav1.GetOptions{}) existing, getErr := dyn.Resource(EnvironmentGVR).Namespace(ns).Get(ctx, envName, metav1.GetOptions{})
if getErr == nil { if getErr == nil {
return envName, nil // уже существует — быстрый путь // Уже существует — проверяем образ. Если устарел — обновляем.
currentImage, _, _ := unstructured.NestedString(existing.Object, "spec", "runtime", "image")
if currentImage != langDef.Image {
if patchErr := unstructured.SetNestedField(existing.Object, langDef.Image, "spec", "runtime", "image"); patchErr == nil {
if _, updateErr := dyn.Resource(EnvironmentGVR).Namespace(ns).Update(ctx, existing, metav1.UpdateOptions{}); updateErr != nil {
log.Printf("ensureEnvironment: update image %s/%s: %v", ns, envName, updateErr)
} else {
log.Printf("ensureEnvironment: updated image %s/%s: %s → %s", ns, envName, currentImage, langDef.Image)
}
}
}
return envName, nil
} }
if !apierrors.IsNotFound(getErr) { if !apierrors.IsNotFound(getErr) {
return "", fmt.Errorf("check environment %q: %w", envName, getErr) return "", fmt.Errorf("check environment %q: %w", envName, getErr)
@@ -5,11 +5,14 @@ package model
// CreateFunctionRequest — тело POST /console/api/functions. // CreateFunctionRequest — тело POST /console/api/functions.
// TTL пустой → функция живёт вечно; "1d", "24h" — протухнет через указанное время. // TTL пустой → функция живёт вечно; "1d", "24h" — протухнет через указанное время.
// Deps — содержимое файла зависимостей: requirements.txt (python), package.json deps (nodejs),
// Gemfile (ruby), composer.json (php). Если задан — код упаковывается в zip вместе с deps-файлом.
type CreateFunctionRequest struct { type CreateFunctionRequest struct {
Name string `json:"name"` Name string `json:"name"`
Language string `json:"language"` Language string `json:"language"`
Environment string `json:"environment"` Environment string `json:"environment"`
Code string `json:"code"` Code string `json:"code"`
Deps string `json:"deps"` // содержимое файла зависимостей (опционально)
Entrypoint string `json:"entrypoint"` Entrypoint string `json:"entrypoint"`
Route string `json:"route"` Route string `json:"route"`
Methods []string `json:"methods"` Methods []string `json:"methods"`
@@ -26,9 +29,32 @@ type CreateTimeTriggerRequest struct {
SubPath string `json:"subpath"` SubPath string `json:"subpath"`
} }
// CreateMQTriggerRequest — тело POST /console/api/mqtriggers.
// Архитектура: Console создаёт K8s Deployment (sqs-consumer) + Secret с credentials
// в namespace пользователя. sqs-consumer поллит shared-sqs → вызывает Fission-функцию.
type CreateMQTriggerRequest struct {
Name string `json:"name"`
FunctionName string `json:"functionName"`
Queue string `json:"queue"` // имя очереди в SQS
SqsEndpoint string `json:"sqsEndpoint"` // URL SQS сервиса (default: internal shared-sqs)
AccessKey string `json:"accessKey"` // SQS access key тенанта
SecretKey string `json:"secretKey"` // SQS secret key тенанта
}
// CreateKWTriggerRequest — тело POST /console/api/kwtriggers.
// Документация полей: kubectl get crd kuberneteswatchtriggers.fission.io -o json
type CreateKWTriggerRequest struct {
Name string `json:"name"`
FunctionName string `json:"functionName"`
ResourceType string `json:"resourceType"` // Pod, Service, Deployment и т.д.
Namespace string `json:"namespace"` // пустое = namespace пользователя
LabelSelector string `json:"labelSelector"` // "app=foo" или "" для всех
}
// UpdateCodeRequest — тело PUT /console/api/functions/:name/code. // UpdateCodeRequest — тело PUT /console/api/functions/:name/code.
type UpdateCodeRequest struct { type UpdateCodeRequest struct {
Code string `json:"code"` Code string `json:"code"`
Deps string `json:"deps"` // содержимое файла зависимостей (опционально)
Timeout int64 `json:"timeout"` Timeout int64 `json:"timeout"`
} }
@@ -44,7 +70,7 @@ type LangEnvDef struct {
// LangEnvMap сопоставляет идентификатор языка (string) с описанием среды выполнения. // LangEnvMap сопоставляет идентификатор языка (string) с описанием среды выполнения.
// Ключ используется в createFunctionRequest.Language и как суффикс имени Environment. // Ключ используется в createFunctionRequest.Language и как суффикс имени Environment.
var LangEnvMap = map[string]LangEnvDef{ var LangEnvMap = map[string]LangEnvDef{
"python": {Image: "naeel/fission-python-env:v1.0"}, "python": {Image: "naeel/fission-python-env:v1.1"},
"nodejs": {Image: "ghcr.io/fission/node-env"}, "nodejs": {Image: "ghcr.io/fission/node-env"},
"go": {Image: "ghcr.io/fission/go-env", BuilderImage: "naeel/go-builder-fast:v1"}, "go": {Image: "ghcr.io/fission/go-env", BuilderImage: "naeel/go-builder-fast:v1"},
"php": {Image: "ghcr.io/fission/php-env"}, "php": {Image: "ghcr.io/fission/php-env"},
@@ -11,6 +11,9 @@ package runtime
func DefaultEntrypoint(lang string) string { func DefaultEntrypoint(lang string) string {
switch lang { switch lang {
case "nodejs": case "nodejs":
// "main" (без .js): node-env.specializeV2 делает split(".") для functionName
// с точкой → funcname="js" → ищет экспорт "js" → 500.
// Без расширения: funcname=undefined → берёт default export (module.exports = __invoke).
return "main" return "main"
case "php": case "php":
// Fission php-env: filename::functionName // Fission php-env: filename::functionName
@@ -0,0 +1,52 @@
package runtime
import (
"archive/zip"
"bytes"
)
// buildZip создаёт zip-архив с одним файлом fileName и содержимым content.
// Вспомогательная функция: используется в buildScriptZip (PHP/Ruby),
// а также как основа для buildGoSourceZip и buildJSDeployZip.
func buildZip(fileName string, content []byte) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
fw, err := zw.Create(fileName)
if err != nil {
return nil, err
}
if _, err := fw.Write(content); err != nil {
return nil, err
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// buildZipTwo создаёт zip-архив с двумя файлами.
// Используется когда пользователь указал файл зависимостей (requirements.txt и т.д.).
func buildZipTwo(file1, file2 string, content1, content2 []byte) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
for _, f := range []struct {
name string
content []byte
}{{file1, content1}, {file2, content2}} {
fw, err := zw.Create(f.name)
if err != nil {
return nil, err
}
if _, err := fw.Write(f.content); err != nil {
return nil, err
}
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
@@ -23,10 +23,12 @@ func BuildJSDeployZip(code string) ([]byte, error) {
// main.js — CommonJS wrapper: // main.js — CommonJS wrapper:
// 1. new Function создаёт функцию в пустом модульном контексте (нет import/export) // 1. new Function создаёт функцию в пустом модульном контексте (нет import/export)
// 2. Передаём ей module и exports как параметры → пользовательский CJS код работает // 2. Передаём ей module, exports и require как параметры → пользовательский CJS код работает
// 3. Экспортируем main/handler/default через module.exports // 3. require передаётся явно, т.к. new Function выполняется в глобальном scope
// и не имеет доступа к module-local require из CJS контекста main.js
// 4. Экспортируем main/handler/default через module.exports
wrapper := fmt.Sprintf(`const __mod = { exports: {} }; wrapper := fmt.Sprintf(`const __mod = { exports: {} };
(new Function('module', 'exports', %s))(__mod, __mod.exports); (new Function('module', 'exports', 'require', %s))(__mod, __mod.exports, require);
const _fn = __mod.exports; const _fn = __mod.exports;
async function __invoke(ctx) { async function __invoke(ctx) {
@@ -33,16 +33,18 @@ func TestBuildJSDeployZipExportsMainAndHandler(t *testing.T) {
files[file.Name] = string(content) files[file.Name] = string(content)
} }
if files["package.json"] != `{"type":"module"}` {
t.Fatalf("package.json = %q, want ESM marker", files["package.json"])
}
mainJS := files["main.js"] mainJS := files["main.js"]
if !strings.Contains(mainJS, `export { __invoke as main, __invoke as handler };`) { if mainJS == "" {
t.Fatalf("main.js does not export both main and handler: %s", mainJS) t.Fatalf("main.js not found in zip, files: %v", files)
} }
if !strings.Contains(mainJS, `export default __invoke;`) { if !strings.Contains(mainJS, `new Function('module', 'exports', 'require',`) {
t.Fatalf("main.js does not export default invoke: %s", mainJS) t.Fatalf("main.js does not pass require to new Function: %s", mainJS)
}
if !strings.Contains(mainJS, `__mod.exports, require)`) {
t.Fatalf("main.js does not forward require: %s", mainJS)
}
if !strings.Contains(mainJS, `module.exports = __invoke`) {
t.Fatalf("main.js does not export __invoke via module.exports: %s", mainJS)
} }
if !strings.Contains(mainJS, `_fn.default || _fn.handler || _fn.main`) { if !strings.Contains(mainJS, `_fn.default || _fn.handler || _fn.main`) {
t.Fatalf("main.js lost user export resolution: %s", mainJS) t.Fatalf("main.js lost user export resolution: %s", mainJS)
+30
View File
@@ -0,0 +1,30 @@
package runtime
// BuildScriptZip создаёт zip-архив с одним файлом fileName и содержимым code.
// Используется для PHP и Ruby — языков где среда Fission ожидает
// именованный файл (handler.rb, handler.pl, main.php и т.д.) внутри архива.
//
// Почему zip, а не просто literal:
// Fission poolmgr при специализации пода распаковывает deployment archive,
// находит нужный файл по имени и загружает его в среду выполнения.
// Если передать просто байты кода в deployment.literal — среда не знает расширение.
func BuildScriptZip(code, fileName string) ([]byte, error) {
return buildZip(fileName, []byte(code))
}
// BuildScriptZipWithDeps создаёт zip с кодом и файлом зависимостей.
// fileName — имя файла кода (handler.rb, main.php и т.д.)
// depsName — имя файла зависимостей (Gemfile, composer.json и т.д.)
func BuildScriptZipWithDeps(code, fileName, deps, depsName string) ([]byte, error) {
return buildZipTwo(fileName, depsName, []byte(code), []byte(deps))
}
// BuildPythonZip создаёт zip с main.py (и опционально requirements.txt).
// Если deps пустой — возвращает raw bytes кода (текущее поведение Python).
// Если deps задан — zip с main.py + requirements.txt для pip install.
func BuildPythonZip(code, deps string) ([]byte, error) {
if deps == "" {
return []byte(code), nil
}
return buildZipTwo("main.py", "requirements.txt", []byte(code), []byte(deps))
}
+36
View File
@@ -0,0 +1,36 @@
package stats
import (
"log"
"os"
"strings"
)
// NewProvider создаёт StatsProvider из переменных окружения.
//
// Переменные:
// - GRAFANA_INTERNAL_URL — внутренний URL (http://grafana.grafana.svc.cluster.local:3000)
// - GRAFANA_PUBLIC_URL — публичный URL для ссылок (https://fission.kube5s.ru/grafana)
// - GRAFANA_ADMIN_USER — имя admin (default: "admin")
// - GRAFANA_ADMIN_PASS — пароль admin
func NewProvider() StatsProvider {
internalURL := strings.TrimSpace(os.Getenv("GRAFANA_INTERNAL_URL"))
if internalURL == "" {
log.Printf("stats: GRAFANA_INTERNAL_URL not set — using NoopProvider")
return NoopProvider{}
}
publicURL := strings.TrimSpace(os.Getenv("GRAFANA_PUBLIC_URL"))
if publicURL == "" {
publicURL = "https://fission.kube5s.ru/grafana"
}
adminUser := strings.TrimSpace(os.Getenv("GRAFANA_ADMIN_USER"))
if adminUser == "" {
adminUser = "admin"
}
adminPass := os.Getenv("GRAFANA_ADMIN_PASS")
log.Printf("stats: GrafanaProvider internalURL=%s publicURL=%s user=%s", internalURL, publicURL, adminUser)
return NewGrafanaProvider(internalURL, publicURL, adminUser, adminPass)
}
+382
View File
@@ -0,0 +1,382 @@
package stats
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"sync"
"time"
)
// GrafanaProvider реализует StatsProvider через Grafana HTTP API.
//
// Для каждого namespace создаётся изолированная Grafana Organization:
// - PostgreSQL datasource (тот же DSN, uid="fission-user-pg")
// - Dashboard с hardcoded WHERE namespace='...'
// - Public Dashboard (без логина) → accessToken
//
// Потокобезопасен: sync.RWMutex + per-namespace singleflight.
type GrafanaProvider struct {
internalURL string // http://grafana.grafana.svc.cluster.local:3000
publicURL string // https://fission.kube5s.ru/grafana
adminUser string
adminPass string
http *http.Client
mu sync.RWMutex
tokens map[string]string // namespace → publicDashboardAccessToken
orgIDs map[string]int64 // namespace → grafana orgId
}
// NewGrafanaProvider создаёт GrafanaProvider.
func NewGrafanaProvider(internalURL, publicURL, adminUser, adminPass string) *GrafanaProvider {
return &GrafanaProvider{
internalURL: strings.TrimRight(internalURL, "/"),
publicURL: strings.TrimRight(publicURL, "/"),
adminUser: adminUser,
adminPass: adminPass,
http: &http.Client{Timeout: 20 * time.Second},
tokens: make(map[string]string),
orgIDs: make(map[string]int64),
}
}
// EnsureOrgForNamespace идемпотентно создаёт Grafana Org + datasource + dashboard + public link.
func (g *GrafanaProvider) EnsureOrgForNamespace(ctx context.Context, namespace, email string) error {
// Быстрый путь: уже провизировано в этом процессе
g.mu.RLock()
_, cached := g.tokens[namespace]
g.mu.RUnlock()
if cached {
return nil
}
// Шаг 1: получить или создать Org
orgID, err := g.getOrCreateOrg(ctx, namespace)
if err != nil {
return fmt.Errorf("getOrCreateOrg(%s): %w", namespace, err)
}
// Шаг 2: создать datasource в этой Org (идемпотентно)
if err := g.ensureDatasource(ctx, orgID); err != nil {
log.Printf("stats: ensureDatasource org=%d ns=%s: %v", orgID, namespace, err)
// не фатально — dashboard может не работать но org создана
}
// Шаг 3: создать dashboard с hardcoded namespace (идемпотентно)
dashUID, err := g.ensureDashboard(ctx, orgID, namespace)
if err != nil {
return fmt.Errorf("ensureDashboard org=%d ns=%s: %w", orgID, namespace, err)
}
// Шаг 4: получить или создать public dashboard → accessToken
token, err := g.ensurePublicDashboard(ctx, orgID, dashUID)
if err != nil {
return fmt.Errorf("ensurePublicDashboard org=%d dash=%s: %w", orgID, dashUID, err)
}
// Кэшируем
g.mu.Lock()
g.tokens[namespace] = token
g.orgIDs[namespace] = orgID
g.mu.Unlock()
log.Printf("stats: org provisioned ns=%s orgId=%d publicToken=%s...", namespace, orgID, token[:8])
return nil
}
// DashboardURL возвращает публичный URL или "" если ещё не провизировано.
func (g *GrafanaProvider) DashboardURL(ctx context.Context, namespace string) string {
// Сначала пробуем из кэша
g.mu.RLock()
token, ok := g.tokens[namespace]
g.mu.RUnlock()
if ok && token != "" {
return g.publicURL + "/public-dashboards/" + token
}
// Кэш промах (после перезапуска сервера) — провизируем заново
if err := g.EnsureOrgForNamespace(ctx, namespace, ""); err != nil {
log.Printf("stats: DashboardURL re-provision ns=%s: %v", namespace, err)
return ""
}
g.mu.RLock()
token = g.tokens[namespace]
g.mu.RUnlock()
if token == "" {
return ""
}
return g.publicURL + "/public-dashboards/" + token
}
// --- Grafana API helpers ---
// getOrCreateOrg возвращает orgId существующей или создаёт новую Org.
func (g *GrafanaProvider) getOrCreateOrg(ctx context.Context, namespace string) (int64, error) {
// Проверяем кэш orgIDs
g.mu.RLock()
if id, ok := g.orgIDs[namespace]; ok {
g.mu.RUnlock()
return id, nil
}
g.mu.RUnlock()
// GET /api/orgs/name/{namespace}
resp, body, err := g.grafanaRequest(ctx, http.MethodGet, "/api/orgs/name/"+namespace, 0, nil)
if err != nil {
return 0, err
}
if resp.StatusCode == http.StatusOK {
var org struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(body, &org); err != nil {
return 0, fmt.Errorf("parse org: %w", err)
}
return org.ID, nil
}
// Org не найдена — создаём
payload := map[string]string{"name": namespace}
resp, body, err = g.grafanaRequest(ctx, http.MethodPost, "/api/orgs", 0, payload)
if err != nil {
return 0, err
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return 0, fmt.Errorf("create org status=%d body=%s", resp.StatusCode, string(body))
}
var created struct {
OrgID int64 `json:"orgId"`
}
if err := json.Unmarshal(body, &created); err != nil {
return 0, fmt.Errorf("parse create org: %w", err)
}
return created.OrgID, nil
}
// ensureDatasource создаёт PostgreSQL datasource в org (uid="fission-user-pg").
// Идемпотентен: 409 Conflict считается успехом.
func (g *GrafanaProvider) ensureDatasource(ctx context.Context, orgID int64) error {
// Проверяем есть ли уже datasource в этой org
resp, _, err := g.grafanaRequest(ctx, http.MethodGet, "/api/datasources/uid/fission-user-pg", orgID, nil)
if err != nil {
return err
}
if resp.StatusCode == http.StatusOK {
return nil // уже есть
}
// Получаем DSN из уже существующего datasource в Org 1 (uid=fission-pg)
_, body, err := g.grafanaRequest(ctx, http.MethodGet, "/api/datasources/uid/fission-pg", 1, nil)
if err != nil {
return fmt.Errorf("get main datasource: %w", err)
}
var ds struct {
URL string `json:"url"`
JSONData json.RawMessage `json:"jsonData"`
SecureJSONData struct {
Password string `json:"password"`
} `json:"secureJsonData"`
}
if err := json.Unmarshal(body, &ds); err != nil {
return fmt.Errorf("parse main datasource: %w", err)
}
// Создаём копию datasource в новой Org
payload := map[string]any{
"name": "fission-pg",
"type": "postgres",
"uid": "fission-user-pg",
"url": ds.URL,
"access": "proxy",
"jsonData": map[string]any{
"sslmode": "disable",
"postgresVersion": 1700,
"timescaledb": false,
},
"secureJsonData": ds.SecureJSONData,
}
resp, body, err = g.grafanaRequest(ctx, http.MethodPost, "/api/datasources", orgID, payload)
if err != nil {
return err
}
if resp.StatusCode == http.StatusConflict {
return nil // уже существует
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return fmt.Errorf("create datasource status=%d body=%s", resp.StatusCode, string(body))
}
return nil
}
// ensureDashboard создаёт/обновляет user-дашборд в org.
// Возвращает uid дашборда.
func (g *GrafanaProvider) ensureDashboard(ctx context.Context, orgID int64, namespace string) (string, error) {
const dashUID = "fission-user-overview"
// Проверяем существование
resp, _, err := g.grafanaRequest(ctx, http.MethodGet, "/api/dashboards/uid/"+dashUID, orgID, nil)
if err != nil {
return "", err
}
if resp.StatusCode == http.StatusOK {
return dashUID, nil // уже есть
}
// Импортируем dashboard JSON с hardcoded namespace
dashJSON := userDashboardJSON(namespace)
payload := map[string]any{
"dashboard": json.RawMessage(dashJSON),
"overwrite": true,
"folderId": 0,
}
resp, body, err := g.grafanaRequest(ctx, http.MethodPost, "/api/dashboards/db", orgID, payload)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("import dashboard status=%d body=%s", resp.StatusCode, string(body))
}
return dashUID, nil
}
// ensurePublicDashboard создаёт public dashboard и возвращает accessToken.
// Идемпотентен: если уже существует — возвращает существующий token.
func (g *GrafanaProvider) ensurePublicDashboard(ctx context.Context, orgID int64, dashUID string) (string, error) {
path := "/api/dashboards/uid/" + dashUID + "/public-dashboards"
// Проверяем существование
resp, body, err := g.grafanaRequest(ctx, http.MethodGet, path, orgID, nil)
if err != nil {
return "", err
}
if resp.StatusCode == http.StatusOK {
var pd struct {
AccessToken string `json:"accessToken"`
}
if err := json.Unmarshal(body, &pd); err != nil {
return "", fmt.Errorf("parse public dashboard: %w", err)
}
if pd.AccessToken != "" {
return pd.AccessToken, nil
}
}
// Создаём
payload := map[string]any{
"isEnabled": true,
"annotationsEnabled": false,
"timeSelectionEnabled": true,
}
resp, body, err = g.grafanaRequest(ctx, http.MethodPost, path, orgID, payload)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("create public dashboard status=%d body=%s", resp.StatusCode, string(body))
}
var pd struct {
AccessToken string `json:"accessToken"`
}
if err := json.Unmarshal(body, &pd); err != nil {
return "", fmt.Errorf("parse created public dashboard: %w", err)
}
if pd.AccessToken == "" {
return "", fmt.Errorf("empty accessToken in response: %s", string(body))
}
return pd.AccessToken, nil
}
// grafanaRequest выполняет HTTP запрос к Grafana API.
// orgID > 0 → устанавливает X-Grafana-Org-Id заголовок (thread-safe, без смены контекста).
// orgID == 0 → без заголовка (используется Org 1 admin по умолчанию).
func (g *GrafanaProvider) grafanaRequest(ctx context.Context, method, path string, orgID int64, payload any) (*http.Response, []byte, error) {
var bodyReader io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return nil, nil, fmt.Errorf("marshal payload: %w", err)
}
bodyReader = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, g.internalURL+path, bodyReader)
if err != nil {
return nil, nil, fmt.Errorf("new request: %w", err)
}
req.SetBasicAuth(g.adminUser, g.adminPass)
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
if orgID > 0 {
req.Header.Set("X-Grafana-Org-Id", fmt.Sprintf("%d", orgID))
}
resp, err := g.http.Do(req)
if err != nil {
return nil, nil, fmt.Errorf("do request %s %s: %w", method, path, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return resp, nil, fmt.Errorf("read body: %w", err)
}
return resp, body, nil
}
// userDashboardJSON генерирует JSON дашборда для конкретного namespace.
// Namespace вшит прямо в SQL запросы — без template variables.
// Dashboard uid="fission-user-overview" (per-org, без конфликтов между org).
func userDashboardJSON(namespace string) string {
// Безопасное экранирование namespace для SQL (namespace это sha256 hex — только [a-z0-9-])
ns := strings.ReplaceAll(namespace, "'", "''")
return fmt.Sprintf(`{
"title": "Мои функции — %s",
"uid": "fission-user-overview",
"tags": ["fission", "user"],
"timezone": "browser",
"refresh": "1m",
"time": {"from": "now-24h", "to": "now"},
"panels": [
{
"id": 1, "title": "Вызовы в час", "type": "timeseries",
"gridPos": {"x": 0, "y": 0, "w": 16, "h": 8},
"datasource": {"type": "postgres", "uid": "fission-user-pg"},
"targets": [{"rawSql": "SELECT date_trunc('hour', started_at) AS time, count(*) AS value, function_name FROM invocations WHERE namespace = '%s' AND started_at BETWEEN $__timeFrom() AND $__timeTo() GROUP BY 1, function_name ORDER BY 1", "format": "time_series", "refId": "A"}]
},
{
"id": 2, "title": "Успех vs Ошибки", "type": "piechart",
"gridPos": {"x": 16, "y": 0, "w": 8, "h": 8},
"datasource": {"type": "postgres", "uid": "fission-user-pg"},
"targets": [{"rawSql": "SELECT CASE WHEN status_code >= 200 AND status_code < 300 THEN 'success' WHEN status_code = 0 THEN 'event' ELSE 'error' END AS metric, count(*) AS value FROM invocations WHERE namespace = '%s' AND started_at BETWEEN $__timeFrom() AND $__timeTo() GROUP BY 1", "format": "table", "refId": "A"}]
},
{
"id": 3, "title": "Топ функций", "type": "bargauge",
"gridPos": {"x": 0, "y": 8, "w": 12, "h": 8},
"datasource": {"type": "postgres", "uid": "fission-user-pg"},
"targets": [{"rawSql": "SELECT function_name AS metric, count(*) AS value FROM invocations WHERE namespace = '%s' AND started_at BETWEEN $__timeFrom() AND $__timeTo() GROUP BY 1 ORDER BY 2 DESC LIMIT 10", "format": "table", "refId": "A"}]
},
{
"id": 4, "title": "Средняя латентность (ms)", "type": "timeseries",
"gridPos": {"x": 12, "y": 8, "w": 12, "h": 8},
"datasource": {"type": "postgres", "uid": "fission-user-pg"},
"targets": [{"rawSql": "SELECT date_trunc('hour', started_at) AS time, round(avg(duration_ms)) AS avg_ms FROM invocations WHERE namespace = '%s' AND started_at BETWEEN $__timeFrom() AND $__timeTo() AND trigger_type != 'event' GROUP BY 1 ORDER BY 1", "format": "time_series", "refId": "A"}]
},
{
"id": 5, "title": "Последние события", "type": "table",
"gridPos": {"x": 0, "y": 16, "w": 24, "h": 8},
"datasource": {"type": "postgres", "uid": "fission-user-pg"},
"targets": [{"rawSql": "SELECT started_at AS time, function_name, trigger_type, event_type, status_code, duration_ms, error_msg FROM invocations WHERE namespace = '%s' ORDER BY started_at DESC LIMIT 50", "format": "table", "refId": "A"}]
}
],
"schemaVersion": 39
}`, namespace, ns, ns, ns, ns, ns)
}
+10
View File
@@ -0,0 +1,10 @@
package stats
import "context"
// NoopProvider — заглушка когда GRAFANA_INTERNAL_URL не задан.
// Ни на что не влияет, не крашит.
type NoopProvider struct{}
func (NoopProvider) EnsureOrgForNamespace(_ context.Context, _, _ string) error { return nil }
func (NoopProvider) DashboardURL(_ context.Context, _ string) string { return "" }
+20
View File
@@ -0,0 +1,20 @@
// Package stats — аналитический слой консоли.
//
// StatsProvider абстрагирует конкретный инструмент (Grafana, Prometheus, etc.).
// При смене инструмента достаточно заменить реализацию и переменные окружения.
package stats
import "context"
// StatsProvider — интерфейс аналитики.
// Реализации: GrafanaProvider (GRAFANA_INTERNAL_URL задан), NoopProvider (заглушка).
type StatsProvider interface {
// EnsureOrgForNamespace идемпотентно создаёт аналитическое пространство
// для namespace (Grafana Org + datasource + dashboard + public link).
// Вызывается при handleAuth — fire-and-forget горутиной.
EnsureOrgForNamespace(ctx context.Context, namespace, email string) error
// DashboardURL возвращает публичный URL дашборда без логина.
// Возвращает "" если аналитика не настроена или provisioning не завершён.
DashboardURL(ctx context.Context, namespace string) string
}
@@ -25,6 +25,7 @@
<script src="js/fn-archive.js"></script> <script src="js/fn-archive.js"></script>
<script src="js/ai.js"></script> <script src="js/ai.js"></script>
<script src="js/app.js"></script> <script src="js/app.js"></script>
<script src="js/mq.js"></script>
</head> </head>
<body> <body>
@@ -102,13 +103,14 @@
<div class="nubes">NUBES</div> <div class="nubes">NUBES</div>
<div class="product">FISSION CONSOLE</div> <div class="product">FISSION CONSOLE</div>
</div> </div>
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.86</div> <div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.96</div>
</div> </div>
<div class="row" style="margin:0;"> <div class="row" style="margin:0;">
<button class="btn ghost" onclick="reloadAll()">Refresh</button> <button class="btn ghost" onclick="reloadAll()">Refresh</button>
<button class="btn" onclick="openCreateCode()">✏️ Из кода</button> <button class="btn" onclick="openCreateCode()">✏️ Из кода</button>
<button class="btn" onclick="openCreateArchive()">📦 Из архива</button> <button class="btn" onclick="openCreateArchive()">📦 Из архива</button>
<button class="btn ghost" onclick="openHelp()">Help</button> <button class="btn ghost" onclick="openHelp()">Help</button>
<button class="btn ghost" onclick="openAnalytics()" title="Открыть дашборд Grafana">📊 Аналитика</button>
<button class="btn ghost" onclick="doLogout()" style="margin-left:8px;">Выход</button> <button class="btn ghost" onclick="doLogout()" style="margin-left:8px;">Выход</button>
</div> </div>
</div> </div>
@@ -135,6 +137,10 @@
<div class="k">Крон-функции</div> <div class="k">Крон-функции</div>
<div id="cron-count" class="v">-</div> <div id="cron-count" class="v">-</div>
</div> </div>
<div class="card">
<div class="k">MQ-триггеры</div>
<div id="mq-count" class="v">-</div>
</div>
</div> </div>
<div class="box"> <div class="box">
@@ -173,6 +179,72 @@
<div id="status" class="status"></div> <div id="status" class="status"></div>
<div class="hint">Изменения применяются напрямую через CRD Fission.</div> <div class="hint">Изменения применяются напрямую через CRD Fission.</div>
</div> </div>
<!-- MQ-триггеры -->
<div class="box">
<div class="toolbar">
<div style="font-weight:600;">MQ-триггеры</div>
<button class="btn" onclick="openCreateMQ()">+ Добавить</button>
</div>
<table>
<thead>
<tr>
<th>Имя</th>
<th>Очередь</th>
<th>Функция</th>
<th>Endpoint SQS</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="mq-rows">
<tr><td colspan="6" style="color:var(--fg-muted,#888);text-align:center;">Загрузка...</td></tr>
</tbody>
</table>
<div class="hint">MQ-триггер поллит очередь SQS и вызывает Fission-функцию при появлении сообщений.</div>
</div>
</div>
<!-- Модалка: создать MQ-триггер -->
<div id="mq-create-modal" class="modal">
<div class="panel" style="width:520px;max-width:96vw;">
<h3>📨 Создать MQ-триггер</h3>
<div class="row">
<div class="field">
<label>Имя триггера</label>
<input id="mq-name" placeholder="weather-trigger">
</div>
<div class="field">
<label>Функция</label>
<input id="mq-fn" placeholder="weather-store">
</div>
</div>
<div class="row">
<div class="field">
<label>Имя очереди (SQS)</label>
<input id="mq-queue" placeholder="weather-raw">
</div>
<div class="field">
<label>SQS Endpoint</label>
<input id="mq-endpoint" placeholder="http://shared-sqs.shared-sqs.svc.cluster.local:4100">
</div>
</div>
<div class="row">
<div class="field">
<label>Access Key</label>
<input id="mq-access-key" placeholder="SSAK-...">
</div>
<div class="field">
<label>Secret Key</label>
<input id="mq-secret-key" type="password" placeholder="...">
</div>
</div>
<div id="mq-create-error" class="modal-error"></div>
<div class="actions">
<button class="btn ghost" onclick="closeMQCreate()">Отмена</button>
<button class="btn" onclick="submitCreateMQ()">Создать</button>
</div>
</div>
</div> </div>
<!-- Модалка: создать функцию из кода (prefix cc-) --> <!-- Модалка: создать функцию из кода (prefix cc-) -->
@@ -234,6 +306,12 @@
<button class="btn ghost" id="cc-gen-btn" onclick="showGenPrompt('cc')">&#x2728; Сгенерировать код LLM</button> <button class="btn ghost" id="cc-gen-btn" onclick="showGenPrompt('cc')">&#x2728; Сгенерировать код LLM</button>
<button class="btn ghost" id="cc-exp-btn" onclick="aiExplain('cc-code','cc-lang','cc-ai-result')">&#x1F4D6; LLM: Что делает?</button> <button class="btn ghost" id="cc-exp-btn" onclick="aiExplain('cc-code','cc-lang','cc-ai-result')">&#x1F4D6; LLM: Что делает?</button>
</div> </div>
<div style="margin-top:10px;">
<label id="cc-deps-label" style="font-size:12px; color:var(--text-secondary); margin-bottom:4px; display:block;">Зависимости (requirements.txt)</label>
<textarea id="cc-deps" rows="3" placeholder="boto3
requests>=2.28
psycopg2-binary" style="width:100%; box-sizing:border-box; font-family:monospace; font-size:12px; background:#1a1a2e; border:1px dashed #3a3a5c; border-radius:4px; color:#cdd6f4; padding:6px 8px; resize:none; overflow-y:auto;"></textarea>
</div>
</div> </div>
<div id="cc-gen-prompt" style="display:none; margin-top:8px; gap:6px; align-items:center;"> <div id="cc-gen-prompt" style="display:none; margin-top:8px; gap:6px; align-items:center;">
<input id="cc-gen-desc" type="text" <input id="cc-gen-desc" type="text"
@@ -373,6 +451,12 @@
<button class="btn ghost" id="e-exp-btn" onclick="aiExplain('e-code','e-lang-hidden','e-ai-result')">&#x1F4D6; <button class="btn ghost" id="e-exp-btn" onclick="aiExplain('e-code','e-lang-hidden','e-ai-result')">&#x1F4D6;
LLM: Что делает?</button> LLM: Что делает?</button>
</div> </div>
<div style="margin-top:10px;">
<label id="e-deps-label" style="font-size:12px; color:var(--text-secondary); margin-bottom:4px; display:block;">Зависимости (requirements.txt)</label>
<textarea id="e-deps" rows="3" placeholder="boto3
requests>=2.28
psycopg2-binary" style="width:100%; box-sizing:border-box; font-family:monospace; font-size:12px; background:#1a1a2e; border:1px dashed #3a3a5c; border-radius:4px; color:#cdd6f4; padding:6px 8px; resize:none; overflow-y:auto;"></textarea>
</div>
</div> </div>
<div id="e-archive-area" style="display:none;"> <div id="e-archive-area" style="display:none;">
<div id="e-archive-current" style="margin-bottom:10px; padding:8px 12px; background:var(--bg-alt); border-radius:6px; font-size:13px; color:var(--text-secondary);"> <div id="e-archive-current" style="margin-bottom:10px; padding:8px 12px; background:var(--bg-alt); border-radius:6px; font-size:13px; color:var(--text-secondary);">
@@ -542,7 +626,7 @@
</div> </div>
<div class="actions" style="justify-content:space-between; align-items:center;"> <div class="actions" style="justify-content:space-between; align-items:center;">
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.86</span> <span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.96</span>
<button class="btn ghost" onclick="closeHelp()">Закрыть</button> <button class="btn ghost" onclick="closeHelp()">Закрыть</button>
</div> </div>
</div> </div>
@@ -1,5 +1,43 @@
/* ai.js — AI/LLM функции: проверка, генерация, объяснение, ассистент */ /* ai.js — AI/LLM функции: проверка, генерация, объяснение, ассистент */
// PYTHON_STDLIB — стандартная библиотека Python (не требуют pip install).
var PYTHON_STDLIB = new Set([
'os','sys','json','time','re','math','io','collections','itertools','functools',
'datetime','pathlib','urllib','http','threading','asyncio','logging','random',
'string','struct','hashlib','hmac','base64','uuid','copy','abc','typing',
'dataclasses','contextlib','enum','warnings','traceback','inspect','importlib',
'subprocess','socket','ssl','email','html','xml','csv','sqlite3','unittest',
'gc','weakref','array','queue','heapq','bisect','decimal','fractions',
'statistics','textwrap','difflib','shutil','glob','fnmatch','tempfile',
'zipfile','tarfile','gzip','bz2','lzma','pickle','shelve','codecs',
'unicodedata','ast','dis','types','builtins','operator','numbers','cmath',
'pprint','reprlib','platform','signal','mmap','ctypes','multiprocessing',
'concurrent','select','selectors','errno','atexit','sched','calendar',
'locale','gettext','argparse','getopt','shlex','configparser','tokenize',
'runpy','pkgutil','site','sysconfig','distutils','zipimport','abc','io',
'__future__','_thread','threading','contextvars','netrc','plistlib',
'html','xml','http','urllib','email','mailbox','mimetypes','encodings',
'codecs','unicodedata','readline','rlcompleter','curses','idlelib','tkinter',
]);
// detectExternalPythonImports — возвращает список модулей, которых нет в stdlib.
function detectExternalPythonImports(code) {
var external = [];
var seen = {};
var patterns = [/^import\s+([\w]+)/gm, /^from\s+([\w]+)/gm];
for (var pi = 0; pi < patterns.length; pi++) {
var m;
while ((m = patterns[pi].exec(code)) !== null) {
var mod = m[1];
if (!PYTHON_STDLIB.has(mod) && !seen[mod]) {
seen[mod] = true;
external.push(mod);
}
}
}
return external;
}
function llmGeneratedWarning(lang) { function llmGeneratedWarning(lang) {
var text = 'Сделано LLM. Не доверяй, проверяй!'; var text = 'Сделано LLM. Не доверяй, проверяй!';
switch (lang) { switch (lang) {
@@ -45,7 +83,25 @@ async function aiCheck(codeId, langId, resultId) {
var data = await requestJSON(API_BASE + '/ai/check', 'POST', { language: lang, code: code }); var data = await requestJSON(API_BASE + '/ai/check', 'POST', { language: lang, code: code });
resEl.style.background = data.ok ? '#1a3a1a' : '#3a1a1a'; resEl.style.background = data.ok ? '#1a3a1a' : '#3a1a1a';
resEl.style.color = data.ok ? '#8f8' : '#f88'; resEl.style.color = data.ok ? '#8f8' : '#f88';
resEl.textContent = data.result || '(пустой ответ)'; var resultText = data.result || '(пустой ответ)';
// Для Python: проверяем внешние импорты vs поле зависимостей
if (lang === 'python') {
var depsId = codeId.replace('-code', '-deps'); // cc-code→cc-deps, e-code→e-deps
var depsEl = document.getElementById(depsId);
var depsVal = depsEl ? depsEl.value.trim() : '';
var external = detectExternalPythonImports(code);
if (external.length > 0 && !depsVal) {
resultText += '\n\n⚠️ Внешние библиотеки: ' + external.join(', ') +
'\nДобавьте их в поле "Зависимости (requirements.txt)" или убедитесь что они уже есть в Python-окружении.';
if (data.ok) {
resEl.style.background = '#3a2a00';
resEl.style.color = '#ffa';
}
}
}
resEl.textContent = resultText;
} catch (e) { } catch (e) {
resEl.style.background = '#3a2a00'; resEl.style.background = '#3a2a00';
resEl.style.color = '#ffa'; resEl.style.color = '#ffa';
@@ -16,6 +16,9 @@ async function reloadAll() {
getJSON(API_BASE + '/timetriggers') getJSON(API_BASE + '/timetriggers')
]); ]);
// MQ-триггеры загружаем параллельно, не блокируем основную таблицу
if (typeof loadMQTriggers === 'function') loadMQTriggers();
S.envs = envs || []; S.envs = envs || [];
S.fns = fns || []; S.fns = fns || [];
S.httpTriggers = http || []; S.httpTriggers = http || [];
@@ -93,6 +93,27 @@ function doLogout() {
showLoginOverlay(); showLoginOverlay();
} }
async function openAnalytics() {
var token = localStorage.getItem('auth_token');
var env = localStorage.getItem('auth_env') || 'test';
if (!token) { alert('Требуется авторизация'); return; }
try {
var r = await fetch(API_BASE + '/stats/dashboard-url', {
headers: { 'X-Auth-Token': token, 'X-Auth-Env': env }
});
if (!r.ok) throw new Error('HTTP ' + r.status);
var d = await r.json();
if (d.url) {
window.open(d.url, '_blank', 'noopener');
} else {
// Grafana не настроена или org ещё провизируется — ссылка на оператора
window.open('/grafana/', '_blank', 'noopener');
}
} catch (e) {
alert('Аналитика временно недоступна: ' + e.message);
}
}
function checkAuth() { function checkAuth() {
var storedToken = localStorage.getItem('auth_token'); var storedToken = localStorage.getItem('auth_token');
if (!storedToken) { if (!storedToken) {
@@ -7,6 +7,23 @@ function onLangChangeCode() {
document.getElementById('cc-entry').value = t.entrypoint; document.getElementById('cc-entry').value = t.entrypoint;
document.getElementById('cc-code').value = t.code; document.getElementById('cc-code').value = t.code;
} }
// Обновляем лейбл поля зависимостей под язык
var depsLabels = {
python: 'Зависимости (requirements.txt)',
nodejs: 'Зависимости (package.json dependencies)',
php: 'Зависимости (composer.json)',
ruby: 'Зависимости (Gemfile)',
};
var depsPlaceholders = {
python: 'boto3\nrequests>=2.28\npsycopg2-binary',
nodejs: 'express: ^4.18.2\naxios: ^1.6.0',
php: '{\n "require": {\n "guzzlehttp/guzzle": "^7.0"\n }\n}',
ruby: "gem 'httparty'\ngem 'pg'",
};
var label = document.getElementById('cc-deps-label');
var area = document.getElementById('cc-deps');
if (label) label.textContent = depsLabels[lang] || 'Зависимости';
if (area) area.placeholder = depsPlaceholders[lang] || '';
} }
function openCreateCode() { function openCreateCode() {
@@ -16,6 +33,7 @@ function openCreateCode() {
document.getElementById('cc-route').value = ''; document.getElementById('cc-route').value = '';
document.getElementById('cc-methods').value = 'GET'; document.getElementById('cc-methods').value = 'GET';
document.getElementById('cc-timeout').value = '60'; document.getElementById('cc-timeout').value = '60';
document.getElementById('cc-deps').value = '';
document.getElementById('cc-schedule-enabled').checked = false; document.getElementById('cc-schedule-enabled').checked = false;
document.getElementById('cc-cron').value = ''; document.getElementById('cc-cron').value = '';
toggleScheduleFields('cc'); toggleScheduleFields('cc');
@@ -47,7 +65,8 @@ async function submitCreateCode() {
route: document.getElementById('cc-route').value.trim(), route: document.getElementById('cc-route').value.trim(),
methods: parseMethods(document.getElementById('cc-methods').value), methods: parseMethods(document.getElementById('cc-methods').value),
timeout: parseTimeout(document.getElementById('cc-timeout').value), timeout: parseTimeout(document.getElementById('cc-timeout').value),
code: document.getElementById('cc-code').value code: document.getElementById('cc-code').value,
deps: document.getElementById('cc-deps').value.trim(),
}); });
try { try {
@@ -188,6 +188,13 @@ async function openEdit(name) {
else if (envName.includes('ruby')) lang = 'ruby'; else if (envName.includes('ruby')) lang = 'ruby';
else if (envName.includes('php')) lang = 'php'; else if (envName.includes('php')) lang = 'php';
document.getElementById('e-lang-hidden').value = lang; document.getElementById('e-lang-hidden').value = lang;
// Обновляем лейбл и плейсхолдер поля зависимостей
var depsLabels = {python:'Зависимости (requirements.txt)', nodejs:'Зависимости (package.json dependencies)', php:'Зависимости (composer.json)', ruby:'Зависимости (Gemfile)'};
var depsPlaceholders = {python:'boto3\nrequests>=2.28', nodejs:'express: ^4.18.2\naxios: ^1.6.0', php:'{\n "require": {\n "guzzlehttp/guzzle": "^7.0"\n }\n}', ruby:"gem 'httparty'\ngem 'pg'"};
var eDepsLabel = document.getElementById('e-deps-label');
var eDepsArea = document.getElementById('e-deps');
if (eDepsLabel) eDepsLabel.textContent = depsLabels[lang] || 'Зависимости';
if (eDepsArea) { eDepsArea.placeholder = depsPlaceholders[lang] || ''; eDepsArea.value = fn.deps || ''; }
var aiRes = document.getElementById('e-ai-result'); var aiRes = document.getElementById('e-ai-result');
if (aiRes) { aiRes.style.display = 'none'; aiRes.textContent = ''; } if (aiRes) { aiRes.style.display = 'none'; aiRes.textContent = ''; }
var warnEl = document.getElementById('e-tf-warn'); var warnEl = document.getElementById('e-tf-warn');
@@ -242,6 +249,7 @@ async function submitEdit() {
} else { } else {
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/code', 'PUT', { await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/code', 'PUT', {
code: document.getElementById('e-code').value, code: document.getElementById('e-code').value,
deps: document.getElementById('e-deps').value.trim(),
timeout: parseTimeout(document.getElementById('e-timeout').value) timeout: parseTimeout(document.getElementById('e-timeout').value)
}); });
} }
+103
View File
@@ -0,0 +1,103 @@
// mq.js — MQ-триггеры (SQS → Fission function)
// Архитектура: Console создаёт K8s Deployment + Secret (sqs-consumer) в namespace пользователя.
// sqs-consumer поллит SQS очередь → HTTP POST в Fission-функцию → DeleteMessage.
// Backend: POST /console/api/mqtriggers, DELETE /console/api/mqtriggers/{name}
// v1.3.88
// ── Загрузка и отрисовка ────────────────────────────────────────────
async function loadMQTriggers() {
try {
const data = await apiFetch('/console/api/mqtriggers');
renderMQTable(data.items || []);
const cnt = document.getElementById('mq-count');
if (cnt) cnt.textContent = (data.items || []).length;
} catch (e) {
renderMQTable([]);
const cnt = document.getElementById('mq-count');
if (cnt) cnt.textContent = '0';
}
}
function renderMQTable(items) {
const tbody = document.getElementById('mq-rows');
if (!tbody) return;
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="6" style="color:var(--fg-muted,#888);text-align:center;">Нет MQ-триггеров</td></tr>';
return;
}
tbody.innerHTML = items.map(t => `
<tr>
<td>${escHtml(t.name)}</td>
<td>${escHtml(t.queue)}</td>
<td>${escHtml(t.functionName)}</td>
<td style="font-size:0.8em;color:var(--fg-muted,#aaa);">${escHtml(t.sqsEndpoint || '')}</td>
<td><span style="color:${t.ready ? '#4a4' : '#a44'}">${t.ready ? '▶ Running' : '◼ Pending'}</span></td>
<td>
<button class="btn ghost small" onclick="deleteMQTrigger('${escHtml(t.name)}')">Удалить</button>
</td>
</tr>
`).join('');
}
// ── Создание ─────────────────────────────────────────────────────────
function openCreateMQ() {
document.getElementById('mq-name').value = '';
document.getElementById('mq-fn').value = '';
document.getElementById('mq-queue').value = '';
document.getElementById('mq-endpoint').value = 'http://shared-sqs.shared-sqs.svc.cluster.local:4100';
document.getElementById('mq-access-key').value = '';
document.getElementById('mq-secret-key').value = '';
document.getElementById('mq-create-error').textContent = '';
document.getElementById('mq-create-modal').style.display = 'flex';
}
function closeMQCreate() {
document.getElementById('mq-create-modal').style.display = 'none';
}
async function submitCreateMQ() {
const name = document.getElementById('mq-name').value.trim();
const fnName = document.getElementById('mq-fn').value.trim();
const queue = document.getElementById('mq-queue').value.trim();
const endpoint = document.getElementById('mq-endpoint').value.trim();
const accessKey = document.getElementById('mq-access-key').value.trim();
const secretKey = document.getElementById('mq-secret-key').value.trim();
const errEl = document.getElementById('mq-create-error');
if (!name || !fnName || !queue || !accessKey || !secretKey) {
errEl.textContent = 'Заполните все поля';
return;
}
errEl.textContent = '';
try {
await apiFetch('/console/api/mqtriggers', {
method: 'POST',
body: JSON.stringify({ name, functionName: fnName, queue, sqsEndpoint: endpoint, accessKey, secretKey })
});
closeMQCreate();
await loadMQTriggers();
} catch (e) {
errEl.textContent = e.message || 'Ошибка создания';
}
}
// ── Удаление ─────────────────────────────────────────────────────────
async function deleteMQTrigger(name) {
if (!confirm(`Удалить MQ-триггер "${name}"?`)) return;
try {
await apiFetch(`/console/api/mqtriggers/${encodeURIComponent(name)}`, { method: 'DELETE' });
await loadMQTriggers();
} catch (e) {
alert('Ошибка удаления: ' + (e.message || e));
}
}
// ── Утилита ──────────────────────────────────────────────────────────
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
-27
View File
@@ -1,27 +0,0 @@
package runtime
import (
"archive/zip"
"bytes"
)
// buildZip создаёт zip-архив с одним файлом fileName и содержимым content.
// Вспомогательная функция: используется в buildScriptZip (PHP/Ruby),
// а также как основа для buildGoSourceZip и buildJSDeployZip.
func buildZip(fileName string, content []byte) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
fw, err := zw.Create(fileName)
if err != nil {
return nil, err
}
if _, err := fw.Write(content); err != nil {
return nil, err
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
-13
View File
@@ -1,13 +0,0 @@
package runtime
// BuildScriptZip создаёт zip-архив с одним файлом fileName и содержимым code.
// Используется для PHP и Ruby — языков где среда Fission ожидает
// именованный файл (handler.rb, handler.pl, main.php и т.д.) внутри архива.
//
// Почему zip, а не просто literal:
// Fission poolmgr при специализации пода распаковывает deployment archive,
// находит нужный файл по имени и загружает его в среду выполнения.
// Если передать просто байты кода в deployment.literal — среда не знает расширение.
func BuildScriptZip(code, fileName string) ([]byte, error) {
return buildZip(fileName, []byte(code))
}
@@ -0,0 +1,102 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: grafana-dashboard-fission
namespace: grafana
data:
fission-overview.json: |
{
"title": "Fission — Operator Overview",
"uid": "fission-overview",
"tags": ["fission"],
"timezone": "browser",
"refresh": "30s",
"time": { "from": "now-24h", "to": "now" },
"templating": {
"list": [
{
"name": "namespace",
"type": "query",
"datasource": { "type": "postgres", "uid": "fission-pg" },
"query": "SELECT DISTINCT namespace FROM invocations ORDER BY 1",
"includeAll": true,
"multi": true,
"label": "Namespace",
"current": { "text": "All", "value": "$__all" }
}
]
},
"panels": [
{
"id": 1,
"title": "Вызовы в час",
"type": "timeseries",
"gridPos": { "x": 0, "y": 0, "w": 16, "h": 8 },
"datasource": { "type": "postgres", "uid": "fission-pg" },
"targets": [
{
"rawSql": "SELECT date_trunc('hour', started_at) AS time, count(*) AS value, namespace FROM invocations WHERE started_at BETWEEN $__timeFrom() AND $__timeTo() AND ('$namespace' = '$__all' OR namespace = ANY(string_to_array('$namespace', ','))) GROUP BY 1, namespace ORDER BY 1",
"format": "time_series",
"refId": "A"
}
]
},
{
"id": 2,
"title": "Успех vs Ошибки",
"type": "piechart",
"gridPos": { "x": 16, "y": 0, "w": 8, "h": 8 },
"datasource": { "type": "postgres", "uid": "fission-pg" },
"targets": [
{
"rawSql": "SELECT CASE WHEN status_code >= 200 AND status_code < 300 THEN 'success' ELSE 'error' END AS metric, count(*) AS value FROM invocations WHERE started_at BETWEEN $__timeFrom() AND $__timeTo() AND ('$namespace' = '$__all' OR namespace = ANY(string_to_array('$namespace', ','))) GROUP BY 1",
"format": "table",
"refId": "A"
}
]
},
{
"id": 3,
"title": "Топ функций по вызовам",
"type": "bargauge",
"gridPos": { "x": 0, "y": 8, "w": 12, "h": 8 },
"datasource": { "type": "postgres", "uid": "fission-pg" },
"targets": [
{
"rawSql": "SELECT function_name AS metric, count(*) AS value FROM invocations WHERE started_at BETWEEN $__timeFrom() AND $__timeTo() AND ('$namespace' = '$__all' OR namespace = ANY(string_to_array('$namespace', ','))) GROUP BY 1 ORDER BY 2 DESC LIMIT 10",
"format": "table",
"refId": "A"
}
]
},
{
"id": 4,
"title": "Средняя латентность (ms)",
"type": "timeseries",
"gridPos": { "x": 12, "y": 8, "w": 12, "h": 8 },
"datasource": { "type": "postgres", "uid": "fission-pg" },
"targets": [
{
"rawSql": "SELECT date_trunc('hour', started_at) AS time, round(avg(duration_ms)) AS avg_ms, round(percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms)) AS p95_ms FROM invocations WHERE started_at BETWEEN $__timeFrom() AND $__timeTo() AND ('$namespace' = '$__all' OR namespace = ANY(string_to_array('$namespace', ','))) GROUP BY 1 ORDER BY 1",
"format": "time_series",
"refId": "A"
}
]
},
{
"id": 5,
"title": "Последние вызовы",
"type": "table",
"gridPos": { "x": 0, "y": 16, "w": 24, "h": 8 },
"datasource": { "type": "postgres", "uid": "fission-pg" },
"targets": [
{
"rawSql": "SELECT started_at AS time, namespace, function_name, trigger_type, event_type, status_code, duration_ms FROM invocations WHERE ('$namespace' = '$__all' OR namespace = ANY(string_to_array('$namespace', ','))) ORDER BY started_at DESC LIMIT 50",
"format": "table",
"refId": "A"
}
]
}
],
"schemaVersion": 38
}

Some files were not shown because too many files have changed in this diff Show More