Files
tech-service-portal/app/static/admin.js
T
darkorion 0f239d5015 feat: документация из Nextcloud, график у сотрудника, автоочистка фото, мониторинг, итог мастера, фильтр по местам; доп. сервисы в docker-compose
- docker-compose.yml: профили docs (Nextcloud), office (ONLYOFFICE Document Server), ai (Ollama + скачивание модели)
- автонастройка: адреса Nextcloud, ONLYOFFICE, портала и модели подставляются из .env или по именам сервисов
  во внутренней сети Docker; значения из интерфейса важнее
- локальная модель в своей сети (имя сервиса, частный IP) подключается без ключа API
- .env.example, docs/DEPLOY.md (раздел 7а), README
2026-10-13 13:27:20 +03:00

98 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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, await docsAiSection());
}