поправлены удаления, и поправлено отображение в настройках SQL
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user