From 82979e71df177212b2764f011509217590cc9be6 Mon Sep 17 00:00:00 2001 From: Naeel Date: Thu, 16 Apr 2026 06:06:42 +0300 Subject: [PATCH] docs: enforce SSH-only policy and add full dashboard handoff context --- .github/copilot-instructions.md | 87 ++++ .gitignore | 33 ++ Dockerfile | 8 + doc/CHAT_CONTEXT_FULL.md | 181 +++++++ k8s/deployment.yaml | 28 + k8s/ingress.yaml | 20 + k8s/service.yaml | 11 + main.py | 220 ++++++++ requirements.txt | 3 + static/index.html | 894 ++++++++++++++++++++++++++++++++ 10 files changed, 1485 insertions(+) create mode 100644 .github/copilot-instructions.md create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 doc/CHAT_CONTEXT_FULL.md create mode 100644 k8s/deployment.yaml create mode 100644 k8s/ingress.yaml create mode 100644 k8s/service.yaml create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 static/index.html diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..3a8a868 --- /dev/null +++ b/.github/copilot-instructions.md @@ -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 операционной дисциплины. + +Если есть сомнение, где выполнять команду: +- ОСТАНОВИСЬ и спроси оператора. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa55fa8 --- /dev/null +++ b/.gitignore @@ -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.* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..55b48c9 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/doc/CHAT_CONTEXT_FULL.md b/doc/CHAT_CONTEXT_FULL.md new file mode 100644 index 0000000..e94cacd --- /dev/null +++ b/doc/CHAT_CONTEXT_FULL.md @@ -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/тесты проекта + +Почему: +- так исключаются конфликты сред и ложные результаты; +- единая точка выполнения (ВМ) дает предсказуемость и воспроизводимость. diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 0000000..f37f603 --- /dev/null +++ b/k8s/deployment.yaml @@ -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" diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml new file mode 100644 index 0000000..7c12319 --- /dev/null +++ b/k8s/ingress.yaml @@ -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 diff --git a/k8s/service.yaml b/k8s/service.yaml new file mode 100644 index 0000000..6404c7b --- /dev/null +++ b/k8s/service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: cloud-dashboard + namespace: terra +spec: + selector: + app: cloud-dashboard + ports: + - port: 80 + targetPort: 8080 diff --git a/main.py b/main.py new file mode 100644 index 0000000..63fa974 --- /dev/null +++ b/main.py @@ -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") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6ef40cf --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.0 +uvicorn[standard]==0.34.0 +httpx==0.27.0 diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..96f04c4 --- /dev/null +++ b/static/index.html @@ -0,0 +1,894 @@ + + + + + + Nubes Cloud Dashboard + + + + +
+ +
+ +
+
+
+
Общий граф зависимостей: running
+ +
+
+
+
+
+
+ + + + + +