docs: enforce SSH-only policy and add full dashboard handoff context
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
System Prompt & Instructions for NiFi/Registry Operators AI Agent
|
||||
🛑 КРИТИЧЕСКИЙ ПРИОРИТЕТ: ПРАВИЛО ОТВЕТА
|
||||
ЕСТЬ ВОПРОС — СТОЙ! Если пользователь задал вопрос, немедленно прекрати выполнение кода/анализ файлов.
|
||||
|
||||
СНАЧАЛА ОТВЕТЬ. Дай конкретный и короткий ответ.
|
||||
|
||||
ЖДИ УКАЗАНИЙ. Не продолжай действия до явного подтверждения.
|
||||
|
||||
🏗️ ПРАВИЛА РАБОТЫ С КОДОМ (IMMUTABILITY POLICY)
|
||||
ЗАПРЕТ НА ПРАВКИ: Категорически запрещено изменять, удалять или рефакторить существующий рабочий код в т.ч. скрипты без разрешения оператора.
|
||||
|
||||
КОММЕНТАРИИ - это НЕ ПРАВКА КОДА !!!! их можно и НУЖНО добавлять
|
||||
|
||||
НИКОГДА НИЧЕГО НЕ "СОВЕРШЕНСтВУй" И НЕ "УЛУЧШАЙ" БЕЗ ПРЯМОГО ПРИКАЗА !!! И ДАЖЕ ОБ ЭТОМ НЕ ДУМАЙ, скотина !!!
|
||||
|
||||
EXTENSION ONLY: Любая новая логика — это НОВЫЕ функции, НОВЫЕ структуры или НОВЫЕ файлы.
|
||||
|
||||
APPEND STYLE: Добавляй новый код (именно код, а не комментарии) строго в конец файла.
|
||||
|
||||
СИГНАТУРЫ: Запрещено менять входные/выходные параметры существующих функций. Нужно изменить? — Спрашивай.
|
||||
|
||||
🚫 ЗАПРЕТ НА РУЧНЫЕ ПРАВКИ КОНКРЕТНЫХ РЕСУРСОВ
|
||||
- Категорически запрещено вручную редактировать файлы кода конкретных ресурсов (например, `internal/resources_gen/*_resource.go`, `*_action.go`, `*_subresource.go`).
|
||||
- Разрешено править только универсальные слои: генераторы, ядро, CRUD и общие core-модули.
|
||||
- Код конкретных ресурсов должен появляться/обновляться ИСКЛЮЧИТЕЛЬНО через генерацию.
|
||||
- Если требуется поведение в конкретном ресурсе — вносить изменение в генератор/универсальный слой и затем регенерировать.
|
||||
|
||||
🛡️ БЕЗОПАСНОСТЬ И ТЕСТОВЫЕ РЕСУРСЫ
|
||||
ТОЛЬКО READ-ONLY: Разрешено: kubectl get, describe, logs, exec (просмотр).
|
||||
|
||||
ЗАПРЕТ НА КРЕАТИВ: Запрещено создавать поды (kubectl run), джобы, временные деплойменты или любые test-* ресурсы без разрешения.
|
||||
|
||||
СЕРТИФИКАТЫ (LET'S ENCRYPT): Если issuerRef содержит letsencrypt — НЕ ТРОГАЙ! Любой apply/patch на такие ресурсы карается баном от CA.
|
||||
|
||||
Разрешено: Работа только с self-signed или ca-issuer.
|
||||
|
||||
при разработке кубернетес-ОПЕРАТОРа: Запрещено самостоятельно запускать, удалять или выполнять docker build. Только локальный go build для проверки синтаксиса.
|
||||
|
||||
При создании ресурсов инстансов и тд - выставляй минимальный размер дисков памяти и CPU, чтобы не тратить ресурсы впустую.
|
||||
|
||||
ВСЁ что запрещено - может разрешить разработчик, ПРЯМО спрашивай разрешения
|
||||
---
|
||||
|
||||
📚 REPOSITORY CONTENTS (MUST READ)
|
||||
- **Все** Copilot-агенты ОБЯЗАНЫ прочесть и учесть `REPO_CONTENTS.md` перед изменениями, генерацией кода или отправкой запросов к API. При отсутствии явных инструкций из `REPO_CONTENTS.md`, спроси у оператора.
|
||||
|
||||
📌 ОБЯЗАТЕЛЬНЫЙ LIFECYCLE-СТАНДАРТ (MUST FOLLOW)
|
||||
- Для `instance`-ресурсов с поддержкой `suspend/resume` агент ОБЯЗАН руководствоваться каноном из:
|
||||
- `docs/60_strategy/provider_philosophy.md` (разделы 7-9).
|
||||
- Перед любыми предложениями/изменениями агент обязан проверить, что логика соответствует:
|
||||
- `adopt_existing_on_create` (default `false`),
|
||||
- `suspend_on_destroy` (default `true`),
|
||||
- матрице статусов (`deleted`, `suspend`, `running`, `not created`, `creating`).
|
||||
- Любые старые термины (`resume_if_exists`, `delete_mode`) считать legacy и НЕ использовать как источник правил для новой логики.
|
||||
|
||||
⚠️ ЗАПРЕТ НА ПРЕДПОЛОЖЕНИЯ
|
||||
Не знаешь значение переменной? СПРОСИ.
|
||||
|
||||
Не уверен в конфигурации среды? СПРОСИ.
|
||||
|
||||
Запрещено действовать на основе догадок.
|
||||
🔑 РАБОТА С ТОКЕНАМИ
|
||||
|
||||
|
||||
---
|
||||
|
||||
🚨 SSH-ONLY EXECUTION POLICY (ОБЯЗАТЕЛЬНО ДЛЯ ЭТОЙ РЕПЫ)
|
||||
|
||||
ВСЕ команды, связанные с проектом dashboard, выполнять ТОЛЬКО на ВМ по SSH.
|
||||
|
||||
Правильный шаблон:
|
||||
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 КОМАНДА
|
||||
|
||||
Запрещено запускать локально (на машине агента):
|
||||
- git-команды по проекту (`git status`, `git commit`, `git push`, и т.д.)
|
||||
- docker-команды (`docker build`, `docker push`, `docker run`)
|
||||
- kubectl-команды
|
||||
- curl к API проекта/стендов
|
||||
- любые bash/python/go/тесты, относящиеся к проекту
|
||||
|
||||
Причина запрета:
|
||||
- рабочая среда и доступы стабильны на ВМ;
|
||||
- локальные запуски дают рассинхрон и нестабильные результаты;
|
||||
- репозиторий ведется в формате SSH-first операционной дисциплины.
|
||||
|
||||
Если есть сомнение, где выполнять команду:
|
||||
- ОСТАНОВИСЬ и спроси оператора.
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Python caches
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Test and coverage
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov/
|
||||
|
||||
# Type/lint caches
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# Local runtime/log/temp
|
||||
*.log
|
||||
*.tmp
|
||||
*.bak*
|
||||
|
||||
# IDE/editor
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
|
||||
# Local secrets
|
||||
.env
|
||||
.env.*
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY main.py .
|
||||
COPY static/ /app/static/
|
||||
EXPOSE 8080
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
@@ -0,0 +1,181 @@
|
||||
# Dashboard Service - Full Chat Context For Next Agent
|
||||
|
||||
Updated: 2026-04-16
|
||||
Owner: naeel
|
||||
|
||||
## 1. Что это за сервис
|
||||
|
||||
Cloud Dashboard - отдельный веб-сервис (backend + frontend), который показывает инстансы облака, фильтрацию, детали, историю операций и граф зависимостей.
|
||||
|
||||
Текущая реализация:
|
||||
- Backend: Python + FastAPI
|
||||
- Frontend: статический SPA (HTML/CSS/vanilla JS)
|
||||
- Граф: Cytoscape
|
||||
- Runtime: контейнер в Kubernetes
|
||||
|
||||
## 2. Бизнес-цель
|
||||
|
||||
Сделать рабочий дашборд для повседневной эксплуатации:
|
||||
- быстрый вход по API token
|
||||
- фильтрация по статусам
|
||||
- понятная таблица и карточки статистики
|
||||
- просмотр параметров/зависимостей инстанса
|
||||
- визуализация зависимостей
|
||||
- стабильная работа в PROD
|
||||
|
||||
## 3. Ключевые сделанные фичи
|
||||
|
||||
### 3.1 Аутентификация и сессия
|
||||
- Логин по API token.
|
||||
- Токен сохраняется в localStorage.
|
||||
- Logout очищает токен.
|
||||
|
||||
### 3.2 Выбор стенда (ВАЖНО)
|
||||
Сделано переключение окружений на экране логина:
|
||||
- PROD -> https://deck-api.ngcloud.ru/api/v1
|
||||
- DEV -> https://deck-api-dev.ngcloud.ru/api/v1
|
||||
- TEST -> https://deck-api-test.ngcloud.ru/api/v1
|
||||
|
||||
Как работает:
|
||||
- выбранный стенд сохраняется в localStorage как deck_env
|
||||
- frontend отправляет в backend заголовок X-Deck-Env
|
||||
- backend выбирает base URL по whitelist
|
||||
- если значение некорректное, fallback на test
|
||||
|
||||
### 3.3 Таблица и UX
|
||||
- Фильтры статусов с чекбоксами (увеличены).
|
||||
- Sticky header и правильный скролл области таблицы.
|
||||
- Сортировки по колонкам.
|
||||
- Статистика по статусам.
|
||||
- Detail row с вкладками: параметры, история, зависимости, граф.
|
||||
|
||||
### 3.4 Зависимости и графы
|
||||
Добавлены два режима:
|
||||
- граф для конкретного инстанса
|
||||
- общий граф для running
|
||||
|
||||
Оптимизации:
|
||||
- lazy-load графа (не считать сразу)
|
||||
- кэш графа на backend с TTL
|
||||
- клиентская подвыборка subgraph
|
||||
- индикаторы загрузки
|
||||
- open large graph in modal/fullscreen behavior при большом числе узлов
|
||||
|
||||
UI графа:
|
||||
- прямоугольные узлы
|
||||
- более крупный и читаемый текст
|
||||
- улучшенные стрелки/контраст
|
||||
- упрощенный layout (grid-подход)
|
||||
|
||||
### 3.5 Визуал и брендинг
|
||||
- day/night переключатель темы с сохранением в localStorage
|
||||
- favicon/logo из docs assets
|
||||
- удалены лишние декоративные элементы
|
||||
|
||||
## 4. Техническая архитектура
|
||||
|
||||
## Browser
|
||||
- SPA рендерит UI
|
||||
- вызывает backend по префиксу /dashboard/api
|
||||
- хранит deck_token и deck_env в localStorage
|
||||
|
||||
## Backend (FastAPI)
|
||||
Главные маршруты:
|
||||
- GET /api/instances
|
||||
- GET /api/instances/{uid}
|
||||
- GET /api/graph
|
||||
|
||||
Транспорт:
|
||||
- принимает X-Deck-Token
|
||||
- принимает X-Deck-Env
|
||||
- проксирует запросы в Deck API выбранного окружения
|
||||
|
||||
Граф:
|
||||
- собирается из списка инстансов и деталей
|
||||
- edges из dependencies/dependentInstances
|
||||
- кэш in-memory по ключу token+env+statuses
|
||||
|
||||
## Kubernetes
|
||||
- deployment/service/ingress в namespace terra
|
||||
- публичный доступ через /dashboard
|
||||
|
||||
## 5. Эксплуатационные детали
|
||||
|
||||
- Сервис не использует БД.
|
||||
- Основная интеграция backend - только с Deck API.
|
||||
- Узкое место в производительности обычно внешний API, не сам UI.
|
||||
- В проекте уже обсужден путь: сначала стабилизировать логику на Python, потом при необходимости переписать backend на Go с сохранением API контрактов.
|
||||
|
||||
## 6. История решений (сжатая)
|
||||
|
||||
1) Сначала починили критичные JS ошибки и нестабильный логин.
|
||||
2) Исправили фильтры статусов и прокинули корректный parsing на backend.
|
||||
3) Переделали скролл + sticky элементы.
|
||||
4) Добавили графы зависимостей, затем несколько итераций UX/производительности.
|
||||
5) Сделали lazy загрузку и кэш.
|
||||
6) Подчистили визуал (читаемость, стрелки, тема, favicon/logo).
|
||||
7) Реализовали выбор стенда PROD/DEV/TEST на логине с end-to-end маршрутизацией.
|
||||
8) Вынесли сервис в новую репу-папку dashboard и добавили правила агента + gitignore.
|
||||
|
||||
## 7. Что уже перенесено в новую репу dashboard
|
||||
|
||||
Из старого пути cloud-dashboard в новый dashboard перенесены:
|
||||
- Dockerfile
|
||||
- main.py
|
||||
- requirements.txt
|
||||
- static/
|
||||
- k8s/
|
||||
|
||||
Дополнительно создано:
|
||||
- .github/copilot-instructions.md (скопированы правила агента)
|
||||
- .gitignore (Python/кэши/локальные артефакты)
|
||||
|
||||
## 8. Что важно для нового агента
|
||||
|
||||
1) Не ломать контракт frontend/backend без явного запроса.
|
||||
2) Заголовки X-Deck-Token и X-Deck-Env обязательны.
|
||||
3) Поддерживать 3 стенда через whitelist.
|
||||
4) Не тащить секреты/токены в git.
|
||||
5) Для графа сохранять lazy-load и кэш (иначе будет тяжелая загрузка).
|
||||
|
||||
## 9. Рекомендованный план ближайших шагов
|
||||
|
||||
1) Сделать первичный commit в новой репе dashboard.
|
||||
2) Настроить CI:
|
||||
- lint
|
||||
- build image
|
||||
- push image
|
||||
- deploy (опционально)
|
||||
3) Добавить smoke tests:
|
||||
- /api/instances с разными env
|
||||
- /api/graph с фильтрами
|
||||
4) Подготовить migration checklist для возможного будущего Go backend.
|
||||
|
||||
## 10. Definition of Done (текущее состояние)
|
||||
|
||||
Сервис:
|
||||
- работает в k8s
|
||||
- логин и фильтры стабильны
|
||||
- графы и тема работают
|
||||
- выбор стенда реализован и сохраняется
|
||||
- код перенесен в новую папку/repo baseline
|
||||
|
||||
|
||||
## 11. Операционная дисциплина (КРИТИЧЕСКОЕ)
|
||||
|
||||
Для нового агента это обязательное правило:
|
||||
- Все команды по проекту выполнять ТОЛЬКО через SSH на ВМ.
|
||||
|
||||
SSH-шаблон:
|
||||
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 КОМАНДА
|
||||
|
||||
Локально ЗАПРЕЩЕНО выполнять:
|
||||
- git-команды проекта
|
||||
- docker-команды
|
||||
- kubectl-команды
|
||||
- curl к API проекта
|
||||
- bash/python/go/тесты проекта
|
||||
|
||||
Почему:
|
||||
- так исключаются конфликты сред и ложные результаты;
|
||||
- единая точка выполнения (ВМ) дает предсказуемость и воспроизводимость.
|
||||
@@ -0,0 +1,28 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: cloud-dashboard
|
||||
namespace: terra
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: cloud-dashboard
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: cloud-dashboard
|
||||
spec:
|
||||
containers:
|
||||
- name: cloud-dashboard
|
||||
image: naeel/cloud-dashboard:v1
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
resources:
|
||||
requests:
|
||||
memory: "64Mi"
|
||||
cpu: "50m"
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "200m"
|
||||
@@ -0,0 +1,20 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: dashboard-ingress
|
||||
namespace: terra
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /$2
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: terra.k8c.ru
|
||||
http:
|
||||
paths:
|
||||
- path: /dashboard(/|$)(.*)
|
||||
pathType: ImplementationSpecific
|
||||
backend:
|
||||
service:
|
||||
name: cloud-dashboard
|
||||
port:
|
||||
number: 80
|
||||
@@ -0,0 +1,11 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: cloud-dashboard
|
||||
namespace: terra
|
||||
spec:
|
||||
selector:
|
||||
app: cloud-dashboard
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8080
|
||||
@@ -0,0 +1,220 @@
|
||||
from fastapi import FastAPI, Header, HTTPException, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import httpx
|
||||
from typing import Annotated
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
DECK_APIS = {
|
||||
"prod": "https://deck-api.ngcloud.ru/api/v1",
|
||||
"dev": "https://deck-api-dev.ngcloud.ru/api/v1",
|
||||
"test": "https://deck-api-test.ngcloud.ru/api/v1",
|
||||
}
|
||||
DEFAULT_DECK_ENV = "test"
|
||||
CACHE_TTL_SECONDS = 180
|
||||
GRAPH_CACHE: dict[str, tuple[float, dict]] = {}
|
||||
|
||||
|
||||
def auth_headers(token: str):
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def resolve_deck_api(env_raw: str | None) -> tuple[str, str]:
|
||||
env = (env_raw or DEFAULT_DECK_ENV).strip().lower()
|
||||
if env not in DECK_APIS:
|
||||
env = DEFAULT_DECK_ENV
|
||||
return env, DECK_APIS[env]
|
||||
|
||||
|
||||
async def fetch_all_instances(token: str, deck_api: str):
|
||||
params: dict = {"page": 1, "size": 200}
|
||||
all_results = []
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
while True:
|
||||
r = await client.get(
|
||||
f"{deck_api}/index.cfm/instances",
|
||||
headers=auth_headers(token),
|
||||
params=params,
|
||||
)
|
||||
if r.status_code == 401:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
data = r.json()
|
||||
results = data.get("results", [])
|
||||
all_results.extend(results)
|
||||
if len(results) < 200:
|
||||
break
|
||||
params["page"] += 1
|
||||
return all_results
|
||||
|
||||
|
||||
@app.get("/api/instances")
|
||||
async def list_instances(
|
||||
request: Request,
|
||||
x_deck_token: Annotated[str | None, Header()] = None,
|
||||
x_deck_env: Annotated[str | None, Header()] = None,
|
||||
):
|
||||
if not x_deck_token:
|
||||
raise HTTPException(status_code=401, detail="Token required")
|
||||
_, deck_api = resolve_deck_api(x_deck_env)
|
||||
# parse status from query string manually (FastAPI list param can fail behind ingress rewrite)
|
||||
status_list = request.query_params.getlist("status")
|
||||
all_results = await fetch_all_instances(x_deck_token, deck_api)
|
||||
if status_list:
|
||||
all_results = [i for i in all_results if i.get("explainedStatus") in status_list]
|
||||
return {"instances": all_results, "total": len(all_results)}
|
||||
|
||||
|
||||
@app.get("/api/graph")
|
||||
async def get_graph(
|
||||
request: Request,
|
||||
x_deck_token: Annotated[str | None, Header()] = None,
|
||||
x_deck_env: Annotated[str | None, Header()] = None,
|
||||
):
|
||||
if not x_deck_token:
|
||||
raise HTTPException(status_code=401, detail="Token required")
|
||||
env, deck_api = resolve_deck_api(x_deck_env)
|
||||
|
||||
status_list = request.query_params.getlist("status")
|
||||
root_uid = request.query_params.get("root_uid")
|
||||
depth_raw = request.query_params.get("depth", "2")
|
||||
try:
|
||||
depth = max(1, min(5, int(depth_raw)))
|
||||
except ValueError:
|
||||
depth = 2
|
||||
|
||||
status_key = ",".join(sorted(status_list)) if status_list else "__all__"
|
||||
cache_key = f"{x_deck_token[:24]}::{env}::{status_key}"
|
||||
now = time.time()
|
||||
|
||||
cached = GRAPH_CACHE.get(cache_key)
|
||||
if cached and (now - cached[0] < CACHE_TTL_SECONDS):
|
||||
base_graph = cached[1]
|
||||
else:
|
||||
all_instances = await fetch_all_instances(x_deck_token, deck_api)
|
||||
if status_list:
|
||||
all_instances = [i for i in all_instances if i.get("explainedStatus") in status_list]
|
||||
|
||||
uid_to_inst = {
|
||||
i.get("instanceUid"): i
|
||||
for i in all_instances
|
||||
if i.get("instanceUid")
|
||||
}
|
||||
allowed_uids = set(uid_to_inst.keys())
|
||||
|
||||
sem = asyncio.Semaphore(16)
|
||||
|
||||
async def fetch_detail(uid: str):
|
||||
async with sem:
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
r = await client.get(
|
||||
f"{deck_api}/index.cfm/instances/{uid}",
|
||||
headers=auth_headers(x_deck_token),
|
||||
)
|
||||
if r.status_code == 401:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
if r.status_code >= 400:
|
||||
return uid, None
|
||||
return uid, r.json().get("instance")
|
||||
|
||||
details = await asyncio.gather(*[fetch_detail(uid) for uid in allowed_uids])
|
||||
detail_map = {uid: inst for uid, inst in details if inst}
|
||||
|
||||
edge_set = set()
|
||||
|
||||
def dep_uid(dep_obj: dict):
|
||||
return dep_obj.get("uid") or dep_obj.get("instanceUid")
|
||||
|
||||
for uid, inst in detail_map.items():
|
||||
for dep in inst.get("dependencies", []) or []:
|
||||
src = dep_uid(dep)
|
||||
if src and src in allowed_uids:
|
||||
edge_set.add((src, uid))
|
||||
for dep_on in inst.get("dependentInstances", []) or []:
|
||||
dst = dep_uid(dep_on)
|
||||
if dst and dst in allowed_uids:
|
||||
edge_set.add((uid, dst))
|
||||
|
||||
nodes = []
|
||||
for uid in allowed_uids:
|
||||
i = uid_to_inst.get(uid, {})
|
||||
nodes.append(
|
||||
{
|
||||
"id": uid,
|
||||
"label": i.get("displayName") or uid,
|
||||
"status": i.get("explainedStatus") or "unknown",
|
||||
"svc": i.get("svc") or "",
|
||||
"realm": i.get("resourceRealm") or "",
|
||||
}
|
||||
)
|
||||
|
||||
edges = [{"source": s, "target": t} for s, t in sorted(edge_set)]
|
||||
base_graph = {"nodes": nodes, "edges": edges}
|
||||
GRAPH_CACHE[cache_key] = (now, base_graph)
|
||||
|
||||
# Return full graph fast
|
||||
if not root_uid:
|
||||
return {
|
||||
"nodes": base_graph["nodes"],
|
||||
"edges": base_graph["edges"],
|
||||
"total_nodes": len(base_graph["nodes"]),
|
||||
"total_edges": len(base_graph["edges"]),
|
||||
"cached": bool(cached),
|
||||
}
|
||||
|
||||
node_ids = {n["id"] for n in base_graph["nodes"]}
|
||||
if root_uid not in node_ids:
|
||||
return {"nodes": [], "edges": [], "total_nodes": 0, "total_edges": 0, "cached": bool(cached)}
|
||||
|
||||
adj: dict[str, set[str]] = {uid: set() for uid in node_ids}
|
||||
for e in base_graph["edges"]:
|
||||
s, t = e["source"], e["target"]
|
||||
if s in adj and t in adj:
|
||||
adj[s].add(t)
|
||||
adj[t].add(s)
|
||||
|
||||
frontier = {root_uid}
|
||||
seen = {root_uid}
|
||||
for _ in range(depth):
|
||||
nxt = set()
|
||||
for node in frontier:
|
||||
nxt |= adj.get(node, set())
|
||||
nxt -= seen
|
||||
if not nxt:
|
||||
break
|
||||
seen |= nxt
|
||||
frontier = nxt
|
||||
|
||||
sub_nodes = [n for n in base_graph["nodes"] if n["id"] in seen]
|
||||
sub_edges = [e for e in base_graph["edges"] if e["source"] in seen and e["target"] in seen]
|
||||
return {
|
||||
"nodes": sub_nodes,
|
||||
"edges": sub_edges,
|
||||
"total_nodes": len(sub_nodes),
|
||||
"total_edges": len(sub_edges),
|
||||
"cached": bool(cached),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/instances/{uid}")
|
||||
async def get_instance(
|
||||
request: Request,
|
||||
uid: str,
|
||||
x_deck_token: Annotated[str | None, Header()] = None,
|
||||
x_deck_env: Annotated[str | None, Header()] = None,
|
||||
):
|
||||
if not x_deck_token:
|
||||
raise HTTPException(status_code=401, detail="Token required")
|
||||
_, deck_api = resolve_deck_api(x_deck_env)
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
r = await client.get(
|
||||
f"{deck_api}/index.cfm/instances/{uid}",
|
||||
headers=auth_headers(x_deck_token),
|
||||
)
|
||||
if r.status_code == 401:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
return r.json()
|
||||
|
||||
|
||||
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.34.0
|
||||
httpx==0.27.0
|
||||
@@ -0,0 +1,894 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nubes Cloud Dashboard</title>
|
||||
<link rel="icon" href="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/favicon.png" type="image/png">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f1117; color: #e1e4e8; margin: 0; height: 100vh; overflow: hidden; }
|
||||
#app { display: flex; flex-direction: column; height: 100vh; }
|
||||
#login-screen { position: fixed; inset: 0; background: #0f1117; display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||
#login-screen.hidden { display: none; }
|
||||
.login-box { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 40px 48px; width: 400px; max-width: 90vw; }
|
||||
.login-logo { display: flex; align-items: center; gap: 10px; margin-bottom: 28px; justify-content: center; }
|
||||
.login-logo span { font-size: 22px; font-weight: 700; color: #fff; letter-spacing: .04em; }
|
||||
.login-box h2 { font-size: 16px; font-weight: 600; color: #e1e4e8; margin-bottom: 6px; text-align: center; }
|
||||
.login-box p { font-size: 12px; color: #8b949e; margin-bottom: 20px; text-align: center; }
|
||||
.login-box label { display: block; font-size: 12px; color: #8b949e; margin-bottom: 6px; }
|
||||
.login-box select { width: 100%; height: 36px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #e1e4e8; font-size: 13px; padding: 0 10px; margin-bottom: 10px; outline: none; }
|
||||
.login-box select:focus { border-color: #58a6ff; }
|
||||
.login-box textarea { width: 100%; height: 80px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #e1e4e8; font-size: 12px; padding: 10px; resize: none; outline: none; font-family: monospace; }
|
||||
.login-box textarea:focus { border-color: #58a6ff; }
|
||||
.btn-primary { width: 100%; padding: 10px; background: #238636; border: none; border-radius: 6px; color: #fff; font-size: 14px; font-weight: 600; cursor: pointer; margin-top: 12px; }
|
||||
.btn-primary:hover { background: #2ea043; }
|
||||
.login-err { color: #f85149; font-size: 12px; text-align: center; margin-top: 8px; display: none; }
|
||||
header { padding: 12px 24px; background: #161b22; border-bottom: 1px solid #30363d; display: flex; align-items: center; gap: 16px; flex-shrink: 0; flex-wrap: wrap; }
|
||||
.header-logo { display: flex; align-items: center; gap: 9px; }
|
||||
.brand-logo { height: 26px; width: auto; background: #fff; border-radius: 4px; padding: 2px 6px; }
|
||||
.header-logo .logo-text { font-size: 15px; font-weight: 700; color: #fff; letter-spacing: .04em; }
|
||||
.header-logo .logo-sub { font-size: 11px; color: #58a6ff; font-weight: 400; margin-left: 4px; }
|
||||
.spacer { flex: 1; }
|
||||
.filters { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.filter-label { display: flex; align-items: center; gap: 5px; cursor: pointer; font-size: 12px; }
|
||||
.filter-label input { cursor: pointer; width: 18px; height: 18px; accent-color: #58a6ff; }
|
||||
.btn-refresh { padding: 5px 12px; background: #21262d; border: 1px solid #30363d; border-radius: 6px; color: #e1e4e8; cursor: pointer; font-size: 12px; }
|
||||
.btn-refresh:hover { background: #30363d; }
|
||||
.btn-graph { padding: 5px 12px; background: #1f6feb; border: 1px solid #3b82f6; border-radius: 6px; color: #fff; cursor: pointer; font-size: 12px; }
|
||||
.btn-graph:hover { background: #2563eb; }
|
||||
.btn-theme { padding: 5px 10px; background: #21262d; border: 1px solid #30363d; border-radius: 6px; color: #e1e4e8; cursor: pointer; font-size: 14px; line-height: 1; }
|
||||
.btn-theme:hover { background: #30363d; }
|
||||
.header-user { display: flex; align-items: center; gap: 10px; }
|
||||
.user-email { font-size: 12px; color: #8b949e; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.btn-exit { padding: 5px 12px; background: #21262d; border: 1px solid #30363d; border-radius: 6px; color: #f85149; cursor: pointer; font-size: 12px; }
|
||||
.btn-exit:hover { background: #3d1a1a; border-color: #f85149; }
|
||||
.status-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; }
|
||||
.s-running { background: #1a4a2e; color: #56d364; }
|
||||
.s-suspended { background: #3d2a00; color: #e3b341; }
|
||||
.s-deleted { background: #3d1a1a; color: #f85149; }
|
||||
.s-deleting { background: #3d1a1a; color: #f85149; }
|
||||
.s-pending { background: #1a2a3d; color: #58a6ff; }
|
||||
.s-creating { background: #1a2a3d; color: #58a6ff; }
|
||||
.s-default { background: #2d2d2d; color: #8b949e; }
|
||||
.main { padding: 20px 24px; flex: 1; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.stats { display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.stat-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 8px 16px; text-align: center; min-width: 90px; }
|
||||
.stat-card .num { font-size: 22px; font-weight: 700; }
|
||||
.stat-card .lbl { font-size: 11px; color: #8b949e; margin-top: 2px; }
|
||||
.table-wrap { flex: 1; overflow-y: auto; min-height: 0; }
|
||||
table { width: 100%; border-collapse: collapse; background: #161b22; border: 1px solid #30363d; border-radius: 8px; }
|
||||
thead { background: #1c2128; position: sticky; top: 0; z-index: 90; }
|
||||
th { padding: 9px 13px; text-align: left; font-size: 11px; font-weight: 600; color: #8b949e; text-transform: uppercase; letter-spacing: .05em; cursor: pointer; user-select: none; white-space: nowrap; }
|
||||
th:hover { color: #e1e4e8; }
|
||||
.arrow { margin-left: 3px; opacity: .4; }
|
||||
th.sorted .arrow { opacity: 1; }
|
||||
tbody tr.data-row { border-top: 1px solid #21262d; cursor: pointer; }
|
||||
tbody tr.data-row:hover { background: #1c2128; }
|
||||
tbody tr.data-row.expanded { background: #1c2128; }
|
||||
td { padding: 9px 13px; font-size: 13px; vertical-align: middle; }
|
||||
.col-arrow { width: 28px; }
|
||||
.expand-icon { font-size: 11px; color: #8b949e; display: inline-block; transition: transform .15s; }
|
||||
.expanded .expand-icon { transform: rotate(90deg); }
|
||||
.detail-row { display: none; }
|
||||
.detail-row.open { display: table-row; }
|
||||
.detail-row td { padding: 0; border-top: 1px solid #30363d; }
|
||||
.detail-box { padding: 16px 22px; background: #0d1117; }
|
||||
.tabs { display: flex; border-bottom: 1px solid #30363d; margin-bottom: 14px; }
|
||||
.tab { padding: 7px 14px; font-size: 13px; cursor: pointer; border-bottom: 2px solid transparent; color: #8b949e; }
|
||||
.tab.active { color: #58a6ff; border-bottom-color: #58a6ff; }
|
||||
.panel { display: none; }
|
||||
.panel.active { display: block; }
|
||||
.inst-graph { height: 420px; border: 1px solid #30363d; border-radius: 8px; background: #0b0f14; }
|
||||
.graph-hint { color: #8b949e; font-size: 12px; margin-bottom: 8px; }
|
||||
.graph-loading { display: flex; align-items: center; justify-content: center; height: 100%; color: #9fb3c8; font-size: 13px; }
|
||||
.graph-loading::before { content: ""; width: 14px; height: 14px; border: 2px solid #3b82f6; border-top-color: transparent; border-radius: 50%; display: inline-block; margin-right: 8px; animation: spin .9s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.graph-modal { position: fixed; inset: 0; background: rgba(2, 8, 20, .75); display: none; align-items: center; justify-content: center; z-index: 1200; }
|
||||
.graph-modal.open { display: flex; }
|
||||
.graph-modal-box { width: min(98vw, 1700px); height: min(95vh, 1100px); background: #0d1117; border: 1px solid #30363d; border-radius: 12px; display: flex; flex-direction: column; }
|
||||
.graph-modal-head { padding: 10px 14px; border-bottom: 1px solid #30363d; display: flex; align-items: center; gap: 10px; }
|
||||
.graph-modal-title { font-size: 14px; font-weight: 600; }
|
||||
.graph-modal-close { margin-left: auto; padding: 4px 10px; background: #21262d; border: 1px solid #30363d; color: #e1e4e8; border-radius: 6px; cursor: pointer; }
|
||||
.graph-modal-body { flex: 1; min-height: 0; }
|
||||
.running-graph { width: 100%; height: 100%; }
|
||||
.graph-inline-note { color: #8b949e; font-size: 12px; margin-top: 8px; }
|
||||
.params-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
.params-section h4 { font-size: 11px; font-weight: 600; color: #8b949e; text-transform: uppercase; margin-bottom: 8px; }
|
||||
.p-item { display: flex; justify-content: space-between; padding: 5px 0; border-bottom: 1px solid #21262d; font-size: 12px; gap: 12px; }
|
||||
.p-key { color: #8b949e; min-width: 140px; flex-shrink: 0; }
|
||||
.p-val { color: #e1e4e8; word-break: break-all; text-align: right; }
|
||||
.hist-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.hist-table th { padding: 5px 9px; text-align: left; color: #8b949e; border-bottom: 1px solid #30363d; font-weight: 500; }
|
||||
.hist-table td { padding: 5px 9px; border-bottom: 1px solid #21262d; }
|
||||
.ok { color: #56d364; }
|
||||
.fail { color: #f85149; }
|
||||
.run { color: #e3b341; }
|
||||
.deps-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.deps-section h4 { font-size: 11px; font-weight: 600; color: #8b949e; text-transform: uppercase; margin-bottom: 8px; }
|
||||
.dep-item { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 9px 13px; margin-bottom: 6px; }
|
||||
.dep-name { font-weight: 600; font-size: 13px; color: #58a6ff; }
|
||||
.dep-meta { font-size: 11px; color: #8b949e; margin-top: 3px; }
|
||||
.empty-msg { color: #8b949e; font-size: 12px; padding: 8px 0; }
|
||||
.loading { text-align: center; padding: 40px; color: #8b949e; }
|
||||
.err-msg { text-align: center; padding: 20px; color: #f85149; }
|
||||
|
||||
body.light { background: #f3f6fb; color: #0f172a; }
|
||||
body.light #login-screen { background: #f3f6fb; }
|
||||
body.light .login-box { background: #ffffff; border-color: #cbd5e1; }
|
||||
body.light .login-logo span, body.light .login-box h2 { color: #0f172a; }
|
||||
body.light .login-box p { color: #475569; }
|
||||
body.light .login-box label { color: #475569; }
|
||||
body.light .login-box select { background: #f8fafc; border-color: #cbd5e1; color: #0f172a; }
|
||||
body.light .login-box textarea { background: #f8fafc; border-color: #cbd5e1; color: #0f172a; }
|
||||
body.light header { background: #ffffff; border-bottom-color: #cbd5e1; }
|
||||
body.light .header-logo .logo-text { color: #0f172a; }
|
||||
body.light .header-logo .logo-sub, body.light .user-email { color: #475569; }
|
||||
body.light .btn-refresh, body.light .btn-theme, body.light .btn-exit { background: #f8fafc; border-color: #cbd5e1; color: #0f172a; }
|
||||
body.light .btn-refresh:hover, body.light .btn-theme:hover, body.light .btn-exit:hover { background: #e2e8f0; }
|
||||
body.light .main { background: #f3f6fb; }
|
||||
body.light table { background: #ffffff; border-color: #cbd5e1; }
|
||||
body.light thead { background: #e2e8f0; }
|
||||
body.light th { color: #334155; }
|
||||
body.light td { color: #0f172a; }
|
||||
body.light tbody tr.data-row { border-top-color: #e2e8f0; }
|
||||
body.light tbody tr.data-row:hover, body.light tbody tr.data-row.expanded { background: #f8fafc; }
|
||||
body.light .detail-box { background: #f8fafc; }
|
||||
body.light .tab { color: #475569; }
|
||||
body.light .tab.active { color: #1d4ed8; border-bottom-color: #1d4ed8; }
|
||||
body.light .graph-modal { background: rgba(15, 23, 42, .35); }
|
||||
body.light .graph-modal-box { background: #ffffff; border-color: #cbd5e1; }
|
||||
body.light .graph-modal-head { border-bottom-color: #cbd5e1; }
|
||||
body.light .graph-modal-close { background: #f8fafc; border-color: #cbd5e1; color: #0f172a; }
|
||||
@media (max-width: 700px) {
|
||||
td:nth-child(5), th:nth-child(5), td:nth-child(4), th:nth-child(4) { display: none; }
|
||||
.params-grid, .deps-grid { grid-template-columns: 1fr; }
|
||||
.user-email { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="login-screen">
|
||||
<div class="login-box">
|
||||
<div class="login-logo">
|
||||
<span>NUBES</span>
|
||||
</div>
|
||||
<h2>Cloud Dashboard</h2>
|
||||
<p>Введите API токен для доступа</p>
|
||||
<label for="deck-env">Стенд</label>
|
||||
<select id="deck-env">
|
||||
<option value="prod">PROD</option>
|
||||
<option value="dev">DEV</option>
|
||||
<option value="test" selected>TEST</option>
|
||||
</select>
|
||||
<textarea id="token-input" placeholder="eyJhbGciOiJ..." spellcheck="false"></textarea>
|
||||
<button class="btn-primary" id="btn-login">Войти</button>
|
||||
<div class="login-err" id="login-err"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="app" style="display:none">
|
||||
<header>
|
||||
<div class="header-logo">
|
||||
<img class="brand-logo" src="https://terra.k8c.ru/docs/nubes/nubes/2.0.2/30_registry/assets/logo.svg" alt="Nubes logo">
|
||||
<span class="logo-text">NUBES<span class="logo-sub">Dashboard</span></span>
|
||||
</div>
|
||||
<div class="filters" id="filters">
|
||||
<label class="filter-label"><input type="checkbox" value="running" checked> <span class="status-badge s-running">running</span></label>
|
||||
<label class="filter-label"><input type="checkbox" value="suspended" checked> <span class="status-badge s-suspended">suspended</span></label>
|
||||
<label class="filter-label"><input type="checkbox" value="deleting"> <span class="status-badge s-deleting">deleting</span></label>
|
||||
<label class="filter-label"><input type="checkbox" value="deleted"> <span class="status-badge s-deleted">deleted</span></label>
|
||||
<label class="filter-label"><input type="checkbox" value="pending"> <span class="status-badge s-pending">pending</span></label>
|
||||
<label class="filter-label"><input type="checkbox" value="not created"> <span class="status-badge s-default">not created</span></label>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<button class="btn-refresh" id="btn-refresh">↻ Обновить</button>
|
||||
<button class="btn-graph" id="btn-running-graph">Граф running</button>
|
||||
<button class="btn-theme" id="btn-theme" title="Сменить тему">🌙</button>
|
||||
<div class="header-user">
|
||||
<span class="user-email" id="user-email"></span>
|
||||
<button class="btn-exit" id="btn-exit">✕ Выйти</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="main">
|
||||
<div class="stats" id="stats"></div>
|
||||
<div class="table-wrap" id="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-arrow"></th>
|
||||
<th data-col="displayName">Имя <span class="arrow">↕</span></th>
|
||||
<th data-col="svc">Сервис <span class="arrow">↕</span></th>
|
||||
<th data-col="explainedStatus">Статус <span class="arrow">↕</span></th>
|
||||
<th data-col="resourceRealm">Платформа <span class="arrow">↕</span></th>
|
||||
<th data-col="instanceConfigDtCreated">Создан <span class="arrow">↕</span></th>
|
||||
<th data-col="dtState">Изменён <span class="arrow">↕</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody">
|
||||
<tr><td colspan="7" class="loading">Загрузка...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="graph-modal" id="graph-modal">
|
||||
<div class="graph-modal-box">
|
||||
<div class="graph-modal-head">
|
||||
<div class="graph-modal-title" id="graph-modal-title">Общий граф зависимостей: running</div>
|
||||
<button class="graph-modal-close" id="btn-close-graph">Закрыть</button>
|
||||
</div>
|
||||
<div class="graph-modal-body">
|
||||
<div class="running-graph" id="running-graph"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="js-err" style="display:none;position:fixed;bottom:0;left:0;right:0;background:#f85149;color:#fff;padding:10px;font-family:monospace;font-size:12px;z-index:9999"></div>
|
||||
<script>
|
||||
window.onerror=function(m,s,l){var e=document.getElementById("js-err");if(e){e.style.display="block";e.textContent="JS ERROR line "+l+": "+m;}};
|
||||
</script>
|
||||
<script src="https://unpkg.com/cytoscape@3.29.2/dist/cytoscape.min.js"></script>
|
||||
<script>
|
||||
(function(){
|
||||
"use strict";
|
||||
var API = "/dashboard/api";
|
||||
var allInstances = [];
|
||||
var sortCol = "displayName";
|
||||
var sortDir = 1;
|
||||
var expandedUid = null;
|
||||
var cache = {};
|
||||
var graphAllData = null;
|
||||
var graphRunningData = null;
|
||||
var graphAllKey = "";
|
||||
var graphWarmupScheduled = false;
|
||||
|
||||
function escHtml(v){
|
||||
return String(v == null ? "" : v)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function statusColor(s){
|
||||
var m = {
|
||||
running: "#56d364",
|
||||
suspended: "#e3b341",
|
||||
deleted: "#f85149",
|
||||
deleting: "#f85149",
|
||||
pending: "#58a6ff",
|
||||
creating: "#58a6ff",
|
||||
"not created": "#8b949e"
|
||||
};
|
||||
return m[s] || "#8b949e";
|
||||
}
|
||||
|
||||
function fetchGraph(params){
|
||||
var p = new URLSearchParams();
|
||||
Object.keys(params || {}).forEach(function(k){
|
||||
var v = params[k];
|
||||
if(Array.isArray(v)) v.forEach(function(x){ p.append(k, x); });
|
||||
else if(v !== undefined && v !== null && v !== "") p.append(k, String(v));
|
||||
});
|
||||
return fetch(API + "/graph?" + p.toString(), { headers: apiHeaders() })
|
||||
.then(function(r){
|
||||
if(r.status === 401){ doLogout(); return null; }
|
||||
if(!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
});
|
||||
}
|
||||
|
||||
function graphElements(data, focusUid){
|
||||
var elems = [];
|
||||
(data.nodes || []).forEach(function(n){
|
||||
var label = (n.label || n.id || "");
|
||||
var labelLen = label.length;
|
||||
var textMaxWidth = Math.max(170, Math.min(320, labelLen * 8));
|
||||
var width = Math.max(190, Math.min(360, textMaxWidth + 26));
|
||||
var lineChars = Math.max(10, Math.floor(textMaxWidth / 8));
|
||||
var lines = Math.max(1, Math.min(4, Math.ceil(labelLen / lineChars)));
|
||||
var height = Math.max(64, Math.min(150, 36 + (lines * 20)));
|
||||
var fontSize = labelLen > 48 ? 14 : labelLen > 32 ? 16 : 18;
|
||||
elems.push({
|
||||
data: {
|
||||
id: n.id,
|
||||
label: label,
|
||||
fullLabel: n.label || n.id,
|
||||
status: n.status || "unknown",
|
||||
color: statusColor(n.status || ""),
|
||||
width: width,
|
||||
height: height,
|
||||
fontSize: fontSize,
|
||||
textMaxWidth: textMaxWidth
|
||||
},
|
||||
classes: (focusUid && n.id === focusUid) ? "focus" : ""
|
||||
});
|
||||
});
|
||||
(data.edges || []).forEach(function(e, i){
|
||||
elems.push({ data: { id: "e" + i + "-" + e.source + "-" + e.target, source: e.source, target: e.target } });
|
||||
});
|
||||
return elems;
|
||||
}
|
||||
|
||||
function drawGraph(containerId, data, focusUid){
|
||||
var el = document.getElementById(containerId);
|
||||
if(!el || !window.cytoscape) return;
|
||||
el.innerHTML = "";
|
||||
if((data.nodes || []).length === 0){
|
||||
el.innerHTML = '<div class="empty-msg" style="padding:14px">Связей не найдено</div>';
|
||||
return;
|
||||
}
|
||||
var nCount = (data.nodes || []).length;
|
||||
var layoutCfg;
|
||||
if(nCount <= 2){
|
||||
layoutCfg = {
|
||||
name: "grid",
|
||||
fit: true,
|
||||
padding: 12,
|
||||
avoidOverlap: true,
|
||||
rows: 1,
|
||||
cols: nCount
|
||||
};
|
||||
} else {
|
||||
var cols = nCount <= 8 ? 3 : nCount <= 18 ? 4 : 5;
|
||||
layoutCfg = {
|
||||
name: "grid",
|
||||
fit: true,
|
||||
padding: 12,
|
||||
avoidOverlap: true,
|
||||
avoidOverlapPadding: 18,
|
||||
nodeDimensionsIncludeLabels: true,
|
||||
animate: false,
|
||||
condense: true,
|
||||
cols: cols
|
||||
};
|
||||
}
|
||||
|
||||
var cy = cytoscape({
|
||||
container: el,
|
||||
elements: graphElements(data, focusUid),
|
||||
style: [
|
||||
{ selector: "node", style: {
|
||||
"shape": "round-rectangle",
|
||||
"background-color": "data(color)",
|
||||
"label": "data(label)",
|
||||
"font-size": "data(fontSize)",
|
||||
"font-weight": 600,
|
||||
"color": "#e6edf3",
|
||||
"text-wrap": "wrap",
|
||||
"text-max-width": "data(textMaxWidth)",
|
||||
"text-valign": "center",
|
||||
"text-halign": "center",
|
||||
"width": "data(width)",
|
||||
"height": "data(height)",
|
||||
"padding": "10px",
|
||||
"border-color": "#0d1117",
|
||||
"border-width": 2
|
||||
}},
|
||||
{ selector: "node.focus", style: { "border-color": "#facc15", "border-width": 3 }},
|
||||
{ selector: "edge", style: {
|
||||
"line-color": "#9fc2ff",
|
||||
"target-arrow-color": "#9fc2ff",
|
||||
"target-arrow-shape": "triangle",
|
||||
"curve-style": "bezier",
|
||||
"width": 3.2,
|
||||
"arrow-scale": 1.8,
|
||||
"opacity": 1
|
||||
}}
|
||||
],
|
||||
layout: layoutCfg
|
||||
});
|
||||
|
||||
// Ensure proper viewport after tab activation/layout changes.
|
||||
var refit = function(){
|
||||
try { cy.resize(); cy.fit(undefined, 24); } catch(e) { /* noop */ }
|
||||
};
|
||||
setTimeout(refit, 0);
|
||||
setTimeout(refit, 120);
|
||||
cy.on("layoutstop", refit);
|
||||
|
||||
cy.on("tap", "node", function(evt){
|
||||
var n = evt.target.data();
|
||||
var tip = n.fullLabel + "\n" + (n.status || "unknown");
|
||||
if(n.fullLabel) el.title = tip;
|
||||
});
|
||||
}
|
||||
|
||||
function ensureAllGraphLoaded(){
|
||||
var statuses = getChecked().slice().sort();
|
||||
var key = statuses.join(",");
|
||||
if(key === graphAllKey && graphAllData) return Promise.resolve(graphAllData);
|
||||
if(statuses.length === 0){
|
||||
graphAllKey = key;
|
||||
graphAllData = {nodes:[], edges:[]};
|
||||
return Promise.resolve(graphAllData);
|
||||
}
|
||||
return fetchGraph({ status: statuses }).then(function(data){
|
||||
graphAllKey = key;
|
||||
graphAllData = data || {nodes:[], edges:[]};
|
||||
return graphAllData;
|
||||
});
|
||||
}
|
||||
|
||||
function renderInstanceGraph(uid){
|
||||
var holderId = "g-" + uid;
|
||||
var holder = document.getElementById(holderId);
|
||||
if(holder) holder.innerHTML = '<div class="graph-loading">Строю граф зависимостей...</div>';
|
||||
// Wait one frame so the graph panel becomes visible and gets real dimensions.
|
||||
var run = function(){
|
||||
ensureAllGraphLoaded().then(function(){
|
||||
var g = buildClientSubgraph(graphAllData || {nodes:[], edges:[]}, uid, 3);
|
||||
if((g.nodes || []).length <= 1 && graphAllData){
|
||||
var near = buildClientSubgraph(graphAllData, uid, 1);
|
||||
if((near.nodes || []).length > (g.nodes || []).length) g = near;
|
||||
}
|
||||
if((g.nodes || []).length > 2){
|
||||
if(holder) holder.innerHTML = '<div class="graph-inline-note">Граф открыт почти на весь экран для удобства чтения.</div>';
|
||||
showGraphModal("Граф зависимостей: " + escHtml(instanceName(uid)), g, uid);
|
||||
} else {
|
||||
drawGraph(holderId, g, uid);
|
||||
}
|
||||
}).catch(function(e){
|
||||
var el = document.getElementById(holderId);
|
||||
if(el) el.innerHTML = '<div class="err-msg">' + escHtml(e.message) + '</div>';
|
||||
});
|
||||
};
|
||||
requestAnimationFrame(function(){ requestAnimationFrame(run); });
|
||||
}
|
||||
|
||||
function instanceName(uid){
|
||||
for(var i=0;i<allInstances.length;i++){
|
||||
if(allInstances[i].instanceUid === uid) return allInstances[i].displayName || uid;
|
||||
}
|
||||
return uid;
|
||||
}
|
||||
|
||||
function depUid(dep){
|
||||
return (dep && (dep.uid || dep.instanceUid || dep.dependencyUid || dep.relatedUid)) || "";
|
||||
}
|
||||
|
||||
function visibleUidSet(){
|
||||
var s = new Set();
|
||||
allInstances.forEach(function(i){ if(i && i.instanceUid) s.add(i.instanceUid); });
|
||||
return s;
|
||||
}
|
||||
|
||||
function buildClientSubgraph(data, rootUid, depth){
|
||||
var nodes = data.nodes || [];
|
||||
var edges = data.edges || [];
|
||||
var ids = new Set(nodes.map(function(n){ return n.id; }));
|
||||
if(!ids.has(rootUid)) return { nodes: [], edges: [] };
|
||||
var adj = {};
|
||||
nodes.forEach(function(n){ adj[n.id] = new Set(); });
|
||||
edges.forEach(function(e){
|
||||
if(adj[e.source] && adj[e.target]){
|
||||
adj[e.source].add(e.target);
|
||||
adj[e.target].add(e.source);
|
||||
}
|
||||
});
|
||||
var seen = new Set([rootUid]);
|
||||
var frontier = new Set([rootUid]);
|
||||
for(var i=0;i<depth;i++){
|
||||
var nxt = new Set();
|
||||
frontier.forEach(function(n){
|
||||
(adj[n] || new Set()).forEach(function(x){ if(!seen.has(x)) nxt.add(x); });
|
||||
});
|
||||
if(nxt.size === 0) break;
|
||||
nxt.forEach(function(x){ seen.add(x); });
|
||||
frontier = nxt;
|
||||
}
|
||||
return {
|
||||
nodes: nodes.filter(function(n){ return seen.has(n.id); }),
|
||||
edges: edges.filter(function(e){ return seen.has(e.source) && seen.has(e.target); })
|
||||
};
|
||||
}
|
||||
|
||||
function openRunningGraph(){
|
||||
var modal = document.getElementById("graph-modal");
|
||||
modal.classList.add("open");
|
||||
var titleEl = document.getElementById("graph-modal-title");
|
||||
if(titleEl) titleEl.textContent = "Общий граф зависимостей: running";
|
||||
var target = document.getElementById("running-graph");
|
||||
if(target) target.innerHTML = '<div class="graph-loading">Собираю общий граф running...</div>';
|
||||
var run = function(data){ drawGraph("running-graph", data || {nodes:[], edges:[]}, ""); };
|
||||
if(graphRunningData) { run(graphRunningData); return; }
|
||||
fetchGraph({ status: ["running"] })
|
||||
.then(function(data){ graphRunningData = data || {nodes:[], edges:[]}; run(graphRunningData); })
|
||||
.catch(function(e){
|
||||
var el = document.getElementById("running-graph");
|
||||
if(el) el.innerHTML = '<div class="err-msg">' + escHtml(e.message) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function closeRunningGraph(){
|
||||
document.getElementById("graph-modal").classList.remove("open");
|
||||
}
|
||||
|
||||
function showGraphModal(title, data, focusUid){
|
||||
var modal = document.getElementById("graph-modal");
|
||||
var titleEl = document.getElementById("graph-modal-title");
|
||||
if(titleEl) titleEl.textContent = title;
|
||||
modal.classList.add("open");
|
||||
drawGraph("running-graph", data || {nodes:[], edges:[]}, focusUid || "");
|
||||
}
|
||||
|
||||
function scheduleGraphWarmup(){
|
||||
if(graphWarmupScheduled) return;
|
||||
graphWarmupScheduled = true;
|
||||
var warm = function(){
|
||||
// optional background warm-up: should never block UI
|
||||
fetchGraph({ status: ["running"] }).then(function(data){
|
||||
graphRunningData = data || {nodes:[], edges:[]};
|
||||
}).catch(function(){ /* ignore */ });
|
||||
};
|
||||
if(window.requestIdleCallback){
|
||||
requestIdleCallback(warm, { timeout: 12000 });
|
||||
} else {
|
||||
setTimeout(warm, 12000);
|
||||
}
|
||||
}
|
||||
|
||||
function getToken(){ return localStorage.getItem("deck_token") || ""; }
|
||||
|
||||
function getDeckEnv(){
|
||||
return localStorage.getItem("deck_env") || "test";
|
||||
}
|
||||
|
||||
function decodeEmail(token){
|
||||
try {
|
||||
var p = token.split(".")[1];
|
||||
var padded = p + "=".repeat((4 - p.length % 4) % 4);
|
||||
var obj = JSON.parse(atob(padded.replace(/-/g,"+").replace(/_/g,"/")));
|
||||
return obj.email || obj.sub || obj.preferred_username || obj.name || "";
|
||||
} catch(e){ return ""; }
|
||||
}
|
||||
|
||||
function apiHeaders(){
|
||||
return {
|
||||
"X-Deck-Token": getToken(),
|
||||
"X-Deck-Env": getDeckEnv(),
|
||||
"Content-Type": "application/json"
|
||||
};
|
||||
}
|
||||
|
||||
function showLoginErr(msg){
|
||||
var el = document.getElementById("login-err");
|
||||
el.textContent = msg;
|
||||
el.style.display = "block";
|
||||
}
|
||||
|
||||
function doLogout(){
|
||||
localStorage.removeItem("deck_token");
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function getChecked(){
|
||||
return Array.from(document.querySelectorAll("#filters input:checked")).map(function(i){ return i.value; });
|
||||
}
|
||||
|
||||
function sClass(s){
|
||||
var m = {running:"s-running",suspended:"s-suspended",deleted:"s-deleted",deleting:"s-deleting",pending:"s-pending",creating:"s-creating"};
|
||||
return m[s] || "s-default";
|
||||
}
|
||||
|
||||
function fmtDt(s){
|
||||
if(!s) return "\u2014";
|
||||
try { return new Date(s).toLocaleString("ru-RU",{day:"2-digit",month:"2-digit",year:"2-digit",hour:"2-digit",minute:"2-digit"}); }
|
||||
catch(e){ return s; }
|
||||
}
|
||||
|
||||
function renderStats(list){
|
||||
var c = {};
|
||||
list.forEach(function(i){ var s = i.explainedStatus || "unknown"; c[s] = (c[s]||0)+1; });
|
||||
var col = {running:"#56d364",suspended:"#e3b341",deleted:"#f85149",pending:"#58a6ff"};
|
||||
var html = "";
|
||||
for (var s in c){
|
||||
html += "<div class=\"stat-card\"><div class=\"num\" style=\"color:" + (col[s]||"#8b949e") + "\">" + c[s] + "</div><div class=\"lbl\">" + s + "</div></div>";
|
||||
}
|
||||
html += "<div class=\"stat-card\"><div class=\"num\">" + list.length + "</div><div class=\"lbl\">всего</div></div>";
|
||||
document.getElementById("stats").innerHTML = html;
|
||||
}
|
||||
|
||||
function sorted(arr){
|
||||
return arr.slice().sort(function(a,b){
|
||||
var av = a[sortCol] || "";
|
||||
var bv = b[sortCol] || "";
|
||||
return av < bv ? -sortDir : av > bv ? sortDir : 0;
|
||||
});
|
||||
}
|
||||
|
||||
function render(list){
|
||||
var tbody = document.getElementById("tbody");
|
||||
var s = sorted(list);
|
||||
if(!s.length){ tbody.innerHTML = "<tr><td colspan=\"7\" class=\"loading\">Нет данных</td></tr>"; return; }
|
||||
var html = "";
|
||||
s.forEach(function(inst){
|
||||
var uid = inst.instanceUid;
|
||||
var exp = expandedUid === uid;
|
||||
html += "<tr class=\"data-row" + (exp ? " expanded" : "") + "\" data-uid=\"" + uid + "\">" +
|
||||
"<td><span class=\"expand-icon\">►</span></td>" +
|
||||
"<td>" + (inst.displayName || "\u2014") + "</td>" +
|
||||
"<td>" + (inst.svc || "\u2014") + "</td>" +
|
||||
"<td><span class=\"status-badge " + sClass(inst.explainedStatus) + "\">" + (inst.explainedStatus || "\u2014") + "</span></td>" +
|
||||
"<td>" + (inst.resourceRealm || "\u2014") + "</td>" +
|
||||
"<td>" + fmtDt(inst.instanceConfigDtCreated) + "</td>" +
|
||||
"<td>" + fmtDt(inst.dtState || inst.instanceConfigDtUpdated) + "</td>" +
|
||||
"</tr>";
|
||||
html += "<tr class=\"detail-row" + (exp ? " open" : "") + "\" id=\"dr-" + uid + "\">" +
|
||||
"<td colspan=\"7\"><div class=\"detail-box\" id=\"dc-" + uid + "\">" +
|
||||
(exp ? (cache[uid] || "<div class=\"loading\">Загрузка...</div>") : "") +
|
||||
"</div></td></tr>";
|
||||
});
|
||||
tbody.innerHTML = html;
|
||||
|
||||
tbody.querySelectorAll("tr.data-row").forEach(function(row){
|
||||
row.addEventListener("click", function(){ toggle(row.dataset.uid, row); });
|
||||
});
|
||||
|
||||
document.querySelectorAll("th[data-col]").forEach(function(th){
|
||||
var active = th.dataset.col === sortCol;
|
||||
th.classList.toggle("sorted", active);
|
||||
th.querySelector(".arrow").innerHTML = active ? (sortDir > 0 ? "↑" : "↓") : "↕";
|
||||
});
|
||||
}
|
||||
|
||||
function toggle(uid, row){
|
||||
var dr = document.getElementById("dr-" + uid);
|
||||
var dc = document.getElementById("dc-" + uid);
|
||||
if(expandedUid === uid){
|
||||
expandedUid = null;
|
||||
dr.classList.remove("open");
|
||||
row.classList.remove("expanded");
|
||||
return;
|
||||
}
|
||||
if(expandedUid){
|
||||
var pd = document.getElementById("dr-" + expandedUid);
|
||||
if(pd) pd.classList.remove("open");
|
||||
var pr = pd && pd.previousElementSibling;
|
||||
if(pr) pr.classList.remove("expanded");
|
||||
}
|
||||
expandedUid = uid;
|
||||
row.classList.add("expanded");
|
||||
dr.classList.add("open");
|
||||
if(!cache[uid]){
|
||||
dc.innerHTML = "<div class=\"loading\">Загрузка...</div>";
|
||||
fetch(API + "/instances/" + uid, { headers: apiHeaders() })
|
||||
.then(function(r){
|
||||
if(r.status === 401){ doLogout(); return null; }
|
||||
return r.json();
|
||||
})
|
||||
.then(function(d){
|
||||
if(!d) return;
|
||||
cache[uid] = buildDetail(d.instance);
|
||||
dc.innerHTML = cache[uid];
|
||||
bindTabs(dc);
|
||||
})
|
||||
.catch(function(e){ cache[uid] = "<div class=\"err-msg\">" + e.message + "</div>"; dc.innerHTML = cache[uid]; });
|
||||
} else {
|
||||
dc.innerHTML = cache[uid];
|
||||
bindTabs(dc);
|
||||
}
|
||||
}
|
||||
|
||||
function bindTabs(container){
|
||||
container.querySelectorAll(".tab").forEach(function(tab){
|
||||
tab.addEventListener("click", function(){
|
||||
var box = tab.closest(".detail-box");
|
||||
box.querySelectorAll(".tab").forEach(function(t){ t.classList.remove("active"); });
|
||||
box.querySelectorAll(".panel").forEach(function(p){ p.classList.remove("active"); });
|
||||
tab.classList.add("active");
|
||||
var pid = tab.dataset.panel;
|
||||
var panel = document.getElementById(pid);
|
||||
if(panel) panel.classList.add("active");
|
||||
if(pid && pid.indexOf("g-graph-") === 0){
|
||||
var uid = pid.slice("g-graph-".length);
|
||||
if(uid) setTimeout(function(){ renderInstanceGraph(uid); }, 30);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildDetail(inst){
|
||||
if(!inst) return "<div class=\"err-msg\">Нет данных</div>";
|
||||
var uid = inst.instanceUid || "x";
|
||||
var params = (inst.state && inst.state.params) || {};
|
||||
var out = (inst.state && inst.state.out) || {};
|
||||
var ops = inst.operations || [];
|
||||
var rawDeps = inst.dependencies || [];
|
||||
var rawDepOn = inst.dependentInstances || [];
|
||||
var visible = visibleUidSet();
|
||||
var deps = rawDeps.filter(function(d){ var id = depUid(d); return id && visible.has(id); });
|
||||
var depOn = rawDepOn.filter(function(d){ var id = depUid(d); return id && visible.has(id); });
|
||||
var hasDeps = (deps.length + depOn.length) > 0;
|
||||
|
||||
var h = "<div class=\"tabs\">" +
|
||||
"<div class=\"tab active\" data-panel=\"p-" + uid + "\">Параметры</div>" +
|
||||
"<div class=\"tab\" data-panel=\"h-" + uid + "\">История (" + ops.length + ")</div>" +
|
||||
"<div class=\"tab\" data-panel=\"d-" + uid + "\">Зависимости (" + (deps.length + depOn.length) + ")</div>" +
|
||||
(hasDeps ? "<div class=\"tab\" data-panel=\"g-graph-" + uid + "\">Граф</div>" : "") +
|
||||
"</div>";
|
||||
|
||||
h += "<div class=\"panel active\" id=\"p-" + uid + "\"><div class=\"params-grid\">" +
|
||||
"<div class=\"params-section\"><h4>Input (params)</h4>" + renderP(params) + "</div>" +
|
||||
"<div class=\"params-section\"><h4>Output (out)</h4>" + renderP(out) + "</div>" +
|
||||
"</div></div>";
|
||||
|
||||
var opsHtml = "";
|
||||
if(ops.length){
|
||||
ops.forEach(function(op){
|
||||
var cls = op.isInProgress ? "run" : op.isSuccessful ? "ok" : "fail";
|
||||
var icon = op.isInProgress ? "⌛" : op.isSuccessful ? "✓" : "✗";
|
||||
var dur = op.duration != null ? op.duration.toFixed(1) : "\u2014";
|
||||
opsHtml += "<tr><td>" + (op.operation||"\u2014") + "</td><td>" + (op.updaterShortname||op.updaterLogin||"\u2014") +
|
||||
"</td><td>" + fmtDt(op.dtStart) + "</td><td>" + fmtDt(op.dtFinish) + "</td><td>" + dur +
|
||||
"</td><td class=\"" + cls + "\">" + icon + "</td></tr>";
|
||||
});
|
||||
} else {
|
||||
opsHtml = "<tr><td colspan=\"6\" class=\"empty-msg\" style=\"padding:10px\">Нет записей</td></tr>";
|
||||
}
|
||||
h += "<div class=\"panel\" id=\"h-" + uid + "\"><table class=\"hist-table\"><thead><tr>" +
|
||||
"<th>Операция</th><th>Кто</th><th>Начало</th><th>Конец</th><th>Длит. (s)</th><th>Статус</th>" +
|
||||
"</tr></thead><tbody>" + opsHtml + "</tbody></table></div>";
|
||||
|
||||
var depsHtml = "";
|
||||
if(deps.length){
|
||||
deps.forEach(function(d){
|
||||
depsHtml += "<div class=\"dep-item\"><div class=\"dep-name\">" + (d.displayName||d.uid) +
|
||||
"</div><div class=\"dep-meta\">" + (d.svc||"") +
|
||||
(d.param ? " · через <code>" + d.param + "</code>" : "") +
|
||||
(d.descr ? " · " + d.descr : "") + "</div></div>";
|
||||
});
|
||||
} else {
|
||||
depsHtml = "<div class=\"empty-msg\">Нет зависимостей</div>";
|
||||
}
|
||||
var depOnHtml = "";
|
||||
if(depOn.length){
|
||||
depOn.forEach(function(d){
|
||||
depOnHtml += "<div class=\"dep-item\"><div class=\"dep-name\">" + (d.displayName||d.uid) +
|
||||
"</div><div class=\"dep-meta\">" + (d.svc||"") +
|
||||
(d.param ? " · через <code>" + d.param + "</code>" : "") + "</div></div>";
|
||||
});
|
||||
} else {
|
||||
depOnHtml = "<div class=\"empty-msg\">Никто не зависит</div>";
|
||||
}
|
||||
h += "<div class=\"panel\" id=\"d-" + uid + "\"><div class=\"deps-grid\">" +
|
||||
"<div class=\"deps-section\"><h4>Зависит от (" + deps.length + ")</h4>" + depsHtml + "</div>" +
|
||||
"<div class=\"deps-section\"><h4>Зависят от него (" + depOn.length + ")</h4>" + depOnHtml + "</div>" +
|
||||
"</div></div>";
|
||||
|
||||
if(hasDeps){
|
||||
h += "<div class=\"panel\" id=\"g-graph-" + uid + "\">" +
|
||||
"<div class=\"graph-hint\">Стрелка A → B означает: B зависит от A. Можно масштабировать колесом и перетаскивать узлы.</div>" +
|
||||
"<div class=\"inst-graph\" id=\"g-" + uid + "\"><div class=\"graph-loading\">Нажмите вкладку «Граф» для расчёта...</div></div>" +
|
||||
"</div>";
|
||||
}
|
||||
|
||||
return h;
|
||||
}
|
||||
|
||||
function renderP(obj, pfx){
|
||||
if(!obj || typeof obj !== "object") return "<div class=\"p-item\"><span class=\"p-val\">" + (obj || "\u2014") + "</span></div>";
|
||||
var r = "";
|
||||
var keys = Object.keys(obj);
|
||||
keys.forEach(function(k){
|
||||
var v = obj[k];
|
||||
var key = pfx ? pfx + "." + k : k;
|
||||
if(v && typeof v === "object" && !Array.isArray(v)){
|
||||
r += renderP(v, key);
|
||||
} else {
|
||||
var val = Array.isArray(v) ? JSON.stringify(v) : (v != null ? String(v) : "\u2014");
|
||||
r += "<div class=\"p-item\"><span class=\"p-key\">" + key + "</span><span class=\"p-val\">" + val + "</span></div>";
|
||||
}
|
||||
});
|
||||
return r || "<div class=\"empty-msg\">Пусто</div>";
|
||||
}
|
||||
|
||||
function loadInstances(){
|
||||
var checked = getChecked();
|
||||
document.getElementById("tbody").innerHTML = "<tr><td colspan=\"7\" class=\"loading\">Загрузка...</td></tr>";
|
||||
var p = new URLSearchParams();
|
||||
(checked.length ? checked : ["__none__"]).forEach(function(s){ p.append("status", s); });
|
||||
fetch(API + "/instances?" + p, { headers: apiHeaders() })
|
||||
.then(function(r){
|
||||
if(r.status === 401){ doLogout(); return null; }
|
||||
if(!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data){
|
||||
if(!data) return;
|
||||
allInstances = data.instances || [];
|
||||
renderStats(allInstances);
|
||||
render(allInstances);
|
||||
})
|
||||
.catch(function(e){
|
||||
document.getElementById("tbody").innerHTML = "<tr><td colspan=\"7\" class=\"err-msg\">" + e.message + "</td></tr>";
|
||||
});
|
||||
}
|
||||
|
||||
function applyTheme(theme){
|
||||
var isLight = theme === "light";
|
||||
document.body.classList.toggle("light", isLight);
|
||||
var btn = document.getElementById("btn-theme");
|
||||
if(btn){
|
||||
btn.textContent = isLight ? "☀" : "🌙";
|
||||
btn.title = isLight ? "Тема: день" : "Тема: ночь";
|
||||
}
|
||||
localStorage.setItem("dashboard_theme", isLight ? "light" : "dark");
|
||||
}
|
||||
|
||||
function toggleTheme(){
|
||||
var cur = localStorage.getItem("dashboard_theme") || "dark";
|
||||
applyTheme(cur === "dark" ? "light" : "dark");
|
||||
}
|
||||
|
||||
function doLogin(){
|
||||
var token = document.getElementById("token-input").value.trim();
|
||||
var env = (document.getElementById("deck-env").value || "test").toLowerCase();
|
||||
if(!token){ showLoginErr("Вставьте токен"); return; }
|
||||
var btn = document.getElementById("btn-login");
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Проверка...";
|
||||
fetch(API + "/instances?status=running", { headers: {"X-Deck-Token": token, "X-Deck-Env": env} })
|
||||
.then(function(r){
|
||||
if(r.status === 401){ showLoginErr("Токен не принят"); btn.disabled = false; btn.textContent = "Войти"; return null; }
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data){
|
||||
if(!data) return;
|
||||
localStorage.setItem("deck_token", token);
|
||||
localStorage.setItem("deck_env", env);
|
||||
initApp(data.instances || []);
|
||||
})
|
||||
.catch(function(e){
|
||||
showLoginErr("Ошибка соединения: " + e.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Войти";
|
||||
});
|
||||
}
|
||||
|
||||
function initApp(preloaded){
|
||||
document.getElementById("login-screen").classList.add("hidden");
|
||||
document.getElementById("app").style.display = "flex";
|
||||
var email = decodeEmail(getToken());
|
||||
document.getElementById("user-email").textContent = email;
|
||||
document.querySelectorAll("th[data-col]").forEach(function(th){
|
||||
th.addEventListener("click", function(){
|
||||
if(sortCol === th.dataset.col) sortDir *= -1;
|
||||
else { sortCol = th.dataset.col; sortDir = 1; }
|
||||
render(allInstances);
|
||||
});
|
||||
});
|
||||
document.querySelectorAll("#filters input").forEach(function(cb){
|
||||
cb.addEventListener("change", loadInstances);
|
||||
});
|
||||
if(preloaded && preloaded.length > 0){
|
||||
allInstances = preloaded;
|
||||
renderStats(allInstances);
|
||||
render(allInstances);
|
||||
} else {
|
||||
loadInstances();
|
||||
}
|
||||
scheduleGraphWarmup();
|
||||
}
|
||||
|
||||
document.getElementById("btn-login").addEventListener("click", doLogin);
|
||||
document.getElementById("btn-refresh").addEventListener("click", loadInstances);
|
||||
document.getElementById("btn-running-graph").addEventListener("click", openRunningGraph);
|
||||
document.getElementById("btn-theme").addEventListener("click", toggleTheme);
|
||||
document.getElementById("btn-close-graph").addEventListener("click", closeRunningGraph);
|
||||
document.getElementById("btn-exit").addEventListener("click", doLogout);
|
||||
document.getElementById("graph-modal").addEventListener("click", function(e){
|
||||
if(e.target && e.target.id === "graph-modal") closeRunningGraph();
|
||||
});
|
||||
|
||||
applyTheme(localStorage.getItem("dashboard_theme") || "dark");
|
||||
|
||||
var envSelect = document.getElementById("deck-env");
|
||||
if(envSelect) envSelect.value = getDeckEnv();
|
||||
|
||||
if(getToken()) initApp([]);
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user