v9.43: pre-graph-cleanup baseline (zebra, uuid copy, theme fix, delete policy)
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
- состояние: стабильно, критичных открытых багов не зафиксировано.
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
+377
-94
@@ -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 @@
|
||||
<label class="filter-label" style="margin-left:12px;border-left:1px solid #555;padding-left:12px"><input type="checkbox" id="chk-auxiliary"> <span style="font-size:12px;color:#8b949e">Служебные</span></label>
|
||||
</div>
|
||||
<div class="spacer"></div>
|
||||
<span class="data-ts" id="data-ts"></span>
|
||||
<button class="btn-refresh" id="btn-refresh">↻ Обновить</button>
|
||||
<button class="btn-graph" id="btn-running-graph">📊 Граф зависимостей</button>
|
||||
<button class="btn-theme" id="btn-theme" title="Сменить тему">🌙</button>
|
||||
<div class="header-user">
|
||||
<span class="user-email" id="user-email"></span>
|
||||
@@ -223,6 +288,7 @@
|
||||
<tr>
|
||||
<th class="col-arrow"></th>
|
||||
<th data-col="displayName">Имя <span class="arrow">↕</span></th>
|
||||
<th data-col="instanceUid">UUID <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>
|
||||
@@ -231,23 +297,12 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody">
|
||||
<tr><td colspan="7" class="loading">Загрузка...</td></tr>
|
||||
<tr><td colspan="8" 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 src="https://cdn.jsdelivr.net/npm/panzoom@9.4.3/dist/panzoom.min.js"></script>
|
||||
<script>
|
||||
@@ -271,6 +326,51 @@ var graphFetchInFlight = false;
|
||||
var rawDetail = {};
|
||||
var navBackUid = null;
|
||||
var renderTimer = null;
|
||||
// Table constants are centralized for quick UI reconfiguration.
|
||||
var TABLE_COLSPAN = 8;
|
||||
var TABLE_STATE = {
|
||||
isLoading: false,
|
||||
loaded: 0,
|
||||
total: 0,
|
||||
lastDurationMs: 0
|
||||
};
|
||||
var SNAPSHOT_CFG = {
|
||||
version: 2,
|
||||
keyPrefix: "dashboard_snapshot_v2",
|
||||
schema: "instance-stream-v2",
|
||||
maxChars: 4_500_000
|
||||
};
|
||||
|
||||
// Dashboard behavior and text templates.
|
||||
var DASHBOARD_CFG = {
|
||||
deletePolicy: {
|
||||
status: "suspended",
|
||||
minDays: 14,
|
||||
opNamePatterns: ["suspend", "приост", "замороз"],
|
||||
opTimeFields: ["dtFinish", "dtStart", "dtCreated", "dtState"],
|
||||
fallbackTimeFields: ["dtState", "instanceConfigDtUpdated"]
|
||||
},
|
||||
text: {
|
||||
deleteHintOnlySuspended: "Удаляемость доступна только для suspended > {days} дней",
|
||||
deleteReadyTitle: "Можно удалять",
|
||||
deleteWaitTitle: "Пока рано удалять",
|
||||
detailNoData: "Детали не загружены. Нажмите «Обновить», чтобы перечитать все данные из API.",
|
||||
detailDeleteReady: "Инстанс находится в suspended уже {days} дн. Достаточно старый для удаления.",
|
||||
detailDeleteWait: "Инстанс находится в suspended {days} дн. Для удаления нужно {minDays}+ дней.",
|
||||
tableNoData: "Нет данных",
|
||||
tableLoading: "Загрузка данных...",
|
||||
loadingProgress: "Загрузка: {loaded}/{total}",
|
||||
freshAt: "Актуально на {time}",
|
||||
freshAtWithDuration: "Актуально на {time} (загрузка {seconds} c)",
|
||||
freshFromSnapshot: "Актуально на {time} (из локального snapshot)"
|
||||
}
|
||||
};
|
||||
|
||||
function fmtText(tpl, vars){
|
||||
var out = String(tpl || "");
|
||||
Object.keys(vars || {}).forEach(function(k){ out = out.replaceAll("{" + k + "}", String(vars[k])); });
|
||||
return out;
|
||||
}
|
||||
|
||||
function escHtml(v){
|
||||
return String(v == null ? "" : v)
|
||||
@@ -678,6 +778,68 @@ function decodeEmail(token){
|
||||
} catch(e){ return ""; }
|
||||
}
|
||||
|
||||
function snapshotStorageKey(){
|
||||
var env = getDeckEnv();
|
||||
var user = decodeEmail(getToken()) || "anonymous";
|
||||
return SNAPSHOT_CFG.keyPrefix + ":" + env + ":" + user;
|
||||
}
|
||||
|
||||
// Keep one lightweight local snapshot per user+env for offline-like table work.
|
||||
function saveSnapshot(){
|
||||
try {
|
||||
var payload = {
|
||||
version: SNAPSHOT_CFG.version,
|
||||
schema: SNAPSHOT_CFG.schema,
|
||||
savedAt: new Date().toISOString(),
|
||||
durationMs: TABLE_STATE.lastDurationMs || 0,
|
||||
instances: allInstances,
|
||||
details: rawDetail
|
||||
};
|
||||
var raw = JSON.stringify(payload);
|
||||
if(raw.length > SNAPSHOT_CFG.maxChars) return;
|
||||
localStorage.setItem(snapshotStorageKey(), raw);
|
||||
} catch(e){}
|
||||
}
|
||||
|
||||
function clearSnapshot(){
|
||||
try { localStorage.removeItem(snapshotStorageKey()); } catch(e){}
|
||||
}
|
||||
|
||||
function restoreSnapshot(){
|
||||
try {
|
||||
var raw = localStorage.getItem(snapshotStorageKey());
|
||||
if(!raw) return false;
|
||||
var snap = JSON.parse(raw);
|
||||
if(!snap || snap.version !== SNAPSHOT_CFG.version || snap.schema !== SNAPSHOT_CFG.schema) return false;
|
||||
if(!Array.isArray(snap.instances) || typeof snap.details !== "object" || snap.details === null) return false;
|
||||
|
||||
allInstances = snap.instances;
|
||||
rawDetail = snap.details;
|
||||
cache = {};
|
||||
expandedUid = null;
|
||||
navBackUid = null;
|
||||
TABLE_STATE.lastDurationMs = Number(snap.durationMs || 0);
|
||||
|
||||
var nb = document.getElementById("nav-back");
|
||||
if(nb) nb.style.display = "none";
|
||||
|
||||
renderStats(allInstances);
|
||||
render(filteredInstances());
|
||||
|
||||
var ts = document.getElementById("data-ts");
|
||||
if(ts){
|
||||
var d = new Date(snap.savedAt || Date.now());
|
||||
var t = isNaN(d.getTime())
|
||||
? new Date().toLocaleTimeString("ru-RU", {hour:"2-digit", minute:"2-digit", second:"2-digit"})
|
||||
: d.toLocaleTimeString("ru-RU", {hour:"2-digit", minute:"2-digit", second:"2-digit"});
|
||||
ts.textContent = fmtText(DASHBOARD_CFG.text.freshFromSnapshot, {time: t});
|
||||
}
|
||||
return true;
|
||||
} catch(e){
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function apiHeaders(){
|
||||
return {
|
||||
"X-Deck-Token": getToken(),
|
||||
@@ -693,6 +855,7 @@ function showLoginErr(msg){
|
||||
}
|
||||
|
||||
function doLogout(){
|
||||
clearSnapshot();
|
||||
localStorage.removeItem("deck_token");
|
||||
location.reload();
|
||||
}
|
||||
@@ -712,6 +875,31 @@ function fmtDt(s){
|
||||
catch(e){ return s; }
|
||||
}
|
||||
|
||||
// Parse timestamp safely: invalid or empty values are treated as missing.
|
||||
function parseTs(v){
|
||||
if(!v) return 0;
|
||||
var t = Date.parse(v);
|
||||
return isNaN(t) ? 0 : t;
|
||||
}
|
||||
|
||||
// Prefer backend-computed metadata to keep policy logic centralized server-side.
|
||||
function applyBackendMeta(row, meta){
|
||||
row._suspendDays = 0;
|
||||
row._canDelete = false;
|
||||
row._suspendEnteredTs = 0;
|
||||
if(!meta || !meta.deleteCandidate) return false;
|
||||
var m = meta.deleteCandidate;
|
||||
row._suspendDays = Number(m.daysInSuspend || 0);
|
||||
row._canDelete = Boolean(m.eligible);
|
||||
row._suspendEnteredTs = parseTs(m.suspendEnteredAt || "");
|
||||
return true;
|
||||
}
|
||||
|
||||
function changedIndicatorHtml(inst){
|
||||
if(!inst || !inst._canDelete) return "";
|
||||
return " <span class=\"del-dot ok\" title=\"" + escHtml(DASHBOARD_CFG.text.deleteReadyTitle) + "\"></span>";
|
||||
}
|
||||
|
||||
function renderStats(list){
|
||||
var c = {};
|
||||
list.forEach(function(i){ var s = i.explainedStatus || "unknown"; c[s] = (c[s]||0)+1; });
|
||||
@@ -732,27 +920,45 @@ function sorted(arr){
|
||||
});
|
||||
}
|
||||
|
||||
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){
|
||||
// Split table rendering into small units to avoid a monolithic render function.
|
||||
function renderDataRow(inst, expanded, rowIdx){
|
||||
var uid = inst.instanceUid;
|
||||
var exp = expandedUid === uid;
|
||||
html += "<tr class=\"data-row" + (exp ? " expanded" : "") + "\" data-uid=\"" + uid + "\">" +
|
||||
var zebraCls = (rowIdx % 2 === 0) ? " row-base" : " row-alt";
|
||||
var uuid = inst.instanceUid || "\u2014";
|
||||
var uuidB64 = uuid && uuid !== "\u2014" ? toBase64Utf8(uuid) : "";
|
||||
var uuidCopy = uuidB64 ? "<button class=\"uuid-copy\" type=\"button\" title=\"Копировать UUID\" data-copy-b64=\"" + escHtml(uuidB64) + "\" onclick=\"event.stopPropagation();copyParamValue(this);return false;\">⧉</button>" : "";
|
||||
return "<tr class=\"data-row" + zebraCls + (expanded ? " expanded" : "") + "\" data-uid=\"" + uid + "\">" +
|
||||
"<td><span class=\"expand-icon\">►</span></td>" +
|
||||
"<td>" + (inst.displayName || "\u2014") + "</td>" +
|
||||
"<td class=\"inst-name\">" + escHtml(inst.displayName || "\u2014") + "</td>" +
|
||||
"<td class=\"col-uuid-cell\"><span class=\"uuid-wrap\"><span>" + escHtml(uuid) + "</span>" + uuidCopy + "</span></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>" +
|
||||
"<td class=\"changed-at-cell\">" + fmtDt(inst.dtState || inst.instanceConfigDtUpdated) + changedIndicatorHtml(inst) + "</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>") : "") +
|
||||
}
|
||||
|
||||
function renderDetailRow(uid, expanded){
|
||||
return "<tr class=\"detail-row" + (expanded ? " open" : "") + "\" id=\"dr-" + uid + "\">" +
|
||||
"<td colspan=\"" + TABLE_COLSPAN + "\"><div class=\"detail-box\" id=\"dc-" + uid + "\">" +
|
||||
(expanded ? (cache[uid] || "<div class=\"loading\">Загрузка...</div>") : "") +
|
||||
"</div></td></tr>";
|
||||
}
|
||||
|
||||
function render(list){
|
||||
var tbody = document.getElementById("tbody");
|
||||
var s = sorted(list);
|
||||
if(!s.length){
|
||||
tbody.innerHTML = "<tr><td colspan=\"" + TABLE_COLSPAN + "\" class=\"loading\">" + DASHBOARD_CFG.text.tableNoData + "</td></tr>";
|
||||
return;
|
||||
}
|
||||
var html = "";
|
||||
s.forEach(function(inst, idx){
|
||||
var uid = inst.instanceUid;
|
||||
var exp = expandedUid === uid;
|
||||
html += renderDataRow(inst, exp, idx);
|
||||
html += renderDetailRow(uid, exp);
|
||||
});
|
||||
tbody.innerHTML = html;
|
||||
|
||||
@@ -776,6 +982,7 @@ function toggle(uid, row){
|
||||
row.classList.remove("expanded");
|
||||
return;
|
||||
}
|
||||
|
||||
if(expandedUid){
|
||||
var pd = document.getElementById("dr-" + expandedUid);
|
||||
if(pd) pd.classList.remove("open");
|
||||
@@ -790,22 +997,10 @@ function toggle(uid, row){
|
||||
cache[uid] = buildDetail(rawDetail[uid]);
|
||||
dc.innerHTML = cache[uid];
|
||||
bindTabs(dc);
|
||||
} else {
|
||||
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;
|
||||
rawDetail[uid] = d.instance;
|
||||
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 {
|
||||
cache[uid] = "<div class=\"empty-msg\">" + DASHBOARD_CFG.text.detailNoData + "</div>";
|
||||
dc.innerHTML = cache[uid];
|
||||
}
|
||||
} else {
|
||||
dc.innerHTML = cache[uid];
|
||||
bindTabs(dc);
|
||||
@@ -822,10 +1017,6 @@ function bindTabs(container){
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -833,6 +1024,7 @@ function bindTabs(container){
|
||||
function buildDetail(inst){
|
||||
if(!inst) return "<div class=\"err-msg\">Нет данных</div>";
|
||||
var uid = inst.instanceUid || "x";
|
||||
var rowMeta = allInstances.find(function(i){ return i.instanceUid === uid; }) || null;
|
||||
var params = (inst.state && inst.state.params) || {};
|
||||
var out = (inst.state && inst.state.out) || {};
|
||||
var ops = inst.operations || [];
|
||||
@@ -841,18 +1033,27 @@ function buildDetail(inst){
|
||||
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\">" +
|
||||
var h = "";
|
||||
if(rowMeta && rowMeta.explainedStatus === DASHBOARD_CFG.deletePolicy.status){
|
||||
if(rowMeta._canDelete){
|
||||
h += "<div class=\"delete-note ok\">" + escHtml(fmtText(DASHBOARD_CFG.text.detailDeleteReady, {days: rowMeta._suspendDays})) + "</div>";
|
||||
} else {
|
||||
h += "<div class=\"delete-note wait\">" + escHtml(fmtText(DASHBOARD_CFG.text.detailDeleteWait, {days: rowMeta._suspendDays, minDays: DASHBOARD_CFG.deletePolicy.minDays})) + "</div>";
|
||||
}
|
||||
}
|
||||
|
||||
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 panel-params 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>" +
|
||||
var inKeyCh = maxParamKeyLen(params, "");
|
||||
var outKeyCh = maxParamKeyLen(out, "");
|
||||
h += "<div class=\"panel panel-params active\" id=\"p-" + uid + "\"><div class=\"params-grid\">" +
|
||||
"<div class=\"params-section\" style=\"--pkey-maxch:" + inKeyCh + "\"><h4>Input (params)</h4>" + renderP(params) + "</div>" +
|
||||
"<div class=\"params-section\" style=\"--pkey-maxch:" + outKeyCh + "\"><h4>Output (out)</h4>" + renderP(out) + "</div>" +
|
||||
"</div></div>";
|
||||
|
||||
var opsHtml = "";
|
||||
@@ -900,18 +1101,14 @@ function buildDetail(inst){
|
||||
"<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>";
|
||||
if(!obj || typeof obj !== "object"){
|
||||
var rootVal = obj != null ? String(obj) : "\u2014";
|
||||
return "<div class=\"p-item\"><span class=\"p-key\"></span><span class=\"p-val\">" + escHtml(rootVal) + "</span>" + renderCopyBtnHtml(rootVal) + "</div>";
|
||||
}
|
||||
var r = "";
|
||||
var keys = Object.keys(obj);
|
||||
keys.forEach(function(k){
|
||||
@@ -921,12 +1118,70 @@ function renderP(obj, pfx){
|
||||
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>";
|
||||
r += "<div class=\"p-item\"><span class=\"p-key\">" + escHtml(key) + "</span><span class=\"p-val\">" + escHtml(val) + "</span>" + renderCopyBtnHtml(val) + "</div>";
|
||||
}
|
||||
});
|
||||
return r || "<div class=\"empty-msg\">Пусто</div>";
|
||||
}
|
||||
|
||||
function toBase64Utf8(s){
|
||||
try { return btoa(unescape(encodeURIComponent(String(s == null ? "" : s)))); }
|
||||
catch(e){ return ""; }
|
||||
}
|
||||
|
||||
function fromBase64Utf8(s){
|
||||
try { return decodeURIComponent(escape(atob(String(s || "")))); }
|
||||
catch(e){ return ""; }
|
||||
}
|
||||
|
||||
function renderCopyBtnHtml(val){
|
||||
var raw = String(val == null ? "" : val);
|
||||
var normalized = raw.replace(/\s+/g, " ").trim();
|
||||
if(!normalized || normalized === "\u2014") return "<span></span>";
|
||||
var b64 = toBase64Utf8(val);
|
||||
return "<button class=\"p-copy\" type=\"button\" title=\"Копировать значение\" data-copy-b64=\"" + escHtml(b64) + "\" onclick=\"copyParamValue(this)\">⧉</button>";
|
||||
}
|
||||
|
||||
function copyParamValue(btn){
|
||||
if(!btn) return;
|
||||
var encoded = btn.getAttribute("data-copy-b64") || "";
|
||||
var text = fromBase64Utf8(encoded);
|
||||
var done = function(){
|
||||
var prev = btn.textContent;
|
||||
btn.textContent = "✓";
|
||||
btn.classList.add("ok");
|
||||
setTimeout(function(){ btn.textContent = prev; btn.classList.remove("ok"); }, 900);
|
||||
};
|
||||
if(navigator.clipboard && navigator.clipboard.writeText){
|
||||
navigator.clipboard.writeText(text).then(done).catch(function(){});
|
||||
return;
|
||||
}
|
||||
var ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.setAttribute("readonly", "readonly");
|
||||
ta.style.position = "fixed";
|
||||
ta.style.left = "-9999px";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand("copy"); done(); } catch(e) {}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
function maxParamKeyLen(obj, pfx){
|
||||
if(!obj || typeof obj !== "object") return 1;
|
||||
var maxLen = 1;
|
||||
Object.keys(obj).forEach(function(k){
|
||||
var v = obj[k];
|
||||
var key = pfx ? pfx + "." + k : k;
|
||||
if(key.length > maxLen) maxLen = key.length;
|
||||
if(v && typeof v === "object" && !Array.isArray(v)){
|
||||
var nested = maxParamKeyLen(v, key);
|
||||
if(nested > maxLen) maxLen = nested;
|
||||
}
|
||||
});
|
||||
return maxLen;
|
||||
}
|
||||
|
||||
function filteredInstances(){
|
||||
var checked = Array.from(document.querySelectorAll("#filters input[value]:checked")).map(function(i){ return i.value; });
|
||||
var auxEl = document.getElementById("chk-auxiliary");
|
||||
@@ -943,11 +1198,32 @@ function filteredInstances(){
|
||||
|
||||
function updateTimestamp(){
|
||||
var ts = document.getElementById("data-ts");
|
||||
if(ts) ts.textContent = "Актуально на " + new Date().toLocaleTimeString("ru-RU",{hour:"2-digit",minute:"2-digit",second:"2-digit"});
|
||||
if(!ts) return;
|
||||
var nowStr = new Date().toLocaleTimeString("ru-RU", {hour:"2-digit", minute:"2-digit", second:"2-digit"});
|
||||
ts.textContent = fmtText(DASHBOARD_CFG.text.freshAt, {time: nowStr});
|
||||
}
|
||||
|
||||
function showLoadingProgress(loaded, total){
|
||||
var ts = document.getElementById("data-ts");
|
||||
if(!ts) return;
|
||||
var t = total || 0;
|
||||
TABLE_STATE.loaded = loaded;
|
||||
TABLE_STATE.total = t;
|
||||
ts.textContent = fmtText(DASHBOARD_CFG.text.loadingProgress, {loaded: loaded, total: t});
|
||||
}
|
||||
|
||||
function updateTimestampWithDuration(durationMs){
|
||||
var ts = document.getElementById("data-ts");
|
||||
if(!ts) return;
|
||||
TABLE_STATE.lastDurationMs = durationMs || 0;
|
||||
var sec = Math.max(0, Math.round(TABLE_STATE.lastDurationMs / 1000));
|
||||
var nowStr = new Date().toLocaleTimeString("ru-RU", {hour:"2-digit", minute:"2-digit", second:"2-digit"});
|
||||
ts.textContent = fmtText(DASHBOARD_CFG.text.freshAtWithDuration, {time: nowStr, seconds: sec});
|
||||
}
|
||||
|
||||
function scheduleRender(){
|
||||
if(renderTimer) clearTimeout(renderTimer);
|
||||
// Throttle UI updates during stream without postponing them until the end.
|
||||
if(renderTimer) return;
|
||||
renderTimer = setTimeout(function(){
|
||||
renderTimer = null;
|
||||
renderStats(allInstances);
|
||||
@@ -955,7 +1231,8 @@ function scheduleRender(){
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function loadAllDetails(){
|
||||
// Full refresh mode: read all data from API, then work from memory until next refresh.
|
||||
function loadAllDetails(){
|
||||
allInstances = [];
|
||||
cache = {};
|
||||
rawDetail = {};
|
||||
@@ -968,8 +1245,8 @@ function loadAllDetails(){
|
||||
var nb = document.getElementById("nav-back");
|
||||
if(nb) nb.style.display = "none";
|
||||
var ts = document.getElementById("data-ts");
|
||||
if(ts) ts.textContent = "";
|
||||
document.getElementById("tbody").innerHTML = "<tr><td colspan=\"7\" class=\"loading\">Загрузка данных...</td></tr>";
|
||||
if(ts) ts.textContent = "Загрузка: 0/0";
|
||||
document.getElementById("tbody").innerHTML = "<tr><td colspan=\"" + TABLE_COLSPAN + "\" class=\"loading\">" + DASHBOARD_CFG.text.tableLoading + "</td></tr>";
|
||||
|
||||
fetch(API + "/instances/stream", { headers: apiHeaders() })
|
||||
.then(function(r){
|
||||
@@ -987,16 +1264,27 @@ function loadAllDetails(){
|
||||
var obj;
|
||||
try { obj = JSON.parse(line); } catch(e){ return; }
|
||||
if(obj._done){
|
||||
updateTimestamp();
|
||||
updateTimestampWithDuration(obj.durationMs || 0);
|
||||
saveSnapshot();
|
||||
renderStats(allInstances);
|
||||
render(filteredInstances());
|
||||
return;
|
||||
}
|
||||
if(obj._progress){
|
||||
showLoadingProgress(obj._progress.loaded || 0, obj._progress.total || 0);
|
||||
delete obj._progress;
|
||||
}
|
||||
var uid = obj.instanceUid;
|
||||
if(!uid) return;
|
||||
var backendMeta = obj._meta;
|
||||
if(backendMeta !== undefined) delete obj._meta;
|
||||
var detail = obj._detail;
|
||||
if(detail !== undefined) delete obj._detail;
|
||||
if(detail) rawDetail[uid] = detail;
|
||||
|
||||
applyBackendMeta(obj, backendMeta);
|
||||
if(detail){
|
||||
rawDetail[uid] = detail;
|
||||
}
|
||||
allInstances.push(obj);
|
||||
scheduleRender();
|
||||
}
|
||||
@@ -1005,7 +1293,7 @@ function loadAllDetails(){
|
||||
return reader.read().then(function(result){
|
||||
if(result.done){
|
||||
if(buffer.trim()) processLine(buffer.trim());
|
||||
updateTimestamp();
|
||||
updateTimestamp();
|
||||
renderStats(allInstances);
|
||||
render(filteredInstances());
|
||||
return;
|
||||
@@ -1020,7 +1308,7 @@ function loadAllDetails(){
|
||||
return pump();
|
||||
})
|
||||
.catch(function(e){
|
||||
document.getElementById("tbody").innerHTML = "<tr><td colspan=\"7\" class=\"err-msg\">" + escHtml(e.message) + "</td></tr>";
|
||||
document.getElementById("tbody").innerHTML = "<tr><td colspan=\"" + TABLE_COLSPAN + "\" class=\"err-msg\">" + escHtml(e.message) + "</td></tr>";
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1063,8 +1351,8 @@ function applyTheme(theme){
|
||||
document.body.classList.toggle("light", isLight);
|
||||
var btn = document.getElementById("btn-theme");
|
||||
if(btn){
|
||||
btn.textContent = isLight ? "☀" : "🌙";
|
||||
btn.title = isLight ? "Тема: день" : "Тема: ночь";
|
||||
btn.textContent = isLight ? "🌙" : "☀";
|
||||
btn.title = isLight ? "Переключить на ночную тему" : "Переключить на дневную тему";
|
||||
}
|
||||
localStorage.setItem("dashboard_theme", isLight ? "light" : "dark");
|
||||
}
|
||||
@@ -1117,21 +1405,15 @@ function initApp(){
|
||||
document.getElementById("chk-auxiliary").addEventListener("change", function(){
|
||||
cache = {};
|
||||
render(filteredInstances());
|
||||
if(graphRunningData){ drawGraph("running-graph", graphRunningData, ""); }
|
||||
});
|
||||
loadAllDetails();
|
||||
if(!restoreSnapshot()) loadAllDetails();
|
||||
}
|
||||
|
||||
document.getElementById("btn-login").addEventListener("click", doLogin);
|
||||
document.getElementById("btn-refresh").addEventListener("click", loadAllDetails);
|
||||
document.getElementById("btn-running-graph").addEventListener("click", openRunningGraph);
|
||||
document.getElementById("btn-theme").addEventListener("click", toggleTheme);
|
||||
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");
|
||||
@@ -1142,6 +1424,7 @@ if(getToken()) initApp();
|
||||
// Expose functions called from inline onclick attributes
|
||||
window.navigateToDep = navigateToDep;
|
||||
window.navBack = navBack;
|
||||
window.copyParamValue = copyParamValue;
|
||||
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user