поправлены удаления, и поправлено отображение в настройках SQL

This commit is contained in:
2026-08-20 13:40:20 +03:00
parent 381e630c77
commit 9422719ea6
6 changed files with 216 additions and 19 deletions

View File

@@ -3878,6 +3878,10 @@ def create_hockey_agent_router(
async def mapping_data_sources() -> dict[str, Any]:
return hub.mapping_data.list_sources()
@router.get("/api/hockey/admin/mapping-data/database-schema", dependencies=admin)
async def mapping_database_schema() -> dict[str, Any]:
return hub.mapping_data.database_schema_reference()
@router.post("/api/hockey/admin/mapping-data/sources", dependencies=admin)
async def mapping_data_source_create(payload: SqlDataSourcePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return hub.mapping_data.create_source(payload, user)

View File

@@ -6,7 +6,7 @@ from datetime import datetime
from typing import Any
from fastapi import HTTPException
from sqlalchemy import and_, delete, select, text
from sqlalchemy import and_, delete, inspect, select, text
from sqlalchemy.exc import SQLAlchemyError
from .auth_bridge import HockeyUser
@@ -229,6 +229,7 @@ class MappingDataService:
def __init__(self, database: HockeyDatabase, settings: Any | None = None) -> None:
self.database = database
self.settings = settings
self._database_schema_cache: dict[str, Any] | None = None
def _current_language(self, supplied: dict[str, Any] | None = None) -> str:
supplied = supplied or {}
@@ -383,6 +384,44 @@ class MappingDataService:
rows = list(session.scalars(select(MappingSqlDataSource).order_by(MappingSqlDataSource.sort_order, MappingSqlDataSource.id)))
return {"sources": [self._source_payload(row) for row in rows]}
def database_schema_reference(self) -> dict[str, Any]:
"""Return a compact read-only reference of hockey database tables.
This is intentionally metadata-only: table/column names and SQL types,
never row data or connection credentials. The SQL editor uses it as an
expandable cheat sheet so operators do not have to inspect PostgreSQL
manually just to remember a table name.
"""
if self._database_schema_cache is not None:
return self._database_schema_cache
try:
inspector = inspect(self.database.engine)
table_names = sorted(
name for name in inspector.get_table_names()
if str(name).startswith("hockey_")
)
tables: list[dict[str, Any]] = []
for table_name in table_names:
columns = []
for column in inspector.get_columns(table_name):
columns.append({
"name": str(column.get("name") or ""),
"type": str(column.get("type") or ""),
"nullable": bool(column.get("nullable", True)),
})
tables.append({
"name": table_name,
"column_count": len(columns),
"columns": columns,
})
payload = {"tables": tables, "table_count": len(tables)}
self._database_schema_cache = payload
return payload
except SQLAlchemyError as exc:
return {"tables": [], "table_count": 0, "error": str(exc)}
except Exception as exc:
return {"tables": [], "table_count": 0, "error": str(exc)}
def create_source(self, payload: Any, user: HockeyUser) -> dict[str, Any]:
self.ensure_defaults()
code = str(payload.code or "").strip()

View File

@@ -1726,3 +1726,22 @@
.hockey-map-sql-cell-quick:not([open]),.hockey-map-table-picker:not([open]){min-height:0!important;height:auto!important;display:block;padding:10px}
.hockey-map-sql-cell-quick:not([open])>summary,.hockey-map-table-picker:not([open])>summary{margin:0}
@media(max-width:1100px){.hockey-map-sql-cell-quick>summary{align-items:flex-start;flex-direction:column}.hockey-map-sql-cell-quick>summary small{max-width:none;text-align:left}}
/* BUILD85 — collapsed SQL parameters + database table reference */
.hockey-map-sql-reference{margin:8px 0;border:1px solid #30465d;border-radius:10px;background:#0b141e;overflow:hidden}
.hockey-map-sql-reference>summary{display:flex;align-items:center;gap:9px;padding:8px 10px;cursor:pointer;list-style:none;color:#dbe8f5;user-select:none}
.hockey-map-sql-reference>summary::-webkit-details-marker{display:none}
.hockey-map-sql-reference>summary>span{display:grid;place-items:center;flex:0 0 24px;height:24px;border:1px solid #3a6c88;border-radius:7px;background:#122639;color:#6ad3ef;font-weight:950;font:900 11px Consolas,monospace}
.hockey-map-sql-reference>summary>div{display:grid;gap:1px;min-width:0}
.hockey-map-sql-reference>summary strong{font-size:10px;color:#dce9f6}
.hockey-map-sql-reference>summary small{font-size:8px;color:#7890a8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.hockey-map-sql-reference>summary code{font:8px Consolas,monospace;color:#67dfc6}
.hockey-map-sql-reference-body{display:grid;gap:7px;padding:0 10px 10px}
.hockey-map-sql-reference .hockey-map-sql-params{margin:0;padding:0;border:0;background:transparent}
.hockey-map-sql-table-list{grid-template-columns:repeat(2,minmax(0,1fr))}
.hockey-map-sql-table-ref{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:3px 8px;min-width:0;padding:7px 8px;border:1px solid #2b4359;border-radius:8px;background:#101c29;color:#dce8f3;text-align:left;cursor:pointer}
.hockey-map-sql-table-ref:hover{border-color:#3b8ca3;background:#132535}
.hockey-map-sql-table-ref strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:10px Consolas,monospace;color:#77dbc8}
.hockey-map-sql-table-ref span{font-size:8px;color:#7890a8;white-space:nowrap}
.hockey-map-sql-table-ref code{grid-column:1/-1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:8px Consolas,monospace;color:#738da6}
@media(max-width:1050px){.hockey-map-sql-table-list{grid-template-columns:1fr}}

View File

@@ -41,6 +41,7 @@
mappingCatalog: null,
mappingContextVariables: null,
mappingSqlSources: null,
mappingDatabaseSchema: null,
mappingSelectedSqlSourceId: null,
mappingSqlPreview: null,
mappingSqlDraft: null,
@@ -1316,16 +1317,18 @@
try { return await request(url); }
catch (error) { state.mappingLoadErrors[key] = error.message || String(error); return fallback; }
};
const [devices, profiles, variables, sources] = await Promise.all([
const [devices, profiles, variables, sources, databaseSchema] = await Promise.all([
safe("devices", "/api/hockey/admin/vmix-mapping/devices", { devices: [] }),
safe("profiles", "/api/hockey/admin/vmix-mapping/profiles", { profiles: [] }),
safe("variables", "/api/hockey/admin/mapping-context/variables", { variables: [] }),
safe("sources", "/api/hockey/admin/mapping-data/sources", { sources: [] }),
safe("databaseSchema", "/api/hockey/admin/mapping-data/database-schema", { tables: [], table_count: 0 }),
]);
state.mappingDevices = devices;
state.mappingProfiles = profiles;
state.mappingContextVariables = variables;
state.mappingSqlSources = sources;
state.mappingDatabaseSchema = databaseSchema;
const id = profileId ?? state.mappingActiveProfile?.id ?? profiles.profiles?.[0]?.id ?? null;
if (id) {
try { state.mappingActiveProfile = await request(`/api/hockey/admin/vmix-mapping/profiles/${id}`); }
@@ -1334,7 +1337,7 @@
if (!state.mappingSelectedSqlSourceId && sources.sources?.length) state.mappingSelectedSqlSourceId = sources.sources[0].id;
await loadMappingCatalog();
if (state.mappingCatalog?.error) state.mappingLoadErrors.catalog = state.mappingCatalog.error;
return { devices, profiles, variables, sources };
return { devices, profiles, variables, sources, databaseSchema };
}
function mappingInventoryInputs(profile) {
@@ -2072,6 +2075,30 @@
</section>`;
}
function mappingSqlDatabaseReference() {
const payload = state.mappingDatabaseSchema || {};
const tables = Array.isArray(payload.tables) ? payload.tables : [];
const count = Number(payload.table_count || tables.length || 0);
const rows = tables.map((table) => {
const columns = Array.isArray(table.columns) ? table.columns : [];
const columnNames = columns.map((column) => String(column.name || "")).filter(Boolean);
const compactColumns = columnNames.slice(0, 12).join(", ");
const tail = columnNames.length > 12 ? ` … +${columnNames.length - 12}` : "";
return `<button type="button" class="hockey-map-sql-table-ref" data-sql-table-name="${escapeHtml(table.name || "")}" title="Вставить ${escapeHtml(table.name || "")} в SQL">
<strong>${escapeHtml(table.name || "")}</strong>
<span>${Number(table.column_count || columns.length || 0)} колонок</span>
<code>${escapeHtml(compactColumns)}${escapeHtml(tail)}</code>
</button>`;
}).join("");
const body = payload.error
? `<div class="hockey-directory-empty is-error">Не удалось получить схему БД: ${escapeHtml(payload.error)}</div>`
: rows || `<div class="hockey-directory-empty">Таблицы не найдены.</div>`;
return `<details class="hockey-map-sql-reference hockey-map-sql-schema-ref">
<summary><span>▦</span><div><strong>Справочник таблиц БД</strong><small>${count} таблиц · клик по названию вставляет его в SQL</small></div></summary>
<div class="hockey-map-sql-reference-body hockey-map-sql-table-list">${body}</div>
</details>`;
}
function mappingSqlHelp() {
const snippets = [
["Текст", "CONCAT_WS", "CONCAT_WS(' ', last_name, first_name)", "Склеить строки через разделитель"],
@@ -2144,7 +2171,11 @@
<label>Период, сек<input type="number" min="1" max="3600" step="1" data-sql-refresh-seconds value="${Math.max(1, Math.round(Number(draft.refresh_interval_ms || 1000) / 1000))}" ${draft.auto_refresh_enabled ? "" : "disabled"}></label>
<small>Сервер повторяет только этот SQL. В vMix отправляются только связанные с ним поля и только если значение реально изменилось.</small>
</div>
<div class="hockey-map-sql-params"><span>Доступные параметры — клик вставляет в SQL</span><div>${variableChips}</div></div>
<details class="hockey-map-sql-reference hockey-map-sql-params-ref">
<summary><span>:</span><div><strong>Доступные параметры</strong><small>${(state.mappingCatalog?.variables || []).length} идентификаторов · клик вставляет <code>:параметр</code> в SQL</small></div></summary>
<div class="hockey-map-sql-reference-body"><div class="hockey-map-sql-params"><div>${variableChips}</div></div></div>
</details>
${mappingSqlDatabaseReference()}
${mappingSqlHelp()}
<label class="hockey-map-sql-code"><span>SQL</span><textarea data-sql-text spellcheck="false">${escapeHtml(draft.sql_text || "")}</textarea></label>
<div class="hockey-map-sql-actions"><label class="hockey-directory-check"><input type="checkbox" data-sql-enabled ${draft.enabled !== false ? "checked" : ""}><span>Источник включён</span></label><button type="button" data-sql-preview>Проверить SQL</button><button type="button" class="is-accent" data-sql-save>${draft.id ? "Сохранить источник" : "Создать источник"}</button>${draft.id ? `<button type="button" class="danger" data-sql-delete>Удалить</button>` : ""}</div>
@@ -2366,6 +2397,18 @@
textarea.focus(); textarea.setSelectionRange(start + token.length, start + token.length);
}));
modal.querySelectorAll("[data-sql-table-name]").forEach((button) => button.addEventListener("click", () => {
const textarea = modal.querySelector("[data-sql-text]");
if (!textarea) return;
const tableName = String(button.dataset.sqlTableName || "").trim();
if (!tableName) return;
const start = textarea.selectionStart ?? textarea.value.length;
const end = textarea.selectionEnd ?? start;
textarea.value = textarea.value.slice(0, start) + tableName + textarea.value.slice(end);
textarea.focus(); textarea.setSelectionRange(start + tableName.length, start + tableName.length);
state.mappingSqlDraft = null;
}));
modal.querySelectorAll("[data-sql-snippet]").forEach((button) => button.addEventListener("click", () => {
const textarea = modal.querySelector("[data-sql-text]");
if (!textarea) return;