- API и страницы профиля (редактирование, смена пароля, аватар), публичные карточки. - Сайдбар: блок пользователя, пункт Users для admin/root, исправлен порядок инициализации (показ admin-only после initAuthSession, currentUser). - GET /auth/me: ответ через ProfileMeResponse, исправлена валидация (is_founder bool). Команды и роли - Маршруты и UI команд; при редактировании роли: видимость Team, выбор команды в модалке, только команды с активным членством; API team_id в details/ update. - GET /api/v1/teams?membership=active для списка «своих» команд. - Форма роли: сегмент Team, панель выбора команды только при Team и не при с
254 lines
9.4 KiB
JavaScript
254 lines
9.4 KiB
JavaScript
const form = document.getElementById("event-form");
|
|
const input = document.getElementById("message-input");
|
|
const list = document.getElementById("events-list");
|
|
const clusterForm = document.getElementById("cluster-config-form");
|
|
const clusterFieldsContainer = document.getElementById("cluster-schema-fields");
|
|
const clusterOutput = document.getElementById("cluster-form-output");
|
|
const addonSelect = document.getElementById("addon-name");
|
|
const addonForm = document.getElementById("addon-config-form");
|
|
const addonFieldsContainer = document.getElementById("addon-schema-fields");
|
|
const addonOutput = document.getElementById("addon-form-output");
|
|
const clusterStatsForm = document.getElementById("cluster-stats-form");
|
|
const clusterIdInput = document.getElementById("cluster-id-input");
|
|
const clusterStatsOutput = document.getElementById("cluster-stats-output");
|
|
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
|
const socket = new WebSocket(`${protocol}://${window.location.host}/ws`);
|
|
let clusterStatsSocket = null;
|
|
|
|
function prependEvent(eventData) {
|
|
const li = document.createElement("li");
|
|
li.textContent = `[${eventData.created_at}] ${eventData.message}`;
|
|
list.prepend(li);
|
|
}
|
|
|
|
async function loadInitialEvents() {
|
|
const response = await fetch("/api/events", { method: "GET" });
|
|
const events = await response.json();
|
|
events.forEach(prependEvent);
|
|
}
|
|
|
|
function buildField(name, schema) {
|
|
const wrapper = document.createElement("div");
|
|
wrapper.className = "schema-field";
|
|
|
|
let inputElement;
|
|
if (schema.type === "boolean") {
|
|
wrapper.classList.add("schema-field--boolean");
|
|
const safeId = `field-${String(name).replace(/[^a-zA-Z0-9_-]/g, "_")}`;
|
|
inputElement = document.createElement("input");
|
|
inputElement.type = "checkbox";
|
|
inputElement.className = "form-check-input";
|
|
inputElement.id = safeId;
|
|
inputElement.name = name;
|
|
inputElement.checked = Boolean(schema.default);
|
|
const titleLabel = document.createElement("label");
|
|
titleLabel.setAttribute("for", safeId);
|
|
titleLabel.textContent = name;
|
|
if (schema.help) {
|
|
const labelRow = document.createElement("div");
|
|
labelRow.className = "schema-field-label-row";
|
|
labelRow.appendChild(titleLabel);
|
|
const tipWrap = document.createElement("div");
|
|
tipWrap.className = "schema-field-tip-wrap";
|
|
const tipBtn = document.createElement("button");
|
|
tipBtn.type = "button";
|
|
tipBtn.className = "schema-field-tip";
|
|
tipBtn.setAttribute("aria-label", `Hint: ${name}`);
|
|
tipBtn.innerHTML = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" width=\"17\" height=\"17\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M12 16v-4\"/><path d=\"M12 8h.01\"/></svg>";
|
|
const panel = document.createElement("div");
|
|
panel.className = "schema-field-tip-panel";
|
|
panel.setAttribute("role", "tooltip");
|
|
panel.textContent = schema.help;
|
|
tipWrap.appendChild(tipBtn);
|
|
tipWrap.appendChild(panel);
|
|
labelRow.appendChild(tipWrap);
|
|
wrapper.appendChild(labelRow);
|
|
} else {
|
|
wrapper.appendChild(titleLabel);
|
|
}
|
|
const swRow = document.createElement("div");
|
|
swRow.className = "form-check form-switch";
|
|
swRow.appendChild(inputElement);
|
|
wrapper.appendChild(swRow);
|
|
} else if (schema.type === "select") {
|
|
inputElement = document.createElement("select");
|
|
(schema.options || []).forEach((optionValue) => {
|
|
const option = document.createElement("option");
|
|
option.value = optionValue;
|
|
option.textContent = optionValue;
|
|
if (optionValue === schema.default) {
|
|
option.selected = true;
|
|
}
|
|
inputElement.appendChild(option);
|
|
});
|
|
} else {
|
|
inputElement = document.createElement("input");
|
|
inputElement.type = schema.type === "number" ? "number" : "text";
|
|
if (schema.default !== undefined && schema.default !== null) {
|
|
inputElement.value = String(schema.default);
|
|
}
|
|
if (schema.validation && schema.validation.min !== undefined) {
|
|
inputElement.min = String(schema.validation.min);
|
|
}
|
|
if (schema.validation && schema.validation.max !== undefined) {
|
|
inputElement.max = String(schema.validation.max);
|
|
}
|
|
if (schema.validation && schema.validation.min_length !== undefined) {
|
|
inputElement.minLength = Number(schema.validation.min_length);
|
|
}
|
|
if (schema.validation && schema.validation.max_length !== undefined) {
|
|
inputElement.maxLength = Number(schema.validation.max_length);
|
|
}
|
|
}
|
|
|
|
if (schema.type !== "boolean") {
|
|
const label = document.createElement("label");
|
|
label.setAttribute("for", name);
|
|
label.textContent = name;
|
|
if (schema.help) {
|
|
const row = document.createElement("div");
|
|
row.className = "schema-field-label-row";
|
|
row.appendChild(label);
|
|
const tipWrap = document.createElement("div");
|
|
tipWrap.className = "schema-field-tip-wrap";
|
|
const tipBtn = document.createElement("button");
|
|
tipBtn.type = "button";
|
|
tipBtn.className = "schema-field-tip";
|
|
tipBtn.setAttribute("aria-label", `Hint: ${name}`);
|
|
tipBtn.innerHTML = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" width=\"17\" height=\"17\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M12 16v-4\"/><path d=\"M12 8h.01\"/></svg>";
|
|
const panel = document.createElement("div");
|
|
panel.className = "schema-field-tip-panel";
|
|
panel.setAttribute("role", "tooltip");
|
|
panel.textContent = schema.help;
|
|
tipWrap.appendChild(tipBtn);
|
|
tipWrap.appendChild(panel);
|
|
row.appendChild(tipWrap);
|
|
wrapper.appendChild(row);
|
|
} else {
|
|
wrapper.appendChild(label);
|
|
}
|
|
inputElement.id = name;
|
|
inputElement.name = name;
|
|
if (schema.validation && schema.validation.required) {
|
|
inputElement.required = true;
|
|
}
|
|
wrapper.appendChild(inputElement);
|
|
}
|
|
|
|
const description = document.createElement("small");
|
|
description.className = "field-description";
|
|
description.textContent = schema.description || "";
|
|
if (description.textContent.trim()) wrapper.appendChild(description);
|
|
return wrapper;
|
|
}
|
|
|
|
function renderSchema(container, schema) {
|
|
container.innerHTML = "";
|
|
Object.entries(schema.fields || {}).forEach(([name, fieldSchema]) => {
|
|
container.appendChild(buildField(name, fieldSchema));
|
|
});
|
|
}
|
|
|
|
function collectValues(container, schema) {
|
|
const values = {};
|
|
Object.entries(schema.fields || {}).forEach(([name, fieldSchema]) => {
|
|
const element = container.querySelector(`[name="${name}"]`);
|
|
if (!element) {
|
|
return;
|
|
}
|
|
if (fieldSchema.type === "boolean") {
|
|
values[name] = element.checked;
|
|
} else if (fieldSchema.type === "number") {
|
|
values[name] = Number(element.value);
|
|
} else {
|
|
values[name] = element.value;
|
|
}
|
|
});
|
|
return values;
|
|
}
|
|
|
|
let clusterSchema = null;
|
|
let addonSchema = null;
|
|
|
|
async function loadClusterSchema() {
|
|
const response = await fetch("/api/schemas/cluster", { method: "GET" });
|
|
clusterSchema = await response.json();
|
|
renderSchema(clusterFieldsContainer, clusterSchema);
|
|
}
|
|
|
|
async function loadAddonSchema(addon) {
|
|
const response = await fetch(`/api/schemas/addons/${encodeURIComponent(addon)}`, { method: "GET" });
|
|
addonSchema = await response.json();
|
|
renderSchema(addonFieldsContainer, addonSchema);
|
|
}
|
|
|
|
socket.onmessage = (event) => {
|
|
const data = JSON.parse(event.data);
|
|
prependEvent(data);
|
|
};
|
|
|
|
form.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
const message = input.value.trim();
|
|
if (!message || socket.readyState !== WebSocket.OPEN) {
|
|
return;
|
|
}
|
|
socket.send(message);
|
|
input.value = "";
|
|
});
|
|
|
|
clusterForm.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
if (!clusterSchema) {
|
|
return;
|
|
}
|
|
const payload = collectValues(clusterFieldsContainer, clusterSchema);
|
|
clusterOutput.textContent = JSON.stringify(payload, null, 2);
|
|
});
|
|
|
|
addonSelect.addEventListener("change", async (event) => {
|
|
await loadAddonSchema(event.target.value);
|
|
});
|
|
|
|
addonForm.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
if (!addonSchema) {
|
|
return;
|
|
}
|
|
const payload = collectValues(addonFieldsContainer, addonSchema);
|
|
addonOutput.textContent = JSON.stringify(payload, null, 2);
|
|
});
|
|
|
|
function connectClusterStats(clusterId) {
|
|
if (clusterStatsSocket) {
|
|
clusterStatsSocket.close();
|
|
}
|
|
clusterStatsOutput.textContent = "Connecting...";
|
|
clusterStatsSocket = new WebSocket(`${protocol}://${window.location.host}/ws/clusters/${clusterId}/stats`);
|
|
clusterStatsSocket.onmessage = (event) => {
|
|
const payload = JSON.parse(event.data);
|
|
if (payload.event === "cluster.stats") {
|
|
clusterStatsOutput.textContent = JSON.stringify(payload.data, null, 2);
|
|
} else {
|
|
clusterStatsOutput.textContent = JSON.stringify(payload, null, 2);
|
|
}
|
|
};
|
|
clusterStatsSocket.onerror = () => {
|
|
clusterStatsOutput.textContent = "WebSocket stats connection error";
|
|
};
|
|
}
|
|
|
|
clusterStatsForm.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
const clusterId = Number(clusterIdInput.value);
|
|
if (!clusterId || clusterId < 1) {
|
|
return;
|
|
}
|
|
connectClusterStats(clusterId);
|
|
});
|
|
|
|
loadInitialEvents();
|
|
loadClusterSchema();
|
|
loadAddonSchema(addonSelect.value);
|
|
connectClusterStats(Number(clusterIdInput.value));
|