fix(topology): repair controls navigation and details
This commit is contained in:
@@ -147,7 +147,24 @@
|
|||||||
const API_BASE = '';
|
const API_BASE = '';
|
||||||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||||
const TYPE_LABELS = { module: '舱段模块', node: '结构节点', vehicle: '着陆器/车辆', equipment: '设备模块' };
|
const TYPE_LABELS = { module: '舱段模块', node: '结构节点', vehicle: '着陆器/车辆', equipment: '设备模块' };
|
||||||
|
const ANCHOR_LABELS = {
|
||||||
|
explicit_time: '明确时间',
|
||||||
|
mission_latest_event: '任务末条记录',
|
||||||
|
latest_recorded_state: '最新已记录状态',
|
||||||
|
no_events: '暂无记录',
|
||||||
|
};
|
||||||
|
const TIME_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
let currentData = null;
|
let currentData = null;
|
||||||
|
let lastModalTrigger = null;
|
||||||
|
|
||||||
function svgElement(name, attributes = {}) {
|
function svgElement(name, attributes = {}) {
|
||||||
const element = document.createElementNS(SVG_NS, name);
|
const element = document.createElementNS(SVG_NS, name);
|
||||||
@@ -160,17 +177,50 @@ function svgElement(name, attributes = {}) {
|
|||||||
async function fetchSnapshot(mission, at) {
|
async function fetchSnapshot(mission, at) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (at) params.set('at', at);
|
if (at) params.set('at', at);
|
||||||
else if (mission) params.set('mission', mission);
|
if (mission) params.set('mission', mission);
|
||||||
const response = await fetch(`${API_BASE}/api/v1/bases/guanghan/snapshot?${params}`);
|
const response = await fetch(`${API_BASE}/api/v1/bases/guanghan/snapshot?${params}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
let error;
|
||||||
|
try {
|
||||||
|
error = await response.json();
|
||||||
|
} catch {
|
||||||
|
error = { message: `HTTP ${response.status}` };
|
||||||
|
}
|
||||||
throw new Error(error.message || `HTTP ${response.status}`);
|
throw new Error(error.message || `HTTP ${response.status}`);
|
||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMissions() {
|
async function loadMissions() {
|
||||||
return ['BL-01', 'GHC-01', 'JC-01', 'BL-02', 'GHC-02', 'GHC-03', 'BL-03', 'GHC-04', 'GHC-05'];
|
const response = await fetch(`${API_BASE}/api/v1/bases/guanghan/missions`);
|
||||||
|
if (!response.ok) throw new Error(`任务列表加载失败(HTTP ${response.status})`);
|
||||||
|
return (await response.json()).missions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSnapshotTime(value) {
|
||||||
|
return value ? TIME_FORMATTER.format(new Date(value)) : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidTimeInput(value) {
|
||||||
|
if (!value) return true;
|
||||||
|
if (!/(Z|[+-]\d{2}:\d{2})$/.test(value)) return false;
|
||||||
|
return !Number.isNaN(Date.parse(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setError(message) {
|
||||||
|
const error = document.getElementById('error-msg');
|
||||||
|
error.textContent = message;
|
||||||
|
error.style.display = message ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLines(elementId, lines, fallback) {
|
||||||
|
const container = document.getElementById(elementId);
|
||||||
|
const values = lines.length ? lines : [fallback];
|
||||||
|
container.replaceChildren(...values.map(value => {
|
||||||
|
const line = document.createElement('span');
|
||||||
|
line.textContent = value;
|
||||||
|
return line;
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitEndpoint(endpoint) {
|
function splitEndpoint(endpoint) {
|
||||||
@@ -337,11 +387,11 @@ function renderTopology() {
|
|||||||
appendNodeText(group, node, 'node-title', title, node.y + node.height * (hasHint ? 0.34 : 0.42));
|
appendNodeText(group, node, 'node-title', title, node.y + node.height * (hasHint ? 0.34 : 0.42));
|
||||||
appendNodeText(group, node, 'node-meta', node.displayMeta, node.y + node.height * (hasHint ? 0.60 : 0.72));
|
appendNodeText(group, node, 'node-meta', node.displayMeta, node.y + node.height * (hasHint ? 0.60 : 0.72));
|
||||||
appendNodeText(group, node, 'node-hint', node.displayHint, node.y + node.height * 0.82);
|
appendNodeText(group, node, 'node-hint', node.displayHint, node.y + node.height * 0.82);
|
||||||
group.addEventListener('click', () => openModal(node.nodeKey));
|
group.addEventListener('click', () => openModal(node.nodeKey, group));
|
||||||
group.addEventListener('keydown', event => {
|
group.addEventListener('keydown', event => {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
openModal(node.nodeKey);
|
openModal(node.nodeKey, group);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
nodeLayer.appendChild(group);
|
nodeLayer.appendChild(group);
|
||||||
@@ -381,7 +431,7 @@ function buildConnectionsForComponent(componentKey) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openModal(nodeKey) {
|
function openModal(nodeKey, trigger = null) {
|
||||||
const components = getNodeComponents(nodeKey);
|
const components = getNodeComponents(nodeKey);
|
||||||
if (components.length === 0) return;
|
if (components.length === 0) return;
|
||||||
const primary = components[0];
|
const primary = components[0];
|
||||||
@@ -425,8 +475,7 @@ function openModal(nodeKey) {
|
|||||||
if (values.length) metrics.push(`${component.name}:${values.join(',')}`);
|
if (values.length) metrics.push(`${component.name}:${values.join(',')}`);
|
||||||
else if (components.length > 1 && component.summary) metrics.push(`${component.name}:${component.summary}`);
|
else if (components.length > 1 && component.summary) metrics.push(`${component.name}:${component.summary}`);
|
||||||
});
|
});
|
||||||
document.getElementById('detail-metrics').innerHTML =
|
setLines('detail-metrics', metrics, '资料未记录');
|
||||||
metrics.map(value => `<span>${value}</span>`).join('') || '<span>资料未记录</span>';
|
|
||||||
|
|
||||||
const dimensionLines = [];
|
const dimensionLines = [];
|
||||||
components.forEach(component => {
|
components.forEach(component => {
|
||||||
@@ -438,8 +487,7 @@ function openModal(nodeKey) {
|
|||||||
if (dimensions.diameterM) values.push(`直径 ${dimensions.diameterM} m`);
|
if (dimensions.diameterM) values.push(`直径 ${dimensions.diameterM} m`);
|
||||||
if (values.length) dimensionLines.push(`${component.name}:${values.join(',')}`);
|
if (values.length) dimensionLines.push(`${component.name}:${values.join(',')}`);
|
||||||
});
|
});
|
||||||
document.getElementById('detail-dimensions').innerHTML =
|
setLines('detail-dimensions', dimensionLines, '资料未记录');
|
||||||
dimensionLines.map(value => `<span>${value}</span>`).join('') || '<span>资料未记录</span>';
|
|
||||||
|
|
||||||
const connections = [];
|
const connections = [];
|
||||||
components.forEach(component => {
|
components.forEach(component => {
|
||||||
@@ -447,12 +495,14 @@ function openModal(nodeKey) {
|
|||||||
if (!connections.includes(value)) connections.push(value);
|
if (!connections.includes(value)) connections.push(value);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
document.getElementById('detail-connections').innerHTML =
|
setLines('detail-connections', connections, '无连接记录');
|
||||||
connections.map(value => `<span>${value}</span>`).join('') || '<span>无连接记录</span>';
|
const missions = components.map(component =>
|
||||||
document.getElementById('detail-mission').textContent =
|
`${component.name}:${component.introducedMission} · ${formatSnapshotTime(component.introducedAt)}`
|
||||||
primary.detailUrl ? `详见 Wiki:${primary.detailUrl}` : '资料未记录';
|
);
|
||||||
|
setLines('detail-mission', missions, '资料未记录');
|
||||||
|
|
||||||
const modal = document.getElementById('detail-modal');
|
const modal = document.getElementById('detail-modal');
|
||||||
|
lastModalTrigger = trigger;
|
||||||
modal.setAttribute('aria-hidden', 'false');
|
modal.setAttribute('aria-hidden', 'false');
|
||||||
document.body.classList.add('modal-open');
|
document.body.classList.add('modal-open');
|
||||||
modal.querySelector('.detail-dialog').focus();
|
modal.querySelector('.detail-dialog').focus();
|
||||||
@@ -461,11 +511,13 @@ function openModal(nodeKey) {
|
|||||||
function closeModal() {
|
function closeModal() {
|
||||||
document.getElementById('detail-modal').setAttribute('aria-hidden', 'true');
|
document.getElementById('detail-modal').setAttribute('aria-hidden', 'true');
|
||||||
document.body.classList.remove('modal-open');
|
document.body.classList.remove('modal-open');
|
||||||
|
if (lastModalTrigger?.isConnected) lastModalTrigger.focus();
|
||||||
|
lastModalTrigger = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load(mission, at) {
|
async function load(mission, at) {
|
||||||
document.getElementById('loading-msg').style.display = 'block';
|
document.getElementById('loading-msg').style.display = 'block';
|
||||||
document.getElementById('error-msg').style.display = 'none';
|
setError('');
|
||||||
document.getElementById('map-shell').style.display = 'none';
|
document.getElementById('map-shell').style.display = 'none';
|
||||||
try {
|
try {
|
||||||
currentData = await fetchSnapshot(mission, at);
|
currentData = await fetchSnapshot(mission, at);
|
||||||
@@ -473,15 +525,14 @@ async function load(mission, at) {
|
|||||||
const query = currentData.query;
|
const query = currentData.query;
|
||||||
const missionLabel = query.requestedMission || '(最新)';
|
const missionLabel = query.requestedMission || '(最新)';
|
||||||
document.getElementById('page-subtitle').textContent =
|
document.getElementById('page-subtitle').textContent =
|
||||||
`${missionLabel} · 快照时间: ${new Date(query.snapshotAt).toLocaleString('zh-CN')} · ${currentData.components.length} 组件`;
|
`${missionLabel} · 快照时间: ${formatSnapshotTime(query.snapshotAt)} · ${currentData.components.length} 组件`;
|
||||||
document.getElementById('snapshot-info').textContent =
|
document.getElementById('snapshot-info').textContent =
|
||||||
`快照: ${query.snapshotAt ? new Date(query.snapshotAt).toLocaleString('zh-CN') : '—'} · ${query.anchorSource}`;
|
`快照: ${formatSnapshotTime(query.snapshotAt)} · ${ANCHOR_LABELS[query.anchorSource] || query.anchorSource}`;
|
||||||
document.getElementById('loading-msg').style.display = 'none';
|
document.getElementById('loading-msg').style.display = 'none';
|
||||||
document.getElementById('map-shell').style.display = 'block';
|
document.getElementById('map-shell').style.display = 'block';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
document.getElementById('loading-msg').style.display = 'none';
|
document.getElementById('loading-msg').style.display = 'none';
|
||||||
document.getElementById('error-msg').style.display = 'block';
|
setError(`加载失败: ${error.message}`);
|
||||||
document.getElementById('error-msg').textContent = `加载失败: ${error.message}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -490,27 +541,36 @@ async function load(mission, at) {
|
|||||||
const missions = await loadMissions();
|
const missions = await loadMissions();
|
||||||
missions.forEach(mission => {
|
missions.forEach(mission => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = mission;
|
option.value = mission.code;
|
||||||
option.textContent = mission;
|
option.textContent = mission.code;
|
||||||
missionSelect.appendChild(option);
|
missionSelect.appendChild(option);
|
||||||
});
|
});
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
async function applyUrlState() {
|
||||||
const urlMission = urlParams.get('mission');
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const urlAt = urlParams.get('at');
|
const mission = urlParams.get('mission') || '';
|
||||||
if (urlMission) missionSelect.value = urlMission;
|
const at = urlParams.get('at') || '';
|
||||||
if (urlAt) document.getElementById('time-input').value = urlAt;
|
missionSelect.value = mission;
|
||||||
await load(urlMission || null, urlAt || null);
|
document.getElementById('time-input').value = at;
|
||||||
|
await load(mission || null, at || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
await applyUrlState();
|
||||||
|
|
||||||
document.getElementById('go-btn').addEventListener('click', () => {
|
document.getElementById('go-btn').addEventListener('click', () => {
|
||||||
const mission = missionSelect.value || null;
|
const mission = missionSelect.value || null;
|
||||||
const at = document.getElementById('time-input').value || null;
|
const at = document.getElementById('time-input').value || null;
|
||||||
|
if (!isValidTimeInput(at)) {
|
||||||
|
setError('请输入带时区的 ISO 8601 时间,例如 2025-01-02T08:00:00+08:00。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (mission) params.set('mission', mission);
|
if (mission) params.set('mission', mission);
|
||||||
if (at) params.set('at', at);
|
if (at) params.set('at', at);
|
||||||
window.history.pushState({}, '', `${window.location.pathname}${params.size ? `?${params}` : ''}`);
|
window.history.pushState({}, '', `${window.location.pathname}${params.size ? `?${params}` : ''}`);
|
||||||
load(mission, at);
|
load(mission, at);
|
||||||
});
|
});
|
||||||
|
window.addEventListener('popstate', applyUrlState);
|
||||||
|
|
||||||
document.getElementById('detail-modal').addEventListener('click', event => {
|
document.getElementById('detail-modal').addEventListener('click', event => {
|
||||||
if (event.target === event.currentTarget) closeModal();
|
if (event.target === event.currentTarget) closeModal();
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ class GuanghanTopologySourceTests(unittest.TestCase):
|
|||||||
self.assertIn("const API_BASE = ''", self.html)
|
self.assertIn("const API_BASE = ''", self.html)
|
||||||
self.assertNotIn("http://localhost:5000", self.html)
|
self.assertNotIn("http://localhost:5000", self.html)
|
||||||
|
|
||||||
|
def test_controls_use_api_navigation_and_safe_text(self) -> None:
|
||||||
|
self.assertIn("/api/v1/bases/guanghan/missions", self.html)
|
||||||
|
self.assertNotIn("return ['BL-01'", self.html)
|
||||||
|
self.assertIn("popstate", self.html)
|
||||||
|
self.assertIn("Asia/Shanghai", self.html)
|
||||||
|
self.assertNotIn(".innerHTML =", self.html)
|
||||||
|
self.assertIn("replaceChildren", self.html)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -115,6 +115,110 @@ class GuanghanTopologyE2ETests(unittest.TestCase):
|
|||||||
with topology_server() as base_url:
|
with topology_server() as base_url:
|
||||||
asyncio.run(scenario(base_url))
|
asyncio.run(scenario(base_url))
|
||||||
|
|
||||||
|
def test_controls_navigation_and_modal_semantics(self) -> None:
|
||||||
|
async def scenario(base_url: str) -> None:
|
||||||
|
async with async_playwright() as pw:
|
||||||
|
browser = await pw.chromium.launch(headless=True)
|
||||||
|
page = await browser.new_page(
|
||||||
|
viewport={"width": 1440, "height": 1000}
|
||||||
|
)
|
||||||
|
await page.goto(
|
||||||
|
f"{base_url}/guanghan_topology_demo.html"
|
||||||
|
"?mission=GHC-05",
|
||||||
|
wait_until="networkidle",
|
||||||
|
)
|
||||||
|
await page.wait_for_function(
|
||||||
|
"""() => document.querySelectorAll(
|
||||||
|
'.topology-node'
|
||||||
|
).length === 14"""
|
||||||
|
)
|
||||||
|
|
||||||
|
options = await page.locator(
|
||||||
|
"#mission-select option"
|
||||||
|
).evaluate_all("(items) => items.map(item => item.value)")
|
||||||
|
self.assertEqual(
|
||||||
|
options,
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"BL-01",
|
||||||
|
"GHC-01",
|
||||||
|
"JC-01",
|
||||||
|
"BL-02",
|
||||||
|
"GHC-02",
|
||||||
|
"GHC-03",
|
||||||
|
"BL-03",
|
||||||
|
"GHC-04",
|
||||||
|
"GHC-05",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
core = page.locator(
|
||||||
|
'.topology-node[data-node-key="core_cabin"]'
|
||||||
|
)
|
||||||
|
await core.click()
|
||||||
|
self.assertIn(
|
||||||
|
"GHC-01",
|
||||||
|
await page.locator("#detail-mission").inner_text(),
|
||||||
|
)
|
||||||
|
await page.locator(".modal-close").click()
|
||||||
|
self.assertEqual(
|
||||||
|
await page.evaluate(
|
||||||
|
"() => document.activeElement?.dataset.nodeKey"
|
||||||
|
),
|
||||||
|
"core_cabin",
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.select_option("#mission-select", "GHC-01")
|
||||||
|
await page.fill(
|
||||||
|
"#time-input",
|
||||||
|
"2025-01-02T08:00:00+08:00",
|
||||||
|
)
|
||||||
|
await page.locator("#go-btn").click()
|
||||||
|
await page.wait_for_function(
|
||||||
|
"""() => document.querySelector('#snapshot-info')
|
||||||
|
?.textContent.includes('明确时间')"""
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
(await page.locator("#page-subtitle").inner_text())
|
||||||
|
.startswith("GHC-01 ·")
|
||||||
|
)
|
||||||
|
self.assertIn("mission=GHC-01", page.url)
|
||||||
|
self.assertIn("at=", page.url)
|
||||||
|
|
||||||
|
await page.fill("#time-input", "not-a-time")
|
||||||
|
await page.locator("#go-btn").click()
|
||||||
|
self.assertIn(
|
||||||
|
"请输入带时区的 ISO 8601 时间",
|
||||||
|
await page.locator("#error-msg").inner_text(),
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.fill("#time-input", "")
|
||||||
|
await page.select_option("#mission-select", "GHC-01")
|
||||||
|
await page.locator("#go-btn").click()
|
||||||
|
await page.wait_for_function(
|
||||||
|
"""() => document.querySelector('#page-subtitle')
|
||||||
|
?.textContent.startsWith('GHC-01 ·')"""
|
||||||
|
)
|
||||||
|
await page.select_option("#mission-select", "GHC-05")
|
||||||
|
await page.locator("#go-btn").click()
|
||||||
|
await page.wait_for_function(
|
||||||
|
"""() => document.querySelector('#page-subtitle')
|
||||||
|
?.textContent.startsWith('GHC-05 ·')"""
|
||||||
|
)
|
||||||
|
await page.go_back()
|
||||||
|
await page.wait_for_function(
|
||||||
|
"""() => document.querySelector('#page-subtitle')
|
||||||
|
?.textContent.startsWith('GHC-01 ·')"""
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
await page.locator("#mission-select").input_value(),
|
||||||
|
"GHC-01",
|
||||||
|
)
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
with topology_server() as base_url:
|
||||||
|
asyncio.run(scenario(base_url))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -181,6 +181,25 @@ class TopologyApiQueryTests(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_components_include_introduction_mission_and_time(self) -> None:
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/v1/bases/guanghan/snapshot?mission=GHC-05"
|
||||||
|
)
|
||||||
|
components = {
|
||||||
|
component["key"]: component
|
||||||
|
for component in response.get_json()["components"]
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
components["core_cabin"]["introducedMission"],
|
||||||
|
"GHC-01",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
datetime.fromisoformat(
|
||||||
|
components["core_cabin"]["introducedAt"]
|
||||||
|
).astimezone(timezone.utc),
|
||||||
|
datetime.fromisoformat("2024-06-16T07:23:00+00:00"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -112,10 +112,14 @@ def fetch_snapshot(snapshot_at):
|
|||||||
cv.diameter_m,
|
cv.diameter_m,
|
||||||
cv.mass_t,
|
cv.mass_t,
|
||||||
cv.detail_url,
|
cv.detail_url,
|
||||||
cv.properties
|
cv.properties,
|
||||||
|
intro_mission.code AS introduced_mission,
|
||||||
|
e_intro.effective_at AS introduced_at
|
||||||
FROM components c
|
FROM components c
|
||||||
JOIN mission_events e_intro
|
JOIN mission_events e_intro
|
||||||
ON e_intro.id = c.introduced_by_event_id
|
ON e_intro.id = c.introduced_by_event_id
|
||||||
|
JOIN missions intro_mission
|
||||||
|
ON intro_mission.id = e_intro.mission_id
|
||||||
LEFT JOIN mission_events e_retire
|
LEFT JOIN mission_events e_retire
|
||||||
ON e_retire.id = c.retired_by_event_id
|
ON e_retire.id = c.retired_by_event_id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
@@ -144,6 +148,8 @@ def fetch_snapshot(snapshot_at):
|
|||||||
"summary": row["summary"],
|
"summary": row["summary"],
|
||||||
"description": row["description"],
|
"description": row["description"],
|
||||||
"detailUrl": row["detail_url"],
|
"detailUrl": row["detail_url"],
|
||||||
|
"introducedMission": row["introduced_mission"],
|
||||||
|
"introducedAt": row["introduced_at"].isoformat(),
|
||||||
"dimensions": {},
|
"dimensions": {},
|
||||||
"properties": row["properties"] or {},
|
"properties": row["properties"] or {},
|
||||||
"ports": [],
|
"ports": [],
|
||||||
|
|||||||
Reference in New Issue
Block a user