313 lines
13 KiB
JavaScript
313 lines
13 KiB
JavaScript
const byId = (id) => document.getElementById(id);
|
|
const loginView = byId("login-view");
|
|
const consoleView = byId("console-view");
|
|
const loginForm = byId("login-form");
|
|
const contextForm = byId("context-form");
|
|
const loginError = byId("login-error");
|
|
const toast = byId("toast");
|
|
const contextFields = ["session-id", "workspace-id", "task-id", "prospective-id", "capability-id"];
|
|
let sessionToken = sessionStorage.getItem("domovoy.creator.session") || "";
|
|
let toastTimer;
|
|
|
|
function setText(id, value, fallback = "—") {
|
|
byId(id).textContent = value === undefined || value === null || value === "" ? fallback : String(value);
|
|
}
|
|
|
|
function setSignal(name, text, state) {
|
|
const signal = byId(`${name}-signal`);
|
|
signal.textContent = text;
|
|
signal.dataset.state = state;
|
|
}
|
|
|
|
function setBadge(name, text, tone) {
|
|
const badge = byId(`${name}-badge`);
|
|
badge.textContent = text;
|
|
badge.className = `status-badge ${tone}`;
|
|
}
|
|
|
|
function notify(message) {
|
|
toast.textContent = message;
|
|
toast.hidden = false;
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(() => { toast.hidden = true; }, 4200);
|
|
}
|
|
|
|
async function request(path, options = {}) {
|
|
const headers = new Headers(options.headers || {});
|
|
if (sessionToken) headers.set("Authorization", `Bearer ${sessionToken}`);
|
|
headers.set("Accept", "application/json");
|
|
const response = await fetch(path, {...options, headers});
|
|
if (response.status === 401 && path !== "/api/session") {
|
|
endSession("Сессия истекла. Войдите снова.");
|
|
throw new Error("Сессия отклонена");
|
|
}
|
|
if (!response.ok) {
|
|
const detail = (await response.text()).trim();
|
|
throw new Error(detail || `Ошибка ${response.status}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
function restoreContext() {
|
|
try {
|
|
const saved = JSON.parse(localStorage.getItem("domovoy.creator.context") || "{}");
|
|
contextFields.forEach((id) => {
|
|
if (saved[id]) byId(id).value = saved[id];
|
|
});
|
|
} catch (_) {
|
|
localStorage.removeItem("domovoy.creator.context");
|
|
}
|
|
}
|
|
|
|
function saveContext() {
|
|
const saved = {};
|
|
contextFields.forEach((id) => { saved[id] = byId(id).value.trim(); });
|
|
localStorage.setItem("domovoy.creator.context", JSON.stringify(saved));
|
|
return saved;
|
|
}
|
|
|
|
async function showConsole() {
|
|
const session = await request("/api/session/current");
|
|
setText("session-actor", session.ActorID);
|
|
const expiry = session.ExpiresAtUnix ? new Date(session.ExpiresAtUnix * 1000).toLocaleString("ru-RU") : "—";
|
|
setText("session-expiry", expiry);
|
|
loginView.hidden = true;
|
|
consoleView.hidden = false;
|
|
restoreContext();
|
|
const context = saveContext();
|
|
if (context["session-id"] && context["workspace-id"]) loadDashboard();
|
|
}
|
|
|
|
function endSession(message = "Сессия завершена.") {
|
|
sessionToken = "";
|
|
sessionStorage.removeItem("domovoy.creator.session");
|
|
consoleView.hidden = true;
|
|
loginView.hidden = false;
|
|
loginForm.reset();
|
|
loginError.textContent = "";
|
|
notify(message);
|
|
}
|
|
|
|
loginForm.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
const button = byId("login-button");
|
|
const bootstrap = byId("bootstrap-token").value.trim();
|
|
loginError.textContent = "";
|
|
button.disabled = true;
|
|
button.firstElementChild.textContent = "Проверяю ключ…";
|
|
try {
|
|
const result = await request("/api/session", {method: "POST", headers: {Authorization: `Bearer ${bootstrap}`}});
|
|
sessionToken = result.Token;
|
|
sessionStorage.setItem("domovoy.creator.session", sessionToken);
|
|
byId("bootstrap-token").value = "";
|
|
await showConsole();
|
|
} catch (error) {
|
|
loginError.textContent = error.message === "creator authentication denied" ? "Ключ не принят." : error.message;
|
|
} finally {
|
|
button.disabled = false;
|
|
button.firstElementChild.textContent = "Открыть консоль";
|
|
}
|
|
});
|
|
|
|
byId("toggle-secret").addEventListener("click", () => {
|
|
const input = byId("bootstrap-token");
|
|
input.type = input.type === "password" ? "text" : "password";
|
|
byId("toggle-secret").setAttribute("aria-label", input.type === "password" ? "Показать ключ" : "Скрыть ключ");
|
|
});
|
|
|
|
contextForm.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
saveContext();
|
|
loadDashboard();
|
|
});
|
|
|
|
byId("refresh-all").addEventListener("click", loadDashboard);
|
|
byId("logout").addEventListener("click", () => endSession());
|
|
|
|
async function loadDashboard() {
|
|
const context = saveContext();
|
|
const sessionID = context["session-id"];
|
|
const workspaceID = context["workspace-id"];
|
|
const query = new URLSearchParams({session_id: sessionID, workspace_id: workspaceID});
|
|
byId("connection-state").classList.remove("offline");
|
|
setText("last-update", "Обновление…");
|
|
|
|
const work = [];
|
|
if (sessionID && workspaceID) work.push(loadFocus(query));
|
|
else {
|
|
setSignal("focus", "нет контекста", "idle");
|
|
setBadge("focus", "НЕ ЗАГРУЖЕН", "neutral");
|
|
setText("focus-message", "Введите идентификаторы сессии и рабочей области.");
|
|
}
|
|
if (sessionID && workspaceID && context["task-id"]) work.push(loadTask(context["task-id"], query));
|
|
else {
|
|
setSignal("task", "нет задачи", "idle");
|
|
setBadge("task", "НЕ ЗАГРУЖЕНА", "neutral");
|
|
setText("task-message", "Укажите задачу для проверки относительно активного фокуса.");
|
|
}
|
|
work.push(loadProspectiveRegister(context["prospective-id"]));
|
|
if (context["capability-id"]) work.push(loadCapability(context["capability-id"]));
|
|
else setSignal("capability", "не выбрана", "idle");
|
|
|
|
await Promise.allSettled(work);
|
|
setText("last-update", `Обновлено ${new Intl.DateTimeFormat("ru-RU", {hour: "2-digit", minute: "2-digit", second: "2-digit"}).format(new Date())}`);
|
|
}
|
|
|
|
async function loadFocus(query) {
|
|
setSignal("focus", "загрузка", "loading");
|
|
try {
|
|
const focus = await request(`/api/focus?${query}`);
|
|
setText("focus-project", focus.ProjectID);
|
|
setText("focus-goal", focus.GoalID);
|
|
setText("focus-scope", focus.ScopeID);
|
|
setText("focus-binding", focus.ID);
|
|
setText("focus-message", "Фокус принадлежит текущему Actor и прошёл проверку сессии.");
|
|
setSignal("focus", "активен", "ok");
|
|
setBadge("focus", focus.Active ? "АКТИВЕН" : "НЕАКТИВЕН", focus.Active ? "good" : "warn");
|
|
} catch (error) {
|
|
setSignal("focus", "ошибка", "error");
|
|
setBadge("focus", "НЕДОСТУПЕН", "bad");
|
|
setText("focus-message", error.message);
|
|
byId("connection-state").classList.add("offline");
|
|
}
|
|
}
|
|
|
|
async function loadTask(taskID, query) {
|
|
setSignal("task", "загрузка", "loading");
|
|
try {
|
|
const task = await request(`/api/tasks/${encodeURIComponent(taskID)}?${query}`);
|
|
setText("task-value", task.TaskID);
|
|
setText("task-goal", task.GoalID);
|
|
setText("task-scope", task.ScopeID);
|
|
setText("task-revision", task.Revision);
|
|
setText("task-message", "Цель и область задачи совпадают с активным фокусом.");
|
|
setSignal("task", "в фокусе", "ok");
|
|
setBadge("task", "ПРОВЕРЕНА", "good");
|
|
} catch (error) {
|
|
setSignal("task", "отклонена", "error");
|
|
setBadge("task", "ВНЕ ФОКУСА", "bad");
|
|
setText("task-message", error.message);
|
|
}
|
|
}
|
|
|
|
async function loadProspectiveRegister(selectedID) {
|
|
const list = byId("prospective-list");
|
|
list.replaceChildren();
|
|
const loading = document.createElement("p");
|
|
loading.className = "register-empty";
|
|
loading.textContent = "Загружаю владельческий реестр…";
|
|
list.append(loading);
|
|
setSignal("prospective", "загрузка", "loading");
|
|
try {
|
|
const result = await request("/api/prospective-items?limit=25");
|
|
const items = Array.isArray(result.Items) ? result.Items : [];
|
|
setText("prospective-count", `${items.length} ${items.length === 1 ? "ЗАПИСЬ" : "ЗАПИСЕЙ"}`);
|
|
list.replaceChildren();
|
|
if (items.length === 0) {
|
|
const empty = document.createElement("p");
|
|
empty.className = "register-empty";
|
|
empty.textContent = "Для текущего Principal ожиданий нет.";
|
|
list.append(empty);
|
|
setSignal("prospective", "пусто", "idle");
|
|
setBadge("prospective", "НЕТ ЗАПИСЕЙ", "neutral");
|
|
return;
|
|
}
|
|
let effectiveID = selectedID;
|
|
if (!effectiveID) {
|
|
effectiveID = items[0].ID;
|
|
byId("prospective-id").value = effectiveID;
|
|
saveContext();
|
|
}
|
|
items.forEach((item) => list.append(createProspectiveRow(item, effectiveID)));
|
|
await loadProspective(effectiveID);
|
|
} catch (error) {
|
|
list.replaceChildren();
|
|
const failed = document.createElement("p");
|
|
failed.className = "register-empty";
|
|
failed.textContent = error.message;
|
|
list.append(failed);
|
|
setText("prospective-count", "ОШИБКА");
|
|
setSignal("prospective", "ошибка", "error");
|
|
setBadge("prospective", "НЕДОСТУПНА", "bad");
|
|
setText("prospective-message", error.message);
|
|
}
|
|
}
|
|
|
|
function createProspectiveRow(item, selectedID) {
|
|
const row = document.createElement("button");
|
|
row.type = "button";
|
|
row.className = "register-row";
|
|
row.setAttribute("aria-current", item.ID === selectedID ? "true" : "false");
|
|
row.setAttribute("aria-label", `Открыть ожидание ${item.ID}, состояние ${item.State}`);
|
|
const id = document.createElement("strong");
|
|
id.textContent = item.ID;
|
|
const goal = document.createElement("span");
|
|
goal.textContent = item.GoalID || "без цели";
|
|
const trigger = document.createElement("time");
|
|
trigger.textContent = item.TriggerAtUnixMillis ? new Date(item.TriggerAtUnixMillis).toLocaleString("ru-RU") : "без времени";
|
|
const state = document.createElement("em");
|
|
state.textContent = item.State || "UNKNOWN";
|
|
if (["TRIGGERED", "REVALIDATING", "DELIVERED"].includes(item.State)) state.className = "warn";
|
|
if (["SUPPRESSED", "EXPIRED"].includes(item.State)) state.className = "bad";
|
|
row.append(id, goal, trigger, state);
|
|
row.addEventListener("click", async () => {
|
|
byId("prospective-id").value = item.ID;
|
|
saveContext();
|
|
row.parentElement.querySelectorAll(".register-row").forEach((candidate) => candidate.setAttribute("aria-current", candidate === row ? "true" : "false"));
|
|
await loadProspective(item.ID);
|
|
});
|
|
return row;
|
|
}
|
|
|
|
async function loadProspective(prospectiveID) {
|
|
setSignal("prospective", "загрузка", "loading");
|
|
try {
|
|
const item = await request(`/api/prospective-items/${encodeURIComponent(prospectiveID)}`);
|
|
const state = item.State || "UNKNOWN";
|
|
const goodStates = ["REGISTERED", "ARMED", "SATISFIED"];
|
|
const warningStates = ["TRIGGERED", "REVALIDATING", "DELIVERED"];
|
|
const tone = goodStates.includes(state) ? "good" : warningStates.includes(state) ? "warn" : "bad";
|
|
setText("prospective-state", state);
|
|
setText("prospective-trigger", item.TriggerAtUnixMillis ? new Date(item.TriggerAtUnixMillis).toLocaleString("ru-RU") : "не указано");
|
|
setText("prospective-value", item.ID);
|
|
setText("prospective-commitment", item.CommitmentID);
|
|
setText("prospective-goal", item.GoalID);
|
|
setText("prospective-scope", item.ScopeID);
|
|
setText("prospective-owner", item.OwnerPrincipalID);
|
|
setText("prospective-revision", item.Revision);
|
|
setText("prospective-message", "Запись принадлежит текущему Principal и прочитана из канонического Continuity state.");
|
|
setSignal("prospective", state.toLowerCase(), tone === "bad" ? "error" : "ok");
|
|
setBadge("prospective", state, tone);
|
|
} catch (error) {
|
|
setSignal("prospective", "ошибка", "error");
|
|
setBadge("prospective", "НЕДОСТУПНА", "bad");
|
|
setText("prospective-state", "UNKNOWN");
|
|
setText("prospective-message", error.message);
|
|
}
|
|
}
|
|
|
|
async function loadCapability(capabilityID) {
|
|
setSignal("capability", "загрузка", "loading");
|
|
try {
|
|
const health = await request(`/api/capabilities/${encodeURIComponent(capabilityID)}/health`);
|
|
const state = health.State || "UNKNOWN";
|
|
const tone = state === "AVAILABLE" ? "good" : state === "DEGRADED" ? "warn" : "bad";
|
|
setText("capability-state", state);
|
|
setText("capability-reason", health.Reason, "NONE");
|
|
setText("capability-value", health.CapabilityID);
|
|
setText("capability-revision", health.Revision);
|
|
setText("capability-observed", health.ObservedAtUnixMillis ? new Date(health.ObservedAtUnixMillis).toLocaleString("ru-RU") : "не указано");
|
|
setSignal("capability", state.toLowerCase(), tone === "bad" ? "error" : "ok");
|
|
setBadge("capability", state, tone);
|
|
} catch (error) {
|
|
setSignal("capability", "ошибка", "error");
|
|
setBadge("capability", "НЕДОСТУПНА", "bad");
|
|
setText("capability-state", "UNKNOWN");
|
|
setText("capability-reason", error.message);
|
|
}
|
|
}
|
|
|
|
if (sessionToken) {
|
|
showConsole().catch(() => endSession("Сохранённая сессия больше не действует."));
|
|
}
|