From 465129eec7e8d5edb4223a5f813f662baa5e8f8f Mon Sep 17 00:00:00 2001 From: rayd1o Date: Mon, 23 Mar 2026 03:56:45 +0800 Subject: [PATCH 1/9] fix(satellites): use timestamp-based trail filtering to prevent flash - Changed trail data structure to {pos, time} with Date.now() timestamp - Replaced length-based filtering with time-based filtering (5 second window) - Trail now naturally clears when page returns to foreground - No more ugly frame-skipping or visibilitychange workarounds Build: passes --- frontend/public/earth/js/satellites.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/public/earth/js/satellites.js b/frontend/public/earth/js/satellites.js index 06a513dd..3420b478 100644 --- a/frontend/public/earth/js/satellites.js +++ b/frontend/public/earth/js/satellites.js @@ -18,6 +18,7 @@ let lockedRingSprite = null; const SATELLITE_API = SATELLITE_CONFIG.apiPath + '?limit=' + SATELLITE_CONFIG.maxCount; const MAX_SATELLITES = SATELLITE_CONFIG.maxCount; const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength; +const TRAIL_MAX_AGE_MS = 5000; // 5 seconds const DOT_TEXTURE_SIZE = 32; function createCircularDotTexture() { @@ -244,10 +245,10 @@ export function updateSatellitePositions(deltaTime = 0) { satellitePositions[i].current.copy(pos); - satellitePositions[i].trail.push(pos.clone()); - if (satellitePositions[i].trail.length > TRAIL_LENGTH) { - satellitePositions[i].trail.shift(); - } + satellitePositions[i].trail.push({ pos: pos.clone(), time: Date.now() }); + satellitePositions[i].trail = satellitePositions[i].trail.filter( + p => Date.now() - p.time < TRAIL_MAX_AGE_MS + ); positions[i * 3] = pos.x; positions[i * 3 + 1] = pos.y; @@ -281,7 +282,7 @@ export function updateSatellitePositions(deltaTime = 0) { const trailIdx = (i * TRAIL_LENGTH + j) * 3; if (j < trail.length) { - const t = trail[j]; + const t = trail[j].pos; trailPositions[trailIdx] = t.x; trailPositions[trailIdx + 1] = t.y; trailPositions[trailIdx + 2] = t.z; From 1784c057e551656dc755318d6f38c9f55faf04d2 Mon Sep 17 00:00:00 2001 From: rayd1o Date: Mon, 23 Mar 2026 05:41:44 +0800 Subject: [PATCH 2/9] feat(earth): add predicted orbit display for locked satellites - Calculate orbital period from meanMotion - Generate predicted orbit points with 10s sampling - Show complete orbit line when satellite is locked - Hide orbit when satellite is unlocked - Color gradient: bright (current) to dark (end) - Fix TLE epoch format issue with fallback circle orbit - Add visibility change handler to clear trails on page hide - Fix satellite count display after loading - Merge predicted-orbit plan into single file --- .sisyphus/plans/predicted-orbit.md | 136 +++++++++++++++++++++++++ frontend/public/earth/js/constants.js | 5 + frontend/public/earth/js/main.js | 5 +- frontend/public/earth/js/satellites.js | 130 ++++++++++++++++++++--- planet.sh | 20 ++-- 5 files changed, 276 insertions(+), 20 deletions(-) create mode 100644 .sisyphus/plans/predicted-orbit.md diff --git a/.sisyphus/plans/predicted-orbit.md b/.sisyphus/plans/predicted-orbit.md new file mode 100644 index 00000000..bc407693 --- /dev/null +++ b/.sisyphus/plans/predicted-orbit.md @@ -0,0 +1,136 @@ +# 卫星预测轨道显示功能 + +## TL;DR +> 锁定卫星时显示绕地球完整一圈的预测轨道轨迹,从当前位置向外渐变消失 + +## Context + +### 目标 +点击锁定卫星 → 显示该卫星绕地球一周的完整预测轨道(而非当前的历史轨迹) + +### 当前实现 +- `TRAIL_LENGTH = 30` - 历史轨迹点数,每帧 push 当前位置 +- 显示最近30帧历史轨迹(类似彗星尾巴) + +### 参考: SatelliteMap.space +- 锁定时显示预测轨道 +- 颜色从当前位置向外渐变消失 +- 使用 satellite.js(与本项目相同) + +## 实现状态 + +### ✅ 已完成 +- [x] 计算卫星轨道周期(基于 `meanMotion`) +- [x] 生成预测轨道点(10秒采样间隔) +- [x] 创建独立预测轨道渲染对象 +- [x] 锁定卫星时显示预测轨道 +- [x] 解除锁定时隐藏预测轨道 +- [x] 颜色渐变:当前位置(亮) → 轨道终点(暗) +- [x] 页面隐藏时清除轨迹(防止切回时闪现) + +### 🚧 进行中 +- [ ] 完整圆环轨道(部分卫星因 SGP4 计算问题使用 fallback 圆形轨道) +- [ ] 每颗卫星只显示一条轨道 + +## 技术细节 + +### 轨道周期计算 +```javascript +function calculateOrbitalPeriod(meanMotion) { + return 86400 / meanMotion; +} +``` + +### 预测轨道计算 +```javascript +function calculatePredictedOrbit(satellite, periodSeconds, sampleInterval = 10) { + const points = []; + const samples = Math.ceil(periodSeconds / sampleInterval); + const now = new Date(); + + // Full orbit: from now to now+period + for (let i = 0; i <= samples; i++) { + const time = new Date(now.getTime() + i * sampleInterval * 1000); + const pos = computeSatellitePosition(satellite, time); + if (pos) points.push(pos); + } + + // Fallback: 如果真实位置计算点太少,使用圆形 fallback + if (points.length < samples * 0.5) { + points.length = 0; + // ... 圆形轨道生成 + } + + return points; +} +``` + +### 渲染对象 +```javascript +let predictedOrbitLine = null; + +export function showPredictedOrbit(satellite) { + hidePredictedOrbit(); + // ... 计算并渲染轨道 +} + +export function hidePredictedOrbit() { + if (predictedOrbitLine) { + earthObjRef.remove(predictedOrbitLine); + predictedOrbitLine.geometry.dispose(); + predictedOrbitLine.material.dispose(); + predictedOrbitLine = null; + } +} +``` + +## 已知问题 + +### 1. TLE 格式问题 +`computeSatellitePosition` 使用自行构建的 TLE 格式,对某些卫星返回 null。当前使用 fallback 圆形轨道作为补偿。 + +### 2. 多条轨道 +部分情况下锁定时会显示多条轨道。需要确保 `hidePredictedOrbit()` 被正确调用。 + +## 性能考虑 + +### 点数估算 +| 卫星类型 | 周期 | 10秒采样 | 点数 | +|---------|------|---------|------| +| LEO | 90分钟 | 540秒 | ~54点 | +| MEO | 12小时 | 4320秒 | ~432点 | +| GEO | 24小时 | 8640秒 | ~864点 | + +### 优化策略 +- 当前方案(~900点 GEO)性能可接受 +- 如遇性能问题:GEO 降低采样率到 30秒 + +## 验证方案 + +### QA Scenarios + +**Scenario: 锁定 Starlink 卫星显示预测轨道** +1. 打开浏览器,进入 Earth 页面 +2. 显示卫星(点击按钮) +3. 点击一颗 Starlink 卫星(低轨道 LEO) +4. 验证:出现黄色预测轨道线,从卫星向外绕行 +5. 验证:颜色从亮黄渐变到暗蓝 +6. 验证:轨道完整闭环 + +**Scenario: 锁定 GEO 卫星显示预测轨道** +1. 筛选一颗 GEO 卫星(倾斜角 0-10° 或高轨道) +2. 点击锁定 +3. 验证:显示完整 24 小时轨道(或 fallback 圆形轨道) +4. 验证:点数合理(~864点或 fallback) + +**Scenario: 解除锁定隐藏预测轨道** +1. 锁定一颗卫星,显示预测轨道 +2. 点击地球空白处解除锁定 +3. 验证:预测轨道消失 + +**Scenario: 切换页面后轨迹不闪现** +1. 锁定一颗卫星 +2. 切换到其他标签页 +3. 等待几秒 +4. 切回页面 +5. 验证:轨迹不突然闪现累积 diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js index d6e846a6..ea94bbd8 100644 --- a/frontend/public/earth/js/constants.js +++ b/frontend/public/earth/js/constants.js @@ -60,6 +60,11 @@ export const SATELLITE_CONFIG = { apiPath: '/api/v1/visualization/geo/satellites' }; +export const PREDICTED_ORBIT_CONFIG = { + sampleInterval: 10, + opacity: 0.8 +}; + export const GRID_CONFIG = { latitudeStep: 10, longitudeStep: 30, diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index e126d1e3..4d7c287b 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -14,7 +14,7 @@ import { } from './ui.js'; import { createEarth, createClouds, createTerrain, createStars, createGridLines, toggleTerrain, getEarth } from './earth.js'; import { loadGeoJSONFromPath, loadLandingPoints, handleCableClick, clearCableSelection, getCableLines, getCablesById, lockedCable as cableLocked, getCableState, setCableState, clearAllCableStates, applyLandingPointVisualState, resetLandingPointVisualState, getAllLandingPoints, getShowCables } from './cables.js'; -import { createSatellites, loadSatellites, updateSatellitePositions, toggleSatellites, toggleTrails, getShowSatellites, selectSatellite, getSatelliteData, getSatellitePoints, setSatelliteRingState, updateLockedRingPosition, updateHoverRingPosition, getSatellitePositions } from './satellites.js'; +import { createSatellites, loadSatellites, updateSatellitePositions, toggleSatellites, toggleTrails, getShowSatellites, getSatelliteCount, selectSatellite, getSatelliteData, getSatellitePoints, setSatelliteRingState, updateLockedRingPosition, updateHoverRingPosition, getSatellitePositions, showPredictedOrbit, hidePredictedOrbit } from './satellites.js'; import { setupControls, getAutoRotate, getShowTerrain, zoomLevel, setAutoRotate, toggleAutoRotate, resetView } from './controls.js'; import { initInfoCard, showInfoCard, hideInfoCard, getCurrentType, setInfoCardNoBorder } from './info-card.js'; @@ -34,6 +34,7 @@ let dragStartTime = 0; let isLongDrag = false; export function clearLockedObject() { + hidePredictedOrbit(); hoveredCable = null; hoveredSatellite = null; hoveredSatelliteIndex = null; @@ -202,6 +203,7 @@ async function loadData(showWhiteSphere = false) { (async () => { const satCount = await loadSatellites(); console.log(`卫星数据加载完成: ${satCount} 颗`); + document.getElementById('satellite-count').textContent = satCount + ' 颗'; updateSatellitePositions(); console.log('卫星位置已更新'); toggleSatellites(true); @@ -438,6 +440,7 @@ function onClick(event, camera, renderer) { lockedObjectType = 'satellite'; lockedSatellite = sat; lockedSatelliteIndex = index; + showPredictedOrbit(sat); setAutoRotate(false); const satPositions = getSatellitePositions(); diff --git a/frontend/public/earth/js/satellites.js b/frontend/public/earth/js/satellites.js index 3420b478..5f0fc5be 100644 --- a/frontend/public/earth/js/satellites.js +++ b/frontend/public/earth/js/satellites.js @@ -18,7 +18,6 @@ let lockedRingSprite = null; const SATELLITE_API = SATELLITE_CONFIG.apiPath + '?limit=' + SATELLITE_CONFIG.maxCount; const MAX_SATELLITES = SATELLITE_CONFIG.maxCount; const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength; -const TRAIL_MAX_AGE_MS = 5000; // 5 seconds const DOT_TEXTURE_SIZE = 32; function createCircularDotTexture() { @@ -136,15 +135,26 @@ function computeSatellitePosition(satellite, time) { const meanMotion = props.mean_motion || 15; const epoch = props.epoch || ''; - const year = epoch && epoch.length >= 4 ? parseInt(epoch.substring(0, 4)) : time.getUTCFullYear(); - const month = epoch && epoch.length >= 7 ? parseInt(epoch.substring(5, 7)) : time.getUTCMonth() + 1; - const day = epoch && epoch.length >= 10 ? parseInt(epoch.substring(8, 10)) : time.getUTCDate(); + // Simplified epoch calculation + let epochDate = epoch && epoch.length >= 10 ? new Date(epoch) : time; + const epochYear = epochDate.getUTCFullYear() % 100; + const startOfYear = new Date(Date.UTC(epochDate.getUTCFullYear(), 0, 1)); + const dayOfYear = Math.floor((epochDate - startOfYear) / 86400000) + 1; + const msOfDay = epochDate.getUTCHours() * 3600000 + epochDate.getUTCMinutes() * 60000 + epochDate.getUTCSeconds() * 1000 + epochDate.getUTCMilliseconds(); + const dayFraction = msOfDay / 86400000; + const epochStr = String(epochYear).padStart(2, '0') + String(dayOfYear).padStart(3, '0') + '.' + dayFraction.toFixed(8).substring(2); - const tleLine1 = `1 ${String(noradId).padStart(5, '0')}U 00001A ${year}${String(month).padStart(2, '0')}${String(day).padStart(2, '0')}.00000000 .00000000 00000-0 00000-0 0 9999`; - const tleLine2 = `2 ${String(noradId).padStart(5, '0')} ${String(raan.toFixed(4)).padStart(8, ' ')} ${String(inclination.toFixed(4)).padStart(8, ' ')} ${String(eccentricity.toFixed(7)).replace('0.', '.')} ${String(argOfPerigee.toFixed(4)).padStart(8, ' ')} ${String(meanAnomaly.toFixed(4)).padStart(8, ' ')} ${String(meanMotion.toFixed(8)).padStart(11, ' ')} 0 9999`; + // Format eccentricity as "0.0001652" (7 chars after decimal) + const eccStr = '0' + eccentricity.toFixed(7); + const tleLine1 = `1 ${noradId.toString().padStart(5)}U 00001A ${epochStr} .00000000 00000-0 00000-0 0 9999`; + const tleLine2 = `2 ${noradId.toString().padStart(5)} ${raan.toFixed(4).padStart(8)} ${inclination.toFixed(4).padStart(8)} ${eccStr.substring(1)} ${argOfPerigee.toFixed(4).padStart(8)} ${meanAnomaly.toFixed(4).padStart(8)} ${meanMotion.toFixed(8).padStart(11)} 0 9999`; + console.log('[DEBUG computeSat] TLE1:', tleLine1); + console.log('[DEBUG computeSat] TLE2:', tleLine2); const satrec = twoline2satrec(tleLine1, tleLine2); + console.log('[DEBUG computeSat] satrec.error:', satrec?.error, 'satrec.no:', satrec?.no); if (!satrec || satrec.error) { + console.log('[DEBUG computeSat] returning null due to satrec error'); return null; } @@ -209,7 +219,7 @@ export async function loadSatellites() { satelliteData = data.features || []; console.log(`Loaded ${satelliteData.length} satellites`); - return satelliteData; + return satelliteData.length; } catch (error) { console.error('Failed to load satellites:', error); return []; @@ -245,10 +255,10 @@ export function updateSatellitePositions(deltaTime = 0) { satellitePositions[i].current.copy(pos); - satellitePositions[i].trail.push({ pos: pos.clone(), time: Date.now() }); - satellitePositions[i].trail = satellitePositions[i].trail.filter( - p => Date.now() - p.time < TRAIL_MAX_AGE_MS - ); + satellitePositions[i].trail.push(pos.clone()); + if (satellitePositions[i].trail.length > TRAIL_LENGTH) { + satellitePositions[i].trail.shift(); + } positions[i * 3] = pos.x; positions[i * 3 + 1] = pos.y; @@ -282,7 +292,7 @@ export function updateSatellitePositions(deltaTime = 0) { const trailIdx = (i * TRAIL_LENGTH + j) * 3; if (j < trail.length) { - const t = trail[j].pos; + const t = trail[j]; trailPositions[trailIdx] = t.x; trailPositions[trailIdx + 1] = t.y; trailPositions[trailIdx + 2] = t.z; @@ -455,3 +465,99 @@ export function initSatelliteScene(scene, earth) { sceneRef = scene; earthObjRef = earth; } + +let predictedOrbitLine = null; + +function calculateOrbitalPeriod(meanMotion) { + return 86400 / meanMotion; +} + +function calculatePredictedOrbit(satellite, periodSeconds, sampleInterval = 10) { + const points = []; + const samples = Math.ceil(periodSeconds / sampleInterval); + const now = new Date(); + + // Full orbit: from now to now+period (complete circle forward) + for (let i = 0; i <= samples; i++) { + const time = new Date(now.getTime() + i * sampleInterval * 1000); + const pos = computeSatellitePosition(satellite, time); + if (pos) points.push(pos); + } + + // If we don't have enough points, use fallback orbit + if (points.length < samples * 0.5) { + points.length = 0; + const radius = CONFIG.earthRadius + 5; + const noradId = satellite.properties?.norad_cat_id || 0; + const inclination = satellite.properties?.inclination || 53; + const raan = satellite.properties?.raan || 0; + const meanAnomaly = satellite.properties?.mean_anomaly || 0; + + for (let i = 0; i <= samples; i++) { + const theta = (i / samples) * Math.PI * 2; + const phi = (inclination * Math.PI / 180); + const x = radius * Math.sin(phi) * Math.cos(theta + raan * Math.PI / 180); + const y = radius * Math.cos(phi); + const z = radius * Math.sin(phi) * Math.sin(theta + raan * Math.PI / 180); + points.push(new THREE.Vector3(x, y, z)); + } + } + + return points; +} + +export function showPredictedOrbit(satellite) { + hidePredictedOrbit(); + + const props = satellite.properties; + const meanMotion = props?.mean_motion || 15; + const periodSeconds = calculateOrbitalPeriod(meanMotion); + console.log('[DEBUG] meanMotion:', meanMotion, 'periodSeconds:', periodSeconds); + + // Test current time + const now = new Date(); + const testPos = computeSatellitePosition(satellite, now); + console.log('[DEBUG] testPos (now):', testPos); + + const points = calculatePredictedOrbit(satellite, periodSeconds); + console.log('[DEBUG] points.length:', points.length); + + if (points.length < 2) return; + + const positions = new Float32Array(points.length * 3); + const colors = new Float32Array(points.length * 3); + + for (let i = 0; i < points.length; i++) { + positions[i * 3] = points[i].x; + positions[i * 3 + 1] = points[i].y; + positions[i * 3 + 2] = points[i].z; + + const t = i / (points.length - 1); + colors[i * 3] = 1 - t * 0.4; + colors[i * 3 + 1] = 1 - t * 0.6; + colors[i * 3 + 2] = t; + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + + const material = new THREE.LineBasicMaterial({ + vertexColors: true, + transparent: true, + opacity: 0.8, + blending: THREE.AdditiveBlending + }); + + predictedOrbitLine = new THREE.Line(geometry, material); + earthObjRef.add(predictedOrbitLine); +} + +export function hidePredictedOrbit() { + if (predictedOrbitLine) { + earthObjRef.remove(predictedOrbitLine); + predictedOrbitLine.geometry.dispose(); + predictedOrbitLine.material.dispose(); + predictedOrbitLine = null; + } +} diff --git a/planet.sh b/planet.sh index fc10341e..63c44dc4 100755 --- a/planet.sh +++ b/planet.sh @@ -25,13 +25,19 @@ start() { PYTHONPATH="$SCRIPT_DIR/backend" nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload > /tmp/planet_backend.log 2>&1 & BACKEND_PID=$! - sleep 3 - - if ! curl -s http://localhost:8000/health > /dev/null 2>&1; then - echo -e "${RED}❌ 后端启动失败${NC}" - tail -10 /tmp/planet_backend.log - exit 1 - fi + echo " 等待后端启动..." + for i in {1..10}; do + sleep 2 + if curl -s http://localhost:8000/health > /dev/null 2>&1; then + echo -e " ${GREEN}✅ 后端已就绪${NC}" + break + fi + if [ $i -eq 10 ]; then + echo -e "${RED}❌ 后端启动失败${NC}" + tail -10 /tmp/planet_backend.log + exit 1 + fi + done echo -e "${BLUE}🌐 启动前端...${NC}" pkill -f "vite" 2>/dev/null || true From 543fe35fbb3c6c2f19e1ca20dbcabe11dc539d0b Mon Sep 17 00:00:00 2001 From: linkong Date: Mon, 23 Mar 2026 17:41:27 +0800 Subject: [PATCH 3/9] fix(satellites): fix ring size attenuation and breathing animation - Add sizeAttenuation: false to sprite materials for fixed ring size - Move breathing animation parameters to SATELLITE_CONFIG constants - Export updateBreathingPhase function to avoid ES module binding issues - Adjust breathing speed and amplitude for better visual effect --- frontend/public/earth/js/constants.js | 17 ++- frontend/public/earth/js/main.js | 39 +++++- frontend/public/earth/js/satellites.js | 164 +++++++++++++++++++------ 3 files changed, 174 insertions(+), 46 deletions(-) diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js index ea94bbd8..fbb027a7 100644 --- a/frontend/public/earth/js/constants.js +++ b/frontend/public/earth/js/constants.js @@ -54,10 +54,19 @@ export const CABLE_STATE = { }; export const SATELLITE_CONFIG = { - maxCount: 2000, - dotSize: 1.5, - trailLength: 30, - apiPath: '/api/v1/visualization/geo/satellites' + maxCount: 5000, + trailLength: 10, + dotSize: 4, + ringSize: 0.07, + apiPath: '/api/v1/visualization/geo/satellites', + breathingSpeed: 0.08, + breathingScaleAmplitude: 0.15, + breathingOpacityMin: 0.5, + breathingOpacityMax: 0.8, + dotBreathingSpeed: 0.12, + dotBreathingScaleAmplitude: 0.2, + dotOpacityMin: 0.7, + dotOpacityMax: 1.0 }; export const PREDICTED_ORBIT_CONFIG = { diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index 4d7c287b..e5ced5df 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -14,7 +14,7 @@ import { } from './ui.js'; import { createEarth, createClouds, createTerrain, createStars, createGridLines, toggleTerrain, getEarth } from './earth.js'; import { loadGeoJSONFromPath, loadLandingPoints, handleCableClick, clearCableSelection, getCableLines, getCablesById, lockedCable as cableLocked, getCableState, setCableState, clearAllCableStates, applyLandingPointVisualState, resetLandingPointVisualState, getAllLandingPoints, getShowCables } from './cables.js'; -import { createSatellites, loadSatellites, updateSatellitePositions, toggleSatellites, toggleTrails, getShowSatellites, getSatelliteCount, selectSatellite, getSatelliteData, getSatellitePoints, setSatelliteRingState, updateLockedRingPosition, updateHoverRingPosition, getSatellitePositions, showPredictedOrbit, hidePredictedOrbit } from './satellites.js'; +import { createSatellites, loadSatellites, updateSatellitePositions, toggleSatellites, toggleTrails, getShowSatellites, getSatelliteCount, selectSatellite, getSatelliteData, getSatellitePoints, setSatelliteRingState, updateLockedRingPosition, updateHoverRingPosition, getSatellitePositions, showPredictedOrbit, hidePredictedOrbit, updateBreathingPhase } from './satellites.js'; import { setupControls, getAutoRotate, getShowTerrain, zoomLevel, setAutoRotate, toggleAutoRotate, resetView } from './controls.js'; import { initInfoCard, showInfoCard, hideInfoCard, getCurrentType, setInfoCardNoBorder } from './info-card.js'; @@ -32,6 +32,9 @@ let lockedObject = null; let lockedObjectType = null; let dragStartTime = 0; let isLongDrag = false; +let lastSatClickTime = 0; +let lastSatClickIndex = 0; +let lastSatClickPos = { x: 0, y: 0 }; export function clearLockedObject() { hidePredictedOrbit(); @@ -44,6 +47,7 @@ export function clearLockedObject() { lockedObjectType = null; lockedSatellite = null; lockedSatelliteIndex = null; + window.lockedSatelliteIndex = null; cableLockedData = null; } @@ -135,6 +139,7 @@ export function init() { camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.z = CONFIG.defaultCameraZ; + window.camera = camera; renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' }); renderer.setSize(window.innerWidth, window.innerHeight); @@ -430,8 +435,26 @@ function onClick(event, camera, renderer) { setAutoRotate(false); handleCableClick(clickedCable); } else if (satIntersects.length > 0) { - const index = satIntersects[0].index; - const sat = selectSatellite(index); + const now = Date.now(); + const clickX = event.clientX; + const clickY = event.clientY; + + let selectedIndex; + if (satIntersects.length > 1 && + now - lastSatClickTime < 500 && + Math.abs(clickX - lastSatClickPos.x) < 30 && + Math.abs(clickY - lastSatClickPos.y) < 30) { + const currentIdx = satIntersects.findIndex(s => s.index === lastSatClickIndex); + selectedIndex = satIntersects[(currentIdx + 1) % satIntersects.length].index; + } else { + selectedIndex = satIntersects[0].index; + } + + lastSatClickTime = now; + lastSatClickIndex = selectedIndex; + lastSatClickPos = { x: clickX, y: clickY }; + + const sat = selectSatellite(selectedIndex); if (sat && sat.properties) { clearLockedObject(); @@ -439,13 +462,14 @@ function onClick(event, camera, renderer) { lockedObject = sat; lockedObjectType = 'satellite'; lockedSatellite = sat; - lockedSatelliteIndex = index; + lockedSatelliteIndex = selectedIndex; + window.lockedSatelliteIndex = selectedIndex; showPredictedOrbit(sat); setAutoRotate(false); const satPositions = getSatellitePositions(); - if (satPositions && satPositions[index]) { - setSatelliteRingState(index, 'locked', satPositions[index].current); + if (satPositions && satPositions[selectedIndex]) { + setSatelliteRingState(selectedIndex, 'locked', satPositions[selectedIndex].current); } const props = sat.properties; @@ -501,6 +525,9 @@ function animate() { const satPositions = getSatellitePositions(); + // 更新呼吸动画相位 + updateBreathingPhase(); + if (lockedObjectType === 'satellite' && lockedSatelliteIndex !== null) { if (satPositions && satPositions[lockedSatelliteIndex]) { updateLockedRingPosition(satPositions[lockedSatelliteIndex].current); diff --git a/frontend/public/earth/js/satellites.js b/frontend/public/earth/js/satellites.js index 5f0fc5be..b4af0255 100644 --- a/frontend/public/earth/js/satellites.js +++ b/frontend/public/earth/js/satellites.js @@ -14,13 +14,19 @@ let selectedSatellite = null; let satellitePositions = []; let hoverRingSprite = null; let lockedRingSprite = null; +let lockedDotSprite = null; +export let breathingPhase = 0; + +export function updateBreathingPhase() { + breathingPhase += SATELLITE_CONFIG.breathingSpeed; +} const SATELLITE_API = SATELLITE_CONFIG.apiPath + '?limit=' + SATELLITE_CONFIG.maxCount; const MAX_SATELLITES = SATELLITE_CONFIG.maxCount; const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength; const DOT_TEXTURE_SIZE = 32; -function createCircularDotTexture() { +function createDotTexture() { const canvas = document.createElement('canvas'); canvas.width = DOT_TEXTURE_SIZE; canvas.height = DOT_TEXTURE_SIZE; @@ -68,7 +74,7 @@ export function createSatellites(scene, earthObj) { const positions = new Float32Array(MAX_SATELLITES * 3); const colors = new Float32Array(MAX_SATELLITES * 3); - const dotTexture = createCircularDotTexture(); + const dotTexture = createDotTexture(); const pointsGeometry = new THREE.BufferGeometry(); pointsGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); @@ -80,13 +86,27 @@ export function createSatellites(scene, earthObj) { vertexColors: true, transparent: true, opacity: 0.9, - sizeAttenuation: true, + sizeAttenuation: false, alphaTest: 0.1 }); satellitePoints = new THREE.Points(pointsGeometry, pointsMaterial); satellitePoints.visible = false; satellitePoints.userData = { type: 'satellitePoints' }; + + const originalScale = { x: 1, y: 1, z: 1 }; + satellitePoints.onBeforeRender = (renderer, scene, camera, geometry, material) => { + if (earthObj && earthObj.scale.x !== 1) { + satellitePoints.scale.set( + originalScale.x / earthObj.scale.x, + originalScale.y / earthObj.scale.y, + originalScale.z / earthObj.scale.z + ); + } else { + satellitePoints.scale.set(originalScale.x, originalScale.y, originalScale.z); + } + }; + earthObj.add(satellitePoints); const trailPositions = new Float32Array(MAX_SATELLITES * TRAIL_LENGTH * 3); @@ -112,7 +132,9 @@ export function createSatellites(scene, earthObj) { for (let i = 0; i < MAX_SATELLITES; i++) { satellitePositions.push({ current: new THREE.Vector3(), - trail: [] + trail: [], + trailIndex: 0, + trailCount: 0 }); } @@ -149,12 +171,8 @@ function computeSatellitePosition(satellite, time) { const tleLine1 = `1 ${noradId.toString().padStart(5)}U 00001A ${epochStr} .00000000 00000-0 00000-0 0 9999`; const tleLine2 = `2 ${noradId.toString().padStart(5)} ${raan.toFixed(4).padStart(8)} ${inclination.toFixed(4).padStart(8)} ${eccStr.substring(1)} ${argOfPerigee.toFixed(4).padStart(8)} ${meanAnomaly.toFixed(4).padStart(8)} ${meanMotion.toFixed(8).padStart(11)} 0 9999`; - console.log('[DEBUG computeSat] TLE1:', tleLine1); - console.log('[DEBUG computeSat] TLE2:', tleLine2); const satrec = twoline2satrec(tleLine1, tleLine2); - console.log('[DEBUG computeSat] satrec.error:', satrec?.error, 'satrec.no:', satrec?.no); if (!satrec || satrec.error) { - console.log('[DEBUG computeSat] returning null due to satrec error'); return null; } @@ -255,9 +273,11 @@ export function updateSatellitePositions(deltaTime = 0) { satellitePositions[i].current.copy(pos); - satellitePositions[i].trail.push(pos.clone()); - if (satellitePositions[i].trail.length > TRAIL_LENGTH) { - satellitePositions[i].trail.shift(); + const satPos = satellitePositions[i]; + if (i !== window.lockedSatelliteIndex) { + satPos.trail[satPos.trailIndex] = pos.clone(); + satPos.trailIndex = (satPos.trailIndex + 1) % TRAIL_LENGTH; + if (satPos.trailCount < TRAIL_LENGTH) satPos.trailCount++; } positions[i * 3] = pos.x; @@ -287,20 +307,33 @@ export function updateSatellitePositions(deltaTime = 0) { colors[i * 3 + 1] = g; colors[i * 3 + 2] = b; - const trail = satellitePositions[i].trail; + const sp = satellitePositions[i]; + const trail = sp.trail; + const tc = sp.trailCount; + const ti = sp.trailIndex; + for (let j = 0; j < TRAIL_LENGTH; j++) { const trailIdx = (i * TRAIL_LENGTH + j) * 3; - if (j < trail.length) { - const t = trail[j]; - trailPositions[trailIdx] = t.x; - trailPositions[trailIdx + 1] = t.y; - trailPositions[trailIdx + 2] = t.z; - - const alpha = j / trail.length; - trailColors[trailIdx] = r * alpha; - trailColors[trailIdx + 1] = g * alpha; - trailColors[trailIdx + 2] = b * alpha; + if (j < tc) { + const idx = (ti - tc + j + TRAIL_LENGTH) % TRAIL_LENGTH; + const t = trail[idx]; + if (t) { + trailPositions[trailIdx] = t.x; + trailPositions[trailIdx + 1] = t.y; + trailPositions[trailIdx + 2] = t.z; + const alpha = (j + 1) / tc; + trailColors[trailIdx] = r * alpha; + trailColors[trailIdx + 1] = g * alpha; + trailColors[trailIdx + 2] = b * alpha; + } else { + trailPositions[trailIdx] = pos.x; + trailPositions[trailIdx + 1] = pos.y; + trailPositions[trailIdx + 2] = pos.z; + trailColors[trailIdx] = 0; + trailColors[trailIdx + 1] = 0; + trailColors[trailIdx + 2] = 0; + } } else { trailPositions[trailIdx] = pos.x; trailPositions[trailIdx + 1] = pos.y; @@ -312,7 +345,7 @@ export function updateSatellitePositions(deltaTime = 0) { } } - for (let i = count; i < 2000; i++) { + for (let i = count; i < MAX_SATELLITES; i++) { positions[i * 3] = 0; positions[i * 3 + 1] = 0; positions[i * 3 + 2] = 0; @@ -393,12 +426,19 @@ export function showHoverRing(position, isLocked = false) { map: ringTexture, transparent: true, opacity: 0.8, - depthTest: false + depthTest: false, + sizeAttenuation: false }); + const ringSize = SATELLITE_CONFIG.ringSize; const sprite = new THREE.Sprite(spriteMaterial); sprite.position.copy(position); - sprite.scale.set(3, 3, 1); + + const camera = window.camera; + const cameraDistance = camera ? camera.position.distanceTo(position) : 400; + const scale = ringSize; + sprite.scale.set(scale, scale, 1); + console.log(`[Ring create] ringSize: ${ringSize}, camDist: ${cameraDistance}, scale: ${scale}`); earthObjRef.add(sprite); @@ -407,6 +447,24 @@ export function showHoverRing(position, isLocked = false) { earthObjRef.remove(lockedRingSprite); } lockedRingSprite = sprite; + + if (lockedDotSprite) { + earthObjRef.remove(lockedDotSprite); + } + const dotCanvas = createBrighterDotCanvas(); + const dotTexture = new THREE.CanvasTexture(dotCanvas); + dotTexture.needsUpdate = true; + const dotMaterial = new THREE.SpriteMaterial({ + map: dotTexture, + transparent: true, + opacity: 1.0, + depthTest: false + }); + lockedDotSprite = new THREE.Sprite(dotMaterial); + lockedDotSprite.position.copy(position); + lockedDotSprite.scale.set(4 * cameraDistance / 200, 4 * cameraDistance / 200, 1); + + earthObjRef.add(lockedDotSprite); } else { if (hoverRingSprite) { earthObjRef.remove(hoverRingSprite); @@ -417,6 +475,23 @@ export function showHoverRing(position, isLocked = false) { return sprite; } +function createBrighterDotCanvas() { + const size = DOT_TEXTURE_SIZE * 2; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + const center = size / 2; + const gradient = ctx.createRadialGradient(center, center, 0, center, center, center); + gradient.addColorStop(0, 'rgba(255, 255, 200, 1)'); + gradient.addColorStop(0.3, 'rgba(255, 220, 100, 0.9)'); + gradient.addColorStop(0.7, 'rgba(255, 180, 50, 0.5)'); + gradient.addColorStop(1, 'rgba(255, 150, 0, 0)'); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, size, size); + return canvas; +} + export function hideHoverRings() { if (!earthObjRef) return; @@ -427,20 +502,45 @@ export function hideHoverRings() { } export function hideLockedRing() { - if (!earthObjRef || !lockedRingSprite) return; - earthObjRef.remove(lockedRingSprite); - lockedRingSprite = null; + if (!earthObjRef) return; + if (lockedRingSprite) { + earthObjRef.remove(lockedRingSprite); + lockedRingSprite = null; + } + if (lockedDotSprite) { + earthObjRef.remove(lockedDotSprite); + lockedDotSprite = null; + } } export function updateLockedRingPosition(position) { + const ringSize = SATELLITE_CONFIG.ringSize; + const camera = window.camera; + const cameraDistance = camera ? camera.position.distanceTo(position) : 400; if (lockedRingSprite && position) { lockedRingSprite.position.copy(position); + const breathScale = 1 + Math.sin(breathingPhase) * SATELLITE_CONFIG.breathingScaleAmplitude; + lockedRingSprite.scale.set(ringSize * breathScale, ringSize * breathScale, 1); + const breathOpacity = SATELLITE_CONFIG.breathingOpacityMin + Math.sin(breathingPhase) * (SATELLITE_CONFIG.breathingOpacityMax - SATELLITE_CONFIG.breathingOpacityMin); + lockedRingSprite.material.opacity = breathOpacity; + } + if (lockedDotSprite && position) { + lockedDotSprite.position.copy(position); + const dotBreathScale = 1 + Math.sin(breathingPhase) * SATELLITE_CONFIG.dotBreathingScaleAmplitude; + lockedDotSprite.scale.set(4 * cameraDistance / 200 * dotBreathScale, 4 * cameraDistance / 200 * dotBreathScale, 1); + lockedDotSprite.material.opacity = SATELLITE_CONFIG.dotOpacityMin + Math.sin(breathingPhase) * (SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin); } } export function updateHoverRingPosition(position) { + const ringSize = SATELLITE_CONFIG.ringSize; + const camera = window.camera; + const cameraDistance = camera ? camera.position.distanceTo(position) : 400; + const scale = ringSize; if (hoverRingSprite && position) { hoverRingSprite.position.copy(position); + hoverRingSprite.scale.set(scale, scale, 1); + console.log(`[Hover update] ringSize: ${ringSize}, camDist: ${cameraDistance}, scale: ${scale}`); } } @@ -512,16 +612,8 @@ export function showPredictedOrbit(satellite) { const props = satellite.properties; const meanMotion = props?.mean_motion || 15; const periodSeconds = calculateOrbitalPeriod(meanMotion); - console.log('[DEBUG] meanMotion:', meanMotion, 'periodSeconds:', periodSeconds); - - // Test current time - const now = new Date(); - const testPos = computeSatellitePosition(satellite, now); - console.log('[DEBUG] testPos (now):', testPos); const points = calculatePredictedOrbit(satellite, periodSeconds); - console.log('[DEBUG] points.length:', points.length); - if (points.length < 2) return; const positions = new Float32Array(points.length * 3); From b9fbacade7bac2061a2012980b8646b2d66875fc Mon Sep 17 00:00:00 2001 From: linkong Date: Tue, 24 Mar 2026 10:44:06 +0800 Subject: [PATCH 4/9] fix(satellites): prevent selecting satellites on far side of earth - Add isSatelliteFrontFacing() to detect if satellite is on visible side - Filter satellites in hover and click handlers by front-facing check - Apply same logic as cables for consistent back-face culling --- frontend/public/earth/js/main.js | 20 +++++++++++++------- frontend/public/earth/js/satellites.js | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index e5ced5df..3c1c08c0 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -14,7 +14,7 @@ import { } from './ui.js'; import { createEarth, createClouds, createTerrain, createStars, createGridLines, toggleTerrain, getEarth } from './earth.js'; import { loadGeoJSONFromPath, loadLandingPoints, handleCableClick, clearCableSelection, getCableLines, getCablesById, lockedCable as cableLocked, getCableState, setCableState, clearAllCableStates, applyLandingPointVisualState, resetLandingPointVisualState, getAllLandingPoints, getShowCables } from './cables.js'; -import { createSatellites, loadSatellites, updateSatellitePositions, toggleSatellites, toggleTrails, getShowSatellites, getSatelliteCount, selectSatellite, getSatelliteData, getSatellitePoints, setSatelliteRingState, updateLockedRingPosition, updateHoverRingPosition, getSatellitePositions, showPredictedOrbit, hidePredictedOrbit, updateBreathingPhase } from './satellites.js'; +import { createSatellites, loadSatellites, updateSatellitePositions, toggleSatellites, toggleTrails, getShowSatellites, getSatelliteCount, selectSatellite, getSatelliteData, getSatellitePoints, setSatelliteRingState, updateLockedRingPosition, updateHoverRingPosition, getSatellitePositions, showPredictedOrbit, hidePredictedOrbit, updateBreathingPhase, isSatelliteFrontFacing } from './satellites.js'; import { setupControls, getAutoRotate, getShowTerrain, zoomLevel, setAutoRotate, toggleAutoRotate, resetView } from './controls.js'; import { initInfoCard, showInfoCard, hideInfoCard, getCurrentType, setInfoCardNoBorder } from './info-card.js'; @@ -301,8 +301,11 @@ function onMouseMove(event, camera) { if (satPoints) { const satIntersects = raycaster.intersectObject(satPoints); if (satIntersects.length > 0) { - hoveredSatIndexFromIntersect = satIntersects[0].index; - hoveredSat = selectSatellite(hoveredSatIndexFromIntersect); + const satIndex = satIntersects[0].index; + if (isSatelliteFrontFacing(satIndex, camera)) { + hoveredSatIndexFromIntersect = satIndex; + hoveredSat = selectSatellite(hoveredSatIndexFromIntersect); + } } } } @@ -440,14 +443,17 @@ function onClick(event, camera, renderer) { const clickY = event.clientY; let selectedIndex; - if (satIntersects.length > 1 && + const frontFacingSats = satIntersects.filter(s => isSatelliteFrontFacing(s.index, camera)); + if (frontFacingSats.length === 0) return; + + if (frontFacingSats.length > 1 && now - lastSatClickTime < 500 && Math.abs(clickX - lastSatClickPos.x) < 30 && Math.abs(clickY - lastSatClickPos.y) < 30) { - const currentIdx = satIntersects.findIndex(s => s.index === lastSatClickIndex); - selectedIndex = satIntersects[(currentIdx + 1) % satIntersects.length].index; + const currentIdx = frontFacingSats.findIndex(s => s.index === lastSatClickIndex); + selectedIndex = frontFacingSats[(currentIdx + 1) % frontFacingSats.length].index; } else { - selectedIndex = satIntersects[0].index; + selectedIndex = frontFacingSats[0].index; } lastSatClickTime = now; diff --git a/frontend/public/earth/js/satellites.js b/frontend/public/earth/js/satellites.js index b4af0255..909733f9 100644 --- a/frontend/public/earth/js/satellites.js +++ b/frontend/public/earth/js/satellites.js @@ -415,6 +415,20 @@ export function getSatellitePositions() { return satellitePositions; } +export function isSatelliteFrontFacing(index, camera) { + if (!earthObjRef || !camera) return true; + const positions = satellitePositions; + if (!positions || !positions[index]) return true; + + const satPos = positions[index].current; + if (!satPos) return true; + + const toCamera = new THREE.Vector3().subVectors(camera.position, earthObjRef.position).normalize(); + const toSat = new THREE.Vector3().subVectors(satPos, earthObjRef.position).normalize(); + + return toCamera.dot(toSat) < 0; +} + let earthObjRef = null; let sceneRef = null; From b57d69c98b0674114aaba1829df05452b05daf16 Mon Sep 17 00:00:00 2001 From: linkong Date: Tue, 24 Mar 2026 11:40:10 +0800 Subject: [PATCH 5/9] fix(satellites): remove debug console.log for ring create/update Also ensures back-facing satellite selection prevention is in place --- frontend/public/earth/js/satellites.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/public/earth/js/satellites.js b/frontend/public/earth/js/satellites.js index 909733f9..95a733f1 100644 --- a/frontend/public/earth/js/satellites.js +++ b/frontend/public/earth/js/satellites.js @@ -452,7 +452,6 @@ export function showHoverRing(position, isLocked = false) { const cameraDistance = camera ? camera.position.distanceTo(position) : 400; const scale = ringSize; sprite.scale.set(scale, scale, 1); - console.log(`[Ring create] ringSize: ${ringSize}, camDist: ${cameraDistance}, scale: ${scale}`); earthObjRef.add(sprite); @@ -554,7 +553,6 @@ export function updateHoverRingPosition(position) { if (hoverRingSprite && position) { hoverRingSprite.position.copy(position); hoverRingSprite.scale.set(scale, scale, 1); - console.log(`[Hover update] ringSize: ${ringSize}, camDist: ${cameraDistance}, scale: ${scale}`); } } From 81a0ca5e7ab0c41ee51808fd17c826906bf85075 Mon Sep 17 00:00:00 2001 From: linkong Date: Tue, 24 Mar 2026 12:10:52 +0800 Subject: [PATCH 6/9] fix(satellites): fix back-facing detection with proper coordinate transform --- frontend/public/earth/js/satellites.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/public/earth/js/satellites.js b/frontend/public/earth/js/satellites.js index 95a733f1..597ed25a 100644 --- a/frontend/public/earth/js/satellites.js +++ b/frontend/public/earth/js/satellites.js @@ -423,10 +423,11 @@ export function isSatelliteFrontFacing(index, camera) { const satPos = positions[index].current; if (!satPos) return true; + const worldSatPos = satPos.clone().applyMatrix4(earthObjRef.matrixWorld); const toCamera = new THREE.Vector3().subVectors(camera.position, earthObjRef.position).normalize(); - const toSat = new THREE.Vector3().subVectors(satPos, earthObjRef.position).normalize(); + const toSat = new THREE.Vector3().subVectors(worldSatPos, earthObjRef.position).normalize(); - return toCamera.dot(toSat) < 0; + return toCamera.dot(toSat) > 0; } let earthObjRef = null; From ef0fefdfc7033175280e9cca5da7221f764551a1 Mon Sep 17 00:00:00 2001 From: rayd1o Date: Wed, 25 Mar 2026 02:57:58 +0800 Subject: [PATCH 7/9] feat: persist system settings and refine admin layouts --- backend/app/api/v1/datasources.py | 402 ++++------- backend/app/api/v1/settings.py | 178 ++++- backend/app/core/datasource_defaults.py | 126 ++++ backend/app/db/session.py | 41 ++ backend/app/main.py | 20 +- backend/app/models/__init__.py | 6 +- backend/app/models/system_setting.py | 19 + backend/app/services/scheduler.py | 206 +++--- docs/system-settings-plan.md | 47 ++ frontend/src/App.tsx | 4 +- .../src/components/AppLayout/AppLayout.tsx | 84 ++- frontend/src/index.css | 667 +++++++++++++++++- frontend/src/pages/Alerts/Alerts.tsx | 4 +- frontend/src/pages/Dashboard/Dashboard.tsx | 165 ++--- frontend/src/pages/DataList/DataList.tsx | 589 +++++++++------- .../src/pages/DataSources/DataSources.tsx | 98 ++- frontend/src/pages/Settings/Settings.tsx | 648 ++++++++--------- frontend/src/pages/Tasks/Tasks.tsx | 4 +- frontend/src/pages/Users/Users.tsx | 14 +- 19 files changed, 2091 insertions(+), 1231 deletions(-) create mode 100644 backend/app/core/datasource_defaults.py create mode 100644 backend/app/models/system_setting.py create mode 100644 docs/system-settings-plan.md diff --git a/backend/app/api/v1/datasources.py b/backend/app/api/v1/datasources.py index 51c04353..d4b73889 100644 --- a/backend/app/api/v1/datasources.py +++ b/backend/app/api/v1/datasources.py @@ -1,155 +1,66 @@ -from typing import List, Optional -from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy import select, func +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.core.security import get_current_user from app.db.session import get_db -from app.models.user import User +from app.models.collected_data import CollectedData from app.models.datasource import DataSource from app.models.task import CollectionTask -from app.models.collected_data import CollectedData -from app.core.security import get_current_user -from app.services.collectors.registry import collector_registry +from app.models.user import User +from app.services.scheduler import run_collector_now, sync_datasource_job router = APIRouter() -COLLECTOR_INFO = { - "top500": { - "id": 1, - "name": "TOP500 Supercomputers", - "module": "L1", - "priority": "P0", - "frequency_hours": 4, - }, - "epoch_ai_gpu": { - "id": 2, - "name": "Epoch AI GPU Clusters", - "module": "L1", - "priority": "P0", - "frequency_hours": 6, - }, - "huggingface_models": { - "id": 3, - "name": "HuggingFace Models", - "module": "L2", - "priority": "P1", - "frequency_hours": 12, - }, - "huggingface_datasets": { - "id": 4, - "name": "HuggingFace Datasets", - "module": "L2", - "priority": "P1", - "frequency_hours": 12, - }, - "huggingface_spaces": { - "id": 5, - "name": "HuggingFace Spaces", - "module": "L2", - "priority": "P2", - "frequency_hours": 24, - }, - "peeringdb_ixp": { - "id": 6, - "name": "PeeringDB IXP", - "module": "L2", - "priority": "P1", - "frequency_hours": 24, - }, - "peeringdb_network": { - "id": 7, - "name": "PeeringDB Networks", - "module": "L2", - "priority": "P2", - "frequency_hours": 48, - }, - "peeringdb_facility": { - "id": 8, - "name": "PeeringDB Facilities", - "module": "L2", - "priority": "P2", - "frequency_hours": 48, - }, - "telegeography_cables": { - "id": 9, - "name": "Submarine Cables", - "module": "L2", - "priority": "P1", - "frequency_hours": 168, - }, - "telegeography_landing": { - "id": 10, - "name": "Cable Landing Points", - "module": "L2", - "priority": "P2", - "frequency_hours": 168, - }, - "telegeography_systems": { - "id": 11, - "name": "Cable Systems", - "module": "L2", - "priority": "P2", - "frequency_hours": 168, - }, - "arcgis_cables": { - "id": 15, - "name": "ArcGIS Submarine Cables", - "module": "L2", - "priority": "P1", - "frequency_hours": 168, - }, - "arcgis_landing_points": { - "id": 16, - "name": "ArcGIS Landing Points", - "module": "L2", - "priority": "P1", - "frequency_hours": 168, - }, - "arcgis_cable_landing_relation": { - "id": 17, - "name": "ArcGIS Cable-Landing Relations", - "module": "L2", - "priority": "P1", - "frequency_hours": 168, - }, - "fao_landing_points": { - "id": 18, - "name": "FAO Landing Points", - "module": "L2", - "priority": "P1", - "frequency_hours": 168, - }, - "spacetrack_tle": { - "id": 19, - "name": "Space-Track TLE", - "module": "L3", - "priority": "P2", - "frequency_hours": 24, - }, - "celestrak_tle": { - "id": 20, - "name": "CelesTrak TLE", - "module": "L3", - "priority": "P2", - "frequency_hours": 24, - }, -} -ID_TO_COLLECTOR = {info["id"]: name for name, info in COLLECTOR_INFO.items()} -COLLECTOR_TO_ID = {name: info["id"] for name, info in COLLECTOR_INFO.items()} +def format_frequency_label(minutes: int) -> str: + if minutes % 1440 == 0: + return f"{minutes // 1440}d" + if minutes % 60 == 0: + return f"{minutes // 60}h" + return f"{minutes}m" -def get_collector_name(source_id: str) -> Optional[str]: +async def get_datasource_record(db: AsyncSession, source_id: str) -> Optional[DataSource]: + datasource = None try: - numeric_id = int(source_id) - if numeric_id in ID_TO_COLLECTOR: - return ID_TO_COLLECTOR[numeric_id] + datasource = await db.get(DataSource, int(source_id)) except ValueError: pass - if source_id in COLLECTOR_INFO: - return source_id - return None + + if datasource is not None: + return datasource + + result = await db.execute( + select(DataSource).where( + (DataSource.source == source_id) | (DataSource.collector_class == source_id) + ) + ) + return result.scalar_one_or_none() + + +async def get_last_completed_task(db: AsyncSession, datasource_id: int) -> Optional[CollectionTask]: + result = await db.execute( + select(CollectionTask) + .where(CollectionTask.datasource_id == datasource_id) + .where(CollectionTask.completed_at.isnot(None)) + .order_by(CollectionTask.completed_at.desc()) + .limit(1) + ) + return result.scalar_one_or_none() + + +async def get_running_task(db: AsyncSession, datasource_id: int) -> Optional[CollectionTask]: + result = await db.execute( + select(CollectionTask) + .where(CollectionTask.datasource_id == datasource_id) + .where(CollectionTask.status == "running") + .order_by(CollectionTask.started_at.desc()) + .limit(1) + ) + return result.scalar_one_or_none() @router.get("") @@ -160,48 +71,24 @@ async def list_datasources( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - query = select(DataSource) - - filters = [] + query = select(DataSource).order_by(DataSource.module, DataSource.id) if module: - filters.append(DataSource.module == module) + query = query.where(DataSource.module == module) if is_active is not None: - filters.append(DataSource.is_active == is_active) + query = query.where(DataSource.is_active == is_active) if priority: - filters.append(DataSource.priority == priority) - - if filters: - query = query.where(*filters) + query = query.where(DataSource.priority == priority) result = await db.execute(query) datasources = result.scalars().all() collector_list = [] - for name, info in COLLECTOR_INFO.items(): - is_active_status = collector_registry.is_active(name) - - running_task_query = ( - select(CollectionTask) - .where(CollectionTask.datasource_id == info["id"]) - .where(CollectionTask.status == "running") - .order_by(CollectionTask.started_at.desc()) - .limit(1) + for datasource in datasources: + running_task = await get_running_task(db, datasource.id) + last_task = await get_last_completed_task(db, datasource.id) + data_count_result = await db.execute( + select(func.count(CollectedData.id)).where(CollectedData.source == datasource.source) ) - running_result = await db.execute(running_task_query) - running_task = running_result.scalar_one_or_none() - - last_run_query = ( - select(CollectionTask) - .where(CollectionTask.datasource_id == info["id"]) - .where(CollectionTask.completed_at.isnot(None)) - .order_by(CollectionTask.completed_at.desc()) - .limit(1) - ) - last_run_result = await db.execute(last_run_query) - last_task = last_run_result.scalar_one_or_none() - - data_count_query = select(func.count(CollectedData.id)).where(CollectedData.source == name) - data_count_result = await db.execute(data_count_query) data_count = data_count_result.scalar() or 0 last_run = None @@ -210,13 +97,14 @@ async def list_datasources( collector_list.append( { - "id": info["id"], - "name": info["name"], - "module": info["module"], - "priority": info["priority"], - "frequency": f"{info['frequency_hours']}h", - "is_active": is_active_status, - "collector_class": name, + "id": datasource.id, + "name": datasource.name, + "module": datasource.module, + "priority": datasource.priority, + "frequency": format_frequency_label(datasource.frequency_minutes), + "frequency_minutes": datasource.frequency_minutes, + "is_active": datasource.is_active, + "collector_class": datasource.collector_class, "last_run": last_run, "is_running": running_task is not None, "task_id": running_task.id if running_task else None, @@ -226,15 +114,7 @@ async def list_datasources( } ) - if module: - collector_list = [c for c in collector_list if c["module"] == module] - if priority: - collector_list = [c for c in collector_list if c["priority"] == priority] - - return { - "total": len(collector_list), - "data": collector_list, - } + return {"total": len(collector_list), "data": collector_list} @router.get("/{source_id}") @@ -243,19 +123,20 @@ async def get_datasource( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - collector_name = get_collector_name(source_id) - if not collector_name: + datasource = await get_datasource_record(db, source_id) + if not datasource: raise HTTPException(status_code=404, detail="Data source not found") - info = COLLECTOR_INFO[collector_name] return { - "id": info["id"], - "name": info["name"], - "module": info["module"], - "priority": info["priority"], - "frequency": f"{info['frequency_hours']}h", - "collector_class": collector_name, - "is_active": collector_registry.is_active(collector_name), + "id": datasource.id, + "name": datasource.name, + "module": datasource.module, + "priority": datasource.priority, + "frequency": format_frequency_label(datasource.frequency_minutes), + "frequency_minutes": datasource.frequency_minutes, + "collector_class": datasource.collector_class, + "source": datasource.source, + "is_active": datasource.is_active, } @@ -263,24 +144,32 @@ async def get_datasource( async def enable_datasource( source_id: str, current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), ): - collector_name = get_collector_name(source_id) - if not collector_name: + datasource = await get_datasource_record(db, source_id) + if not datasource: raise HTTPException(status_code=404, detail="Data source not found") - collector_registry.set_active(collector_name, True) - return {"status": "enabled", "source_id": source_id} + + datasource.is_active = True + await db.commit() + await sync_datasource_job(datasource.id) + return {"status": "enabled", "source_id": datasource.id} @router.post("/{source_id}/disable") async def disable_datasource( source_id: str, current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), ): - collector_name = get_collector_name(source_id) - if not collector_name: + datasource = await get_datasource_record(db, source_id) + if not datasource: raise HTTPException(status_code=404, detail="Data source not found") - collector_registry.set_active(collector_name, False) - return {"status": "disabled", "source_id": source_id} + + datasource.is_active = False + await db.commit() + await sync_datasource_job(datasource.id) + return {"status": "disabled", "source_id": datasource.id} @router.get("/{source_id}/stats") @@ -289,26 +178,19 @@ async def get_datasource_stats( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - collector_name = get_collector_name(source_id) - if not collector_name: + datasource = await get_datasource_record(db, source_id) + if not datasource: raise HTTPException(status_code=404, detail="Data source not found") - info = COLLECTOR_INFO[collector_name] - source_name = info["name"] - - query = select(func.count(CollectedData.id)).where(CollectedData.source == collector_name) - result = await db.execute(query) + result = await db.execute( + select(func.count(CollectedData.id)).where(CollectedData.source == datasource.source) + ) total = result.scalar() or 0 - if total == 0: - query = select(func.count(CollectedData.id)).where(CollectedData.source == source_name) - result = await db.execute(query) - total = result.scalar() or 0 - return { - "source_id": source_id, - "collector_name": collector_name, - "name": info["name"], + "source_id": datasource.id, + "collector_name": datasource.collector_class, + "name": datasource.name, "total_records": total, } @@ -317,30 +199,25 @@ async def get_datasource_stats( async def trigger_datasource( source_id: str, current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), ): - collector_name = get_collector_name(source_id) - if not collector_name: + datasource = await get_datasource_record(db, source_id) + if not datasource: raise HTTPException(status_code=404, detail="Data source not found") - from app.services.scheduler import run_collector_now - - if not collector_registry.is_active(collector_name): + if not datasource.is_active: raise HTTPException(status_code=400, detail="Data source is disabled") - success = run_collector_now(collector_name) + success = run_collector_now(datasource.source) + if not success: + raise HTTPException(status_code=500, detail=f"Failed to trigger collector '{datasource.source}'") - if success: - return { - "status": "triggered", - "source_id": source_id, - "collector_name": collector_name, - "message": f"Collector '{collector_name}' has been triggered", - } - else: - raise HTTPException( - status_code=500, - detail=f"Failed to trigger collector '{collector_name}'", - ) + return { + "status": "triggered", + "source_id": datasource.id, + "collector_name": datasource.source, + "message": f"Collector '{datasource.source}' has been triggered", + } @router.delete("/{source_id}/data") @@ -349,39 +226,25 @@ async def clear_datasource_data( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - collector_name = get_collector_name(source_id) - if not collector_name: + datasource = await get_datasource_record(db, source_id) + if not datasource: raise HTTPException(status_code=404, detail="Data source not found") - info = COLLECTOR_INFO[collector_name] - source_name = info["name"] - - query = select(func.count(CollectedData.id)).where(CollectedData.source == collector_name) - result = await db.execute(query) + result = await db.execute( + select(func.count(CollectedData.id)).where(CollectedData.source == datasource.source) + ) count = result.scalar() or 0 if count == 0: - query = select(func.count(CollectedData.id)).where(CollectedData.source == source_name) - result = await db.execute(query) - count = result.scalar() or 0 - delete_source = source_name - else: - delete_source = collector_name + return {"status": "success", "message": "No data to clear", "deleted_count": 0} - if count == 0: - return { - "status": "success", - "message": "No data to clear", - "deleted_count": 0, - } - - delete_query = CollectedData.__table__.delete().where(CollectedData.source == delete_source) + delete_query = CollectedData.__table__.delete().where(CollectedData.source == datasource.source) await db.execute(delete_query) await db.commit() return { "status": "success", - "message": f"Cleared {count} records for data source '{info['name']}'", + "message": f"Cleared {count} records for data source '{datasource.name}'", "deleted_count": count, } @@ -391,22 +254,11 @@ async def get_task_status( source_id: str, db: AsyncSession = Depends(get_db), ): - collector_name = get_collector_name(source_id) - if not collector_name: + datasource = await get_datasource_record(db, source_id) + if not datasource: raise HTTPException(status_code=404, detail="Data source not found") - info = COLLECTOR_INFO[collector_name] - - running_task_query = ( - select(CollectionTask) - .where(CollectionTask.datasource_id == info["id"]) - .where(CollectionTask.status == "running") - .order_by(CollectionTask.started_at.desc()) - .limit(1) - ) - running_result = await db.execute(running_task_query) - running_task = running_result.scalar_one_or_none() - + running_task = await get_running_task(db, datasource.id) if not running_task: return {"is_running": False, "task_id": None, "progress": None} @@ -417,4 +269,4 @@ async def get_task_status( "records_processed": running_task.records_processed, "total_records": running_task.total_records, "status": running_task.status, - } + } diff --git a/backend/app/api/v1/settings.py b/backend/app/api/v1/settings.py index feeda38c..cdde6d2a 100644 --- a/backend/app/api/v1/settings.py +++ b/backend/app/api/v1/settings.py @@ -1,13 +1,21 @@ +from datetime import datetime from typing import Optional -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, EmailStr -from app.models.user import User +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, EmailStr, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + from app.core.security import get_current_user +from app.db.session import get_db +from app.models.datasource import DataSource +from app.models.system_setting import SystemSetting +from app.models.user import User +from app.services.scheduler import sync_datasource_job router = APIRouter() -default_settings = { +DEFAULT_SETTINGS = { "system": { "system_name": "智能星球", "refresh_interval": 60, @@ -29,17 +37,13 @@ default_settings = { }, } -system_settings = default_settings["system"].copy() -notification_settings = default_settings["notifications"].copy() -security_settings = default_settings["security"].copy() - class SystemSettingsUpdate(BaseModel): system_name: str = "智能星球" - refresh_interval: int = 60 + refresh_interval: int = Field(default=60, ge=10, le=3600) auto_refresh: bool = True - data_retention_days: int = 30 - max_concurrent_tasks: int = 5 + data_retention_days: int = Field(default=30, ge=1, le=3650) + max_concurrent_tasks: int = Field(default=5, ge=1, le=50) class NotificationSettingsUpdate(BaseModel): @@ -51,60 +55,166 @@ class NotificationSettingsUpdate(BaseModel): class SecuritySettingsUpdate(BaseModel): - session_timeout: int = 60 - max_login_attempts: int = 5 - password_policy: str = "medium" + session_timeout: int = Field(default=60, ge=5, le=1440) + max_login_attempts: int = Field(default=5, ge=1, le=20) + password_policy: str = Field(default="medium") + + +class CollectorSettingsUpdate(BaseModel): + is_active: bool + priority: str = Field(default="P1") + frequency_minutes: int = Field(default=60, ge=1, le=10080) + + +def merge_with_defaults(category: str, payload: Optional[dict]) -> dict: + merged = DEFAULT_SETTINGS[category].copy() + if payload: + merged.update(payload) + return merged + + +async def get_setting_record(db: AsyncSession, category: str) -> Optional[SystemSetting]: + result = await db.execute(select(SystemSetting).where(SystemSetting.category == category)) + return result.scalar_one_or_none() + + +async def get_setting_payload(db: AsyncSession, category: str) -> dict: + record = await get_setting_record(db, category) + return merge_with_defaults(category, record.payload if record else None) + + +async def save_setting_payload(db: AsyncSession, category: str, payload: dict) -> dict: + record = await get_setting_record(db, category) + if record is None: + record = SystemSetting(category=category, payload=payload) + db.add(record) + else: + record.payload = payload + + await db.commit() + await db.refresh(record) + return merge_with_defaults(category, record.payload) + + +def format_frequency_label(minutes: int) -> str: + if minutes % 1440 == 0: + return f"{minutes // 1440}d" + if minutes % 60 == 0: + return f"{minutes // 60}h" + return f"{minutes}m" + + +def serialize_collector(datasource: DataSource) -> dict: + return { + "id": datasource.id, + "name": datasource.name, + "source": datasource.source, + "module": datasource.module, + "priority": datasource.priority, + "frequency_minutes": datasource.frequency_minutes, + "frequency": format_frequency_label(datasource.frequency_minutes), + "is_active": datasource.is_active, + "last_run_at": datasource.last_run_at.isoformat() if datasource.last_run_at else None, + "last_status": datasource.last_status, + "next_run_at": datasource.next_run_at.isoformat() if datasource.next_run_at else None, + } @router.get("/system") -async def get_system_settings(current_user: User = Depends(get_current_user)): - return {"system": system_settings} +async def get_system_settings( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + return {"system": await get_setting_payload(db, "system")} @router.put("/system") async def update_system_settings( settings: SystemSettingsUpdate, current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), ): - global system_settings - system_settings = settings.model_dump() - return {"status": "updated", "system": system_settings} + payload = await save_setting_payload(db, "system", settings.model_dump()) + return {"status": "updated", "system": payload} @router.get("/notifications") -async def get_notification_settings(current_user: User = Depends(get_current_user)): - return {"notifications": notification_settings} +async def get_notification_settings( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + return {"notifications": await get_setting_payload(db, "notifications")} @router.put("/notifications") async def update_notification_settings( settings: NotificationSettingsUpdate, current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), ): - global notification_settings - notification_settings = settings.model_dump() - return {"status": "updated", "notifications": notification_settings} + payload = await save_setting_payload(db, "notifications", settings.model_dump()) + return {"status": "updated", "notifications": payload} @router.get("/security") -async def get_security_settings(current_user: User = Depends(get_current_user)): - return {"security": security_settings} +async def get_security_settings( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + return {"security": await get_setting_payload(db, "security")} @router.put("/security") async def update_security_settings( settings: SecuritySettingsUpdate, current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), ): - global security_settings - security_settings = settings.model_dump() - return {"status": "updated", "security": security_settings} + payload = await save_setting_payload(db, "security", settings.model_dump()) + return {"status": "updated", "security": payload} + + +@router.get("/collectors") +async def get_collector_settings( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(DataSource).order_by(DataSource.module, DataSource.id)) + datasources = result.scalars().all() + return {"collectors": [serialize_collector(datasource) for datasource in datasources]} + + +@router.put("/collectors/{datasource_id}") +async def update_collector_settings( + datasource_id: int, + settings: CollectorSettingsUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + datasource = await db.get(DataSource, datasource_id) + if not datasource: + raise HTTPException(status_code=404, detail="Data source not found") + + datasource.is_active = settings.is_active + datasource.priority = settings.priority + datasource.frequency_minutes = settings.frequency_minutes + await db.commit() + await db.refresh(datasource) + await sync_datasource_job(datasource.id) + return {"status": "updated", "collector": serialize_collector(datasource)} @router.get("") -async def get_all_settings(current_user: User = Depends(get_current_user)): +async def get_all_settings( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(DataSource).order_by(DataSource.module, DataSource.id)) + datasources = result.scalars().all() return { - "system": system_settings, - "notifications": notification_settings, - "security": security_settings, - } + "system": await get_setting_payload(db, "system"), + "notifications": await get_setting_payload(db, "notifications"), + "security": await get_setting_payload(db, "security"), + "collectors": [serialize_collector(datasource) for datasource in datasources], + "generated_at": datetime.utcnow().isoformat() + "Z", + } diff --git a/backend/app/core/datasource_defaults.py b/backend/app/core/datasource_defaults.py new file mode 100644 index 00000000..7c5f9430 --- /dev/null +++ b/backend/app/core/datasource_defaults.py @@ -0,0 +1,126 @@ +"""Default built-in datasource definitions.""" + +DEFAULT_DATASOURCES = { + "top500": { + "id": 1, + "name": "TOP500 Supercomputers", + "module": "L1", + "priority": "P0", + "frequency_minutes": 240, + }, + "epoch_ai_gpu": { + "id": 2, + "name": "Epoch AI GPU Clusters", + "module": "L1", + "priority": "P0", + "frequency_minutes": 360, + }, + "huggingface_models": { + "id": 3, + "name": "HuggingFace Models", + "module": "L2", + "priority": "P1", + "frequency_minutes": 720, + }, + "huggingface_datasets": { + "id": 4, + "name": "HuggingFace Datasets", + "module": "L2", + "priority": "P1", + "frequency_minutes": 720, + }, + "huggingface_spaces": { + "id": 5, + "name": "HuggingFace Spaces", + "module": "L2", + "priority": "P2", + "frequency_minutes": 1440, + }, + "peeringdb_ixp": { + "id": 6, + "name": "PeeringDB IXP", + "module": "L2", + "priority": "P1", + "frequency_minutes": 1440, + }, + "peeringdb_network": { + "id": 7, + "name": "PeeringDB Networks", + "module": "L2", + "priority": "P2", + "frequency_minutes": 2880, + }, + "peeringdb_facility": { + "id": 8, + "name": "PeeringDB Facilities", + "module": "L2", + "priority": "P2", + "frequency_minutes": 2880, + }, + "telegeography_cables": { + "id": 9, + "name": "Submarine Cables", + "module": "L2", + "priority": "P1", + "frequency_minutes": 10080, + }, + "telegeography_landing": { + "id": 10, + "name": "Cable Landing Points", + "module": "L2", + "priority": "P2", + "frequency_minutes": 10080, + }, + "telegeography_systems": { + "id": 11, + "name": "Cable Systems", + "module": "L2", + "priority": "P2", + "frequency_minutes": 10080, + }, + "arcgis_cables": { + "id": 15, + "name": "ArcGIS Submarine Cables", + "module": "L2", + "priority": "P1", + "frequency_minutes": 10080, + }, + "arcgis_landing_points": { + "id": 16, + "name": "ArcGIS Landing Points", + "module": "L2", + "priority": "P1", + "frequency_minutes": 10080, + }, + "arcgis_cable_landing_relation": { + "id": 17, + "name": "ArcGIS Cable-Landing Relations", + "module": "L2", + "priority": "P1", + "frequency_minutes": 10080, + }, + "fao_landing_points": { + "id": 18, + "name": "FAO Landing Points", + "module": "L2", + "priority": "P1", + "frequency_minutes": 10080, + }, + "spacetrack_tle": { + "id": 19, + "name": "Space-Track TLE", + "module": "L3", + "priority": "P2", + "frequency_minutes": 1440, + }, + "celestrak_tle": { + "id": 20, + "name": "CelesTrak TLE", + "module": "L3", + "priority": "P2", + "frequency_minutes": 1440, + }, +} + +ID_TO_COLLECTOR = {info["id"]: name for name, info in DEFAULT_DATASOURCES.items()} +COLLECTOR_TO_ID = {name: info["id"] for name, info in DEFAULT_DATASOURCES.items()} diff --git a/backend/app/db/session.py b/backend/app/db/session.py index f1bcc83c..392ca380 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -25,11 +25,52 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]: raise +async def seed_default_datasources(session: AsyncSession): + from app.core.datasource_defaults import DEFAULT_DATASOURCES + from app.models.datasource import DataSource + + for source, info in DEFAULT_DATASOURCES.items(): + existing = await session.get(DataSource, info["id"]) + if existing: + existing.name = info["name"] + existing.source = source + existing.module = info["module"] + existing.priority = info["priority"] + existing.frequency_minutes = info["frequency_minutes"] + existing.collector_class = source + if existing.config is None: + existing.config = "{}" + continue + + session.add( + DataSource( + id=info["id"], + name=info["name"], + source=source, + module=info["module"], + priority=info["priority"], + frequency_minutes=info["frequency_minutes"], + collector_class=source, + config="{}", + is_active=True, + ) + ) + + await session.commit() + + async def init_db(): import app.models.user # noqa: F401 import app.models.gpu_cluster # noqa: F401 import app.models.task # noqa: F401 import app.models.datasource # noqa: F401 + import app.models.datasource_config # noqa: F401 + import app.models.alert # noqa: F401 + import app.models.collected_data # noqa: F401 + import app.models.system_setting # noqa: F401 async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + + async with async_session_factory() as session: + await seed_default_datasources(session) diff --git a/backend/app/main.py b/backend/app/main.py index 82fee24c..3bd9aa02 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,15 +2,14 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from fastapi.staticfiles import StaticFiles from starlette.middleware.base import BaseHTTPMiddleware -from app.core.config import settings -from app.core.websocket.broadcaster import broadcaster -from app.db.session import init_db, async_session_factory from app.api.main import api_router from app.api.v1 import websocket -from app.services.scheduler import start_scheduler, stop_scheduler +from app.core.config import settings +from app.core.websocket.broadcaster import broadcaster +from app.db.session import init_db +from app.services.scheduler import start_scheduler, stop_scheduler, sync_scheduler_with_datasources class WebSocketCORSMiddleware(BaseHTTPMiddleware): @@ -28,6 +27,7 @@ class WebSocketCORSMiddleware(BaseHTTPMiddleware): async def lifespan(app: FastAPI): await init_db() start_scheduler() + await sync_scheduler_with_datasources() broadcaster.start() yield broadcaster.stop() @@ -60,16 +60,11 @@ app.include_router(websocket.router) @app.get("/health") async def health_check(): - """健康检查端点""" - return { - "status": "healthy", - "version": settings.VERSION, - } + return {"status": "healthy", "version": settings.VERSION} @app.get("/") async def root(): - """API根目录""" return { "name": settings.PROJECT_NAME, "version": settings.VERSION, @@ -80,7 +75,6 @@ async def root(): @app.get("/api/v1/scheduler/jobs") async def get_scheduler_jobs(): - """获取调度任务列表""" from app.services.scheduler import get_scheduler_jobs - return {"jobs": get_scheduler_jobs()} + return {"jobs": get_scheduler_jobs()} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index cf662d94..16dc63d4 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,14 +2,18 @@ from app.models.user import User from app.models.gpu_cluster import GPUCluster from app.models.task import CollectionTask from app.models.datasource import DataSource +from app.models.datasource_config import DataSourceConfig from app.models.alert import Alert, AlertSeverity, AlertStatus +from app.models.system_setting import SystemSetting __all__ = [ "User", "GPUCluster", "CollectionTask", "DataSource", + "DataSourceConfig", + "SystemSetting", "Alert", "AlertSeverity", "AlertStatus", -] +] diff --git a/backend/app/models/system_setting.py b/backend/app/models/system_setting.py new file mode 100644 index 00000000..ab143348 --- /dev/null +++ b/backend/app/models/system_setting.py @@ -0,0 +1,19 @@ +"""Persistent system settings model.""" + +from sqlalchemy import JSON, Column, DateTime, Integer, String, UniqueConstraint +from sqlalchemy.sql import func + +from app.db.session import Base + + +class SystemSetting(Base): + __tablename__ = "system_settings" + __table_args__ = (UniqueConstraint("category", name="uq_system_settings_category"),) + + id = Column(Integer, primary_key=True, autoincrement=True) + category = Column(String(50), nullable=False) + payload = Column(JSON, nullable=False, default={}) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + def __repr__(self): + return f"" diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index 58762e11..3932ca16 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -1,15 +1,16 @@ -"""Task Scheduler for running collection jobs""" +"""Task Scheduler for running collection jobs.""" import asyncio import logging from datetime import datetime -from typing import Dict, Any +from typing import Any, Dict from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.interval import IntervalTrigger -from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select from app.db.session import async_session_factory +from app.models.datasource import DataSource from app.services.collectors.registry import collector_registry logger = logging.getLogger(__name__) @@ -17,77 +18,119 @@ logger = logging.getLogger(__name__) scheduler = AsyncIOScheduler() -COLLECTOR_TO_ID = { - "top500": 1, - "epoch_ai_gpu": 2, - "huggingface_models": 3, - "huggingface_datasets": 4, - "huggingface_spaces": 5, - "peeringdb_ixp": 6, - "peeringdb_network": 7, - "peeringdb_facility": 8, - "telegeography_cables": 9, - "telegeography_landing": 10, - "telegeography_systems": 11, - "arcgis_cables": 15, - "arcgis_landing_points": 16, - "arcgis_cable_landing_relation": 17, - "fao_landing_points": 18, - "spacetrack_tle": 19, - "celestrak_tle": 20, -} +async def _update_next_run_at(datasource: DataSource, session) -> None: + job = scheduler.get_job(datasource.source) + datasource.next_run_at = job.next_run_time if job else None + await session.commit() + + +async def _apply_datasource_schedule(datasource: DataSource, session) -> None: + collector = collector_registry.get(datasource.source) + if not collector: + logger.warning("Collector not found for datasource %s", datasource.source) + return + + collector_registry.set_active(datasource.source, datasource.is_active) + + existing_job = scheduler.get_job(datasource.source) + if existing_job: + scheduler.remove_job(datasource.source) + + if datasource.is_active: + scheduler.add_job( + run_collector_task, + trigger=IntervalTrigger(minutes=max(1, datasource.frequency_minutes)), + id=datasource.source, + name=datasource.name, + replace_existing=True, + kwargs={"collector_name": datasource.source}, + ) + logger.info( + "Scheduled collector: %s (every %sm)", + datasource.source, + datasource.frequency_minutes, + ) + else: + logger.info("Collector disabled: %s", datasource.source) + + await _update_next_run_at(datasource, session) async def run_collector_task(collector_name: str): - """Run a single collector task""" + """Run a single collector task.""" collector = collector_registry.get(collector_name) if not collector: - logger.error(f"Collector not found: {collector_name}") + logger.error("Collector not found: %s", collector_name) return - # Get the correct datasource_id - datasource_id = COLLECTOR_TO_ID.get(collector_name, 1) - async with async_session_factory() as db: + result = await db.execute(select(DataSource).where(DataSource.source == collector_name)) + datasource = result.scalar_one_or_none() + if not datasource: + logger.error("Datasource not found for collector: %s", collector_name) + return + + if not datasource.is_active: + logger.info("Skipping disabled collector: %s", collector_name) + return + try: - # Set the datasource_id on the collector instance - collector._datasource_id = datasource_id - - logger.info(f"Running collector: {collector_name} (datasource_id={datasource_id})") - result = await collector.run(db) - logger.info(f"Collector {collector_name} completed: {result}") - except Exception as e: - logger.error(f"Collector {collector_name} failed: {e}") + collector._datasource_id = datasource.id + logger.info("Running collector: %s (datasource_id=%s)", collector_name, datasource.id) + task_result = await collector.run(db) + datasource.last_run_at = datetime.utcnow() + datasource.last_status = task_result.get("status") + await _update_next_run_at(datasource, db) + logger.info("Collector %s completed: %s", collector_name, task_result) + except Exception as exc: + datasource.last_run_at = datetime.utcnow() + datasource.last_status = "failed" + await db.commit() + logger.exception("Collector %s failed: %s", collector_name, exc) -def start_scheduler(): - """Start the scheduler with all registered collectors""" - collectors = collector_registry.all() - - for name, collector in collectors.items(): - if collector_registry.is_active(name): - scheduler.add_job( - run_collector_task, - trigger=IntervalTrigger(hours=collector.frequency_hours), - id=name, - name=name, - replace_existing=True, - kwargs={"collector_name": name}, - ) - logger.info(f"Scheduled collector: {name} (every {collector.frequency_hours}h)") - - scheduler.start() - logger.info("Scheduler started") +def start_scheduler() -> None: + """Start the scheduler.""" + if not scheduler.running: + scheduler.start() + logger.info("Scheduler started") -def stop_scheduler(): - """Stop the scheduler""" - scheduler.shutdown() - logger.info("Scheduler stopped") +def stop_scheduler() -> None: + """Stop the scheduler.""" + if scheduler.running: + scheduler.shutdown(wait=False) + logger.info("Scheduler stopped") + + +async def sync_scheduler_with_datasources() -> None: + """Synchronize scheduler jobs with datasource table.""" + async with async_session_factory() as db: + result = await db.execute(select(DataSource).order_by(DataSource.id)) + datasources = result.scalars().all() + + configured_sources = {datasource.source for datasource in datasources} + for job in list(scheduler.get_jobs()): + if job.id not in configured_sources: + scheduler.remove_job(job.id) + + for datasource in datasources: + await _apply_datasource_schedule(datasource, db) + + +async def sync_datasource_job(datasource_id: int) -> bool: + """Synchronize a single datasource job after settings changes.""" + async with async_session_factory() as db: + datasource = await db.get(DataSource, datasource_id) + if not datasource: + return False + + await _apply_datasource_schedule(datasource, db) + return True def get_scheduler_jobs() -> list[Dict[str, Any]]: - """Get all scheduled jobs""" + """Get all scheduled jobs.""" jobs = [] for job in scheduler.get_jobs(): jobs.append( @@ -101,52 +144,17 @@ def get_scheduler_jobs() -> list[Dict[str, Any]]: return jobs -def add_job(collector_name: str, hours: int = 4): - """Add a new scheduled job""" - collector = collector_registry.get(collector_name) - if not collector: - raise ValueError(f"Collector not found: {collector_name}") - - scheduler.add_job( - run_collector_task, - trigger=IntervalTrigger(hours=hours), - id=collector_name, - name=collector_name, - replace_existing=True, - kwargs={"collector_name": collector_name}, - ) - logger.info(f"Added scheduled job: {collector_name} (every {hours}h)") - - -def remove_job(collector_name: str): - """Remove a scheduled job""" - scheduler.remove_job(collector_name) - logger.info(f"Removed scheduled job: {collector_name}") - - -def pause_job(collector_name: str): - """Pause a scheduled job""" - scheduler.pause_job(collector_name) - logger.info(f"Paused job: {collector_name}") - - -def resume_job(collector_name: str): - """Resume a scheduled job""" - scheduler.resume_job(collector_name) - logger.info(f"Resumed job: {collector_name}") - - def run_collector_now(collector_name: str) -> bool: - """Run a collector immediately (not scheduled)""" + """Run a collector immediately (not scheduled).""" collector = collector_registry.get(collector_name) if not collector: - logger.error(f"Collector not found: {collector_name}") + logger.error("Collector not found: %s", collector_name) return False try: asyncio.create_task(run_collector_task(collector_name)) - logger.info(f"Triggered collector: {collector_name}") + logger.info("Triggered collector: %s", collector_name) return True - except Exception as e: - logger.error(f"Failed to trigger collector {collector_name}: {e}") - return False + except Exception as exc: + logger.error("Failed to trigger collector %s: %s", collector_name, exc) + return False diff --git a/docs/system-settings-plan.md b/docs/system-settings-plan.md new file mode 100644 index 00000000..8d7d8952 --- /dev/null +++ b/docs/system-settings-plan.md @@ -0,0 +1,47 @@ +# 系统配置中心开发计划 + +## 目标 + +将当前仅保存于内存中的“系统配置”页面升级为真正可用的配置中心,优先服务以下两类能力: + +1. 系统级配置持久化 +2. 采集调度配置管理 + +## 第一阶段范围 + +### 1. 系统配置持久化 + +- 新增 `system_settings` 表,用于保存分类配置 +- 将系统、通知、安全配置从进程内存迁移到数据库 +- 提供统一读取接口,页面刷新和服务重启后保持不丢失 + +### 2. 采集调度配置接入真实数据源 + +- 统一内置采集器默认定义 +- 启动时自动初始化 `data_sources` 表 +- 配置页允许修改: + - 是否启用 + - 采集频率(分钟) + - 优先级 +- 修改后实时同步到调度器 + +### 3. 前端配置页重构 + +- 将当前通用模板页调整为项目专用配置中心 +- 增加“采集调度”Tab +- 保留“系统显示 / 通知 / 安全”三类配置 +- 将设置页正式接入主路由 + +## 非本阶段内容 + +- 邮件发送能力本身 +- 配置审计历史 +- 敏感凭证加密管理 +- 多租户或按角色细粒度配置 + +## 验收标准 + +- 设置项修改后重启服务仍然存在 +- 配置页可以查看并修改所有内置采集器的启停与采集频率 +- 调整采集频率后,调度器任务随之更新 +- `/settings` 页面可从主导航进入并正常工作 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4e041816..aeb7a8e2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import Users from './pages/Users/Users' import DataSources from './pages/DataSources/DataSources' import DataList from './pages/DataList/DataList' import Earth from './pages/Earth/Earth' +import Settings from './pages/Settings/Settings' function App() { const { token } = useAuthStore() @@ -23,9 +24,10 @@ function App() { } /> } /> } /> + } /> } /> ) } -export default App +export default App diff --git a/frontend/src/components/AppLayout/AppLayout.tsx b/frontend/src/components/AppLayout/AppLayout.tsx index 5ee28271..74167315 100644 --- a/frontend/src/components/AppLayout/AppLayout.tsx +++ b/frontend/src/components/AppLayout/AppLayout.tsx @@ -1,5 +1,5 @@ import { ReactNode, useState } from 'react' -import { Layout, Menu, Typography, Button } from 'antd' +import { Layout, Menu, Typography, Button, Space } from 'antd' import { DashboardOutlined, DatabaseOutlined, @@ -12,7 +12,7 @@ import { import { Link, useLocation } from 'react-router-dom' import { useAuthStore } from '../../stores/auth' -const { Header, Sider, Content } = Layout +const { Sider, Content } = Layout const { Text } = Typography interface AppLayoutProps { @@ -23,6 +23,7 @@ function AppLayout({ children }: AppLayoutProps) { const location = useLocation() const { user, logout } = useAuthStore() const [collapsed, setCollapsed] = useState(false) + const showBanner = true const menuItems = [ { key: '/', icon: , label: 仪表盘 }, @@ -34,43 +35,56 @@ function AppLayout({ children }: AppLayoutProps) { return ( - -
- {collapsed ? ( - 🌏 - ) : ( - 智能星球 - )} -
- - - -
- +
+
+
setCollapsed(false) : undefined}> +
+
-
- - {children} + + {showBanner && !collapsed ? ( +
+ +
+ 当前账号 + {user?.username} +
+ +
+
+ ) : null} + + + + +
{children}
diff --git a/frontend/src/index.css b/frontend/src/index.css index fb11001b..27d0a568 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -31,29 +31,247 @@ body { } .dashboard-layout { - min-height: 100vh; + height: 100vh; +} + +.dashboard-layout .ant-layout, +.dashboard-layout .ant-layout-content { + min-width: 0; + min-height: 0; +} + +.dashboard-layout > .ant-layout { + height: 100%; } .dashboard-sider { background: #001529 !important; } -.ant-layout-sider-trigger { - display: none !important; +.dashboard-sider-inner { + height: 100%; + display: flex; + flex-direction: column; + justify-content: space-between; } -.dashboard-header { - background: white; - padding: 0 24px; +.dashboard-brand { + position: relative; + height: 64px; display: flex; align-items: center; - justify-content: space-between; + justify-content: center; + padding-left: 12px; + padding-right: 40px; +} + +.dashboard-brand .ant-typography { + margin-right: auto; + padding-left: 24px; + transform: none; +} + +.dashboard-brand--collapsed { + cursor: pointer; +} + +.dashboard-sider-toggle { + position: absolute; + top: 50%; + right: 10px; + width: 32px; + height: 32px; + min-width: 32px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + transform: translateY(-50%); + color: rgba(255, 255, 255, 0.88) !important; +} + +.dashboard-sider-toggle--collapsed { + left: 50%; + right: auto; + width: 32px; + transform: translate(-50%, -50%); + justify-content: center; +} + +.dashboard-sider-banner { + margin: 12px; + padding: 14px 12px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.dashboard-sider-banner-label { + display: block; + margin-bottom: 4px; + color: rgba(255, 255, 255, 0.62); + font-size: 12px; +} + +.dashboard-sider-banner-value { + display: block; + color: white !important; + font-size: 14px; +} + +.dashboard-sider-logout { + width: 100%; + color: #ff7875 !important; +} + +.ant-layout-sider-trigger { + display: none !important; } .dashboard-content { padding: 24px; background: #f0f2f5; - min-height: calc(100vh - 64px); + height: 100%; + min-height: 0; + min-width: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.dashboard-content-inner { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; +} + +.dashboard-content-inner > * { + min-width: 0; + min-height: 0; +} + +.page-shell { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + gap: 16px; +} + +.page-shell__header { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.page-shell__body { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.page-shell__body > * { + min-width: 0; + min-height: 0; +} + + +.data-source-tabs-shell, +.data-source-tabs, +.data-source-tabs .ant-tabs-content-holder, +.data-source-tabs .ant-tabs-content, +.data-source-tabs .ant-tabs-tabpane { + min-width: 0; + min-height: 0; + height: 100%; +} + +.data-source-tabs-shell { + flex: 1 1 auto; + display: flex; + flex-direction: column; +} + +.data-source-tabs { + flex: 1 1 auto; + display: flex; + flex-direction: column; +} + +.data-source-tabs .ant-tabs-nav { + flex: 0 0 auto; + margin-bottom: 12px; +} + +.data-source-tabs .ant-tabs-content-holder { + flex: 1 1 auto; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.data-source-tabs .ant-tabs-content { + flex: 1 1 auto; + display: flex; + flex-direction: column; +} + +.data-source-tabs .ant-tabs-tabpane { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.data-source-custom-tab { + gap: 12px; +} + +.data-source-custom-toolbar { + flex: 0 0 auto; + display: flex; + justify-content: flex-end; +} + +.data-source-table-region { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.data-source-table-region .ant-table-wrapper, +.data-source-table-region .ant-spin-nested-loading, +.data-source-table-region .ant-spin-container, +.data-source-table-region .ant-table, +.data-source-table-region .ant-table-container, +.data-source-table-region .ant-table-body { + height: 100%; + min-height: 0; +} + +.data-source-table-region .ant-table-wrapper, +.data-source-table-region .ant-spin-nested-loading, +.data-source-table-region .ant-spin-container { + display: flex; + flex-direction: column; +} + +.data-source-empty-state { + flex: 1 1 auto; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + background: white; + border-radius: 12px; } .stat-card { @@ -88,37 +306,6 @@ body { color: #ff4d4f; } -/* Table column resize */ -.ant-table-wrapper .ant-table-thead > tr > th { - position: relative; -} - -.resize-handle { - position: absolute; - right: 0; - top: 0; - bottom: 0; - width: 6px; - cursor: col-resize; - background: transparent; - z-index: 10; - display: flex; - align-items: center; - justify-content: center; -} - -.resize-handle::before { - content: ''; - width: 2px; - height: 20px; - background: #d9d9d9; - border-radius: 1px; -} - -.resize-handle:hover::before { - background: #1890ff; -} - /* Table cell fixed width */ .ant-table-wrapper .ant-table-tbody > tr > td { max-width: 0; @@ -126,3 +313,405 @@ body { text-overflow: ellipsis; white-space: nowrap; } + +.ant-table-wrapper { + width: 100%; +} + +.ant-table-wrapper .ant-table-container, +.ant-table-wrapper .ant-table-content, +.ant-table-wrapper .ant-table-body { + overflow-x: auto !important; +} + +.table-scroll-region { + width: 100%; + max-width: 100%; + min-width: 0; + overflow: hidden; +} + +.table-scroll-region .ant-table-wrapper { + width: 100%; + min-width: 0; +} + +.table-scroll-region .ant-table { + min-width: 100%; +} + + + + +.data-list-workspace { + min-height: 0; + display: flex; + flex-direction: column; + gap: 12px; + overflow: hidden; +} + +.data-list-topbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex: 0 0 auto; +} + +.data-list-controls-shell { + flex: 1 1 auto; + min-height: 0; +} + +.data-list-split-layout { + height: 100%; + min-height: 0; + display: grid; + grid-template-columns: minmax(280px, 0.95fr) 12px minmax(0, 1fr); + gap: 0; +} + +.data-list-summary-card, +.data-list-table-shell { + min-width: 0; + min-height: 0; +} + +.data-list-summary-card--panel, +.data-list-summary-card--panel .ant-card-body { + height: 100%; +} + +.data-list-summary-card--panel .ant-card-body { + overflow-y: auto; + overflow-x: hidden; + scrollbar-gutter: stable; +} + +.data-list-summary-card .ant-card-head, +.data-list-table-shell .ant-card-head { + padding-inline: 16px; +} + +.data-list-summary-card .ant-card-body { + overflow: auto; +} + +.data-list-right-column { + min-width: 0; + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; +} + +.data-list-summary-treemap { + min-height: 100%; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-auto-rows: minmax(56px, 1fr); + grid-auto-flow: dense; + gap: 10px; +} + +.data-list-treemap-tile { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 8px; + padding: 12px; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.55); + color: #0f172a; + overflow: hidden; +} + +.data-list-treemap-tile--ocean { + background: linear-gradient(135deg, #dbeafe 0%, #93c5fd 100%); +} + +.data-list-treemap-tile--sky { + background: linear-gradient(135deg, #e0f2fe 0%, #7dd3fc 100%); +} + +.data-list-treemap-tile--mint { + background: linear-gradient(135deg, #dcfce7 0%, #86efac 100%); +} + +.data-list-treemap-tile--amber { + background: linear-gradient(135deg, #fef3c7 0%, #fcd34d 100%); +} + +.data-list-treemap-tile--rose { + background: linear-gradient(135deg, #ffe4e6 0%, #fda4af 100%); +} + +.data-list-treemap-tile--violet { + background: linear-gradient(135deg, #ede9fe 0%, #c4b5fd 100%); +} + +.data-list-treemap-tile--slate { + background: linear-gradient(135deg, #e2e8f0 0%, #94a3b8 100%); +} + +.data-list-treemap-head { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.data-list-treemap-label { + min-width: 0; + font-size: clamp(11px, 0.75vw, 13px); + line-height: 1.2; + color: rgba(15, 23, 42, 0.78); +} + +.data-list-treemap-body { + display: flex; + flex-direction: column; + gap: 4px; +} + +.data-list-summary-tile-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.55); + color: #0f172a; + flex: 0 0 auto; +} + +.data-list-summary-tile-value { + font-size: clamp(12px, 1vw, 16px); + line-height: 1.1; + color: #0f172a; +} + +.data-list-treemap-meta { + color: rgba(15, 23, 42, 0.72) !important; +} + +.data-list-summary-card--panel .ant-card-body::-webkit-scrollbar { + width: 10px; +} + +.data-list-summary-card--panel .ant-card-body::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.8); + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; +} + +.data-list-summary-card--panel .ant-card-body::-webkit-scrollbar-track { + background: transparent; +} + +.data-list-filter-grid { + min-width: 0; + display: flex; + flex-wrap: nowrap; + gap: 10px; + align-items: center; +} + +.data-list-filter-grid--balanced > * { + flex: 1 1 0; + min-width: 0; +} + +.data-list-filter-grid--header { + padding-bottom: 4px; +} + +.data-list-table-shell { + min-height: 0; + display: flex; + flex-direction: column; +} + +.data-list-table-shell .ant-card-body { + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; +} + +.data-list-table-header { + padding: 12px 14px 0 14px; + flex: 0 0 auto; +} + +.data-list-table-header--with-filters { + display: flex; + flex-direction: column; + gap: 10px; +} + +.data-list-table-header-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.data-list-table-region { + flex: 1 1 auto; + min-height: 0; + overflow: visible; +} + +.data-list-table-region .ant-table-wrapper, +.data-list-table-region .ant-spin-nested-loading, +.data-list-table-region .ant-spin-container { + height: 100%; + min-height: 0; +} + +.data-list-table-region .ant-table-wrapper { + display: flex; + flex-direction: column; +} + +.data-list-table-region .ant-spin-nested-loading, +.data-list-table-region .ant-spin-container { + display: flex; + flex-direction: column; +} + +.data-list-table-region .ant-table { + flex: 1 1 auto; +} + +.data-list-table-region .ant-table-pagination { + flex: 0 0 auto; + margin: 12px 0 0; +} + +.data-list-resize-handle { + position: relative; + display: flex; + align-items: center; + justify-content: center; + user-select: none; + touch-action: none; +} + +.data-list-resize-handle::before { + content: ''; + display: block; + border-radius: 999px; + background: #d0d7e2; + transition: background-color 0.2s ease, transform 0.2s ease; +} + +.data-list-resize-handle:hover::before { + background: #8fb4ff; +} + +.data-list-resize-handle--vertical { + cursor: col-resize; +} + +.data-list-resize-handle--vertical::before { + width: 4px; + height: 56px; +} + +.data-list-resize-handle--horizontal { + cursor: row-resize; +} + +.data-list-resize-handle--horizontal::before { + width: 56px; + height: 4px; +} + +@media (min-width: 1201px) and (orientation: landscape) { + .data-list-summary-treemap { + grid-auto-rows: minmax(48px, 1fr); + } + + .data-list-treemap-tile { + padding: 10px 12px; + } + + .data-list-summary-tile-value { + font-size: 15px; + } +} + +@media (max-width: 1200px) { + .data-list-summary-treemap { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .data-list-split-layout { + grid-template-columns: minmax(240px, 0.9fr) 12px minmax(0, 1fr); + } +} + +@media (max-width: 992px) { + .dashboard-content { + padding: 12px; + } + + .data-list-workspace { + gap: 10px; + } + + .data-list-topbar { + align-items: flex-start; + flex-direction: column; + gap: 8px; + } + + .data-list-split-layout { + grid-template-columns: 1fr; + gap: 10px; + height: auto; + } + + .data-list-right-column { + grid-template-rows: auto auto; + gap: 10px; + height: auto; + } + + .data-list-summary-treemap { + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-auto-rows: minmax(88px, 1fr); + } + + .data-list-filter-grid { + flex-wrap: wrap; + } + + .data-list-filter-grid--balanced > * { + flex: 1 1 180px; + min-width: 160px; + } +} + +@media (max-width: 640px) { + .data-list-summary-treemap { + grid-template-columns: 1fr; + } + + .data-list-filter-grid { + flex-wrap: wrap; + } + + .data-list-filter-grid--balanced > * { + flex-basis: 100%; + min-width: 100%; + } + +} diff --git a/frontend/src/pages/Alerts/Alerts.tsx b/frontend/src/pages/Alerts/Alerts.tsx index 939a61bc..e3225336 100644 --- a/frontend/src/pages/Alerts/Alerts.tsx +++ b/frontend/src/pages/Alerts/Alerts.tsx @@ -174,7 +174,9 @@ function Alerts() { title="告警列表" extra={} > - +
+
+ (null) const [loading, setLoading] = useState(true) const [wsConnected, setWsConnected] = useState(false) @@ -63,7 +59,7 @@ function Dashboard() { } fetchStats() - }, [token]) + }, [token, clearAuth]) useEffect(() => { if (!token) return @@ -112,28 +108,10 @@ function Dashboard() { } }, [token]) - const handleLogout = () => { - logout() - window.location.href = '/' - } - - const handleClearAuth = () => { - clearAuth() - window.location.href = '/' - } - const handleRetry = () => { window.location.reload() } - const menuItems = [ - { key: '/', icon: , label: 仪表盘 }, - { key: '/datasources', icon: , label: 数据源 }, - { key: '/data', icon: , label: 采集数据 }, - { key: '/users', icon: , label: 用户管理 }, - { key: '/settings', icon: , label: '系统配置' }, - ] - if (loading && !stats) { return (
@@ -143,81 +121,78 @@ function Dashboard() { } return ( - - -
- 智能星球 -
- - - -
- 欢迎, {user?.username} -
+ +
+
+
+ 仪表盘 + 系统总览与实时态势 +
+ {wsConnected ? ( } color="success">实时连接 ) : ( } color="default">离线 )} - - - -
-
- - {error && ( - - {error} + + +
+ + {error && ( + + {error} + + )} + + +
+ + } /> - )} - - - - } /> - - - - - - - - - - } /> - - - - - - - - - - - - } /> - - - - - } /> - - - - - } /> - - - - {stats?.last_updated && ( -
- 最后更新: {new Date(stats.last_updated).toLocaleString('zh-CN')} - {wsConnected && 实时同步中} -
- )} - - - + + + + + + + + + } /> + + + + + + + + + + + + + } /> + + + + + } /> + + + + + } /> + + + + + {stats?.last_updated && ( +
+ 最后更新: {new Date(stats.last_updated).toLocaleString('zh-CN')} + {wsConnected && 实时同步中} +
+ )} + + ) } diff --git a/frontend/src/pages/DataList/DataList.tsx b/frontend/src/pages/DataList/DataList.tsx index bcc1567f..f2f37f9e 100644 --- a/frontend/src/pages/DataList/DataList.tsx +++ b/frontend/src/pages/DataList/DataList.tsx @@ -1,16 +1,19 @@ -import { useEffect, useState, useRef } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { - Table, Tag, Space, Card, Row, Col, Select, Input, Button, - Statistic, Modal, Descriptions, Spin, Empty, Tooltip + Table, Tag, Space, Card, Select, Input, Button, + Modal, Descriptions, Spin, Empty, Tooltip, Typography, Grid } from 'antd' import type { ColumnsType } from 'antd/es/table' import { DatabaseOutlined, GlobalOutlined, CloudServerOutlined, - AppstoreOutlined, EyeOutlined, SearchOutlined + AppstoreOutlined, EyeOutlined, SearchOutlined, FilterOutlined, ReloadOutlined } from '@ant-design/icons' import axios from 'axios' import AppLayout from '../../components/AppLayout/AppLayout' +const { Title, Text } = Typography +const { useBreakpoint } = Grid + interface CollectedData { id: number source: string @@ -38,6 +41,21 @@ interface Summary { } function DataList() { + const screens = useBreakpoint() + const isCompact = !screens.lg + const topbarRef = useRef(null) + const workspaceRef = useRef(null) + const mainAreaRef = useRef(null) + const rightColumnRef = useRef(null) + const tableHeaderRef = useRef(null) + const hasCustomLeftWidthRef = useRef(false) + + const [mainAreaWidth, setMainAreaWidth] = useState(0) + const [mainAreaHeight, setMainAreaHeight] = useState(0) + const [rightColumnHeight, setRightColumnHeight] = useState(0) + const [tableHeaderHeight, setTableHeaderHeight] = useState(0) + const [leftPanelWidth, setLeftPanelWidth] = useState(360) + const [data, setData] = useState([]) const [loading, setLoading] = useState(false) const [summary, setSummary] = useState(null) @@ -55,6 +73,73 @@ function DataList() { const [detailData, setDetailData] = useState(null) const [detailLoading, setDetailLoading] = useState(false) + useEffect(() => { + const updateLayout = () => { + setMainAreaWidth(mainAreaRef.current?.offsetWidth || 0) + setMainAreaHeight(mainAreaRef.current?.offsetHeight || 0) + setRightColumnHeight(rightColumnRef.current?.offsetHeight || 0) + setTableHeaderHeight(tableHeaderRef.current?.offsetHeight || 0) + } + + updateLayout() + + if (typeof ResizeObserver === 'undefined') { + return undefined + } + + const observer = new ResizeObserver(updateLayout) + if (workspaceRef.current) observer.observe(workspaceRef.current) + if (topbarRef.current) observer.observe(topbarRef.current) + if (mainAreaRef.current) observer.observe(mainAreaRef.current) + if (rightColumnRef.current) observer.observe(rightColumnRef.current) + if (tableHeaderRef.current) observer.observe(tableHeaderRef.current) + + return () => observer.disconnect() + }, [isCompact]) + + useEffect(() => { + if (isCompact || mainAreaWidth === 0) { + return + } + + const minLeft = 260 + const minRight = 360 + const maxLeft = Math.max(minLeft, mainAreaWidth - minRight - 12) + const preferredLeft = Math.max(minLeft, Math.min(Math.round((mainAreaWidth - 12) / 4), maxLeft)) + + setLeftPanelWidth((current) => { + if (!hasCustomLeftWidthRef.current) { + return preferredLeft + } + return Math.max(minLeft, Math.min(current, maxLeft)) + }) + }, [isCompact, mainAreaWidth]) + + const beginHorizontalResize = (event: React.MouseEvent) => { + if (isCompact) return + event.preventDefault() + hasCustomLeftWidthRef.current = true + const startX = event.clientX + const startWidth = leftPanelWidth + const containerWidth = mainAreaRef.current?.offsetWidth || 0 + + const onMove = (moveEvent: MouseEvent) => { + const minLeft = 260 + const minRight = 360 + const maxLeft = Math.max(minLeft, containerWidth - minRight - 12) + const nextWidth = startWidth + moveEvent.clientX - startX + setLeftPanelWidth(Math.max(minLeft, Math.min(nextWidth, maxLeft))) + } + + const onUp = () => { + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + } + + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + } + const fetchData = async () => { setLoading(true) try { @@ -115,6 +200,15 @@ function DataList() { fetchData() } + const handleReset = () => { + setSourceFilter(undefined) + setTypeFilter(undefined) + setCountryFilter(undefined) + setSearchText('') + setPage(1) + setTimeout(fetchData, 0) + } + const handleViewDetail = async (id: number) => { setDetailVisible(true) setDetailLoading(true) @@ -130,102 +224,115 @@ function DataList() { const getSourceIcon = (source: string) => { const iconMap: Record = { - 'top500': , - 'huggingface_models': , - 'huggingface_datasets': , - 'huggingface_spaces': , - 'telegeography_cables': , - 'epoch_ai_gpu': , + top500: , + huggingface_models: , + huggingface_datasets: , + huggingface_spaces: , + telegeography_cables: , + epoch_ai_gpu: , } return iconMap[source] || } const getTypeColor = (type: string) => { const colors: Record = { - 'supercomputer': 'red', - 'model': 'blue', - 'dataset': 'green', - 'space': 'purple', - 'submarine_cable': 'cyan', - 'gpu_cluster': 'orange', - 'ixp': 'magenta', - 'network': 'gold', - 'facility': 'lime', + supercomputer: 'red', + model: 'blue', + dataset: 'green', + space: 'purple', + submarine_cable: 'cyan', + gpu_cluster: 'orange', + ixp: 'magenta', + network: 'gold', + facility: 'lime', } return colors[type] || 'default' } - const [columnsWidth, setColumnsWidth] = useState>({ - id: 60, - name: 300, - source: 150, - data_type: 100, - country: 100, - value: 100, - collected_at: 160, - action: 80, - }) + const activeFilterCount = useMemo( + () => [sourceFilter, typeFilter, countryFilter, searchText.trim()].filter(Boolean).length, + [sourceFilter, typeFilter, countryFilter, searchText] + ) - const resizeRef = useRef<{ startX: number; startWidth: number; key: string } | null>(null) + const summaryItems = useMemo(() => { + const items = [ + { key: 'total', label: '总记录', value: summary?.total_records || 0, icon: }, + { key: 'result', label: '筛选结果', value: total, icon: }, + { key: 'filters', label: '启用筛选', value: activeFilterCount, icon: }, + { key: 'sources', label: '数据源数', value: sources.length, icon: }, + ] - const handleResizeStart = (key: string) => (e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() - resizeRef.current = { - startX: e.clientX, - startWidth: columnsWidth[key], - key, + for (const item of (summary?.source_totals || []).slice(0, isCompact ? 3 : 5)) { + items.push({ + key: item.source, + label: item.source, + value: item.count, + icon: getSourceIcon(item.source), + }) } - document.addEventListener('mousemove', handleResizeMove) - document.addEventListener('mouseup', handleResizeEnd) - } - const handleResizeMove = (e: MouseEvent) => { - if (!resizeRef.current) return - const diff = e.clientX - resizeRef.current.startX - const newWidth = Math.max(50, resizeRef.current.startWidth + diff) - setColumnsWidth((prev) => ({ - ...prev, - [resizeRef.current!.key]: newWidth, - })) - } + return items + }, [summary, total, activeFilterCount, isCompact, sources.length]) - const handleResizeEnd = () => { - resizeRef.current = null - document.removeEventListener('mousemove', handleResizeMove) - document.removeEventListener('mouseup', handleResizeEnd) - } + const treemapColumns = useMemo(() => { + if (isCompact) return 1 + if (leftPanelWidth < 360) return 2 + if (leftPanelWidth < 520) return 3 + return 4 + }, [isCompact, leftPanelWidth]) + + const treemapRowHeight = useMemo(() => { + if (isCompact) return 88 + if (leftPanelWidth < 360) return 44 + if (leftPanelWidth < 520) return 48 + return 56 + }, [isCompact, leftPanelWidth]) + + const treemapItems = useMemo(() => { + const palette = ['ocean', 'sky', 'mint', 'amber', 'rose', 'violet', 'slate'] + const maxValue = Math.max(...summaryItems.map((item) => item.value), 1) + const allowTallTiles = !isCompact && leftPanelWidth >= 520 + + return summaryItems.map((item, index) => { + const ratio = item.value / maxValue + let colSpan = 1 + let rowSpan = 1 + + if (allowTallTiles && index === 0) { + colSpan = Math.min(2, treemapColumns) + rowSpan = 2 + } else if (allowTallTiles && ratio >= 0.7) { + colSpan = Math.min(2, treemapColumns) + rowSpan = 2 + } else if (allowTallTiles && ratio >= 0.35) { + rowSpan = 2 + } + + return { + ...item, + colSpan, + rowSpan, + tone: palette[index % palette.length], + } + }) + }, [summaryItems, isCompact, leftPanelWidth, treemapColumns]) + + const pageHeight = '100%' + const desktopTableHeight = rightColumnHeight - tableHeaderHeight - 132 + const compactTableHeight = mainAreaHeight - tableHeaderHeight - 156 + const tableHeight = Math.max(180, isCompact ? compactTableHeight : desktopTableHeight) + + const splitLayoutStyle = isCompact + ? undefined + : { gridTemplateColumns: `${leftPanelWidth}px 12px minmax(0, 1fr)` } const columns: ColumnsType = [ + { title: 'ID', dataIndex: 'id', key: 'id', width: 80 }, { - title: () => ( -
- ID -
e.stopPropagation()} - /> -
- ), - dataIndex: 'id', - key: 'id', - width: columnsWidth.id, - }, - { - title: () => ( -
- 名称 -
e.stopPropagation()} - /> -
- ), + title: '名称', dataIndex: 'name', key: 'name', - width: columnsWidth.name, + width: 280, ellipsis: true, render: (name: string, record: CollectedData) => ( @@ -236,101 +343,40 @@ function DataList() { ), }, { - title: () => ( -
- 数据源 -
e.stopPropagation()} - /> -
- ), + title: '数据源', dataIndex: 'source', key: 'source', - width: columnsWidth.source, - render: (source: string) => ( - {source} - ), + width: 170, + render: (source: string) => {source}, }, { - title: () => ( -
- 类型 -
e.stopPropagation()} - /> -
- ), + title: '类型', dataIndex: 'data_type', key: 'data_type', - width: columnsWidth.data_type, - render: (type: string) => ( - {type} - ), + width: 120, + render: (type: string) => {type}, }, + { title: '国家/地区', dataIndex: 'country', key: 'country', width: 130, ellipsis: true }, { - title: () => ( -
- 国家/地区 -
e.stopPropagation()} - /> -
- ), - dataIndex: 'country', - key: 'country', - width: columnsWidth.country, - ellipsis: true, - }, - { - title: () => ( -
- 数值 -
e.stopPropagation()} - /> -
- ), + title: '数值', dataIndex: 'value', key: 'value', - width: columnsWidth.value, - render: (value: string | null, record: CollectedData) => ( - value ? `${value} ${record.unit || ''}` : '-' - ), + width: 140, + render: (value: string | null, record: CollectedData) => (value ? `${value} ${record.unit || ''}` : '-'), }, { - title: () => ( -
- 采集时间 -
e.stopPropagation()} - /> -
- ), + title: '采集时间', dataIndex: 'collected_at', key: 'collected_at', - width: columnsWidth.collected_at, + width: 180, render: (time: string) => new Date(time).toLocaleString('zh-CN'), }, { title: '操作', key: 'action', - width: columnsWidth.action, + width: 96, render: (_: unknown, record: CollectedData) => ( - ), @@ -339,93 +385,160 @@ function DataList() { return ( -

采集数据管理

+
+
+
+ 采集数据 +
+ + + 结果 {total.toLocaleString()} 条 + + + 筛选 {activeFilterCount} 项 + + +
- {/* Summary Cards */} - {summary && ( - -
- - } - /> +
+
+ +
+ {treemapItems.map((item) => ( +
+
+ {item.icon} + {item.label} +
+
+ + {item.value.toLocaleString()} + +
+
+ ))} +
- - {summary.source_totals.slice(0, 4).map((item) => ( -
- - + + {!isCompact && ( +
+ )} + +
+ +
+
+ + 数据列表 + 共 {total.toLocaleString()} 条结果 + + + + + + +
+
+ { + setTypeFilter(value) + setPage(1) + }} + options={types.map((type) => ({ label: type, value: type }))} + style={{ width: '100%' }} + /> + setSearchText(event.target.value)} + onPressEnter={handleSearch} + /> +
+
+
+
{ + setPage(nextPage) + setPageSize(nextPageSize) + }, + showSizeChanger: true, + showTotal: (count) => `共 ${count} 条`, + }} + /> + - - ))} - - )} + + + + - {/* Filters */} - - - { setTypeFilter(v); setPage(1); }} - options={types.map(t => ({ label: t, value: t }))} - /> - setSearchText(e.target.value)} - onPressEnter={handleSearch} - /> - - - - - {/* Data Table */} -
{ setPage(p); setPageSize(ps); }, - showSizeChanger: true, - showTotal: (t) => `共 ${t} 条`, - }} - /> - - {/* Detail Modal */} (0) const [testing, setTesting] = useState(false) const [testResult, setTestResult] = useState(null) + const builtinTableRegionRef = useRef(null) + const customTableRegionRef = useRef(null) + const [builtinTableHeight, setBuiltinTableHeight] = useState(360) + const [customTableHeight, setCustomTableHeight] = useState(360) const [form] = Form.useForm() const fetchData = async () => { @@ -91,6 +95,28 @@ function DataSources() { fetchData() }, []) + useEffect(() => { + const updateHeights = () => { + const builtinRegionHeight = builtinTableRegionRef.current?.offsetHeight || 0 + const customRegionHeight = customTableRegionRef.current?.offsetHeight || 0 + + setBuiltinTableHeight(Math.max(220, builtinRegionHeight - 56)) + setCustomTableHeight(Math.max(220, customRegionHeight - 56)) + } + + updateHeights() + + if (typeof ResizeObserver === 'undefined') { + return undefined + } + + const observer = new ResizeObserver(updateHeights) + if (builtinTableRegionRef.current) observer.observe(builtinTableRegionRef.current) + if (customTableRegionRef.current) observer.observe(customTableRegionRef.current) + + return () => observer.disconnect() + }, [activeTab, builtInSources.length, customSources.length]) + useEffect(() => { const runningSources = builtInSources.filter(s => s.is_running) if (runningSources.length === 0) return @@ -440,16 +466,21 @@ function DataSources() { key: 'builtin', label: '内置数据源', children: ( -
+
+
+
+ + ), }, { @@ -460,35 +491,48 @@ function DataSources() { ), children: ( - <> -
+
+
{customSources.length === 0 ? ( - +
+ +
) : ( -
+
+
+ )} - + ), }, ] return ( -

数据源管理

- +
+
+

数据源管理

+
+
+
+ +
+
+
({ - system_name: '智能星球', - refresh_interval: 60, - auto_refresh: true, - data_retention_days: 30, - max_concurrent_tasks: 5, - }) - const [notificationSettings, setNotificationSettings] = useState({ - email_enabled: false, - email_address: '', - critical_alerts: true, - warning_alerts: true, - daily_summary: false, - }) - const [securitySettings, setSecuritySettings] = useState({ - session_timeout: 60, - max_login_attempts: 5, - password_policy: 'medium', - }) - const [form] = Form.useForm() +interface CollectorSettings { + id: number + name: string + source: string + module: string + priority: string + frequency_minutes: number + frequency: string + is_active: boolean + last_run_at: string | null + last_status: string | null + next_run_at: string | null +} - useEffect(() => { - if (!token) { - navigate('/') - return - } - fetchSettings() - }, [token, navigate]) +function Settings() { + const [loading, setLoading] = useState(true) + const [savingCollectorId, setSavingCollectorId] = useState(null) + const [collectors, setCollectors] = useState([]) + const [systemForm] = Form.useForm() + const [notificationForm] = Form.useForm() + const [securityForm] = Form.useForm() const fetchSettings = async () => { try { setLoading(true) - const res = await fetch('/api/v1/settings/system', { - headers: { Authorization: `Bearer ${token}` }, - }) - if (res.status === 401) { - clearAuth() - navigate('/') - return - } - if (res.ok) { - const data = await res.json() - setSystemSettings(data.system || systemSettings) - setNotificationSettings(data.notifications || notificationSettings) - setSecuritySettings(data.security || securitySettings) - form.setFieldsValue({ - ...data.system, - ...data.notifications, - ...data.security, - }) - } - } catch (err) { - message.error('获取设置失败') - console.error(err) + const response = await axios.get('/api/v1/settings') + systemForm.setFieldsValue(response.data.system) + notificationForm.setFieldsValue(response.data.notifications) + securityForm.setFieldsValue(response.data.security) + setCollectors(response.data.collectors || []) + } catch (error) { + message.error('获取系统配置失败') + console.error(error) } finally { setLoading(false) } } - const handleSaveSystem = async (values: any) => { + useEffect(() => { + fetchSettings() + }, []) + + const saveSection = async (section: 'system' | 'notifications' | 'security', values: object) => { try { - setSaving(true) - const res = await fetch('/api/v1/settings/system', { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(values), - }) - if (res.ok) { - message.success('系统设置已保存') - setSystemSettings(values) - } else { - message.error('保存失败') - } - } catch (err) { - message.error('保存设置失败') - console.error(err) - } finally { - setSaving(false) + await axios.put(`/api/v1/settings/${section}`, values) + message.success('配置已保存') + await fetchSettings() + } catch (error) { + message.error('保存失败') + console.error(error) } } - const handleSaveNotifications = async (values: any) => { - try { - setSaving(true) - const res = await fetch('/api/v1/settings/notifications', { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(values), - }) - if (res.ok) { - message.success('通知设置已保存') - setNotificationSettings(values) - } else { - message.error('保存失败') - } - } catch (err) { - message.error('保存设置失败') - console.error(err) - } finally { - setSaving(false) - } - } - - const handleSaveSecurity = async (values: any) => { - try { - setSaving(true) - const res = await fetch('/api/v1/settings/security', { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify(values), - }) - if (res.ok) { - message.success('安全设置已保存') - setSecuritySettings(values) - } else { - message.error('保存失败') - } - } catch (err) { - message.error('保存设置失败') - console.error(err) - } finally { - setSaving(false) - } - } - - const handleLogout = () => { - logout() - navigate('/') - } - - const menuItems = [ - { key: '/', icon: , label: 仪表盘 }, - { key: '/datasources', icon: , label: 数据源 }, - { key: '/users', icon: , label: 用户管理 }, - { key: '/settings', icon: , label: '系统配置' }, - ] - - if (loading && !token) { - return ( -
- -
+ const updateCollectorField = (id: number, field: keyof CollectorSettings, value: string | number | boolean) => { + setCollectors((prev) => + prev.map((collector) => (collector.id === id ? { ...collector, [field]: value } : collector)) ) } - return ( - - -
- 智能星球 + const saveCollector = async (collector: CollectorSettings) => { + try { + setSavingCollectorId(collector.id) + await axios.put(`/api/v1/settings/collectors/${collector.id}`, { + is_active: collector.is_active, + priority: collector.priority, + frequency_minutes: collector.frequency_minutes, + }) + message.success(`${collector.name} 配置已更新`) + await fetchSettings() + } catch (error) { + message.error('采集调度配置保存失败') + console.error(error) + } finally { + setSavingCollectorId(null) + } + } + + const collectorColumns = [ + { + title: '数据源', + dataIndex: 'name', + key: 'name', + render: (_: string, record: CollectorSettings) => ( +
+
{record.name}
+ {record.source}
- - - -
- 欢迎, {user?.username} -
- -
-
- - <SettingOutlined /> 系统设置 - - 系统配置} - key="system" - > - -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 通知设置} - key="notifications" - > - -
- 邮件通知 - -
- - - - - - - - - - - 告警通知 - - - - - - - - - - - - - - - - - - - - - - - - 安全设置} - key="security" - > - -
- -
- - - - - - - - - - - - - - - - - - - - - - + ), + }, + { + title: '层级', + dataIndex: 'module', + key: 'module', + width: 90, + render: (module: string) => {module}, + }, + { + title: '优先级', + dataIndex: 'priority', + key: 'priority', + width: 130, + render: (priority: string, record: CollectorSettings) => ( + + + + + + + + + + + + + + + + + + ), + }, + { + key: 'notifications', + label: '通知策略', + children: ( + +
saveSection('notifications', values)}> + + + + + + + + + + + + + + + + + +
+ ), + }, + { + key: 'security', + label: '安全策略', + children: ( + +
saveSection('security', values)}> + + + + + + + +
+ + + ), + }, + ]} + /> + + ) } export default Settings + diff --git a/frontend/src/pages/Tasks/Tasks.tsx b/frontend/src/pages/Tasks/Tasks.tsx index 79bcd106..8af549db 100644 --- a/frontend/src/pages/Tasks/Tasks.tsx +++ b/frontend/src/pages/Tasks/Tasks.tsx @@ -145,7 +145,9 @@ function Tasks() { } > -
+
+
+ ) diff --git a/frontend/src/pages/Users/Users.tsx b/frontend/src/pages/Users/Users.tsx index f9cc7848..7281dc53 100644 --- a/frontend/src/pages/Users/Users.tsx +++ b/frontend/src/pages/Users/Users.tsx @@ -115,11 +115,17 @@ function Users() { return ( -
-

用户管理

- +
+
+

用户管理

+ +
+
+
+
+ + -
Date: Wed, 25 Mar 2026 10:42:10 +0800 Subject: [PATCH 8/9] Fix settings layout and frontend startup checks --- frontend/src/index.css | 92 ++++++++ frontend/src/pages/Settings/Settings.tsx | 257 ++++++++++++++--------- planet.sh | 42 ++-- 3 files changed, 273 insertions(+), 118 deletions(-) diff --git a/frontend/src/index.css b/frontend/src/index.css index 27d0a568..de1a1f0c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -340,6 +340,98 @@ body { min-width: 100%; } +.settings-shell, +.settings-tabs-shell, +.settings-tabs, +.settings-tabs .ant-tabs-content-holder, +.settings-tabs .ant-tabs-content, +.settings-tabs .ant-tabs-tabpane { + min-width: 0; + min-height: 0; + height: 100%; +} + +.settings-tabs-shell { + overflow: hidden; +} + +.settings-tabs { + display: flex; + flex-direction: column; +} + +.settings-tabs .ant-tabs-nav { + flex: 0 0 auto; + margin-bottom: 12px; +} + +.settings-tabs .ant-tabs-content-holder, +.settings-tabs .ant-tabs-content, +.settings-tabs .ant-tabs-tabpane { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.settings-tabs .ant-tabs-tabpane-hidden { + display: none !important; +} + +.settings-tab-panel { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; +} + +.settings-panel-card, +.settings-panel-card .ant-card-body { + min-width: 0; + min-height: 0; + height: 100%; +} + +.settings-panel-card { + flex: 1 1 auto; +} + +.settings-panel-card .ant-card-body { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.settings-panel-scroll { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding-right: 6px; + scrollbar-gutter: stable; +} + +.settings-panel-scroll::-webkit-scrollbar { + width: 10px; +} + +.settings-panel-scroll::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.8); + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; +} + +.settings-panel-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.settings-table-scroll-region { + flex: 1 1 auto; + overflow: hidden; +} + diff --git a/frontend/src/pages/Settings/Settings.tsx b/frontend/src/pages/Settings/Settings.tsx index 5679ccb6..8bdf6a5c 100644 --- a/frontend/src/pages/Settings/Settings.tsx +++ b/frontend/src/pages/Settings/Settings.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Button, Card, @@ -59,6 +59,8 @@ function Settings() { const [loading, setLoading] = useState(true) const [savingCollectorId, setSavingCollectorId] = useState(null) const [collectors, setCollectors] = useState([]) + const collectorTableRegionRef = useRef(null) + const [collectorTableHeight, setCollectorTableHeight] = useState(360) const [systemForm] = Form.useForm() const [notificationForm] = Form.useForm() const [securityForm] = Form.useForm() @@ -83,6 +85,24 @@ function Settings() { fetchSettings() }, []) + useEffect(() => { + const updateTableHeight = () => { + const regionHeight = collectorTableRegionRef.current?.offsetHeight || 0 + setCollectorTableHeight(Math.max(220, regionHeight - 56)) + } + + updateTableHeight() + + if (typeof ResizeObserver === 'undefined') { + return undefined + } + + const observer = new ResizeObserver(updateTableHeight) + if (collectorTableRegionRef.current) observer.observe(collectorTableRegionRef.current) + + return () => observer.disconnect() + }, [collectors.length]) + const saveSection = async (section: 'system' | 'notifications' | 'security', values: object) => { try { await axios.put(`/api/v1/settings/${section}`, values) @@ -219,115 +239,142 @@ function Settings() { return ( - -
- 系统配置中心 - 这一页现在已经直接连接数据库配置和采集调度,不再只是演示表单。 +
+
+
+ 系统配置中心 + 这一页现在已经直接连接数据库配置和采集调度,不再只是演示表单。 +
- - saveSection('system', values)}> - - - - - - - - - - - - - - - - - - - ), - }, - { - key: 'notifications', - label: '通知策略', - children: ( - -
saveSection('notifications', values)}> - - - - - - - - - - - - - - - - - -
- ), - }, - { - key: 'security', - label: '安全策略', - children: ( - -
saveSection('security', values)}> - - - - - - - -
+
+ + +
+ saveSection('system', values)}> + + + + + + + + + + + + + + + + + +
+
- - ), - }, - ]} - /> - + ), + }, + { + key: 'notifications', + label: '通知策略', + children: ( +
+ +
+
saveSection('notifications', values)}> + + + + + + + + + + + + + + + + + +
+
+
+ ), + }, + { + key: 'security', + label: '安全策略', + children: ( +
+ +
+
saveSection('security', values)}> + + + + + + + +
+ + + + ), + }, + ]} + /> + + ) } export default Settings - diff --git a/planet.sh b/planet.sh index 63c44dc4..8d6380f4 100755 --- a/planet.sh +++ b/planet.sh @@ -11,6 +11,27 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' +ensure_frontend_deps() { + echo -e "${BLUE}📦 检查前端依赖...${NC}" + + if ! command -v bun >/dev/null 2>&1; then + echo -e "${RED}❌ 未找到 bun,请先安装或加载 bun 到 PATH${NC}" + exit 1 + fi + + cd "$SCRIPT_DIR/frontend" + + if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then + echo -e "${YELLOW}⚠️ 前端依赖缺失,正在执行 bun install...${NC}" + bun install + fi + + if [ ! -x "$SCRIPT_DIR/frontend/node_modules/.bin/vite" ]; then + echo -e "${RED}❌ 前端依赖安装失败,未找到 vite${NC}" + exit 1 + fi +} + start() { echo -e "${BLUE}🚀 启动智能星球计划...${NC}" @@ -25,21 +46,16 @@ start() { PYTHONPATH="$SCRIPT_DIR/backend" nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload > /tmp/planet_backend.log 2>&1 & BACKEND_PID=$! - echo " 等待后端启动..." - for i in {1..10}; do - sleep 2 - if curl -s http://localhost:8000/health > /dev/null 2>&1; then - echo -e " ${GREEN}✅ 后端已就绪${NC}" - break - fi - if [ $i -eq 10 ]; then - echo -e "${RED}❌ 后端启动失败${NC}" - tail -10 /tmp/planet_backend.log - exit 1 - fi - done + sleep 3 + + if ! curl -s http://localhost:8000/health > /dev/null 2>&1; then + echo -e "${RED}❌ 后端启动失败${NC}" + tail -10 /tmp/planet_backend.log + exit 1 + fi echo -e "${BLUE}🌐 启动前端...${NC}" + ensure_frontend_deps pkill -f "vite" 2>/dev/null || true pkill -f "bun run dev" 2>/dev/null || true cd "$SCRIPT_DIR/frontend" From 020c1d5051e86e6ce7dd820e493639f140c9df5a Mon Sep 17 00:00:00 2001 From: linkong Date: Wed, 25 Mar 2026 17:19:10 +0800 Subject: [PATCH 9/9] Refine data management and collection workflows --- .gitignore | 2 + .python-version | 1 + backend/app/api/v1/collected_data.py | 290 +++++---- backend/app/api/v1/datasources.py | 46 +- backend/app/api/v1/visualization.py | 45 +- backend/app/core/collected_data_fields.py | 62 ++ backend/app/core/countries.py | 280 ++++++++ backend/app/db/session.py | 53 +- backend/app/main.py | 8 +- backend/app/models/__init__.py | 2 + backend/app/models/collected_data.py | 42 +- backend/app/models/data_snapshot.py | 26 + backend/app/models/task.py | 1 + .../services/collectors/arcgis_relation.py | 133 +++- backend/app/services/collectors/base.py | 245 ++++++- backend/app/services/collectors/peeringdb.py | 6 +- backend/app/services/collectors/top500.py | 210 ++++-- backend/app/services/scheduler.py | 47 +- docs/collected-data-column-removal-plan.md | 207 ++++++ docs/collected-data-history-plan.md | 402 ++++++++++++ docs/system-settings-plan.md | 3 +- frontend/src/index.css | 306 ++++++++- frontend/src/main.tsx | 2 +- frontend/src/pages/Dashboard/Dashboard.tsx | 14 +- frontend/src/pages/DataList/DataList.tsx | 479 +++++++++++--- .../src/pages/DataSources/DataSources.tsx | 207 ++++-- frontend/src/pages/Settings/Settings.tsx | 250 +++---- frontend/src/pages/Users/Users.tsx | 40 +- planet.sh | 24 +- pyproject.toml | 24 +- scripts/backfill_collected_data_metadata.py | 57 ++ ...eck_collected_data_column_removal_ready.py | 119 ++++ scripts/drop_collected_data_legacy_columns.py | 41 ++ uv.lock | 614 ++++++++---------- 34 files changed, 3341 insertions(+), 947 deletions(-) create mode 100644 .python-version create mode 100644 backend/app/core/collected_data_fields.py create mode 100644 backend/app/core/countries.py create mode 100644 backend/app/models/data_snapshot.py create mode 100644 docs/collected-data-column-removal-plan.md create mode 100644 docs/collected-data-history-plan.md create mode 100644 scripts/backfill_collected_data_metadata.py create mode 100644 scripts/check_collected_data_column_removal_ready.py create mode 100644 scripts/drop_collected_data_legacy_columns.py diff --git a/.gitignore b/.gitignore index a621032e..bb7024c5 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,8 @@ MANIFEST venv/ ENV/ env/ +.uv/ +.uv-cache/ .ruff_cache/ *.db *.sqlite diff --git a/.python-version b/.python-version new file mode 100644 index 00000000..6324d401 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/backend/app/api/v1/collected_data.py b/backend/app/api/v1/collected_data.py index bf0b9e17..0b77edfa 100644 --- a/backend/app/api/v1/collected_data.py +++ b/backend/app/api/v1/collected_data.py @@ -7,6 +7,8 @@ import json import csv import io +from app.core.collected_data_fields import get_metadata_field +from app.core.countries import COUNTRY_OPTIONS, get_country_search_variants, normalize_country from app.db.session import get_db from app.models.user import User from app.core.security import get_current_user @@ -15,8 +17,119 @@ from app.models.collected_data import CollectedData router = APIRouter() +COUNTRY_SQL = "metadata->>'country'" +SEARCHABLE_SQL = [ + "name", + "title", + "description", + "source", + "data_type", + "source_id", + "metadata::text", +] + + +def parse_multi_values(value: Optional[str]) -> list[str]: + if not value: + return [] + return [item.strip() for item in value.split(",") if item.strip()] + + +def build_in_condition(field_sql: str, values: list[str], param_prefix: str, params: dict) -> str: + placeholders = [] + for index, value in enumerate(values): + key = f"{param_prefix}_{index}" + params[key] = value + placeholders.append(f":{key}") + return f"{field_sql} IN ({', '.join(placeholders)})" + + +def build_search_condition(search: Optional[str], params: dict) -> Optional[str]: + if not search: + return None + + normalized = search.strip() + if not normalized: + return None + + search_terms = [normalized] + for variant in get_country_search_variants(normalized): + if variant.casefold() not in {term.casefold() for term in search_terms}: + search_terms.append(variant) + + conditions = [] + for index, term in enumerate(search_terms): + params[f"search_{index}"] = f"%{term}%" + conditions.extend(f"{field} ILIKE :search_{index}" for field in SEARCHABLE_SQL) + + params["search_exact"] = normalized + params["search_prefix"] = f"{normalized}%" + + canonical_variants = get_country_search_variants(normalized) + canonical = canonical_variants[0] if canonical_variants else None + params["country_search_exact"] = canonical or normalized + params["country_search_prefix"] = f"{(canonical or normalized)}%" + + return "(" + " OR ".join(conditions) + ")" + + +def build_search_rank_sql(search: Optional[str]) -> str: + if not search or not search.strip(): + return "0" + + return """ + CASE + WHEN name ILIKE :search_exact THEN 700 + WHEN name ILIKE :search_prefix THEN 600 + WHEN title ILIKE :search_exact THEN 500 + WHEN title ILIKE :search_prefix THEN 400 + WHEN metadata->>'country' ILIKE :country_search_exact THEN 380 + WHEN metadata->>'country' ILIKE :country_search_prefix THEN 340 + WHEN source_id ILIKE :search_exact THEN 350 + WHEN source ILIKE :search_exact THEN 300 + WHEN data_type ILIKE :search_exact THEN 250 + WHEN description ILIKE :search_0 THEN 150 + WHEN metadata::text ILIKE :search_0 THEN 100 + WHEN title ILIKE :search_0 THEN 80 + WHEN name ILIKE :search_0 THEN 60 + WHEN source ILIKE :search_0 THEN 40 + WHEN data_type ILIKE :search_0 THEN 30 + WHEN source_id ILIKE :search_0 THEN 20 + ELSE 0 + END + """ + + +def serialize_collected_row(row) -> dict: + metadata = row[7] + return { + "id": row[0], + "source": row[1], + "source_id": row[2], + "data_type": row[3], + "name": row[4], + "title": row[5], + "description": row[6], + "country": get_metadata_field(metadata, "country"), + "city": get_metadata_field(metadata, "city"), + "latitude": get_metadata_field(metadata, "latitude"), + "longitude": get_metadata_field(metadata, "longitude"), + "value": get_metadata_field(metadata, "value"), + "unit": get_metadata_field(metadata, "unit"), + "metadata": metadata, + "cores": get_metadata_field(metadata, "cores"), + "rmax": get_metadata_field(metadata, "rmax"), + "rpeak": get_metadata_field(metadata, "rpeak"), + "power": get_metadata_field(metadata, "power"), + "collected_at": row[8].isoformat() if row[8] else None, + "reference_date": row[9].isoformat() if row[9] else None, + "is_valid": row[10], + } + + @router.get("") async def list_collected_data( + mode: str = Query("current", description="查询模式: current/history"), source: Optional[str] = Query(None, description="数据源过滤"), data_type: Optional[str] = Query(None, description="数据类型过滤"), country: Optional[str] = Query(None, description="国家过滤"), @@ -27,25 +140,30 @@ async def list_collected_data( db: AsyncSession = Depends(get_db), ): """查询采集的数据列表""" + normalized_country = normalize_country(country) if country else None + source_values = parse_multi_values(source) + data_type_values = parse_multi_values(data_type) # Build WHERE clause conditions = [] params = {} - if source: - conditions.append("source = :source") - params["source"] = source - if data_type: - conditions.append("data_type = :data_type") - params["data_type"] = data_type - if country: - conditions.append("country = :country") - params["country"] = country - if search: - conditions.append("(name ILIKE :search OR title ILIKE :search)") - params["search"] = f"%{search}%" + if mode != "history": + conditions.append("COALESCE(is_current, TRUE) = TRUE") + + if source_values: + conditions.append(build_in_condition("source", source_values, "source", params)) + if data_type_values: + conditions.append(build_in_condition("data_type", data_type_values, "data_type", params)) + if normalized_country: + conditions.append(f"{COUNTRY_SQL} = :country") + params["country"] = normalized_country + search_condition = build_search_condition(search, params) + if search_condition: + conditions.append(search_condition) where_sql = " AND ".join(conditions) if conditions else "1=1" + search_rank_sql = build_search_rank_sql(search) # Calculate offset offset = (page - 1) * page_size @@ -58,11 +176,11 @@ async def list_collected_data( # Query data query = text(f""" SELECT id, source, source_id, data_type, name, title, description, - country, city, latitude, longitude, value, unit, - metadata, collected_at, reference_date, is_valid + metadata, collected_at, reference_date, is_valid, + {search_rank_sql} AS search_rank FROM collected_data WHERE {where_sql} - ORDER BY collected_at DESC + ORDER BY search_rank DESC, collected_at DESC LIMIT :limit OFFSET :offset """) params["limit"] = page_size @@ -73,27 +191,7 @@ async def list_collected_data( data = [] for row in rows: - data.append( - { - "id": row[0], - "source": row[1], - "source_id": row[2], - "data_type": row[3], - "name": row[4], - "title": row[5], - "description": row[6], - "country": row[7], - "city": row[8], - "latitude": row[9], - "longitude": row[10], - "value": row[11], - "unit": row[12], - "metadata": row[13], - "collected_at": row[14].isoformat() if row[14] else None, - "reference_date": row[15].isoformat() if row[15] else None, - "is_valid": row[16], - } - ) + data.append(serialize_collected_row(row[:11])) return { "total": total, @@ -105,16 +203,19 @@ async def list_collected_data( @router.get("/summary") async def get_data_summary( + mode: str = Query("current", description="查询模式: current/history"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """获取数据汇总统计""" + where_sql = "WHERE COALESCE(is_current, TRUE) = TRUE" if mode != "history" else "" # By source and data_type result = await db.execute( text(""" SELECT source, data_type, COUNT(*) as count FROM collected_data + """ + where_sql + """ GROUP BY source, data_type ORDER BY source, data_type """) @@ -138,6 +239,7 @@ async def get_data_summary( text(""" SELECT source, COUNT(*) as count FROM collected_data + """ + where_sql + """ GROUP BY source ORDER BY count DESC """) @@ -153,6 +255,7 @@ async def get_data_summary( @router.get("/sources") async def get_data_sources( + mode: str = Query("current", description="查询模式: current/history"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): @@ -160,7 +263,9 @@ async def get_data_sources( result = await db.execute( text(""" - SELECT DISTINCT source FROM collected_data ORDER BY source + SELECT DISTINCT source FROM collected_data + """ + ("WHERE COALESCE(is_current, TRUE) = TRUE " if mode != "history" else "") + """ + ORDER BY source """) ) rows = result.fetchall() @@ -172,6 +277,7 @@ async def get_data_sources( @router.get("/types") async def get_data_types( + mode: str = Query("current", description="查询模式: current/history"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): @@ -179,7 +285,9 @@ async def get_data_types( result = await db.execute( text(""" - SELECT DISTINCT data_type FROM collected_data ORDER BY data_type + SELECT DISTINCT data_type FROM collected_data + """ + ("WHERE COALESCE(is_current, TRUE) = TRUE " if mode != "history" else "") + """ + ORDER BY data_type """) ) rows = result.fetchall() @@ -196,17 +304,8 @@ async def get_countries( ): """获取所有国家列表""" - result = await db.execute( - text(""" - SELECT DISTINCT country FROM collected_data - WHERE country IS NOT NULL AND country != '' - ORDER BY country - """) - ) - rows = result.fetchall() - return { - "countries": [row[0] for row in rows], + "countries": COUNTRY_OPTIONS, } @@ -221,7 +320,6 @@ async def get_collected_data( result = await db.execute( text(""" SELECT id, source, source_id, data_type, name, title, description, - country, city, latitude, longitude, value, unit, metadata, collected_at, reference_date, is_valid FROM collected_data WHERE id = :id @@ -236,25 +334,7 @@ async def get_collected_data( detail="数据不存在", ) - return { - "id": row[0], - "source": row[1], - "source_id": row[2], - "data_type": row[3], - "name": row[4], - "title": row[5], - "description": row[6], - "country": row[7], - "city": row[8], - "latitude": row[9], - "longitude": row[10], - "value": row[11], - "unit": row[12], - "metadata": row[13], - "collected_at": row[14].isoformat() if row[14] else None, - "reference_date": row[15].isoformat() if row[15] else None, - "is_valid": row[16], - } + return serialize_collected_row(row) def build_where_clause( @@ -263,19 +343,21 @@ def build_where_clause( """Build WHERE clause and params for queries""" conditions = [] params = {} + source_values = parse_multi_values(source) + data_type_values = parse_multi_values(data_type) - if source: - conditions.append("source = :source") - params["source"] = source - if data_type: - conditions.append("data_type = :data_type") - params["data_type"] = data_type - if country: - conditions.append("country = :country") - params["country"] = country - if search: - conditions.append("(name ILIKE :search OR title ILIKE :search)") - params["search"] = f"%{search}%" + if source_values: + conditions.append(build_in_condition("source", source_values, "source", params)) + if data_type_values: + conditions.append(build_in_condition("data_type", data_type_values, "data_type", params)) + normalized_country = normalize_country(country) if country else None + + if normalized_country: + conditions.append(f"{COUNTRY_SQL} = :country") + params["country"] = normalized_country + search_condition = build_search_condition(search, params) + if search_condition: + conditions.append(search_condition) where_sql = " AND ".join(conditions) if conditions else "1=1" return where_sql, params @@ -283,6 +365,7 @@ def build_where_clause( @router.get("/export/json") async def export_json( + mode: str = Query("current", description="查询模式: current/history"), source: Optional[str] = Query(None, description="数据源过滤"), data_type: Optional[str] = Query(None, description="数据类型过滤"), country: Optional[str] = Query(None, description="国家过滤"), @@ -294,11 +377,12 @@ async def export_json( """导出数据为 JSON 格式""" where_sql, params = build_where_clause(source, data_type, country, search) + if mode != "history": + where_sql = f"({where_sql}) AND COALESCE(is_current, TRUE) = TRUE" params["limit"] = limit query = text(f""" SELECT id, source, source_id, data_type, name, title, description, - country, city, latitude, longitude, value, unit, metadata, collected_at, reference_date, is_valid FROM collected_data WHERE {where_sql} @@ -311,27 +395,7 @@ async def export_json( data = [] for row in rows: - data.append( - { - "id": row[0], - "source": row[1], - "source_id": row[2], - "data_type": row[3], - "name": row[4], - "title": row[5], - "description": row[6], - "country": row[7], - "city": row[8], - "latitude": row[9], - "longitude": row[10], - "value": row[11], - "unit": row[12], - "metadata": row[13], - "collected_at": row[14].isoformat() if row[14] else None, - "reference_date": row[15].isoformat() if row[15] else None, - "is_valid": row[16], - } - ) + data.append(serialize_collected_row(row)) json_str = json.dumps({"data": data, "total": len(data)}, ensure_ascii=False, indent=2) @@ -346,6 +410,7 @@ async def export_json( @router.get("/export/csv") async def export_csv( + mode: str = Query("current", description="查询模式: current/history"), source: Optional[str] = Query(None, description="数据源过滤"), data_type: Optional[str] = Query(None, description="数据类型过滤"), country: Optional[str] = Query(None, description="国家过滤"), @@ -357,11 +422,12 @@ async def export_csv( """导出数据为 CSV 格式""" where_sql, params = build_where_clause(source, data_type, country, search) + if mode != "history": + where_sql = f"({where_sql}) AND COALESCE(is_current, TRUE) = TRUE" params["limit"] = limit query = text(f""" SELECT id, source, source_id, data_type, name, title, description, - country, city, latitude, longitude, value, unit, metadata, collected_at, reference_date, is_valid FROM collected_data WHERE {where_sql} @@ -409,16 +475,16 @@ async def export_csv( row[4], row[5], row[6], - row[7], - row[8], - row[9], + get_metadata_field(row[7], "country"), + get_metadata_field(row[7], "city"), + get_metadata_field(row[7], "latitude"), + get_metadata_field(row[7], "longitude"), + get_metadata_field(row[7], "value"), + get_metadata_field(row[7], "unit"), + json.dumps(row[7]) if row[7] else "", + row[8].isoformat() if row[8] else "", + row[9].isoformat() if row[9] else "", row[10], - row[11], - row[12], - json.dumps(row[13]) if row[13] else "", - row[14].isoformat() if row[14] else "", - row[15].isoformat() if row[15] else "", - row[16], ] ) diff --git a/backend/app/api/v1/datasources.py b/backend/app/api/v1/datasources.py index d4b73889..be4e4543 100644 --- a/backend/app/api/v1/datasources.py +++ b/backend/app/api/v1/datasources.py @@ -5,12 +5,13 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.security import get_current_user +from app.core.data_sources import get_data_sources_config from app.db.session import get_db from app.models.collected_data import CollectedData from app.models.datasource import DataSource from app.models.task import CollectionTask from app.models.user import User -from app.services.scheduler import run_collector_now, sync_datasource_job +from app.services.scheduler import get_latest_task_id_for_datasource, run_collector_now, sync_datasource_job router = APIRouter() @@ -83,9 +84,11 @@ async def list_datasources( datasources = result.scalars().all() collector_list = [] + config = get_data_sources_config() for datasource in datasources: running_task = await get_running_task(db, datasource.id) last_task = await get_last_completed_task(db, datasource.id) + endpoint = await config.get_url(datasource.source, db) data_count_result = await db.execute( select(func.count(CollectedData.id)).where(CollectedData.source == datasource.source) ) @@ -105,10 +108,12 @@ async def list_datasources( "frequency_minutes": datasource.frequency_minutes, "is_active": datasource.is_active, "collector_class": datasource.collector_class, + "endpoint": endpoint, "last_run": last_run, "is_running": running_task is not None, "task_id": running_task.id if running_task else None, "progress": running_task.progress if running_task else None, + "phase": running_task.phase if running_task else None, "records_processed": running_task.records_processed if running_task else None, "total_records": running_task.total_records if running_task else None, } @@ -127,6 +132,9 @@ async def get_datasource( if not datasource: raise HTTPException(status_code=404, detail="Data source not found") + config = get_data_sources_config() + endpoint = await config.get_url(datasource.source, db) + return { "id": datasource.id, "name": datasource.name, @@ -136,6 +144,7 @@ async def get_datasource( "frequency_minutes": datasource.frequency_minutes, "collector_class": datasource.collector_class, "source": datasource.source, + "endpoint": endpoint, "is_active": datasource.is_active, } @@ -212,9 +221,16 @@ async def trigger_datasource( if not success: raise HTTPException(status_code=500, detail=f"Failed to trigger collector '{datasource.source}'") + task_id = None + for _ in range(10): + task_id = await get_latest_task_id_for_datasource(datasource.id) + if task_id is not None: + break + return { "status": "triggered", "source_id": datasource.id, + "task_id": task_id, "collector_name": datasource.source, "message": f"Collector '{datasource.source}' has been triggered", } @@ -252,21 +268,29 @@ async def clear_datasource_data( @router.get("/{source_id}/task-status") async def get_task_status( source_id: str, + task_id: Optional[int] = None, db: AsyncSession = Depends(get_db), ): datasource = await get_datasource_record(db, source_id) if not datasource: raise HTTPException(status_code=404, detail="Data source not found") - running_task = await get_running_task(db, datasource.id) - if not running_task: - return {"is_running": False, "task_id": None, "progress": None} + if task_id is not None: + task = await db.get(CollectionTask, task_id) + if not task or task.datasource_id != datasource.id: + raise HTTPException(status_code=404, detail="Task not found") + else: + task = await get_running_task(db, datasource.id) + + if not task: + return {"is_running": False, "task_id": None, "progress": None, "phase": None, "status": "idle"} return { - "is_running": True, - "task_id": running_task.id, - "progress": running_task.progress, - "records_processed": running_task.records_processed, - "total_records": running_task.total_records, - "status": running_task.status, - } + "is_running": task.status == "running", + "task_id": task.id, + "progress": task.progress, + "phase": task.phase, + "records_processed": task.records_processed, + "total_records": task.total_records, + "status": task.status, + } diff --git a/backend/app/api/v1/visualization.py b/backend/app/api/v1/visualization.py index 39da00c7..c3e295e6 100644 --- a/backend/app/api/v1/visualization.py +++ b/backend/app/api/v1/visualization.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func from typing import List, Dict, Any, Optional +from app.core.collected_data_fields import get_record_field from app.db.session import get_db from app.models.collected_data import CollectedData from app.services.cable_graph import build_graph_from_data, CableGraph @@ -83,9 +84,9 @@ def convert_cable_to_geojson(records: List[CollectedData]) -> Dict[str, Any]: "rfs": metadata.get("rfs"), "RFS": metadata.get("rfs"), "status": metadata.get("status", "active"), - "length": record.value, - "length_km": record.value, - "SHAPE__Length": record.value, + "length": get_record_field(record, "value"), + "length_km": get_record_field(record, "value"), + "SHAPE__Length": get_record_field(record, "value"), "url": metadata.get("url"), "color": metadata.get("color"), "year": metadata.get("year"), @@ -101,8 +102,10 @@ def convert_landing_point_to_geojson(records: List[CollectedData], city_to_cable for record in records: try: - lat = float(record.latitude) if record.latitude else None - lon = float(record.longitude) if record.longitude else None + latitude = get_record_field(record, "latitude") + longitude = get_record_field(record, "longitude") + lat = float(latitude) if latitude else None + lon = float(longitude) if longitude else None except (ValueError, TypeError): continue @@ -116,8 +119,8 @@ def convert_landing_point_to_geojson(records: List[CollectedData], city_to_cable "id": record.id, "source_id": record.source_id, "name": record.name, - "country": record.country, - "city": record.city, + "country": get_record_field(record, "country"), + "city": get_record_field(record, "city"), "is_tbd": metadata.get("is_tbd", False), } @@ -185,9 +188,11 @@ def convert_supercomputer_to_geojson(records: List[CollectedData]) -> Dict[str, for record in records: try: - lat = float(record.latitude) if record.latitude and record.latitude != "0.0" else None + latitude = get_record_field(record, "latitude") + longitude = get_record_field(record, "longitude") + lat = float(latitude) if latitude and latitude != "0.0" else None lon = ( - float(record.longitude) if record.longitude and record.longitude != "0.0" else None + float(longitude) if longitude and longitude != "0.0" else None ) except (ValueError, TypeError): lat, lon = None, None @@ -203,12 +208,12 @@ def convert_supercomputer_to_geojson(records: List[CollectedData]) -> Dict[str, "id": record.id, "name": record.name, "rank": metadata.get("rank"), - "r_max": record.value, - "r_peak": metadata.get("r_peak"), - "cores": metadata.get("cores"), - "power": metadata.get("power"), - "country": record.country, - "city": record.city, + "r_max": get_record_field(record, "rmax"), + "r_peak": get_record_field(record, "rpeak"), + "cores": get_record_field(record, "cores"), + "power": get_record_field(record, "power"), + "country": get_record_field(record, "country"), + "city": get_record_field(record, "city"), "data_type": "supercomputer", }, } @@ -223,8 +228,10 @@ def convert_gpu_cluster_to_geojson(records: List[CollectedData]) -> Dict[str, An for record in records: try: - lat = float(record.latitude) if record.latitude else None - lon = float(record.longitude) if record.longitude else None + latitude = get_record_field(record, "latitude") + longitude = get_record_field(record, "longitude") + lat = float(latitude) if latitude else None + lon = float(longitude) if longitude else None except (ValueError, TypeError): lat, lon = None, None @@ -238,8 +245,8 @@ def convert_gpu_cluster_to_geojson(records: List[CollectedData]) -> Dict[str, An "properties": { "id": record.id, "name": record.name, - "country": record.country, - "city": record.city, + "country": get_record_field(record, "country"), + "city": get_record_field(record, "city"), "metadata": metadata, "data_type": "gpu_cluster", }, diff --git a/backend/app/core/collected_data_fields.py b/backend/app/core/collected_data_fields.py new file mode 100644 index 00000000..5574605c --- /dev/null +++ b/backend/app/core/collected_data_fields.py @@ -0,0 +1,62 @@ +from typing import Any, Dict, Optional + + +FIELD_ALIASES = { + "country": ("country",), + "city": ("city",), + "latitude": ("latitude",), + "longitude": ("longitude",), + "value": ("value",), + "unit": ("unit",), + "cores": ("cores",), + "rmax": ("rmax", "r_max"), + "rpeak": ("rpeak", "r_peak"), + "power": ("power",), +} + + +def get_metadata_field(metadata: Optional[Dict[str, Any]], field: str, fallback: Any = None) -> Any: + if isinstance(metadata, dict): + for key in FIELD_ALIASES.get(field, (field,)): + value = metadata.get(key) + if value not in (None, ""): + return value + return fallback + + +def build_dynamic_metadata( + metadata: Optional[Dict[str, Any]], + *, + country: Any = None, + city: Any = None, + latitude: Any = None, + longitude: Any = None, + value: Any = None, + unit: Any = None, +) -> Dict[str, Any]: + merged = dict(metadata) if isinstance(metadata, dict) else {} + + fallbacks = { + "country": country, + "city": city, + "latitude": latitude, + "longitude": longitude, + "value": value, + "unit": unit, + } + + for field, fallback in fallbacks.items(): + if fallback not in (None, "") and get_metadata_field(merged, field) in (None, ""): + merged[field] = fallback + + return merged + + +def get_record_field(record: Any, field: str) -> Any: + metadata = getattr(record, "extra_data", None) or {} + fallback_attr = field + if field in {"cores", "rmax", "rpeak", "power"}: + fallback = None + else: + fallback = getattr(record, fallback_attr, None) + return get_metadata_field(metadata, field, fallback=fallback) diff --git a/backend/app/core/countries.py b/backend/app/core/countries.py new file mode 100644 index 00000000..b1e8bc3c --- /dev/null +++ b/backend/app/core/countries.py @@ -0,0 +1,280 @@ +import re +from typing import Any, Optional + + +COUNTRY_ENTRIES = [ + ("阿富汗", ["Afghanistan", "AF", "AFG"]), + ("阿尔巴尼亚", ["Albania", "AL", "ALB"]), + ("阿尔及利亚", ["Algeria", "DZ", "DZA"]), + ("安道尔", ["Andorra", "AD", "AND"]), + ("安哥拉", ["Angola", "AO", "AGO"]), + ("安提瓜和巴布达", ["Antigua and Barbuda", "AG", "ATG"]), + ("阿根廷", ["Argentina", "AR", "ARG"]), + ("亚美尼亚", ["Armenia", "AM", "ARM"]), + ("澳大利亚", ["Australia", "AU", "AUS"]), + ("奥地利", ["Austria", "AT", "AUT"]), + ("阿塞拜疆", ["Azerbaijan", "AZ", "AZE"]), + ("巴哈马", ["Bahamas", "BS", "BHS"]), + ("巴林", ["Bahrain", "BH", "BHR"]), + ("孟加拉国", ["Bangladesh", "BD", "BGD"]), + ("巴巴多斯", ["Barbados", "BB", "BRB"]), + ("白俄罗斯", ["Belarus", "BY", "BLR"]), + ("比利时", ["Belgium", "BE", "BEL"]), + ("伯利兹", ["Belize", "BZ", "BLZ"]), + ("贝宁", ["Benin", "BJ", "BEN"]), + ("不丹", ["Bhutan", "BT", "BTN"]), + ("玻利维亚", ["Bolivia", "BO", "BOL", "Bolivia (Plurinational State of)"]), + ("波斯尼亚和黑塞哥维那", ["Bosnia and Herzegovina", "BA", "BIH"]), + ("博茨瓦纳", ["Botswana", "BW", "BWA"]), + ("巴西", ["Brazil", "BR", "BRA"]), + ("文莱", ["Brunei", "BN", "BRN", "Brunei Darussalam"]), + ("保加利亚", ["Bulgaria", "BG", "BGR"]), + ("布基纳法索", ["Burkina Faso", "BF", "BFA"]), + ("布隆迪", ["Burundi", "BI", "BDI"]), + ("柬埔寨", ["Cambodia", "KH", "KHM"]), + ("喀麦隆", ["Cameroon", "CM", "CMR"]), + ("加拿大", ["Canada", "CA", "CAN"]), + ("佛得角", ["Cape Verde", "CV", "CPV", "Cabo Verde"]), + ("中非", ["Central African Republic", "CF", "CAF"]), + ("乍得", ["Chad", "TD", "TCD"]), + ("智利", ["Chile", "CL", "CHL"]), + ("中国", ["China", "CN", "CHN", "Mainland China", "PRC", "People's Republic of China"]), + ("中国(香港)", ["Hong Kong", "HK", "HKG", "Hong Kong SAR", "China Hong Kong", "Hong Kong, China"]), + ("中国(澳门)", ["Macao", "Macau", "MO", "MAC", "Macao SAR", "China Macao", "Macau, China"]), + ("中国(台湾)", ["Taiwan", "TW", "TWN", "Chinese Taipei", "Taiwan, China"]), + ("哥伦比亚", ["Colombia", "CO", "COL"]), + ("科摩罗", ["Comoros", "KM", "COM"]), + ("刚果(布)", ["Republic of the Congo", "Congo", "Congo-Brazzaville", "CG", "COG"]), + ("刚果(金)", ["Democratic Republic of the Congo", "DR Congo", "Congo-Kinshasa", "CD", "COD"]), + ("哥斯达黎加", ["Costa Rica", "CR", "CRI"]), + ("科特迪瓦", ["Cote d'Ivoire", "Côte d'Ivoire", "Ivory Coast", "CI", "CIV"]), + ("克罗地亚", ["Croatia", "HR", "HRV"]), + ("古巴", ["Cuba", "CU", "CUB"]), + ("塞浦路斯", ["Cyprus", "CY", "CYP"]), + ("捷克", ["Czech Republic", "Czechia", "CZ", "CZE"]), + ("丹麦", ["Denmark", "DK", "DNK"]), + ("吉布提", ["Djibouti", "DJ", "DJI"]), + ("多米尼克", ["Dominica", "DM", "DMA"]), + ("多米尼加", ["Dominican Republic", "DO", "DOM"]), + ("厄瓜多尔", ["Ecuador", "EC", "ECU"]), + ("埃及", ["Egypt", "EG", "EGY"]), + ("萨尔瓦多", ["El Salvador", "SV", "SLV"]), + ("赤道几内亚", ["Equatorial Guinea", "GQ", "GNQ"]), + ("厄立特里亚", ["Eritrea", "ER", "ERI"]), + ("爱沙尼亚", ["Estonia", "EE", "EST"]), + ("埃斯瓦蒂尼", ["Eswatini", "SZ", "SWZ", "Swaziland"]), + ("埃塞俄比亚", ["Ethiopia", "ET", "ETH"]), + ("斐济", ["Fiji", "FJ", "FJI"]), + ("芬兰", ["Finland", "FI", "FIN"]), + ("法国", ["France", "FR", "FRA"]), + ("加蓬", ["Gabon", "GA", "GAB"]), + ("冈比亚", ["Gambia", "GM", "GMB"]), + ("格鲁吉亚", ["Georgia", "GE", "GEO"]), + ("德国", ["Germany", "DE", "DEU"]), + ("加纳", ["Ghana", "GH", "GHA"]), + ("希腊", ["Greece", "GR", "GRC"]), + ("格林纳达", ["Grenada", "GD", "GRD"]), + ("危地马拉", ["Guatemala", "GT", "GTM"]), + ("几内亚", ["Guinea", "GN", "GIN"]), + ("几内亚比绍", ["Guinea-Bissau", "GW", "GNB"]), + ("圭亚那", ["Guyana", "GY", "GUY"]), + ("海地", ["Haiti", "HT", "HTI"]), + ("洪都拉斯", ["Honduras", "HN", "HND"]), + ("匈牙利", ["Hungary", "HU", "HUN"]), + ("冰岛", ["Iceland", "IS", "ISL"]), + ("印度", ["India", "IN", "IND"]), + ("印度尼西亚", ["Indonesia", "ID", "IDN"]), + ("伊朗", ["Iran", "IR", "IRN", "Iran (Islamic Republic of)"]), + ("伊拉克", ["Iraq", "IQ", "IRQ"]), + ("爱尔兰", ["Ireland", "IE", "IRL"]), + ("以色列", ["Israel", "IL", "ISR"]), + ("意大利", ["Italy", "IT", "ITA"]), + ("牙买加", ["Jamaica", "JM", "JAM"]), + ("日本", ["Japan", "JP", "JPN"]), + ("约旦", ["Jordan", "JO", "JOR"]), + ("哈萨克斯坦", ["Kazakhstan", "KZ", "KAZ"]), + ("肯尼亚", ["Kenya", "KE", "KEN"]), + ("基里巴斯", ["Kiribati", "KI", "KIR"]), + ("朝鲜", ["North Korea", "Korea, DPRK", "Democratic People's Republic of Korea", "KP", "PRK"]), + ("韩国", ["South Korea", "Republic of Korea", "Korea", "KR", "KOR"]), + ("科威特", ["Kuwait", "KW", "KWT"]), + ("吉尔吉斯斯坦", ["Kyrgyzstan", "KG", "KGZ"]), + ("老挝", ["Laos", "Lao PDR", "Lao People's Democratic Republic", "LA", "LAO"]), + ("拉脱维亚", ["Latvia", "LV", "LVA"]), + ("黎巴嫩", ["Lebanon", "LB", "LBN"]), + ("莱索托", ["Lesotho", "LS", "LSO"]), + ("利比里亚", ["Liberia", "LR", "LBR"]), + ("利比亚", ["Libya", "LY", "LBY"]), + ("列支敦士登", ["Liechtenstein", "LI", "LIE"]), + ("立陶宛", ["Lithuania", "LT", "LTU"]), + ("卢森堡", ["Luxembourg", "LU", "LUX"]), + ("马达加斯加", ["Madagascar", "MG", "MDG"]), + ("马拉维", ["Malawi", "MW", "MWI"]), + ("马来西亚", ["Malaysia", "MY", "MYS"]), + ("马尔代夫", ["Maldives", "MV", "MDV"]), + ("马里", ["Mali", "ML", "MLI"]), + ("马耳他", ["Malta", "MT", "MLT"]), + ("马绍尔群岛", ["Marshall Islands", "MH", "MHL"]), + ("毛里塔尼亚", ["Mauritania", "MR", "MRT"]), + ("毛里求斯", ["Mauritius", "MU", "MUS"]), + ("墨西哥", ["Mexico", "MX", "MEX"]), + ("密克罗尼西亚", ["Micronesia", "FM", "FSM", "Federated States of Micronesia"]), + ("摩尔多瓦", ["Moldova", "MD", "MDA", "Republic of Moldova"]), + ("摩纳哥", ["Monaco", "MC", "MCO"]), + ("蒙古", ["Mongolia", "MN", "MNG"]), + ("黑山", ["Montenegro", "ME", "MNE"]), + ("摩洛哥", ["Morocco", "MA", "MAR"]), + ("莫桑比克", ["Mozambique", "MZ", "MOZ"]), + ("缅甸", ["Myanmar", "MM", "MMR", "Burma"]), + ("纳米比亚", ["Namibia", "NA", "NAM"]), + ("瑙鲁", ["Nauru", "NR", "NRU"]), + ("尼泊尔", ["Nepal", "NP", "NPL"]), + ("荷兰", ["Netherlands", "NL", "NLD"]), + ("新西兰", ["New Zealand", "NZ", "NZL"]), + ("尼加拉瓜", ["Nicaragua", "NI", "NIC"]), + ("尼日尔", ["Niger", "NE", "NER"]), + ("尼日利亚", ["Nigeria", "NG", "NGA"]), + ("北马其顿", ["North Macedonia", "MK", "MKD", "Macedonia"]), + ("挪威", ["Norway", "NO", "NOR"]), + ("阿曼", ["Oman", "OM", "OMN"]), + ("巴基斯坦", ["Pakistan", "PK", "PAK"]), + ("帕劳", ["Palau", "PW", "PLW"]), + ("巴勒斯坦", ["Palestine", "PS", "PSE", "State of Palestine"]), + ("巴拿马", ["Panama", "PA", "PAN"]), + ("巴布亚新几内亚", ["Papua New Guinea", "PG", "PNG"]), + ("巴拉圭", ["Paraguay", "PY", "PRY"]), + ("秘鲁", ["Peru", "PE", "PER"]), + ("菲律宾", ["Philippines", "PH", "PHL"]), + ("波兰", ["Poland", "PL", "POL"]), + ("葡萄牙", ["Portugal", "PT", "PRT"]), + ("卡塔尔", ["Qatar", "QA", "QAT"]), + ("罗马尼亚", ["Romania", "RO", "ROU"]), + ("俄罗斯", ["Russia", "Russian Federation", "RU", "RUS"]), + ("卢旺达", ["Rwanda", "RW", "RWA"]), + ("圣基茨和尼维斯", ["Saint Kitts and Nevis", "KN", "KNA"]), + ("圣卢西亚", ["Saint Lucia", "LC", "LCA"]), + ("圣文森特和格林纳丁斯", ["Saint Vincent and the Grenadines", "VC", "VCT"]), + ("萨摩亚", ["Samoa", "WS", "WSM"]), + ("圣马力诺", ["San Marino", "SM", "SMR"]), + ("圣多美和普林西比", ["Sao Tome and Principe", "ST", "STP", "São Tomé and Príncipe"]), + ("沙特阿拉伯", ["Saudi Arabia", "SA", "SAU"]), + ("塞内加尔", ["Senegal", "SN", "SEN"]), + ("塞尔维亚", ["Serbia", "RS", "SRB", "Kosovo", "XK", "XKS", "Republic of Kosovo"]), + ("塞舌尔", ["Seychelles", "SC", "SYC"]), + ("塞拉利昂", ["Sierra Leone", "SL", "SLE"]), + ("新加坡", ["Singapore", "SG", "SGP"]), + ("斯洛伐克", ["Slovakia", "SK", "SVK"]), + ("斯洛文尼亚", ["Slovenia", "SI", "SVN"]), + ("所罗门群岛", ["Solomon Islands", "SB", "SLB"]), + ("索马里", ["Somalia", "SO", "SOM"]), + ("南非", ["South Africa", "ZA", "ZAF"]), + ("南苏丹", ["South Sudan", "SS", "SSD"]), + ("西班牙", ["Spain", "ES", "ESP"]), + ("斯里兰卡", ["Sri Lanka", "LK", "LKA"]), + ("苏丹", ["Sudan", "SD", "SDN"]), + ("苏里南", ["Suriname", "SR", "SUR"]), + ("瑞典", ["Sweden", "SE", "SWE"]), + ("瑞士", ["Switzerland", "CH", "CHE"]), + ("叙利亚", ["Syria", "SY", "SYR", "Syrian Arab Republic"]), + ("塔吉克斯坦", ["Tajikistan", "TJ", "TJK"]), + ("坦桑尼亚", ["Tanzania", "TZ", "TZA", "United Republic of Tanzania"]), + ("泰国", ["Thailand", "TH", "THA"]), + ("东帝汶", ["Timor-Leste", "East Timor", "TL", "TLS"]), + ("多哥", ["Togo", "TG", "TGO"]), + ("汤加", ["Tonga", "TO", "TON"]), + ("特立尼达和多巴哥", ["Trinidad and Tobago", "TT", "TTO"]), + ("突尼斯", ["Tunisia", "TN", "TUN"]), + ("土耳其", ["Turkey", "TR", "TUR", "Türkiye"]), + ("土库曼斯坦", ["Turkmenistan", "TM", "TKM"]), + ("图瓦卢", ["Tuvalu", "TV", "TUV"]), + ("乌干达", ["Uganda", "UG", "UGA"]), + ("乌克兰", ["Ukraine", "UA", "UKR"]), + ("阿联酋", ["United Arab Emirates", "AE", "ARE", "UAE"]), + ("英国", ["United Kingdom", "UK", "GB", "GBR", "Great Britain", "Britain", "England"]), + ("美国", ["United States", "United States of America", "US", "USA", "U.S.", "U.S.A."]), + ("乌拉圭", ["Uruguay", "UY", "URY"]), + ("乌兹别克斯坦", ["Uzbekistan", "UZ", "UZB"]), + ("瓦努阿图", ["Vanuatu", "VU", "VUT"]), + ("梵蒂冈", ["Vatican City", "Holy See", "VA", "VAT"]), + ("委内瑞拉", ["Venezuela", "VE", "VEN", "Venezuela (Bolivarian Republic of)"]), + ("越南", ["Vietnam", "Viet Nam", "VN", "VNM"]), + ("也门", ["Yemen", "YE", "YEM"]), + ("赞比亚", ["Zambia", "ZM", "ZMB"]), + ("津巴布韦", ["Zimbabwe", "ZW", "ZWE"]), +] + + +COUNTRY_OPTIONS = [entry[0] for entry in COUNTRY_ENTRIES] +CANONICAL_COUNTRY_SET = set(COUNTRY_OPTIONS) +INVALID_COUNTRY_VALUES = { + "", + "-", + "--", + "unknown", + "n/a", + "na", + "none", + "null", + "global", + "world", + "worldwide", + "xx", +} +NUMERIC_LIKE_PATTERN = re.compile(r"^[\d\s,._%+\-]+$") + +COUNTRY_ALIAS_MAP = {} +COUNTRY_VARIANTS_MAP = {} +for canonical, aliases in COUNTRY_ENTRIES: + COUNTRY_ALIAS_MAP[canonical.casefold()] = canonical + variants = [canonical, *aliases] + COUNTRY_VARIANTS_MAP[canonical] = variants + for alias in aliases: + COUNTRY_ALIAS_MAP[alias.casefold()] = canonical + + +def normalize_country(value: Any) -> Optional[str]: + if value is None: + return None + + if not isinstance(value, str): + return None + + normalized = re.sub(r"\s+", " ", value.strip()) + normalized = normalized.replace("(", "(").replace(")", ")") + + if not normalized: + return None + + lowered = normalized.casefold() + if lowered in INVALID_COUNTRY_VALUES: + return None + + if NUMERIC_LIKE_PATTERN.fullmatch(normalized): + return None + + if normalized in CANONICAL_COUNTRY_SET: + return normalized + + return COUNTRY_ALIAS_MAP.get(lowered) + + +def get_country_search_variants(value: Any) -> list[str]: + canonical = normalize_country(value) + if canonical is None: + return [] + + variants = [] + seen = set() + for item in COUNTRY_VARIANTS_MAP.get(canonical, [canonical]): + if not isinstance(item, str): + continue + normalized = re.sub(r"\s+", " ", item.strip()) + if not normalized: + continue + key = normalized.casefold() + if key in seen: + continue + seen.add(key) + variants.append(normalized) + + return variants diff --git a/backend/app/db/session.py b/backend/app/db/session.py index 392ca380..4d3ccf69 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -1,5 +1,6 @@ from typing import AsyncGenerator +from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker from sqlalchemy.orm import declarative_base @@ -63,6 +64,7 @@ async def init_db(): import app.models.user # noqa: F401 import app.models.gpu_cluster # noqa: F401 import app.models.task # noqa: F401 + import app.models.data_snapshot # noqa: F401 import app.models.datasource # noqa: F401 import app.models.datasource_config # noqa: F401 import app.models.alert # noqa: F401 @@ -71,6 +73,55 @@ async def init_db(): async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) + await conn.execute( + text( + """ + ALTER TABLE collected_data + ADD COLUMN IF NOT EXISTS snapshot_id INTEGER, + ADD COLUMN IF NOT EXISTS task_id INTEGER, + ADD COLUMN IF NOT EXISTS entity_key VARCHAR(255), + ADD COLUMN IF NOT EXISTS is_current BOOLEAN DEFAULT TRUE, + ADD COLUMN IF NOT EXISTS previous_record_id INTEGER, + ADD COLUMN IF NOT EXISTS change_type VARCHAR(20), + ADD COLUMN IF NOT EXISTS change_summary JSONB DEFAULT '{}'::jsonb, + ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ + """ + ) + ) + await conn.execute( + text( + """ + ALTER TABLE collection_tasks + ADD COLUMN IF NOT EXISTS phase VARCHAR(30) DEFAULT 'queued' + """ + ) + ) + await conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_collected_data_source_source_id + ON collected_data (source, source_id) + """ + ) + ) + await conn.execute( + text( + """ + UPDATE collected_data + SET entity_key = source || ':' || COALESCE(source_id, id::text) + WHERE entity_key IS NULL + """ + ) + ) + await conn.execute( + text( + """ + UPDATE collected_data + SET is_current = TRUE + WHERE is_current IS NULL + """ + ) + ) async with async_session_factory() as session: - await seed_default_datasources(session) + await seed_default_datasources(session) diff --git a/backend/app/main.py b/backend/app/main.py index 3bd9aa02..a65b77b1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,7 +9,12 @@ from app.api.v1 import websocket from app.core.config import settings from app.core.websocket.broadcaster import broadcaster from app.db.session import init_db -from app.services.scheduler import start_scheduler, stop_scheduler, sync_scheduler_with_datasources +from app.services.scheduler import ( + cleanup_stale_running_tasks, + start_scheduler, + stop_scheduler, + sync_scheduler_with_datasources, +) class WebSocketCORSMiddleware(BaseHTTPMiddleware): @@ -26,6 +31,7 @@ class WebSocketCORSMiddleware(BaseHTTPMiddleware): @asynccontextmanager async def lifespan(app: FastAPI): await init_db() + await cleanup_stale_running_tasks() start_scheduler() await sync_scheduler_with_datasources() broadcaster.start() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 16dc63d4..38e79102 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,6 +1,7 @@ from app.models.user import User from app.models.gpu_cluster import GPUCluster from app.models.task import CollectionTask +from app.models.data_snapshot import DataSnapshot from app.models.datasource import DataSource from app.models.datasource_config import DataSourceConfig from app.models.alert import Alert, AlertSeverity, AlertStatus @@ -10,6 +11,7 @@ __all__ = [ "User", "GPUCluster", "CollectionTask", + "DataSnapshot", "DataSource", "DataSourceConfig", "SystemSetting", diff --git a/backend/app/models/collected_data.py b/backend/app/models/collected_data.py index ef05658c..84791f15 100644 --- a/backend/app/models/collected_data.py +++ b/backend/app/models/collected_data.py @@ -1,8 +1,9 @@ """Collected Data model for storing data from all collectors""" -from sqlalchemy import Column, DateTime, Integer, String, Text, JSON, Index +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, JSON, Index from sqlalchemy.sql import func +from app.core.collected_data_fields import get_record_field from app.db.session import Base @@ -12,8 +13,11 @@ class CollectedData(Base): __tablename__ = "collected_data" id = Column(Integer, primary_key=True, autoincrement=True) + snapshot_id = Column(Integer, ForeignKey("data_snapshots.id"), nullable=True, index=True) + task_id = Column(Integer, ForeignKey("collection_tasks.id"), nullable=True, index=True) source = Column(String(100), nullable=False, index=True) # e.g., "top500", "huggingface_models" source_id = Column(String(100), index=True) # Original ID from source, e.g., "rank_1" + entity_key = Column(String(255), index=True) data_type = Column( String(50), nullable=False, index=True ) # e.g., "supercomputer", "model", "dataset" @@ -23,16 +27,6 @@ class CollectedData(Base): title = Column(String(500)) description = Column(Text) - # Location data (for geo visualization) - country = Column(String(100)) - city = Column(String(100)) - latitude = Column(String(50)) - longitude = Column(String(50)) - - # Performance metrics - value = Column(String(100)) # Generic value field (Rmax, Rpeak, etc.) - unit = Column(String(20)) - # Additional metadata as JSON extra_data = Column( "metadata", JSON, default={} @@ -44,11 +38,17 @@ class CollectedData(Base): # Status is_valid = Column(Integer, default=1) # 1=valid, 0=invalid + is_current = Column(Boolean, default=True, index=True) + previous_record_id = Column(Integer, ForeignKey("collected_data.id"), nullable=True, index=True) + change_type = Column(String(20), nullable=True) + change_summary = Column(JSON, default={}) + deleted_at = Column(DateTime(timezone=True), nullable=True) # Indexes for common queries __table_args__ = ( Index("idx_collected_data_source_collected", "source", "collected_at"), Index("idx_collected_data_source_type", "source", "data_type"), + Index("idx_collected_data_source_source_id", "source", "source_id"), ) def __repr__(self): @@ -58,18 +58,21 @@ class CollectedData(Base): """Convert to dictionary""" return { "id": self.id, + "snapshot_id": self.snapshot_id, + "task_id": self.task_id, "source": self.source, "source_id": self.source_id, + "entity_key": self.entity_key, "data_type": self.data_type, "name": self.name, "title": self.title, "description": self.description, - "country": self.country, - "city": self.city, - "latitude": self.latitude, - "longitude": self.longitude, - "value": self.value, - "unit": self.unit, + "country": get_record_field(self, "country"), + "city": get_record_field(self, "city"), + "latitude": get_record_field(self, "latitude"), + "longitude": get_record_field(self, "longitude"), + "value": get_record_field(self, "value"), + "unit": get_record_field(self, "unit"), "metadata": self.extra_data, "collected_at": self.collected_at.isoformat() if self.collected_at is not None @@ -77,4 +80,9 @@ class CollectedData(Base): "reference_date": self.reference_date.isoformat() if self.reference_date is not None else None, + "is_current": self.is_current, + "previous_record_id": self.previous_record_id, + "change_type": self.change_type, + "change_summary": self.change_summary, + "deleted_at": self.deleted_at.isoformat() if self.deleted_at is not None else None, } diff --git a/backend/app/models/data_snapshot.py b/backend/app/models/data_snapshot.py new file mode 100644 index 00000000..f70b4f12 --- /dev/null +++ b/backend/app/models/data_snapshot.py @@ -0,0 +1,26 @@ +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, JSON, String +from sqlalchemy.sql import func + +from app.db.session import Base + + +class DataSnapshot(Base): + __tablename__ = "data_snapshots" + + id = Column(Integer, primary_key=True, autoincrement=True) + datasource_id = Column(Integer, nullable=False, index=True) + task_id = Column(Integer, ForeignKey("collection_tasks.id"), nullable=True, index=True) + source = Column(String(100), nullable=False, index=True) + snapshot_key = Column(String(100), nullable=True, index=True) + reference_date = Column(DateTime(timezone=True), nullable=True) + started_at = Column(DateTime(timezone=True), server_default=func.now()) + completed_at = Column(DateTime(timezone=True), nullable=True) + record_count = Column(Integer, default=0) + status = Column(String(20), nullable=False, default="running") + is_current = Column(Boolean, default=True, index=True) + parent_snapshot_id = Column(Integer, ForeignKey("data_snapshots.id"), nullable=True, index=True) + summary = Column(JSON, default={}) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + def __repr__(self): + return f"" diff --git a/backend/app/models/task.py b/backend/app/models/task.py index c5509027..12d858c2 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -12,6 +12,7 @@ class CollectionTask(Base): id = Column(Integer, primary_key=True, autoincrement=True) datasource_id = Column(Integer, nullable=False, index=True) status = Column(String(20), nullable=False) # pending, running, success, failed, cancelled + phase = Column(String(30), default="queued") started_at = Column(DateTime(timezone=True)) completed_at = Column(DateTime(timezone=True)) records_processed = Column(Integer, default=0) diff --git a/backend/app/services/collectors/arcgis_relation.py b/backend/app/services/collectors/arcgis_relation.py index 8b98536f..d06a46c8 100644 --- a/backend/app/services/collectors/arcgis_relation.py +++ b/backend/app/services/collectors/arcgis_relation.py @@ -1,10 +1,11 @@ -from typing import Dict, Any, List +import asyncio from datetime import datetime +from typing import Any, Dict, List, Optional + import httpx -from app.services.collectors.base import BaseCollector from app.core.data_sources import get_data_sources_config - +from app.services.collectors.base import BaseCollector class ArcGISCableLandingRelationCollector(BaseCollector): @@ -18,45 +19,129 @@ class ArcGISCableLandingRelationCollector(BaseCollector): def base_url(self) -> str: if self._resolved_url: return self._resolved_url - from app.core.data_sources import get_data_sources_config - config = get_data_sources_config() return config.get_yaml_url("arcgis_cable_landing_relation") + def _layer_url(self, layer_id: int) -> str: + if "/FeatureServer/" not in self.base_url: + return self.base_url + prefix = self.base_url.split("/FeatureServer/")[0] + return f"{prefix}/FeatureServer/{layer_id}/query" + + async def _fetch_layer_attributes( + self, client: httpx.AsyncClient, layer_id: int + ) -> List[Dict[str, Any]]: + response = await client.get( + self._layer_url(layer_id), + params={ + "where": "1=1", + "outFields": "*", + "returnGeometry": "false", + "f": "json", + }, + ) + response.raise_for_status() + data = response.json() + return [feature.get("attributes", {}) for feature in data.get("features", [])] + + async def _fetch_relation_features(self, client: httpx.AsyncClient) -> List[Dict[str, Any]]: + response = await client.get( + self.base_url, + params={ + "where": "1=1", + "outFields": "*", + "returnGeometry": "true", + "f": "geojson", + }, + ) + response.raise_for_status() + data = response.json() + return data.get("features", []) + async def fetch(self) -> List[Dict[str, Any]]: - params = {"where": "1=1", "outFields": "*", "returnGeometry": "true", "f": "geojson"} - async with httpx.AsyncClient(timeout=60.0) as client: - response = await client.get(self.base_url, params=params) - response.raise_for_status() - return self.parse_response(response.json()) + relation_features, landing_rows, cable_rows = await asyncio.gather( + self._fetch_relation_features(client), + self._fetch_layer_attributes(client, 1), + self._fetch_layer_attributes(client, 2), + ) + return self.parse_response(relation_features, landing_rows, cable_rows) - def parse_response(self, data: Dict[str, Any]) -> List[Dict[str, Any]]: - result = [] + def _build_landing_lookup(self, landing_rows: List[Dict[str, Any]]) -> Dict[int, Dict[str, Any]]: + lookup: Dict[int, Dict[str, Any]] = {} + for row in landing_rows: + city_id = row.get("city_id") + if city_id is None: + continue + lookup[int(city_id)] = { + "landing_point_id": row.get("landing_point_id") or city_id, + "landing_point_name": row.get("Name") or row.get("name") or "", + "facility": row.get("facility") or "", + "status": row.get("status") or "", + "country": row.get("country") or "", + } + return lookup - features = data.get("features", []) - for feature in features: + def _build_cable_lookup(self, cable_rows: List[Dict[str, Any]]) -> Dict[int, Dict[str, Any]]: + lookup: Dict[int, Dict[str, Any]] = {} + for row in cable_rows: + cable_id = row.get("cable_id") + if cable_id is None: + continue + lookup[int(cable_id)] = { + "cable_name": row.get("Name") or "", + "status": row.get("status") or "active", + } + return lookup + + def parse_response( + self, + relation_features: List[Dict[str, Any]], + landing_rows: List[Dict[str, Any]], + cable_rows: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: + result: List[Dict[str, Any]] = [] + landing_lookup = self._build_landing_lookup(landing_rows) + cable_lookup = self._build_cable_lookup(cable_rows) + + for feature in relation_features: props = feature.get("properties", {}) try: + city_id = props.get("city_id") + cable_id = props.get("cable_id") + landing_info = landing_lookup.get(int(city_id), {}) if city_id is not None else {} + cable_info = cable_lookup.get(int(cable_id), {}) if cable_id is not None else {} + + cable_name = cable_info.get("cable_name") or props.get("cable_name") or "Unknown" + landing_point_name = ( + landing_info.get("landing_point_name") + or props.get("landing_point_name") + or "Unknown" + ) + facility = landing_info.get("facility") or props.get("facility") or "-" + status = cable_info.get("status") or landing_info.get("status") or props.get("status") or "-" + country = landing_info.get("country") or props.get("country") or "" + landing_point_id = landing_info.get("landing_point_id") or props.get("landing_point_id") or city_id + entry = { "source_id": f"arcgis_relation_{props.get('OBJECTID', props.get('id', ''))}", - "name": f"{props.get('cable_name', 'Unknown')} - {props.get('landing_point_name', 'Unknown')}", - "country": props.get("country", ""), - "city": props.get("landing_point_name", ""), + "name": f"{cable_name} - {landing_point_name}", + "country": country, + "city": landing_point_name, "latitude": str(props.get("latitude", "")) if props.get("latitude") else "", "longitude": str(props.get("longitude", "")) if props.get("longitude") else "", "value": "", "unit": "", "metadata": { "objectid": props.get("OBJECTID"), - "city_id": props.get("city_id"), - "cable_id": props.get("cable_id"), - "cable_name": props.get("cable_name"), - "landing_point_id": props.get("landing_point_id"), - "landing_point_name": props.get("landing_point_name"), - "facility": props.get("facility"), - "status": props.get("status"), + "city_id": city_id, + "cable_id": cable_id, + "cable_name": cable_name, + "landing_point_id": landing_point_id, + "landing_point_name": landing_point_name, + "facility": facility, + "status": status, }, "reference_date": datetime.utcnow().strftime("%Y-%m-%d"), } diff --git a/backend/app/services/collectors/base.py b/backend/app/services/collectors/base.py index 77a1948c..0288dd61 100644 --- a/backend/app/services/collectors/base.py +++ b/backend/app/services/collectors/base.py @@ -4,10 +4,12 @@ from abc import ABC, abstractmethod from typing import Dict, List, Any, Optional from datetime import datetime import httpx -from sqlalchemy import text +from sqlalchemy import select, text from sqlalchemy.ext.asyncio import AsyncSession +from app.core.collected_data_fields import build_dynamic_metadata, get_record_field from app.core.config import settings +from app.core.countries import normalize_country class BaseCollector(ABC): @@ -39,6 +41,11 @@ class BaseCollector(ABC): records_processed / self._current_task.total_records ) * 100 + async def set_phase(self, phase: str): + if self._current_task and self._db_session: + self._current_task.phase = phase + await self._db_session.commit() + @abstractmethod async def fetch(self) -> List[Dict[str, Any]]: """Fetch raw data from source""" @@ -48,14 +55,87 @@ class BaseCollector(ABC): """Transform raw data to internal format (default: pass through)""" return raw_data + def _parse_reference_date(self, value: Any) -> Optional[datetime]: + if not value: + return None + if isinstance(value, datetime): + return value + if isinstance(value, str): + return datetime.fromisoformat(value.replace("Z", "+00:00")) + return None + + def _build_comparable_payload(self, record: Any) -> Dict[str, Any]: + return { + "name": getattr(record, "name", None), + "title": getattr(record, "title", None), + "description": getattr(record, "description", None), + "country": get_record_field(record, "country"), + "city": get_record_field(record, "city"), + "latitude": get_record_field(record, "latitude"), + "longitude": get_record_field(record, "longitude"), + "value": get_record_field(record, "value"), + "unit": get_record_field(record, "unit"), + "metadata": getattr(record, "extra_data", None) or {}, + "reference_date": ( + getattr(record, "reference_date", None).isoformat() + if getattr(record, "reference_date", None) + else None + ), + } + + async def _create_snapshot( + self, + db: AsyncSession, + task_id: int, + data: List[Dict[str, Any]], + started_at: datetime, + ) -> int: + from app.models.data_snapshot import DataSnapshot + + reference_dates = [ + parsed + for parsed in (self._parse_reference_date(item.get("reference_date")) for item in data) + if parsed is not None + ] + reference_date = max(reference_dates) if reference_dates else None + + result = await db.execute( + select(DataSnapshot) + .where(DataSnapshot.source == self.name, DataSnapshot.is_current == True) + .order_by(DataSnapshot.completed_at.desc().nullslast(), DataSnapshot.id.desc()) + .limit(1) + ) + previous_snapshot = result.scalar_one_or_none() + + snapshot = DataSnapshot( + datasource_id=getattr(self, "_datasource_id", 1), + task_id=task_id, + source=self.name, + snapshot_key=f"{self.name}:{task_id}", + reference_date=reference_date, + started_at=started_at, + status="running", + is_current=True, + parent_snapshot_id=previous_snapshot.id if previous_snapshot else None, + summary={}, + ) + db.add(snapshot) + + if previous_snapshot: + previous_snapshot.is_current = False + + await db.commit() + return snapshot.id + async def run(self, db: AsyncSession) -> Dict[str, Any]: """Full pipeline: fetch -> transform -> save""" from app.services.collectors.registry import collector_registry from app.models.task import CollectionTask - from app.models.collected_data import CollectedData + from app.models.data_snapshot import DataSnapshot start_time = datetime.utcnow() datasource_id = getattr(self, "_datasource_id", 1) + snapshot_id: Optional[int] = None if not collector_registry.is_active(self.name): return {"status": "skipped", "reason": "Collector is disabled"} @@ -63,6 +143,7 @@ class BaseCollector(ABC): task = CollectionTask( datasource_id=datasource_id, status="running", + phase="queued", started_at=start_time, ) db.add(task) @@ -75,15 +156,20 @@ class BaseCollector(ABC): await self.resolve_url(db) try: + await self.set_phase("fetching") raw_data = await self.fetch() task.total_records = len(raw_data) await db.commit() + await self.set_phase("transforming") data = self.transform(raw_data) + snapshot_id = await self._create_snapshot(db, task_id, data, start_time) - records_count = await self._save_data(db, data) + await self.set_phase("saving") + records_count = await self._save_data(db, data, task_id=task_id, snapshot_id=snapshot_id) task.status = "success" + task.phase = "completed" task.records_processed = records_count task.progress = 100.0 task.completed_at = datetime.utcnow() @@ -97,8 +183,15 @@ class BaseCollector(ABC): } except Exception as e: task.status = "failed" + task.phase = "failed" task.error_message = str(e) task.completed_at = datetime.utcnow() + if snapshot_id is not None: + snapshot = await db.get(DataSnapshot, snapshot_id) + if snapshot: + snapshot.status = "failed" + snapshot.completed_at = datetime.utcnow() + snapshot.summary = {"error": str(e)} await db.commit() return { @@ -108,53 +201,163 @@ class BaseCollector(ABC): "execution_time_seconds": (datetime.utcnow() - start_time).total_seconds(), } - async def _save_data(self, db: AsyncSession, data: List[Dict[str, Any]]) -> int: + async def _save_data( + self, + db: AsyncSession, + data: List[Dict[str, Any]], + task_id: Optional[int] = None, + snapshot_id: Optional[int] = None, + ) -> int: """Save transformed data to database""" from app.models.collected_data import CollectedData + from app.models.data_snapshot import DataSnapshot if not data: + if snapshot_id is not None: + snapshot = await db.get(DataSnapshot, snapshot_id) + if snapshot: + snapshot.record_count = 0 + snapshot.summary = {"created": 0, "updated": 0, "unchanged": 0} + snapshot.status = "success" + snapshot.completed_at = datetime.utcnow() + await db.commit() return 0 collected_at = datetime.utcnow() records_added = 0 + created_count = 0 + updated_count = 0 + unchanged_count = 0 + seen_entity_keys: set[str] = set() + previous_current_keys: set[str] = set() + + previous_current_result = await db.execute( + select(CollectedData.entity_key).where( + CollectedData.source == self.name, + CollectedData.is_current == True, + ) + ) + previous_current_keys = {row[0] for row in previous_current_result.fetchall() if row[0]} for i, item in enumerate(data): print( f"DEBUG: Saving item {i}: name={item.get('name')}, metadata={item.get('metadata', 'NOT FOUND')}" ) + raw_metadata = item.get("metadata", {}) + extra_data = build_dynamic_metadata( + raw_metadata, + country=item.get("country"), + city=item.get("city"), + latitude=item.get("latitude"), + longitude=item.get("longitude"), + value=item.get("value"), + unit=item.get("unit"), + ) + normalized_country = normalize_country(item.get("country")) + if normalized_country is not None: + extra_data["country"] = normalized_country + + if item.get("country") and normalized_country != item.get("country"): + extra_data["raw_country"] = item.get("country") + if normalized_country is None: + extra_data["country_validation"] = "invalid" + + source_id = item.get("source_id") or item.get("id") + reference_date = ( + self._parse_reference_date(item.get("reference_date")) + ) + source_id_str = str(source_id) if source_id is not None else None + entity_key = f"{self.name}:{source_id_str}" if source_id_str else f"{self.name}:{i}" + previous_record = None + + if entity_key and entity_key not in seen_entity_keys: + result = await db.execute( + select(CollectedData) + .where( + CollectedData.source == self.name, + CollectedData.entity_key == entity_key, + CollectedData.is_current == True, + ) + .order_by(CollectedData.collected_at.desc().nullslast(), CollectedData.id.desc()) + ) + previous_records = result.scalars().all() + if previous_records: + previous_record = previous_records[0] + for old_record in previous_records: + old_record.is_current = False + record = CollectedData( + snapshot_id=snapshot_id, + task_id=task_id, source=self.name, - source_id=item.get("source_id") or item.get("id"), + source_id=source_id_str, + entity_key=entity_key, data_type=self.data_type, name=item.get("name"), title=item.get("title"), description=item.get("description"), - country=item.get("country"), - city=item.get("city"), - latitude=str(item.get("latitude", "")) - if item.get("latitude") is not None - else None, - longitude=str(item.get("longitude", "")) - if item.get("longitude") is not None - else None, - value=item.get("value"), - unit=item.get("unit"), - extra_data=item.get("metadata", {}), + extra_data=extra_data, collected_at=collected_at, - reference_date=datetime.fromisoformat( - item.get("reference_date").replace("Z", "+00:00") - ) - if item.get("reference_date") - else None, + reference_date=reference_date, is_valid=1, + is_current=True, + previous_record_id=previous_record.id if previous_record else None, + deleted_at=None, ) + + if previous_record is None: + record.change_type = "created" + record.change_summary = {} + created_count += 1 + else: + previous_payload = self._build_comparable_payload(previous_record) + current_payload = self._build_comparable_payload(record) + if current_payload == previous_payload: + record.change_type = "unchanged" + record.change_summary = {} + unchanged_count += 1 + else: + changed_fields = [ + key for key in current_payload.keys() if current_payload[key] != previous_payload.get(key) + ] + record.change_type = "updated" + record.change_summary = {"changed_fields": changed_fields} + updated_count += 1 + db.add(record) + seen_entity_keys.add(entity_key) records_added += 1 if i % 100 == 0: self.update_progress(i + 1) await db.commit() + if snapshot_id is not None: + deleted_keys = previous_current_keys - seen_entity_keys + await db.execute( + text( + """ + UPDATE collected_data + SET is_current = FALSE + WHERE source = :source + AND snapshot_id IS DISTINCT FROM :snapshot_id + AND COALESCE(is_current, TRUE) = TRUE + """ + ), + {"source": self.name, "snapshot_id": snapshot_id}, + ) + snapshot = await db.get(DataSnapshot, snapshot_id) + if snapshot: + snapshot.record_count = records_added + snapshot.status = "success" + snapshot.completed_at = datetime.utcnow() + snapshot.summary = { + "created": created_count, + "updated": updated_count, + "unchanged": unchanged_count, + "deleted": len(deleted_keys), + } + await db.commit() self.update_progress(len(data)) return records_added diff --git a/backend/app/services/collectors/peeringdb.py b/backend/app/services/collectors/peeringdb.py index bf59b1b0..3c2b0179 100644 --- a/backend/app/services/collectors/peeringdb.py +++ b/backend/app/services/collectors/peeringdb.py @@ -76,7 +76,7 @@ class PeeringDBIXPCollector(HTTPCollector): print(f"Warning: PeeringDB collection failed after {max_retries} retries: {last_error}") return {} - async def collect(self) -> List[Dict[str, Any]]: + async def fetch(self) -> List[Dict[str, Any]]: """Collect IXP data from PeeringDB with rate limit handling""" response_data = await self.fetch_with_retry() if not response_data: @@ -177,7 +177,7 @@ class PeeringDBNetworkCollector(HTTPCollector): print(f"Warning: PeeringDB collection failed after {max_retries} retries: {last_error}") return {} - async def collect(self) -> List[Dict[str, Any]]: + async def fetch(self) -> List[Dict[str, Any]]: """Collect Network data from PeeringDB with rate limit handling""" response_data = await self.fetch_with_retry() if not response_data: @@ -280,7 +280,7 @@ class PeeringDBFacilityCollector(HTTPCollector): print(f"Warning: PeeringDB collection failed after {max_retries} retries: {last_error}") return {} - async def collect(self) -> List[Dict[str, Any]]: + async def fetch(self) -> List[Dict[str, Any]]: """Collect Facility data from PeeringDB with rate limit handling""" response_data = await self.fetch_with_retry() if not response_data: diff --git a/backend/app/services/collectors/top500.py b/backend/app/services/collectors/top500.py index 78872374..9e21937d 100644 --- a/backend/app/services/collectors/top500.py +++ b/backend/app/services/collectors/top500.py @@ -4,9 +4,9 @@ Collects data from TOP500 supercomputer rankings. https://top500.org/lists/top500/ """ +import asyncio import re from typing import Dict, Any, List -from datetime import datetime from bs4 import BeautifulSoup import httpx @@ -21,14 +21,108 @@ class TOP500Collector(BaseCollector): data_type = "supercomputer" async def fetch(self) -> List[Dict[str, Any]]: - """Fetch TOP500 data from website (scraping)""" - # Get the latest list page + """Fetch TOP500 list data and enrich each row with detail-page metadata.""" url = "https://top500.org/lists/top500/list/2025/11/" - async with httpx.AsyncClient(timeout=60.0) as client: + async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client: response = await client.get(url) response.raise_for_status() - return self.parse_response(response.text) + entries = self.parse_response(response.text) + + semaphore = asyncio.Semaphore(8) + + async def enrich(entry: Dict[str, Any]) -> Dict[str, Any]: + detail_url = entry.pop("_detail_url", "") + if not detail_url: + return entry + + async with semaphore: + try: + detail_response = await client.get(detail_url) + detail_response.raise_for_status() + entry["metadata"].update(self.parse_detail_response(detail_response.text)) + except Exception: + entry["metadata"]["detail_fetch_failed"] = True + return entry + + return await asyncio.gather(*(enrich(entry) for entry in entries)) + + def _extract_system_fields(self, system_cell) -> Dict[str, str]: + link = system_cell.find("a") + system_name = link.get_text(" ", strip=True) if link else system_cell.get_text(" ", strip=True) + detail_url = "" + if link and link.get("href"): + detail_url = f"https://top500.org{link.get('href')}" + + manufacturer = "" + if link and link.next_sibling: + manufacturer = str(link.next_sibling).strip(" ,\n\t") + + cell_text = system_cell.get_text("\n", strip=True) + lines = [line.strip(" ,") for line in cell_text.splitlines() if line.strip()] + + site = "" + country = "" + if lines: + system_name = lines[0] + if len(lines) >= 3: + site = lines[-2] + country = lines[-1] + elif len(lines) == 2: + country = lines[-1] + + if not manufacturer and len(lines) >= 2: + manufacturer = lines[1] + + return { + "name": system_name, + "manufacturer": manufacturer, + "site": site, + "country": country, + "detail_url": detail_url, + } + + def parse_detail_response(self, html: str) -> Dict[str, Any]: + soup = BeautifulSoup(html, "html.parser") + detail_table = soup.find("table", {"class": "table table-condensed"}) + if not detail_table: + return {} + + detail_map: Dict[str, Any] = {} + label_aliases = { + "Site": "site", + "Manufacturer": "manufacturer", + "Cores": "cores", + "Processor": "processor", + "Interconnect": "interconnect", + "Installation Year": "installation_year", + "Linpack Performance (Rmax)": "rmax", + "Theoretical Peak (Rpeak)": "rpeak", + "Nmax": "nmax", + "HPCG": "hpcg", + "Power": "power", + "Power Measurement Level": "power_measurement_level", + "Operating System": "operating_system", + "Compiler": "compiler", + "Math Library": "math_library", + "MPI": "mpi", + } + + for row in detail_table.find_all("tr"): + header = row.find("th") + value_cell = row.find("td") + if not header or not value_cell: + continue + + label = header.get_text(" ", strip=True).rstrip(":") + key = label_aliases.get(label) + if not key: + continue + + value = value_cell.get_text(" ", strip=True) + detail_map[key] = value + + return detail_map def parse_response(self, html: str) -> List[Dict[str, Any]]: """Parse TOP500 HTML response""" @@ -36,27 +130,26 @@ class TOP500Collector(BaseCollector): soup = BeautifulSoup(html, "html.parser") # Find the table with TOP500 data - table = soup.find("table", {"class": "top500-table"}) - if not table: - # Try alternative table selector - table = soup.find("table", {"id": "top500"}) + table = None + for candidate in soup.find_all("table"): + header_cells = [ + cell.get_text(" ", strip=True) for cell in candidate.select("thead th") + ] + normalized_headers = [header.lower() for header in header_cells] + if ( + "rank" in normalized_headers + and "system" in normalized_headers + and any("cores" in header for header in normalized_headers) + and any("rmax" in header for header in normalized_headers) + ): + table = candidate + break if not table: - # Try to find any table with rank data - tables = soup.find_all("table") - for t in tables: - if t.find(string=re.compile(r"Rank.*System.*Cores.*Rmax", re.I)): - table = t - break - - if not table: - # Fallback: try to extract data from any table - tables = soup.find_all("table") - if tables: - table = tables[0] + table = soup.find("table", {"class": "top500-table"}) or soup.find("table", {"id": "top500"}) if table: - rows = table.find_all("tr") + rows = table.select("tr") for row in rows[1:]: # Skip header row cells = row.find_all(["td", "th"]) if len(cells) >= 6: @@ -68,43 +161,26 @@ class TOP500Collector(BaseCollector): rank = int(rank_text) - # System name (may contain link) system_cell = cells[1] - system_name = system_cell.get_text(strip=True) - # Try to get full name from link title or data attribute - link = system_cell.find("a") - if link and link.get("title"): - system_name = link.get("title") + system_fields = self._extract_system_fields(system_cell) + system_name = system_fields["name"] + manufacturer = system_fields["manufacturer"] + site = system_fields["site"] + country = system_fields["country"] + detail_url = system_fields["detail_url"] - # Country - country_cell = cells[2] - country = country_cell.get_text(strip=True) - # Try to get country from data attribute or image alt - img = country_cell.find("img") - if img and img.get("alt"): - country = img.get("alt") - - # Extract location (city) city = "" - location_text = country_cell.get_text(strip=True) - if "(" in location_text and ")" in location_text: - city = location_text.split("(")[0].strip() + cores = cells[2].get_text(strip=True).replace(",", "") - # Cores - cores = cells[3].get_text(strip=True).replace(",", "") - - # Rmax - rmax_text = cells[4].get_text(strip=True) + rmax_text = cells[3].get_text(strip=True) rmax = self._parse_performance(rmax_text) - # Rpeak - rpeak_text = cells[5].get_text(strip=True) + rpeak_text = cells[4].get_text(strip=True) rpeak = self._parse_performance(rpeak_text) - # Power (optional) power = "" - if len(cells) >= 7: - power = cells[6].get_text(strip=True) + if len(cells) >= 6: + power = cells[5].get_text(strip=True).replace(",", "") entry = { "source_id": f"top500_{rank}", @@ -117,10 +193,14 @@ class TOP500Collector(BaseCollector): "unit": "PFlop/s", "metadata": { "rank": rank, - "r_peak": rpeak, - "power": power, "cores": cores, + "rmax": rmax_text, + "rpeak": rpeak_text, + "power": power, + "manufacturer": manufacturer, + "site": site, }, + "_detail_url": detail_url, "reference_date": "2025-11-01", } data.append(entry) @@ -184,10 +264,15 @@ class TOP500Collector(BaseCollector): "unit": "PFlop/s", "metadata": { "rank": 1, - "r_peak": 2746.38, - "power": 29581, - "cores": 11039616, + "cores": "11039616", + "rmax": "1742.00", + "rpeak": "2746.38", + "power": "29581", "manufacturer": "HPE", + "site": "DOE/NNSA/LLNL", + "processor": "AMD 4th Gen EPYC 24C 1.8GHz", + "interconnect": "Slingshot-11", + "installation_year": "2025", }, "reference_date": "2025-11-01", }, @@ -202,10 +287,12 @@ class TOP500Collector(BaseCollector): "unit": "PFlop/s", "metadata": { "rank": 2, - "r_peak": 2055.72, - "power": 24607, - "cores": 9066176, + "cores": "9066176", + "rmax": "1353.00", + "rpeak": "2055.72", + "power": "24607", "manufacturer": "HPE", + "site": "DOE/SC/Oak Ridge National Laboratory", }, "reference_date": "2025-11-01", }, @@ -220,9 +307,10 @@ class TOP500Collector(BaseCollector): "unit": "PFlop/s", "metadata": { "rank": 3, - "r_peak": 1980.01, - "power": 38698, - "cores": 9264128, + "cores": "9264128", + "rmax": "1012.00", + "rpeak": "1980.01", + "power": "38698", "manufacturer": "Intel", }, "reference_date": "2025-11-01", diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index 3932ca16..ce43ea66 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -2,8 +2,8 @@ import asyncio import logging -from datetime import datetime -from typing import Any, Dict +from datetime import datetime, timedelta +from typing import Any, Dict, Optional from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.interval import IntervalTrigger @@ -11,6 +11,7 @@ from sqlalchemy import select from app.db.session import async_session_factory from app.models.datasource import DataSource +from app.models.task import CollectionTask from app.services.collectors.registry import collector_registry logger = logging.getLogger(__name__) @@ -89,6 +90,35 @@ async def run_collector_task(collector_name: str): logger.exception("Collector %s failed: %s", collector_name, exc) +async def cleanup_stale_running_tasks(max_age_hours: int = 2) -> int: + """Mark stale running tasks as failed after restarts or collector hangs.""" + cutoff = datetime.utcnow() - timedelta(hours=max_age_hours) + + async with async_session_factory() as db: + result = await db.execute( + select(CollectionTask).where( + CollectionTask.status == "running", + CollectionTask.started_at.is_not(None), + CollectionTask.started_at < cutoff, + ) + ) + stale_tasks = result.scalars().all() + + for task in stale_tasks: + task.status = "failed" + task.phase = "failed" + task.completed_at = datetime.utcnow() + existing_error = (task.error_message or "").strip() + cleanup_error = "Marked failed automatically after stale running task cleanup" + task.error_message = f"{existing_error}\n{cleanup_error}".strip() if existing_error else cleanup_error + + if stale_tasks: + await db.commit() + logger.warning("Cleaned up %s stale running collection task(s)", len(stale_tasks)) + + return len(stale_tasks) + + def start_scheduler() -> None: """Start the scheduler.""" if not scheduler.running: @@ -144,6 +174,19 @@ def get_scheduler_jobs() -> list[Dict[str, Any]]: return jobs +async def get_latest_task_id_for_datasource(datasource_id: int) -> Optional[int]: + from app.models.task import CollectionTask + + async with async_session_factory() as db: + result = await db.execute( + select(CollectionTask.id) + .where(CollectionTask.datasource_id == datasource_id) + .order_by(CollectionTask.created_at.desc(), CollectionTask.id.desc()) + .limit(1) + ) + return result.scalar_one_or_none() + + def run_collector_now(collector_name: str) -> bool: """Run a collector immediately (not scheduled).""" collector = collector_registry.get(collector_name) diff --git a/docs/collected-data-column-removal-plan.md b/docs/collected-data-column-removal-plan.md new file mode 100644 index 00000000..efb18f28 --- /dev/null +++ b/docs/collected-data-column-removal-plan.md @@ -0,0 +1,207 @@ +# collected_data 强耦合列拆除计划 + +## 背景 + +当前 `collected_data` 同时承担了两类职责: + +1. 通用采集事实表 +2. 少数数据源的宽表字段承载 + +典型强耦合列包括: + +- `country` +- `city` +- `latitude` +- `longitude` +- `value` +- `unit` + +以及 API 层临时平铺出来的: + +- `cores` +- `rmax` +- `rpeak` +- `power` + +这些字段并不适合作为统一事实表的长期 schema。 +推荐方向是: + +- 表内保留通用稳定字段 +- 业务差异字段全部归入 `metadata` +- API 和前端动态读取 `metadata` + +## 拆除目标 + +最终希望 `collected_data` 只保留: + +- `id` +- `snapshot_id` +- `task_id` +- `source` +- `source_id` +- `entity_key` +- `data_type` +- `name` +- `title` +- `description` +- `metadata` +- `collected_at` +- `reference_date` +- `is_valid` +- `is_current` +- `previous_record_id` +- `change_type` +- `change_summary` +- `deleted_at` + +## 计划阶段 + +### Phase 1:读取层去依赖 + +目标: + +- API / 可视化 / 前端不再优先依赖宽列表字段 +- 所有动态字段优先从 `metadata` 取 + +当前已完成: + +- 新写入数据时,将 `country/city/latitude/longitude/value/unit` 自动镜像到 `metadata` +- `/api/v1/collected` 优先从 `metadata` 取动态字段 +- `visualization` 接口优先从 `metadata` 取动态字段 +- 国家筛选已改成只走 `metadata->>'country'` +- `CollectedData.to_dict()` 已切到 metadata-first +- 变更比较逻辑已切到 metadata-first +- 已新增历史回填脚本: + [scripts/backfill_collected_data_metadata.py](/home/ray/dev/linkong/planet/scripts/backfill_collected_data_metadata.py) +- 已新增删列脚本: + [scripts/drop_collected_data_legacy_columns.py](/home/ray/dev/linkong/planet/scripts/drop_collected_data_legacy_columns.py) + +涉及文件: + +- [backend/app/core/collected_data_fields.py](/home/ray/dev/linkong/planet/backend/app/core/collected_data_fields.py) +- [backend/app/services/collectors/base.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/base.py) +- [backend/app/api/v1/collected_data.py](/home/ray/dev/linkong/planet/backend/app/api/v1/collected_data.py) +- [backend/app/api/v1/visualization.py](/home/ray/dev/linkong/planet/backend/app/api/v1/visualization.py) + +### Phase 2:写入层去依赖 + +目标: + +- 采集器内部不再把这些字段当作数据库一级列来理解 +- 统一只写: + - 通用主字段 + - `metadata` + +建议动作: + +1. Collector 内部仍可使用 `country/city/value` 这种临时字段作为采集过程变量 +2. 进入 `BaseCollector._save_data()` 后统一归档到 `metadata` +3. `CollectedData` 模型中的强耦合列已从 ORM 移除,写入统一归档到 `metadata` + +### Phase 3:数据库删列 + +目标: + +- 从 `collected_data` 真正移除以下列: + - `country` + - `city` + - `latitude` + - `longitude` + - `value` + - `unit` + +注意: + +- `cores / rmax / rpeak / power` 当前本来就在 `metadata` 里,不是表列 +- 这四个主要是 API 平铺字段,不需要数据库删列 + +## 当前阻塞点 + +在正式删列前,还需要确认这些地方已经完全不再直接依赖数据库列: + +### 1. `CollectedData.to_dict()` + +文件: + +- [backend/app/models/collected_data.py](/home/ray/dev/linkong/planet/backend/app/models/collected_data.py) + +状态: + +- 已完成 + +### 2. 差异计算逻辑 + +文件: + +- [backend/app/services/collectors/base.py](/home/ray/dev/linkong/planet/backend/app/services/collectors/base.py) + +状态: + +- 已完成 +- 当前已改成比较归一化后的 metadata-first payload + +### 3. 历史数据回填 + +问题: + +- 老数据可能只有列值,没有对应 `metadata` + +当前方案: + +- 在删列前执行一次回填脚本: + - [scripts/backfill_collected_data_metadata.py](/home/ray/dev/linkong/planet/scripts/backfill_collected_data_metadata.py) + +### 4. 导出格式兼容 + +文件: + +- [backend/app/api/v1/collected_data.py](/home/ray/dev/linkong/planet/backend/app/api/v1/collected_data.py) + +现状: + +- CSV/JSON 导出已基本切成 metadata-first + +建议: + +- 删列前再回归检查一次导出字段是否一致 + +## 推荐执行顺序 + +1. 保持新数据写入时 `metadata` 完整 +2. 把模型和 diff 逻辑完全切成 metadata-first +3. 写一条历史回填脚本 +4. 回填后观察一轮 +5. 正式执行删列迁移 + +## 推荐迁移 SQL + +仅在确认全部读取链路已去依赖后执行: + +```sql +ALTER TABLE collected_data +DROP COLUMN IF EXISTS country, +DROP COLUMN IF EXISTS city, +DROP COLUMN IF EXISTS latitude, +DROP COLUMN IF EXISTS longitude, +DROP COLUMN IF EXISTS value, +DROP COLUMN IF EXISTS unit; +``` + +## 风险提示 + +1. 地图类接口对经纬度最敏感 + 必须确保所有地图需要的记录,其 `metadata.latitude/longitude` 已回填完整。 + +2. 历史老数据如果没有回填,删列后会直接丢失这些信息。 + +3. 某些 collector 可能仍隐式依赖这些宽字段做差异比较,删列前必须做一次全量回归。 + +## 当前判断 + +当前项目已经完成“代码去依赖 + 历史回填 + readiness 检查”。 +下一步执行顺序建议固定为: + +1. 先部署当前代码版本并重启后端 +2. 再做一轮功能回归 +3. 最后执行: + `uv run python scripts/drop_collected_data_legacy_columns.py` diff --git a/docs/collected-data-history-plan.md b/docs/collected-data-history-plan.md new file mode 100644 index 00000000..574cebc5 --- /dev/null +++ b/docs/collected-data-history-plan.md @@ -0,0 +1,402 @@ +# 采集数据历史快照化改造方案 + +## 背景 + +当前系统的 `collected_data` 更接近“当前结果表”: + +- 同一个 `source + source_id` 会被更新覆盖 +- 前端列表页默认读取这张表 +- `collection_tasks` 只记录任务执行状态,不直接承载数据版本语义 + +这套方式适合管理后台,但不利于后续做态势感知、时间回放、趋势分析和版本对比。 +如果后面需要回答下面这类问题,当前模型会比较吃力: + +- 某条实体在过去 7 天如何变化 +- 某次采集相比上次新增了什么、删除了什么、值变了什么 +- 某个时刻地图上“当时的世界状态”是什么 +- 告警是在第几次采集后触发的 + +因此建议把采集数据改造成“历史快照 + 当前视图”模型。 + +## 目标 + +1. 每次触发采集都保留一份独立快照,历史可追溯。 +2. 管理后台默认仍然只看“当前最新状态”,不增加使用复杂度。 +3. 后续支持: + - 时间线回放 + - 两次采集差异对比 + - 趋势分析 + - 按快照回溯告警和地图状态 +4. 尽量兼容现有接口,降低改造成本。 + +## 结论 + +不建议继续用以下两种单一模式: + +- 直接覆盖旧数据 + 问题:没有历史,无法回溯。 + +- 软删除旧数据再全量新增 + 问题:语义不清,历史和“当前无效”混在一起,后续统计复杂。 + +推荐方案: + +- 保留历史事实表 +- 维护当前视图 +- 每次采集对应一个明确的快照批次 + +## 推荐数据模型 + +### 方案概览 + +建议拆成三层: + +1. `collection_tasks` + 继续作为采集任务表,表示“这次采集任务”。 + +2. `data_snapshots` + 新增快照表,表示“某个数据源在某次任务中产出的一个快照批次”。 + +3. `collected_data` + 从“当前结果表”升级为“历史事实表”,每一行归属于一个快照。 + +同时再提供一个“当前视图”: + +- SQL View / 物化视图 / API 查询层封装均可 +- 语义是“每个 `source + source_id` 的最新有效记录” + +### 新增表:`data_snapshots` + +建议字段: + +| 字段 | 类型 | 含义 | +|---|---|---| +| `id` | bigint PK | 快照主键 | +| `datasource_id` | int | 对应数据源 | +| `task_id` | int | 对应采集任务 | +| `source` | varchar(100) | 数据源名,如 `top500` | +| `snapshot_key` | varchar(100) | 可选,业务快照标识 | +| `reference_date` | timestamptz nullable | 这批数据的参考时间 | +| `started_at` | timestamptz | 快照开始时间 | +| `completed_at` | timestamptz | 快照完成时间 | +| `record_count` | int | 快照总记录数 | +| `status` | varchar(20) | `running/success/failed/partial` | +| `is_current` | bool | 当前是否是该数据源最新快照 | +| `parent_snapshot_id` | bigint nullable | 上一版快照,可用于 diff | +| `summary` | jsonb | 本次快照统计摘要 | + +说明: + +- `collection_tasks` 偏“执行过程” +- `data_snapshots` 偏“数据版本” +- 一个任务通常对应一个快照,但保留分层更清晰 + +### 升级表:`collected_data` + +建议新增字段: + +| 字段 | 类型 | 含义 | +|---|---|---| +| `snapshot_id` | bigint not null | 归属快照 | +| `task_id` | int nullable | 归属任务,便于追查 | +| `entity_key` | varchar(255) | 实体稳定键,通常可由 `source + source_id` 派生 | +| `is_current` | bool | 当前是否为该实体最新记录 | +| `previous_record_id` | bigint nullable | 上一个版本的记录 | +| `change_type` | varchar(20) | `created/updated/unchanged/deleted` | +| `change_summary` | jsonb | 字段变化摘要 | +| `deleted_at` | timestamptz nullable | 对应“本次快照中消失”的实体 | + +保留现有字段: + +- `source` +- `source_id` +- `data_type` +- `name` +- `title` +- `description` +- `country` +- `city` +- `latitude` +- `longitude` +- `value` +- `unit` +- `metadata` +- `collected_at` +- `reference_date` +- `is_valid` + +### 当前视图 + +建议新增一个只读视图: + +`current_collected_data` + +语义: + +- 对每个 `source + source_id` 只保留最新一条 `is_current = true` 且 `deleted_at is null` 的记录 + +这样: + +- 管理后台继续像现在一样查“当前数据” +- 历史分析查 `collected_data` + +## 写入策略 + +### 触发按钮语义 + +“触发”不再理解为“覆盖旧表”,而是: + +- 启动一次新的采集任务 +- 生成一个新的快照 +- 将本次结果写入历史事实表 +- 再更新当前视图标记 + +### 写入流程 + +1. 创建 `collection_tasks` 记录,状态 `running` +2. 创建 `data_snapshots` 记录,状态 `running` +3. 采集器拉取原始数据并标准化 +4. 为每条记录生成 `entity_key` + - 推荐:`{source}:{source_id}` +5. 将本次记录批量写入 `collected_data` +6. 与上一个快照做比对,计算: + - 新增 + - 更新 + - 未变 + - 删除 +7. 更新本批记录的: + - `change_type` + - `previous_record_id` + - `is_current` +8. 将上一批同实体记录的 `is_current` 置为 `false` +9. 将本次快照未出现但上一版存在的实体标记为 `deleted` +10. 更新 `data_snapshots.status = success` +11. 更新 `collection_tasks.status = success` + +### 删除语义 + +这里不建议真的删记录。 +建议采用“逻辑消失”模型: + +- 历史行永远保留 +- 如果某实体在新快照里消失: + - 上一条历史记录补一条“删除状态记录”或标记 `change_type = deleted` + - 同时该实体不再出现在当前视图 + +这样最适合态势感知。 + +## API 改造建议 + +### 保持现有接口默认行为 + +现有接口: + +- `GET /api/v1/collected` +- `GET /api/v1/collected/{id}` +- `GET /api/v1/collected/summary` + +建议默认仍返回“当前视图”,避免前端全面重写。 + +### 新增历史查询能力 + +建议新增参数或新接口: + +#### 1. 当前/历史切换 + +`GET /api/v1/collected?mode=current|history` + +- `current`:默认,查当前视图 +- `history`:查历史事实表 + +#### 2. 按快照查询 + +`GET /api/v1/collected?snapshot_id=123` + +#### 3. 快照列表 + +`GET /api/v1/snapshots` + +支持筛选: + +- `datasource_id` +- `source` +- `status` +- `date_from/date_to` + +#### 4. 快照详情 + +`GET /api/v1/snapshots/{id}` + +返回: + +- 快照基础信息 +- 统计摘要 +- 与上一版的 diff 摘要 + +#### 5. 快照 diff + +`GET /api/v1/snapshots/{id}/diff?base_snapshot_id=122` + +返回: + +- `created` +- `updated` +- `deleted` +- `unchanged` + +## 前端改造建议 + +### 1. 数据列表页 + +默认仍看当前数据,不改用户使用习惯。 + +建议新增: + +- “视图模式” + - 当前数据 + - 历史数据 +- “快照时间”筛选 +- “只看变化项”筛选 + +### 2. 数据详情页 + +详情页建议展示: + +- 当前记录基础信息 +- 元数据动态字段 +- 所属快照 +- 上一版本对比入口 +- 历史版本时间线 + +### 3. 数据源管理页 + +“触发”按钮文案建议改成更准确的: + +- `立即采集` + +并在详情里补: + +- 最近一次快照时间 +- 最近一次快照记录数 +- 最近一次变化数 + +## 迁移方案 + +### Phase 1:兼容式落地 + +目标:先保留当前页面可用。 + +改动: + +1. 新增 `data_snapshots` +2. 给 `collected_data` 增加: + - `snapshot_id` + - `task_id` + - `entity_key` + - `is_current` + - `previous_record_id` + - `change_type` + - `change_summary` + - `deleted_at` +3. 现有数据全部补成一个“初始化快照” +4. 现有 `/collected` 默认改查当前视图 + +优点: + +- 前端几乎无感 +- 风险最小 + +### Phase 2:启用差异计算 + +目标:采集后可知道本次改了什么。 + +改动: + +1. 写入时做新旧快照比对 +2. 写 `change_type` +3. 生成快照摘要 + +### Phase 3:前端态势感知能力 + +目标:支持历史回放和趋势分析。 + +改动: + +1. 快照时间线 +2. 版本 diff 页面 +3. 地图时间回放 +4. 告警和快照关联 + +## 唯一性与索引建议 + +### 建议保留的业务唯一性 + +在“同一个快照内部”,建议唯一: + +- `(snapshot_id, source, source_id)` + +不要在整张历史表上强加: + +- `(source, source_id)` 唯一 + +因为历史表本来就应该允许同一实体跨快照存在多条版本。 + +### 建议索引 + +- `idx_collected_data_snapshot_id` +- `idx_collected_data_source_source_id` +- `idx_collected_data_entity_key` +- `idx_collected_data_is_current` +- `idx_collected_data_reference_date` +- `idx_snapshots_source_completed_at` + +## 风险点 + +1. 存储量会明显增加 + - 需要评估保留周期 + - 可以考虑冷热分层 + +2. 写入复杂度上升 + - 需要批量 upsert / diff 逻辑 + +3. 当前接口语义会从“表”变成“视图” + - 文档必须同步 + +4. 某些采集器缺稳定 `source_id` + - 需要补齐实体稳定键策略 + +## 对当前项目的具体建议 + +结合当前代码,推荐这样落地: + +### 短期 + +1. 先设计并落表: + - `data_snapshots` + - `collected_data` 新字段 +2. 采集完成后每次新增快照 +3. `/api/v1/collected` 默认查 `is_current = true` + +### 中期 + +1. 在 `BaseCollector._save_data()` 中改成: + - 生成快照 + - 批量写历史 + - 标记当前 +2. 将 `CollectionTask.id` 关联到 `snapshot.task_id` + +### 长期 + +1. 地图接口支持按 `snapshot_id` 查询 +2. 仪表盘支持“最近一次快照变化量” +3. 告警支持绑定到快照版本 + +## 最终建议 + +最终建议采用: + +- 历史事实表:保存每次采集结果 +- 当前视图:服务管理后台默认查询 +- 快照表:承载版本批次和 diff 语义 + +这样既能保留历史,又不会把当前页面全部推翻重做,是最适合后续做态势感知的一条路径。 diff --git a/docs/system-settings-plan.md b/docs/system-settings-plan.md index 8d7d8952..8933cd34 100644 --- a/docs/system-settings-plan.md +++ b/docs/system-settings-plan.md @@ -44,4 +44,5 @@ - 设置项修改后重启服务仍然存在 - 配置页可以查看并修改所有内置采集器的启停与采集频率 - 调整采集频率后,调度器任务随之更新 -- `/settings` 页面可从主导航进入并正常工作 +- `/settings` 页面可从主导航进入并正常工作 + diff --git a/frontend/src/index.css b/frontend/src/index.css index de1a1f0c..ae02ec64 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -231,6 +231,10 @@ body { overflow: hidden; } +.data-source-tabs .ant-tabs-tabpane-hidden { + display: none !important; +} + .data-source-custom-tab { gap: 12px; } @@ -340,6 +344,42 @@ body { min-width: 100%; } +.table-scroll-region .ant-table-thead > tr > th, +.table-scroll-region .ant-table-tbody > tr > td { + padding: 10px 12px !important; +} + +.table-scroll-region .ant-table-body, +.table-scroll-region .ant-table-content { + scrollbar-width: thin; + scrollbar-color: rgba(148, 163, 184, 0.88) transparent; +} + +.table-scroll-region .ant-table-body::-webkit-scrollbar, +.table-scroll-region .ant-table-content::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +.table-scroll-region .ant-table-body::-webkit-scrollbar-thumb, +.table-scroll-region .ant-table-content::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.82); + border-radius: 999px; + border: 2px solid transparent; + background-clip: padding-box; +} + +.table-scroll-region .ant-table-body::-webkit-scrollbar-thumb:hover, +.table-scroll-region .ant-table-content::-webkit-scrollbar-thumb:hover { + background: rgba(100, 116, 139, 0.9); + background-clip: padding-box; +} + +.table-scroll-region .ant-table-body::-webkit-scrollbar-track, +.table-scroll-region .ant-table-content::-webkit-scrollbar-track { + background: transparent; +} + .settings-shell, .settings-tabs-shell, .settings-tabs, @@ -377,7 +417,7 @@ body { display: none !important; } -.settings-tab-panel { +.settings-pane { flex: 1 1 auto; min-width: 0; min-height: 0; @@ -427,9 +467,22 @@ body { background: transparent; } -.settings-table-scroll-region { +.settings-pane .data-source-table-region .ant-table-container { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.settings-pane .data-source-table-region .ant-table-header { + flex: 0 0 auto; +} + +.settings-pane .data-source-table-region .ant-table-body { flex: 1 1 auto; - overflow: hidden; + min-height: 0; + height: 0 !important; + max-height: none !important; } @@ -490,6 +543,10 @@ body { overflow: auto; } +.data-list-summary-card-inner { + min-height: 100%; +} + .data-list-right-column { min-width: 0; min-height: 0; @@ -499,7 +556,9 @@ body { } .data-list-summary-treemap { - min-height: 100%; + --data-list-treemap-tile-padding: 12px; + --data-list-treemap-label-size: 12px; + --data-list-treemap-value-size: 16px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); grid-auto-rows: minmax(56px, 1fr); @@ -512,9 +571,9 @@ body { min-height: 0; display: flex; flex-direction: column; - justify-content: space-between; - gap: 8px; - padding: 12px; + justify-content: flex-start; + gap: 6px; + padding: var(--data-list-treemap-tile-padding); border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.55); color: #0f172a; @@ -552,29 +611,36 @@ body { .data-list-treemap-head { display: flex; align-items: center; - gap: 8px; + gap: 6px; min-width: 0; + flex: 0 0 auto; } .data-list-treemap-label { min-width: 0; - font-size: clamp(11px, 0.75vw, 13px); + font-size: var(--data-list-treemap-label-size); line-height: 1.2; color: rgba(15, 23, 42, 0.78); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .data-list-treemap-body { display: flex; flex-direction: column; - gap: 4px; + gap: 2px; + margin-top: auto; + min-height: 0; + flex: 0 0 auto; } .data-list-summary-tile-icon { display: inline-flex; align-items: center; justify-content: center; - width: 24px; - height: 24px; + width: 22px; + height: 22px; border-radius: 8px; background: rgba(255, 255, 255, 0.55); color: #0f172a; @@ -582,9 +648,12 @@ body { } .data-list-summary-tile-value { - font-size: clamp(12px, 1vw, 16px); + font-size: var(--data-list-treemap-value-size); line-height: 1.1; color: #0f172a; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .data-list-treemap-meta { @@ -611,7 +680,7 @@ body { display: flex; flex-wrap: nowrap; gap: 10px; - align-items: center; + align-items: flex-start; } .data-list-filter-grid--balanced > * { @@ -687,6 +756,46 @@ body { margin: 12px 0 0; } +.data-list-name-link { + max-width: 100%; + display: inline-flex; + align-items: center; + justify-content: flex-start; + padding-inline: 0 !important; +} + +.data-list-name-marquee { + display: block; + max-width: 100%; + overflow: hidden; + white-space: nowrap; +} + +.data-list-name-marquee--overflow { + width: 100%; +} + +.data-list-name-marquee__text { + display: inline-block; + max-width: 100%; + white-space: nowrap; + transform: translateX(0); + will-change: transform; +} + +.data-list-name-link:hover .data-list-name-marquee--overflow .data-list-name-marquee__text { + animation: data-list-name-marquee 8s linear infinite; +} + +@keyframes data-list-name-marquee { + from { + transform: translateX(0); + } + to { + transform: translateX(-100%); + } +} + .data-list-resize-handle { position: relative; display: flex; @@ -807,3 +916,172 @@ body { } } + +.data-list-detail-modal { + display: flex; + flex-direction: column; + gap: 16px; +} + +.data-list-detail-section { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} + +.data-list-detail-section__title { + font-size: 14px; +} + +.data-list-detail-hero { + padding: 14px 16px; + border-radius: 12px; + background: #f7f8fa; + border: 1px solid #eef1f5; +} + +.data-list-detail-hero__label { + display: block; + margin-bottom: 6px; + color: #6b7280; + font-size: 12px; +} + +.data-list-detail-hero__title.ant-typography { + margin: 0; + overflow-wrap: anywhere; +} + +.data-list-detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 12px; +} + +.data-list-detail-cell { + min-width: 0; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid #eef1f5; + background: #fff; +} + +.data-list-detail-cell--block { + grid-column: 1 / -1; +} + +.data-list-detail-cell__label { + display: block; + margin-bottom: 8px; + color: #6b7280; + font-size: 12px; +} + +.data-list-detail-cell__value { + color: #111827; + line-height: 1.6; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + +.data-list-detail-code { + margin: 0; + padding: 12px; + max-height: 240px; + overflow: auto; + border-radius: 10px; + background: #111827; + color: #e5eef9; + font-size: 12px; + line-height: 1.6; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + +.data-list-detail-code--raw { + max-height: 320px; +} + +.data-list-tag-cell { + min-width: 140px; +} + +.data-list-tag-cell .ant-tag { + display: inline-block; + max-width: 100%; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; + line-height: 1.4; +} + +.data-list-filter-select { + max-width: 220px; +} + +.data-list-filter-select .ant-select-selector { + height: auto !important; + min-height: 32px; + max-height: 72px; + align-items: flex-start !important; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; +} + +.data-list-filter-select .ant-select-selection-overflow { + flex-wrap: wrap !important; +} + +.data-list-filter-select .ant-select-selection-overflow-item { + max-width: 100%; +} + +.data-list-filter-select .ant-select-selection-item { + max-width: 100%; +} + +.dashboard-page { + display: flex; + flex-direction: column; + gap: 16px; +} + +.dashboard-page__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + flex-wrap: wrap; +} + +.dashboard-page__actions { + align-items: center; +} + +.dashboard-status-tag { + margin-inline-end: 0 !important; + padding-inline: 10px; + border-radius: 999px; + line-height: 24px; +} + +.dashboard-refresh-button.ant-btn { + height: 26px; + padding-inline: 12px; + border-radius: 999px; + border-color: #d9d9d9; + background: #ffffff; + color: rgba(0, 0, 0, 0.88); + box-shadow: none; +} + +.dashboard-refresh-button.ant-btn:hover, +.dashboard-refresh-button.ant-btn:focus { + border-color: #bfbfbf; + background: #ffffff; + color: rgba(0, 0, 0, 0.88); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 55e0e867..0b9f3b4f 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -14,7 +14,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render( }, }} > - + diff --git a/frontend/src/pages/Dashboard/Dashboard.tsx b/frontend/src/pages/Dashboard/Dashboard.tsx index c2db29ca..9202ddd4 100644 --- a/frontend/src/pages/Dashboard/Dashboard.tsx +++ b/frontend/src/pages/Dashboard/Dashboard.tsx @@ -122,19 +122,19 @@ function Dashboard() { return ( -
-
+
+
仪表盘 系统总览与实时态势
- + {wsConnected ? ( - } color="success">实时连接 + } color="success">实时连接 ) : ( - } color="default">离线 + } color="default">离线 )} - +
@@ -188,7 +188,7 @@ function Dashboard() { {stats?.last_updated && (
最后更新: {new Date(stats.last_updated).toLocaleString('zh-CN')} - {wsConnected && 实时同步中} + {wsConnected && 实时同步中}
)}
diff --git a/frontend/src/pages/DataList/DataList.tsx b/frontend/src/pages/DataList/DataList.tsx index f2f37f9e..4f2f548c 100644 --- a/frontend/src/pages/DataList/DataList.tsx +++ b/frontend/src/pages/DataList/DataList.tsx @@ -1,9 +1,10 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react' import { Table, Tag, Space, Card, Select, Input, Button, - Modal, Descriptions, Spin, Empty, Tooltip, Typography, Grid + Modal, Spin, Empty, Tooltip, Typography, Grid } from 'antd' import type { ColumnsType } from 'antd/es/table' +import type { CustomTagProps } from 'rc-select/lib/BaseSelect' import { DatabaseOutlined, GlobalOutlined, CloudServerOutlined, AppstoreOutlined, EyeOutlined, SearchOutlined, FilterOutlined, ReloadOutlined @@ -28,6 +29,10 @@ interface CollectedData { longitude: string | null value: string | null unit: string | null + cores: string | null + rmax: string | null + rpeak: string | null + power: string | null metadata: Record | null collected_at: string reference_date: string | null @@ -40,6 +45,183 @@ interface Summary { source_totals: Array<{ source: string; count: number }> } +const DETAIL_FIELD_LABELS: Record = { + id: 'ID', + source: '数据源', + source_id: '原始ID', + data_type: '数据类型', + name: '名称', + title: '标题', + description: '描述', + country: '国家', + city: '城市', + latitude: '纬度', + longitude: '经度', + value: '数值', + unit: '单位', + collected_at: '采集时间', + reference_date: '参考日期', + is_valid: '有效状态', + rank: '排名', + cores: '核心数量', + rmax: '实际最大算力', + rpeak: '理论算力', + power: '功耗', + manufacturer: '厂商', + site: '站点', + processor: '处理器', + interconnect: '互连', + installation_year: '安装年份', + nmax: 'Nmax', + hpcg: 'HPCG', + power_measurement_level: '功耗测量等级', + operating_system: '操作系统', + compiler: '编译器', + math_library: '数学库', + mpi: 'MPI', + raw_country: '原始国家值', + country_validation: '国家校验', +} + +const DETAIL_BASE_FIELDS = [ + 'source', + 'data_type', + 'source_id', + 'country', + 'city', + 'collected_at', + 'reference_date', +] + +function formatFieldLabel(key: string) { + if (DETAIL_FIELD_LABELS[key]) { + return DETAIL_FIELD_LABELS[key] + } + + return key + .split('_') + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') +} + +function formatDetailValue(key: string, value: unknown) { + if (value === null || value === undefined || value === '') { + return '-' + } + + if (key === 'collected_at' || key === 'reference_date') { + const date = new Date(String(value)) + return Number.isNaN(date.getTime()) + ? String(value) + : key === 'reference_date' + ? date.toLocaleDateString('zh-CN') + : date.toLocaleString('zh-CN') + } + + if (typeof value === 'boolean') { + return value ? '是' : '否' + } + + if (typeof value === 'object') { + return JSON.stringify(value, null, 2) + } + + return String(value) +} + +function NameMarquee({ text }: { text: string }) { + const containerRef = useRef(null) + const textRef = useRef(null) + const [overflowing, setOverflowing] = useState(false) + + useLayoutEffect(() => { + const updateOverflow = () => { + const container = containerRef.current + const content = textRef.current + if (!container || !content) return + setOverflowing(content.scrollWidth > container.clientWidth + 1) + } + + updateOverflow() + + if (typeof ResizeObserver === 'undefined') { + return undefined + } + + const observer = new ResizeObserver(updateOverflow) + if (containerRef.current) observer.observe(containerRef.current) + if (textRef.current) observer.observe(textRef.current) + + return () => observer.disconnect() + }, [text]) + + return ( + + + {text} + + + ) +} + +function estimateTreemapRows( + items: Array<{ colSpan: number; rowSpan: number }>, + columns: number +): number { + const occupancy: boolean[][] = [] + + const ensureRow = (rowIndex: number) => { + while (occupancy.length <= rowIndex) { + occupancy.push(Array(columns).fill(false)) + } + } + + for (const item of items) { + let placed = false + let rowIndex = 0 + + while (!placed) { + ensureRow(rowIndex) + + for (let columnIndex = 0; columnIndex <= columns - item.colSpan; columnIndex += 1) { + let canPlace = true + + for (let rowOffset = 0; rowOffset < item.rowSpan; rowOffset += 1) { + ensureRow(rowIndex + rowOffset) + + for (let columnOffset = 0; columnOffset < item.colSpan; columnOffset += 1) { + if (occupancy[rowIndex + rowOffset][columnIndex + columnOffset]) { + canPlace = false + break + } + } + + if (!canPlace) break + } + + if (!canPlace) continue + + for (let rowOffset = 0; rowOffset < item.rowSpan; rowOffset += 1) { + for (let columnOffset = 0; columnOffset < item.colSpan; columnOffset += 1) { + occupancy[rowIndex + rowOffset][columnIndex + columnOffset] = true + } + } + + placed = true + break + } + + rowIndex += 1 + } + } + + return Math.max(occupancy.length, 1) +} + function DataList() { const screens = useBreakpoint() const isCompact = !screens.lg @@ -48,6 +230,7 @@ function DataList() { const mainAreaRef = useRef(null) const rightColumnRef = useRef(null) const tableHeaderRef = useRef(null) + const summaryBodyRef = useRef(null) const hasCustomLeftWidthRef = useRef(false) const [mainAreaWidth, setMainAreaWidth] = useState(0) @@ -55,6 +238,7 @@ function DataList() { const [rightColumnHeight, setRightColumnHeight] = useState(0) const [tableHeaderHeight, setTableHeaderHeight] = useState(0) const [leftPanelWidth, setLeftPanelWidth] = useState(360) + const [summaryBodyHeight, setSummaryBodyHeight] = useState(0) const [data, setData] = useState([]) const [loading, setLoading] = useState(false) @@ -62,13 +246,11 @@ function DataList() { const [total, setTotal] = useState(0) const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(20) - const [sourceFilter, setSourceFilter] = useState() - const [typeFilter, setTypeFilter] = useState() - const [countryFilter, setCountryFilter] = useState() + const [sourceFilter, setSourceFilter] = useState([]) + const [typeFilter, setTypeFilter] = useState([]) const [searchText, setSearchText] = useState('') const [sources, setSources] = useState([]) const [types, setTypes] = useState([]) - const [countries, setCountries] = useState([]) const [detailVisible, setDetailVisible] = useState(false) const [detailData, setDetailData] = useState(null) const [detailLoading, setDetailLoading] = useState(false) @@ -79,6 +261,7 @@ function DataList() { setMainAreaHeight(mainAreaRef.current?.offsetHeight || 0) setRightColumnHeight(rightColumnRef.current?.offsetHeight || 0) setTableHeaderHeight(tableHeaderRef.current?.offsetHeight || 0) + setSummaryBodyHeight(summaryBodyRef.current?.offsetHeight || 0) } updateLayout() @@ -93,6 +276,7 @@ function DataList() { if (mainAreaRef.current) observer.observe(mainAreaRef.current) if (rightColumnRef.current) observer.observe(rightColumnRef.current) if (tableHeaderRef.current) observer.observe(tableHeaderRef.current) + if (summaryBodyRef.current) observer.observe(summaryBodyRef.current) return () => observer.disconnect() }, [isCompact]) @@ -147,9 +331,8 @@ function DataList() { page: page.toString(), page_size: pageSize.toString(), }) - if (sourceFilter) params.append('source', sourceFilter) - if (typeFilter) params.append('data_type', typeFilter) - if (countryFilter) params.append('country', countryFilter) + if (sourceFilter.length > 0) params.append('source', sourceFilter.join(',')) + if (typeFilter.length > 0) params.append('data_type', typeFilter.join(',')) if (searchText) params.append('search', searchText) const res = await axios.get(`/api/v1/collected?${params}`) @@ -173,14 +356,12 @@ function DataList() { const fetchFilters = async () => { try { - const [sourcesRes, typesRes, countriesRes] = await Promise.all([ + const [sourcesRes, typesRes] = await Promise.all([ axios.get('/api/v1/collected/sources'), axios.get('/api/v1/collected/types'), - axios.get('/api/v1/collected/countries'), ]) setSources(sourcesRes.data.sources || []) setTypes(typesRes.data.data_types || []) - setCountries(countriesRes.data.countries || []) } catch (error) { console.error('Failed to fetch filters:', error) } @@ -193,7 +374,7 @@ function DataList() { useEffect(() => { fetchData() - }, [page, pageSize, sourceFilter, typeFilter, countryFilter]) + }, [page, pageSize, sourceFilter, typeFilter]) const handleSearch = () => { setPage(1) @@ -201,9 +382,8 @@ function DataList() { } const handleReset = () => { - setSourceFilter(undefined) - setTypeFilter(undefined) - setCountryFilter(undefined) + setSourceFilter([]) + setTypeFilter([]) setSearchText('') setPage(1) setTimeout(fetchData, 0) @@ -234,6 +414,47 @@ function DataList() { return iconMap[source] || } + const getSourceTagColor = (source: string) => { + const colorMap: Record = { + top500: 'geekblue', + huggingface_models: 'purple', + huggingface_datasets: 'cyan', + huggingface_spaces: 'magenta', + telegeography_cables: 'green', + epoch_ai_gpu: 'volcano', + } + return colorMap[source] || 'blue' + } + + const getDataTypeTagColor = (dataType: string) => { + const colorMap: Record = { + supercomputer: 'geekblue', + model: 'purple', + dataset: 'cyan', + space: 'magenta', + submarine_cable: 'green', + cable_landing_point: 'lime', + cable_landing_relation: 'gold', + gpu_cluster: 'volcano', + generic: 'default', + } + return colorMap[dataType] || 'default' + } + + const renderFilterTag = (tagProps: CustomTagProps, getColor: (value: string) => string) => { + const { label, value, closable, onClose } = tagProps + return ( + + {label} + + ) + } + const getTypeColor = (type: string) => { const colors: Record = { supercomputer: 'red', @@ -250,8 +471,8 @@ function DataList() { } const activeFilterCount = useMemo( - () => [sourceFilter, typeFilter, countryFilter, searchText.trim()].filter(Boolean).length, - [sourceFilter, typeFilter, countryFilter, searchText] + () => [sourceFilter.length > 0, typeFilter.length > 0, searchText.trim()].filter(Boolean).length, + [sourceFilter, typeFilter, searchText] ) const summaryItems = useMemo(() => { @@ -281,30 +502,24 @@ function DataList() { return 4 }, [isCompact, leftPanelWidth]) - const treemapRowHeight = useMemo(() => { - if (isCompact) return 88 - if (leftPanelWidth < 360) return 44 - if (leftPanelWidth < 520) return 48 - return 56 - }, [isCompact, leftPanelWidth]) - const treemapItems = useMemo(() => { const palette = ['ocean', 'sky', 'mint', 'amber', 'rose', 'violet', 'slate'] const maxValue = Math.max(...summaryItems.map((item) => item.value), 1) - const allowTallTiles = !isCompact && leftPanelWidth >= 520 + const allowFeaturedTile = !isCompact && treemapColumns > 1 && summaryItems.length > 2 + const allowSecondaryTallTiles = !isCompact && leftPanelWidth >= 520 return summaryItems.map((item, index) => { const ratio = item.value / maxValue let colSpan = 1 let rowSpan = 1 - if (allowTallTiles && index === 0) { + if (allowFeaturedTile && index === 0) { colSpan = Math.min(2, treemapColumns) rowSpan = 2 - } else if (allowTallTiles && ratio >= 0.7) { + } else if (allowSecondaryTallTiles && ratio >= 0.7) { colSpan = Math.min(2, treemapColumns) rowSpan = 2 - } else if (allowTallTiles && ratio >= 0.35) { + } else if (allowSecondaryTallTiles && ratio >= 0.35) { rowSpan = 2 } @@ -317,27 +532,70 @@ function DataList() { }) }, [summaryItems, isCompact, leftPanelWidth, treemapColumns]) + const treemapRows = useMemo( + () => estimateTreemapRows(treemapItems, treemapColumns), + [treemapColumns, treemapItems] + ) + + const treemapGap = isCompact ? 8 : 10 + const treemapMinRowHeight = isCompact ? 88 : 68 + const treemapTargetRowHeight = isCompact ? 88 : leftPanelWidth < 360 ? 44 : leftPanelWidth < 520 ? 48 : 56 + const treemapAvailableHeight = Math.max(summaryBodyHeight, 0) + const treemapAutoRowHeight = treemapRows > 0 + ? Math.floor((treemapAvailableHeight - Math.max(0, treemapRows - 1) * treemapGap) / treemapRows) + : treemapTargetRowHeight + const treemapRowHeight = Math.max( + treemapMinRowHeight, + Math.min(treemapTargetRowHeight, treemapAutoRowHeight || treemapTargetRowHeight) + ) + const treemapContentHeight = treemapRows * treemapRowHeight + Math.max(0, treemapRows - 1) * treemapGap + const treemapTilePadding = treemapRowHeight <= 72 ? 8 : treemapRowHeight <= 84 ? 10 : 12 + const treemapLabelSize = treemapRowHeight <= 72 ? 10 : treemapRowHeight <= 84 ? 11 : 12 + const treemapValueSize = treemapRowHeight <= 72 ? 13 : treemapRowHeight <= 84 ? 15 : 16 + const pageHeight = '100%' const desktopTableHeight = rightColumnHeight - tableHeaderHeight - 132 const compactTableHeight = mainAreaHeight - tableHeaderHeight - 156 const tableHeight = Math.max(180, isCompact ? compactTableHeight : desktopTableHeight) + const detailBaseItems = useMemo(() => { + if (!detailData) return [] + + return DETAIL_BASE_FIELDS.map((key) => ({ + key, + label: formatFieldLabel(key), + value: formatDetailValue(key, detailData[key as keyof CollectedData]), + })).filter((item) => item.value !== '-') + }, [detailData]) + + const detailMetadataItems = useMemo(() => { + if (!detailData?.metadata) return [] + + return Object.entries(detailData.metadata) + .filter(([key]) => key !== '_detail_url') + .map(([key, value]) => ({ + key, + label: formatFieldLabel(key), + value: formatDetailValue(key, value), + isBlock: typeof value === 'object' && value !== null, + })) + }, [detailData]) + const splitLayoutStyle = isCompact ? undefined : { gridTemplateColumns: `${leftPanelWidth}px 12px minmax(0, 1fr)` } const columns: ColumnsType = [ - { title: 'ID', dataIndex: 'id', key: 'id', width: 80 }, { title: '名称', dataIndex: 'name', key: 'name', - width: 280, + width: 320, ellipsis: true, render: (name: string, record: CollectedData) => ( - ), @@ -346,23 +604,31 @@ function DataList() { title: '数据源', dataIndex: 'source', key: 'source', - width: 170, - render: (source: string) => {source}, + minWidth: 140, + render: (value: string) => ( + value ? ( +
+ + {value} + +
+ ) : '-' + ), }, { - title: '类型', + title: '数据类型', dataIndex: 'data_type', key: 'data_type', - width: 120, - render: (type: string) => {type}, - }, - { title: '国家/地区', dataIndex: 'country', key: 'country', width: 130, ellipsis: true }, - { - title: '数值', - dataIndex: 'value', - key: 'value', - width: 140, - render: (value: string | null, record: CollectedData) => (value ? `${value} ${record.unit || ''}` : '-'), + minWidth: 140, + render: (value: string) => ( + value ? ( +
+ + {value} + +
+ ) : '-' + ), }, { title: '采集时间', @@ -371,6 +637,13 @@ function DataList() { width: 180, render: (time: string) => new Date(time).toLocaleString('zh-CN'), }, + { + title: '参考日期', + dataIndex: 'reference_date', + key: 'reference_date', + width: 120, + render: (time: string | null) => (time ? new Date(time).toLocaleDateString('zh-CN') : '-'), + }, { title: '操作', key: 'action', @@ -406,14 +679,21 @@ function DataList() { className="data-list-summary-card data-list-summary-card--panel" title="数据概览" size="small" - bodyStyle={{ padding: isCompact ? 12 : 16 }} + styles={{ body: { padding: isCompact ? 12 : 16 } }} > +
0 ? Math.min(treemapContentHeight, treemapAvailableHeight) : undefined, + height: treemapContentHeight, + ['--data-list-treemap-tile-padding' as '--data-list-treemap-tile-padding']: `${treemapTilePadding}px`, + ['--data-list-treemap-label-size' as '--data-list-treemap-label-size']: `${treemapLabelSize}px`, + ['--data-list-treemap-value-size' as '--data-list-treemap-value-size']: `${treemapValueSize}px`, + } as CSSProperties} > {treemapItems.map((item) => (
))}
+
{!isCompact && ( @@ -449,7 +730,7 @@ function DataList() { )}
- +
@@ -468,6 +749,7 @@ function DataList() { { @@ -487,23 +772,13 @@ function DataList() { setPage(1) }} options={types.map((type) => ({ label: type, value: type }))} + tagRender={(tagProps) => renderFilterTag(tagProps, getDataTypeTagColor)} style={{ width: '100%' }} - /> - setSearchText(event.target.value)} onPressEnter={handleSearch} @@ -516,9 +791,8 @@ function DataList() { dataSource={data} rowKey="id" loading={loading} - virtual scroll={{ x: 'max-content', y: tableHeight }} - tableLayout="fixed" + tableLayout="auto" size={isCompact ? 'small' : 'middle'} pagination={{ current: page, @@ -548,38 +822,65 @@ function DataList() { 关闭 , ]} - width={700} + width={880} > {detailLoading ? (
) : detailData ? ( - - {detailData.id} - {detailData.source} - {detailData.data_type} - {detailData.source_id || '-'} - {detailData.name} - {detailData.title || '-'} - {detailData.description || '-'} - {detailData.country || '-'} - {detailData.city || '-'} - {detailData.longitude || '-'} - {detailData.latitude || '-'} - {detailData.value} {detailData.unit || ''} - - {new Date(detailData.collected_at).toLocaleString('zh-CN')} - - - {detailData.reference_date ? new Date(detailData.reference_date).toLocaleDateString('zh-CN') : '-'} - - -
+          
+
+
+ 名称 + + {detailData.name || '-'} + +
+
+ + {detailBaseItems.length > 0 && ( +
+ 基础信息 +
+ {detailBaseItems.map((item) => ( +
+ {item.label} +
{item.value}
+
+ ))} +
+
+ )} + + {detailMetadataItems.length > 0 && ( +
+ 扩展字段 +
+ {detailMetadataItems.map((item) => ( +
+ {item.label} + {item.isBlock ? ( +
{item.value}
+ ) : ( +
{item.value}
+ )} +
+ ))} +
+
+ )} + +
+ 原始元数据 +
                 {JSON.stringify(detailData.metadata || {}, null, 2)}
               
- - +
+
) : ( )} diff --git a/frontend/src/pages/DataSources/DataSources.tsx b/frontend/src/pages/DataSources/DataSources.tsx index 7e58b35b..d8bb3bb3 100644 --- a/frontend/src/pages/DataSources/DataSources.tsx +++ b/frontend/src/pages/DataSources/DataSources.tsx @@ -7,7 +7,7 @@ import { PlayCircleOutlined, PauseCircleOutlined, PlusOutlined, EditOutlined, DeleteOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, ExperimentOutlined, - SyncOutlined, ClearOutlined + SyncOutlined, ClearOutlined, CopyOutlined } from '@ant-design/icons' import axios from 'axios' import AppLayout from '../../components/AppLayout/AppLayout' @@ -18,16 +18,28 @@ interface BuiltInDataSource { module: string priority: string frequency: string + endpoint?: string is_active: boolean collector_class: string last_run: string | null is_running: boolean task_id: number | null progress: number | null + phase?: string | null records_processed: number | null total_records: number | null } +interface TaskTrackerState { + task_id: number | null + is_running: boolean + progress: number + phase: string | null + status?: string | null + records_processed?: number | null + total_records?: number | null +} + interface CustomDataSource { id: number name: string @@ -89,7 +101,7 @@ function DataSources() { } } - const [taskProgress, setTaskProgress] = useState>({}) + const [taskProgress, setTaskProgress] = useState>({}) useEffect(() => { fetchData() @@ -118,80 +130,85 @@ function DataSources() { }, [activeTab, builtInSources.length, customSources.length]) useEffect(() => { - const runningSources = builtInSources.filter(s => s.is_running) - if (runningSources.length === 0) return + const trackedSources = builtInSources.filter((source) => { + const trackedTask = taskProgress[source.id] + return Boolean((trackedTask?.task_id ?? source.task_id) && (trackedTask?.is_running ?? source.is_running)) + }) + + if (trackedSources.length === 0) return const interval = setInterval(async () => { - const progressMap: Record = {} - + const updates: Record = {} + await Promise.all( - runningSources.map(async (source) => { + trackedSources.map(async (source) => { + const trackedTaskId = taskProgress[source.id]?.task_id ?? source.task_id + if (!trackedTaskId) return + try { - const res = await axios.get(`/api/v1/datasources/${source.id}/task-status`) - progressMap[source.id] = { + const res = await axios.get(`/api/v1/datasources/${source.id}/task-status`, { + params: { task_id: trackedTaskId }, + }) + updates[source.id] = { + task_id: res.data.task_id ?? trackedTaskId, progress: res.data.progress || 0, - is_running: res.data.is_running + is_running: !!res.data.is_running, + phase: res.data.phase || null, + status: res.data.status || null, + records_processed: res.data.records_processed, + total_records: res.data.total_records, } } catch { - progressMap[source.id] = { progress: 0, is_running: false } + updates[source.id] = { + task_id: trackedTaskId, + progress: 0, + is_running: false, + phase: 'failed', + status: 'failed', + } } }) ) - - setTaskProgress(prev => ({ ...prev, ...progressMap })) + + setTaskProgress((prev) => { + const next = { ...prev, ...updates } + for (const [sourceId, state] of Object.entries(updates)) { + if (!state.is_running && state.status !== 'running') { + delete next[Number(sourceId)] + } + } + return next + }) + + if (Object.values(updates).some((state) => !state.is_running)) { + fetchData() + } }, 2000) return () => clearInterval(interval) - }, [builtInSources.map(s => s.id).join(',')]) + }, [builtInSources, taskProgress]) const handleTrigger = async (id: number) => { try { - await axios.post(`/api/v1/datasources/${id}/trigger`) + const res = await axios.post(`/api/v1/datasources/${id}/trigger`) message.success('任务已触发') - // Trigger polling immediately - setTaskProgress(prev => ({ ...prev, [id]: { progress: 0, is_running: true } })) - // Also refresh data + setTaskProgress(prev => ({ + ...prev, + [id]: { + task_id: res.data.task_id ?? null, + progress: 0, + is_running: true, + phase: 'queued', + status: 'running', + }, + })) fetchData() - // Also fetch the running task status - pollTaskStatus(id) } catch (error: unknown) { const err = error as { response?: { data?: { detail?: string } } } message.error(err.response?.data?.detail || '触发失败') } } - const pollTaskStatus = async (sourceId: number) => { - const poll = async () => { - try { - const res = await axios.get(`/api/v1/datasources/${sourceId}/task-status`) - const data = res.data - - setTaskProgress(prev => ({ ...prev, [sourceId]: { - progress: data.progress || 0, - is_running: data.is_running - } })) - - // Keep polling while running - if (data.is_running) { - setTimeout(poll, 2000) - } else { - // Task completed - refresh data and clear this source from progress - setTimeout(() => { - setTaskProgress(prev => { - const newState = { ...prev } - delete newState[sourceId] - return newState - }) - }, 1000) - fetchData() - } - } catch { - // Stop polling on error - } - } - poll() - } - const handleToggle = async (id: number, current: boolean) => { const endpoint = current ? 'disable' : 'enable' try { @@ -229,7 +246,7 @@ function DataSources() { name: data.name, description: null, source_type: data.collector_class, - endpoint: '', + endpoint: data.endpoint || '', auth_type: 'none', headers: {}, config: {}, @@ -340,6 +357,27 @@ function DataSources() { setTestResult(null) } + const handleCopyLink = async (value: string, successText: string) => { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value) + } else { + const textArea = document.createElement('textarea') + textArea.value = value + textArea.style.position = 'fixed' + textArea.style.opacity = '0' + document.body.appendChild(textArea) + textArea.focus() + textArea.select() + document.execCommand('copy') + document.body.removeChild(textArea) + } + message.success(successText) + } catch { + message.error('复制失败,请手动复制') + } + } + const builtinColumns = [ { title: 'ID', dataIndex: 'id', key: 'id', width: 60, fixed: 'left' as const }, { @@ -374,15 +412,31 @@ function DataSources() { title: '状态', dataIndex: 'is_active', key: 'is_active', - width: 100, + width: 180, render: (_: unknown, record: BuiltInDataSource) => { - const progress = taskProgress[record.id] - if (progress?.is_running || record.is_running) { - const pct = progress?.progress ?? record.progress ?? 0 + const taskState = taskProgress[record.id] + const isTaskRunning = taskState?.is_running || record.is_running + + const phaseLabelMap: Record = { + queued: '排队中', + fetching: '抓取中', + transforming: '处理中', + saving: '保存中', + completed: '已完成', + failed: '失败', + } + + if (isTaskRunning) { + const pct = taskState?.progress ?? record.progress ?? 0 + const phase = taskState?.phase || record.phase || 'queued' return ( - - 采集中 {Math.round(pct)}% - + + {record.is_active ? '运行中' : '已暂停'} + + {phaseLabelMap[phase] || phase} + {pct > 0 ? ` ${Math.round(pct)}%` : ''} + + ) } return {record.is_active ? '运行中' : '已暂停'} @@ -420,6 +474,22 @@ function DataSources() { { title: 'ID', dataIndex: 'id', key: 'id', width: 60, fixed: 'left' as const }, { title: '名称', dataIndex: 'name', key: 'name', width: 150, ellipsis: true }, { title: '类型', dataIndex: 'source_type', key: 'source_type', width: 100 }, + { + title: 'API链接', + dataIndex: 'endpoint', + key: 'endpoint', + width: 280, + ellipsis: true, + render: (endpoint: string) => ( + endpoint ? ( + + + {endpoint} + + + ) : '-' + ), + }, { title: '状态', dataIndex: 'is_active', @@ -477,7 +547,6 @@ function DataSources() { scroll={{ x: 800, y: builtinTableHeight }} tableLayout="fixed" size="small" - virtual />
@@ -509,10 +578,9 @@ function DataSources() { rowKey="id" loading={loading} pagination={false} - scroll={{ x: 600, y: customTableHeight }} + scroll={{ x: 900, y: customTableHeight }} tableLayout="fixed" size="small" - virtual />
)} @@ -811,6 +879,19 @@ function DataSources() { + + + + +
+ ) +} + function Settings() { const [loading, setLoading] = useState(true) const [savingCollectorId, setSavingCollectorId] = useState(null) @@ -227,7 +243,7 @@ function Settings() { { title: '操作', key: 'action', - width: 120, + width: 92, fixed: 'right' as const, render: (_: unknown, record: CollectorSettings) => ( + + + ), + }, + { + key: 'notifications', + label: '通知策略', + children: ( + +
saveSection('notifications', values)}> + + + + + + + + + + + + + + + + + +
+ ), + }, + { + key: 'security', + label: '安全策略', + children: ( + +
saveSection('security', values)}> + + + + + + + +
+ + + + ), + }, + ] + return (
@@ -248,129 +370,7 @@ function Settings() {
- - -
- saveSection('system', values)}> - - - - - - - - - - - - - - - - - -
-
-
- ), - }, - { - key: 'notifications', - label: '通知策略', - children: ( -
- -
-
saveSection('notifications', values)}> - - - - - - - - - - - - - - - - - -
-
-
- ), - }, - { - key: 'security', - label: '安全策略', - children: ( -
- -
-
saveSection('security', values)}> - - - - - - - -
- - - - ), - }, - ]} - /> + diff --git a/frontend/src/pages/Users/Users.tsx b/frontend/src/pages/Users/Users.tsx index 7281dc53..ea3b6e61 100644 --- a/frontend/src/pages/Users/Users.tsx +++ b/frontend/src/pages/Users/Users.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Table, Button, Tag, Space, message, Modal, Form, Input, Select } from 'antd' import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons' import axios from 'axios' @@ -18,6 +18,8 @@ function Users() { const [loading, setLoading] = useState(false) const [modalVisible, setModalVisible] = useState(false) const [editingUser, setEditingUser] = useState(null) + const tableRegionRef = useRef(null) + const [tableHeight, setTableHeight] = useState(360) const [form] = Form.useForm() const fetchUsers = async () => { @@ -34,6 +36,24 @@ function Users() { fetchUsers() }, []) + useEffect(() => { + const updateTableHeight = () => { + const regionHeight = tableRegionRef.current?.offsetHeight || 0 + setTableHeight(Math.max(220, regionHeight - 56)) + } + + updateTableHeight() + + if (typeof ResizeObserver === 'undefined') { + return undefined + } + + const observer = new ResizeObserver(updateTableHeight) + if (tableRegionRef.current) observer.observe(tableRegionRef.current) + + return () => observer.disconnect() + }, [users.length]) + const handleAdd = () => { setEditingUser(null) form.resetFields() @@ -77,12 +97,13 @@ function Users() { const columns = [ { title: 'ID', dataIndex: 'id', key: 'id', width: 80 }, - { title: '用户名', dataIndex: 'username', key: 'username' }, - { title: '邮箱', dataIndex: 'email', key: 'email' }, + { title: '用户名', dataIndex: 'username', key: 'username', width: 180 }, + { title: '邮箱', dataIndex: 'email', key: 'email', width: 260, ellipsis: true }, { title: '角色', dataIndex: 'role', key: 'role', + width: 140, render: (role: string) => { const colors: Record = { super_admin: 'red', @@ -97,6 +118,7 @@ function Users() { title: '状态', dataIndex: 'is_active', key: 'is_active', + width: 120, render: (active: boolean) => ( {active ? '活跃' : '禁用'} ), @@ -104,6 +126,7 @@ function Users() { { title: '操作', key: 'action', + width: 180, render: (_: unknown, record: User) => ( @@ -121,8 +144,15 @@ function Users() {
-
-
+
+
diff --git a/planet.sh b/planet.sh index 8d6380f4..217ad484 100755 --- a/planet.sh +++ b/planet.sh @@ -11,6 +11,27 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' +ensure_uv_backend_deps() { + echo -e "${BLUE}📦 检查后端 uv 环境...${NC}" + + if ! command -v uv >/dev/null 2>&1; then + echo -e "${RED}❌ 未找到 uv,请先安装 uv 并加入 PATH${NC}" + exit 1 + fi + + cd "$SCRIPT_DIR" + + if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then + echo -e "${YELLOW}⚠️ 未检测到 .venv,正在执行 uv sync...${NC}" + uv sync --group dev + fi + + if [ ! -x "$SCRIPT_DIR/.venv/bin/python" ]; then + echo -e "${RED}❌ uv 环境初始化失败,未找到 .venv/bin/python${NC}" + exit 1 + fi +} + ensure_frontend_deps() { echo -e "${BLUE}📦 检查前端依赖...${NC}" @@ -41,9 +62,10 @@ start() { sleep 3 echo -e "${BLUE}🔧 启动后端...${NC}" + ensure_uv_backend_deps pkill -f "uvicorn" 2>/dev/null || true cd "$SCRIPT_DIR/backend" - PYTHONPATH="$SCRIPT_DIR/backend" nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload > /tmp/planet_backend.log 2>&1 & + PYTHONPATH="$SCRIPT_DIR/backend" nohup uv run --project "$SCRIPT_DIR" python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload > /tmp/planet_backend.log 2>&1 & BACKEND_PID=$! sleep 3 diff --git a/pyproject.toml b/pyproject.toml index 3636ef16..bb8cc87c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "planet" version = "1.0.0" description = "智能星球计划 - 态势感知系统" -requires-python = ">=3.11" +requires-python = ">=3.14" dependencies = [ "fastapi>=0.109.0", "uvicorn[standard]>=0.27.0", @@ -13,28 +13,32 @@ dependencies = [ "pydantic-settings>=2.1.0", "python-jose[cryptography]>=3.3.0", "bcrypt>=4.0.0", + "passlib[bcrypt]>=1.7.4", "python-multipart>=0.0.6", "httpx>=0.26.0", + "beautifulsoup4>=4.12.0", "aiofiles>=23.2.1", "python-dotenv>=1.0.0", "email-validator>=2.1.0", + "apscheduler>=3.10.4", + "networkx>=3.0", ] [tool.uv] package = false -[scripts] -start = "uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload" -start-prod = "uvicorn app.main:app --host 0.0.0.0 --port 8000" -init-db = "python scripts/init_db.py" -lint = "ruff check ." -format = "black ." -test = "pytest" +[dependency-groups] +dev = [ + "black>=24.0.0", + "pytest>=7.4.0", + "pytest-asyncio>=0.23.0", + "ruff>=0.6.0", +] [tool.black] line-length = 100 -target-version = ["py311"] +target-version = ["py314"] [tool.ruff] line-length = 100 -target-version = "py311" +target-version = "py314" diff --git a/scripts/backfill_collected_data_metadata.py b/scripts/backfill_collected_data_metadata.py new file mode 100644 index 00000000..d899a256 --- /dev/null +++ b/scripts/backfill_collected_data_metadata.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Backfill legacy collected_data columns into metadata.""" + +import asyncio +import os +import sys + +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +BACKEND_DIR = os.path.join(ROOT_DIR, "backend") + +sys.path.insert(0, ROOT_DIR) +sys.path.insert(0, BACKEND_DIR) + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker + +from app.core.collected_data_fields import build_dynamic_metadata +from app.models.collected_data import CollectedData + + +async def main(): + database_url = os.environ.get( + "DATABASE_URL", "postgresql+asyncpg://postgres:postgres@localhost:5432/planet_db" + ) + engine = create_async_engine(database_url, echo=False) + async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + updated = 0 + + async with async_session() as session: + result = await session.execute(select(CollectedData)) + records = result.scalars().all() + + for record in records: + merged_metadata = build_dynamic_metadata( + record.extra_data or {}, + country=record.country, + city=record.city, + latitude=record.latitude, + longitude=record.longitude, + value=record.value, + unit=record.unit, + ) + + if merged_metadata != (record.extra_data or {}): + record.extra_data = merged_metadata + updated += 1 + + await session.commit() + + await engine.dispose() + print(f"Backfill completed. Updated {updated} collected_data rows.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/check_collected_data_column_removal_ready.py b/scripts/check_collected_data_column_removal_ready.py new file mode 100644 index 00000000..1328f74f --- /dev/null +++ b/scripts/check_collected_data_column_removal_ready.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Check whether collected_data is ready for strong-coupled column removal.""" + +import asyncio +import os +import sys + +ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +BACKEND_DIR = os.path.join(ROOT_DIR, "backend") + +sys.path.insert(0, ROOT_DIR) +sys.path.insert(0, BACKEND_DIR) + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker + + +CHECKS = { + "country_missing_in_metadata": """ + SELECT COUNT(*) + FROM collected_data + WHERE country IS NOT NULL + AND country != '' + AND COALESCE(metadata->>'country', '') = '' + """, + "city_missing_in_metadata": """ + SELECT COUNT(*) + FROM collected_data + WHERE city IS NOT NULL + AND city != '' + AND COALESCE(metadata->>'city', '') = '' + """, + "latitude_missing_in_metadata": """ + SELECT COUNT(*) + FROM collected_data + WHERE latitude IS NOT NULL + AND latitude != '' + AND COALESCE(metadata->>'latitude', '') = '' + """, + "longitude_missing_in_metadata": """ + SELECT COUNT(*) + FROM collected_data + WHERE longitude IS NOT NULL + AND longitude != '' + AND COALESCE(metadata->>'longitude', '') = '' + """, + "value_missing_in_metadata": """ + SELECT COUNT(*) + FROM collected_data + WHERE value IS NOT NULL + AND value != '' + AND COALESCE(metadata->>'value', '') = '' + """, + "unit_missing_in_metadata": """ + SELECT COUNT(*) + FROM collected_data + WHERE unit IS NOT NULL + AND unit != '' + AND COALESCE(metadata->>'unit', '') = '' + """, + "rows_with_any_legacy_value": """ + SELECT COUNT(*) + FROM collected_data + WHERE COALESCE(country, '') != '' + OR COALESCE(city, '') != '' + OR COALESCE(latitude, '') != '' + OR COALESCE(longitude, '') != '' + OR COALESCE(value, '') != '' + OR COALESCE(unit, '') != '' + """, + "total_rows": """ + SELECT COUNT(*) FROM collected_data + """, +} + + +async def scalar(session: AsyncSession, sql: str) -> int: + result = await session.execute(text(sql)) + return int(result.scalar() or 0) + + +async def main(): + database_url = os.environ.get( + "DATABASE_URL", "postgresql+asyncpg://postgres:postgres@localhost:5432/planet_db" + ) + engine = create_async_engine(database_url, echo=False) + async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + async with async_session() as session: + results = {name: await scalar(session, sql) for name, sql in CHECKS.items()} + + await engine.dispose() + + print("Collected Data Column Removal Readiness") + print("=" * 44) + for key, value in results.items(): + print(f"{key}: {value}") + + blocking_checks = { + key: value + for key, value in results.items() + if key.endswith("_missing_in_metadata") and value > 0 + } + + print("\nConclusion:") + if blocking_checks: + print("NOT READY") + print("The following fields still have legacy column values not mirrored into metadata:") + for key, value in blocking_checks.items(): + print(f"- {key}: {value}") + else: + print("READY FOR COLUMN REMOVAL CHECKPOINT") + print("All legacy column values are mirrored into metadata.") + print("You can proceed to the SQL migration after one more functional verification round.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/drop_collected_data_legacy_columns.py b/scripts/drop_collected_data_legacy_columns.py new file mode 100644 index 00000000..6432a8ef --- /dev/null +++ b/scripts/drop_collected_data_legacy_columns.py @@ -0,0 +1,41 @@ +"""Drop legacy collected_data columns after metadata backfill verification.""" + +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +from sqlalchemy import text + +ROOT = Path(__file__).resolve().parents[1] +BACKEND_DIR = ROOT / "backend" + +for path in (ROOT, BACKEND_DIR): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) + +from app.db.session import engine # noqa: E402 + + +DROP_SQL = """ +ALTER TABLE collected_data + DROP COLUMN IF EXISTS country, + DROP COLUMN IF EXISTS city, + DROP COLUMN IF EXISTS latitude, + DROP COLUMN IF EXISTS longitude, + DROP COLUMN IF EXISTS value, + DROP COLUMN IF EXISTS unit; +""" + + +async def main() -> None: + async with engine.begin() as conn: + await conn.execute(text(DROP_SQL)) + + print("Dropped legacy collected_data columns: country, city, latitude, longitude, value, unit.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/uv.lock b/uv.lock index 5b1fecf5..6ce747bb 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.11" +requires-python = ">=3.14" [[package]] name = "aiofiles" @@ -35,7 +35,6 @@ version = "4.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } wheels = [ @@ -43,12 +42,15 @@ wheels = [ ] [[package]] -name = "async-timeout" -version = "5.0.1" +name = "apscheduler" +version = "3.11.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, + { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, ] [[package]] @@ -57,30 +59,6 @@ version = "0.31.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/17/cc02bc49bc350623d050fa139e34ea512cd6e020562f2a7312a7bcae4bc9/asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d", size = 643159, upload-time = "2025-11-24T23:25:36.443Z" }, - { url = "https://files.pythonhosted.org/packages/a4/62/4ded7d400a7b651adf06f49ea8f73100cca07c6df012119594d1e3447aa6/asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab", size = 638157, upload-time = "2025-11-24T23:25:37.89Z" }, - { url = "https://files.pythonhosted.org/packages/d6/5b/4179538a9a72166a0bf60ad783b1ef16efb7960e4d7b9afe9f77a5551680/asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c", size = 2918051, upload-time = "2025-11-24T23:25:39.461Z" }, - { url = "https://files.pythonhosted.org/packages/e6/35/c27719ae0536c5b6e61e4701391ffe435ef59539e9360959240d6e47c8c8/asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109", size = 2972640, upload-time = "2025-11-24T23:25:41.512Z" }, - { url = "https://files.pythonhosted.org/packages/43/f4/01ebb9207f29e645a64699b9ce0eefeff8e7a33494e1d29bb53736f7766b/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da", size = 2851050, upload-time = "2025-11-24T23:25:43.153Z" }, - { url = "https://files.pythonhosted.org/packages/3e/f4/03ff1426acc87be0f4e8d40fa2bff5c3952bef0080062af9efc2212e3be8/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9", size = 2962574, upload-time = "2025-11-24T23:25:44.942Z" }, - { url = "https://files.pythonhosted.org/packages/c7/39/cc788dfca3d4060f9d93e67be396ceec458dfc429e26139059e58c2c244d/asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24", size = 521076, upload-time = "2025-11-24T23:25:46.486Z" }, - { url = "https://files.pythonhosted.org/packages/28/fc/735af5384c029eb7f1ca60ccb8fa95521dbdaeef788edf4cecfc604c3cab/asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047", size = 584980, upload-time = "2025-11-24T23:25:47.938Z" }, - { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, - { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, - { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, - { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, - { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, - { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, - { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, - { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, - { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, - { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, - { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, - { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, - { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, @@ -105,21 +83,6 @@ version = "5.0.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, - { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, - { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, - { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, - { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, - { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, - { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, - { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, - { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, - { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, - { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, - { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, - { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, - { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, @@ -163,10 +126,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, - { url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" }, - { url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" }, - { url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" }, - { url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, +] + +[[package]] +name = "black" +version = "26.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, + { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, + { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, ] [[package]] @@ -187,43 +181,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, @@ -320,12 +277,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/23/cbb2036e450980f65c6e0a173b73a56ff3bccd8998965dea5cc9ddd424a5/cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", size = 4664837, upload-time = "2026-01-28T00:24:17.629Z" }, { url = "https://files.pythonhosted.org/packages/0a/21/f7433d18fe6d5845329cbdc597e30caf983229c7a245bcf54afecc555938/cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", size = 3009779, upload-time = "2026-01-28T00:24:20.198Z" }, { url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" }, - { url = "https://files.pythonhosted.org/packages/59/e0/f9c6c53e1f2a1c2507f00f2faba00f01d2f334b35b0fbfe5286715da2184/cryptography-46.0.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b", size = 3476316, upload-time = "2026-01-28T00:24:24.144Z" }, - { url = "https://files.pythonhosted.org/packages/27/7a/f8d2d13227a9a1a9fe9c7442b057efecffa41f1e3c51d8622f26b9edbe8f/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da", size = 4216693, upload-time = "2026-01-28T00:24:25.758Z" }, - { url = "https://files.pythonhosted.org/packages/c5/de/3787054e8f7972658370198753835d9d680f6cd4a39df9f877b57f0dd69c/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80", size = 4382765, upload-time = "2026-01-28T00:24:27.577Z" }, - { url = "https://files.pythonhosted.org/packages/8a/5f/60e0afb019973ba6a0b322e86b3d61edf487a4f5597618a430a2a15f2d22/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822", size = 4216066, upload-time = "2026-01-28T00:24:29.056Z" }, - { url = "https://files.pythonhosted.org/packages/81/8e/bf4a0de294f147fee66f879d9bae6f8e8d61515558e3d12785dd90eca0be/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947", size = 4382025, upload-time = "2026-01-28T00:24:30.681Z" }, - { url = "https://files.pythonhosted.org/packages/79/f4/9ceb90cfd6a3847069b0b0b353fd3075dc69b49defc70182d8af0c4ca390/cryptography-46.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3", size = 3406043, upload-time = "2026-01-28T00:24:32.236Z" }, ] [[package]] @@ -383,33 +334,6 @@ version = "3.3.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/8a/99/1cd3411c56a410994669062bd73dd58270c00cc074cac15f385a1fd91f8a/greenlet-3.3.1.tar.gz", hash = "sha256:41848f3230b58c08bb43dee542e74a2a2e34d3c59dc3076cec9151aeeedcae98", size = 184690, upload-time = "2026-01-23T15:31:02.076Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/e8/2e1462c8fdbe0f210feb5ac7ad2d9029af8be3bf45bd9fa39765f821642f/greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c", size = 274974, upload-time = "2026-01-23T15:31:02.891Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a8/530a401419a6b302af59f67aaf0b9ba1015855ea7e56c036b5928793c5bd/greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f51496a0bfbaa9d74d36a52d2580d1ef5ed4fdfcff0a73730abfbbbe1403dd", size = 577175, upload-time = "2026-01-23T16:00:56.213Z" }, - { url = "https://files.pythonhosted.org/packages/8e/89/7e812bb9c05e1aaef9b597ac1d0962b9021d2c6269354966451e885c4e6b/greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb0feb07fe6e6a74615ee62a880007d976cf739b6669cce95daa7373d4fc69c5", size = 590401, upload-time = "2026-01-23T16:05:26.365Z" }, - { url = "https://files.pythonhosted.org/packages/70/ae/e2d5f0e59b94a2269b68a629173263fa40b63da32f5c231307c349315871/greenlet-3.3.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67ea3fc73c8cd92f42467a72b75e8f05ed51a0e9b1d15398c913416f2dafd49f", size = 601161, upload-time = "2026-01-23T16:15:53.456Z" }, - { url = "https://files.pythonhosted.org/packages/5c/ae/8d472e1f5ac5efe55c563f3eabb38c98a44b832602e12910750a7c025802/greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39eda9ba259cc9801da05351eaa8576e9aa83eb9411e8f0c299e05d712a210f2", size = 590272, upload-time = "2026-01-23T15:32:49.411Z" }, - { url = "https://files.pythonhosted.org/packages/a8/51/0fde34bebfcadc833550717eade64e35ec8738e6b097d5d248274a01258b/greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2e7e882f83149f0a71ac822ebf156d902e7a5d22c9045e3e0d1daf59cee2cc9", size = 1550729, upload-time = "2026-01-23T16:04:20.867Z" }, - { url = "https://files.pythonhosted.org/packages/16/c9/2fb47bee83b25b119d5a35d580807bb8b92480a54b68fef009a02945629f/greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80aa4d79eb5564f2e0a6144fcc744b5a37c56c4a92d60920720e99210d88db0f", size = 1615552, upload-time = "2026-01-23T15:33:45.743Z" }, - { url = "https://files.pythonhosted.org/packages/1f/54/dcf9f737b96606f82f8dd05becfb8d238db0633dd7397d542a296fe9cad3/greenlet-3.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:32e4ca9777c5addcbf42ff3915d99030d8e00173a56f80001fb3875998fe410b", size = 226462, upload-time = "2026-01-23T15:36:50.422Z" }, - { url = "https://files.pythonhosted.org/packages/91/37/61e1015cf944ddd2337447d8e97fb423ac9bc21f9963fb5f206b53d65649/greenlet-3.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:da19609432f353fed186cc1b85e9440db93d489f198b4bdf42ae19cc9d9ac9b4", size = 225715, upload-time = "2026-01-23T15:33:17.298Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443, upload-time = "2026-01-23T15:30:10.066Z" }, - { url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359, upload-time = "2026-01-23T16:00:57.394Z" }, - { url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805, upload-time = "2026-01-23T16:05:28.068Z" }, - { url = "https://files.pythonhosted.org/packages/3b/cd/7a7ca57588dac3389e97f7c9521cb6641fd8b6602faf1eaa4188384757df/greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca", size = 622363, upload-time = "2026-01-23T16:15:54.754Z" }, - { url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947, upload-time = "2026-01-23T15:32:51.004Z" }, - { url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487, upload-time = "2026-01-23T16:04:22.125Z" }, - { url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087, upload-time = "2026-01-23T15:33:47.486Z" }, - { url = "https://files.pythonhosted.org/packages/34/2f/5e0e41f33c69655300a5e54aeb637cf8ff57f1786a3aba374eacc0228c1d/greenlet-3.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cc98b9c4e4870fa983436afa999d4eb16b12872fab7071423d5262fa7120d57a", size = 227156, upload-time = "2026-01-23T15:34:34.808Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ab/717c58343cf02c5265b531384b248787e04d8160b8afe53d9eec053d7b44/greenlet-3.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bfb2d1763d777de5ee495c85309460f6fd8146e50ec9d0ae0183dbf6f0a829d1", size = 226403, upload-time = "2026-01-23T15:31:39.372Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" }, - { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" }, - { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" }, - { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" }, - { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" }, - { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b3/c9c23a6478b3bcc91f979ce4ca50879e4d0b2bd7b9a53d8ecded719b92e2/greenlet-3.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:27289986f4e5b0edec7b5a91063c109f0276abb09a7e9bdab08437525977c946", size = 227042, upload-time = "2026-01-23T15:33:58.216Z" }, - { url = "https://files.pythonhosted.org/packages/90/e7/824beda656097edee36ab15809fd063447b200cc03a7f6a24c34d520bc88/greenlet-3.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:2f080e028001c5273e0b42690eaf359aeef9cb1389da0f171ea51a5dc3c7608d", size = 226294, upload-time = "2026-01-23T15:30:52.73Z" }, { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" }, { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" }, { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" }, @@ -457,27 +381,6 @@ version = "0.7.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, - { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, - { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, - { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, - { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, - { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, - { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, - { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, - { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, - { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, - { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, - { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, - { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, - { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, - { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, - { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, - { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, - { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, - { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, @@ -511,17 +414,80 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "passlib" +version = "1.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, +] + +[package.optional-dependencies] +bcrypt = [ + { name = "bcrypt" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + [[package]] name = "planet" version = "1.0.0" source = { virtual = "." } dependencies = [ { name = "aiofiles" }, + { name = "apscheduler" }, { name = "asyncpg" }, { name = "bcrypt" }, + { name = "beautifulsoup4" }, { name = "email-validator" }, { name = "fastapi" }, { name = "httpx" }, + { name = "networkx" }, + { name = "passlib", extra = ["bcrypt"] }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "python-dotenv" }, @@ -532,14 +498,26 @@ dependencies = [ { name = "uvicorn", extra = ["standard"] }, ] +[package.dev-dependencies] +dev = [ + { name = "black" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + [package.metadata] requires-dist = [ { name = "aiofiles", specifier = ">=23.2.1" }, + { name = "apscheduler", specifier = ">=3.10.4" }, { name = "asyncpg", specifier = ">=0.29.0" }, { name = "bcrypt", specifier = ">=4.0.0" }, + { name = "beautifulsoup4", specifier = ">=4.12.0" }, { name = "email-validator", specifier = ">=2.1.0" }, { name = "fastapi", specifier = ">=0.109.0" }, { name = "httpx", specifier = ">=0.26.0" }, + { name = "networkx", specifier = ">=3.0" }, + { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, { name = "pydantic", specifier = ">=2.5.0" }, { name = "pydantic-settings", specifier = ">=2.1.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, @@ -550,6 +528,32 @@ requires-dist = [ { name = "uvicorn", extras = ["standard"], specifier = ">=0.27.0" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "black", specifier = ">=24.0.0" }, + { name = "pytest", specifier = ">=7.4.0" }, + { name = "pytest-asyncio", specifier = ">=0.23.0" }, + { name = "ruff", specifier = ">=0.6.0" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pyasn1" version = "0.6.2" @@ -592,48 +596,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, - { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, - { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, - { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, - { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, - { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, - { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, - { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, - { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, - { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, - { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, @@ -662,22 +624,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, - { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, - { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, - { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, - { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, - { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, - { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, - { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] [[package]] @@ -694,6 +640,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, ] +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" @@ -731,41 +714,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, ] +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, @@ -790,9 +763,6 @@ wheels = [ name = "redis" version = "7.1.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, -] sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, @@ -810,6 +780,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, ] +[[package]] +name = "ruff" +version = "0.15.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, + { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, + { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, + { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, + { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, + { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -819,6 +814,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "soupsieve" +version = "2.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.46" @@ -829,31 +833,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/ac/b42ad16800d0885105b59380ad69aad0cce5a65276e269ce2729a2343b6a/sqlalchemy-2.0.46-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:261c4b1f101b4a411154f1da2b76497d73abbfc42740029205d4d01fa1052684", size = 2154851, upload-time = "2026-01-21T18:27:30.54Z" }, - { url = "https://files.pythonhosted.org/packages/a0/60/d8710068cb79f64d002ebed62a7263c00c8fd95f4ebd4b5be8f7ca93f2bc/sqlalchemy-2.0.46-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:181903fe8c1b9082995325f1b2e84ac078b1189e2819380c2303a5f90e114a62", size = 3311241, upload-time = "2026-01-21T18:32:33.45Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/20c71487c7219ab3aa7421c7c62d93824c97c1460f2e8bb72404b0192d13/sqlalchemy-2.0.46-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:590be24e20e2424a4c3c1b0835e9405fa3d0af5823a1a9fc02e5dff56471515f", size = 3310741, upload-time = "2026-01-21T18:44:57.887Z" }, - { url = "https://files.pythonhosted.org/packages/65/80/d26d00b3b249ae000eee4db206fcfc564bf6ca5030e4747adf451f4b5108/sqlalchemy-2.0.46-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7568fe771f974abadce52669ef3a03150ff03186d8eb82613bc8adc435a03f01", size = 3263116, upload-time = "2026-01-21T18:32:35.044Z" }, - { url = "https://files.pythonhosted.org/packages/da/ee/74dda7506640923821340541e8e45bd3edd8df78664f1f2e0aae8077192b/sqlalchemy-2.0.46-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf7e1e78af38047e08836d33502c7a278915698b7c2145d045f780201679999", size = 3285327, upload-time = "2026-01-21T18:44:59.254Z" }, - { url = "https://files.pythonhosted.org/packages/9f/25/6dcf8abafff1389a21c7185364de145107b7394ecdcb05233815b236330d/sqlalchemy-2.0.46-cp311-cp311-win32.whl", hash = "sha256:9d80ea2ac519c364a7286e8d765d6cd08648f5b21ca855a8017d9871f075542d", size = 2114564, upload-time = "2026-01-21T18:33:15.85Z" }, - { url = "https://files.pythonhosted.org/packages/93/5f/e081490f8523adc0088f777e4ebad3cac21e498ec8a3d4067074e21447a1/sqlalchemy-2.0.46-cp311-cp311-win_amd64.whl", hash = "sha256:585af6afe518732d9ccd3aea33af2edaae4a7aa881af5d8f6f4fe3a368699597", size = 2139233, upload-time = "2026-01-21T18:33:17.528Z" }, - { url = "https://files.pythonhosted.org/packages/b6/35/d16bfa235c8b7caba3730bba43e20b1e376d2224f407c178fbf59559f23e/sqlalchemy-2.0.46-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a9a72b0da8387f15d5810f1facca8f879de9b85af8c645138cba61ea147968c", size = 2153405, upload-time = "2026-01-21T19:05:54.143Z" }, - { url = "https://files.pythonhosted.org/packages/06/6c/3192e24486749862f495ddc6584ed730c0c994a67550ec395d872a2ad650/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2347c3f0efc4de367ba00218e0ae5c4ba2306e47216ef80d6e31761ac97cb0b9", size = 3334702, upload-time = "2026-01-21T18:46:45.384Z" }, - { url = "https://files.pythonhosted.org/packages/ea/a2/b9f33c8d68a3747d972a0bb758c6b63691f8fb8a49014bc3379ba15d4274/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9094c8b3197db12aa6f05c51c05daaad0a92b8c9af5388569847b03b1007fb1b", size = 3347664, upload-time = "2026-01-21T18:40:09.979Z" }, - { url = "https://files.pythonhosted.org/packages/aa/d2/3e59e2a91eaec9db7e8dc6b37b91489b5caeb054f670f32c95bcba98940f/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37fee2164cf21417478b6a906adc1a91d69ae9aba8f9533e67ce882f4bb1de53", size = 3277372, upload-time = "2026-01-21T18:46:47.168Z" }, - { url = "https://files.pythonhosted.org/packages/dd/dd/67bc2e368b524e2192c3927b423798deda72c003e73a1e94c21e74b20a85/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b1e14b2f6965a685c7128bd315e27387205429c2e339eeec55cb75ca4ab0ea2e", size = 3312425, upload-time = "2026-01-21T18:40:11.548Z" }, - { url = "https://files.pythonhosted.org/packages/43/82/0ecd68e172bfe62247e96cb47867c2d68752566811a4e8c9d8f6e7c38a65/sqlalchemy-2.0.46-cp312-cp312-win32.whl", hash = "sha256:412f26bb4ba942d52016edc8d12fb15d91d3cd46b0047ba46e424213ad407bcb", size = 2113155, upload-time = "2026-01-21T18:42:49.748Z" }, - { url = "https://files.pythonhosted.org/packages/bc/2a/2821a45742073fc0331dc132552b30de68ba9563230853437cac54b2b53e/sqlalchemy-2.0.46-cp312-cp312-win_amd64.whl", hash = "sha256:ea3cd46b6713a10216323cda3333514944e510aa691c945334713fca6b5279ff", size = 2140078, upload-time = "2026-01-21T18:42:51.197Z" }, - { url = "https://files.pythonhosted.org/packages/b3/4b/fa7838fe20bb752810feed60e45625a9a8b0102c0c09971e2d1d95362992/sqlalchemy-2.0.46-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00", size = 2150268, upload-time = "2026-01-21T19:05:56.621Z" }, - { url = "https://files.pythonhosted.org/packages/46/c1/b34dccd712e8ea846edf396e00973dda82d598cb93762e55e43e6835eba9/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2", size = 3276511, upload-time = "2026-01-21T18:46:49.022Z" }, - { url = "https://files.pythonhosted.org/packages/96/48/a04d9c94753e5d5d096c628c82a98c4793b9c08ca0e7155c3eb7d7db9f24/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee", size = 3292881, upload-time = "2026-01-21T18:40:13.089Z" }, - { url = "https://files.pythonhosted.org/packages/be/f4/06eda6e91476f90a7d8058f74311cb65a2fb68d988171aced81707189131/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad", size = 3224559, upload-time = "2026-01-21T18:46:50.974Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a2/d2af04095412ca6345ac22b33b89fe8d6f32a481e613ffcb2377d931d8d0/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e", size = 3262728, upload-time = "2026-01-21T18:40:14.883Z" }, - { url = "https://files.pythonhosted.org/packages/31/48/1980c7caa5978a3b8225b4d230e69a2a6538a3562b8b31cea679b6933c83/sqlalchemy-2.0.46-cp313-cp313-win32.whl", hash = "sha256:8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f", size = 2111295, upload-time = "2026-01-21T18:42:52.366Z" }, - { url = "https://files.pythonhosted.org/packages/2d/54/f8d65bbde3d877617c4720f3c9f60e99bb7266df0d5d78b6e25e7c149f35/sqlalchemy-2.0.46-cp313-cp313-win_amd64.whl", hash = "sha256:77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef", size = 2137076, upload-time = "2026-01-21T18:42:53.924Z" }, - { url = "https://files.pythonhosted.org/packages/56/ba/9be4f97c7eb2b9d5544f2624adfc2853e796ed51d2bb8aec90bc94b7137e/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10", size = 3556533, upload-time = "2026-01-21T18:33:06.636Z" }, - { url = "https://files.pythonhosted.org/packages/20/a6/b1fc6634564dbb4415b7ed6419cdfeaadefd2c39cdab1e3aa07a5f2474c2/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764", size = 3523208, upload-time = "2026-01-21T18:45:08.436Z" }, - { url = "https://files.pythonhosted.org/packages/a1/d8/41e0bdfc0f930ff236f86fccd12962d8fa03713f17ed57332d38af6a3782/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b", size = 3464292, upload-time = "2026-01-21T18:33:08.208Z" }, - { url = "https://files.pythonhosted.org/packages/f0/8b/9dcbec62d95bea85f5ecad9b8d65b78cc30fb0ffceeb3597961f3712549b/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447", size = 3473497, upload-time = "2026-01-21T18:45:10.552Z" }, { url = "https://files.pythonhosted.org/packages/e9/f8/5ecdfc73383ec496de038ed1614de9e740a82db9ad67e6e4514ebc0708a3/sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada", size = 2152079, upload-time = "2026-01-21T19:05:58.477Z" }, { url = "https://files.pythonhosted.org/packages/e5/bf/eba3036be7663ce4d9c050bc3d63794dc29fbe01691f2bf5ccb64e048d20/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366", size = 3272216, upload-time = "2026-01-21T18:46:52.634Z" }, { url = "https://files.pythonhosted.org/packages/05/45/1256fb597bb83b58a01ddb600c59fe6fdf0e5afe333f0456ed75c0f8d7bd/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d", size = 3277208, upload-time = "2026-01-21T18:40:16.38Z" }, @@ -879,7 +858,6 @@ version = "0.50.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } wheels = [ @@ -907,6 +885,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + [[package]] name = "uvicorn" version = "0.40.0" @@ -937,24 +936,6 @@ version = "0.22.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, - { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, - { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, - { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, - { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, - { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, - { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, - { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, - { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, - { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, - { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, - { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, - { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, - { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, - { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, @@ -978,55 +959,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, - { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, - { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, - { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, - { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, - { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, - { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, - { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, - { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, - { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, - { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, - { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, - { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, - { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, - { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, - { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, - { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, - { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, - { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, - { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, - { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, - { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, - { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, - { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, - { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, - { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, - { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, - { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, - { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, - { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, - { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, - { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, - { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, - { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, @@ -1050,10 +982,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, - { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, - { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, - { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, ] [[package]] @@ -1062,33 +990,6 @@ version = "16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, - { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, - { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, - { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, - { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, - { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, - { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, - { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, - { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, - { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, - { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, - { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, - { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, - { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, - { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, - { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, - { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, - { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, - { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, - { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, @@ -1107,10 +1008,5 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, - { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, - { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, - { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, - { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ]