diff --git a/frontend/js/app.js b/frontend/js/app.js
index 274c0d6..ccf4d45 100755
--- a/frontend/js/app.js
+++ b/frontend/js/app.js
@@ -1,7 +1,7 @@
import { requireAuthOrRedirect, logout } from "./auth.js";
import { apiFetch } from "./api.js";
-import { loadSession } from "./storage.js";
import { CONFIG } from "./config.js";
+import { loadSession, saveSession, clearSession } from "./storage.js";
if (!requireAuthOrRedirect()) {
// редирект уже сделан
@@ -63,32 +63,34 @@ function renderTable(filter = "") {
for (const r of filtered) {
const tr = document.createElement("tr");
- const commentPreview = r.comments.length > 180 ? (r.comments.slice(0, 180) + "…") : r.comments;
-
tr.innerHTML = `
-
${r.id} |
- ${escapeHtml(r.name)} |
+ ${escapeHtml(r.id)} |
+ ${escapeHtml(r.name)} |
${escapeHtml(r.status)} |
- ${escapeHtml(r.ownerName)} (#${r.ownerId}) |
- ${escapeHtml(r.targetName)} (#${r.targetId}) |
- ${escapeHtml(commentPreview)} |
- ${escapeHtml(r.inventoryId)} |
+ ${escapeHtml(r.ipLocal)} |
+ ${escapeHtml(r.ipServer)} |
|
`;
- const actionTd = tr.lastElementChild;
-
- if (isWireguardLike(r.comments)) {
- const btn = document.createElement("button");
- btn.className = "btn btn-ghost btn-sm";
- btn.textContent = "Копировать WG";
- btn.addEventListener("click", async () => {
- await copyToClipboard(r.comments);
- setStatus("Скопировано.");
- setTimeout(() => setStatus(""), 1200);
- });
- actionTd.appendChild(btn);
- }
+ const tdBtn = tr.lastElementChild;
+ const btn = document.createElement("button");
+ btn.className = "btn btn-ghost btn-sm";
+ btn.textContent = "Скачать";
+ btn.addEventListener("click", async () => {
+ clearError();
+ try {
+ await triggerDownloadById(r.id);
+ } catch (err) {
+ if (err?.status === CONFIG.AUTH_ERROR_STATUS) {
+ await logout();
+ location.replace("./login.html");
+ return;
+ }
+ showError("Не удалось скачать конфиг.");
+ setStatus("");
+ }
+ });
+ tdBtn.appendChild(btn);
tbody.appendChild(tr);
}
@@ -108,28 +110,15 @@ async function loadData() {
setStatus("Загрузка…");
try {
- // /ls/ возвращает {"ls": bd_module.ls_item(uid)} :contentReference[oaicite:7]{index=7}
- // Формат строк из БД: [item_id,item_name,status,user_id_1,user_name1,user_id_2,user_name2,icon,comments,inventory_id] :contentReference[oaicite:8]{index=8}
const data = await apiFetch(CONFIG.ENDPOINTS.list, { method: "GET", auth: true });
-
- const list = Array.isArray(data?.ls) ? data.ls : [];
- rows = list.map((a) => {
- const id = normalizeCell(a?.[0]);
- const name = normalizeCell(a?.[1]);
- const status = normalizeCell(a?.[2]);
- const ownerId = normalizeCell(a?.[3]);
- const ownerName = normalizeCell(a?.[4]);
- const targetId = normalizeCell(a?.[5]);
- const targetName = normalizeCell(a?.[6]);
- const comments = normalizeCell(a?.[8]);
- const inventoryId = normalizeCell(a?.[9]);
-
- const searchBlob = [
- id, name, status, ownerId, ownerName, targetId, targetName, comments, inventoryId
- ].join(" ").toLowerCase();
-
- return { id, name, status, ownerId, ownerName, targetId, targetName, comments, inventoryId, searchBlob };
- });
+ const list = Array.isArray(data?.ls) ? data.ls : Array.isArray(data) ? data : [];
+ rows = list
+ .map(mapLsItem)
+ .filter(Boolean)
+ .map(r => ({
+ ...r,
+ searchBlob: [r.id, r.name, r.status, r.ipLocal, r.ipServer].join(" ").toLowerCase()
+ }));
renderTable(searchEl.value);
setStatus(`Готово. Строк: ${rows.length}`);
@@ -160,3 +149,55 @@ async function loadData() {
loadData();
})();
+function filenameFromDisposition(cd) {
+ if (!cd) return null;
+
+ // filename*=UTF-8''...
+ let m = cd.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
+ if (m?.[1]) return decodeURIComponent(m[1].replace(/"/g, "").trim());
+
+ // filename="..."
+ m = cd.match(/filename\s*=\s*"?([^";]+)"?/i);
+ if (m?.[1]) return m[1].trim();
+
+ return null;
+}
+
+export async function apiFetchBlob(path, { method = "GET", headers = {}, auth = true } = {}) {
+ const s = loadSession();
+ const reqHeaders = { ...headers };
+
+ if (auth) {
+ if (!s?.short_token) throw new Error("NO_AUTH");
+ reqHeaders["short_token"] = String(s.short_token);
+ }
+
+ const doRequest = async () => {
+ return await fetch(path.startsWith("http") ? path : (CONFIG.API_BASE_URL ? CONFIG.API_BASE_URL.replace(/\/+$/, "") + path : path), {
+ method,
+ headers: reqHeaders
+ });
+ };
+
+ let res = await doRequest();
+
+ if (auth && res.status === CONFIG.AUTH_ERROR_STATUS) {
+ // renew + retry один раз
+ await (await import("./api.js")).renewTokens?.(); // на случай циклического импорта (если будет) — безопасно
+ const s2 = loadSession();
+ reqHeaders["short_token"] = String(s2.short_token);
+ res = await doRequest();
+ }
+
+ if (!res.ok) {
+ const text = await res.text().catch(() => "");
+ const err = new Error("API_BLOB_ERROR");
+ err.status = res.status;
+ err.payload = text;
+ throw err;
+ }
+
+ const blob = await res.blob();
+ const filename = filenameFromDisposition(res.headers.get("content-disposition"));
+ return { blob, filename };
+}