"use strict"; // ---------- Администрирование сервера: резервные копии ---------- const mbx = n => n >= 1048576 ? (n / 1048576).toFixed(1) + " МБ" : Math.max(1, Math.round(n / 1024)) + " КБ"; async function backupsPage() { if (!me.is_admin) throw new Error("Только для администратора"); const j = await api("GET", "/backups"); const reload = () => backupsPage(); const media = h("input", { type: "checkbox" }), note = h("input", { placeholder: "Комментарий, например «перед обновлением»" }); const auto = h("input", { type: "number", min: "0", max: "720", value: j.auto_hours, style: "max-width:120px" }); const keep = h("input", { type: "number", min: "1", max: "365", value: j.keep, style: "max-width:120px" }); const amedia = h("input", { type: "checkbox", checked: j.media }); const upload = h("input", { type: "file", accept: ".zip", style: "display:none", onchange: guard(async e => { const f = e.target.files[0]; if (!f) return; toast("Загрузка копии…"); const r = await authFetch(API + "/backups/upload", { method: "POST", headers: { "Content-Type": "application/zip" }, body: f }); if (!r.ok) { let d = r.statusText; try { d = (await r.json()).detail; } catch (_) {} throw new Error(d); } toast("Копия загружена"); reload(); }) }); async function download(name) { const r = await authFetch(`${API}/backups/${encodeURIComponent(name)}`); if (!r.ok) throw new Error("Не удалось скачать копию"); const a = h("a", { href: URL.createObjectURL(await r.blob()), download: name }); document.body.append(a); a.click(); a.remove(); } fill($app, h("a", { href: "#/admin" }, "← Администрирование"), h("h1", {}, "Резервные копии"), me.demo_mode ? h("div", { class: "card", style: "border-color:var(--warn)" }, "Вы сейчас в демо-режиме: копировать и восстанавливать рабочую базу можно только после выхода из демо.") : null, h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Создать копию"), h("p", { class: "small muted" }, "В копию входят вся продуктовая база (пользователи, оборудование, наряды, задания, склады, инциденты…) и конфигурация: настройки сервера и несекретные параметры окружения. ", "Пароли, SECRET_KEY и строка подключения к базе в копию не попадают — только их названия."), h("div", { class: "row" }, h("div", { style: "flex:1;min-width:260px" }, note), h("label", { class: "chk" }, media, "включить фото и картинки (файл будет больше)"), h("button", { onclick: guard(async () => { toast("Создаю копию…"); const b = await api("POST", "/backups", { include_media: media.checked, note: note.value }); toast("Копия создана: " + mbx(b.size)); reload(); }) }, "Создать копию"))), h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "Автоматические копии"), h("div", { class: "grid" }, field("Делать копию каждые, часов (0 — выключено)", auto), field("Сколько автокопий хранить", keep), h("div", {}, h("label", {}, "Состав"), h("label", { class: "chk" }, amedia, "с фото и картинками"))), h("p", { class: "small muted" }, j.next_auto ? `Следующая автокопия: ${fmt(j.next_auto)}. ` : "Автокопии выключены. ", `Каталог на сервере: ${j.folder}. В Docker это том appdata — копируйте файлы и за пределы сервера.`), h("button", { class: "sec", onclick: guard(async () => { await api("PUT", "/settings", { backup_auto_hours: +auto.value, backup_keep: +keep.value, backup_media: amedia.checked }); toast("Сохранено"); reload(); }) }, "Сохранить")), h("div", { class: "card" }, h("div", { class: "row" }, h("h2", { style: "margin:0;flex:1" }, `Копии (${j.items.length})`), upload, h("button", { class: "sec sm", onclick: () => upload.click() }, "⬆ Загрузить файл копии")), j.items.length ? h("div", { class: "tablewrap" }, h("table", { style: "margin-top:8px" }, h("thead", {}, h("tr", {}, ...["Создана", "Комментарий", "Размер", "Записей", "Фото", ""].map(t => h("th", {}, t)))), h("tbody", {}, ...j.items.map(b => h("tr", {}, h("td", {}, fmt(b.created_at), h("div", { class: "small muted" }, b.name)), h("td", {}, b.note || "—"), h("td", {}, mbx(b.size)), h("td", {}, b.rows), h("td", {}, b.media ? "✔" : "—"), h("td", { class: "row end" }, h("button", { class: "sec sm", onclick: guard(() => download(b.name)) }, "⬇ Скачать"), h("button", { class: "sec sm", onclick: guard(async () => { if (!confirm(`Восстановить данные из копии от ${fmt(b.created_at)}?\n\nВСЕ текущие данные будут заменены содержимым копии. Перед этим текущее состояние автоматически сохранится отдельной копией.`)) return; if (prompt("Для подтверждения введите слово ВОССТАНОВИТЬ") !== "ВОССТАНОВИТЬ") return; const r = await api("POST", `/backups/${encodeURIComponent(b.name)}/restore`, { confirm: true }); alert(`Восстановлено записей: ${r.rows}${r.media_files ? `, файлов: ${r.media_files}` : ""}.\nКопия состояния до восстановления: ${r.safety_copy}.\nВойдите заново.`); logout(); }) }, "↺ Восстановить"), h("button", { class: "sec sm", onclick: guard(async () => { if (confirm("Удалить файл копии?")) { await api("DELETE", "/backups/" + encodeURIComponent(b.name)); reload(); } }) }, "✕"))))))) : h("p", { class: "muted" }, "Копий пока нет"))); } // ---------- AI-помощник: настройки (вызывается со страницы «Настройки сервера») ---------- async function assistantSettings(box) { const c = await api("GET", "/assistant/config"); const st = { provider: c.provider || "openai" }; const enabled = h("input", { type: "checkbox", checked: c.enabled }); const prov = h("select", { onchange: e => { st.provider = e.target.value; url.placeholder = c.default_urls[st.provider] || ""; hint.replaceChildren(...hints()); } }, ...Object.entries(c.providers).map(([k, t]) => h("option", { value: k, selected: k === st.provider }, t))); const url = h("input", { value: c.base_url, placeholder: c.default_urls[st.provider] || "" }); const model = h("input", { value: c.model, placeholder: "например: yandexgpt-lite, gpt-4o-mini, GigaChat, claude-haiku-…" }); const key = h("input", { type: "password", autocomplete: "new-password", placeholder: c.api_key_set ? `ключ задан (${c.api_key_hint}) — оставьте пустым, чтобы не менять` : "ключ API провайдера" }); const scope = h("textarea", { placeholder: "Необязательно: дополнительные указания помощнику, например «о закупках — к Козлову Д. О., тел. 123»" }, c.scope); const out = h("div"), hint = h("div", { class: "small muted" }); const hints = () => ({ openai: ["Подходит для OpenAI, YandexGPT (адрес https://llm.api.cloud.yandex.net/v1, модель gpt://<каталог>/yandexgpt-lite/latest, ключ — API-ключ сервисного аккаунта), DeepSeek, OpenRouter и локальных моделей (Ollama: http://localhost:11434/v1, ключ не нужен)."], anthropic: ["Anthropic: адрес можно не указывать, модель — например claude-haiku-4-5, ключ — sk-ant-…"], gigachat: ["GigaChat: адрес можно не указывать, модель — GigaChat, ключ — «Ключ авторизации» из личного кабинета Сбера (Base64)."], })[st.provider]; hint.replaceChildren(...hints()); const body = () => { const b = { enabled: enabled.checked, provider: st.provider, base_url: url.value.trim(), model: model.value.trim(), scope: scope.value }; if (key.value) b.api_key = key.value; return b; }; box.replaceChildren(h("h2", { style: "margin-top:0" }, "🤖 AI-помощник"), h("p", { class: "small muted" }, "Кнопка помощника появится у всех пользователей в правом нижнем углу. Помощник отвечает только о работе в портале (остальное вежливо отклоняет), ", "учитывает права пользователя и читает данные только от его имени, ничего не меняет. В провайдер уходят вопрос, справка по порталу и найденные для ответа данные пользователя. Лимит — 30 вопросов в час на человека."), h("div", { class: "grid" }, field("Провайдер", prov), field("Адрес API", url), field("Модель", model), field("Ключ API", key)), hint, h("div", { style: "margin-top:8px" }, field("Указания помощнику", scope)), h("div", { class: "row", style: "margin-top:10px" }, h("label", { class: "chk" }, enabled, "Помощник включён"), h("span", { style: "flex:1" }), h("button", { class: "sec", onclick: guard(async () => { await api("PUT", "/assistant/config", body()); key.value = ""; toast("Проверяю…"); const r = await api("POST", "/assistant/test"); out.replaceChildren(h("div", { class: "sheet", style: "margin-top:8px" }, h("b", {}, "Подключение работает. "), r.offtopic_filtered ? "Фильтр тематики работает. " : h("span", { style: "color:var(--warn)" }, "Модель не отклонила вопрос не по теме — выберите модель поумнее. "), h("div", { class: "small", style: "white-space:pre-wrap;margin-top:4px" }, "Пример ответа: " + r.answer))); window.assistantRefresh && window.assistantRefresh(); }) }, "Проверить"), h("button", { onclick: guard(async () => { await api("PUT", "/assistant/config", body()); toast("Сохранено"); key.value = ""; window.assistantRefresh && window.assistantRefresh(); assistantSettings(box); }) }, "Сохранить")), out); }