Compare commits

..
10 Commits
Author SHA1 Message Date
Andrey 5dbc062b1e 🔖 release: 1.7.4
Автоматическое создание категорий при импорте знаний и исправление добавления меток диалога.
2026-09-14 22:37:02 +03:00
Andrey ee8367e205 🐛 fix: создавать категории при импорте и восстановить добавление меток
Создавать недостающие уровни categoryPath в транзакции документа и повторно использовать существующие категории. Разрешить импорт новых путей в предпросмотре, обновить переводы и baseline.

Исправить конфликт ширины кнопки создания метки со стилями меню. Выделить метки, заметку и план импорта в отдельные компоненты. Проверено: 13 backend-тестов, 7 frontend-тестов, сборка и браузерный предпросмотр файла из 66 документов.
2026-09-14 22:36:35 +03:00
Andrey 839a1fe815 🔖 release: 1.7.3 2026-09-14 17:34:03 +03:00
Andrey c62cb903ab 🐛 fix(gateway): подключать дополнительные сайты из постоянного тома 2026-09-14 17:34:03 +03:00
AndreyandClaude Fable 5.1 0bd76042d2 🔖 release: 1.7.2
Логотип организации в сайдбаре без обводки, крупные загрузчики системы и
портала, загрузчик портала сразу по центру, подпись «Работает на Chatballs»
в футере порталов.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 07:32:00 +03:00
AndreyandClaude Fable 5.1 c286ba7903 💄 fix(ui): логотип без обводки, крупные загрузчики, подпись «Работает на Chatballs»
Логотип организации в сайдбаре рисовался с рамкой и подложкой — снято,
знак стоит на прозрачном фоне.

Загрузчик портала до прихода манифеста и темы появлялся в левом верхнем
углу и только потом прыгал в центр: его стиль лежал в CSS портала, а этот
чанк грузится лениво, и первый кадр шёл без стилей. Стиль перенесён в
общий state.css, слой фиксирован на весь экран и центрирован независимо
от отступов документа. Загрузчик системы на экране входа в сессию и
загрузчик портала увеличены.

В футере всех шаблонов портала — подпись «Работает на Chatballs» со
ссылкой на chatballs.ru.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 07:31:50 +03:00
AndreyandClaude Opus 5 234a3e43da 📝 fix(i18n): убрать упоминания лимитов из текстов интерфейса
Тарифные лимиты сняты (ADR-CHATBALLS-0042), дневного бюджета в коде нет,
но два текста продолжали о них говорить.

Уведомление AI_STOPPED шлётся только при ошибке провайдера — подпись
«или лимитом» вводила в заблуждение. Подсказка первого шага онбординга
обещала учёт лимитов, которого не существует: расходы считает провайдер
по своему тарифу.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 06:34:03 +03:00
AndreyandClaude Opus 5 17b0c9387f 🔖 release: 1.7.1
Ход обновления виден в интерфейсе: шаги установки, время, итог с кнопкой
перезагрузки. Перезагрузка страницы во время установки больше не теряет ход.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 03:12:26 +03:00
AndreyandClaude Opus 5 27091c0234 ✨ feat(updates): ход обновления виден в интерфейсе
Установка перезапускает сам бэкенд, который о ней и рассказывает, — и до сих
пор человек об этом не узнавал: признак «идёт установка» жил в useRef и
исчезал при перезагрузке страницы, прогресса не было вовсе (одна строка
текста), а готовый результат карточка «Обновления» не показывала совсем.
Запустив обновление, человек оставался перед экраном без единого признака
того, что что-то происходит.

Теперь ход установки — отдельный блок: шаги (запуск, файл релиза, образы,
перезапуск) с отметками пройденного, текущий шаг с пульсом, номер шага и
время с начала. Молчание бэкенда — это и есть перезапуск, так он и подписан.
Метка установки живёт в sessionStorage, поэтому перезагрузка страницы
(в том числе во время лежащих сервисов) ход не теряет, а время считается от
момента запроса, а не от перезагрузки. Когда версия встала, карточка и полоса
показывают итог с кнопкой «Обновить страницу» — и молчат, если страница уже
открыта в новой версии. Прогресс появляется по нажатию кнопки, а не по ответу
сервера. Полоса наверху молчит, пока карточка «Обновления» на экране.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 03:12:17 +03:00
AndreyandClaude Opus 5 3c01486aa3 🐛 fix(presence): isinstance через union — на этом падал ruff в CI
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 02:47:09 +03:00
37 changed files with 1030 additions and 310 deletions

No files matched your search

+3
View File
@@ -51,6 +51,9 @@
import surfaces
}
# Дополнительные сайты из постоянного тома сохраняются при обновлении шлюза.
import /config/sites/*.caddy
# Любой хост, которому ask-эндпоинт разрешил сертификат.
https:// {
encode gzip zstd
+1 -1
View File
@@ -1 +1 @@
1.7.0
1.7.4
@@ -66,14 +66,11 @@ def _category_for_path(*, context: TenantContext, raw_path: object) -> Knowledge
for raw_name in raw_path:
if not isinstance(raw_name, str) or not raw_name.strip():
raise ValidationError({"categoryPath": t("ai.category_names_strings")})
try:
category = KnowledgeCategory.objects.get(
organization_id=context.organization_id,
parent_id=parent_id,
name=raw_name.strip(),
)
except KnowledgeCategory.DoesNotExist as error:
raise ValidationError({"categoryPath": t("ai.category_path_not_found")}) from error
category, _ = KnowledgeCategory.objects.get_or_create(
organization_id=context.organization_id,
parent_id=parent_id,
name=raw_name.strip(),
)
parent_id = category.id
assert category is not None
return category
@@ -65,7 +65,7 @@ class KnowledgeMetadataImportTests(TestCase):
knowledge = Knowledge.objects.get(title="Acme support")
self.assertEqual(knowledge.category_id, self.app.id)
def test_unknown_category_path_fails_per_document(self) -> None:
def test_missing_category_is_created_under_existing_parent(self) -> None:
response = self._import(
[
{
@@ -78,15 +78,52 @@ class KnowledgeMetadataImportTests(TestCase):
)
payload = response.json()
self.assertEqual(payload["created"], 1)
self.assertEqual(len(payload["failed"]), 1)
self.assertFalse(Knowledge.objects.filter(title="Unknown path").exists())
self.assertFalse(
KnowledgeCategory.objects.filter(
organization=self.organization,
name="Missing",
).exists()
self.assertEqual(payload["created"], 2)
self.assertEqual(payload["failed"], [])
category = KnowledgeCategory.objects.get(
organization=self.organization, parent=self.products, name="Missing"
)
self.assertEqual(Knowledge.objects.get(title="Unknown path").category_id, category.id)
def test_new_tree_is_shared_and_repeat_import_does_not_duplicate_categories(self) -> None:
documents = [
{"title": title, "content": "Text", "categoryPath": [" New root ", "Child", "Leaf"]}
for title in ["First", "Second"]
]
before = KnowledgeCategory.objects.count()
self.assertEqual(self._import(documents).json()["created"], 2)
self.assertEqual(self._import(documents).json()["unchanged"], 2)
self.assertEqual(KnowledgeCategory.objects.count(), before + 3)
first = Knowledge.objects.get(title="First")
second = Knowledge.objects.get(title="Second")
self.assertEqual(first.category_id, second.category_id)
self.assertEqual(first.category.parent.parent.name, "New root")
def test_failed_document_rolls_back_new_categories_and_other_documents_import(self) -> None:
response = self._import([
{"title": "Invalid", "content": "Text", "description": [],
"categoryPath": ["Rollback root", "Child"]},
{"title": "Valid sibling", "content": "Text", "categoryPath": ["Kept root"]},
])
self.assertEqual(response.json()["created"], 1)
self.assertEqual(len(response.json()["failed"]), 1)
self.assertFalse(KnowledgeCategory.objects.filter(name="Rollback root").exists())
self.assertFalse(Knowledge.objects.filter(title="Invalid").exists())
def test_invalid_path_rolls_back_preceding_levels(self) -> None:
for path in [["Invalid root", " "], ["Invalid root", 42], []]:
with self.subTest(path=path):
response = self._import([{"title": "Invalid path", "content": "Text", "categoryPath": path}])
self.assertEqual(len(response.json()["failed"]), 1)
self.assertFalse(KnowledgeCategory.objects.filter(name="Invalid root").exists())
def test_existing_document_moves_to_new_category(self) -> None:
self._import([{"title": "Moving", "content": "Text"}])
response = self._import([
{"title": "Moving", "content": "Text", "categoryPath": ["New destination"]}
])
self.assertEqual(response.json()["updated"], 1)
self.assertEqual(Knowledge.objects.get(title="Moving").category.name, "New destination")
def test_omitted_metadata_preserves_category_and_agent_links(self) -> None:
self._import(
@@ -157,6 +194,7 @@ class KnowledgeImportPolicyTests(KnowledgePolicyTestBase):
{
"title": self.support_only.title,
"content": "Attempted overwrite",
"categoryPath": ["Forbidden category"],
}
]
}
@@ -167,3 +205,4 @@ class KnowledgeImportPolicyTests(KnowledgePolicyTestBase):
self.assertEqual(response.status_code, 403)
self.support_only.refresh_from_db()
self.assertEqual(self.support_only.content, "")
self.assertFalse(KnowledgeCategory.objects.filter(name="Forbidden category").exists())
+1 -1
View File
@@ -498,7 +498,7 @@ MESSAGES: dict[str, object] = {
"notifications.type_dialog_new_message": "A new message in my conversation",
"notifications.type_dialog_assigned": "A conversation was assigned to me",
"notifications.type_dialog_waiting_long": "A conversation has been waiting a long time",
"notifications.type_ai_stopped": "AI stopped by an error or a limit",
"notifications.type_ai_stopped": "AI stopped by an error",
"integrations.check_no_connection": "No connection: {error}",
"integrations.check_api_key_missing": "The API key is not set",
"integrations.check_base_url_missing": "Base URL is not set",
+1 -1
View File
@@ -502,7 +502,7 @@ MESSAGES: dict[str, object] = {
"notifications.type_dialog_new_message": "Новое сообщение в моём диалоге",
"notifications.type_dialog_assigned": "Диалог назначили на меня",
"notifications.type_dialog_waiting_long": "Диалог долго ждёт человека",
"notifications.type_ai_stopped": "AI остановлен ошибкой или лимитом",
"notifications.type_ai_stopped": "AI остановлен ошибкой",
"integrations.check_no_connection": "Нет связи: {error}",
"integrations.check_api_key_missing": "Не указан API-ключ",
"integrations.check_base_url_missing": "Не указан Base URL",
+1 -1
View File
@@ -53,7 +53,7 @@ def last_seen(organization_id: int, user_ids: Iterable[int]) -> dict[int, dateti
return {
keys[key]: datetime.fromtimestamp(value, tz=timezone.get_current_timezone())
for key, value in found.items()
if isinstance(value, (int, float))
if isinstance(value, int | float)
}
@@ -16,79 +16,8 @@ import {
} from "./model";
import { t, tn } from "../../../i18n";
import { readableSize } from "../../../shared/utils";
// Импорт YAML (дизайн-базлайн v2, кадр KB8): файл разбирается до применения —
// видно, что создастся, что обновится и где ошибка пути. Импорт не создаёт
// категории неявно, поэтому несуществующий categoryPath отклоняется отдельно.
type ImportAction = "create" | "update" | "error";
type ImportRow = {
document: ParsedKnowledgeYaml["documents"][number];
action: ImportAction;
path: string;
note: string;
};
const ACTION_LABEL: Record<ImportAction, string> = {
create: t("ai.create"),
update: t("ai.update"),
error: t("ai.error"),
};
/** Разрешает путь категории в дерево организации: путь ищется по уровням. */
function resolvePath(categories: KnowledgeCategory[], path: string[]): { ok: boolean; missing: string } {
let parentId: number | null = null;
for (const name of path) {
const found: KnowledgeCategory | undefined = categories.find(
(category) => category.parentId === parentId && category.name === name,
);
if (!found) return { ok: false, missing: name };
parentId = found.id;
}
return { ok: true, missing: "" };
}
function planRows(
parsed: ParsedKnowledgeYaml,
categories: KnowledgeCategory[],
items: KnowledgeItem[],
): ImportRow[] {
const byTitle = new Map(items.map((item) => [item.title, item]));
return parsed.documents.map((document) => {
const existing = byTitle.get(document.title) ?? null;
if (document.categoryPath) {
const { ok, missing } = resolvePath(categories, document.categoryPath);
if (!ok) {
return {
document,
action: "error",
path: document.categoryPath.join(" / "),
note: t("ai.missing_category_path", { name: missing }),
};
}
}
const path = document.categoryPath ? document.categoryPath.join(" / ") : t("ai.not_given");
if (!existing) {
return {
document,
action: "create",
path,
note: document.categoryPath ? "" : t("ai.will_land_no_category"),
};
}
const samePlace = document.categoryPath
&& document.categoryPath[document.categoryPath.length - 1] === existing.category.name;
return {
document,
action: "update",
path,
note: samePlace
? t("ai.category_file_matches_current_one")
: t("ai.title_matched_text_will_replaced"),
};
});
}
import { planRows } from "./knowledgeImportPlan";
import { KnowledgeImportPreview } from "./KnowledgeImportPreview";
export function KnowledgeImportPage({
canManage,
@@ -218,44 +147,11 @@ export function KnowledgeImportPage({
</div>
)}
{parsed && (
<div className="knowledge-import-plan">
<div className="knowledge-import-plan-head">
<strong>{t("ai.what_will_happen")}</strong>
<span>
{counts.create > 0 && <small className="is-create">{t("ai.count_create", { count: counts.create })}</small>}
{counts.update > 0 && <small className="is-update">{t("ai.count_update", { count: counts.update })}</small>}
{counts.error > 0 && <small className="is-error">{t("ai.count_error", { count: counts.error })}</small>}
</span>
</div>
<table className="knowledge-import-table">
<thead>
<tr>
<th>{t("ai.document")}</th>
<th>CATEGORYPATH</th>
<th>{t("ai.action")}</th>
<th>{t("ai.note")}</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr className={row.action === "error" ? "is-error" : ""} key={`${row.document.title}-${index}`}>
<td><code>{row.document.title}</code></td>
<td className="knowledge-import-path">{row.path}</td>
<td><span className={`knowledge-import-action is-${row.action}`}>{ACTION_LABEL[row.action]}</span></td>
<td className="knowledge-import-note">{row.note}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{parsed && <KnowledgeImportPreview rows={rows} counts={counts} />}
{parsed && (
<div className="knowledge-import-actions">
<span>{t("ai.import_never_creates_categories_implicitly")}<code>categoryPath</code>{" "}
{t("ai.import_path_tail")}
</span>
<span>{t("ai.import_category_policy")}</span>
<Button variant="secondary" disabled={busy} onClick={() => setRoute("knowledge")}>{t("common.cancel")}</Button>
<Button variant="primary" disabled={busy || !canManage || importable.length === 0} onClick={() => void submit()}>
{t("ai.import_documents", { count: tn("plural.documents", importable.length) })}
@@ -0,0 +1,40 @@
import { t } from "../../../i18n";
import { ACTION_LABEL, type ImportAction, type ImportRow } from "./knowledgeImportPlan";
export function KnowledgeImportPreview({ rows, counts }: {
rows: ImportRow[];
counts: Record<ImportAction, number>;
}) {
return (
<div className="knowledge-import-plan">
<div className="knowledge-import-plan-head">
<strong>{t("ai.what_will_happen")}</strong>
<span>
{counts.create > 0 && <small className="is-create">{t("ai.count_create", { count: counts.create })}</small>}
{counts.update > 0 && <small className="is-update">{t("ai.count_update", { count: counts.update })}</small>}
{counts.error > 0 && <small className="is-error">{t("ai.count_error", { count: counts.error })}</small>}
</span>
</div>
<table className="knowledge-import-table">
<thead>
<tr>
<th>{t("ai.document")}</th>
<th>CATEGORYPATH</th>
<th>{t("ai.action")}</th>
<th>{t("ai.note")}</th>
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr className={row.action === "error" ? "is-error" : ""} key={`${row.document.title}-${index}`}>
<td><code>{row.document.title}</code></td>
<td className="knowledge-import-path">{row.path}</td>
<td><span className={`knowledge-import-action is-${row.action}`}>{ACTION_LABEL[row.action]}</span></td>
<td className="knowledge-import-note">{row.note}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { t } from "../../../i18n";
import { planRows } from "./knowledgeImportPlan";
import { parseKnowledgeYaml } from "./parseKnowledgeYaml";
import type { KnowledgeCategory, KnowledgeItem } from "./types";
const categories: KnowledgeCategory[] = [
{ id: 1, name: "Root", parentId: null, sortOrder: 0, isSystem: false, knowledgeCount: 0 },
{ id: 2, name: "Child", parentId: 1, sortOrder: 0, isSystem: false, knowledgeCount: 0 },
{ id: 3, name: "Other", parentId: null, sortOrder: 0, isSystem: false, knowledgeCount: 0 },
{ id: 4, name: "Child", parentId: 3, sortOrder: 0, isSystem: false, knowledgeCount: 0 },
];
const existing: KnowledgeItem = {
id: 10, title: "Existing", description: "", category: categories[1],
isEnabled: true, attachments: [], agentsCount: 0, fragmentsCount: 0,
createdAt: "", updatedAt: "",
};
describe("knowledge import preview", () => {
it("allows an entirely new category tree from YAML", () => {
const parsed = parseKnowledgeYaml("documents:\n - title: New\n content: Text\n categoryPath: [New root, New child]");
const [row] = planRows(parsed, [], []);
expect(row.action).toBe("create");
expect(row.path).toBe("New root / New child");
expect(row.note).toBe(t("ai.import_category_creation"));
});
it("allows missing descendants and updates existing documents", () => {
const [row] = planRows({ documents: [
{ title: existing.title, content: "Text", categoryPath: ["Root", "New child"] },
] }, categories, [existing]);
expect(row.action).toBe("update");
expect(row.note).toBe(t("ai.import_category_creation"));
});
it("compares the full path instead of only the leaf name", () => {
const rows = planRows({ documents: [
{ title: existing.title, content: "Text", categoryPath: ["Other", "Child"] },
{ title: existing.title, content: "Text", categoryPath: ["Root", "Child"] },
] }, categories, [existing]);
expect(rows[0].note).toBe(t("ai.title_matched_text_will_replaced"));
expect(rows[1].note).toBe(t("ai.category_file_matches_current_one"));
});
it("keeps documents without a path importable", () => {
const [row] = planRows({ documents: [{ title: "New", content: "Text" }] }, [], []);
expect(row.action).toBe("create");
expect(row.note).toBe(t("ai.will_land_no_category"));
});
});
@@ -0,0 +1,62 @@
import { t } from "../../../i18n";
import type { KnowledgeCategory, KnowledgeItem } from "./model";
import type { ParsedKnowledgeYaml } from "./parseKnowledgeYaml";
export type ImportAction = "create" | "update" | "error";
export type ImportRow = {
document: ParsedKnowledgeYaml["documents"][number];
action: ImportAction;
path: string;
note: string;
};
export const ACTION_LABEL: Record<ImportAction, string> = {
create: t("ai.create"),
update: t("ai.update"),
error: t("ai.error"),
};
/** Разрешает путь категории в дерево организации: путь ищется по уровням. */
function resolvePath(categories: KnowledgeCategory[], path: string[]): number | null {
let parentId: number | null = null;
for (const name of path) {
const found: KnowledgeCategory | undefined = categories.find(
(category) => category.parentId === parentId && category.name === name,
);
if (!found) return null;
parentId = found.id;
}
return parentId;
}
export function planRows(
parsed: ParsedKnowledgeYaml,
categories: KnowledgeCategory[],
items: KnowledgeItem[],
): ImportRow[] {
const byTitle = new Map(items.map((item) => [item.title, item]));
return parsed.documents.map((document) => {
const existing = byTitle.get(document.title) ?? null;
const categoryId = document.categoryPath ? resolvePath(categories, document.categoryPath) : null;
const categoryNote = document.categoryPath && categoryId === null ? t("ai.import_category_creation") : "";
const path = document.categoryPath ? document.categoryPath.join(" / ") : t("ai.not_given");
if (!existing) {
return {
document,
action: "create",
path,
note: document.categoryPath ? categoryNote : t("ai.will_land_no_category"),
};
}
const samePlace = categoryId !== null && categoryId === existing.category.id;
return {
document,
action: "update",
path,
note: categoryNote || (samePlace
? t("ai.category_file_matches_current_one")
: t("ai.title_matched_text_will_replaced")),
};
});
}
@@ -8,8 +8,7 @@ export type ParsedKnowledgeYaml = {
// Парсит YAML импорта знаний (SPEC-CHATBALLS-0012, плоский формат ADR-CHATBALLS-0023):
// { documents: [{ title, description?, content, categoryPath? }] }. Бросает
// Error с человекочитаемым сообщением при невалидном YAML или структуре.
// Путь категории разбирается, но не проверяется: несуществующий путь — это
// замечание в предпросмотре импорта (кадр KB8), а не поломка файла.
// Недостающие уровни пути сервер создаёт при применении импорта.
export function parseKnowledgeYaml(text: string): ParsedKnowledgeYaml {
let data: unknown;
try {
@@ -2,8 +2,9 @@ import { Dropdown } from "antd";
import { useEffect, useState } from "react";
import { Icon } from "../../shared/icons";
import { SearchInput } from "../../shared/ui-controls";
import { assigneeMenuItems, initials, SmallAvatar } from "./assigneeOptions";
import { DialogLabels } from "./DialogLabels";
import { NoteSection } from "./DialogNoteSection";
import { assigneeMenuItems, SmallAvatar } from "./assigneeOptions";
import { WaitingBlock } from "./WaitingBlock";
import { PriorityBars } from "./DialogList";
import { useEmployeeDirectory } from "./useEmployeeDirectory";
@@ -11,17 +12,13 @@ import { statusFor } from "./data";
import {
agentColorOf,
controlModeOf,
createConversationLabel,
fetchConversationLabels,
groupColorOf,
setConversationArchived,
setConversationAssignee,
setConversationGroup,
setConversationLabels,
setConversationNote,
setConversationPriority,
type ApiConversation,
type ConversationLabelRef,
type ConversationPriority,
} from "./model";
import type { EmployeeGroupRef } from "../../types";
@@ -62,18 +59,11 @@ export function DialogControls({
const [busy, setBusy] = useState(false);
const [errorText, setErrorText] = useState("");
const [collapsed, setCollapsed] = useState(false);
const [labels, setLabels] = useState<ConversationLabelRef[]>([]);
const [newLabel, setNewLabel] = useState("");
useEffect(() => {
setNewLabel("");
setErrorText("");
}, [detail.id]);
useEffect(() => {
fetchConversationLabels().then(setLabels).catch(() => setLabels([]));
}, []);
async function run(action: () => Promise<ApiConversation>) {
setBusy(true);
setErrorText("");
@@ -86,30 +76,6 @@ export function DialogControls({
}
}
async function addLabel(id?: number) {
let labelId = id;
setBusy(true);
setErrorText("");
try {
if (labelId === undefined) {
const name = newLabel.trim();
if (!name) return;
const label = await createConversationLabel(name);
setLabels((current) => (current.some((item) => item.id === label.id) ? current : [...current, label]));
labelId = label.id;
setNewLabel("");
}
const ids = [...new Set([...detail.labels.map((item) => item.id), labelId])];
applyConversation(await setConversationLabels(detail.id, ids));
} catch (error) {
setErrorText(error instanceof Error ? error.message : t("conversations.could_not_add_label"));
} finally {
setBusy(false);
}
}
const assignedIds = new Set(detail.labels.map((item) => item.id));
const availableLabels = labels.filter((item) => !assignedIds.has(item.id));
const assignee = detail.assignedOperator;
const assigneeHere = assignee ? directory.employees.some((employee) => employee.id === assignee.id && employee.online) : false;
const assigneeLabel = assignee ? `${assignee.name}${viewerId != null && assignee.id === viewerId ? t("common.you_suffix") : ""}` : t("conversations.unassigned");
@@ -205,57 +171,7 @@ export function DialogControls({
</div>
</div>
<label className="ctx-label">{t("conversations.labels")}</label>
<div className="ctx-labels">
<Dropdown
disabled={busy}
trigger={["click"]}
overlayClassName="app-dropdown is-wide ctx-labels-menu"
menu={{
items: [
...availableLabels.map((label) => ({
key: label.id,
label: <button type="button" onClick={() => void addLabel(label.id)}><i className="ctx-dot is-square" style={{ background: label.color || "var(--n-5)" }} />{label.name}</button>,
})),
{
key: "new",
label: (
<div className="ctx-new-label" onClick={(event) => event.stopPropagation()}>
<input
placeholder={t("conversations.new_label")}
value={newLabel}
onChange={(event) => setNewLabel(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
void addLabel();
}
}}
/>
<button type="button" disabled={busy || !newLabel.trim()} onClick={() => void addLabel()}><Icon name="plus" size={13} /></button>
</div>
),
},
],
}}
>
<button type="button" className="ctx-add-label"><Icon name="plus" size={13} />{t("conversations.add")}</button>
</Dropdown>
{detail.labels.map((label) => (
<b className="ctx-label-chip" key={label.id}>
<i style={{ background: label.color || "var(--n-5)" }} />
{label.name}
<button
aria-label={t("conversations.remove_label", { name: label.name })}
disabled={busy}
type="button"
onClick={() => void run(() => setConversationLabels(detail.id, detail.labels.filter((item) => item.id !== label.id).map((item) => item.id)))}
>
×
</button>
</b>
))}
</div>
<DialogLabels detail={detail} busy={busy} setBusy={setBusy} setErrorText={setErrorText} applyConversation={applyConversation} run={run} />
<div className="ctx-meta-row">
<span>{detail.waitingSince ? t("conversations.in_queue_since") : t("conversations.started")}</span>
@@ -270,36 +186,6 @@ export function DialogControls({
);
}
function NoteSection({ detail, busy, onSave }: { detail: ApiConversation; busy: boolean; onSave: (note: string) => Promise<void> }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(detail.note);
useEffect(() => {
setEditing(false);
setDraft(detail.note);
}, [detail.id, detail.note]);
return (
<section className="ctx-section is-note">
<div className="ctx-section-head">
<h4>{t("conversations.note")}</h4>
<button type="button" aria-label={t("conversations.edit_note")} onClick={() => setEditing(true)}><Icon name="edit" size={14} /></button>
</div>
{editing ? (
<div className="ctx-note is-editing">
<textarea autoFocus disabled={busy} placeholder={t("conversations.internal_note_customer_does_not")} rows={3} value={draft} onChange={(event) => setDraft(event.target.value)} />
<div className="ctx-note-actions">
<button type="button" disabled={busy} onClick={() => { setDraft(detail.note); setEditing(false); }}>{t("common.cancel")}</button>
<button type="button" className="primary" disabled={busy} onClick={() => void onSave(draft).then(() => setEditing(false))}>{t("common.save")}</button>
</div>
</div>
) : (
<div className={`ctx-note ${detail.note ? "" : "is-empty"}`} onClick={() => setEditing(true)}>{detail.note || t("conversations.no_notes")}</div>
)}
</section>
);
}
// «сегодня, 17:02» · «вчера, 11:05» · «12 авг, 09:30»
export function startedLabel(iso: string): string {
const date = new Date(iso);
@@ -328,5 +214,3 @@ export function archiveConversationAction(
});
}
// Аватар 22px в поле «Ответственный»: фото сотрудника или инициалы.
@@ -0,0 +1,110 @@
import { Dropdown } from "antd";
import { useEffect, useState } from "react";
import { t } from "../../i18n";
import { Icon } from "../../shared/icons";
import { createConversationLabel, fetchConversationLabels, setConversationLabels,
type ApiConversation, type ConversationLabelRef } from "./model";
import "./dialogLabels.css";
export function DialogLabels({ detail, busy, setBusy, setErrorText, applyConversation, run }: {
detail: ApiConversation;
busy: boolean;
setBusy: (busy: boolean) => void;
setErrorText: (error: string) => void;
applyConversation: (updated: ApiConversation) => void;
run: (action: () => Promise<ApiConversation>) => Promise<void>;
}) {
const [labels, setLabels] = useState<ConversationLabelRef[]>([]);
const [newLabel, setNewLabel] = useState("");
useEffect(() => { setNewLabel(""); }, [detail.id]);
useEffect(() => {
fetchConversationLabels().then(setLabels).catch((error) => {
setErrorText(error instanceof Error ? error.message : t("conversations.could_not_add_label"));
});
}, [setErrorText]);
async function addLabel(id?: number) {
if (busy) return;
let labelId = id;
setBusy(true);
setErrorText("");
try {
if (labelId === undefined) {
const name = newLabel.trim();
if (!name) return;
const label = await createConversationLabel(name);
setLabels((current) => (current.some((item) => item.id === label.id) ? current : [...current, label]));
labelId = label.id;
setNewLabel("");
}
const ids = [...new Set([...detail.labels.map((item) => item.id), labelId])];
applyConversation(await setConversationLabels(detail.id, ids));
} catch (error) {
setErrorText(error instanceof Error ? error.message : t("conversations.could_not_add_label"));
} finally {
setBusy(false);
}
}
const assignedIds = new Set(detail.labels.map((item) => item.id));
const availableLabels = labels.filter((item) => !assignedIds.has(item.id));
return (
<>
<label className="ctx-label">{t("conversations.labels")}</label>
<div className="ctx-labels">
<Dropdown
disabled={busy}
trigger={["click"]}
overlayClassName="app-dropdown is-wide ctx-labels-menu"
menu={{
items: [
...availableLabels.map((label) => ({
key: label.id,
label: <button type="button" onClick={() => void addLabel(label.id)}><i className="ctx-dot is-square" style={{ background: label.color || "var(--n-5)" }} />{label.name}</button>,
})),
{
key: "new",
label: (
<div className="ctx-new-label" onClick={(event) => event.stopPropagation()}>
<input
aria-label={t("conversations.new_label")}
disabled={busy}
placeholder={t("conversations.new_label")}
value={newLabel}
onChange={(event) => setNewLabel(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.stopPropagation();
event.preventDefault();
void addLabel();
}
}}
/>
<button type="button" aria-label={t("conversations.add")} disabled={busy || !newLabel.trim()} onClick={() => void addLabel()}><Icon name="plus" size={13} /></button>
</div>
),
},
],
}}
>
<button type="button" className="ctx-add-label"><Icon name="plus" size={13} />{t("conversations.add")}</button>
</Dropdown>
{detail.labels.map((label) => (
<b className="ctx-label-chip" key={label.id}>
<i style={{ background: label.color || "var(--n-5)" }} />
{label.name}
<button
aria-label={t("conversations.remove_label", { name: label.name })}
disabled={busy}
type="button"
onClick={() => void run(() => setConversationLabels(detail.id, detail.labels.filter((item) => item.id !== label.id).map((item) => item.id)))}
>
×
</button>
</b>
))}
</div>
</>
);
}
@@ -0,0 +1,34 @@
import { useEffect, useState } from "react";
import { t } from "../../i18n";
import { Icon } from "../../shared/icons";
import type { ApiConversation } from "./model";
export function NoteSection({ detail, busy, onSave }: { detail: ApiConversation; busy: boolean; onSave: (note: string) => Promise<void> }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(detail.note);
useEffect(() => {
setEditing(false);
setDraft(detail.note);
}, [detail.id, detail.note]);
return (
<section className="ctx-section is-note">
<div className="ctx-section-head">
<h4>{t("conversations.note")}</h4>
<button type="button" aria-label={t("conversations.edit_note")} onClick={() => setEditing(true)}><Icon name="edit" size={14} /></button>
</div>
{editing ? (
<div className="ctx-note is-editing">
<textarea autoFocus disabled={busy} placeholder={t("conversations.internal_note_customer_does_not")} rows={3} value={draft} onChange={(event) => setDraft(event.target.value)} />
<div className="ctx-note-actions">
<button type="button" disabled={busy} onClick={() => { setDraft(detail.note); setEditing(false); }}>{t("common.cancel")}</button>
<button type="button" className="primary" disabled={busy} onClick={() => void onSave(draft).then(() => setEditing(false))}>{t("common.save")}</button>
</div>
</div>
) : (
<div className={`ctx-note ${detail.note ? "" : "is-empty"}`} onClick={() => setEditing(true)}>{detail.note || t("conversations.no_notes")}</div>
)}
</section>
);
}
@@ -0,0 +1,8 @@
/* Keep the inline add button from inheriting full-width menu item styles. */
.app-dropdown.ctx-labels-menu .ctx-new-label button {
width: 30px;
min-height: 30px;
padding: 0;
color: #ffffff;
background: var(--primary);
}
@@ -64,7 +64,7 @@ export function HelpCenterApp() {
);
}
if (!manifest || !themeReady) {
return <main className="help-boot-loading"><Loader size={44} /></main>;
return <main className="help-boot-loading"><Loader size={72} /></main>;
}
return route.articleSlug
? <HelpArticle manifest={manifest} articleSlug={route.articleSlug} />
@@ -42,11 +42,17 @@ export function HelpLayout({
</header>
<main>{children}</main>
<footer className="help-footer">
<a className="help-footer-brand" href={homeHref}>
<span className="help-brand-mark"><LogoIcon /></span>
<span>{portal.name}</span>
</a>
<span>{t("portals.knowledge_base_support")}</span>
<div className="help-footer-row">
<a className="help-footer-brand" href={homeHref}>
<span className="help-brand-mark"><LogoIcon /></span>
<span>{portal.name}</span>
</a>
<span>{t("portals.knowledge_base_support")}</span>
</div>
<span className="help-footer-powered">
{t("portals.powered_by")}{" "}
<a href="https://chatballs.ru" target="_blank" rel="noopener noreferrer">Chatballs</a>
</span>
</footer>
{portal.webWidgetKey && <PortalWebWidget widgetKey={portal.webWidgetKey} />}
</div>
@@ -153,17 +153,37 @@ html.help-center-document body {
}
.help-footer {
display: flex;
display: grid;
width: min(1024px, calc(100% - 48px));
margin: 80px auto 0;
padding: 34px 0 42px;
align-items: center;
justify-content: space-between;
gap: 18px;
border-top: 1px solid var(--help-line-soft);
color: var(--help-text-muted);
font-size: 14px;
}
.help-footer-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.help-footer-powered {
font-size: 12px;
opacity: 0.8;
}
.help-footer-powered a {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}
.help-footer-powered a:hover {
color: var(--help-ink);
}
.help-footer-brand {
gap: 9px;
font-size: 16px;
@@ -256,8 +276,7 @@ html.help-center-document body {
color: var(--help-text-muted);
}
.help-fatal,
.help-boot-loading {
.help-fatal {
display: grid;
min-height: 100vh;
place-content: center;
@@ -115,10 +115,14 @@
}
.help-footer {
gap: 14px;
margin-top: 54px;
}
.help-footer-row {
display: grid;
justify-content: start;
gap: 12px;
margin-top: 54px;
}
.help-support-launcher {
@@ -3,7 +3,9 @@ import { useState } from "react";
import { DecisionDialog } from "../../shared/DecisionDialog";
import { Icon, LogoSpinner } from "../../shared/icons";
import { Button } from "../../shared/ui-controls";
import { installUpdate } from "./api";
import { installUpdate, type UpdateInfo } from "./api";
import { useUpdatesCardVisible } from "./cardPresence";
import { STAGES, clockLabel, stageNumber, stageOf, stageProgress, useElapsed } from "./progress";
import { useUpdateInfo } from "./useUpdateInfo";
import { t } from "../../i18n";
@@ -12,47 +14,50 @@ import { t } from "../../i18n";
// что у установки демо-данных (ADR-CHATBALLS-0049).
export function UpdateBanner({ enabled }: { enabled: boolean }) {
const { info, setInfo, installing, unreachable, finished } = useUpdateInfo(enabled);
const { info, setInfo, installing, unreachable, finished, forget, markPending, targetVersion, startedAt } =
useUpdateInfo(enabled);
const [dismissed, setDismissed] = useState<string | null>(null);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const [errorText, setErrorText] = useState("");
const cardVisible = useUpdatesCardVisible();
if (!enabled) return null;
// Карточка «Обновления» открыта — она и рассказывает всё то же самое.
if (!enabled || cardVisible) return null;
async function install() {
setBusy(true);
setErrorText("");
// Полоса хода появляется по нажатию, а не по ответу сервера: запрос идёт
// около секунды, и всё это время экран не должен выглядеть безучастным.
markPending(info?.latestVersion ?? null);
try {
setInfo(await installUpdate());
setConfirming(false);
} catch (error) {
forget();
setErrorText(error instanceof Error ? error.message : t("common.could_not_complete_action"));
} finally {
setBusy(false);
}
}
const version = info?.install.version ?? info?.latestVersion ?? "";
const version = targetVersion ?? info?.latestVersion ?? "";
if (installing) {
const stage = unreachable ? "restarting" : info?.install.message || "starting";
const stageKey = (["downloading", "pulling", "restarting"].includes(stage) ? stage : "starting") as "downloading" | "pulling" | "restarting" | "starting";
return (
<div className="demo-install-banner update-banner">
<LogoSpinner size={17} />
<span>{t("updates.installing", { version })} {t(`updates.stage_${stageKey}`)}</span>
</div>
<UpdateBannerProgress info={info} unreachable={unreachable} version={version} startedAt={startedAt} />
);
}
if (finished && info?.install.status === "DONE" && dismissed !== `done:${version}`) {
if (finished) {
const installed = info?.currentVersion ?? version;
return (
<div className="demo-install-banner update-banner is-done">
<Icon name="check" size={15} strokeWidth={2.2} />
<span>{t("updates.done", { version })}</span>
<button type="button" onClick={() => window.location.reload()}>{t("common.show")}</button>
<button className="demo-install-close" type="button" onClick={() => setDismissed(`done:${version}`)} aria-label={t("common.hide")}>
<span>{t("updates.done", { version: installed })}</span>
<button type="button" onClick={() => window.location.reload()}>{t("updates.reload")}</button>
<button className="demo-install-close" type="button" onClick={forget} aria-label={t("common.hide")}>
<Icon name="close" size={14} strokeWidth={2} />
</button>
</div>
@@ -106,3 +111,44 @@ export function UpdateBanner({ enabled }: { enabled: boolean }) {
</>
);
}
// Ход установки в полосе: место есть только на одну строку, поэтому шаги
// сворачиваются в тонкую линию с подписью «шаг N из M» и временем — то же
// знание, что и в списке шагов карточки «Обновления».
function UpdateBannerProgress({
info,
unreachable,
version,
startedAt,
}: {
info: UpdateInfo | null;
unreachable: boolean;
version: string;
startedAt: string | null;
}) {
const stage = stageOf(info, unreachable);
const elapsed = useElapsed(startedAt, true);
return (
<div
className="demo-install-banner update-banner"
role="progressbar"
aria-valuemin={0}
aria-valuemax={STAGES.length}
aria-valuenow={stageNumber(stage)}
aria-valuetext={`${t("updates.installing", { version })} ${t(`updates.stage_${stage}`)}`}
>
<LogoSpinner size={17} />
<span>
{t("updates.installing", { version })} {t(`updates.stage_${stage}`)}
</span>
<span className="update-banner-bar" aria-hidden="true">
<span style={{ width: `${stageProgress(stage)}%` }} />
</span>
<span className="update-banner-step">
{t("updates.progress_step", { step: String(stageNumber(stage)), total: String(STAGES.length) })} · {clockLabel(elapsed)}
</span>
</div>
);
}
@@ -0,0 +1,51 @@
import { Icon, LogoSpinner } from "../../shared/icons";
import type { UpdateInfo } from "./api";
import { STAGES, clockLabel, stageNumber, stageOf, useElapsed } from "./progress";
import { t } from "../../i18n";
// Ход установки списком шагов. Сколько времени займёт обновление, заранее не
// знает никто (образы тянутся с разной скоростью), поэтому полосы с процентами
// здесь нет: человеку показывается, какой шаг идёт сейчас, сколько их всего и
// сколько уже идёт установка. Этого достаточно, чтобы понять, что всё живо.
export function UpdateProgress({
info,
unreachable,
version,
startedAt,
}: {
info: UpdateInfo | null;
unreachable: boolean;
version: string;
startedAt: string | null;
}) {
const stage = stageOf(info, unreachable);
const current = stageNumber(stage);
const elapsed = useElapsed(startedAt, true);
return (
<div className="update-progress" role="status" aria-live="polite">
<div className="update-progress-head">
<LogoSpinner size={18} />
<strong>{t("updates.progress_title", { version })}</strong>
<span className="update-progress-clock">
{t("updates.progress_step", { step: String(current), total: String(STAGES.length) })} · {clockLabel(elapsed)}
</span>
</div>
<ol className="update-progress-steps">
{STAGES.map((item, index) => {
const state = index + 1 < current ? "is-done" : index + 1 === current ? "is-current" : "is-waiting";
return (
<li key={item} className={`update-progress-step ${state}`}>
<span className="update-progress-mark" aria-hidden="true">
{state === "is-done" && <Icon name="check" size={11} strokeWidth={2.6} />}
</span>
<span>{t(`updates.step_${item}`)}</span>
</li>
);
})}
</ol>
<p className="update-progress-hint">{t("updates.progress_hint")}</p>
</div>
);
}
@@ -1,19 +1,27 @@
import { useState } from "react";
import { Icon } from "../../shared/icons";
import { Button } from "../../shared/ui-controls";
import { shortDateTime } from "../../shared/utils";
import { checkUpdates, installUpdate } from "./api";
import { useUpdatesCardPresence } from "./cardPresence";
import { UpdateProgress } from "./UpdateProgress";
import { useUpdateInfo } from "./useUpdateInfo";
import { t } from "../../i18n";
// Карточка «Обновления» в разделе «Платформа»: версия установки, последняя
// версия на канале релизов, проверка и установка по кнопке. Сама установка
// идёт снаружи приложения (ADR-CHATBALLS-0049), здесь только её состояние.
// идёт снаружи приложения (ADR-CHATBALLS-0049), здесь только её состояние —
// но состояние подробное: обновление перезапускает сервисы, и всё это время
// человек должен видеть, что идёт и на каком оно шаге.
export function UpdatesCard({ canManage }: { canManage: boolean }) {
const { info, setInfo, installing, unreachable } = useUpdateInfo(true);
const { info, setInfo, installing, unreachable, finished, forget, markPending, targetVersion, startedAt } =
useUpdateInfo(true);
const [busy, setBusy] = useState(false);
const [errorText, setErrorText] = useState("");
// Пока карточка на экране, полоса наверху о том же не говорит.
useUpdatesCardPresence();
async function run(action: () => Promise<typeof info>) {
setBusy(true);
@@ -28,6 +36,13 @@ export function UpdatesCard({ canManage }: { canManage: boolean }) {
}
}
function install() {
// Прогресс появляется по нажатию, а не по ответу сервера: запрос на
// установку идёт секунду, и всё это время экран не должен молчать.
markPending(info?.latestVersion ?? null);
void run(installUpdate);
}
if (!info && !unreachable) return null;
const latest = info?.latestVersion;
@@ -61,20 +76,36 @@ export function UpdatesCard({ canManage }: { canManage: boolean }) {
{info?.checkError && <div className="settings-section-error">{t("updates.check_error", { error: info.checkError })}</div>}
{errorText && <div className="settings-section-error">{errorText}</div>}
{installing && (
<p className="settings-card-note">{t("updates.installing", { version: info?.install.version ?? latest ?? "" })} {t(`updates.stage_${unreachable ? "restarting" : (["downloading", "pulling", "restarting"].includes(info?.install.message ?? "") ? info?.install.message : "starting") as "downloading" | "pulling" | "restarting" | "starting"}`)}</p>
<UpdateProgress
info={info}
unreachable={unreachable}
version={targetVersion ?? latest ?? ""}
startedAt={startedAt}
/>
)}
{finished && (
<div className="update-done" role="status">
<Icon name="check" size={15} strokeWidth={2.2} />
<div>
<strong>{t("updates.done_title", { version: info?.currentVersion ?? targetVersion ?? "" })}</strong>
<small>{t("updates.done_hint")}</small>
</div>
<Button variant="primary" onClick={() => window.location.reload()}>{t("updates.reload")}</Button>
<Button variant="secondary" onClick={forget}>{t("common.hide")}</Button>
</div>
)}
{!installing && status === "FAILED" && info?.install.version && (
<div className="settings-section-error">{t("updates.failed", { version: info.install.version })}{info.install.message ? `: ${info.install.message}` : ""}</div>
)}
{!installing && info?.available && (
{!installing && !finished && info?.available && (
<div className="settings-card-actions">
{canManage
? <Button variant="primary" disabled={busy || !info.updaterOnline} onClick={() => void run(installUpdate)}>{t("updates.install_version", { version: latest ?? "" })}</Button>
? <Button variant="primary" disabled={busy || !info.updaterOnline} onClick={install}>{t("updates.install_version", { version: latest ?? "" })}</Button>
: <span className="settings-card-note">{t("updates.available_title", { version: latest ?? "" })}</span>}
{!info.updaterOnline && <span className="settings-card-note">{t("updates.updater_offline")}</span>}
</div>
)}
{!installing && info && !info.available && latest && !info.checkError && (
{!installing && !finished && info && !info.available && latest && !info.checkError && (
<p className="settings-card-note">{t("updates.up_to_date")}</p>
)}
</div>
@@ -0,0 +1,33 @@
import { useEffect, useSyncExternalStore } from "react";
// Полоса поверх рабочей области и карточка «Обновления» рассказывают одно и то
// же. Когда карточка на экране, полоса молчит: два сообщения об одном событии
// рядом — это не «заметнее», а шум, и человек перестаёт читать оба.
let mounted = 0;
const listeners = new Set<() => void>();
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** Карточка на экране, пока смонтирована. */
export function useUpdatesCardPresence(): void {
useEffect(() => {
mounted += 1;
listeners.forEach((listener) => listener());
return () => {
mounted -= 1;
listeners.forEach((listener) => listener());
};
}, []);
}
export function useUpdatesCardVisible(): boolean {
return useSyncExternalStore(
subscribe,
() => mounted > 0,
() => false,
);
}
@@ -0,0 +1,60 @@
import { useEffect, useState } from "react";
import type { UpdateInfo } from "./api";
// Ход установки для интерфейса. Сервис обновления пишет стадию словом в
// status.json (starting → downloading → pulling → restarting → updated), и это
// единственное, что о ходе вообще известно: процента у docker pull здесь нет.
// Поэтому прогресс показывается стадиями — человек видит, что именно идёт
// сейчас и сколько таких шагов осталось, а не безымянную полосу.
export const STAGES = ["starting", "downloading", "pulling", "restarting"] as const;
export type Stage = (typeof STAGES)[number];
function isStage(value: string): value is Stage {
return (STAGES as readonly string[]).includes(value);
}
/**
* Текущая стадия.
*
* Молчание бэкенда во время установки — это и есть перезапуск: он сам входит в
* перезапускаемый стек и последнюю стадию сообщить уже не может.
*/
export function stageOf(info: UpdateInfo | null, unreachable: boolean): Stage {
if (unreachable) return "restarting";
const message = info?.install.message ?? "";
return isStage(message) ? message : "starting";
}
export function stageNumber(stage: Stage): number {
return STAGES.indexOf(stage) + 1;
}
/** Доля выполненного: последняя стадия не доводится до края, пока не готово. */
export function stageProgress(stage: Stage): number {
return Math.round(((stageNumber(stage) - 0.35) / STAGES.length) * 100);
}
/** Секунды с момента запуска установки; тикает раз в секунду. */
export function useElapsed(startedAt: string | null, running: boolean): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!running) return undefined;
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, [running]);
if (!startedAt) return 0;
const started = new Date(startedAt).getTime();
if (Number.isNaN(started)) return 0;
return Math.max(0, Math.floor((now - started) / 1000));
}
/** м:сс — установка идёт минуты, часы здесь не нужны. */
export function clockLabel(seconds: number): string {
const minutes = Math.floor(seconds / 60);
return `${minutes}:${String(seconds % 60).padStart(2, "0")}`;
}
@@ -0,0 +1,177 @@
/* Ход обновления установки. Обновление перезапускает сервисы и длится минуты,
поэтому его состояние показывается не строкой в общем потоке, а отдельным
блоком: видно, какой шаг идёт, сколько их всего и сколько уже длится. */
.update-progress {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 14px;
padding: 14px 16px;
border: 1px solid var(--primary-border);
border-radius: var(--radius-xl);
background: var(--primary-bg);
}
.update-progress-head {
display: flex;
align-items: center;
gap: 9px;
color: var(--primary-text);
font-size: 13.5px;
}
.update-progress-head strong {
flex: 1;
min-width: 0;
font-weight: 600;
}
.update-progress-clock {
flex: none;
color: var(--text-secondary);
font-size: 12.5px;
font-variant-numeric: tabular-nums;
}
.update-progress-steps {
display: flex;
flex-direction: column;
gap: 7px;
margin: 0;
padding: 0;
list-style: none;
}
.update-progress-step {
display: flex;
align-items: center;
gap: 9px;
color: var(--text-secondary);
font-size: 13px;
}
/* Кружок шага: пустой — впереди, с пульсом — идёт сейчас, с галкой — пройден. */
.update-progress-mark {
display: flex;
width: 16px;
height: 16px;
flex: none;
align-items: center;
justify-content: center;
border: 1.5px solid var(--n-6);
border-radius: 50%;
background: var(--surface-card);
color: #ffffff;
}
.update-progress-step.is-done {
color: var(--text-body);
}
.update-progress-step.is-done .update-progress-mark {
border-color: var(--success);
background: var(--success);
}
.update-progress-step.is-current {
color: var(--primary-text);
font-weight: 600;
}
.update-progress-step.is-current .update-progress-mark {
border-color: var(--primary);
box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 45%, transparent);
animation: update-step-pulse 1.6s ease-out infinite;
}
.update-progress-step.is-current .update-progress-mark::after {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--primary);
content: "";
}
@keyframes update-step-pulse {
70% {
box-shadow: 0 0 0 5px transparent;
}
100% {
box-shadow: 0 0 0 0 transparent;
}
}
@media (prefers-reduced-motion: reduce) {
.update-progress-step.is-current .update-progress-mark {
animation: none;
}
}
.update-progress-hint {
margin: 0;
color: var(--text-secondary);
font-size: 12.5px;
}
/* Итог установки: версия встала, а страница всё ещё на прошлой. */
.update-done {
display: flex;
align-items: center;
gap: 11px;
margin-top: 14px;
padding: 12px 14px;
border: 1px solid var(--success-border);
border-radius: var(--radius-xl);
background: var(--success-bg);
color: var(--success-text);
}
.update-done > svg {
flex: none;
}
.update-done > div {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 2px;
}
.update-done strong {
font-size: 13.5px;
font-weight: 600;
}
.update-done small {
color: var(--text-secondary);
font-size: 12.5px;
}
/* В полосе поверх рабочей области места на список шагов нет — то же знание
сворачивается в тонкую линию и подпись «шаг N из M». */
.update-banner-bar {
display: block;
overflow: hidden;
width: 132px;
height: 4px;
flex: none;
border-radius: var(--radius-pill);
background: color-mix(in srgb, currentColor 22%, transparent);
}
.update-banner-bar > span {
display: block;
height: 100%;
border-radius: inherit;
background: currentColor;
transition: width 0.4s ease;
}
.update-banner-step {
flex: none;
font-size: 12.5px;
font-variant-numeric: tabular-nums;
opacity: 0.8;
}
@@ -2,24 +2,71 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { fetchUpdate, installInProgress, type UpdateInfo } from "./api";
// Состояние обновления с опросом, пока идёт установка. Во время перезапуска
// бэкенд не отвечает — это не ошибка, а стадия: запросы продолжают уходить,
// пока новая версия не ответит статусом DONE или FAILED.
// Состояние обновления с опросом, пока идёт установка.
//
// Обновление перезапускает тот самый бэкенд, который о нём и рассказывает, —
// поэтому «не отвечает» здесь не ошибка, а стадия. По той же причине признак
// «идёт установка» нельзя держать только в памяти вкладки: страница может
// перезагрузиться (руками или потому что человек ждёт) ровно тогда, когда
// сервисы лежат, и вернуться в интерфейс, где про обновление нет ни слова.
// Метка живёт в sessionStorage до подтверждения от поднявшейся версии.
const POLL_INTERVAL_MS = 3000;
const MARK_KEY = "chatballs.update.pending";
// Обновление занимает минуты; за час оно либо кончилось, либо не кончится уже
// никогда — забытая метка не должна вечно показывать мнимую установку.
const MARK_TTL_MS = 60 * 60 * 1000;
type Mark = { version: string | null; at: number };
function readMark(): Mark | null {
try {
const raw = sessionStorage.getItem(MARK_KEY);
if (!raw) return null;
const mark = JSON.parse(raw) as Mark;
if (typeof mark?.at !== "number" || Date.now() - mark.at > MARK_TTL_MS) {
sessionStorage.removeItem(MARK_KEY);
return null;
}
return mark;
} catch {
return null;
}
}
function writeMark(mark: Mark): void {
try {
sessionStorage.setItem(MARK_KEY, JSON.stringify(mark));
} catch {
/* приватное окно — переживём и без метки */
}
}
function clearMark(): void {
try {
sessionStorage.removeItem(MARK_KEY);
} catch {
/* см. выше */
}
}
export function useUpdateInfo(enabled: boolean) {
const [info, setInfo] = useState<UpdateInfo | null>(null);
const [unreachable, setUnreachable] = useState(false);
const wasInstalling = useRef(false);
// Метка переживает перезагрузку страницы, состояние — нет; нужны обе.
const [pending, setPending] = useState<Mark | null>(() => readMark());
// Версия, которую бэкенд назвал при загрузке этой страницы. Если она потом
// изменилась — в браузере лежит код прошлой версии, и его надо перечитать.
const loadedVersion = useRef<string | null>(null);
const load = useCallback(async () => {
try {
const next = await fetchUpdate();
if (loadedVersion.current === null) loadedVersion.current = next.currentVersion;
setInfo(next);
setUnreachable(false);
} catch {
// Нет прав, сеть моргнула или бэкенд перезапускается.
// Нет прав, сеть моргнула или бэкенд перезапускается — различить нельзя.
setUnreachable(true);
}
}, []);
@@ -28,8 +75,39 @@ export function useUpdateInfo(enabled: boolean) {
if (enabled) void load();
}, [enabled, load]);
const installing = installInProgress(info) || (unreachable && wasInstalling.current);
if (installInProgress(info)) wasInstalling.current = true;
const running = installInProgress(info);
// Пока бэкенд молчит, верим метке: молчит он как раз потому, что обновляется.
const installing = running || (pending !== null && (unreachable || info === null));
useEffect(() => {
if (running && info) {
// Начало берётся от сервера: после перезагрузки страницы бэкенд уже может
// не отвечать, и счётчик времени иначе пошёл бы заново.
const requested = Date.parse(info.install.requestedAt ?? "");
const mark = { version: info.install.version, at: Number.isNaN(requested) ? Date.now() : requested };
writeMark(mark);
setPending(mark);
}
}, [running, info]);
// Ответ поднявшегося бэкенда — единственное надёжное «всё кончилось».
const settled = pending !== null && !unreachable && info !== null && !running;
// Страница уже открыта в той версии, что назвал бэкенд, — значит код в
// браузере свежий и перечитывать его незачем.
const pageIsCurrent = loadedVersion.current !== null && info?.currentVersion === loadedVersion.current;
// Единственный итог, который надо держать на экране до действия человека:
// версия встала, а в браузере всё ещё код прошлой. Об остальном (сорвалось,
// страницу уже перезагрузили) рассказывает сам ответ, метка больше не нужна.
const needsReload = settled && info?.install.status === "DONE" && !pageIsCurrent;
const forget = useCallback(() => {
clearMark();
setPending(null);
}, []);
useEffect(() => {
if (settled && !needsReload) forget();
}, [settled, needsReload, forget]);
useEffect(() => {
if (!enabled || !installing) return undefined;
@@ -37,5 +115,28 @@ export function useUpdateInfo(enabled: boolean) {
return () => window.clearInterval(timer);
}, [enabled, installing, load]);
return { info, setInfo, reload: load, installing, unreachable, finished: wasInstalling.current && !installing };
return {
info,
setInfo,
reload: load,
installing,
unreachable,
/** Установка завершилась, а страница всё ещё на прошлой версии. */
finished: needsReload,
/** Убрать сообщение о результате: показано и больше не нужно. */
forget,
/**
* Пометить установку запущенной, не дожидаясь ответа сервера: прогресс
* обязан появиться в тот же миг, когда нажали кнопку.
*/
markPending: useCallback((version: string | null) => {
const mark = { version, at: Date.now() };
writeMark(mark);
setPending(mark);
}, []),
/** Версия, до которой идёт установка, даже когда бэкенд молчит. */
targetVersion: info?.install.version ?? pending?.version ?? null,
/** Момент запуска установки — от него считается прошедшее время. */
startedAt: info?.install.requestedAt ?? (pending ? new Date(pending.at).toISOString() : null),
};
}
+14 -4
View File
@@ -352,10 +352,10 @@ export const en: Record<MessageKey, Message> = {
"ai.how_agent_will_answer": "HOW THE AGENT WILL ANSWER",
"ai.how_agent_will_answer_2": "How the agent will answer",
"ai.how_should_speak": "How it should speak",
"ai.import_category_policy": "Missing categories from the file are created automatically. Without a path, new documents go to “No category” and existing documents keep their category.",
"ai.import_category_creation": "Missing categories will be created automatically.",
"ai.import_documents": "Import {count}",
"ai.import_finished": "Import finished",
"ai.import_never_creates_categories_implicitly": "Import never creates categories implicitly: a document with a non-existent",
"ai.import_path_tail": "is rejected on its own, the rest are applied. Without a path a document lands in “No category”.",
"ai.index_up_date": "The index is up to date",
"ai.indexing": "INDEXING",
"ai.insert_link_into_text": "Insert a link into the text",
@@ -381,7 +381,6 @@ export const en: Record<MessageKey, Message> = {
"ai.list": "List",
"ai.manage": "Manage",
"ai.material_import": "Material import",
"ai.missing_category_path": "there is no category “{name}” — import never creates categories. Create it or drop the last level of the path.",
"ai.move": "Move",
"ai.move_category": "Move to a category",
"ai.name_group_all_takes_start": "A name and a group are all it takes to start. Instructions, knowledge and connections are set later on the card.",
@@ -905,6 +904,7 @@ export const en: Record<MessageKey, Message> = {
"portals.invalid_yaml": "Invalid YAML",
"portals.ip_of_installation": "{value} · this installation's IP",
"portals.knowledge_base_support": "Knowledge base and support",
"portals.powered_by": "Powered by",
"portals.language": "Language",
"portals.language_2": "Language:",
"portals.language_3": "LANGUAGE",
@@ -1062,7 +1062,7 @@ export const en: Record<MessageKey, Message> = {
"onboarding.s1.name": "AI provider",
"onboarding.s1.time": "3 min",
"onboarding.s1.title": "Connect an AI provider",
"onboarding.s1.lead": "Without a provider the agent cannot answer anyone. The key is yours: spend, limits and tokens are all counted in the system.",
"onboarding.s1.lead": "Without a provider the agent cannot answer anyone. The key is yours: you pay the provider at your own rate.",
"onboarding.s1.path": "Settings → AI provider → Add provider",
"onboarding.s1.do_1": "Open Settings in the sidebar and pick the AI provider section.",
"onboarding.s1.do_2": "Click Add provider: OpenRouter, Custom (OpenAI-compatible) or a local model.",
@@ -1288,6 +1288,16 @@ export const en: Record<MessageKey, Message> = {
"updates.stage_downloading": "downloading the release file",
"updates.stage_pulling": "pulling images",
"updates.stage_restarting": "restarting services",
"updates.step_starting": "Starting the update",
"updates.step_downloading": "Downloading the release file",
"updates.step_pulling": "Pulling the images",
"updates.step_restarting": "Restarting the services",
"updates.progress_title": "Updating to {version}",
"updates.progress_step": "Step {step} of {total}",
"updates.progress_hint": "On the last step the application stops responding for a short while — that is expected. You can reload the page: the progress is not lost.",
"updates.done_title": "Version {version} is installed",
"updates.done_hint": "This page is still running the previous version. Reload it to use the new one.",
"updates.reload": "Reload the page",
"updates.done": "Version {version} is installed. Reload the page.",
"updates.failed": "Update to {version} failed",
"updates.confirm_title": "Update to {version}?",
+14 -4
View File
@@ -353,10 +353,10 @@ export const ru = {
"ai.how_agent_will_answer": "КАК ОТВЕТИТ АГЕНТ",
"ai.how_agent_will_answer_2": "Как ответит агент",
"ai.how_should_speak": "Как он должен говорить",
"ai.import_category_policy": "Недостающие категории из файла создаются автоматически. Без указания пути новый документ попадает в «Без категории», существующий сохраняет свою категорию.",
"ai.import_category_creation": "Недостающие категории будут созданы автоматически.",
"ai.import_documents": "Импортировать {count}",
"ai.import_finished": "Импорт завершён",
"ai.import_never_creates_categories_implicitly": "Импорт не создаёт категории неявно: документ с несуществующим",
"ai.import_path_tail": "отклоняется отдельно, остальные применяются. Без указания пути документ попадает в «Без категории».",
"ai.index_up_date": "Индекс актуален",
"ai.indexing": "ИНДЕКСАЦИЯ",
"ai.insert_link_into_text": "Вставить ссылку в текст",
@@ -382,7 +382,6 @@ export const ru = {
"ai.list": "Список",
"ai.manage": "Управлять",
"ai.material_import": "Импорт материалов",
"ai.missing_category_path": "категории «{name}» нет — импорт не создаёт категории. Создайте её или уберите последний уровень пути.",
"ai.move": "Переместить",
"ai.move_category": "Переместить в категорию",
"ai.name_group_all_takes_start": "Имя и группа — всё, что нужно для старта. Инструкции, знания и подключения настраиваются потом на карточке.",
@@ -906,6 +905,7 @@ export const ru = {
"portals.invalid_yaml": "Невалидный YAML",
"portals.ip_of_installation": "{value} · IP этой установки",
"portals.knowledge_base_support": "База знаний и поддержка",
"portals.powered_by": "Работает на",
"portals.language": "Язык",
"portals.language_2": "Язык:",
"portals.language_3": "ЯЗЫК",
@@ -1063,7 +1063,7 @@ export const ru = {
"onboarding.s1.name": "AI-провайдер",
"onboarding.s1.time": "3 мин",
"onboarding.s1.title": "Подключите AI-провайдера",
"onboarding.s1.lead": "Без провайдера агент не ответит ни одному клиенту. Ключ ваш: расходы, лимиты и токены считаются в системе.",
"onboarding.s1.lead": "Без провайдера агент не ответит ни одному клиенту. Ключ ваш: расходы идут по вашему тарифу у провайдера.",
"onboarding.s1.path": "Настройки → AI-провайдер → Добавить провайдера",
"onboarding.s1.do_1": "В сайдбаре откройте «Настройки» и выберите раздел «AI-провайдер».",
"onboarding.s1.do_2": "Нажмите «Добавить провайдера»: OpenRouter, Custom (OpenAI-compatible) или локальная модель.",
@@ -1289,6 +1289,16 @@ export const ru = {
"updates.stage_downloading": "загрузка файла релиза",
"updates.stage_pulling": "загрузка образов",
"updates.stage_restarting": "перезапуск сервисов",
"updates.step_starting": "Запуск обновления",
"updates.step_downloading": "Загрузка файла релиза",
"updates.step_pulling": "Загрузка образов",
"updates.step_restarting": "Перезапуск сервисов",
"updates.progress_title": "Обновление до {version}",
"updates.progress_step": "Шаг {step} из {total}",
"updates.progress_hint": "На последнем шаге приложение ненадолго перестанет отвечать — так и должно быть. Страницу можно перезагрузить: ход обновления не потеряется.",
"updates.done_title": "Установлена версия {version}",
"updates.done_hint": "Страница открыта в прошлой версии. Обновите её, чтобы работать в новой.",
"updates.reload": "Обновить страницу",
"updates.done": "Установлена версия {version}. Обновите страницу.",
"updates.failed": "Обновление до {version} не удалось",
"updates.confirm_title": "Обновить до {version}?",
+1 -2
View File
@@ -266,8 +266,7 @@
.hub-brand-mark.has-logo {
overflow: hidden;
color: inherit;
background: var(--surface-card);
border: 1px solid var(--n-7);
background: transparent;
}
.hub-brand-mark img {
+2 -2
View File
@@ -29,10 +29,10 @@ function SurfaceApp() {
}, [surface]);
if (surface === "loading") {
return <main className="help-boot-loading"><Loader size={44} /></main>;
return <main className="help-boot-loading"><Loader size={72} /></main>;
}
return surface === "help"
? <Suspense fallback={<main className="help-boot-loading"><Loader size={44} /></main>}><HelpCenterApp /></Suspense>
? <Suspense fallback={<main className="help-boot-loading"><Loader size={72} /></main>}><HelpCenterApp /></Suspense>
: <App />;
}
+14
View File
@@ -15,3 +15,17 @@
color: var(--n-3);
font-size: 13px;
}
/* Загрузчик портала до прихода манифеста и темы. Живёт в общем CSS, а не в
чанке портала: чанк грузится лениво, и первый кадр иначе рисуется без
стилей. Фиксированный слой на весь экран — центр не зависит от отступов
и высоты документа. */
.help-boot-loading {
position: fixed;
inset: 0;
display: grid;
place-items: center;
margin: 0;
background: var(--help-bg, var(--surface-page));
color: var(--help-ink, var(--text-body));
}
+1 -1
View File
@@ -108,7 +108,7 @@ export function LoadingState({ variant = "page" }: { variant?: "page" | "inline"
}
export function LoadingScreen() {
return <main className="state-screen"><div className="state-card"><LoadingState variant="inline" /></div></main>;
return <main className="state-screen"><LogoSpinner size={56} /></main>;
}
export function ErrorScreen({ retry }: { retry: () => void }) {
+1
View File
@@ -28,4 +28,5 @@
@import "./features/settings/styles.css";
@import "./features/onboarding/styles.css";
@import "./features/notifications/styles.css";
@import "./features/updates/styles.css";
@import "./shared/state.css";
@@ -118,7 +118,7 @@ support.js — рантайм макетов
- **KB5 Редактор знания** — тот же полноэкранный split, что PT7: слева заголовок + панель Markdown + монополе + статусная строка (знаки, ≈фрагменты, автосохранение); в центре предпросмотр «внутренний материал» и «Как ответит агент»; справа рейка 300px: категория, краткое описание, тумблер ответов, вложения (drop-зона + список), агенты (только чтение). Primary — «Сохранить и переиндексировать».
- **KB6 Редактор · перетаскивание файла** — drop-оверлей «Отпустите, чтобы приложить к знанию» (файл = вложение + ссылка в тексте), в рейке загрузка с прогрессом, в шапке бейдж «Есть несохранённые правки».
- **KB7 Категории** — дерево строками с ручкой перетаскивания, счётчиками, инлайн-переименованием, «+ вложенная»; удаление недоступно у системной и непустой категории (тайтл объясняет причину), справа карточка правил и предупреждение о непустой категории.
- **KB8 Импорт YAML** — разбор до применения: файл, сводка «создать 4 / обновить 2 / ошибка 1», таблица документ · `categoryPath` · действие · замечание; ошибочная строка красная с текстом «категории нет — импорт не создаёт категории»; primary «Импортировать 6 документов».
- **KB8 Импорт YAML** — разбор до применения: файл, сводка «создать 5 / обновить 2», таблица документ · `categoryPath` · действие · замечание; недостающие уровни категорий создаются автоматически с пояснением в замечании; primary «Импортировать 7 документов».
- **KB9 Пустая категория** — «В категории «Обучение» пока нет знаний» + «Добавить знание» / «Импорт YAML»; счётчики не подменяются нулями из другой категории.
### Решения по базе знаний
@@ -580,9 +580,9 @@ a:hover{opacity:0.85;}
</div>
<div style="display:flex;align-items:center;gap:12px;">
<span style="flex:1;font-size:12px;line-height:1.5;color:var(--c-n4);max-width:640px;">Импорт не создаёт категории неявно: документ с несуществующим <code style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;">categoryPath</code> отклоняется отдельно, остальные применяются. Без указания пути документ попадает в «Без категории».</span>
<span style="flex:1;font-size:12px;line-height:1.5;color:var(--c-n4);max-width:640px;">Недостающие категории из файла создаются автоматически. Без указания пути новый документ попадает в «Без категории», существующий сохраняет свою категорию.</span>
<button style="height:34px;padding:0 13px;border:1px solid var(--c-n7);border-radius:8px;background:var(--c-surface);color:var(--c-n2);font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;">Отмена</button>
<button style="height:34px;padding:0 15px;border:none;border-radius:8px;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-accent-hover);">Импортировать 6 документов</button>
<button style="height:34px;padding:0 15px;border:none;border-radius:8px;background:var(--c-accent);color:#fff;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;" style-hover="background:var(--c-accent-hover);">Импортировать 7 документов</button>
</div>
</div>
</div>
@@ -831,9 +831,8 @@ class Component extends DCLogic {
}));
const importStats = [
{ label: "создать 4", bg: "var(--c-ok-bg)", color: "var(--c-ok-text)" },
{ label: "создать 5", bg: "var(--c-ok-bg)", color: "var(--c-ok-text)" },
{ label: "обновить 2", bg: "var(--c-accent-bg)", color: "var(--c-accent-text)" },
{ label: "ошибка 1", bg: "var(--c-err-bg)", color: "var(--c-err-text)" },
];
const IA = {
new: { action: "создать", actBg: "var(--c-ok-bg)", actColor: "var(--c-ok-text)" },
@@ -844,7 +843,7 @@ class Component extends DCLogic {
{ title: "srok-poshiva", path: "Регламенты / Пошив", ...IA.upd, note: "совпал заголовок — заменим текст и пересоберём фрагменты" },
{ title: "perenos-primerki", path: "Регламенты / Пошив", ...IA.new, note: "" },
{ title: "price-2026", path: "Прайсы и оплата", ...IA.upd, note: "категория в файле совпадает с текущей" },
{ title: "foxray-licensing", path: "Продукты / FoxRay / Лицензирование", ...IA.err, note: "категории «Лицензирование» нет — импорт не создаёт категории. Создайте её или уберите последний уровень пути.", err: true },
{ title: "foxray-licensing", path: "Продукты / FoxRay / Лицензирование", ...IA.new, note: "Недостающие категории будут созданы автоматически." },
{ title: "foxray-update", path: "Продукты / FoxRay", ...IA.new, note: "" },
{ title: "uhod-za-shelkom", path: "не указана", ...IA.new, note: "попадёт в «Без категории»" },
{ title: "script-otkaz", path: "Скрипты общения", ...IA.new, note: "" },
+37
View File
@@ -423,6 +423,43 @@ test("администратор установки видит баннер о
await expect(page.locator(".update-banner")).toContainText("Обновление до 1.5.0");
});
// Обновление перезапускает сам бэкенд, который о нём и рассказывает, поэтому
// ход установки обязан пережить и молчание сервера, и перезагрузку страницы:
// иначе человек остаётся без единого признака того, что обновление идёт.
test("ход установки показывается по шагам и переживает перезагрузку страницы", async ({ page }) => {
await mockSession(page, OWNER_IDENTITY);
await mockInstance(page);
await mockEmployees(page);
await page.route("**/api/v1/instance/settings/", (route) => route.fulfill({
json: { instance: {
publicHost: "support.example.ru", publicScheme: "https", publicUrl: "https://support.example.ru",
updatedAt: null, defaultLanguage: "ru", languages: [{ code: "ru", label: "Русский" }],
email: { host: "", port: 587, user: "", useTls: true, from: "", updatedAt: null },
turn: { urls: [], ttlSeconds: 3600, secretReady: false },
} },
}));
let backendDown = false;
await page.route("**/api/v1/instance/update/", (route) => backendDown ? route.abort() : route.fulfill({
json: { update: {
currentVersion: "1.4.0", latestVersion: "1.5.0", latestName: "v1.5.0", latestNotes: "", latestPublishedAt: null,
latestPageUrl: "", available: true, checkedAt: null, checkError: "", updaterOnline: true,
install: { version: "1.5.0", status: "RUNNING", message: "pulling", requestedAt: "2026-09-14T09:00:00Z", updatedAt: null },
} },
}));
await page.goto(`/organizations/${ORGANIZATION_PUBLIC_ID}/settings/platform`);
const progress = page.locator(".update-progress");
await expect(progress).toContainText("Обновление до 1.5.0");
await expect(progress).toContainText("Шаг 3 из 4");
await expect(progress.locator(".update-progress-step.is-current")).toHaveText("Загрузка образов");
// Сервисы перезапускаются, бэкенд молчит, человек перезагружает страницу.
backendDown = true;
await page.reload();
await expect(progress.locator(".update-progress-step.is-current")).toHaveText("Перезапуск сервисов");
});
test("с несколькими организациями вход открывает первую, переключатель ведёт во вторую", async ({ page }) => {
const secondMembership = membershipFor("OWNER", {
id: 3,