diff --git a/doc/CHAT_CONTEXT_FULL.md b/doc/CHAT_CONTEXT_FULL.md index 9a65b2e..d94d2e3 100644 --- a/doc/CHAT_CONTEXT_FULL.md +++ b/doc/CHAT_CONTEXT_FULL.md @@ -210,3 +210,291 @@ ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout= Почему: - так исключаются конфликты сред и ложные результаты; - единая точка выполнения (ВМ) дает предсказуемость и воспроизводимость. + +--- + +## 13. Дисциплина загрузки таблицы (актуально) + +Обязательная модель работы таблицы: +- В начале загрузки читаем данные инстансов и их параметры только из API. +- Строка таблицы появляется сразу после чтения параметров конкретного инстанса. +- Параллельные обращения к внешнему API запрещены: только последовательная обработка. +- После завершения загрузки повторных обращений к API нет. +- Новые обращения к API выполняются только по кнопке Обновить. + +Требования к UX загрузки: +- В верхней строке перед кнопкой Обновить показывать прогресс в формате Загрузка X/Y. +- После завершения загрузки показывать Актуально на ЧЧ:ММ:СС. +- Рядом с меткой актуальности показывать время загрузки последнего обновления. + +Требования к таблице (операторский режим): +- После чтения параметров конкретного инстанса из API сразу добавлять его строку в таблицу. +- Добавить узкий столбец удаляемости сразу после статуса. +- Зеленый индикатор показывать только если инстанс в suspended и прошло 14+ дней с входа в suspended (по данным API). +- В раскрытой строке suspended-инстанса показывать пояснение по удаляемости. +- Блоки Input/Output параметров не растягивать на всю ширину; сделать компактный читаемый layout. +- Порог удаляемости, паттерны операций и пользовательские тексты хранить в конфиге фронта (без хардкода в вычислениях). + +## 14. Журнал решений и хронология (без скрытых рассуждений) + +### Шаг A — Режим загрузки таблицы +- Цель: сделать поведение предсказуемым для оператора и убрать лишние запросы. +- Входные ограничения: читать данные только из API; после загрузки работать из памяти; новые запросы только по Обновить. +- Варианты: + - A1: догружать детали по клику строки. + - A2: читать детали сразу в стриме и больше не дергать API. +- Выбранный вариант: A2. +- Причина выбора: минимальная фрагментация данных, прозрачный UX, отсутствие неожиданной сетевой активности. +- Проверка: поток отдает progress и done-событие; во фронте удалена догрузка деталей по клику. + +### Шаг B — Последовательные вызовы API +- Цель: исключить параллельные обращения к API. +- Входные ограничения: запрет на параллельные запросы. +- Варианты: + - B1: batch-параллелизм с ограничением concurrency. + - B2: строго последовательный обход uid. +- Выбранный вариант: B2. +- Причина выбора: полное соответствие ограничению и упрощение отладки. +- Проверка: убраны gather/create_task/Semaphore для стрим-логики. + +### Шаг C — Удаляемость suspended > 14 дней +- Цель: добавить операторский индикатор удаления без лишней визуальной нагрузки. +- Входные ограничения: считать только по API-данным. +- Варианты: + - C1: считать от dtState всегда. + - C2: сначала искать suspend-операции, затем fallback на поля detail. +- Выбранный вариант: C2. +- Причина выбора: меньше ложных срабатываний при изменениях, не связанных с suspend. +- Проверка: индикатор и пояснение показываются только для suspended; порог 14+ дней. + +### Шаг D — Немонолитная, но умеренная структура +- Цель: не превращать файл в монолит и одновременно не перегрузить архитектуру. +- Входные ограничения: без чрезмерного рефакторинга. +- Варианты: + - D1: полный вынос JS в модули/файлы. + - D2: локальная модульность внутри файла через конфиг и мелкие функции. +- Выбранный вариант: D2. +- Причина выбора: безопасно для текущей стадии, быстро настраивается, меньше риск поломок. +- Реализация: + - Конфиг политики/текстов вынесен в DASHBOARD_CFG. + - Общие шаблоны строк через fmtText. + - Рендер таблицы разделен на renderDataRow и renderDetailRow. + - Константа TABLE_COLSPAN и состояние TABLE_STATE исключают магические значения. + +### Шаг E — Backend как источник вычисляемых метаданных +- Цель: убрать дублирующую бизнес-логику из фронта и сделать поведение единообразным. +- Входные ограничения: без избыточного рефакторинга, только практичный шаг. +- Варианты: + - E1: оставить расчеты удаляемости только во фронте. + - E2: считать удаляемость на backend и отдавать в stream-строке как мета-поля. +- Выбранный вариант: E2. +- Причина выбора: policy централизована на сервере, фронт становится проще и легче переконфигурируется. +- Реализация: + - В backend добавлен конфиг DELETE_POLICY и функции вычисления suspend-возраста. + - В stream добавлено поле _meta.deleteCandidate. + - Фронт сначала применяет backend _meta; клиентский fallback использовался временно и отключен на шаге G. + +### Шаг F — Локальный snapshot таблицы в браузере +- Цель: после перезагрузки страницы не обращаться к API до явного нажатия Обновить. +- Входные ограничения: решение должно быть легким и не монолитным. +- Варианты: + - F1: только RAM (теряется при reload). + - F2: локальный snapshot per user+env в browser storage. +- Выбранный вариант: F2. +- Причина выбора: быстрый старт UI после reload и автономная работа без нового API-вызова. +- Реализация: + - Добавлен snapshot storage key на основе user+env. + - После успешного полного refresh snapshot сохраняется. + - На init сначала пробуется восстановление snapshot; API вызывается только если snapshot отсутствует. + - На logout snapshot очищается. + +### Шаг G — Backend-only deleteability + snapshot schema v2 +- Цель: исключить расхождение бизнес-правил между клиентом и сервером и безопасно версионировать локальный кэш. +- Входные ограничения: без тяжелого рефакторинга. +- Реализация: + - Клиентский fallback вычисления удаляемости отключен в stream-обработке. + - Поля `_canDelete`/`_suspendDays` инициализируются из backend `_meta.deleteCandidate` (или остаются нулевыми по умолчанию). + - Формат snapshot переведен на `version=2` и `schema=instance-stream-v2`. + - Ключ snapshot обновлен до `dashboard_snapshot_v2`, чтобы отделить новый формат от старого. + +### Шаг H — Релиз v9.22 (build/push/deploy + верификация) +- Цель: доставить изменения шага G в кластер и подтвердить фактическую версию рантайма. +- Реализация: + - Собран образ `naeel/cloud-dashboard:v9.22`. + - Выполнен push в Docker Hub. + - Применен `k8s/deployment.yaml` в namespace `terra`. + - Rollout завершен успешно. + - Проверено соответствие версии в deployment и pod. +- Проверка: + - `spec.image`: `naeel/cloud-dashboard:v9.22`. + - `pod.image`: `naeel/cloud-dashboard:v9.22`. + - `pod.imageID`: `docker.io/naeel/cloud-dashboard@sha256:18e87ff592a333cc280fd2af13390f47b111616b8da31f2e32d7ac24e3a3a7ea`. + - `pod` статус: `Running`, `Ready=true`, `Restarts=0`. + - Внутренний smoke-check: `GET http://127.0.0.1:8080/` -> `HTTP 200`. + - Внешний smoke-check (ingress `terra.k8c.ru`): `HTTPS /` -> `200`, `HTTP /` -> `308`. + + ### Шаг I — Добавлен столбец UUID после Имени (v9.29) + - Цель: сделать идентификацию инстанса в таблице мгновенной без раскрытия строки. + - Реализация: + - В таблицу добавлен новый sortable-столбец `UUID` сразу после `Имя`. + - В рендере строк добавлена ячейка `instanceUid` (моноширинный стиль). + - `TABLE_COLSPAN` увеличен с `8` до `9`. + - Обновлены мобильные индексы скрываемых колонок (`nth-child`) после сдвига структуры. + - Выполнен version bump: .naeel/cloud-dashboard:v9.29`. + + ### Шаг J — Фикс `renderDataRow is not defined` (v9.30) + - Цель: убрать runtime-ошибку в таблице после добавления UUID-колонки. + - Реализация: + - `renderDataRow` и `renderDetailRow` вынесены из `toggle()` в общий scope. + - Рендер таблицы (`render`) снова вызывает доступные функции без `ReferenceError`. + - Выполнен version bump: .naeel/cloud-dashboard:v9.30`. + + ### Шаг K — Перенос светофора в колонку Изменён (v9.31) + - Цель: убрать отдельный столбец индикатора и показывать только полезный сигнал. + - Реализация: + - Удален отдельный столбец светофора из таблицы. + - Зеленая точка теперь рисуется после времени в колонке `Изменён` только для eligible (`suspended` > 14 дней, по backend-метаданным). + - Серые точки полностью убраны. + - `TABLE_COLSPAN` обновлен с 9 до 8. + - Обновлены мобильные индексы скрываемых колонок. + - Выполнен version bump: .naeel/cloud-dashboard:v9.31`. + + ### Шаг L — Возврат потокового вывода строк (v9.32) + - Цель: показывать строки таблицы по мере загрузки stream, без ожидания `_done`. + - Реализация: + - Исправлен `scheduleRender()`: вместо perpetual-debounce применяется throttle (если таймер уже запущен, новый не ставится). + - UI теперь обновляется регулярно в ходе потока, а не откладывается до конца непрерывного чтения. + - Выполнен version bump: .naeel/cloud-dashboard:v9.32`. + + ### Шаг M — Левое выравнивание 4 колонок в Параметрах (v9.33) + - Цель: сделать `Input/Output` читабельным в табличном стиле без центрирования. + - Реализация: + - `params-grid` переведен на `max-content` колонки и выравнивание влево. + - `p-item` переведен на grid из 2 колонок (`key` + `value`) с увеличенным фиксированным зазором между ними. + - `p-key` и `p-val` выровнены влево. + - Ширина колонок определяется содержимым; при реальном переполнении включается перенос (`overflow-wrap: anywhere`). + - Выполнен version bump: .naeel/cloud-dashboard:v9.33`. + + ### Шаг N — Ширина колонки имен: max + 10ch (v9.34) + - Цель: сделать значения читаемыми по единой вертикали и увеличить отступ от имен. + - Реализация: + - Для каждой секции (`Input`/`Output`) вычисляется максимальная длина имени параметра. + - Ширина колонки имен задается формулой: `maxKeyLen * 1ch + 10ch`. + - Значения начинаются строго от единой левой границы value-колонки в каждой секции. + - Выполнен version bump: .naeel/cloud-dashboard:v9.34`. + + ### Шаг O — Output +20 и copy-иконка параметров (v9.35) + - Цель: повысить читаемость `Input/Output` и ускорить копирование значений. + - Реализация: + - Увеличен межсекционный отступ между `Input` и `Output` на +20. + - Каждая строка параметров переведена в 3 зоны: `key | value | copy`. + - Иконка копирования фиксирована в конце строки (правая зона), со вспышкой `✓` после успешного копирования. + - Добавлена функция `copyParamValue` с Clipboard API и fallback через `execCommand`. + - Выполнен version bump: .naeel/cloud-dashboard:v9.35`. + + ### Шаг P — Скрытие copy для пустых + фикс дневного режима (v9.36) + - Цель: убрать визуальный шум и исправить неполное переключение в light theme. + - Реализация: + - `renderCopyBtnHtml` не рисует кнопку копирования для пустых значений и `—`. + - Добавлены `body.light` стили для `.stat-card` и `.stat-card .lbl`, чтобы карточки статистики реально становились светлыми. + - Выполнен version bump: .naeel/cloud-dashboard:v9.36`. + + ### Шаг Q — Light-theme для status-badge (v9.37) + - Цель: убрать темные/кислотные бейджи статусов в дневном режиме. + - Реализация: + - Добавлены `body.light` переопределения для `.s-running`, `.s-suspended`, `.s-deleting`, `.s-deleted`, `.s-pending`, `.s-creating`, `.s-default`. + - Для светлой темы введены мягкие светлые фоны и контрастный текст, плюс тонкая рамка бейджа. + - Выполнен version bump: .naeel/cloud-dashboard:v9.37`. + + ### Шаг R — Контрастный фон блока деталей/параметров (v9.38) + - Цель: убрать визуальное слияние раскрытой области деталей с основной таблицей. + - Реализация: + - Усилен контейнер `.detail-box`: отдельный фон, рамка, скругление, внутренний отступ от таблицы. + - Добавлен фон для `.detail-row td` и контрастная подложка вкладок `.tabs`. + - Увеличен контраст панелей `.panel-params`, `.panel-history`, `.panel-deps` в dark/light режимах. + - Выполнен version bump: .naeel/cloud-dashboard:v9.38`. + + ### Шаг S — Полосатые строки (zebra) для читаемости (v9.39) + - Цель: улучшить восприятие строк, чтобы данные не сливались по горизонтали. + - Реализация: + - Добавлена мягкая zebra-разметка в основной таблице (`tbody tr.data-row`) с разным тоном соседних строк. + - Добавлена zebra-разметка в таблице истории (`.hist-table tbody tr`). + - Добавлена лёгкая zebra-подложка для строк параметров (`.p-item`). + - Отдельно настроены оттенки для `dark` и `light` темы. + - Выполнен version bump: .naeel/cloud-dashboard:v9.39`. + + ### Шаг T — Полная деактивация графа + явная зебра основной таблицы (v9.40) + - Цель: полностью убрать интеракции графа и сделать полосатость в основной таблице заметной. + - Реализация: + - Из header удалена кнопка `Граф зависимостей`. + - Из деталей удалена вкладка `Граф` и панель графа. + - Удалены обработчики клика по глобальному графу/модалке, чтобы граф не открывался вообще. + - Полосатость основной таблицы переведена на классы `row-base/row-alt`, назначаемые в `renderDataRow` по индексу данных (не зависит от `detail-row`). + - Усилен контраст тонов зебры в light/dark. + - Выполнен version bump: .naeel/cloud-dashboard:v9.40`. + + ### Шаг U — Copy для UUID + жирные имена инстансов (v9.41) + - Цель: ускорить работу оператора в таблице и усилить визуальный приоритет названий. + - Реализация: + - В колонке UUID добавлена иконка копирования `⧉` для каждой строки. + - Кнопка UUID-копирования использует существующую логику `copyParamValue` и не триггерит раскрытие строки (`event.stopPropagation()`). + - Имена инстансов в колонке `Имя` переведены в жирное начертание (`.inst-name`). + - Для `uuid-copy` добавлены отдельные hover/ok стили в dark/light теме. + - Выполнен version bump: .naeel/cloud-dashboard:v9.41`. + + ### Шаг V — Правильная логика иконки day/night (v9.42) + - Цель: сделать поведение кнопки темы интуитивным (иконка показывает целевое переключение, а не текущий режим). + - Реализация: + - В `applyTheme` изменена логика кнопки: + - при активной дневной теме показывается `🌙` (переключение на ночь), + - при активной ночной теме показывается `☀` (переключение на день). + - Обновлены `title`-подсказки на действие: `Переключить на ... тему`. + - Выполнен version bump: naeel/cloud-dashboard:v9.42. + +## 15. HANDOFF для нового чата (что делать дальше без доп. подсказок) + +Текущее состояние (baseline): +- Таблица загружается через `/instances/stream` один раз на refresh, далее работает из памяти. +- Повторных API-вызовов между refresh нет. +- После reload данные восстанавливаются из browser snapshot (`version=2`, `schema=instance-stream-v2`). +- Удаляемость берется только из backend `_meta.deleteCandidate`. +- В кластере задеплоен образ `naeel/cloud-dashboard:v9.43`. + +Нельзя нарушать (инварианты): +- Не добавлять параллельные обращения к внешнему API. +- Не делать догрузку detail по клику строки. +- Не возвращать клиентский fallback вычисления удаляемости. +- Все проектные команды выполнять только через SSH на ВМ. + +При старте нового чата сделать в таком порядке: +1. Проверить, что в `k8s/deployment.yaml` увеличен image tag относительно предыдущего. +2. Проверить соответствие stream-контракта: `_progress`, `_detail`, `_meta`, `_done`. +3. Проверить, что `restoreSnapshot()` вызывается до `loadAllDetails()`. +4. Прогнать smoke-flow вручную: login -> refresh -> reload -> refresh. +5. Зафиксировать результат в этот файл отдельным шагом (H, I, ...). + +Примечание по sync-проверке путей: +- Путь `/home/naeel/remote_dev/dashboard` может отсутствовать на ВМ, поэтому прямое сравнение хешей с `~/terra/dashboard` не всегда выполнимо на стороне ВМ. +- Если путь отсутствует, источником истины считать `~/terra/dashboard`, а для альтернативной проверки синхронизации запросить у оператора допустимый способ сравнения. + +Чеклист smoke-flow (обязательный): +- После login/refresh видно `Загрузка X/Y`. +- После завершения видно `Актуально на ... (загрузка N c)`. +- После reload видно `Актуально на ... (из локального snapshot)`. +- До кнопки `Обновить` новых обращений к API нет. +- Индикатор удаляемости совпадает с backend `_meta.deleteCandidate`. + +Definition of Done для следующего изменения: +- Выполнен version bump (минимум patch) в image tag. +- Изменение не ломает table-first режим и snapshot-режим. +- Документация обновлена новым шагом в разделе 14 или отдельной секцией. + +### Шаг W — Реальный фикс theme-toggle после валидации pod (v9.43) +- Цель: гарантировать, что в рантайме действительно применяется инвертированная UX-логика иконки day/night. +- Причина: после релиза v9.42 в pod обнаружилась старая логика `applyTheme`. +- Реализация: + - На VM подтвержденно обновлен `static/index.html`: + - `light -> 🌙` (переключение на ночь), + - `dark -> ☀` (переключение на день), + - `title` показывает действие переключения. + - Выполнен повторный version bump: `naeel/cloud-dashboard:v9.43`. diff --git a/doc/NEW_CHAT_SUMMARY_2026-04-16_v9.28.md b/doc/NEW_CHAT_SUMMARY_2026-04-16_v9.28.md new file mode 100644 index 0000000..55cb521 --- /dev/null +++ b/doc/NEW_CHAT_SUMMARY_2026-04-16_v9.28.md @@ -0,0 +1,47 @@ +Контекст: +Работаем с dashboard (FastAPI + static index.html), режим строго практичный: сначала таблица, граф потом. +Модель данных: один полный refresh из API, дальше работа только из памяти; к API повторно ходим только по кнопке Обновить. +Параллельные вызовы к API не используем. + +Что уже сделано: +1. Исправлены runtime-регрессии фронта: +- renderDataRow is not defined +- instanceName is not defined + +2. Исправлена пагинация backend: +- раньше обрезалось на 99/100 +- теперь fetch_all_instances останавливается по пустой странице, а не по size < limit. + +3. Метаданные удаляемости: +- удаляемость только для suspended и только если старше 14 дней +- фронт только отображает backend-решение. + +4. Визуальные правки: +- сужен индикаторный столбец удаляемости +- вкладки и панели сделаны контрастнее, но без кислотных цветов. + +5. Последний релиз: +- image: naeel/cloud-dashboard:v9.28 +- rollout в namespace terra успешный +- в рантайме проверено, что новые CSS-правила реально отдаются. + +Ключевая последняя правка (v9.28): +- перенос в Output (out) теперь только при реальном переполнении, а не заранее: + - убран агрессивный word-break: break-all + - применены word-break: normal и overflow-wrap: break-word + - расширена grid-раскладка params/out, снято искусственное max-width-ограничение. + +Операционные правила: +1. Все команды проекта только по SSH на VM (git, docker, kubectl, curl, проверки). +2. После каждого изменения кода обязательно bump версии image. +3. После деплоя обязательна runtime-проверка в pod. +4. Документацию handoff обновлять сразу после релиза. + +Что проверить первым делом в новом чате: +1. UX блока Input/Output на широком экране (нет раннего переноса). +2. Исключить кэш браузера (hard reload), если визуально кажется, что изменений нет. +3. Что в кластере запущен актуальный тег (baseline: v9.28). + +Текущий baseline: +- dashboard: v9.28 +- состояние: стабильно, критичных открытых багов не зафиксировано. \ No newline at end of file diff --git a/doc/NEW_CHAT_SUMMARY_2026-04-17_v9.42.md b/doc/NEW_CHAT_SUMMARY_2026-04-17_v9.42.md new file mode 100644 index 0000000..1323e99 --- /dev/null +++ b/doc/NEW_CHAT_SUMMARY_2026-04-17_v9.42.md @@ -0,0 +1,56 @@ +# NEW CHAT SUMMARY — 2026-04-17 (baseline v9.42) + +## 1) Текущий фокус продукта +- Dashboard для операторов: table-first UX. +- Данные читаются из API только по кнопке `Обновить`. +- Внутри сессии/до refresh работа идет из in-memory/snapshot, без фоновых параллельных API-дозапросов. + +## 2) Backend/Frontend контракт +- Источник: `/instances/stream`. +- Фронт ожидает NDJSON-события: `_progress`, `_detail`, `_meta`, `_done`. +- Детали по строке не догружаются отдельным API-вызовом. + +## 3) Критичные UX-инварианты (не ломать) +- Сначала таблица, затем детали (параметры/история/зависимости). +- Удаляемость: только backend-логика (`_meta.deleteCandidate`), правило `suspended >= 14 дней`. +- Зеленая точка в колонке `Изменён` только для delete-candidate. +- В light-theme статусы (`running/suspended/...`) имеют отдельные светлые палитры. +- Граф зависимостей полностью отключен в UI (кнопки/вкладки/обработчики удалены). + +## 4) Что изменено в этой сессии (ключевое) +- Усилен контраст области деталей относительно основной таблицы. +- Добавлена/усилена zebra-полосатость: + - основная таблица (`row-base/row-alt`), + - история, + - параметры. +- В таблице: + - имена инстансов сделаны жирными, + - в UUID добавлена кнопка копирования `⧉` с `stopPropagation`. +- Исправлена логика кнопки темы: + - если сейчас ночь -> иконка `☀` (переключить на день), + - если сейчас день -> иконка `🌙` (переключить на ночь). + +## 5) Актуальная версия и деплой +- Docker image: `naeel/cloud-dashboard:v9.42`. +- `k8s/deployment.yaml` обновлен до `v9.42`. +- Rollout выполнен успешно. + +## 6) Важные файлы +- `static/index.html` — вся клиентская логика/рендер/темы/таблица. +- `main.py` — backend API и stream endpoint. +- `k8s/deployment.yaml` — текущий image tag. +- `doc/CHAT_CONTEXT_FULL.md` — пошаговый журнал изменений (A..V). + +## 7) Что проверить первым делом в новом чате +1. Убедиться, что в кластере и в deployment реально `v9.42`. +2. Smoke UX: + - login -> refresh -> видна прогресс-загрузка, + - строки таблицы полосатые, + - графа нигде нет, + - UUID копируется без раскрытия строки, + - кнопка темы показывает целевое действие (☀/🌙). +3. Проверить, что нет API-запросов между refresh (кроме явных пользовательских действий). + +## 8) Операционная дисциплина +- Команды по проекту выполнять через SSH на VM. +- Перед каждым деплоем обязательно increment image tag (минимум patch). diff --git a/doc/NEW_CHAT_SUMMARY_2026-04-17_v9.43.md b/doc/NEW_CHAT_SUMMARY_2026-04-17_v9.43.md new file mode 100644 index 0000000..a0bd11b --- /dev/null +++ b/doc/NEW_CHAT_SUMMARY_2026-04-17_v9.43.md @@ -0,0 +1,56 @@ +# NEW CHAT SUMMARY — 2026-04-17 (baseline v9.43) + +## 1) Текущий фокус продукта +- Dashboard для операторов: table-first UX. +- Данные читаются из API только по кнопке `Обновить`. +- Внутри сессии/до refresh работа идет из in-memory/snapshot, без фоновых параллельных API-дозапросов. + +## 2) Backend/Frontend контракт +- Источник: `/instances/stream`. +- Фронт ожидает NDJSON-события: `_progress`, `_detail`, `_meta`, `_done`. +- Детали по строке не догружаются отдельным API-вызовом. + +## 3) Критичные UX-инварианты (не ломать) +- Сначала таблица, затем детали (параметры/история/зависимости). +- Удаляемость: только backend-логика (`_meta.deleteCandidate`), правило `suspended >= 14 дней`. +- Зеленая точка в колонке `Изменён` только для delete-candidate. +- В light-theme статусы (`running/suspended/...`) имеют отдельные светлые палитры. +- Граф зависимостей полностью отключен в UI (кнопки/вкладки/обработчики удалены). + +## 4) Что изменено в этой сессии (ключевое) +- Усилен контраст области деталей относительно основной таблицы. +- Добавлена/усилена zebra-полосатость: + - основная таблица (`row-base/row-alt`), + - история, + - параметры. +- В таблице: + - имена инстансов сделаны жирными, + - в UUID добавлена кнопка копирования `⧉` с `stopPropagation`. +- Исправлена логика кнопки темы: + - если сейчас ночь -> иконка `☀` (переключить на день), + - если сейчас день -> иконка `🌙` (переключить на ночь). + +## 5) Актуальная версия и деплой +- Docker image: `naeel/cloud-dashboard:v9.43`. +- k8s/deployment.yaml обновлен до v9.43. +- Rollout выполнен успешно. + +## 6) Важные файлы +- `static/index.html` — вся клиентская логика/рендер/темы/таблица. +- `main.py` — backend API и stream endpoint. +- `k8s/deployment.yaml` — текущий image tag. +- `doc/CHAT_CONTEXT_FULL.md` — пошаговый журнал изменений (A..W). + +## 7) Что проверить первым делом в новом чате +1. Убедиться, что в кластере и в deployment реально v9.43. +2. Smoke UX: + - login -> refresh -> видна прогресс-загрузка, + - строки таблицы полосатые, + - графа нигде нет, + - UUID копируется без раскрытия строки, + - кнопка темы показывает целевое действие (☀/🌙). +3. Проверить, что нет API-запросов между refresh (кроме явных пользовательских действий). + +## 8) Операционная дисциплина +- Команды по проекту выполнять через SSH на VM. +- Перед каждым деплоем обязательно increment image tag (минимум patch). diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index b29d621..88f0fb3 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -15,7 +15,7 @@ spec: spec: containers: - name: cloud-dashboard - image: naeel/cloud-dashboard:v9.21 + image: naeel/cloud-dashboard:v9.43 imagePullPolicy: Always ports: - containerPort: 8080 diff --git a/main.py b/main.py index ce5cb1b..7d0a075 100644 --- a/main.py +++ b/main.py @@ -6,6 +6,7 @@ from typing import Annotated import asyncio import json import time +from datetime import datetime, timezone app = FastAPI() @@ -18,6 +19,15 @@ DEFAULT_DECK_ENV = "test" CACHE_TTL_SECONDS = 180 GRAPH_CACHE: dict[str, tuple[float, dict]] = {} +# Centralized backend policy for table-related derived fields. +DELETE_POLICY = { + "status": "suspended", + "min_days": 14, + "op_name_patterns": ("suspend", "приост", "замороз"), + "op_time_fields": ("dtFinish", "dtStart", "dtCreated", "dtState"), + "fallback_time_fields": ("dtState", "instanceConfigDtUpdated"), +} + def auth_headers(token: str): return {"Authorization": f"Bearer {token}"} @@ -77,6 +87,83 @@ def extract_platform_name(inst: dict | None) -> str: return "" +def _parse_ts(value) -> float | None: + if not value: + return None + if isinstance(value, (int, float)): + return float(value) + if not isinstance(value, str): + return None + raw = value.strip() + if not raw: + return None + try: + if raw.endswith("Z"): + raw = raw[:-1] + "+00:00" + dt = datetime.fromisoformat(raw) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.timestamp() + except ValueError: + return None + + +def _extract_suspend_entered_ts(detail: dict | None) -> float | None: + if not isinstance(detail, dict): + return None + + latest_ts: float | None = None + operations = detail.get("operations") + if isinstance(operations, list): + for op in operations: + if not isinstance(op, dict): + continue + op_name = str(op.get("operation") or op.get("type") or op.get("action") or "").strip().lower() + if not any(pattern in op_name for pattern in DELETE_POLICY["op_name_patterns"]): + continue + for field in DELETE_POLICY["op_time_fields"]: + ts = _parse_ts(op.get(field)) + if ts is None: + continue + if latest_ts is None or ts > latest_ts: + latest_ts = ts + break + + if latest_ts is not None: + return latest_ts + + for field in DELETE_POLICY["fallback_time_fields"]: + ts = _parse_ts(detail.get(field)) + if ts is not None: + return ts + return None + + +def build_stream_row_meta(row: dict, detail: dict | None) -> dict: + status = str(row.get("explainedStatus") or "").strip().lower() + meta = { + "deleteCandidate": { + "status": status, + "eligible": False, + "daysInSuspend": 0, + "minDays": DELETE_POLICY["min_days"], + "suspendEnteredAt": "", + } + } + if status != DELETE_POLICY["status"]: + return meta + + entered_ts = _extract_suspend_entered_ts(detail) + if entered_ts is None: + return meta + + days = max(0, int((time.time() - entered_ts) // 86400)) + meta["deleteCandidate"]["daysInSuspend"] = days + meta["deleteCandidate"]["eligible"] = days >= DELETE_POLICY["min_days"] + meta["deleteCandidate"]["suspendEnteredAt"] = datetime.fromtimestamp(entered_ts, tz=timezone.utc).isoformat().replace("+00:00", "Z") + return meta + + async def fetch_all_instances(token: str, deck_api: str): params: dict = {"page": 1, "size": 200} all_results = [] @@ -91,9 +178,11 @@ async def fetch_all_instances(token: str, deck_api: str): raise HTTPException(status_code=401, detail="Invalid token") data = r.json() results = data.get("results", []) - all_results.extend(results) - if len(results) < 200: + if not isinstance(results, list): + results = [] + if not results: break + all_results.extend(results) params["page"] += 1 return all_results @@ -152,26 +241,23 @@ async def get_graph( } allowed_uids = set(uid_to_inst.keys()) - sem = asyncio.Semaphore(20) - - async def fetch_detail(uid: str, client: httpx.AsyncClient): - async with sem: + detail_map: dict[str, dict] = {} + async with httpx.AsyncClient(timeout=25) as shared_client: + for uid in allowed_uids: try: - r = await client.get( + r = await shared_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") + continue + inst = r.json().get("instance") + if inst: + detail_map[uid] = inst except httpx.TimeoutException: - return uid, None - - async with httpx.AsyncClient(timeout=25) as shared_client: - details = await asyncio.gather(*[fetch_detail(uid, shared_client) for uid in allowed_uids]) - detail_map = {uid: inst for uid, inst in details if inst} + continue # Categorise singletons by service name _PLATFORM_PATTERNS = ("виртуальный датацентр", "vdc", "virtual data center", "cloud director", "kubernetes", "k8s") @@ -276,35 +362,35 @@ async def stream_instances( } async def generate(): - sem = asyncio.Semaphore(20) - queue: asyncio.Queue = asyncio.Queue() total = len(uid_to_inst) + loaded = 0 + started_at = time.time() - async def fetch_one(uid: str, client: httpx.AsyncClient): - async with sem: + async with httpx.AsyncClient(timeout=25) as shared_client: + for uid in uid_to_inst: + detail = None try: - r = await client.get( + r = await shared_client.get( f"{deck_api}/index.cfm/instances/{uid}", headers=auth_headers(x_deck_token), ) - detail = None if r.status_code >= 400 else r.json().get("instance") + if r.status_code == 401: + raise HTTPException(status_code=401, detail="Invalid token") + if r.status_code < 400: + detail = r.json().get("instance") except Exception: detail = None - await queue.put((uid, detail)) - async with httpx.AsyncClient(timeout=25) as shared_client: - tasks = [ - asyncio.create_task(fetch_one(uid, shared_client)) - for uid in uid_to_inst - ] - for _ in range(total): - uid, detail = await queue.get() + loaded += 1 row = dict(uid_to_inst.get(uid, {})) + row["_progress"] = {"loaded": loaded, "total": total} + row["_meta"] = build_stream_row_meta(row, detail) if detail: row["_detail"] = detail yield json.dumps(row, ensure_ascii=False) + "\n" - yield json.dumps({"_done": True, "total": total}, ensure_ascii=False) + "\n" + duration_ms = int((time.time() - started_at) * 1000) + yield json.dumps({"_done": True, "total": total, "durationMs": duration_ms}, ensure_ascii=False) + "\n" return StreamingResponse(generate(), media_type="application/x-ndjson") diff --git a/static/index.html b/static/index.html index de3659d..215231e 100644 --- a/static/index.html +++ b/static/index.html @@ -30,6 +30,7 @@ .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; } + .data-ts { font-size: 12px; color: #8b949e; margin-right: 10px; white-space: nowrap; } .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; } @@ -69,6 +70,8 @@ .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.row-base { background: #171c25; } + tbody tr.data-row.row-alt { background: #222a36; } tbody tr.data-row:hover { background: #1c2128; } tbody tr.data-row.expanded { background: #1c2128; } td { padding: 9px 13px; font-size: 13px; vertical-align: middle; } @@ -77,18 +80,25 @@ .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; } + .detail-row td { padding: 0; border-top: 1px solid #30363d; background: #0b1220; } + .detail-box { + padding: 16px 22px; + background: #0b1220; + border: 1px solid #334155; + border-radius: 10px; + margin: 10px 12px 14px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .03); + } + .tabs { display: flex; border-bottom: 1px solid #334155; margin-bottom: 14px; background: rgba(15, 23, 42, .45); border-radius: 8px 8px 0 0; padding: 0 6px; } .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-params { background: #111827; border-top: 2px solid #1e3a5f; } - .panel-history { background: #0f1a0f; border-top: 2px solid #1a4a2e; } - .panel-deps { background: #1a1209; border-top: 2px solid #3d2a00; } - body.light .panel-params { background: #eff6ff; border-top: 2px solid #bfdbfe; } - body.light .panel-history { background: #f0fdf4; border-top: 2px solid #bbf7d0; } - body.light .panel-deps { background: #fffbeb; border-top: 2px solid #fde68a; } + .panel-params { background: #0f223d; border-top: 2px solid #3b82f6; border-radius: 0 0 8px 8px; padding: 12px; } + .panel-history { background: #102617; border-top: 2px solid #22c55e; border-radius: 0 0 8px 8px; padding: 12px; } + .panel-deps { background: #2a1808; border-top: 2px solid #f59e0b; border-radius: 0 0 8px 8px; padding: 12px; } + body.light .panel-params { background: #dbeafe; border-top: 2px solid #60a5fa; } + body.light .panel-history { background: #dcfce7; border-top: 2px solid #4ade80; } + body.light .panel-deps { background: #fef3c7; border-top: 2px solid #f59e0b; } .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; } @@ -104,14 +114,37 @@ .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-grid { display: grid; grid-template-columns: max-content max-content; gap: 14px 68px; max-width: none; margin: 0; justify-content: start; align-content: start; } + .params-section { min-width: 0; } .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; } + .p-item { display: grid; grid-template-columns: calc(var(--pkey-maxch, 24) * 1ch + 10ch) minmax(0, max-content) 20px; justify-content: start; align-items: start; column-gap: 24px; padding: 5px 0; border-bottom: 1px solid #21262d; font-size: 12px; } + .p-item:nth-child(odd) { background: rgba(148, 163, 184, .04); } + .p-item:nth-child(even) { background: rgba(148, 163, 184, .08); } + .p-key { color: #8b949e; min-width: 0; white-space: nowrap; } + .p-val { color: #e1e4e8; text-align: left; max-width: min(44vw, 860px); word-break: normal; overflow-wrap: anywhere; } + .p-copy { width: 20px; height: 20px; border: 0; background: transparent; color: #7f8da3; cursor: pointer; border-radius: 4px; display: inline-flex; align-items: center; justify-content: center; justify-self: end; opacity: .45; transition: opacity .15s ease, color .15s ease, background .15s ease; font-size: 13px; line-height: 1; } + .p-item:hover .p-copy { opacity: .95; } + .p-copy:hover { color: #dbe7ff; background: rgba(88, 166, 255, .14); } + .p-copy.ok { color: #56d364; opacity: 1; } + .inst-name { font-weight: 700; letter-spacing: .01em; } + .col-uuid-cell { font-family: monospace; font-size: 12px; color: #9fb0c6; white-space: nowrap; } + .uuid-wrap { display: inline-flex; align-items: center; gap: 6px; } + .uuid-copy { width: 18px; height: 18px; border: 0; background: transparent; color: #7f8da3; cursor: pointer; border-radius: 4px; display: inline-flex; align-items: center; justify-content: center; opacity: .7; transition: opacity .15s ease, color .15s ease, background .15s ease; font-size: 12px; line-height: 1; } + .col-uuid-cell:hover .uuid-copy { opacity: 1; } + .uuid-copy:hover { color: #dbe7ff; background: rgba(88, 166, 255, .14); } + .uuid-copy.ok { color: #56d364; opacity: 1; } + .del-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #6e7681; border: 1px solid #8b949e; } + .del-dot.ok { background: #238636; border-color: #56d364; box-shadow: 0 0 0 2px rgba(35, 134, 54, .2); } + .changed-at-cell { white-space: nowrap; } + .changed-at-cell .del-dot.ok { margin-left: 8px; vertical-align: middle; } + .delete-note { margin-bottom: 10px; padding: 8px 10px; border: 1px solid #30363d; border-radius: 6px; font-size: 12px; color: #c9d1d9; } + .delete-note.ok { border-color: #238636; background: rgba(35, 134, 54, .12); color: #8ddb9e; } + .delete-note.wait { border-color: #6e7681; background: rgba(110, 118, 129, .12); color: #c9d1d9; } .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; } + .hist-table tbody tr:nth-child(odd) { background: rgba(148, 163, 184, .04); } + .hist-table tbody tr:nth-child(even) { background: rgba(148, 163, 184, .08); } .ok { color: #56d364; } .fail { color: #f85149; } .run { color: #e3b341; } @@ -135,16 +168,35 @@ 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 .data-ts { color: #64748b; } body.light .btn-refresh, body.light .btn-theme, body.light .btn-exit, body.light .btn-graph { background: #f8fafc; border-color: #cbd5e1; color: #0f172a; } body.light .btn-refresh:hover, body.light .btn-theme:hover, body.light .btn-exit:hover, body.light .btn-graph:hover { background: #e2e8f0; } body.light .main { background: #f3f6fb; } + body.light .stat-card { background: #ffffff; border-color: #cbd5e1; } + body.light .stat-card .lbl { color: #64748b; } + body.light .status-badge { border: 1px solid transparent; } + body.light .s-running { background: #dcfce7; color: #166534; border-color: #86efac; } + body.light .s-suspended { background: #fef3c7; color: #92400e; border-color: #fcd34d; } + body.light .s-deleted { background: #fee2e2; color: #991b1b; border-color: #fca5a5; } + body.light .s-deleting { background: #fee2e2; color: #991b1b; border-color: #fca5a5; } + body.light .s-pending { background: #dbeafe; color: #1e40af; border-color: #93c5fd; } + body.light .s-creating { background: #dbeafe; color: #1e40af; border-color: #93c5fd; } + body.light .s-default { background: #e2e8f0; color: #334155; border-color: #cbd5e1; } 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.row-base { background: #ffffff; } + body.light tbody tr.data-row.row-alt { background: #eaf1ff; } body.light tbody tr.data-row:hover, body.light tbody tr.data-row.expanded { background: #f8fafc; } - body.light .detail-box { background: #f8fafc; } + body.light .detail-row td { background: #eaf0fb; } + body.light .detail-box { + background: #eaf0fb; + border-color: #b6c6e8; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .8); + } + body.light .tabs { border-bottom-color: #b6c6e8; background: #dfe9fa; } 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); } @@ -152,20 +204,33 @@ body.light .graph-modal-head { border-bottom-color: #cbd5e1; } body.light .graph-modal-close { background: #f8fafc; border-color: #cbd5e1; color: #0f172a; } body.light .p-item { border-bottom-color: #e2e8f0; color: #0f172a; } + body.light .p-item:nth-child(odd) { background: rgba(148, 163, 184, .12); } + body.light .p-item:nth-child(even) { background: rgba(148, 163, 184, .18); } body.light .p-key { color: #475569; } body.light .p-val { color: #0f172a; } + body.light .p-copy { color: #64748b; } + body.light .p-copy:hover { color: #0f172a; background: rgba(29, 78, 216, .10); } + body.light .p-copy.ok { color: #15803d; } + body.light .uuid-copy { color: #64748b; } + body.light .uuid-copy:hover { color: #0f172a; background: rgba(29, 78, 216, .10); } + body.light .uuid-copy.ok { color: #15803d; } body.light .params-section h4 { color: #64748b; } body.light .dep-item { background: #ffffff; border-color: #cbd5e1; } body.light .dep-name { color: #1d4ed8; } body.light .dep-meta { color: #64748b; } + body.light .delete-note { border-color: #cbd5e1; color: #334155; background: #f8fafc; } + body.light .delete-note.ok { border-color: #86efac; background: #f0fdf4; color: #166534; } + body.light .delete-note.wait { border-color: #cbd5e1; background: #f8fafc; color: #475569; } body.light .deps-section h4 { color: #64748b; } body.light .hist-table th { color: #64748b; border-bottom-color: #cbd5e1; } body.light .hist-table td { border-bottom-color: #e2e8f0; color: #0f172a; } + body.light .hist-table tbody tr:nth-child(odd) { background: rgba(148, 163, 184, .10); } + body.light .hist-table tbody tr:nth-child(even) { background: rgba(148, 163, 184, .16); } body.light .nav-back { background: #e2e8f0; border-bottom-color: #cbd5e1; color: #334155; font-weight: 700; } body.light .nav-back a { color: #0f172a; font-weight: 700; } body.light .nav-back a:hover { color: #000; } @media (max-width: 700px) { - td:nth-child(5), th:nth-child(5), td:nth-child(4), th:nth-child(4) { display: none; } + td:nth-child(6), th:nth-child(6), td:nth-child(7), th:nth-child(7) { display: none; } .params-grid, .deps-grid { grid-template-columns: 1fr; } .user-email { display: none; } } @@ -206,8 +271,8 @@
+ -
@@ -223,6 +288,7 @@ Имя + UUID Сервис Статус Платформа @@ -231,23 +297,12 @@ - Загрузка... + Загрузка...
-
-
-
-
Общий граф зависимостей: running
- -
-
-
-
-
-