Compare commits
39 Commits
b06cb4606f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 36672e4c53 | |||
|
|
020c1d5051 | ||
|
|
cc5f16f8a7 | ||
|
|
ef0fefdfc7 | ||
|
|
81a0ca5e7a | ||
|
|
b57d69c98b | ||
|
|
b9fbacade7 | ||
|
|
543fe35fbb | ||
|
|
1784c057e5 | ||
|
|
465129eec7 | ||
| 506402ce16 | |||
| 9d135bf2e1 | |||
|
|
0c950262d3 | ||
|
|
eabdbdc85a | ||
|
|
af29e90cb0 | ||
|
|
d9a64f7768 | ||
|
|
78bb639a83 | ||
|
|
49a9c33836 | ||
|
|
96222b9e4c | ||
|
|
3fcbae55dc | ||
|
|
3e3090d72a | ||
|
|
4f922f13d1 | ||
|
|
bb6b18fe3b | ||
|
|
0ecc1bc537 | ||
|
|
869d661a94 | ||
|
|
d18e400fcb | ||
|
|
6fabbcfe5c | ||
|
|
1189fec014 | ||
|
|
82f7aa29a6 | ||
|
|
777891f865 | ||
|
|
c2eba54da0 | ||
|
|
f50830712c | ||
|
|
e21b783bef | ||
|
|
11a9dda942 | ||
|
|
3b0e9dec5a | ||
|
|
c82e1d5a04 | ||
|
|
02991730e5 | ||
|
|
4e487b315a | ||
|
|
948af2c88f |
2
.gitignore
vendored
@@ -41,6 +41,8 @@ MANIFEST
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.uv/
|
||||
.uv-cache/
|
||||
.ruff_cache/
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.14
|
||||
165
.sisyphus/plans/earth-architecture-refactor.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# 地球3D可视化架构重构计划
|
||||
|
||||
## 背景
|
||||
|
||||
当前 `frontend/public/earth` 3D地球可视化系统基于 Three.js 构建,未来需要迁移到 Unreal Engine (Cesium)。为降低迁移成本,需要提前做好**逻辑与渲染分离**的架构设计。
|
||||
|
||||
## 目标
|
||||
|
||||
- 将线缆高亮逻辑与渲染实现分离
|
||||
- 保持交互逻辑可复用,只需重写渲染层
|
||||
- 为后续迁移到 UE/Cesium 做好准备
|
||||
|
||||
## 已完成
|
||||
|
||||
### 1. 状态枚举定义 (constants.js)
|
||||
|
||||
```javascript
|
||||
export const CABLE_STATE = {
|
||||
NORMAL: 'normal',
|
||||
HOVERED: 'hovered',
|
||||
LOCKED: 'locked'
|
||||
};
|
||||
```
|
||||
|
||||
### 2. 线缆状态管理 (cables.js - 数据层)
|
||||
|
||||
```javascript
|
||||
const cableStates = new Map();
|
||||
|
||||
export function getCableState(cableId) { ... }
|
||||
export function setCableState(cableId, state) { ... }
|
||||
export function clearAllCableStates() { ... }
|
||||
export function getCableStateInfo() { ... }
|
||||
```
|
||||
|
||||
### 3. 逻辑层调用 (main.js)
|
||||
|
||||
```javascript
|
||||
// 悬停
|
||||
setCableState(cable.userData.cableId, CABLE_STATE.HOVERED);
|
||||
|
||||
// 锁定
|
||||
setCableState(cableId, CABLE_STATE.LOCKED);
|
||||
|
||||
// 恢复
|
||||
setCableState(cableId, CABLE_STATE.NORMAL);
|
||||
clearAllCableStates();
|
||||
|
||||
// 清除锁定时
|
||||
clearLockedObject() {
|
||||
hoveredCable = null;
|
||||
clearAllCableStates();
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 渲染层 (main.js - applyCableVisualState)
|
||||
|
||||
```javascript
|
||||
function applyCableVisualState() {
|
||||
const allCables = getCableLines();
|
||||
const pulse = (Math.sin(Date.now() * CABLE_CONFIG.pulseSpeed) + 1) * 0.5;
|
||||
|
||||
allCables.forEach(c => {
|
||||
const cableId = c.userData.cableId;
|
||||
const state = getCableState(cableId);
|
||||
|
||||
switch (state) {
|
||||
case CABLE_STATE.LOCKED:
|
||||
// 呼吸效果 + 白色
|
||||
c.material.opacity = CABLE_CONFIG.lockedOpacityMin + pulse * CABLE_CONFIG.pulseCoefficient;
|
||||
c.material.color.setRGB(1, 1, 1);
|
||||
break;
|
||||
case CABLE_STATE.HOVERED:
|
||||
// 白色高亮
|
||||
c.material.opacity = 1;
|
||||
c.material.color.setRGB(1, 1, 1);
|
||||
break;
|
||||
case CABLE_STATE.NORMAL:
|
||||
default:
|
||||
if (lockedObjectType === 'cable' && lockedObject) {
|
||||
// 其他线缆变暗
|
||||
c.material.opacity = CABLE_CONFIG.otherOpacity;
|
||||
...
|
||||
} else {
|
||||
// 恢复原始
|
||||
c.material.opacity = 1;
|
||||
c.material.color.setHex(c.userData.originalColor);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 待完成
|
||||
|
||||
### Phase 1: 完善状态配置 (constants.js)
|
||||
|
||||
```javascript
|
||||
export const CABLE_CONFIG = {
|
||||
lockedOpacityMin: 0.6,
|
||||
lockedOpacityMax: 1.0,
|
||||
otherOpacity: 0.5,
|
||||
otherBrightness: 0.6,
|
||||
pulseSpeed: 0.003,
|
||||
pulseCoefficient: 0.4,
|
||||
// 未来可扩展
|
||||
// lockedLineWidth: 3,
|
||||
// normalLineWidth: 1,
|
||||
};
|
||||
```
|
||||
|
||||
### Phase 2: 卫星状态管理 (satellites.js)
|
||||
|
||||
参考线缆状态管理,为卫星添加类似的状态枚举和状态管理函数:
|
||||
|
||||
```javascript
|
||||
export const SATELLITE_STATE = {
|
||||
NORMAL: 'normal',
|
||||
HOVERED: 'hovered',
|
||||
LOCKED: 'locked'
|
||||
};
|
||||
```
|
||||
|
||||
#### 卫星数据源说明
|
||||
|
||||
- **当前使用**: CelesTrak (https://celestrak.org) - 免费,无需认证
|
||||
- **后续计划**: Space-Track.org (https://space-track.org) - 需要认证,数据更权威
|
||||
- 迁移时只需修改 `satellites.js` 中的数据获取逻辑,状态管理和渲染逻辑不变
|
||||
|
||||
### Phase 3: 统一渲染接口
|
||||
|
||||
将所有对象的渲染逻辑抽象为一个统一的渲染函数:
|
||||
|
||||
```javascript
|
||||
function applyObjectVisualState() {
|
||||
applyCableVisualState();
|
||||
applySatelliteVisualState();
|
||||
applyLandingPointVisualState();
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4: UE 迁移准备
|
||||
|
||||
迁移到 Unreal Engine 时:
|
||||
1. 保留 `constants.js` 中的枚举和配置
|
||||
2. 保留 `cables.js` 中的数据层和状态管理
|
||||
3. 保留 `main.js` 中的交互逻辑
|
||||
4. **仅重写** `applyCableVisualState()` 等渲染函数
|
||||
|
||||
---
|
||||
|
||||
## 架构原则
|
||||
|
||||
1. **状态与渲染分离** - 对象状态由数据层管理,渲染层只负责根据状态更新视觉效果
|
||||
2. **逻辑可复用** - 交互逻辑(点击、悬停、锁定)在迁移时应直接复用
|
||||
3. **渲染可替换** - 渲染实现可以针对不同引擎重写,不影响逻辑层
|
||||
|
||||
## 文件变更记录
|
||||
|
||||
| 日期 | 文件 | 变更 |
|
||||
|------|------|------|
|
||||
| 2026-03-19 | constants.js | 新增 CABLE_STATE 枚举 |
|
||||
| 2026-03-19 | cables.js | 新增状态管理函数 |
|
||||
| 2026-03-19 | main.js | 使用状态管理,抽象 applyCableVisualState() |
|
||||
136
.sisyphus/plans/predicted-orbit.md
Normal file
@@ -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. 验证:轨迹不突然闪现累积
|
||||
293
.sisyphus/plans/webgl-instancing-satellites.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# WebGL Instancing 卫星渲染优化计划
|
||||
|
||||
## 背景
|
||||
|
||||
当前 `satellites.js` 使用 `THREE.Points` 渲染卫星,受限于 WebGL 点渲染性能,只能显示 ~500-1000 颗卫星。
|
||||
需要迁移到真正的 WebGL Instancing 以支持 5000+ 卫星流畅渲染。
|
||||
|
||||
## 技术选型
|
||||
|
||||
| 方案 | 性能 | 改动量 | 维护性 | 推荐 |
|
||||
|------|------|--------|--------|------|
|
||||
| THREE.Points (现状) | ★★☆ | - | - | 基准 |
|
||||
| THREE.InstancedMesh | ★★★ | 中 | 高 | 不适合点 |
|
||||
| InstancedBufferGeometry + 自定义Shader | ★★★★ | 中高 | 中 | ✅ 推荐 |
|
||||
| 迁移到 TWGL.js / Raw WebGL | ★★★★★ | 高 | 低 | 未来UE |
|
||||
|
||||
**推荐方案**: InstancedBufferGeometry + 自定义 Shader
|
||||
- 保持 Three.js 架构
|
||||
- 复用 satellite.js 数据层
|
||||
- 性能接近原生 WebGL
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 调研与原型
|
||||
|
||||
### 1.1 分析现有架构
|
||||
|
||||
**现状 (satellites.js)**:
|
||||
```javascript
|
||||
// 创建点云
|
||||
const pointsGeometry = new THREE.BufferGeometry();
|
||||
pointsGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
pointsGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||
|
||||
const pointsMaterial = new THREE.PointsMaterial({
|
||||
size: 2,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
sizeAttenuation: true
|
||||
});
|
||||
|
||||
satellitePoints = new THREE.Points(pointsGeometry, pointsMaterial);
|
||||
```
|
||||
|
||||
**问题**: 每个卫星作为一个顶点,GPU 需要处理 ~500 个 draw calls (取决于视锥体裁剪)
|
||||
|
||||
### 1.2 Instanced Rendering 原理
|
||||
|
||||
```javascript
|
||||
// 目标:单次 draw call 渲染所有卫星
|
||||
// 每个卫星属性:
|
||||
// - position (vec3): 位置
|
||||
// - color (vec3): 颜色
|
||||
// - size (float): 大小 (可选)
|
||||
// - selected (float): 是否选中 (0/1)
|
||||
|
||||
// 使用 InstancedBufferGeometry
|
||||
const geometry = new THREE.InstancedBufferGeometry();
|
||||
geometry.index = originalGeometry.index;
|
||||
geometry.attributes.position = originalGeometry.attributes.position;
|
||||
geometry.attributes.uv = originalGeometry.attributes.uv;
|
||||
|
||||
// 实例数据
|
||||
const instancePositions = new Float32Array(satelliteCount * 3);
|
||||
const instanceColors = new Float32Array(satelliteCount * 3);
|
||||
|
||||
geometry.setAttribute('instancePosition',
|
||||
new THREE.InstancedBufferAttribute(instancePositions, 3));
|
||||
geometry.setAttribute('instanceColor',
|
||||
new THREE.InstancedBufferAttribute(instanceColors, 3));
|
||||
|
||||
// 自定义 Shader
|
||||
const material = new THREE.ShaderMaterial({
|
||||
vertexShader: `
|
||||
attribute vec3 instancePosition;
|
||||
attribute vec3 instanceColor;
|
||||
varying vec3 vColor;
|
||||
|
||||
void main() {
|
||||
vColor = instanceColor;
|
||||
vec3 transformed = position + instancePosition;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
varying vec3 vColor;
|
||||
void main() {
|
||||
gl_FragColor = vec4(vColor, 0.8);
|
||||
}
|
||||
`
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 实现
|
||||
|
||||
### 2.1 创建 instanced-satellites.js
|
||||
|
||||
```javascript
|
||||
// instanced-satellites.js - Instanced rendering for satellites
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { SATELLITE_CONFIG } from './constants.js';
|
||||
|
||||
let instancedMesh = null;
|
||||
let satelliteData = [];
|
||||
let instancePositions = null;
|
||||
let instanceColors = null;
|
||||
let satelliteCount = 0;
|
||||
|
||||
const SATELLITE_VERTEX_SHADER = `
|
||||
attribute vec3 instancePosition;
|
||||
attribute vec3 instanceColor;
|
||||
attribute float instanceSize;
|
||||
|
||||
varying vec3 vColor;
|
||||
|
||||
void main() {
|
||||
vColor = instanceColor;
|
||||
vec3 transformed = position * instanceSize + instancePosition;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const SATELLITE_FRAGMENT_SHADER = `
|
||||
varying vec3 vColor;
|
||||
|
||||
void main() {
|
||||
gl_FragColor = vec4(vColor, 0.9);
|
||||
}
|
||||
`;
|
||||
|
||||
export function createInstancedSatellites(scene, earthObj) {
|
||||
// 基础球体几何 (每个卫星是一个小圆点)
|
||||
const baseGeometry = new THREE.CircleGeometry(1, 8);
|
||||
|
||||
// 创建 InstancedBufferGeometry
|
||||
const geometry = new THREE.InstancedBufferGeometry();
|
||||
geometry.index = baseGeometry.index;
|
||||
geometry.attributes.position = baseGeometry.attributes.position;
|
||||
geometry.attributes.uv = baseGeometry.attributes.uv;
|
||||
|
||||
// 初始化实例数据数组 (稍后填充)
|
||||
instancePositions = new Float32Array(MAX_SATELLITES * 3);
|
||||
instanceColors = new Float32Array(MAX_SATELLITES * 3);
|
||||
const instanceSizes = new Float32Array(MAX_SATELLITES);
|
||||
|
||||
geometry.setAttribute('instancePosition',
|
||||
new THREE.InstancedBufferAttribute(instancePositions, 3));
|
||||
geometry.setAttribute('instanceColor',
|
||||
new THREE.InstancedBufferAttribute(instanceColors, 3));
|
||||
geometry.setAttribute('instanceSize',
|
||||
new THREE.InstancedBufferAttribute(instanceSizes, 1));
|
||||
|
||||
const material = new THREE.ShaderMaterial({
|
||||
vertexShader: SATELLITE_VERTEX_SHADER,
|
||||
fragmentShader: SATELLITE_FRAGMENT_SHADER,
|
||||
transparent: true,
|
||||
side: THREE.DoubleSide
|
||||
});
|
||||
|
||||
instancedMesh = new THREE.Mesh(geometry, material);
|
||||
instancedMesh.frustumCulled = false; // 我们自己处理裁剪
|
||||
scene.add(instancedMesh);
|
||||
|
||||
return instancedMesh;
|
||||
}
|
||||
|
||||
export function updateInstancedSatellites(satellitePositions) {
|
||||
// satellitePositions: Array of { position: Vector3, color: Color }
|
||||
const count = Math.min(satellitePositions.length, MAX_SATELLITES);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const sat = satellitePositions[i];
|
||||
instancePositions[i * 3] = sat.position.x;
|
||||
instancePositions[i * 3 + 1] = sat.position.y;
|
||||
instancePositions[i * 3 + 2] = sat.position.z;
|
||||
|
||||
instanceColors[i * 3] = sat.color.r;
|
||||
instanceColors[i * 3 + 1] = sat.color.g;
|
||||
instanceColors[i * 3 + 2] = sat.color.b;
|
||||
}
|
||||
|
||||
instancedMesh.geometry.attributes.instancePosition.needsUpdate = true;
|
||||
instancedMesh.geometry.attributes.instanceColor.needsUpdate = true;
|
||||
instancedMesh.geometry.setDrawRange(0, count);
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 修改现有 satellites.js
|
||||
|
||||
保持数据层不变,添加新渲染模式:
|
||||
|
||||
```javascript
|
||||
// 添加配置
|
||||
export const SATELLITE_CONFIG = {
|
||||
USE_INSTANCING: true, // 切换渲染模式
|
||||
MAX_SATELLITES: 5000,
|
||||
SATELLITE_SIZE: 0.5,
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
### 2.3 性能优化点
|
||||
|
||||
1. **GPU 实例化**: 单次 draw call 渲染所有卫星
|
||||
2. **批量更新**: 所有位置/颜色一次更新
|
||||
3. **视锥体裁剪**: 自定义裁剪逻辑,避免 CPU 端逐卫星检测
|
||||
4. **LOD (可选)**: 远处卫星简化显示
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 与现有系统集成
|
||||
|
||||
### 3.1 悬停/选中处理
|
||||
|
||||
当前通过 `selectSatellite()` 设置选中状态,Instanced 模式下需要:
|
||||
|
||||
```javascript
|
||||
// 在 shader 中通过 instanceId 判断是否选中
|
||||
// 或者使用单独的 InstancedBufferAttribute 存储选中状态
|
||||
const instanceSelected = new Float32Array(MAX_SATELLITES);
|
||||
geometry.setAttribute('instanceSelected',
|
||||
new THREE.InstancedBufferAttribute(instanceSelected, 1));
|
||||
```
|
||||
|
||||
### 3.2 轨迹线
|
||||
|
||||
轨迹线仍然使用 `THREE.Line` 或 `THREE.LineSegments`,但可以类似地 Instanced 化:
|
||||
|
||||
```javascript
|
||||
// Instanced LineSegments for trails
|
||||
const trailGeometry = new THREE.InstancedBufferGeometry();
|
||||
trailGeometry.setAttribute('position', trailPositions);
|
||||
trailGeometry.setAttribute('instanceStart', ...);
|
||||
trailGeometry.setAttribute('instanceEnd', ...);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: 验证与调优
|
||||
|
||||
### 4.1 性能测试
|
||||
|
||||
| 卫星数量 | Points 模式 | Instanced 模式 |
|
||||
|----------|-------------|----------------|
|
||||
| 500 | ✅ 60fps | ✅ 60fps |
|
||||
| 2000 | ⚠️ 30fps | ✅ 60fps |
|
||||
| 5000 | ❌ 10fps | ✅ 45fps |
|
||||
| 10000 | ❌ 卡顿 | ⚠️ 30fps |
|
||||
|
||||
### 4.2 可能遇到的问题
|
||||
|
||||
1. **Shader 编译错误**: 需要调试 GLSL
|
||||
2. **实例数量限制**: GPU 最大实例数 (通常 65535)
|
||||
3. **大小不一**: 需要 per-instance size 属性
|
||||
4. **透明度排序**: Instanced 渲染透明度处理复杂
|
||||
|
||||
---
|
||||
|
||||
## 文件变更清单
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `constants.js` | 新增 `SATELLITE_CONFIG` |
|
||||
| `satellites.js` | 添加 Instanced 模式支持 |
|
||||
| `instanced-satellites.js` | 新文件 - Instanced 渲染核心 |
|
||||
| `main.js` | 集成新渲染模块 |
|
||||
|
||||
---
|
||||
|
||||
## 时间估算
|
||||
|
||||
| Phase | 工作量 | 难度 |
|
||||
|-------|--------|------|
|
||||
| Phase 1 | 1-2 天 | 低 |
|
||||
| Phase 2 | 2-3 天 | 中 |
|
||||
| Phase 3 | 1-2 天 | 中 |
|
||||
| Phase 4 | 1 天 | 低 |
|
||||
| **总计** | **5-8 天** | - |
|
||||
|
||||
---
|
||||
|
||||
## 替代方案考虑
|
||||
|
||||
如果 Phase 2 实施困难,可以考虑:
|
||||
|
||||
1. **使用 Three.js InstancedMesh**: 适合渲染小型 3D 模型替代点
|
||||
2. **使用 pointcloud2 格式**: 类似 LiDAR 点云渲染
|
||||
3. **Web Workers**: 将轨道计算移到 Worker 线程
|
||||
4. **迁移到 Cesium**: Cesium 原生支持 Instancing,且是 UE 迁移的中间步骤
|
||||
@@ -16,4 +16,4 @@ COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
|
||||
@@ -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],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -1,141 +1,67 @@
|
||||
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.core.data_sources import get_data_sources_config
|
||||
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 get_latest_task_id_for_datasource, 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,
|
||||
},
|
||||
}
|
||||
|
||||
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("")
|
||||
@@ -146,48 +72,26 @@ 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)
|
||||
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)
|
||||
)
|
||||
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
|
||||
@@ -196,31 +100,26 @@ 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,
|
||||
"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,
|
||||
}
|
||||
)
|
||||
|
||||
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}")
|
||||
@@ -229,19 +128,24 @@ 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]
|
||||
config = get_data_sources_config()
|
||||
endpoint = await config.get_url(datasource.source, db)
|
||||
|
||||
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,
|
||||
"endpoint": endpoint,
|
||||
"is_active": datasource.is_active,
|
||||
}
|
||||
|
||||
|
||||
@@ -249,24 +153,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")
|
||||
@@ -275,26 +187,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)
|
||||
total = result.scalar() or 0
|
||||
|
||||
if total == 0:
|
||||
query = select(func.count(CollectedData.id)).where(CollectedData.source == source_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
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -303,30 +208,32 @@ 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}'")
|
||||
|
||||
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
|
||||
|
||||
if success:
|
||||
return {
|
||||
"status": "triggered",
|
||||
"source_id": source_id,
|
||||
"collector_name": collector_name,
|
||||
"message": f"Collector '{collector_name}' has been triggered",
|
||||
"source_id": datasource.id,
|
||||
"task_id": task_id,
|
||||
"collector_name": datasource.source,
|
||||
"message": f"Collector '{datasource.source}' has been triggered",
|
||||
}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to trigger collector '{collector_name}'",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{source_id}/data")
|
||||
@@ -335,39 +242,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,
|
||||
}
|
||||
|
||||
@@ -375,32 +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),
|
||||
):
|
||||
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]
|
||||
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)
|
||||
|
||||
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()
|
||||
|
||||
if not running_task:
|
||||
return {"is_running": False, "task_id": None, "progress": None}
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
"""Visualization API - GeoJSON endpoints for 3D Earth display"""
|
||||
"""Visualization API - GeoJSON endpoints for 3D Earth display
|
||||
|
||||
Unified API for all visualization data sources.
|
||||
Returns GeoJSON format compatible with Three.js, CesiumJS, and Unreal Cesium.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
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
|
||||
@@ -12,6 +18,9 @@ from app.services.cable_graph import build_graph_from_data, CableGraph
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============== Converter Functions ==============
|
||||
|
||||
|
||||
def convert_cable_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
|
||||
"""Convert cable records to GeoJSON FeatureCollection"""
|
||||
features = []
|
||||
@@ -66,6 +75,7 @@ def convert_cable_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
|
||||
"geometry": {"type": "MultiLineString", "coordinates": all_lines},
|
||||
"properties": {
|
||||
"id": record.id,
|
||||
"cable_id": record.name,
|
||||
"source_id": record.source_id,
|
||||
"Name": record.name,
|
||||
"name": record.name,
|
||||
@@ -74,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"),
|
||||
@@ -87,14 +97,15 @@ def convert_cable_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
|
||||
|
||||
def convert_landing_point_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
|
||||
"""Convert landing point records to GeoJSON FeatureCollection"""
|
||||
def convert_landing_point_to_geojson(records: List[CollectedData], city_to_cable_ids_map: Dict[int, List[int]] = None, cable_id_to_name_map: Dict[int, str] = None) -> Dict[str, Any]:
|
||||
features = []
|
||||
|
||||
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
|
||||
|
||||
@@ -102,18 +113,68 @@ def convert_landing_point_to_geojson(records: List[CollectedData]) -> Dict[str,
|
||||
continue
|
||||
|
||||
metadata = record.extra_data or {}
|
||||
city_id = metadata.get("city_id")
|
||||
|
||||
props = {
|
||||
"id": record.id,
|
||||
"source_id": record.source_id,
|
||||
"name": record.name,
|
||||
"country": get_record_field(record, "country"),
|
||||
"city": get_record_field(record, "city"),
|
||||
"is_tbd": metadata.get("is_tbd", False),
|
||||
}
|
||||
|
||||
cable_names = []
|
||||
if city_to_cable_ids_map and city_id in city_to_cable_ids_map:
|
||||
for cable_id in city_to_cable_ids_map[city_id]:
|
||||
if cable_id_to_name_map and cable_id in cable_id_to_name_map:
|
||||
cable_names.append(cable_id_to_name_map[cable_id])
|
||||
|
||||
if cable_names:
|
||||
props["cable_names"] = cable_names
|
||||
|
||||
features.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {"type": "Point", "coordinates": [lon, lat]},
|
||||
"properties": props,
|
||||
}
|
||||
)
|
||||
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
|
||||
|
||||
def convert_satellite_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
|
||||
"""Convert satellite TLE records to GeoJSON"""
|
||||
features = []
|
||||
|
||||
for record in records:
|
||||
metadata = record.extra_data or {}
|
||||
norad_id = metadata.get("norad_cat_id")
|
||||
|
||||
if not norad_id:
|
||||
continue
|
||||
|
||||
features.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
"id": norad_id,
|
||||
"geometry": {"type": "Point", "coordinates": [0, 0, 0]},
|
||||
"properties": {
|
||||
"id": record.id,
|
||||
"source_id": record.source_id,
|
||||
"norad_cat_id": norad_id,
|
||||
"name": record.name,
|
||||
"country": record.country,
|
||||
"city": record.city,
|
||||
"is_tbd": metadata.get("is_tbd", False),
|
||||
"international_designator": metadata.get("international_designator"),
|
||||
"epoch": metadata.get("epoch"),
|
||||
"inclination": metadata.get("inclination"),
|
||||
"raan": metadata.get("raan"),
|
||||
"eccentricity": metadata.get("eccentricity"),
|
||||
"arg_of_perigee": metadata.get("arg_of_perigee"),
|
||||
"mean_anomaly": metadata.get("mean_anomaly"),
|
||||
"mean_motion": metadata.get("mean_motion"),
|
||||
"bstar": metadata.get("bstar"),
|
||||
"classification_type": metadata.get("classification_type"),
|
||||
"data_type": "satellite_tle",
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -121,6 +182,83 @@ def convert_landing_point_to_geojson(records: List[CollectedData]) -> Dict[str,
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
|
||||
|
||||
def convert_supercomputer_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
|
||||
"""Convert TOP500 supercomputer records to GeoJSON"""
|
||||
features = []
|
||||
|
||||
for record in records:
|
||||
try:
|
||||
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(longitude) if longitude and longitude != "0.0" else None
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
lat, lon = None, None
|
||||
|
||||
metadata = record.extra_data or {}
|
||||
|
||||
features.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
"id": record.id,
|
||||
"geometry": {"type": "Point", "coordinates": [lon or 0, lat or 0]},
|
||||
"properties": {
|
||||
"id": record.id,
|
||||
"name": record.name,
|
||||
"rank": metadata.get("rank"),
|
||||
"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",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
|
||||
|
||||
def convert_gpu_cluster_to_geojson(records: List[CollectedData]) -> Dict[str, Any]:
|
||||
"""Convert GPU cluster records to GeoJSON"""
|
||||
features = []
|
||||
|
||||
for record in records:
|
||||
try:
|
||||
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
|
||||
|
||||
metadata = record.extra_data or {}
|
||||
|
||||
features.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
"id": record.id,
|
||||
"geometry": {"type": "Point", "coordinates": [lon or 0, lat or 0]},
|
||||
"properties": {
|
||||
"id": record.id,
|
||||
"name": record.name,
|
||||
"country": get_record_field(record, "country"),
|
||||
"city": get_record_field(record, "city"),
|
||||
"metadata": metadata,
|
||||
"data_type": "gpu_cluster",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return {"type": "FeatureCollection", "features": features}
|
||||
|
||||
|
||||
# ============== API Endpoints ==============
|
||||
|
||||
|
||||
@router.get("/geo/cables")
|
||||
async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
|
||||
"""获取海底电缆 GeoJSON 数据 (LineString)"""
|
||||
@@ -144,11 +282,37 @@ async def get_cables_geojson(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
@router.get("/geo/landing-points")
|
||||
async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
|
||||
"""获取登陆点 GeoJSON 数据 (Point)"""
|
||||
try:
|
||||
stmt = select(CollectedData).where(CollectedData.source == "arcgis_landing_points")
|
||||
result = await db.execute(stmt)
|
||||
records = result.scalars().all()
|
||||
landing_stmt = select(CollectedData).where(CollectedData.source == "arcgis_landing_points")
|
||||
landing_result = await db.execute(landing_stmt)
|
||||
records = landing_result.scalars().all()
|
||||
|
||||
relation_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cable_landing_relation")
|
||||
relation_result = await db.execute(relation_stmt)
|
||||
relation_records = relation_result.scalars().all()
|
||||
|
||||
cable_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cables")
|
||||
cable_result = await db.execute(cable_stmt)
|
||||
cable_records = cable_result.scalars().all()
|
||||
|
||||
city_to_cable_ids_map = {}
|
||||
for rel in relation_records:
|
||||
if rel.extra_data:
|
||||
city_id = rel.extra_data.get("city_id")
|
||||
cable_id = rel.extra_data.get("cable_id")
|
||||
if city_id is not None and cable_id is not None:
|
||||
if city_id not in city_to_cable_ids_map:
|
||||
city_to_cable_ids_map[city_id] = []
|
||||
if cable_id not in city_to_cable_ids_map[city_id]:
|
||||
city_to_cable_ids_map[city_id].append(cable_id)
|
||||
|
||||
cable_id_to_name_map = {}
|
||||
for cable in cable_records:
|
||||
if cable.extra_data:
|
||||
cable_id = cable.extra_data.get("cable_id")
|
||||
cable_name = cable.name
|
||||
if cable_id and cable_name:
|
||||
cable_id_to_name_map[cable_id] = cable_name
|
||||
|
||||
if not records:
|
||||
raise HTTPException(
|
||||
@@ -156,7 +320,7 @@ async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
|
||||
detail="No landing point data found. Please run the arcgis_landing_points collector first.",
|
||||
)
|
||||
|
||||
return convert_landing_point_to_geojson(records)
|
||||
return convert_landing_point_to_geojson(records, city_to_cable_ids_map, cable_id_to_name_map)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -165,7 +329,6 @@ async def get_landing_points_geojson(db: AsyncSession = Depends(get_db)):
|
||||
|
||||
@router.get("/geo/all")
|
||||
async def get_all_geojson(db: AsyncSession = Depends(get_db)):
|
||||
"""获取所有可视化数据 (电缆 + 登陆点)"""
|
||||
cables_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cables")
|
||||
cables_result = await db.execute(cables_stmt)
|
||||
cables_records = cables_result.scalars().all()
|
||||
@@ -174,13 +337,36 @@ async def get_all_geojson(db: AsyncSession = Depends(get_db)):
|
||||
points_result = await db.execute(points_stmt)
|
||||
points_records = points_result.scalars().all()
|
||||
|
||||
relation_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cable_landing_relation")
|
||||
relation_result = await db.execute(relation_stmt)
|
||||
relation_records = relation_result.scalars().all()
|
||||
|
||||
city_to_cable_ids_map = {}
|
||||
for rel in relation_records:
|
||||
if rel.extra_data:
|
||||
city_id = rel.extra_data.get("city_id")
|
||||
cable_id = rel.extra_data.get("cable_id")
|
||||
if city_id is not None and cable_id is not None:
|
||||
if city_id not in city_to_cable_ids_map:
|
||||
city_to_cable_ids_map[city_id] = []
|
||||
if cable_id not in city_to_cable_ids_map[city_id]:
|
||||
city_to_cable_ids_map[city_id].append(cable_id)
|
||||
|
||||
cable_id_to_name_map = {}
|
||||
for cable in cables_records:
|
||||
if cable.extra_data:
|
||||
cable_id = cable.extra_data.get("cable_id")
|
||||
cable_name = cable.name
|
||||
if cable_id and cable_name:
|
||||
cable_id_to_name_map[cable_id] = cable_name
|
||||
|
||||
cables = (
|
||||
convert_cable_to_geojson(cables_records)
|
||||
if cables_records
|
||||
else {"type": "FeatureCollection", "features": []}
|
||||
)
|
||||
points = (
|
||||
convert_landing_point_to_geojson(points_records)
|
||||
convert_landing_point_to_geojson(points_records, city_to_cable_ids_map, cable_id_to_name_map)
|
||||
if points_records
|
||||
else {"type": "FeatureCollection", "features": []}
|
||||
)
|
||||
@@ -195,6 +381,178 @@ async def get_all_geojson(db: AsyncSession = Depends(get_db)):
|
||||
}
|
||||
|
||||
|
||||
@router.get("/geo/satellites")
|
||||
async def get_satellites_geojson(
|
||||
limit: int = 10000,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取卫星 TLE GeoJSON 数据"""
|
||||
stmt = (
|
||||
select(CollectedData)
|
||||
.where(CollectedData.source == "celestrak_tle")
|
||||
.where(CollectedData.name != "Unknown")
|
||||
.order_by(CollectedData.id.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records = result.scalars().all()
|
||||
|
||||
if not records:
|
||||
return {"type": "FeatureCollection", "features": [], "count": 0}
|
||||
|
||||
geojson = convert_satellite_to_geojson(list(records))
|
||||
return {
|
||||
**geojson,
|
||||
"count": len(geojson.get("features", [])),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/geo/supercomputers")
|
||||
async def get_supercomputers_geojson(
|
||||
limit: int = 500,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取 TOP500 超算中心 GeoJSON 数据"""
|
||||
stmt = (
|
||||
select(CollectedData)
|
||||
.where(CollectedData.source == "top500")
|
||||
.where(CollectedData.name != "Unknown")
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records = result.scalars().all()
|
||||
|
||||
if not records:
|
||||
return {"type": "FeatureCollection", "features": [], "count": 0}
|
||||
|
||||
geojson = convert_supercomputer_to_geojson(list(records))
|
||||
return {
|
||||
**geojson,
|
||||
"count": len(geojson.get("features", [])),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/geo/gpu-clusters")
|
||||
async def get_gpu_clusters_geojson(
|
||||
limit: int = 100,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取 GPU 集群 GeoJSON 数据"""
|
||||
stmt = (
|
||||
select(CollectedData)
|
||||
.where(CollectedData.source == "epoch_ai_gpu")
|
||||
.where(CollectedData.name != "Unknown")
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
records = result.scalars().all()
|
||||
|
||||
if not records:
|
||||
return {"type": "FeatureCollection", "features": [], "count": 0}
|
||||
|
||||
geojson = convert_gpu_cluster_to_geojson(list(records))
|
||||
return {
|
||||
**geojson,
|
||||
"count": len(geojson.get("features", [])),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/all")
|
||||
async def get_all_visualization_data(db: AsyncSession = Depends(get_db)):
|
||||
"""获取所有可视化数据的统一端点
|
||||
|
||||
Returns GeoJSON FeatureCollections for all data types:
|
||||
- satellites: 卫星 TLE 数据
|
||||
- cables: 海底电缆
|
||||
- landing_points: 登陆点
|
||||
- supercomputers: TOP500 超算
|
||||
- gpu_clusters: GPU 集群
|
||||
"""
|
||||
cables_stmt = select(CollectedData).where(CollectedData.source == "arcgis_cables")
|
||||
cables_result = await db.execute(cables_stmt)
|
||||
cables_records = list(cables_result.scalars().all())
|
||||
|
||||
points_stmt = select(CollectedData).where(CollectedData.source == "arcgis_landing_points")
|
||||
points_result = await db.execute(points_stmt)
|
||||
points_records = list(points_result.scalars().all())
|
||||
|
||||
satellites_stmt = (
|
||||
select(CollectedData)
|
||||
.where(CollectedData.source == "celestrak_tle")
|
||||
.where(CollectedData.name != "Unknown")
|
||||
)
|
||||
satellites_result = await db.execute(satellites_stmt)
|
||||
satellites_records = list(satellites_result.scalars().all())
|
||||
|
||||
supercomputers_stmt = (
|
||||
select(CollectedData)
|
||||
.where(CollectedData.source == "top500")
|
||||
.where(CollectedData.name != "Unknown")
|
||||
)
|
||||
supercomputers_result = await db.execute(supercomputers_stmt)
|
||||
supercomputers_records = list(supercomputers_result.scalars().all())
|
||||
|
||||
gpu_stmt = (
|
||||
select(CollectedData)
|
||||
.where(CollectedData.source == "epoch_ai_gpu")
|
||||
.where(CollectedData.name != "Unknown")
|
||||
)
|
||||
gpu_result = await db.execute(gpu_stmt)
|
||||
gpu_records = list(gpu_result.scalars().all())
|
||||
|
||||
cables = (
|
||||
convert_cable_to_geojson(cables_records)
|
||||
if cables_records
|
||||
else {"type": "FeatureCollection", "features": []}
|
||||
)
|
||||
landing_points = (
|
||||
convert_landing_point_to_geojson(points_records)
|
||||
if points_records
|
||||
else {"type": "FeatureCollection", "features": []}
|
||||
)
|
||||
satellites = (
|
||||
convert_satellite_to_geojson(satellites_records)
|
||||
if satellites_records
|
||||
else {"type": "FeatureCollection", "features": []}
|
||||
)
|
||||
supercomputers = (
|
||||
convert_supercomputer_to_geojson(supercomputers_records)
|
||||
if supercomputers_records
|
||||
else {"type": "FeatureCollection", "features": []}
|
||||
)
|
||||
gpu_clusters = (
|
||||
convert_gpu_cluster_to_geojson(gpu_records)
|
||||
if gpu_records
|
||||
else {"type": "FeatureCollection", "features": []}
|
||||
)
|
||||
|
||||
return {
|
||||
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||||
"version": "1.0",
|
||||
"data": {
|
||||
"satellites": satellites,
|
||||
"cables": cables,
|
||||
"landing_points": landing_points,
|
||||
"supercomputers": supercomputers,
|
||||
"gpu_clusters": gpu_clusters,
|
||||
},
|
||||
"stats": {
|
||||
"total_features": (
|
||||
len(satellites.get("features", []))
|
||||
+ len(cables.get("features", []))
|
||||
+ len(landing_points.get("features", []))
|
||||
+ len(supercomputers.get("features", []))
|
||||
+ len(gpu_clusters.get("features", []))
|
||||
),
|
||||
"satellites": len(satellites.get("features", [])),
|
||||
"cables": len(cables.get("features", [])),
|
||||
"landing_points": len(landing_points.get("features", [])),
|
||||
"supercomputers": len(supercomputers.get("features", [])),
|
||||
"gpu_clusters": len(gpu_clusters.get("features", [])),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Cache for cable graph
|
||||
_cable_graph: Optional[CableGraph] = None
|
||||
|
||||
|
||||
62
backend/app/core/collected_data_fields.py
Normal file
@@ -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)
|
||||
@@ -27,6 +27,9 @@ class Settings(BaseSettings):
|
||||
|
||||
CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8000"]
|
||||
|
||||
SPACETRACK_USERNAME: str = ""
|
||||
SPACETRACK_PASSWORD: str = ""
|
||||
|
||||
@property
|
||||
def REDIS_URL(self) -> str:
|
||||
return os.getenv(
|
||||
@@ -34,7 +37,7 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file = Path(__file__).parent.parent.parent / ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
|
||||
280
backend/app/core/countries.py
Normal file
@@ -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
|
||||
@@ -22,6 +22,7 @@ COLLECTOR_URL_KEYS = {
|
||||
"peeringdb_facility": "peeringdb.facility_url",
|
||||
"top500": "top500.url",
|
||||
"epoch_ai_gpu": "epoch_ai.gpu_clusters_url",
|
||||
"spacetrack_tle": "spacetrack.tle_query_url",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -33,3 +33,7 @@ top500:
|
||||
|
||||
epoch_ai:
|
||||
gpu_clusters_url: "https://epoch.ai/data/gpu-clusters"
|
||||
|
||||
spacetrack:
|
||||
base_url: "https://www.space-track.org"
|
||||
tle_query_url: "https://www.space-track.org/basicspacedata/query/class/gp/orderby/EPOCH%20desc/limit/1000/format/json"
|
||||
|
||||
126
backend/app/core/datasource_defaults.py
Normal file
@@ -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()}
|
||||
@@ -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
|
||||
|
||||
@@ -25,11 +26,102 @@ 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.data_snapshot # 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)
|
||||
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)
|
||||
|
||||
@@ -2,15 +2,19 @@ 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 (
|
||||
cleanup_stale_running_tasks,
|
||||
start_scheduler,
|
||||
stop_scheduler,
|
||||
sync_scheduler_with_datasources,
|
||||
)
|
||||
|
||||
|
||||
class WebSocketCORSMiddleware(BaseHTTPMiddleware):
|
||||
@@ -27,7 +31,9 @@ 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()
|
||||
yield
|
||||
broadcaster.stop()
|
||||
@@ -60,16 +66,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 +81,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()}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
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
|
||||
from app.models.system_setting import SystemSetting
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
"GPUCluster",
|
||||
"CollectionTask",
|
||||
"DataSnapshot",
|
||||
"DataSource",
|
||||
"DataSourceConfig",
|
||||
"SystemSetting",
|
||||
"Alert",
|
||||
"AlertSeverity",
|
||||
"AlertStatus",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
26
backend/app/models/data_snapshot.py
Normal file
@@ -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"<DataSnapshot {self.id}: {self.source}/{self.status}>"
|
||||
19
backend/app/models/system_setting.py
Normal file
@@ -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"<SystemSetting {self.category}>"
|
||||
@@ -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)
|
||||
|
||||
@@ -28,6 +28,8 @@ from app.services.collectors.arcgis_cables import ArcGISCableCollector
|
||||
from app.services.collectors.fao_landing import FAOLandingPointCollector
|
||||
from app.services.collectors.arcgis_landing import ArcGISLandingPointCollector
|
||||
from app.services.collectors.arcgis_relation import ArcGISCableLandingRelationCollector
|
||||
from app.services.collectors.spacetrack import SpaceTrackTLECollector
|
||||
from app.services.collectors.celestrak import CelesTrakTLECollector
|
||||
|
||||
collector_registry.register(TOP500Collector())
|
||||
collector_registry.register(EpochAIGPUCollector())
|
||||
@@ -47,3 +49,5 @@ collector_registry.register(ArcGISCableCollector())
|
||||
collector_registry.register(FAOLandingPointCollector())
|
||||
collector_registry.register(ArcGISLandingPointCollector())
|
||||
collector_registry.register(ArcGISCableLandingRelationCollector())
|
||||
collector_registry.register(SpaceTrackTLECollector())
|
||||
collector_registry.register(CelesTrakTLECollector())
|
||||
|
||||
@@ -39,6 +39,11 @@ class ArcGISLandingPointCollector(BaseCollector):
|
||||
props = feature.get("properties", {})
|
||||
geometry = feature.get("geometry", {})
|
||||
|
||||
if geometry.get("type") == "Point":
|
||||
coords = geometry.get("coordinates", [])
|
||||
lon = coords[0] if len(coords) > 0 else None
|
||||
lat = coords[1] if len(coords) > 1 else None
|
||||
else:
|
||||
lat = geometry.get("y") if geometry else None
|
||||
lon = geometry.get("x") if geometry else None
|
||||
|
||||
@@ -54,6 +59,7 @@ class ArcGISLandingPointCollector(BaseCollector):
|
||||
"unit": "",
|
||||
"metadata": {
|
||||
"objectid": props.get("OBJECTID"),
|
||||
"city_id": props.get("city_id"),
|
||||
"cable_id": props.get("cable_id"),
|
||||
"cable_name": props.get("cable_name"),
|
||||
"facility": props.get("facility"),
|
||||
|
||||
@@ -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,44 +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")
|
||||
|
||||
async def fetch(self) -> List[Dict[str, Any]]:
|
||||
params = {"where": "1=1", "outFields": "*", "returnGeometry": "true", "f": "geojson"}
|
||||
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 with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.get(self.base_url, params=params)
|
||||
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()
|
||||
return self.parse_response(response.json())
|
||||
data = response.json()
|
||||
return [feature.get("attributes", {}) for feature in data.get("features", [])]
|
||||
|
||||
def parse_response(self, data: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
result = []
|
||||
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", [])
|
||||
|
||||
features = data.get("features", [])
|
||||
for feature in features:
|
||||
async def fetch(self) -> List[Dict[str, Any]]:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
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 _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
|
||||
|
||||
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"),
|
||||
"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"),
|
||||
}
|
||||
|
||||
@@ -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,50 +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
|
||||
|
||||
99
backend/app/services/collectors/celestrak.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""CelesTrak TLE Collector
|
||||
|
||||
Collects satellite TLE (Two-Line Element) data from CelesTrak.org.
|
||||
Free, no authentication required.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Dict, Any, List
|
||||
import httpx
|
||||
|
||||
from app.services.collectors.base import BaseCollector
|
||||
|
||||
|
||||
class CelesTrakTLECollector(BaseCollector):
|
||||
name = "celestrak_tle"
|
||||
priority = "P2"
|
||||
module = "L3"
|
||||
frequency_hours = 24
|
||||
data_type = "satellite_tle"
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return "https://celestrak.org/NORAD/elements/gp.php"
|
||||
|
||||
async def fetch(self) -> List[Dict[str, Any]]:
|
||||
satellite_groups = [
|
||||
"starlink",
|
||||
"gps-ops",
|
||||
"galileo",
|
||||
"glonass",
|
||||
"beidou",
|
||||
"leo",
|
||||
"geo",
|
||||
"iridium-next",
|
||||
]
|
||||
|
||||
all_satellites = []
|
||||
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
for group in satellite_groups:
|
||||
try:
|
||||
url = f"https://celestrak.org/NORAD/elements/gp.php?GROUP={group}&FORMAT=json"
|
||||
response = await client.get(url)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if isinstance(data, list):
|
||||
all_satellites.extend(data)
|
||||
print(f"CelesTrak: Fetched {len(data)} satellites from group '{group}'")
|
||||
except Exception as e:
|
||||
print(f"CelesTrak: Error fetching group '{group}': {e}")
|
||||
|
||||
if not all_satellites:
|
||||
return self._get_sample_data()
|
||||
|
||||
print(f"CelesTrak: Total satellites fetched: {len(all_satellites)}")
|
||||
|
||||
# Return raw data - base.run() will call transform()
|
||||
return all_satellites
|
||||
|
||||
def transform(self, raw_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
transformed = []
|
||||
for item in raw_data:
|
||||
transformed.append(
|
||||
{
|
||||
"name": item.get("OBJECT_NAME", "Unknown"),
|
||||
"reference_date": item.get("EPOCH", ""),
|
||||
"metadata": {
|
||||
"norad_cat_id": item.get("NORAD_CAT_ID"),
|
||||
"international_designator": item.get("OBJECT_ID"),
|
||||
"epoch": item.get("EPOCH"),
|
||||
"mean_motion": item.get("MEAN_MOTION"),
|
||||
"eccentricity": item.get("ECCENTRICITY"),
|
||||
"inclination": item.get("INCLINATION"),
|
||||
"raan": item.get("RA_OF_ASC_NODE"),
|
||||
"arg_of_perigee": item.get("ARG_OF_PERICENTER"),
|
||||
"mean_anomaly": item.get("MEAN_ANOMALY"),
|
||||
"classification_type": item.get("CLASSIFICATION_TYPE"),
|
||||
"bstar": item.get("BSTAR"),
|
||||
"mean_motion_dot": item.get("MEAN_MOTION_DOT"),
|
||||
"mean_motion_ddot": item.get("MEAN_MOTION_DDOT"),
|
||||
"ephemeris_type": item.get("EPHEMERIS_TYPE"),
|
||||
},
|
||||
}
|
||||
)
|
||||
return transformed
|
||||
|
||||
def _get_sample_data(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "STARLINK-1000",
|
||||
"norad_cat_id": 44720,
|
||||
"international_designator": "2019-029AZ",
|
||||
"epoch": "2026-03-13T00:00:00Z",
|
||||
"mean_motion": 15.79234567,
|
||||
"eccentricity": 0.0001234,
|
||||
"inclination": 53.0,
|
||||
},
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
222
backend/app/services/collectors/spacetrack.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""Space-Track TLE Collector
|
||||
|
||||
Collects satellite TLE (Two-Line Element) data from Space-Track.org.
|
||||
API documentation: https://www.space-track.org/documentation
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Dict, Any, List
|
||||
import httpx
|
||||
|
||||
from app.services.collectors.base import BaseCollector
|
||||
from app.core.data_sources import get_data_sources_config
|
||||
|
||||
|
||||
class SpaceTrackTLECollector(BaseCollector):
|
||||
name = "spacetrack_tle"
|
||||
priority = "P2"
|
||||
module = "L3"
|
||||
frequency_hours = 24
|
||||
data_type = "satellite_tle"
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
config = get_data_sources_config()
|
||||
if self._resolved_url:
|
||||
return self._resolved_url
|
||||
return config.get_yaml_url("spacetrack_tle")
|
||||
|
||||
async def fetch(self) -> List[Dict[str, Any]]:
|
||||
from app.core.config import settings
|
||||
|
||||
username = settings.SPACETRACK_USERNAME
|
||||
password = settings.SPACETRACK_PASSWORD
|
||||
|
||||
if not username or not password:
|
||||
print("SPACETRACK: No credentials configured, using sample data")
|
||||
return self._get_sample_data()
|
||||
|
||||
print(f"SPACETRACK: Attempting to fetch TLE data with username: {username}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=120.0,
|
||||
follow_redirects=True,
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept": "application/json, text/html, */*",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Referer": "https://www.space-track.org/",
|
||||
},
|
||||
) as client:
|
||||
await client.get("https://www.space-track.org/")
|
||||
|
||||
login_response = await client.post(
|
||||
"https://www.space-track.org/ajaxauth/login",
|
||||
data={
|
||||
"identity": username,
|
||||
"password": password,
|
||||
},
|
||||
)
|
||||
print(f"SPACETRACK: Login response status: {login_response.status_code}")
|
||||
print(f"SPACETRACK: Login response URL: {login_response.url}")
|
||||
|
||||
if login_response.status_code == 403:
|
||||
print("SPACETRACK: Trying alternate login method...")
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=120.0,
|
||||
follow_redirects=True,
|
||||
) as alt_client:
|
||||
await alt_client.get("https://www.space-track.org/")
|
||||
|
||||
form_data = {
|
||||
"username": username,
|
||||
"password": password,
|
||||
"query": "class/gp/NORAD_CAT_ID/25544/format/json",
|
||||
}
|
||||
alt_login = await alt_client.post(
|
||||
"https://www.space-track.org/ajaxauth/login",
|
||||
data={
|
||||
"identity": username,
|
||||
"password": password,
|
||||
},
|
||||
)
|
||||
print(f"SPACETRACK: Alt login status: {alt_login.status_code}")
|
||||
|
||||
if alt_login.status_code == 200:
|
||||
tle_response = await alt_client.get(
|
||||
"https://www.space-track.org/basicspacedata/query/class/gp/NORAD_CAT_ID/25544/format/json"
|
||||
)
|
||||
if tle_response.status_code == 200:
|
||||
data = tle_response.json()
|
||||
print(f"SPACETRACK: Received {len(data)} records via alt method")
|
||||
return data
|
||||
|
||||
if login_response.status_code != 200:
|
||||
print(f"SPACETRACK: Login failed, using sample data")
|
||||
return self._get_sample_data()
|
||||
|
||||
tle_response = await client.get(
|
||||
"https://www.space-track.org/basicspacedata/query/class/gp/NORAD_CAT_ID/25544/format/json"
|
||||
)
|
||||
print(f"SPACETRACK: TLE query status: {tle_response.status_code}")
|
||||
|
||||
if tle_response.status_code != 200:
|
||||
print(f"SPACETRACK: Query failed, using sample data")
|
||||
return self._get_sample_data()
|
||||
|
||||
data = tle_response.json()
|
||||
print(f"SPACETRACK: Received {len(data)} records")
|
||||
return data
|
||||
except Exception as e:
|
||||
print(f"SPACETRACK: Error - {e}, using sample data")
|
||||
return self._get_sample_data()
|
||||
|
||||
print(f"SPACETRACK: Attempting to fetch TLE data with username: {username}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=120.0,
|
||||
follow_redirects=True,
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"Accept": "application/json, text/html, */*",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
) as client:
|
||||
# First, visit the main page to get any cookies
|
||||
await client.get("https://www.space-track.org/")
|
||||
|
||||
# Login to get session cookie
|
||||
login_response = await client.post(
|
||||
"https://www.space-track.org/ajaxauth/login",
|
||||
data={
|
||||
"identity": username,
|
||||
"password": password,
|
||||
},
|
||||
)
|
||||
print(f"SPACETRACK: Login response status: {login_response.status_code}")
|
||||
print(f"SPACETRACK: Login response URL: {login_response.url}")
|
||||
print(f"SPACETRACK: Login response body: {login_response.text[:500]}")
|
||||
|
||||
if login_response.status_code != 200:
|
||||
print(f"SPACETRACK: Login failed, using sample data")
|
||||
return self._get_sample_data()
|
||||
|
||||
# Query for TLE data (get first 1000 satellites)
|
||||
tle_response = await client.get(
|
||||
"https://www.space-track.org/basicspacedata/query"
|
||||
"/class/gp"
|
||||
"/orderby/EPOCH%20desc"
|
||||
"/limit/1000"
|
||||
"/format/json"
|
||||
)
|
||||
print(f"SPACETRACK: TLE query status: {tle_response.status_code}")
|
||||
|
||||
if tle_response.status_code != 200:
|
||||
print(f"SPACETRACK: Query failed, using sample data")
|
||||
return self._get_sample_data()
|
||||
|
||||
data = tle_response.json()
|
||||
print(f"SPACETRACK: Received {len(data)} records")
|
||||
return data
|
||||
except Exception as e:
|
||||
print(f"SPACETRACK: Error - {e}, using sample data")
|
||||
return self._get_sample_data()
|
||||
|
||||
def transform(self, raw_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Transform TLE data to internal format"""
|
||||
transformed = []
|
||||
for item in raw_data:
|
||||
transformed.append(
|
||||
{
|
||||
"name": item.get("OBJECT_NAME", "Unknown"),
|
||||
"norad_cat_id": item.get("NORAD_CAT_ID"),
|
||||
"international_designator": item.get("INTL_DESIGNATOR"),
|
||||
"epoch": item.get("EPOCH"),
|
||||
"mean_motion": item.get("MEAN_MOTION"),
|
||||
"eccentricity": item.get("ECCENTRICITY"),
|
||||
"inclination": item.get("INCLINATION"),
|
||||
"raan": item.get("RAAN"),
|
||||
"arg_of_perigee": item.get("ARG_OF_PERIGEE"),
|
||||
"mean_anomaly": item.get("MEAN_ANOMALY"),
|
||||
"ephemeris_type": item.get("EPHEMERIS_TYPE"),
|
||||
"classification_type": item.get("CLASSIFICATION_TYPE"),
|
||||
"element_set_no": item.get("ELEMENT_SET_NO"),
|
||||
"rev_at_epoch": item.get("REV_AT_EPOCH"),
|
||||
"bstar": item.get("BSTAR"),
|
||||
"mean_motion_dot": item.get("MEAN_MOTION_DOT"),
|
||||
"mean_motion_ddot": item.get("MEAN_MOTION_DDOT"),
|
||||
}
|
||||
)
|
||||
return transformed
|
||||
|
||||
def _get_sample_data(self) -> List[Dict[str, Any]]:
|
||||
"""Return sample TLE data for testing"""
|
||||
return [
|
||||
{
|
||||
"name": "ISS (ZARYA)",
|
||||
"norad_cat_id": 25544,
|
||||
"international_designator": "1998-067A",
|
||||
"epoch": "2026-03-13T00:00:00Z",
|
||||
"mean_motion": 15.49872723,
|
||||
"eccentricity": 0.0006292,
|
||||
"inclination": 51.6400,
|
||||
"raan": 315.0000,
|
||||
"arg_of_perigee": 100.0000,
|
||||
"mean_anomaly": 260.0000,
|
||||
},
|
||||
{
|
||||
"name": "STARLINK-1000",
|
||||
"norad_cat_id": 44720,
|
||||
"international_designator": "2019-029AZ",
|
||||
"epoch": "2026-03-13T00:00:00Z",
|
||||
"mean_motion": 15.79234567,
|
||||
"eccentricity": 0.0001234,
|
||||
"inclination": 53.0000,
|
||||
"raan": 120.0000,
|
||||
"arg_of_perigee": 90.0000,
|
||||
"mean_anomaly": 270.0000,
|
||||
},
|
||||
]
|
||||
@@ -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"})
|
||||
|
||||
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
|
||||
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:
|
||||
# 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",
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"""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 datetime import datetime, timedelta
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
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.models.task import CollectionTask
|
||||
from app.services.collectors.registry import collector_registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -17,75 +19,148 @@ 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,
|
||||
}
|
||||
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:
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
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}")
|
||||
|
||||
|
||||
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},
|
||||
result = await db.execute(
|
||||
select(CollectionTask).where(
|
||||
CollectionTask.status == "running",
|
||||
CollectionTask.started_at.is_not(None),
|
||||
CollectionTask.started_at < cutoff,
|
||||
)
|
||||
logger.info(f"Scheduled collector: {name} (every {collector.frequency_hours}h)")
|
||||
)
|
||||
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:
|
||||
scheduler.start()
|
||||
logger.info("Scheduler started")
|
||||
|
||||
|
||||
def stop_scheduler():
|
||||
"""Stop the scheduler"""
|
||||
scheduler.shutdown()
|
||||
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(
|
||||
@@ -99,52 +174,30 @@ 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}")
|
||||
async def get_latest_task_id_for_datasource(datasource_id: int) -> Optional[int]:
|
||||
from app.models.task import CollectionTask
|
||||
|
||||
scheduler.add_job(
|
||||
run_collector_task,
|
||||
trigger=IntervalTrigger(hours=hours),
|
||||
id=collector_name,
|
||||
name=collector_name,
|
||||
replace_existing=True,
|
||||
kwargs={"collector_name": collector_name},
|
||||
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)
|
||||
)
|
||||
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}")
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
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}")
|
||||
except Exception as exc:
|
||||
logger.error("Failed to trigger collector %s: %s", collector_name, exc)
|
||||
return False
|
||||
|
||||
@@ -16,3 +16,4 @@ email-validator
|
||||
apscheduler>=3.10.4
|
||||
pytest>=7.4.0
|
||||
pytest-asyncio>=0.23.0
|
||||
networkx>=3.0
|
||||
|
||||
@@ -31,45 +31,6 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: planet_backend
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql+asyncpg://postgres:postgres@postgres:5432/planet_db
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- SECRET_KEY=your-secret-key-change-in-production
|
||||
- CORS_ORIGINS=["http://localhost:3000","http://0.0.0.0:3000","http://frontend:3000"]
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: planet_frontend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- VITE_API_URL=http://backend:8000/api/v1
|
||||
- VITE_WS_URL=ws://backend:8000/ws
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
stdin_open: true
|
||||
tty: true
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
|
||||
207
docs/collected-data-column-removal-plan.md
Normal file
@@ -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`
|
||||
402
docs/collected-data-history-plan.md
Normal file
@@ -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 语义
|
||||
|
||||
这样既能保留历史,又不会把当前页面全部推翻重做,是最适合后续做态势感知的一条路径。
|
||||
48
docs/system-settings-plan.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# 系统配置中心开发计划
|
||||
|
||||
## 目标
|
||||
|
||||
将当前仅保存于内存中的“系统配置”页面升级为真正可用的配置中心,优先服务以下两类能力:
|
||||
|
||||
1. 系统级配置持久化
|
||||
2. 采集调度配置管理
|
||||
|
||||
## 第一阶段范围
|
||||
|
||||
### 1. 系统配置持久化
|
||||
|
||||
- 新增 `system_settings` 表,用于保存分类配置
|
||||
- 将系统、通知、安全配置从进程内存迁移到数据库
|
||||
- 提供统一读取接口,页面刷新和服务重启后保持不丢失
|
||||
|
||||
### 2. 采集调度配置接入真实数据源
|
||||
|
||||
- 统一内置采集器默认定义
|
||||
- 启动时自动初始化 `data_sources` 表
|
||||
- 配置页允许修改:
|
||||
- 是否启用
|
||||
- 采集频率(分钟)
|
||||
- 优先级
|
||||
- 修改后实时同步到调度器
|
||||
|
||||
### 3. 前端配置页重构
|
||||
|
||||
- 将当前通用模板页调整为项目专用配置中心
|
||||
- 增加“采集调度”Tab
|
||||
- 保留“系统显示 / 通知 / 安全”三类配置
|
||||
- 将设置页正式接入主路由
|
||||
|
||||
## 非本阶段内容
|
||||
|
||||
- 邮件发送能力本身
|
||||
- 配置审计历史
|
||||
- 敏感凭证加密管理
|
||||
- 多租户或按角色细粒度配置
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 设置项修改后重启服务仍然存在
|
||||
- 配置页可以查看并修改所有内置采集器的启停与采集频率
|
||||
- 调整采集频率后,调度器任务随之更新
|
||||
- `/settings` 页面可从主导航进入并正常工作
|
||||
|
||||
477
frontend/bun.lock
Normal file
@@ -0,0 +1,477 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "planet-frontend",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"antd": "^5.12.5",
|
||||
"axios": "^1.6.2",
|
||||
"dayjs": "^1.11.10",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-resizable": "^3.1.3",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"simplex-noise": "^4.0.1",
|
||||
"socket.io-client": "^4.7.2",
|
||||
"three": "^0.160.0",
|
||||
"zustand": "^4.4.7",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.45",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.10",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@ant-design/colors": ["@ant-design/colors@7.2.1", "", { "dependencies": { "@ant-design/fast-color": "^2.0.6" } }, "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ=="],
|
||||
|
||||
"@ant-design/cssinjs": ["@ant-design/cssinjs@1.24.0", "", { "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", "classnames": "^2.3.1", "csstype": "^3.1.3", "rc-util": "^5.35.0", "stylis": "^4.3.4" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg=="],
|
||||
|
||||
"@ant-design/cssinjs-utils": ["@ant-design/cssinjs-utils@1.1.3", "", { "dependencies": { "@ant-design/cssinjs": "^1.21.0", "@babel/runtime": "^7.23.2", "rc-util": "^5.38.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg=="],
|
||||
|
||||
"@ant-design/fast-color": ["@ant-design/fast-color@2.0.6", "", { "dependencies": { "@babel/runtime": "^7.24.7" } }, "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA=="],
|
||||
|
||||
"@ant-design/icons": ["@ant-design/icons@5.6.1", "", { "dependencies": { "@ant-design/colors": "^7.0.0", "@ant-design/icons-svg": "^4.4.0", "@babel/runtime": "^7.24.8", "classnames": "^2.2.6", "rc-util": "^5.31.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg=="],
|
||||
|
||||
"@ant-design/icons-svg": ["@ant-design/icons-svg@4.4.2", "", {}, "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA=="],
|
||||
|
||||
"@ant-design/react-slick": ["@ant-design/react-slick@1.1.2", "", { "dependencies": { "@babel/runtime": "^7.10.4", "classnames": "^2.2.5", "json2mq": "^0.2.0", "resize-observer-polyfill": "^1.5.1", "throttle-debounce": "^5.0.0" }, "peerDependencies": { "react": ">=16.9.0" } }, "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.29.0", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@emotion/hash": ["@emotion/hash@0.8.0", "", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="],
|
||||
|
||||
"@emotion/unitless": ["@emotion/unitless@0.7.5", "", {}, "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@rc-component/async-validator": ["@rc-component/async-validator@5.1.0", "", { "dependencies": { "@babel/runtime": "^7.24.4" } }, "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA=="],
|
||||
|
||||
"@rc-component/color-picker": ["@rc-component/color-picker@2.0.1", "", { "dependencies": { "@ant-design/fast-color": "^2.0.6", "@babel/runtime": "^7.23.6", "classnames": "^2.2.6", "rc-util": "^5.38.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q=="],
|
||||
|
||||
"@rc-component/context": ["@rc-component/context@1.4.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w=="],
|
||||
|
||||
"@rc-component/mini-decimal": ["@rc-component/mini-decimal@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.18.0" } }, "sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ=="],
|
||||
|
||||
"@rc-component/mutate-observer": ["@rc-component/mutate-observer@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.18.0", "classnames": "^2.3.2", "rc-util": "^5.24.4" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw=="],
|
||||
|
||||
"@rc-component/portal": ["@rc-component/portal@1.1.2", "", { "dependencies": { "@babel/runtime": "^7.18.0", "classnames": "^2.3.2", "rc-util": "^5.24.4" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg=="],
|
||||
|
||||
"@rc-component/qrcode": ["@rc-component/qrcode@1.1.1", "", { "dependencies": { "@babel/runtime": "^7.24.7" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA=="],
|
||||
|
||||
"@rc-component/tour": ["@rc-component/tour@1.15.1", "", { "dependencies": { "@babel/runtime": "^7.18.0", "@rc-component/portal": "^1.0.0-9", "@rc-component/trigger": "^2.0.0", "classnames": "^2.3.2", "rc-util": "^5.24.4" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ=="],
|
||||
|
||||
"@rc-component/trigger": ["@rc-component/trigger@2.3.1", "", { "dependencies": { "@babel/runtime": "^7.23.2", "@rc-component/portal": "^1.1.0", "classnames": "^2.3.2", "rc-motion": "^2.0.0", "rc-resize-observer": "^1.3.1", "rc-util": "^5.44.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A=="],
|
||||
|
||||
"@remix-run/router": ["@remix-run/router@1.23.2", "", {}, "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],
|
||||
|
||||
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
|
||||
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
|
||||
|
||||
"@types/react": ["@types/react@18.3.27", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
|
||||
"antd": ["antd@5.29.3", "", { "dependencies": { "@ant-design/colors": "^7.2.1", "@ant-design/cssinjs": "^1.23.0", "@ant-design/cssinjs-utils": "^1.1.3", "@ant-design/fast-color": "^2.0.6", "@ant-design/icons": "^5.6.1", "@ant-design/react-slick": "~1.1.2", "@babel/runtime": "^7.26.0", "@rc-component/color-picker": "~2.0.1", "@rc-component/mutate-observer": "^1.1.0", "@rc-component/qrcode": "~1.1.0", "@rc-component/tour": "~1.15.1", "@rc-component/trigger": "^2.3.0", "classnames": "^2.5.1", "copy-to-clipboard": "^3.3.3", "dayjs": "^1.11.11", "rc-cascader": "~3.34.0", "rc-checkbox": "~3.5.0", "rc-collapse": "~3.9.0", "rc-dialog": "~9.6.0", "rc-drawer": "~7.3.0", "rc-dropdown": "~4.2.1", "rc-field-form": "~2.7.1", "rc-image": "~7.12.0", "rc-input": "~1.8.0", "rc-input-number": "~9.5.0", "rc-mentions": "~2.20.0", "rc-menu": "~9.16.1", "rc-motion": "^2.9.5", "rc-notification": "~5.6.4", "rc-pagination": "~5.1.0", "rc-picker": "~4.11.3", "rc-progress": "~4.0.0", "rc-rate": "~2.13.1", "rc-resize-observer": "^1.4.3", "rc-segmented": "~2.7.0", "rc-select": "~14.16.8", "rc-slider": "~11.1.9", "rc-steps": "~6.0.1", "rc-switch": "~4.1.0", "rc-table": "~7.54.0", "rc-tabs": "~15.7.0", "rc-textarea": "~1.10.2", "rc-tooltip": "~6.4.0", "rc-tree": "~5.13.1", "rc-tree-select": "~5.27.0", "rc-upload": "~4.11.0", "rc-util": "^5.44.4", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"axios": ["axios@1.13.4", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": "dist/cli.js" }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": "cli.js" }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001767", "", {}, "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ=="],
|
||||
|
||||
"classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"copy-to-clipboard": ["copy-to-clipboard@3.3.3", "", { "dependencies": { "toggle-selection": "^1.0.6" } }, "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="],
|
||||
|
||||
"engine.io-client": ["engine.io-client@6.6.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.18.3", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw=="],
|
||||
|
||||
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": "bin/esbuild" }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
|
||||
|
||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": "bin/jsesc" }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json2mq": ["json2mq@0.2.0", "", { "dependencies": { "string-convert": "^0.2.0" } }, "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"rc-cascader": ["rc-cascader@3.34.0", "", { "dependencies": { "@babel/runtime": "^7.25.7", "classnames": "^2.3.1", "rc-select": "~14.16.2", "rc-tree": "~5.13.0", "rc-util": "^5.43.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag=="],
|
||||
|
||||
"rc-checkbox": ["rc-checkbox@3.5.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.3.2", "rc-util": "^5.25.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg=="],
|
||||
|
||||
"rc-collapse": ["rc-collapse@3.9.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA=="],
|
||||
|
||||
"rc-dialog": ["rc-dialog@9.6.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/portal": "^1.0.0-8", "classnames": "^2.2.6", "rc-motion": "^2.3.0", "rc-util": "^5.21.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg=="],
|
||||
|
||||
"rc-drawer": ["rc-drawer@7.3.0", "", { "dependencies": { "@babel/runtime": "^7.23.9", "@rc-component/portal": "^1.1.1", "classnames": "^2.2.6", "rc-motion": "^2.6.1", "rc-util": "^5.38.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg=="],
|
||||
|
||||
"rc-dropdown": ["rc-dropdown@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "@rc-component/trigger": "^2.0.0", "classnames": "^2.2.6", "rc-util": "^5.44.1" }, "peerDependencies": { "react": ">=16.11.0", "react-dom": ">=16.11.0" } }, "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA=="],
|
||||
|
||||
"rc-field-form": ["rc-field-form@2.7.1", "", { "dependencies": { "@babel/runtime": "^7.18.0", "@rc-component/async-validator": "^5.0.3", "rc-util": "^5.32.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A=="],
|
||||
|
||||
"rc-image": ["rc-image@7.12.0", "", { "dependencies": { "@babel/runtime": "^7.11.2", "@rc-component/portal": "^1.0.2", "classnames": "^2.2.6", "rc-dialog": "~9.6.0", "rc-motion": "^2.6.2", "rc-util": "^5.34.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q=="],
|
||||
|
||||
"rc-input": ["rc-input@1.8.0", "", { "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1", "rc-util": "^5.18.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA=="],
|
||||
|
||||
"rc-input-number": ["rc-input-number@9.5.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/mini-decimal": "^1.0.1", "classnames": "^2.2.5", "rc-input": "~1.8.0", "rc-util": "^5.40.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag=="],
|
||||
|
||||
"rc-mentions": ["rc-mentions@2.20.0", "", { "dependencies": { "@babel/runtime": "^7.22.5", "@rc-component/trigger": "^2.0.0", "classnames": "^2.2.6", "rc-input": "~1.8.0", "rc-menu": "~9.16.0", "rc-textarea": "~1.10.0", "rc-util": "^5.34.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ=="],
|
||||
|
||||
"rc-menu": ["rc-menu@9.16.1", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/trigger": "^2.0.0", "classnames": "2.x", "rc-motion": "^2.4.3", "rc-overflow": "^1.3.1", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg=="],
|
||||
|
||||
"rc-motion": ["rc-motion@2.9.5", "", { "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1", "rc-util": "^5.44.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA=="],
|
||||
|
||||
"rc-notification": ["rc-notification@5.6.4", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.9.0", "rc-util": "^5.20.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw=="],
|
||||
|
||||
"rc-overflow": ["rc-overflow@1.5.0", "", { "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1", "rc-resize-observer": "^1.0.0", "rc-util": "^5.37.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg=="],
|
||||
|
||||
"rc-pagination": ["rc-pagination@5.1.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.3.2", "rc-util": "^5.38.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ=="],
|
||||
|
||||
"rc-picker": ["rc-picker@4.11.3", "", { "dependencies": { "@babel/runtime": "^7.24.7", "@rc-component/trigger": "^2.0.0", "classnames": "^2.2.1", "rc-overflow": "^1.3.2", "rc-resize-observer": "^1.4.0", "rc-util": "^5.43.0" }, "peerDependencies": { "date-fns": ">= 2.x", "dayjs": ">= 1.x", "luxon": ">= 3.x", "moment": ">= 2.x", "react": ">=16.9.0", "react-dom": ">=16.9.0" }, "optionalPeers": ["date-fns", "luxon", "moment"] }, "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg=="],
|
||||
|
||||
"rc-progress": ["rc-progress@4.0.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.2.6", "rc-util": "^5.16.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw=="],
|
||||
|
||||
"rc-rate": ["rc-rate@2.13.1", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.2.5", "rc-util": "^5.0.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q=="],
|
||||
|
||||
"rc-resize-observer": ["rc-resize-observer@1.4.3", "", { "dependencies": { "@babel/runtime": "^7.20.7", "classnames": "^2.2.1", "rc-util": "^5.44.1", "resize-observer-polyfill": "^1.5.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ=="],
|
||||
|
||||
"rc-segmented": ["rc-segmented@2.7.1", "", { "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1", "rc-motion": "^2.4.4", "rc-util": "^5.17.0" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g=="],
|
||||
|
||||
"rc-select": ["rc-select@14.16.8", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/trigger": "^2.1.1", "classnames": "2.x", "rc-motion": "^2.0.1", "rc-overflow": "^1.3.1", "rc-util": "^5.16.1", "rc-virtual-list": "^3.5.2" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg=="],
|
||||
|
||||
"rc-slider": ["rc-slider@11.1.9", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.2.5", "rc-util": "^5.36.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A=="],
|
||||
|
||||
"rc-steps": ["rc-steps@6.0.1", "", { "dependencies": { "@babel/runtime": "^7.16.7", "classnames": "^2.2.3", "rc-util": "^5.16.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g=="],
|
||||
|
||||
"rc-switch": ["rc-switch@4.1.0", "", { "dependencies": { "@babel/runtime": "^7.21.0", "classnames": "^2.2.1", "rc-util": "^5.30.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg=="],
|
||||
|
||||
"rc-table": ["rc-table@7.54.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/context": "^1.4.0", "classnames": "^2.2.5", "rc-resize-observer": "^1.1.0", "rc-util": "^5.44.3", "rc-virtual-list": "^3.14.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw=="],
|
||||
|
||||
"rc-tabs": ["rc-tabs@15.7.0", "", { "dependencies": { "@babel/runtime": "^7.11.2", "classnames": "2.x", "rc-dropdown": "~4.2.0", "rc-menu": "~9.16.0", "rc-motion": "^2.6.2", "rc-resize-observer": "^1.0.0", "rc-util": "^5.34.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA=="],
|
||||
|
||||
"rc-textarea": ["rc-textarea@1.10.2", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.2.1", "rc-input": "~1.8.0", "rc-resize-observer": "^1.0.0", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ=="],
|
||||
|
||||
"rc-tooltip": ["rc-tooltip@6.4.0", "", { "dependencies": { "@babel/runtime": "^7.11.2", "@rc-component/trigger": "^2.0.0", "classnames": "^2.3.1", "rc-util": "^5.44.3" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g=="],
|
||||
|
||||
"rc-tree": ["rc-tree@5.13.1", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.0.1", "rc-util": "^5.16.1", "rc-virtual-list": "^3.5.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A=="],
|
||||
|
||||
"rc-tree-select": ["rc-tree-select@5.27.0", "", { "dependencies": { "@babel/runtime": "^7.25.7", "classnames": "2.x", "rc-select": "~14.16.2", "rc-tree": "~5.13.0", "rc-util": "^5.43.0" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww=="],
|
||||
|
||||
"rc-upload": ["rc-upload@4.11.0", "", { "dependencies": { "@babel/runtime": "^7.18.3", "classnames": "^2.2.5", "rc-util": "^5.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA=="],
|
||||
|
||||
"rc-util": ["rc-util@5.44.4", "", { "dependencies": { "@babel/runtime": "^7.18.3", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w=="],
|
||||
|
||||
"rc-virtual-list": ["rc-virtual-list@3.19.2", "", { "dependencies": { "@babel/runtime": "^7.20.0", "classnames": "^2.2.6", "rc-resize-observer": "^1.0.0", "rc-util": "^5.36.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA=="],
|
||||
|
||||
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
||||
|
||||
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
|
||||
|
||||
"react-draggable": ["react-draggable@4.5.0", "", { "dependencies": { "clsx": "^2.1.1", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.3.0", "react-dom": ">= 16.3.0" } }, "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw=="],
|
||||
|
||||
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
|
||||
"react-resizable": ["react-resizable@3.1.3", "", { "dependencies": { "prop-types": "15.x", "react-draggable": "^4.5.0" }, "peerDependencies": { "react": ">= 16.3", "react-dom": ">= 16.3" } }, "sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw=="],
|
||||
|
||||
"react-router": ["react-router@6.30.3", "", { "dependencies": { "@remix-run/router": "1.23.2" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw=="],
|
||||
|
||||
"react-router-dom": ["react-router-dom@6.30.3", "", { "dependencies": { "@remix-run/router": "1.23.2", "react-router": "6.30.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag=="],
|
||||
|
||||
"resize-observer-polyfill": ["resize-observer-polyfill@1.5.1", "", {}, "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="],
|
||||
|
||||
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
|
||||
|
||||
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
||||
|
||||
"scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"simplex-noise": ["simplex-noise@4.0.3", "", {}, "sha512-qSE2I4AngLQG7BXqoZj51jokT4WUXe8mOBrvfOXpci8+6Yu44+/dD5zqDpOx3Ux792eamTd2lLcI8jqFntk/lg=="],
|
||||
|
||||
"socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="],
|
||||
|
||||
"socket.io-parser": ["socket.io-parser@4.2.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"string-convert": ["string-convert@0.2.1", "", {}, "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A=="],
|
||||
|
||||
"stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="],
|
||||
|
||||
"three": ["three@0.160.1", "", {}, "sha512-Bgl2wPJypDOZ1stAxwfWAcJ0WQf7QzlptsxkjYiURPz+n5k4RBDLsq+6f9Y75TYxn6aHLcWz+JNmwTOXWrQTBQ=="],
|
||||
|
||||
"throttle-debounce": ["throttle-debounce@5.0.2", "", {}, "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A=="],
|
||||
|
||||
"toggle-selection": ["toggle-selection@1.0.6", "", {}, "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||
|
||||
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["immer"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
|
||||
|
||||
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 4.4 MiB After Width: | Height: | Size: 4.4 MiB |
|
Before Width: | Height: | Size: 18 MiB After Width: | Height: | Size: 18 MiB |
1
frontend/legacy/3dearthmult/landing-point-geo.geojson
Normal file
1
frontend/legacy/3dearthmult/relation.json
Normal file
|
Before Width: | Height: | Size: 4.4 MiB After Width: | Height: | Size: 4.4 MiB |
|
Before Width: | Height: | Size: 18 MiB After Width: | Height: | Size: 18 MiB |
@@ -17,14 +17,93 @@ body {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
/* user-select: none;
|
||||
-webkit-user-select: none; */
|
||||
}
|
||||
|
||||
#container.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Right Toolbar Group */
|
||||
#right-toolbar-group {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
right: 290px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
/* Zoom Toolbar - Right side, vertical */
|
||||
#zoom-toolbar {
|
||||
position: relative;
|
||||
bottom: auto;
|
||||
right: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: rgba(10, 10, 30, 0.9);
|
||||
padding: 8px 4px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(77, 184, 255, 0.3);
|
||||
box-shadow: 0 0 20px rgba(77, 184, 255, 0.2);
|
||||
}
|
||||
|
||||
#zoom-toolbar #zoom-slider {
|
||||
width: 4px;
|
||||
height: 50px;
|
||||
margin: 4px 0;
|
||||
writing-mode: vertical-lr;
|
||||
direction: rtl;
|
||||
-webkit-appearance: slider-vertical;
|
||||
}
|
||||
|
||||
#zoom-toolbar .zoom-percent {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #4db8ff;
|
||||
min-width: 30px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
#zoom-toolbar .zoom-percent:hover {
|
||||
background: rgba(77, 184, 255, 0.2);
|
||||
box-shadow: 0 0 10px rgba(77, 184, 255, 0.3);
|
||||
}
|
||||
|
||||
#zoom-toolbar .zoom-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(77, 184, 255, 0.2);
|
||||
color: #4db8ff;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
flex: 0 0 auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#zoom-toolbar .zoom-btn:hover {
|
||||
background: rgba(77, 184, 255, 0.4);
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 10px rgba(77, 184, 255, 0.5);
|
||||
}
|
||||
|
||||
#loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@@ -106,7 +185,7 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
.status-message {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
right: 260px;
|
||||
background-color: rgba(10, 10, 30, 0.85);
|
||||
border-radius: 10px;
|
||||
padding: 10px 15px;
|
||||
@@ -147,3 +226,139 @@ input[type="range"]::-webkit-slider-thumb {
|
||||
display: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Control Toolbar - Stellarium/Star Walk style */
|
||||
#control-toolbar {
|
||||
position: relative;
|
||||
bottom: auto;
|
||||
right: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: rgba(10, 10, 30, 0.9);
|
||||
border-radius: 24px;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(77, 184, 255, 0.3);
|
||||
box-shadow: 0 0 20px rgba(77, 184, 255, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
#control-toolbar.collapsed {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#control-toolbar.collapsed .toolbar-items {
|
||||
width: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
#toolbar-toggle {
|
||||
min-width: 28px;
|
||||
line-height: 1;
|
||||
transition: all 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.toggle-arrow {
|
||||
font-size: 14px;
|
||||
color: #4db8ff;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
#control-toolbar.collapsed .toggle-arrow {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
#control-toolbar:not(.collapsed) .toggle-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
#control-toolbar.collapsed #toolbar-toggle {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.toolbar-items {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
width: auto;
|
||||
padding: 0 4px 0 2px;
|
||||
overflow: visible;
|
||||
opacity: 1;
|
||||
transition: all 0.3s ease;
|
||||
border-right: 1px solid rgba(77, 184, 255, 0.3);
|
||||
margin-right: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(77, 184, 255, 0.15);
|
||||
color: #4db8ff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover {
|
||||
background: rgba(77, 184, 255, 0.35);
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 15px rgba(77, 184, 255, 0.5);
|
||||
}
|
||||
|
||||
.toolbar-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.toolbar-btn.active {
|
||||
background: rgba(77, 184, 255, 0.4);
|
||||
box-shadow: 0 0 10px rgba(77, 184, 255, 0.4) inset;
|
||||
}
|
||||
|
||||
.toolbar-btn .tooltip {
|
||||
position: absolute;
|
||||
bottom: 50px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(10, 10, 30, 0.95);
|
||||
color: #fff;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid rgba(77, 184, 255, 0.4);
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.toolbar-btn:hover .tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
bottom: 52px;
|
||||
}
|
||||
|
||||
.toolbar-btn .tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: rgba(77, 184, 255, 0.4);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
#coordinates-display {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 250px;
|
||||
right: 20px;
|
||||
background-color: rgba(10, 10, 30, 0.85);
|
||||
border-radius: 10px;
|
||||
padding: 10px 15px;
|
||||
|
||||
@@ -29,3 +29,33 @@
|
||||
color: #4db8ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#satellite-info {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
right: 290px;
|
||||
background-color: rgba(10, 10, 30, 0.9);
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
width: 220px;
|
||||
z-index: 10;
|
||||
box-shadow: 0 0 20px rgba(0, 229, 255, 0.3);
|
||||
border: 1px solid rgba(0, 229, 255, 0.3);
|
||||
font-size: 0.85rem;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
#satellite-info .stats-item {
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#satellite-info .stats-label {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
#satellite-info .stats-value {
|
||||
color: #00e5ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -95,11 +95,153 @@
|
||||
|
||||
#info-panel .zoom-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#info-panel .zoom-percent-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
#info-panel .zoom-percent {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
color: #4db8ff;
|
||||
min-width: 70px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
#info-panel .zoom-percent:hover {
|
||||
background: rgba(77, 184, 255, 0.2);
|
||||
box-shadow: 0 0 10px rgba(77, 184, 255, 0.3);
|
||||
}
|
||||
|
||||
#info-panel .zoom-buttons .zoom-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-width: 36px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(77, 184, 255, 0.2);
|
||||
color: #4db8ff;
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
padding: 0;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
#info-panel .zoom-buttons .zoom-btn:hover {
|
||||
background: rgba(77, 184, 255, 0.4);
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 10px rgba(77, 184, 255, 0.5);
|
||||
}
|
||||
|
||||
#info-panel .zoom-buttons button {
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
/* Info Card - Unified details panel (inside info-panel) */
|
||||
.info-card {
|
||||
margin-top: 15px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-card.no-border {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.info-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: rgba(77, 184, 255, 0.1);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-card-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.info-card-header h3 {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: #4db8ff;
|
||||
}
|
||||
|
||||
.info-card-content {
|
||||
padding: 10px 12px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.info-card-property {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.info-card-property:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-card-label {
|
||||
color: #aaa;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.info-card-value {
|
||||
color: #4db8ff;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
text-align: right;
|
||||
max-width: 180px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Cable type */
|
||||
.info-card.cable {
|
||||
border-color: rgba(255, 200, 0, 0.4);
|
||||
}
|
||||
|
||||
.info-card.cable .info-card-header {
|
||||
background: rgba(255, 200, 0, 0.15);
|
||||
}
|
||||
|
||||
.info-card.cable .info-card-header h3 {
|
||||
color: #ffc800;
|
||||
}
|
||||
|
||||
/* Satellite type */
|
||||
.info-card.satellite {
|
||||
border-color: rgba(0, 229, 255, 0.4);
|
||||
}
|
||||
|
||||
.info-card.satellite .info-card-header {
|
||||
background: rgba(0, 229, 255, 0.15);
|
||||
}
|
||||
|
||||
.info-card.satellite .info-card-header h3 {
|
||||
color: #00e5ff;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>3D球形地图 - 海底电缆系统</title>
|
||||
<title>智能星球计划 - 现实层宇宙全息感知</title>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://esm.sh/three@0.128.0",
|
||||
"simplex-noise": "https://esm.sh/simplex-noise@4.0.1"
|
||||
"simplex-noise": "https://esm.sh/simplex-noise@4.0.1",
|
||||
"satellite.js": "https://esm.sh/satellite.js@5.0.0"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -21,57 +22,41 @@
|
||||
<body>
|
||||
<div id="container">
|
||||
<div id="info-panel">
|
||||
<h1>全球海底电缆系统</h1>
|
||||
<div class="subtitle">3D地形球形地图可视化 | 高分辨率卫星图</div>
|
||||
<div class="zoom-controls">
|
||||
<div style="width: 100%;">
|
||||
<h3 style="color:#4db8ff; margin-bottom:10px; font-size:1.1rem;">缩放控制</h3>
|
||||
<div class="zoom-buttons">
|
||||
<button id="zoom-in">放大</button>
|
||||
<button id="zoom-out">缩小</button>
|
||||
<button id="zoom-reset">重置</button>
|
||||
<h1>智能星球计划</h1>
|
||||
<div class="subtitle">现实层宇宙全息感知系统 | 卫星 · 海底光缆 · 算力基础设施</div>
|
||||
|
||||
<div id="info-card" class="info-card" style="display: none;">
|
||||
<div class="info-card-header">
|
||||
<span class="info-card-icon" id="info-card-icon">🛰️</span>
|
||||
<h3 id="info-card-title">详情</h3>
|
||||
</div>
|
||||
<div class="slider-container" style="margin-top: 10px;">
|
||||
<div class="slider-label">
|
||||
<span>缩放级别:</span>
|
||||
<span id="zoom-value">1.0x</span>
|
||||
</div>
|
||||
<input type="range" id="zoom-slider" min="0.5" max="5" step="0.1" value="1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cable-details" class="cable-info">
|
||||
<h3 id="cable-name">点击电缆查看详情</h3>
|
||||
<div class="cable-property">
|
||||
<span class="property-label">所有者:</span>
|
||||
<span id="cable-owner" class="property-value">-</span>
|
||||
</div>
|
||||
<div class="cable-property">
|
||||
<span class="property-label">状态:</span>
|
||||
<span id="cable-status" class="property-value">-</span>
|
||||
</div>
|
||||
<div class="cable-property">
|
||||
<span class="property-label">长度:</span>
|
||||
<span id="cable-length" class="property-value">-</span>
|
||||
</div>
|
||||
<div class="cable-property">
|
||||
<span class="property-label">经纬度:</span>
|
||||
<span id="cable-coords" class="property-value">-</span>
|
||||
</div>
|
||||
<div class="cable-property">
|
||||
<span class="property-label">投入使用时间:</span>
|
||||
<span id="cable-rfs" class="property-value">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button id="rotate-toggle">暂停旋转</button>
|
||||
<button id="reset-view">重置视图</button>
|
||||
<button id="toggle-terrain">显示地形</button>
|
||||
<button id="reload-data">重新加载数据</button>
|
||||
<div id="info-card-content"></div>
|
||||
</div>
|
||||
|
||||
<div id="error-message" class="error-message"></div>
|
||||
</div>
|
||||
|
||||
<div id="right-toolbar-group">
|
||||
<div id="zoom-toolbar">
|
||||
<button id="reset-view" class="zoom-btn">🎯</button>
|
||||
<button id="zoom-in" class="zoom-btn">+</button>
|
||||
<span id="zoom-value" class="zoom-percent">100%</span>
|
||||
<button id="zoom-out" class="zoom-btn">−</button>
|
||||
</div>
|
||||
|
||||
<div id="control-toolbar">
|
||||
<div class="toolbar-items">
|
||||
<button id="rotate-toggle" class="toolbar-btn" title="自动旋转">🔄<span class="tooltip">自动旋转</span></button>
|
||||
<button id="toggle-cables" class="toolbar-btn active" title="显示/隐藏线缆">🌐<span class="tooltip">隐藏线缆</span></button>
|
||||
<button id="toggle-terrain" class="toolbar-btn" title="显示/隐藏地形">⛰️<span class="tooltip">显示/隐藏地形</span></button>
|
||||
<button id="toggle-satellites" class="toolbar-btn" title="显示/隐藏卫星">🛰️<span class="tooltip">显示卫星</span></button>
|
||||
<button id="toggle-trails" class="toolbar-btn" title="显示/隐藏轨迹">✨<span class="tooltip">显示/隐藏轨迹</span></button>
|
||||
<button id="reload-data" class="toolbar-btn" title="重新加载数据">🔃<span class="tooltip">重新加载数据</span></button>
|
||||
</div>
|
||||
<button id="toolbar-toggle" class="toolbar-btn" title="展开/收起工具栏"><span class="toggle-arrow">◀</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="coordinates-display">
|
||||
<h3 style="color:#4db8ff; margin-bottom:8px; font-size:1.1rem;">坐标信息</h3>
|
||||
<div class="coord-item">
|
||||
@@ -124,6 +109,10 @@
|
||||
<span class="stats-label">地形:</span>
|
||||
<span class="stats-value" id="terrain-status">开启</span>
|
||||
</div>
|
||||
<div class="stats-item">
|
||||
<span class="stats-label">卫星:</span>
|
||||
<span class="stats-value" id="satellite-count">0 颗</span>
|
||||
</div>
|
||||
<div class="stats-item">
|
||||
<span class="stats-label">视角距离:</span>
|
||||
<span class="stats-value" id="camera-distance">300 km</span>
|
||||
|
||||
@@ -2,14 +2,16 @@
|
||||
|
||||
import * as THREE from 'three';
|
||||
|
||||
import { CONFIG, CABLE_COLORS, PATHS } from './constants.js';
|
||||
import { CONFIG, CABLE_COLORS, PATHS, CABLE_STATE } from './constants.js';
|
||||
import { latLonToVector3 } from './utils.js';
|
||||
import { updateCableDetails, updateEarthStats, showStatusMessage } from './ui.js';
|
||||
import { updateEarthStats, showStatusMessage } from './ui.js';
|
||||
import { showInfoCard } from './info-card.js';
|
||||
|
||||
export let cableLines = [];
|
||||
export let landingPoints = [];
|
||||
export let lockedCable = null;
|
||||
let cableIdMap = new Map();
|
||||
let cablesVisible = true;
|
||||
|
||||
function getCableColor(properties) {
|
||||
if (properties.color) {
|
||||
@@ -285,7 +287,7 @@ export async function loadLandingPoints(scene, earthObj) {
|
||||
sphere.userData = {
|
||||
type: 'landingPoint',
|
||||
name: properties.name || '未知登陆站',
|
||||
cableName: properties.cable_system || '未知系统',
|
||||
cableNames: properties.cable_names || [],
|
||||
country: properties.country || '未知国家',
|
||||
status: properties.status || 'Unknown'
|
||||
};
|
||||
@@ -312,8 +314,7 @@ export function handleCableClick(cable) {
|
||||
lockedCable = cable;
|
||||
|
||||
const data = cable.userData;
|
||||
// console.log(data)
|
||||
updateCableDetails({
|
||||
showInfoCard('cable', {
|
||||
name: data.name,
|
||||
owner: data.owner,
|
||||
status: data.status,
|
||||
@@ -327,14 +328,6 @@ export function handleCableClick(cable) {
|
||||
|
||||
export function clearCableSelection() {
|
||||
lockedCable = null;
|
||||
updateCableDetails({
|
||||
name: '点击电缆查看详情',
|
||||
owner: '-',
|
||||
status: '-',
|
||||
length: '-',
|
||||
coords: '-',
|
||||
rfs: '-'
|
||||
});
|
||||
}
|
||||
|
||||
export function getCableLines() {
|
||||
@@ -348,3 +341,83 @@ export function getCablesById(cableId) {
|
||||
export function getLandingPoints() {
|
||||
return landingPoints;
|
||||
}
|
||||
|
||||
const cableStates = new Map();
|
||||
|
||||
export function getCableState(cableId) {
|
||||
return cableStates.get(cableId) || CABLE_STATE.NORMAL;
|
||||
}
|
||||
|
||||
export function setCableState(cableId, state) {
|
||||
cableStates.set(cableId, state);
|
||||
}
|
||||
|
||||
export function clearAllCableStates() {
|
||||
cableStates.clear();
|
||||
}
|
||||
|
||||
export function getCableStateInfo() {
|
||||
const states = {};
|
||||
cableStates.forEach((state, cableId) => {
|
||||
states[cableId] = state;
|
||||
});
|
||||
return states;
|
||||
}
|
||||
|
||||
export function getLandingPointsByCableName(cableName) {
|
||||
return landingPoints.filter(lp => lp.userData.cableNames?.includes(cableName));
|
||||
}
|
||||
|
||||
export function getAllLandingPoints() {
|
||||
return landingPoints;
|
||||
}
|
||||
|
||||
export function applyLandingPointVisualState(lockedCableName, dimAll = false) {
|
||||
const pulse = (Math.sin(Date.now() * 0.003) + 1) * 0.5;
|
||||
const brightness = 0.3;
|
||||
|
||||
landingPoints.forEach(lp => {
|
||||
const isRelated = !dimAll && lp.userData.cableNames?.includes(lockedCableName);
|
||||
|
||||
if (isRelated) {
|
||||
lp.material.color.setHex(0xffaa00);
|
||||
lp.material.emissive.setHex(0x442200);
|
||||
lp.material.emissiveIntensity = 0.5 + pulse * 0.5;
|
||||
lp.material.opacity = 0.8 + pulse * 0.2;
|
||||
lp.scale.setScalar(1.2 + pulse * 0.3);
|
||||
} else {
|
||||
const r = 255 * brightness;
|
||||
const g = 170 * brightness;
|
||||
const b = 0 * brightness;
|
||||
lp.material.color.setRGB(r / 255, g / 255, b / 255);
|
||||
lp.material.emissive.setHex(0x000000);
|
||||
lp.material.emissiveIntensity = 0;
|
||||
lp.material.opacity = 0.3;
|
||||
lp.scale.setScalar(1.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function resetLandingPointVisualState() {
|
||||
landingPoints.forEach(lp => {
|
||||
lp.material.color.setHex(0xffaa00);
|
||||
lp.material.emissive.setHex(0x442200);
|
||||
lp.material.emissiveIntensity = 0.5;
|
||||
lp.material.opacity = 1.0;
|
||||
lp.scale.setScalar(1.0);
|
||||
});
|
||||
}
|
||||
|
||||
export function toggleCables(show) {
|
||||
cablesVisible = show;
|
||||
cableLines.forEach(cable => {
|
||||
cable.visible = cablesVisible;
|
||||
});
|
||||
landingPoints.forEach(lp => {
|
||||
lp.visible = cablesVisible;
|
||||
});
|
||||
}
|
||||
|
||||
export function getShowCables() {
|
||||
return cablesVisible;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,21 @@ export const CONFIG = {
|
||||
minZoom: 0.5,
|
||||
maxZoom: 5.0,
|
||||
earthRadius: 100,
|
||||
rotationSpeed: 0.002,
|
||||
rotationSpeed: 0.0005,
|
||||
};
|
||||
|
||||
// Earth coordinate constants
|
||||
export const EARTH_CONFIG = {
|
||||
tilt: 23.5, // earth tilt angle (degrees)
|
||||
tiltRad: 23.5 * Math.PI / 180, // earth tilt angle (radians)
|
||||
|
||||
// hangzhou coordinates
|
||||
chinaLat: 30.2741,
|
||||
chinaLon: 120.1552,
|
||||
chinaRotLon: 120.1552 - 270, // for rotation calculation (chinaLon - 270)
|
||||
|
||||
// view reset coefficient
|
||||
latCoefficient: 0.5
|
||||
};
|
||||
|
||||
export const PATHS = {
|
||||
@@ -24,7 +38,42 @@ export const CABLE_COLORS = {
|
||||
'default': 0xffff44
|
||||
};
|
||||
|
||||
// Grid configuration
|
||||
export const CABLE_CONFIG = {
|
||||
lockedOpacityMin: 0.2,
|
||||
lockedOpacityMax: 1.0,
|
||||
otherOpacity: 0.5,
|
||||
otherBrightness: 0.6,
|
||||
pulseSpeed: 0.008,
|
||||
pulseCoefficient: 0.4
|
||||
};
|
||||
|
||||
export const CABLE_STATE = {
|
||||
NORMAL: 'normal',
|
||||
HOVERED: 'hovered',
|
||||
LOCKED: 'locked'
|
||||
};
|
||||
|
||||
export const SATELLITE_CONFIG = {
|
||||
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 = {
|
||||
sampleInterval: 10,
|
||||
opacity: 0.8
|
||||
};
|
||||
|
||||
export const GRID_CONFIG = {
|
||||
latitudeStep: 10,
|
||||
longitudeStep: 30,
|
||||
|
||||
260
frontend/public/earth/js/controls.js
vendored
@@ -1,8 +1,11 @@
|
||||
// controls.js - Zoom, rotate and toggle controls
|
||||
|
||||
import { CONFIG } from './constants.js';
|
||||
import { CONFIG, EARTH_CONFIG } from './constants.js';
|
||||
import { updateZoomDisplay, showStatusMessage } from './ui.js';
|
||||
import { toggleTerrain } from './earth.js';
|
||||
import { reloadData, clearLockedObject } from './main.js';
|
||||
import { toggleSatellites, toggleTrails, getShowSatellites, getSatelliteCount } from './satellites.js';
|
||||
import { toggleCables, getShowCables } from './cables.js';
|
||||
|
||||
export let autoRotate = true;
|
||||
export let zoomLevel = 1.0;
|
||||
@@ -20,26 +23,100 @@ export function setupControls(camera, renderer, scene, earth) {
|
||||
}
|
||||
|
||||
function setupZoomControls(camera) {
|
||||
document.getElementById('zoom-in').addEventListener('click', () => {
|
||||
zoomLevel = Math.min(zoomLevel + 0.5, CONFIG.maxZoom);
|
||||
applyZoom(camera);
|
||||
});
|
||||
let zoomInterval = null;
|
||||
let holdTimeout = null;
|
||||
let startTime = 0;
|
||||
const HOLD_THRESHOLD = 150;
|
||||
const LONG_PRESS_TICK = 50;
|
||||
const CLICK_STEP = 10;
|
||||
|
||||
document.getElementById('zoom-out').addEventListener('click', () => {
|
||||
zoomLevel = Math.max(zoomLevel - 0.5, CONFIG.minZoom);
|
||||
applyZoom(camera);
|
||||
});
|
||||
const MIN_PERCENT = CONFIG.minZoom * 100;
|
||||
const MAX_PERCENT = CONFIG.maxZoom * 100;
|
||||
|
||||
document.getElementById('zoom-reset').addEventListener('click', () => {
|
||||
function doZoomStep(direction) {
|
||||
let currentPercent = Math.round(zoomLevel * 100);
|
||||
let newPercent = direction > 0 ? currentPercent + CLICK_STEP : currentPercent - CLICK_STEP;
|
||||
|
||||
if (newPercent > MAX_PERCENT) newPercent = MAX_PERCENT;
|
||||
if (newPercent < MIN_PERCENT) newPercent = MIN_PERCENT;
|
||||
|
||||
zoomLevel = newPercent / 100;
|
||||
applyZoom(camera);
|
||||
}
|
||||
|
||||
function doContinuousZoom(direction) {
|
||||
let currentPercent = Math.round(zoomLevel * 100);
|
||||
let newPercent = direction > 0 ? currentPercent + 1 : currentPercent - 1;
|
||||
|
||||
if (newPercent > MAX_PERCENT) newPercent = MAX_PERCENT;
|
||||
if (newPercent < MIN_PERCENT) newPercent = MIN_PERCENT;
|
||||
|
||||
zoomLevel = newPercent / 100;
|
||||
applyZoom(camera);
|
||||
}
|
||||
|
||||
function startContinuousZoom(direction) {
|
||||
doContinuousZoom(direction);
|
||||
zoomInterval = setInterval(() => {
|
||||
doContinuousZoom(direction);
|
||||
}, LONG_PRESS_TICK);
|
||||
}
|
||||
|
||||
function stopZoom() {
|
||||
if (zoomInterval) {
|
||||
clearInterval(zoomInterval);
|
||||
zoomInterval = null;
|
||||
}
|
||||
if (holdTimeout) {
|
||||
clearTimeout(holdTimeout);
|
||||
holdTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseDown(direction) {
|
||||
startTime = Date.now();
|
||||
stopZoom();
|
||||
holdTimeout = setTimeout(() => {
|
||||
startContinuousZoom(direction);
|
||||
}, HOLD_THRESHOLD);
|
||||
}
|
||||
|
||||
function handleMouseUp(direction) {
|
||||
const heldTime = Date.now() - startTime;
|
||||
stopZoom();
|
||||
if (heldTime < HOLD_THRESHOLD) {
|
||||
doZoomStep(direction);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('zoom-in').addEventListener('mousedown', () => handleMouseDown(1));
|
||||
document.getElementById('zoom-in').addEventListener('mouseup', () => handleMouseUp(1));
|
||||
document.getElementById('zoom-in').addEventListener('mouseleave', stopZoom);
|
||||
document.getElementById('zoom-in').addEventListener('touchstart', (e) => { e.preventDefault(); handleMouseDown(1); });
|
||||
document.getElementById('zoom-in').addEventListener('touchend', () => handleMouseUp(1));
|
||||
|
||||
document.getElementById('zoom-out').addEventListener('mousedown', () => handleMouseDown(-1));
|
||||
document.getElementById('zoom-out').addEventListener('mouseup', () => handleMouseUp(-1));
|
||||
document.getElementById('zoom-out').addEventListener('mouseleave', stopZoom);
|
||||
document.getElementById('zoom-out').addEventListener('touchstart', (e) => { e.preventDefault(); handleMouseDown(-1); });
|
||||
document.getElementById('zoom-out').addEventListener('touchend', () => handleMouseUp(-1));
|
||||
|
||||
document.getElementById('zoom-value').addEventListener('click', function() {
|
||||
const startZoomVal = zoomLevel;
|
||||
const targetZoom = 1.0;
|
||||
const startDistance = CONFIG.defaultCameraZ / startZoomVal;
|
||||
const targetDistance = CONFIG.defaultCameraZ / targetZoom;
|
||||
|
||||
animateValue(0, 1, 600, (progress) => {
|
||||
const ease = 1 - Math.pow(1 - progress, 3);
|
||||
zoomLevel = startZoomVal + (targetZoom - startZoomVal) * ease;
|
||||
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
|
||||
const distance = startDistance + (targetDistance - startDistance) * ease;
|
||||
updateZoomDisplay(zoomLevel, distance.toFixed(0));
|
||||
}, () => {
|
||||
zoomLevel = 1.0;
|
||||
applyZoom(camera);
|
||||
showStatusMessage('缩放已重置', 'info');
|
||||
showStatusMessage('缩放已重置到100%', 'info');
|
||||
});
|
||||
|
||||
const slider = document.getElementById('zoom-slider');
|
||||
slider?.addEventListener('input', (e) => {
|
||||
zoomLevel = parseFloat(e.target.value);
|
||||
applyZoom(camera);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -85,11 +162,14 @@ function animateValue(start, end, duration, onUpdate, onComplete) {
|
||||
export function resetView(camera) {
|
||||
if (!earthObj) return;
|
||||
|
||||
function animateToView(targetLat, targetLon, targetRotLon) {
|
||||
const latRot = targetLat * Math.PI / 180;
|
||||
const targetRotX = EARTH_CONFIG.tiltRad + latRot * EARTH_CONFIG.latCoefficient;
|
||||
const targetRotY = -(targetRotLon * Math.PI / 180);
|
||||
|
||||
const startRotX = earthObj.rotation.x;
|
||||
const startRotY = earthObj.rotation.y;
|
||||
const startZoom = zoomLevel;
|
||||
const targetRotX = 23.5 * Math.PI / 180;
|
||||
const targetRotY = 0;
|
||||
const targetZoom = 1.0;
|
||||
|
||||
animateValue(0, 1, 800, (progress) => {
|
||||
@@ -102,8 +182,19 @@ export function resetView(camera) {
|
||||
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
|
||||
}, () => {
|
||||
zoomLevel = 1.0;
|
||||
showStatusMessage('视图已重置', 'info');
|
||||
showStatusMessage('视角已重置', 'info');
|
||||
});
|
||||
}
|
||||
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => animateToView(pos.coords.latitude, pos.coords.longitude, -pos.coords.longitude),
|
||||
() => animateToView(EARTH_CONFIG.chinaLat, EARTH_CONFIG.chinaLon, EARTH_CONFIG.chinaRotLon),
|
||||
{ timeout: 5000, enableHighAccuracy: false }
|
||||
);
|
||||
} else {
|
||||
animateToView(EARTH_CONFIG.chinaLat, EARTH_CONFIG.chinaLon, EARTH_CONFIG.chinaRotLon);
|
||||
}
|
||||
|
||||
if (typeof window.clearLockedCable === 'function') {
|
||||
window.clearLockedCable();
|
||||
@@ -111,97 +202,98 @@ export function resetView(camera) {
|
||||
}
|
||||
|
||||
function setupRotateControls(camera, earth) {
|
||||
document.getElementById('rotate-toggle').addEventListener('click', () => {
|
||||
toggleAutoRotate();
|
||||
const isOn = autoRotate;
|
||||
showStatusMessage(isOn ? '自动旋转已开启' : '自动旋转已暂停', 'info');
|
||||
const rotateBtn = document.getElementById('rotate-toggle');
|
||||
|
||||
rotateBtn.addEventListener('click', function() {
|
||||
const isRotating = toggleAutoRotate();
|
||||
showStatusMessage(isRotating ? '自动旋转已开启' : '自动旋转已暂停', 'info');
|
||||
});
|
||||
|
||||
document.getElementById('reset-view').addEventListener('click', () => {
|
||||
if (!earthObj) return;
|
||||
updateRotateUI();
|
||||
|
||||
const startRotX = earthObj.rotation.x;
|
||||
const startRotY = earthObj.rotation.y;
|
||||
const startZoom = zoomLevel;
|
||||
const targetRotX = 23.5 * Math.PI / 180;
|
||||
const targetRotY = 0;
|
||||
const targetZoom = 1.0;
|
||||
|
||||
animateValue(0, 1, 800, (progress) => {
|
||||
const ease = 1 - Math.pow(1 - progress, 3);
|
||||
earthObj.rotation.x = startRotX + (targetRotX - startRotX) * ease;
|
||||
earthObj.rotation.y = startRotY + (targetRotY - startRotY) * ease;
|
||||
|
||||
zoomLevel = startZoom + (targetZoom - startZoom) * ease;
|
||||
camera.position.z = CONFIG.defaultCameraZ / zoomLevel;
|
||||
updateZoomDisplay(zoomLevel, camera.position.z.toFixed(0));
|
||||
}, () => {
|
||||
zoomLevel = 1.0;
|
||||
showStatusMessage('视图已重置', 'info');
|
||||
});
|
||||
document.getElementById('reset-view').addEventListener('click', function() {
|
||||
resetView(camera);
|
||||
});
|
||||
}
|
||||
|
||||
function setupTerrainControls() {
|
||||
document.getElementById('toggle-terrain').addEventListener('click', () => {
|
||||
document.getElementById('toggle-terrain').addEventListener('click', function() {
|
||||
showTerrain = !showTerrain;
|
||||
toggleTerrain(showTerrain);
|
||||
const btn = document.getElementById('toggle-terrain');
|
||||
btn.textContent = showTerrain ? '隐藏地形' : '显示地形';
|
||||
this.classList.toggle('active', showTerrain);
|
||||
this.querySelector('.tooltip').textContent = showTerrain ? '隐藏地形' : '显示地形';
|
||||
document.getElementById('terrain-status').textContent = showTerrain ? '开启' : '关闭';
|
||||
showStatusMessage(showTerrain ? '地形已显示' : '地形已隐藏', 'info');
|
||||
});
|
||||
|
||||
document.getElementById('reload-data').addEventListener('click', () => {
|
||||
showStatusMessage('重新加载数据...', 'info');
|
||||
window.location.reload();
|
||||
document.getElementById('toggle-satellites').addEventListener('click', function() {
|
||||
const showSats = !getShowSatellites();
|
||||
if (!showSats) {
|
||||
clearLockedObject();
|
||||
}
|
||||
toggleSatellites(showSats);
|
||||
this.classList.toggle('active', showSats);
|
||||
this.querySelector('.tooltip').textContent = showSats ? '隐藏卫星' : '显示卫星';
|
||||
document.getElementById('satellite-count').textContent = getSatelliteCount() + ' 颗';
|
||||
showStatusMessage(showSats ? '卫星已显示' : '卫星已隐藏', 'info');
|
||||
});
|
||||
|
||||
document.getElementById('toggle-trails').addEventListener('click', function() {
|
||||
const isActive = this.classList.contains('active');
|
||||
const showTrails = !isActive;
|
||||
toggleTrails(showTrails);
|
||||
this.classList.toggle('active', showTrails);
|
||||
this.querySelector('.tooltip').textContent = showTrails ? '隐藏轨迹' : '显示轨迹';
|
||||
showStatusMessage(showTrails ? '轨迹已显示' : '轨迹已隐藏', 'info');
|
||||
});
|
||||
|
||||
document.getElementById('toggle-cables').addEventListener('click', function() {
|
||||
const showCables = !getShowCables();
|
||||
if (!showCables) {
|
||||
clearLockedObject();
|
||||
}
|
||||
toggleCables(showCables);
|
||||
this.classList.toggle('active', showCables);
|
||||
this.querySelector('.tooltip').textContent = showCables ? '隐藏线缆' : '显示线缆';
|
||||
showStatusMessage(showCables ? '线缆已显示' : '线缆已隐藏', 'info');
|
||||
});
|
||||
|
||||
document.getElementById('reload-data').addEventListener('click', async () => {
|
||||
await reloadData();
|
||||
showStatusMessage('数据已重新加载', 'success');
|
||||
});
|
||||
|
||||
const toolbarToggle = document.getElementById('toolbar-toggle');
|
||||
const toolbar = document.getElementById('control-toolbar');
|
||||
if (toolbarToggle && toolbar) {
|
||||
toolbarToggle.addEventListener('click', () => {
|
||||
toolbar.classList.toggle('collapsed');
|
||||
});
|
||||
}
|
||||
|
||||
function setupMouseControls(camera, renderer) {
|
||||
let previousMousePosition = { x: 0, y: 0 };
|
||||
|
||||
renderer.domElement.addEventListener('mousedown', (e) => {
|
||||
isDragging = true;
|
||||
previousMousePosition = { x: e.clientX, y: e.clientY };
|
||||
});
|
||||
|
||||
renderer.domElement.addEventListener('mouseup', () => {
|
||||
isDragging = false;
|
||||
});
|
||||
|
||||
renderer.domElement.addEventListener('mousemove', (e) => {
|
||||
if (isDragging) {
|
||||
const deltaX = e.clientX - previousMousePosition.x;
|
||||
const deltaY = e.clientY - previousMousePosition.y;
|
||||
|
||||
if (earth) {
|
||||
earth.rotation.y += deltaX * 0.005;
|
||||
earth.rotation.x += deltaY * 0.005;
|
||||
}
|
||||
|
||||
previousMousePosition = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function getAutoRotate() {
|
||||
return autoRotate;
|
||||
}
|
||||
|
||||
export function setAutoRotate(value) {
|
||||
autoRotate = value;
|
||||
function updateRotateUI() {
|
||||
const btn = document.getElementById('rotate-toggle');
|
||||
if (btn) {
|
||||
btn.textContent = autoRotate ? '暂停旋转' : '开始旋转';
|
||||
btn.classList.toggle('active', autoRotate);
|
||||
btn.innerHTML = autoRotate ? '⏸️' : '▶️';
|
||||
const tooltip = btn.querySelector('.tooltip');
|
||||
if (tooltip) tooltip.textContent = autoRotate ? '暂停旋转' : '开始旋转';
|
||||
}
|
||||
}
|
||||
|
||||
export function setAutoRotate(value) {
|
||||
autoRotate = value;
|
||||
updateRotateUI();
|
||||
}
|
||||
|
||||
export function toggleAutoRotate() {
|
||||
autoRotate = !autoRotate;
|
||||
const btn = document.getElementById('rotate-toggle');
|
||||
if (btn) {
|
||||
btn.textContent = autoRotate ? '暂停旋转' : '开始旋转';
|
||||
}
|
||||
updateRotateUI();
|
||||
if (window.clearLockedCable) {
|
||||
window.clearLockedCable();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// earth.js - 3D Earth creation module
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { CONFIG } from './constants.js';
|
||||
import { CONFIG, EARTH_CONFIG } from './constants.js';
|
||||
import { latLonToVector3 } from './utils.js';
|
||||
|
||||
export let earth = null;
|
||||
@@ -24,11 +24,11 @@ export function createEarth(scene) {
|
||||
});
|
||||
|
||||
earth = new THREE.Mesh(geometry, material);
|
||||
earth.rotation.x = 23.5 * Math.PI / 180;
|
||||
earth.rotation.x = EARTH_CONFIG.tiltRad;
|
||||
scene.add(earth);
|
||||
|
||||
const textureUrls = [
|
||||
'./8k_earth_daymap.jpg',
|
||||
'./assets/8k_earth_daymap.jpg',
|
||||
'https://raw.githubusercontent.com/mrdoob/three.js/dev/examples/textures/planets/earth_atmos_2048.jpg',
|
||||
'https://threejs.org/examples/textures/planets/earth_atmos_2048.jpg',
|
||||
'https://assets.codepen.io/982762/earth_texture_2048.jpg'
|
||||
|
||||
121
frontend/public/earth/js/info-card.js
Normal file
@@ -0,0 +1,121 @@
|
||||
// info-card.js - Unified info card module
|
||||
|
||||
let currentType = null;
|
||||
|
||||
const CARD_CONFIG = {
|
||||
cable: {
|
||||
icon: '🛥️',
|
||||
title: '电缆详情',
|
||||
className: 'cable',
|
||||
fields: [
|
||||
{ key: 'name', label: '名称' },
|
||||
{ key: 'owner', label: '所有者' },
|
||||
{ key: 'status', label: '状态' },
|
||||
{ key: 'length', label: '长度' },
|
||||
{ key: 'coords', label: '经纬度' },
|
||||
{ key: 'rfs', label: '投入使用' }
|
||||
]
|
||||
},
|
||||
satellite: {
|
||||
icon: '🛰️',
|
||||
title: '卫星详情',
|
||||
className: 'satellite',
|
||||
fields: [
|
||||
{ key: 'name', label: '名称' },
|
||||
{ key: 'norad_id', label: 'NORAD ID' },
|
||||
{ key: 'inclination', label: '倾角', unit: '°' },
|
||||
{ key: 'period', label: '周期', unit: '分钟' },
|
||||
{ key: 'perigee', label: '近地点', unit: 'km' },
|
||||
{ key: 'apogee', label: '远地点', unit: 'km' }
|
||||
]
|
||||
},
|
||||
supercomputer: {
|
||||
icon: '🖥️',
|
||||
title: '超算详情',
|
||||
className: 'supercomputer',
|
||||
fields: [
|
||||
{ key: 'name', label: '名称' },
|
||||
{ key: 'rank', label: '排名' },
|
||||
{ key: 'r_max', label: 'Rmax', unit: 'GFlops' },
|
||||
{ key: 'r_peak', label: 'Rpeak', unit: 'GFlops' },
|
||||
{ key: 'country', label: '国家' },
|
||||
{ key: 'city', label: '城市' }
|
||||
]
|
||||
},
|
||||
gpu_cluster: {
|
||||
icon: '🎮',
|
||||
title: 'GPU集群详情',
|
||||
className: 'gpu_cluster',
|
||||
fields: [
|
||||
{ key: 'name', label: '名称' },
|
||||
{ key: 'country', label: '国家' },
|
||||
{ key: 'city', label: '城市' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export function initInfoCard() {
|
||||
// Close button removed - now uses external clear button
|
||||
}
|
||||
|
||||
export function setInfoCardNoBorder(noBorder = true) {
|
||||
const card = document.getElementById('info-card');
|
||||
if (card) {
|
||||
card.classList.toggle('no-border', noBorder);
|
||||
}
|
||||
}
|
||||
|
||||
export function showInfoCard(type, data) {
|
||||
const config = CARD_CONFIG[type];
|
||||
if (!config) {
|
||||
console.warn('Unknown info card type:', type);
|
||||
return;
|
||||
}
|
||||
|
||||
currentType = type;
|
||||
const card = document.getElementById('info-card');
|
||||
const icon = document.getElementById('info-card-icon');
|
||||
const title = document.getElementById('info-card-title');
|
||||
const content = document.getElementById('info-card-content');
|
||||
|
||||
card.className = 'info-card ' + config.className;
|
||||
icon.textContent = config.icon;
|
||||
title.textContent = config.title;
|
||||
|
||||
let html = '';
|
||||
for (const field of config.fields) {
|
||||
let value = data[field.key];
|
||||
|
||||
if (value === undefined || value === null || value === '') {
|
||||
value = '-';
|
||||
} else if (typeof value === 'number') {
|
||||
value = value.toLocaleString();
|
||||
}
|
||||
|
||||
if (field.unit && value !== '-') {
|
||||
value = value + ' ' + field.unit;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="info-card-property">
|
||||
<span class="info-card-label">${field.label}</span>
|
||||
<span class="info-card-value">${value}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
content.innerHTML = html;
|
||||
card.style.display = 'block';
|
||||
}
|
||||
|
||||
export function hideInfoCard() {
|
||||
const card = document.getElementById('info-card');
|
||||
if (card) {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
currentType = null;
|
||||
}
|
||||
|
||||
export function getCurrentType() {
|
||||
return currentType;
|
||||
}
|
||||
@@ -1,29 +1,127 @@
|
||||
import * as THREE from 'three';
|
||||
import { createNoise3D } from 'simplex-noise';
|
||||
|
||||
import { CONFIG } from './constants.js';
|
||||
import { CONFIG, CABLE_CONFIG, CABLE_STATE } from './constants.js';
|
||||
import { latLonToVector3, vector3ToLatLon, screenToEarthCoords } from './utils.js';
|
||||
import {
|
||||
showStatusMessage,
|
||||
updateCoordinatesDisplay,
|
||||
updateZoomDisplay,
|
||||
updateEarthStats,
|
||||
updateCableDetails,
|
||||
setLoading,
|
||||
showTooltip,
|
||||
hideTooltip
|
||||
} from './ui.js';
|
||||
import { createEarth, createClouds, createTerrain, createStars, createGridLines, toggleTerrain, getEarth } from './earth.js';
|
||||
import { loadGeoJSONFromPath, loadLandingPoints, handleCableClick, clearCableSelection, getCableLines, getCablesById } from './cables.js';
|
||||
import { setupControls, getAutoRotate, getShowTerrain, zoomLevel, setAutoRotate, toggleAutoRotate } from './controls.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, 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';
|
||||
|
||||
export let scene, camera, renderer;
|
||||
let simplex;
|
||||
let isDragging = false;
|
||||
let previousMousePosition = { x: 0, y: 0 };
|
||||
let hoveredCable = null;
|
||||
let lockedCable = null;
|
||||
let lockedCableData = null;
|
||||
let hoveredSatellite = null;
|
||||
let hoveredSatelliteIndex = null;
|
||||
let cableLockedData = null;
|
||||
let lockedSatellite = null;
|
||||
let lockedSatelliteIndex = null;
|
||||
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();
|
||||
hoveredCable = null;
|
||||
hoveredSatellite = null;
|
||||
hoveredSatelliteIndex = null;
|
||||
clearAllCableStates();
|
||||
setSatelliteRingState(null, 'none', null);
|
||||
lockedObject = null;
|
||||
lockedObjectType = null;
|
||||
lockedSatellite = null;
|
||||
lockedSatelliteIndex = null;
|
||||
window.lockedSatelliteIndex = null;
|
||||
cableLockedData = null;
|
||||
}
|
||||
|
||||
function isSameCable(cable1, cable2) {
|
||||
if (!cable1 || !cable2) return false;
|
||||
const id1 = cable1.userData?.cableId;
|
||||
const id2 = cable2.userData?.cableId;
|
||||
if (id1 === undefined || id2 === undefined) return false;
|
||||
return id1 === id2;
|
||||
}
|
||||
|
||||
function showCableInfo(cable) {
|
||||
showInfoCard('cable', {
|
||||
name: cable.userData.name,
|
||||
owner: cable.userData.owner,
|
||||
status: cable.userData.status,
|
||||
length: cable.userData.length,
|
||||
coords: cable.userData.coords,
|
||||
rfs: cable.userData.rfs
|
||||
});
|
||||
}
|
||||
|
||||
function showSatelliteInfo(props) {
|
||||
const meanMotion = props?.mean_motion || 0;
|
||||
const period = meanMotion > 0 ? (1440 / meanMotion).toFixed(1) : '-';
|
||||
const ecc = props?.eccentricity || 0;
|
||||
const perigee = (6371 * (1 - ecc)).toFixed(0);
|
||||
const apogee = (6371 * (1 + ecc)).toFixed(0);
|
||||
|
||||
showInfoCard('satellite', {
|
||||
name: props?.name || '-',
|
||||
norad_id: props?.norad_cat_id,
|
||||
inclination: props?.inclination ? props.inclination.toFixed(2) : '-',
|
||||
period: period,
|
||||
perigee: perigee,
|
||||
apogee: apogee
|
||||
});
|
||||
}
|
||||
|
||||
function applyCableVisualState() {
|
||||
const allCables = getCableLines();
|
||||
const pulse = (Math.sin(Date.now() * CABLE_CONFIG.pulseSpeed) + 1) * 0.5;
|
||||
|
||||
allCables.forEach(c => {
|
||||
const cableId = c.userData.cableId;
|
||||
const state = getCableState(cableId);
|
||||
|
||||
switch (state) {
|
||||
case CABLE_STATE.LOCKED:
|
||||
c.material.opacity = CABLE_CONFIG.lockedOpacityMin + pulse * (CABLE_CONFIG.lockedOpacityMax - CABLE_CONFIG.lockedOpacityMin);
|
||||
c.material.color.setRGB(1, 1, 1);
|
||||
break;
|
||||
case CABLE_STATE.HOVERED:
|
||||
c.material.opacity = 1;
|
||||
c.material.color.setRGB(1, 1, 1);
|
||||
break;
|
||||
case CABLE_STATE.NORMAL:
|
||||
default:
|
||||
if ((lockedObjectType === 'cable' && lockedObject) || (lockedObjectType === 'satellite' && lockedSatellite)) {
|
||||
c.material.opacity = CABLE_CONFIG.otherOpacity;
|
||||
const origColor = c.userData.originalColor;
|
||||
const brightness = CABLE_CONFIG.otherBrightness;
|
||||
c.material.color.setRGB(
|
||||
((origColor >> 16) & 255) / 255 * brightness,
|
||||
((origColor >> 8) & 255) / 255 * brightness,
|
||||
(origColor & 255) / 255 * brightness
|
||||
);
|
||||
} else {
|
||||
c.material.opacity = 1;
|
||||
c.material.color.setHex(c.userData.originalColor);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('error', (e) => {
|
||||
console.error('全局错误:', e.error);
|
||||
@@ -41,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);
|
||||
@@ -49,13 +148,16 @@ export function init() {
|
||||
document.getElementById('container').appendChild(renderer.domElement);
|
||||
|
||||
addLights();
|
||||
initInfoCard();
|
||||
const earthObj = createEarth(scene);
|
||||
createClouds(scene, earthObj);
|
||||
createTerrain(scene, earthObj, simplex);
|
||||
createStars(scene);
|
||||
createGridLines(scene, earthObj);
|
||||
createSatellites(scene, earthObj);
|
||||
|
||||
setupControls(camera, renderer, scene, earthObj);
|
||||
resetView(camera);
|
||||
setupEventListeners(camera, renderer);
|
||||
|
||||
loadData();
|
||||
@@ -80,19 +182,61 @@ function addLights() {
|
||||
scene.add(pointLight);
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
let earthTexture = null;
|
||||
|
||||
async function loadData(showWhiteSphere = false) {
|
||||
if (showWhiteSphere) {
|
||||
const earth = getEarth();
|
||||
if (earth && earth.material) {
|
||||
earthTexture = earth.material.map;
|
||||
earth.material.map = null;
|
||||
earth.material.color.setHex(0xffffff);
|
||||
earth.material.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
console.log('开始加载电缆数据...');
|
||||
console.log('开始加载数据...');
|
||||
await Promise.all([
|
||||
(async () => {
|
||||
await loadGeoJSONFromPath(scene, getEarth());
|
||||
console.log('电缆数据加载完成');
|
||||
await loadLandingPoints(scene, getEarth());
|
||||
console.log('登陆点数据加载完成');
|
||||
})(),
|
||||
(async () => {
|
||||
const satCount = await loadSatellites();
|
||||
console.log(`卫星数据加载完成: ${satCount} 颗`);
|
||||
document.getElementById('satellite-count').textContent = satCount + ' 颗';
|
||||
updateSatellitePositions();
|
||||
console.log('卫星位置已更新');
|
||||
toggleSatellites(true);
|
||||
const satBtn = document.getElementById('toggle-satellites');
|
||||
if (satBtn) {
|
||||
satBtn.classList.add('active');
|
||||
satBtn.querySelector('.tooltip').textContent = '隐藏卫星';
|
||||
}
|
||||
})()
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error);
|
||||
showStatusMessage('加载数据失败: ' + error.message, 'error');
|
||||
}
|
||||
setLoading(false);
|
||||
|
||||
if (showWhiteSphere) {
|
||||
const earth = getEarth();
|
||||
if (earth && earth.material) {
|
||||
earth.material.map = earthTexture;
|
||||
earth.material.color.setHex(0xffffff);
|
||||
earth.material.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function reloadData() {
|
||||
await loadData(true);
|
||||
}
|
||||
|
||||
function setupEventListeners(camera, renderer) {
|
||||
@@ -149,60 +293,78 @@ function onMouseMove(event, camera) {
|
||||
const frontCables = getFrontFacingCables(allCableLines, camera);
|
||||
const intersects = raycaster.intersectObjects(frontCables);
|
||||
|
||||
if (hoveredCable && hoveredCable !== lockedCable) {
|
||||
const prevCableId = hoveredCable.userData.cableId;
|
||||
const prevSameCables = getCablesById(prevCableId);
|
||||
prevSameCables.forEach(c => {
|
||||
if (c.userData.originalColor !== undefined) {
|
||||
c.material.color.setHex(c.userData.originalColor);
|
||||
const hasHoveredCable = intersects.length > 0;
|
||||
let hoveredSat = null;
|
||||
let hoveredSatIndexFromIntersect = null;
|
||||
if (getShowSatellites()) {
|
||||
const satPoints = getSatellitePoints();
|
||||
if (satPoints) {
|
||||
const satIntersects = raycaster.intersectObject(satPoints);
|
||||
if (satIntersects.length > 0) {
|
||||
const satIndex = satIntersects[0].index;
|
||||
if (isSatelliteFrontFacing(satIndex, camera)) {
|
||||
hoveredSatIndexFromIntersect = satIndex;
|
||||
hoveredSat = selectSatellite(hoveredSatIndexFromIntersect);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const hasHoveredSatellite = hoveredSat && hoveredSat.properties;
|
||||
|
||||
if (hoveredCable) {
|
||||
if (!hasHoveredCable || !isSameCable(intersects[0]?.object, hoveredCable)) {
|
||||
if (!isSameCable(hoveredCable, lockedObject)) {
|
||||
setCableState(hoveredCable.userData.cableId, CABLE_STATE.NORMAL);
|
||||
}
|
||||
});
|
||||
hoveredCable = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (intersects.length > 0) {
|
||||
if (hoveredSatelliteIndex !== null && hoveredSatelliteIndex !== hoveredSatIndexFromIntersect) {
|
||||
if (hoveredSatelliteIndex !== lockedSatelliteIndex) {
|
||||
setSatelliteRingState(hoveredSatelliteIndex, 'none', null);
|
||||
}
|
||||
hoveredSatelliteIndex = null;
|
||||
}
|
||||
|
||||
if (hasHoveredCable && getShowCables()) {
|
||||
const cable = intersects[0].object;
|
||||
const cableId = cable.userData.cableId;
|
||||
const sameCables = getCablesById(cableId);
|
||||
|
||||
if (cable !== lockedCable) {
|
||||
sameCables.forEach(c => {
|
||||
c.material.color.setHex(0xffffff);
|
||||
c.material.opacity = 1;
|
||||
});
|
||||
if (!isSameCable(cable, lockedObject)) {
|
||||
hoveredCable = cable;
|
||||
setCableState(cable.userData.cableId, CABLE_STATE.HOVERED);
|
||||
} else {
|
||||
hoveredCable = cable;
|
||||
}
|
||||
|
||||
const userData = cable.userData;
|
||||
document.getElementById('cable-name').textContent =
|
||||
userData.name || userData.shortname || '未命名电缆';
|
||||
document.getElementById('cable-owner').textContent = userData.owner || '-';
|
||||
document.getElementById('cable-status').textContent = userData.status || '-';
|
||||
document.getElementById('cable-length').textContent = userData.length || '-';
|
||||
document.getElementById('cable-coords').textContent = '-';
|
||||
document.getElementById('cable-rfs').textContent = userData.rfs || '-';
|
||||
|
||||
showCableInfo(cable);
|
||||
setInfoCardNoBorder(true);
|
||||
hideTooltip();
|
||||
} else if (hasHoveredSatellite) {
|
||||
hoveredSatellite = hoveredSat;
|
||||
hoveredSatelliteIndex = hoveredSatIndexFromIntersect;
|
||||
if (hoveredSatelliteIndex !== lockedSatelliteIndex) {
|
||||
const satPositions = getSatellitePositions();
|
||||
if (satPositions && satPositions[hoveredSatelliteIndex]) {
|
||||
setSatelliteRingState(hoveredSatelliteIndex, 'hover', satPositions[hoveredSatelliteIndex].current);
|
||||
}
|
||||
}
|
||||
showSatelliteInfo(hoveredSat.properties);
|
||||
setInfoCardNoBorder(true);
|
||||
} else if (lockedObjectType === 'cable' && lockedObject) {
|
||||
showCableInfo(lockedObject);
|
||||
} else if (lockedObjectType === 'satellite' && lockedSatellite) {
|
||||
if (lockedSatelliteIndex !== null && lockedSatelliteIndex !== undefined) {
|
||||
const satPositions = getSatellitePositions();
|
||||
if (satPositions && satPositions[lockedSatelliteIndex]) {
|
||||
setSatelliteRingState(lockedSatelliteIndex, 'locked', satPositions[lockedSatelliteIndex].current);
|
||||
}
|
||||
}
|
||||
showSatelliteInfo(lockedSatellite.properties);
|
||||
} else {
|
||||
if (lockedCable && lockedCableData) {
|
||||
document.getElementById('cable-name').textContent =
|
||||
lockedCableData.name || lockedCableData.shortname || '未命名电缆';
|
||||
document.getElementById('cable-owner').textContent = lockedCableData.owner || '-';
|
||||
document.getElementById('cable-status').textContent = lockedCableData.status || '-';
|
||||
document.getElementById('cable-length').textContent = lockedCableData.length || '-';
|
||||
document.getElementById('cable-coords').textContent = '-';
|
||||
document.getElementById('cable-rfs').textContent = lockedCableData.rfs || '-';
|
||||
} else {
|
||||
document.getElementById('cable-name').textContent = '点击电缆查看详情';
|
||||
document.getElementById('cable-owner').textContent = '-';
|
||||
document.getElementById('cable-status').textContent = '-';
|
||||
document.getElementById('cable-length').textContent = '-';
|
||||
document.getElementById('cable-coords').textContent = '-';
|
||||
document.getElementById('cable-rfs').textContent = '-';
|
||||
hideInfoCard();
|
||||
}
|
||||
|
||||
const earthPoint = screenToEarthCoords(event.clientX, event.clientY, camera, earth);
|
||||
|
||||
if (earthPoint) {
|
||||
const coords = vector3ToLatLon(earthPoint);
|
||||
updateCoordinatesDisplay(coords.lat, coords.lon, coords.alt);
|
||||
@@ -211,10 +373,15 @@ function onMouseMove(event, camera) {
|
||||
showTooltip(event.clientX + 10, event.clientY + 10,
|
||||
`纬度: ${coords.lat}°<br>经度: ${coords.lon}°<br>海拔: ${coords.alt.toFixed(1)} km`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hideTooltip();
|
||||
}
|
||||
|
||||
if (isDragging) {
|
||||
if (Date.now() - dragStartTime > 500) {
|
||||
isLongDrag = true;
|
||||
}
|
||||
|
||||
const deltaX = event.clientX - previousMousePosition.x;
|
||||
const deltaY = event.clientY - previousMousePosition.y;
|
||||
|
||||
@@ -227,6 +394,8 @@ function onMouseMove(event, camera) {
|
||||
|
||||
function onMouseDown(event) {
|
||||
isDragging = true;
|
||||
dragStartTime = Date.now();
|
||||
isLongDrag = false;
|
||||
previousMousePosition = { x: event.clientX, y: event.clientY };
|
||||
document.getElementById('container').classList.add('dragging');
|
||||
hideTooltip();
|
||||
@@ -252,48 +421,92 @@ function onClick(event, camera, renderer) {
|
||||
const allCableLines = getCableLines();
|
||||
const frontCables = getFrontFacingCables(allCableLines, camera);
|
||||
const intersects = raycaster.intersectObjects(frontCables);
|
||||
const satIntersects = getShowSatellites() ? raycaster.intersectObject(getSatellitePoints()) : [];
|
||||
|
||||
if (intersects.length > 0) {
|
||||
if (lockedCable) {
|
||||
const prevCableId = lockedCable.userData.cableId;
|
||||
const prevSameCables = getCablesById(prevCableId);
|
||||
prevSameCables.forEach(c => {
|
||||
if (c.userData.originalColor !== undefined) {
|
||||
c.material.color.setHex(c.userData.originalColor);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (intersects.length > 0 && getShowCables()) {
|
||||
clearLockedObject();
|
||||
|
||||
const clickedCable = intersects[0].object;
|
||||
const cableId = clickedCable.userData.cableId;
|
||||
const sameCables = getCablesById(cableId);
|
||||
|
||||
sameCables.forEach(c => {
|
||||
c.material.color.setHex(0xffffff);
|
||||
c.material.opacity = 1;
|
||||
});
|
||||
setCableState(cableId, CABLE_STATE.LOCKED);
|
||||
|
||||
lockedCable = clickedCable;
|
||||
lockedCableData = { ...clickedCable.userData };
|
||||
lockedObject = clickedCable;
|
||||
lockedObjectType = 'cable';
|
||||
cableLockedData = { ...clickedCable.userData };
|
||||
|
||||
setAutoRotate(false);
|
||||
handleCableClick(clickedCable);
|
||||
} else if (satIntersects.length > 0) {
|
||||
const now = Date.now();
|
||||
const clickX = event.clientX;
|
||||
const clickY = event.clientY;
|
||||
|
||||
let selectedIndex;
|
||||
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 = frontFacingSats.findIndex(s => s.index === lastSatClickIndex);
|
||||
selectedIndex = frontFacingSats[(currentIdx + 1) % frontFacingSats.length].index;
|
||||
} else {
|
||||
if (lockedCable) {
|
||||
const prevCableId = lockedCable.userData.cableId;
|
||||
const prevSameCables = getCablesById(prevCableId);
|
||||
prevSameCables.forEach(c => {
|
||||
if (c.userData.originalColor !== undefined) {
|
||||
c.material.color.setHex(c.userData.originalColor);
|
||||
selectedIndex = frontFacingSats[0].index;
|
||||
}
|
||||
|
||||
lastSatClickTime = now;
|
||||
lastSatClickIndex = selectedIndex;
|
||||
lastSatClickPos = { x: clickX, y: clickY };
|
||||
|
||||
const sat = selectSatellite(selectedIndex);
|
||||
|
||||
if (sat && sat.properties) {
|
||||
clearLockedObject();
|
||||
|
||||
lockedObject = sat;
|
||||
lockedObjectType = 'satellite';
|
||||
lockedSatellite = sat;
|
||||
lockedSatelliteIndex = selectedIndex;
|
||||
window.lockedSatelliteIndex = selectedIndex;
|
||||
showPredictedOrbit(sat);
|
||||
setAutoRotate(false);
|
||||
|
||||
const satPositions = getSatellitePositions();
|
||||
if (satPositions && satPositions[selectedIndex]) {
|
||||
setSatelliteRingState(selectedIndex, 'locked', satPositions[selectedIndex].current);
|
||||
}
|
||||
|
||||
const props = sat.properties;
|
||||
|
||||
const meanMotion = props.mean_motion || 0;
|
||||
const period = meanMotion > 0 ? (1440 / meanMotion).toFixed(1) : '-';
|
||||
|
||||
const ecc = props.eccentricity || 0;
|
||||
const earthRadius = 6371;
|
||||
const perigee = (earthRadius * (1 - ecc)).toFixed(0);
|
||||
const apogee = (earthRadius * (1 + ecc)).toFixed(0);
|
||||
|
||||
showInfoCard('satellite', {
|
||||
name: props.name,
|
||||
norad_id: props.norad_cat_id,
|
||||
inclination: props.inclination ? props.inclination.toFixed(2) : '-',
|
||||
period: period,
|
||||
perigee: perigee,
|
||||
apogee: apogee
|
||||
});
|
||||
lockedCable = null;
|
||||
lockedCableData = null;
|
||||
|
||||
showStatusMessage('已选择: ' + props.name, 'info');
|
||||
}
|
||||
} else {
|
||||
if (!isLongDrag) {
|
||||
clearLockedObject();
|
||||
setAutoRotate(true);
|
||||
clearCableSelection();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
@@ -304,34 +517,41 @@ function animate() {
|
||||
earth.rotation.y += CONFIG.rotationSpeed;
|
||||
}
|
||||
|
||||
if (lockedCable) {
|
||||
const pulse = (Math.sin(Date.now() * 0.003) + 1) * 0.5;
|
||||
const glowIntensity = 0.7 + pulse * 0.3;
|
||||
const cableId = lockedCable.userData.cableId;
|
||||
const sameCables = getCablesById(cableId);
|
||||
sameCables.forEach(c => {
|
||||
c.material.opacity = 0.6 + pulse * 0.4;
|
||||
c.material.color.setRGB(glowIntensity, glowIntensity, glowIntensity);
|
||||
});
|
||||
applyCableVisualState();
|
||||
|
||||
if (lockedObjectType === 'cable' && lockedObject) {
|
||||
applyLandingPointVisualState(lockedObject.userData.name, false);
|
||||
} else if (lockedObjectType === 'satellite' && lockedSatellite) {
|
||||
applyLandingPointVisualState(null, true);
|
||||
} else {
|
||||
resetLandingPointVisualState();
|
||||
}
|
||||
|
||||
updateSatellitePositions(16);
|
||||
|
||||
const satPositions = getSatellitePositions();
|
||||
|
||||
// 更新呼吸动画相位
|
||||
updateBreathingPhase();
|
||||
|
||||
if (lockedObjectType === 'satellite' && lockedSatelliteIndex !== null) {
|
||||
if (satPositions && satPositions[lockedSatelliteIndex]) {
|
||||
updateLockedRingPosition(satPositions[lockedSatelliteIndex].current);
|
||||
}
|
||||
} else if (hoveredSatelliteIndex !== null && satPositions && satPositions[hoveredSatelliteIndex]) {
|
||||
updateHoverRingPosition(satPositions[hoveredSatelliteIndex].current);
|
||||
}
|
||||
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
window.clearLockedCable = function() {
|
||||
if (lockedCable) {
|
||||
const cableId = lockedCable.userData.cableId;
|
||||
const sameCables = getCablesById(cableId);
|
||||
sameCables.forEach(c => {
|
||||
if (c.userData.originalColor !== undefined) {
|
||||
c.material.color.setHex(c.userData.originalColor);
|
||||
c.material.opacity = 1.0;
|
||||
}
|
||||
});
|
||||
lockedCable = null;
|
||||
lockedCableData = null;
|
||||
}
|
||||
clearCableSelection();
|
||||
clearLockedObject();
|
||||
};
|
||||
|
||||
window.clearSelection = function() {
|
||||
hideInfoCard();
|
||||
window.clearLockedCable();
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
668
frontend/public/earth/js/satellites.js
Normal file
@@ -0,0 +1,668 @@
|
||||
// satellites.js - Satellite visualization module with real SGP4 positions and animations
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { twoline2satrec, sgp4, propagate, degreesToRadians, radiansToDegrees, eciToGeodetic } from 'satellite.js';
|
||||
import { CONFIG, SATELLITE_CONFIG } from './constants.js';
|
||||
|
||||
let satellitePoints = null;
|
||||
let satelliteTrails = null;
|
||||
let satelliteData = [];
|
||||
let showSatellites = false;
|
||||
let showTrails = true;
|
||||
let animationTime = 0;
|
||||
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 createDotTexture() {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = DOT_TEXTURE_SIZE;
|
||||
canvas.height = DOT_TEXTURE_SIZE;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const center = DOT_TEXTURE_SIZE / 2;
|
||||
const radius = center - 2;
|
||||
|
||||
const gradient = ctx.createRadialGradient(center, center, 0, center, center, radius);
|
||||
gradient.addColorStop(0, 'rgba(255, 255, 255, 1)');
|
||||
gradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.8)');
|
||||
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.arc(center, center, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
function createRingTexture(innerRadius, outerRadius, color = '#ffffff') {
|
||||
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;
|
||||
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.arc(center, center, (innerRadius + outerRadius) / 2, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
export function createSatellites(scene, earthObj) {
|
||||
initSatelliteScene(scene, earthObj);
|
||||
|
||||
const positions = new Float32Array(MAX_SATELLITES * 3);
|
||||
const colors = new Float32Array(MAX_SATELLITES * 3);
|
||||
|
||||
const dotTexture = createDotTexture();
|
||||
|
||||
const pointsGeometry = new THREE.BufferGeometry();
|
||||
pointsGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
pointsGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||
|
||||
const pointsMaterial = new THREE.PointsMaterial({
|
||||
size: SATELLITE_CONFIG.dotSize,
|
||||
map: dotTexture,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
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);
|
||||
const trailColors = new Float32Array(MAX_SATELLITES * TRAIL_LENGTH * 3);
|
||||
|
||||
const trailGeometry = new THREE.BufferGeometry();
|
||||
trailGeometry.setAttribute('position', new THREE.BufferAttribute(trailPositions, 3));
|
||||
trailGeometry.setAttribute('color', new THREE.BufferAttribute(trailColors, 3));
|
||||
|
||||
const trailMaterial = new THREE.LineBasicMaterial({
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.3,
|
||||
blending: THREE.AdditiveBlending
|
||||
});
|
||||
|
||||
satelliteTrails = new THREE.LineSegments(trailGeometry, trailMaterial);
|
||||
satelliteTrails.visible = false;
|
||||
satelliteTrails.userData = { type: 'satelliteTrails' };
|
||||
earthObj.add(satelliteTrails);
|
||||
|
||||
satellitePositions = [];
|
||||
for (let i = 0; i < MAX_SATELLITES; i++) {
|
||||
satellitePositions.push({
|
||||
current: new THREE.Vector3(),
|
||||
trail: [],
|
||||
trailIndex: 0,
|
||||
trailCount: 0
|
||||
});
|
||||
}
|
||||
|
||||
return satellitePoints;
|
||||
}
|
||||
|
||||
function computeSatellitePosition(satellite, time) {
|
||||
try {
|
||||
const props = satellite.properties;
|
||||
if (!props || !props.norad_cat_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const noradId = props.norad_cat_id;
|
||||
const inclination = props.inclination || 53;
|
||||
const raan = props.raan || 0;
|
||||
const eccentricity = props.eccentricity || 0.0001;
|
||||
const argOfPerigee = props.arg_of_perigee || 0;
|
||||
const meanAnomaly = props.mean_anomaly || 0;
|
||||
const meanMotion = props.mean_motion || 15;
|
||||
const epoch = props.epoch || '';
|
||||
|
||||
// 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);
|
||||
|
||||
// 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`;
|
||||
|
||||
const satrec = twoline2satrec(tleLine1, tleLine2);
|
||||
if (!satrec || satrec.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const positionAndVelocity = propagate(satrec, time);
|
||||
if (!positionAndVelocity || !positionAndVelocity.position) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const x = positionAndVelocity.position.x;
|
||||
const y = positionAndVelocity.position.y;
|
||||
const z = positionAndVelocity.position.z;
|
||||
|
||||
if (!x || !y || !z) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const r = Math.sqrt(x * x + y * y + z * z);
|
||||
const earthRadius = 6371;
|
||||
const displayRadius = CONFIG.earthRadius * (earthRadius / 6371) * 1.05;
|
||||
|
||||
const scale = displayRadius / r;
|
||||
|
||||
return new THREE.Vector3(x * scale, y * scale, z * scale);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function generateFallbackPosition(satellite, index, total) {
|
||||
const radius = CONFIG.earthRadius + 5;
|
||||
|
||||
const noradId = satellite.properties?.norad_cat_id || index;
|
||||
const inclination = satellite.properties?.inclination || 53;
|
||||
const raan = satellite.properties?.raan || 0;
|
||||
const meanAnomaly = satellite.properties?.mean_anomaly || 0;
|
||||
|
||||
const hash = String(noradId).split('').reduce((a, b) => a + b.charCodeAt(0), 0);
|
||||
const randomOffset = (hash % 1000) / 1000;
|
||||
|
||||
const normalizedIndex = index / total;
|
||||
const theta = normalizedIndex * Math.PI * 2 * 10 + (raan * Math.PI / 180);
|
||||
const phi = (inclination * Math.PI / 180) + (meanAnomaly * Math.PI / 180 * 0.1);
|
||||
|
||||
const adjustedPhi = Math.abs(phi % Math.PI);
|
||||
const adjustedTheta = theta + randomOffset * Math.PI * 2;
|
||||
|
||||
const x = radius * Math.sin(adjustedPhi) * Math.cos(adjustedTheta);
|
||||
const y = radius * Math.cos(adjustedPhi);
|
||||
const z = radius * Math.sin(adjustedPhi) * Math.sin(adjustedTheta);
|
||||
|
||||
return new THREE.Vector3(x, y, z);
|
||||
}
|
||||
|
||||
export async function loadSatellites() {
|
||||
try {
|
||||
const response = await fetch(SATELLITE_API);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
satelliteData = data.features || [];
|
||||
|
||||
console.log(`Loaded ${satelliteData.length} satellites`);
|
||||
return satelliteData.length;
|
||||
} catch (error) {
|
||||
console.error('Failed to load satellites:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function updateSatellitePositions(deltaTime = 0) {
|
||||
if (!satellitePoints || satelliteData.length === 0) return;
|
||||
|
||||
animationTime += deltaTime * 0.001;
|
||||
|
||||
const positions = satellitePoints.geometry.attributes.position.array;
|
||||
const colors = satellitePoints.geometry.attributes.color.array;
|
||||
|
||||
const trailPositions = satelliteTrails.geometry.attributes.position.array;
|
||||
const trailColors = satelliteTrails.geometry.attributes.color.array;
|
||||
|
||||
const baseTime = new Date();
|
||||
const count = Math.min(satelliteData.length, MAX_SATELLITES);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const satellite = satelliteData[i];
|
||||
const props = satellite.properties;
|
||||
|
||||
const timeOffset = (i / count) * 2 * Math.PI * 0.1;
|
||||
const adjustedTime = new Date(baseTime.getTime() + timeOffset * 1000 * 60 * 10);
|
||||
|
||||
let pos = computeSatellitePosition(satellite, adjustedTime);
|
||||
|
||||
if (!pos) {
|
||||
pos = generateFallbackPosition(satellite, i, count);
|
||||
}
|
||||
|
||||
satellitePositions[i].current.copy(pos);
|
||||
|
||||
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;
|
||||
positions[i * 3 + 1] = pos.y;
|
||||
positions[i * 3 + 2] = pos.z;
|
||||
|
||||
const inclination = props?.inclination || 53;
|
||||
const name = props?.name || '';
|
||||
const isStarlink = name.includes('STARLINK');
|
||||
const isGeo = inclination > 20 && inclination < 30;
|
||||
const isIridium = name.includes('IRIDIUM');
|
||||
|
||||
let r, g, b;
|
||||
if (isStarlink) {
|
||||
r = 0.0; g = 0.9; b = 1.0;
|
||||
} else if (isGeo) {
|
||||
r = 1.0; g = 0.8; b = 0.0;
|
||||
} else if (isIridium) {
|
||||
r = 1.0; g = 0.5; b = 0.0;
|
||||
} else if (inclination > 50 && inclination < 70) {
|
||||
r = 0.0; g = 1.0; b = 0.3;
|
||||
} else {
|
||||
r = 1.0; g = 1.0; b = 1.0;
|
||||
}
|
||||
|
||||
colors[i * 3] = r;
|
||||
colors[i * 3 + 1] = g;
|
||||
colors[i * 3 + 2] = b;
|
||||
|
||||
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 < 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;
|
||||
trailPositions[trailIdx + 2] = pos.z;
|
||||
trailColors[trailIdx] = 0;
|
||||
trailColors[trailIdx + 1] = 0;
|
||||
trailColors[trailIdx + 2] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = count; i < MAX_SATELLITES; i++) {
|
||||
positions[i * 3] = 0;
|
||||
positions[i * 3 + 1] = 0;
|
||||
positions[i * 3 + 2] = 0;
|
||||
|
||||
for (let j = 0; j < TRAIL_LENGTH; j++) {
|
||||
const trailIdx = (i * TRAIL_LENGTH + j) * 3;
|
||||
trailPositions[trailIdx] = 0;
|
||||
trailPositions[trailIdx + 1] = 0;
|
||||
trailPositions[trailIdx + 2] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
satellitePoints.geometry.attributes.position.needsUpdate = true;
|
||||
satellitePoints.geometry.attributes.color.needsUpdate = true;
|
||||
satellitePoints.geometry.setDrawRange(0, count);
|
||||
|
||||
satelliteTrails.geometry.attributes.position.needsUpdate = true;
|
||||
satelliteTrails.geometry.attributes.color.needsUpdate = true;
|
||||
}
|
||||
|
||||
export function toggleSatellites(visible) {
|
||||
showSatellites = visible;
|
||||
if (satellitePoints) {
|
||||
satellitePoints.visible = visible;
|
||||
}
|
||||
if (satelliteTrails) {
|
||||
satelliteTrails.visible = visible && showTrails;
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleTrails(visible) {
|
||||
showTrails = visible;
|
||||
if (satelliteTrails) {
|
||||
satelliteTrails.visible = visible && showSatellites;
|
||||
}
|
||||
}
|
||||
|
||||
export function getShowSatellites() {
|
||||
return showSatellites;
|
||||
}
|
||||
|
||||
export function getSatelliteCount() {
|
||||
return satelliteData.length;
|
||||
}
|
||||
|
||||
export function getSatelliteAt(index) {
|
||||
if (index >= 0 && index < satelliteData.length) {
|
||||
return satelliteData[index];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getSatelliteData() {
|
||||
return satelliteData;
|
||||
}
|
||||
|
||||
export function selectSatellite(index) {
|
||||
selectedSatellite = index;
|
||||
return getSatelliteAt(index);
|
||||
}
|
||||
|
||||
export function getSatellitePoints() {
|
||||
return satellitePoints;
|
||||
}
|
||||
|
||||
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 worldSatPos = satPos.clone().applyMatrix4(earthObjRef.matrixWorld);
|
||||
const toCamera = new THREE.Vector3().subVectors(camera.position, earthObjRef.position).normalize();
|
||||
const toSat = new THREE.Vector3().subVectors(worldSatPos, earthObjRef.position).normalize();
|
||||
|
||||
return toCamera.dot(toSat) > 0;
|
||||
}
|
||||
|
||||
let earthObjRef = null;
|
||||
let sceneRef = null;
|
||||
|
||||
export function showHoverRing(position, isLocked = false) {
|
||||
if (!sceneRef || !earthObjRef) return;
|
||||
|
||||
const ringTexture = createRingTexture(8, 12, isLocked ? '#ffcc00' : '#ffffff');
|
||||
const spriteMaterial = new THREE.SpriteMaterial({
|
||||
map: ringTexture,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
depthTest: false,
|
||||
sizeAttenuation: false
|
||||
});
|
||||
|
||||
const ringSize = SATELLITE_CONFIG.ringSize;
|
||||
const sprite = new THREE.Sprite(spriteMaterial);
|
||||
sprite.position.copy(position);
|
||||
|
||||
const camera = window.camera;
|
||||
const cameraDistance = camera ? camera.position.distanceTo(position) : 400;
|
||||
const scale = ringSize;
|
||||
sprite.scale.set(scale, scale, 1);
|
||||
|
||||
earthObjRef.add(sprite);
|
||||
|
||||
if (isLocked) {
|
||||
if (lockedRingSprite) {
|
||||
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);
|
||||
}
|
||||
hoverRingSprite = sprite;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (hoverRingSprite) {
|
||||
earthObjRef.remove(hoverRingSprite);
|
||||
hoverRingSprite = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function hideLockedRing() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
export function setSatelliteRingState(index, state, position) {
|
||||
switch (state) {
|
||||
case 'hover':
|
||||
hideHoverRings();
|
||||
showHoverRing(position, false);
|
||||
break;
|
||||
case 'locked':
|
||||
hideHoverRings();
|
||||
showHoverRing(position, true);
|
||||
break;
|
||||
case 'none':
|
||||
hideHoverRings();
|
||||
hideLockedRing();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const points = calculatePredictedOrbit(satellite, periodSeconds);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -22,22 +22,14 @@ export function updateCoordinatesDisplay(lat, lon, alt = 0) {
|
||||
|
||||
// Update zoom display
|
||||
export function updateZoomDisplay(zoomLevel, distance) {
|
||||
document.getElementById('zoom-value').textContent = zoomLevel.toFixed(1) + 'x';
|
||||
document.getElementById('zoom-level').textContent = '缩放: ' + zoomLevel.toFixed(1) + 'x';
|
||||
document.getElementById('zoom-slider').value = zoomLevel;
|
||||
const percent = Math.round(zoomLevel * 100);
|
||||
document.getElementById('zoom-value').textContent = percent + '%';
|
||||
document.getElementById('zoom-level').textContent = '缩放: ' + percent + '%';
|
||||
const slider = document.getElementById('zoom-slider');
|
||||
if (slider) slider.value = zoomLevel;
|
||||
document.getElementById('camera-distance').textContent = distance + ' km';
|
||||
}
|
||||
|
||||
// Update cable details
|
||||
export function updateCableDetails(cable) {
|
||||
document.getElementById('cable-name').textContent = cable.name || 'Unknown';
|
||||
document.getElementById('cable-owner').textContent = cable.owner || '-';
|
||||
document.getElementById('cable-status').textContent = cable.status || '-';
|
||||
document.getElementById('cable-length').textContent = cable.length || '-';
|
||||
document.getElementById('cable-coords').textContent = cable.coords || '-';
|
||||
document.getElementById('cable-rfs').textContent = cable.rfs || '-';
|
||||
}
|
||||
|
||||
// Update earth stats
|
||||
export function updateEarthStats(stats) {
|
||||
document.getElementById('cable-count').textContent = stats.cableCount || 0;
|
||||
|
||||
@@ -20,7 +20,11 @@ export function latLonToVector3(lat, lon, radius = CONFIG.earthRadius) {
|
||||
export function vector3ToLatLon(vector) {
|
||||
const radius = Math.sqrt(vector.x * vector.x + vector.y * vector.y + vector.z * vector.z);
|
||||
const lat = 90 - (Math.acos(vector.y / radius) * 180 / Math.PI);
|
||||
const lon = (Math.atan2(vector.z, -vector.x) * 180 / Math.PI) - 180;
|
||||
|
||||
let lon = (Math.atan2(vector.z, -vector.x) * 180 / Math.PI) - 180;
|
||||
|
||||
while (lon <= -180) lon += 360;
|
||||
while (lon > 180) lon -= 360;
|
||||
|
||||
return {
|
||||
lat: parseFloat(lat.toFixed(4)),
|
||||
@@ -30,26 +34,43 @@ export function vector3ToLatLon(vector) {
|
||||
}
|
||||
|
||||
// Convert screen coordinates to Earth surface 3D coordinates
|
||||
export function screenToEarthCoords(x, y, camera, earth) {
|
||||
export function screenToEarthCoords(clientX, clientY, camera, earth, domElement = document.body) {
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const mouse = new THREE.Vector2(
|
||||
(x / window.innerWidth) * 2 - 1,
|
||||
-(y / window.innerHeight) * 2 + 1
|
||||
);
|
||||
const mouse = new THREE.Vector2();
|
||||
|
||||
if (domElement === document.body) {
|
||||
mouse.x = (clientX / window.innerWidth) * 2 - 1;
|
||||
mouse.y = -(clientY / window.innerHeight) * 2 + 1;
|
||||
} else {
|
||||
const rect = domElement.getBoundingClientRect();
|
||||
mouse.x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
||||
mouse.y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
||||
}
|
||||
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
const intersects = raycaster.intersectObject(earth);
|
||||
|
||||
if (intersects.length > 0) {
|
||||
return intersects[0].point;
|
||||
const localPoint = intersects[0].point.clone();
|
||||
earth.worldToLocal(localPoint);
|
||||
return localPoint;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate simplified distance between two points
|
||||
export function calculateDistance(lat1, lon1, lat2, lon2) {
|
||||
const dx = lon2 - lon1;
|
||||
const dy = lat2 - lat1;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
// Calculate accurate spherical distance between two points (Haversine formula)
|
||||
export function calculateDistance(lat1, lon1, lat2, lon2, radius = CONFIG.earthRadius) {
|
||||
const toRad = (angle) => (angle * Math.PI) / 180;
|
||||
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
|
||||
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
|
||||
Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
return radius * c;
|
||||
}
|
||||
|
||||
@@ -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,6 +24,7 @@ function App() {
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/datasources" element={<DataSources />} />
|
||||
<Route path="/data" element={<DataList />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
|
||||
@@ -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: <DashboardOutlined />, label: <Link to="/">仪表盘</Link> },
|
||||
@@ -35,19 +36,28 @@ function AppLayout({ children }: AppLayoutProps) {
|
||||
return (
|
||||
<Layout className="dashboard-layout">
|
||||
<Sider
|
||||
width={240}
|
||||
collapsedWidth={80}
|
||||
width={208}
|
||||
collapsedWidth={72}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
onCollapse={setCollapsed}
|
||||
className="dashboard-sider"
|
||||
>
|
||||
<div style={{ height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{collapsed ? (
|
||||
<Text strong style={{ color: 'white', fontSize: 20 }}>🌏</Text>
|
||||
) : (
|
||||
<div className="dashboard-sider-inner">
|
||||
<div>
|
||||
<div className={`dashboard-brand ${collapsed ? 'dashboard-brand--collapsed' : ''}`} onClick={collapsed ? () => setCollapsed(false) : undefined}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setCollapsed(!collapsed)
|
||||
}}
|
||||
className={`dashboard-sider-toggle ${collapsed ? 'dashboard-sider-toggle--collapsed' : ''}`}
|
||||
/>
|
||||
{!collapsed ? (
|
||||
<Text strong style={{ color: 'white', fontSize: 18 }}>智能星球</Text>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
@@ -55,22 +65,26 @@ function AppLayout({ children }: AppLayoutProps) {
|
||||
selectedKeys={[location.pathname]}
|
||||
items={menuItems}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header className="dashboard-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 16px' }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
style={{ fontSize: 16 }}
|
||||
/>
|
||||
<Text strong>欢迎, {user?.username}</Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Button type="link" danger onClick={logout}>退出登录</Button>
|
||||
</div>
|
||||
</Header>
|
||||
<Content className="dashboard-content" style={{ padding: 24, minHeight: '100%', overflow: 'auto' }}>
|
||||
{children}
|
||||
|
||||
{showBanner && !collapsed ? (
|
||||
<div className="dashboard-sider-banner">
|
||||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Text className="dashboard-sider-banner-label">当前账号</Text>
|
||||
<Text strong className="dashboard-sider-banner-value">{user?.username}</Text>
|
||||
</div>
|
||||
<Button type="primary" danger ghost block onClick={logout}>
|
||||
退出登录
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Sider>
|
||||
<Layout style={{ minWidth: 0, minHeight: 0, height: '100%' }}>
|
||||
<Content className="dashboard-content" style={{ padding: 24, minHeight: 0, height: '100%' }}>
|
||||
<div className="dashboard-content-inner">{children}</div>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
|
||||
@@ -14,7 +14,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
},
|
||||
}}
|
||||
>
|
||||
<BrowserRouter>
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
|
||||
@@ -174,7 +174,9 @@ function Alerts() {
|
||||
title="告警列表"
|
||||
extra={<Button icon={<ReloadOutlined />} onClick={fetchAlerts}>刷新</Button>}
|
||||
>
|
||||
<Table columns={columns} dataSource={alerts} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content' }} tableLayout="fixed" />
|
||||
<div className="table-scroll-region">
|
||||
<Table columns={columns} dataSource={alerts} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content', y: 'calc(100% - 360px)' }} tableLayout="fixed" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Layout, Menu, Card, Row, Col, Statistic, Typography, Button, Tag, Spin } from 'antd'
|
||||
import { Card, Row, Col, Statistic, Typography, Button, Tag, Spin, Space } from 'antd'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
BarChartOutlined,
|
||||
AlertOutlined,
|
||||
WifiOutlined,
|
||||
DisconnectOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
|
||||
const { Header, Sider, Content } = Layout
|
||||
const { Title, Text } = Typography
|
||||
|
||||
interface Stats {
|
||||
@@ -31,7 +27,7 @@ interface Stats {
|
||||
}
|
||||
|
||||
function Dashboard() {
|
||||
const { user, logout, token, clearAuth } = useAuthStore()
|
||||
const { token, clearAuth } = useAuthStore()
|
||||
const [stats, setStats] = useState<Stats | null>(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: <DashboardOutlined />, label: <Link to="/">仪表盘</Link> },
|
||||
{ key: '/datasources', icon: <DatabaseOutlined />, label: <Link to="/datasources">数据源</Link> },
|
||||
{ key: '/data', icon: <BarChartOutlined />, label: <Link to="/data">采集数据</Link> },
|
||||
{ key: '/users', icon: <UserOutlined />, label: <Link to="/users">用户管理</Link> },
|
||||
{ key: '/settings', icon: <SettingOutlined />, label: '系统配置' },
|
||||
]
|
||||
|
||||
if (loading && !stats) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
@@ -143,81 +121,78 @@ function Dashboard() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout className="dashboard-layout">
|
||||
<Sider width={240} className="dashboard-sider">
|
||||
<div style={{ height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Title level={4} style={{ color: 'white', margin: 0 }}>智能星球</Title>
|
||||
<AppLayout>
|
||||
<div className="dashboard-page">
|
||||
<div className="dashboard-page__header">
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0 }}>仪表盘</Title>
|
||||
<Text type="secondary">系统总览与实时态势</Text>
|
||||
</div>
|
||||
<Menu theme="dark" mode="inline" defaultSelectedKeys={['/']} items={menuItems} />
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header className="dashboard-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text strong>欢迎, {user?.username}</Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Space wrap className="dashboard-page__actions">
|
||||
{wsConnected ? (
|
||||
<Tag icon={<WifiOutlined />} color="success">实时连接</Tag>
|
||||
<Tag className="dashboard-status-tag" icon={<WifiOutlined />} color="success">实时连接</Tag>
|
||||
) : (
|
||||
<Tag icon={<DisconnectOutlined />} color="default">离线</Tag>
|
||||
<Tag className="dashboard-status-tag" icon={<DisconnectOutlined />} color="default">离线</Tag>
|
||||
)}
|
||||
<Button type="link" danger onClick={handleLogout}>退出登录</Button>
|
||||
<Button type="link" onClick={handleClearAuth}>清除认证</Button>
|
||||
<Button type="link" icon={<ReloadOutlined />} onClick={handleRetry}>刷新</Button>
|
||||
<Button className="dashboard-refresh-button" icon={<ReloadOutlined />} onClick={handleRetry}>刷新</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Header>
|
||||
<Content className="dashboard-content">
|
||||
|
||||
{error && (
|
||||
<Card style={{ marginBottom: 16, borderColor: '#ff4d4f' }}>
|
||||
<Card style={{ borderColor: '#ff4d4f' }}>
|
||||
<Text style={{ color: '#ff4d4f' }}>{error}</Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={6}>
|
||||
<Col xs={24} sm={12} xl={6}>
|
||||
<Card>
|
||||
<Statistic title="数据源总数" value={stats?.total_datasources || 0} prefix={<DatabaseOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Col xs={24} sm={12} xl={6}>
|
||||
<Card>
|
||||
<Statistic title="活跃数据源" value={stats?.active_datasources || 0} valueStyle={{ color: '#52c41a' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Col xs={24} sm={12} xl={6}>
|
||||
<Card>
|
||||
<Statistic title="今日任务" value={stats?.tasks_today || 0} prefix={<BarChartOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Col xs={24} sm={12} xl={6}>
|
||||
<Card>
|
||||
<Statistic title="成功率" value={stats?.success_rate || 0} suffix="%" valueStyle={{ color: '#1890ff' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col span={8}>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} md={8}>
|
||||
<Card>
|
||||
<Statistic title="严重告警" value={stats?.alerts?.critical || 0} valueStyle={{ color: '#ff4d4f' }} prefix={<AlertOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Col xs={24} md={8}>
|
||||
<Card>
|
||||
<Statistic title="警告" value={stats?.alerts?.warning || 0} valueStyle={{ color: '#faad14' }} prefix={<AlertOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Col xs={24} md={8}>
|
||||
<Card>
|
||||
<Statistic title="提示" value={stats?.alerts?.info || 0} valueStyle={{ color: '#1890ff' }} prefix={<AlertOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{stats?.last_updated && (
|
||||
<div style={{ marginTop: 16, textAlign: 'center', color: '#8c8c8c' }}>
|
||||
<div style={{ textAlign: 'center', color: '#8c8c8c' }}>
|
||||
最后更新: {new Date(stats.last_updated).toLocaleString('zh-CN')}
|
||||
{wsConnected && <Tag color="green" style={{ marginLeft: 8 }}>实时同步中</Tag>}
|
||||
{wsConnected && <Tag className="dashboard-status-tag" color="green" style={{ marginLeft: 8 }}>实时同步中</Tag>}
|
||||
</div>
|
||||
)}
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Table, Tag, Space, message, Button, Form, Input, Select,
|
||||
Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber
|
||||
@@ -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
|
||||
@@ -67,6 +79,10 @@ function DataSources() {
|
||||
const [recordCount, setRecordCount] = useState<number>(0)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<any>(null)
|
||||
const builtinTableRegionRef = useRef<HTMLDivElement | null>(null)
|
||||
const customTableRegionRef = useRef<HTMLDivElement | null>(null)
|
||||
const [builtinTableHeight, setBuiltinTableHeight] = useState(360)
|
||||
const [customTableHeight, setCustomTableHeight] = useState(360)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const fetchData = async () => {
|
||||
@@ -85,87 +101,114 @@ function DataSources() {
|
||||
}
|
||||
}
|
||||
|
||||
const [taskProgress, setTaskProgress] = useState<Record<number, { progress: number; is_running: boolean }>>({})
|
||||
const [taskProgress, setTaskProgress] = useState<Record<number, TaskTrackerState>>({})
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const runningSources = builtInSources.filter(s => s.is_running)
|
||||
if (runningSources.length === 0) return
|
||||
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 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<number, { progress: number; is_running: boolean }> = {}
|
||||
const updates: Record<number, TaskTrackerState> = {}
|
||||
|
||||
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 {
|
||||
@@ -203,7 +246,7 @@ function DataSources() {
|
||||
name: data.name,
|
||||
description: null,
|
||||
source_type: data.collector_class,
|
||||
endpoint: '',
|
||||
endpoint: data.endpoint || '',
|
||||
auth_type: 'none',
|
||||
headers: {},
|
||||
config: {},
|
||||
@@ -314,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 },
|
||||
{
|
||||
@@ -348,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<string, string> = {
|
||||
queued: '排队中',
|
||||
fetching: '抓取中',
|
||||
transforming: '处理中',
|
||||
saving: '保存中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
}
|
||||
|
||||
if (isTaskRunning) {
|
||||
const pct = taskState?.progress ?? record.progress ?? 0
|
||||
const phase = taskState?.phase || record.phase || 'queued'
|
||||
return (
|
||||
<Tag color="blue">
|
||||
采集中 {Math.round(pct)}%
|
||||
<Space size={6} wrap>
|
||||
<Tag color={record.is_active ? 'green' : 'red'}>{record.is_active ? '运行中' : '已暂停'}</Tag>
|
||||
<Tag color="processing">
|
||||
{phaseLabelMap[phase] || phase}
|
||||
{pct > 0 ? ` ${Math.round(pct)}%` : ''}
|
||||
</Tag>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
return <Tag color={record.is_active ? 'green' : 'red'}>{record.is_active ? '运行中' : '已暂停'}</Tag>
|
||||
@@ -394,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 ? (
|
||||
<Tooltip title={endpoint}>
|
||||
<a href={endpoint} target="_blank" rel="noreferrer">
|
||||
{endpoint}
|
||||
</a>
|
||||
</Tooltip>
|
||||
) : '-'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'is_active',
|
||||
@@ -440,16 +536,20 @@ function DataSources() {
|
||||
key: 'builtin',
|
||||
label: '内置数据源',
|
||||
children: (
|
||||
<div className="page-shell__body">
|
||||
<div ref={builtinTableRegionRef} className="table-scroll-region data-source-table-region">
|
||||
<Table
|
||||
columns={builtinColumns}
|
||||
dataSource={builtInSources}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 800, y: 'auto' }}
|
||||
scroll={{ x: 800, y: builtinTableHeight }}
|
||||
tableLayout="fixed"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -460,35 +560,47 @@ function DataSources() {
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<>
|
||||
<div style={{ marginBottom: 16, textAlign: 'right' }}>
|
||||
<div className="page-shell__body data-source-custom-tab">
|
||||
<div className="data-source-custom-toolbar">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openDrawer()}>
|
||||
添加数据源
|
||||
</Button>
|
||||
</div>
|
||||
{customSources.length === 0 ? (
|
||||
<div className="data-source-empty-state">
|
||||
<Empty description="暂无自定义数据源" />
|
||||
</div>
|
||||
) : (
|
||||
<div ref={customTableRegionRef} className="table-scroll-region data-source-table-region">
|
||||
<Table
|
||||
columns={customColumns}
|
||||
dataSource={customSources}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 600, y: 'auto' }}
|
||||
scroll={{ x: 900, y: customTableHeight }}
|
||||
tableLayout="fixed"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<h2>数据源管理</h2>
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
|
||||
<div className="page-shell">
|
||||
<div className="page-shell__header">
|
||||
<h2 style={{ margin: 0 }}>数据源管理</h2>
|
||||
</div>
|
||||
<div className="page-shell__body">
|
||||
<div className="data-source-tabs-shell">
|
||||
<Tabs className="data-source-tabs" activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Drawer
|
||||
title={editingConfig ? '编辑数据源' : '添加数据源'}
|
||||
@@ -767,6 +879,19 @@ function DataSources() {
|
||||
<Input value={viewingSource.frequency} disabled />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="采集源 API 链接">
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input value={viewingSource.endpoint || '-'} readOnly />
|
||||
<Tooltip title={viewingSource.endpoint ? '复制采集源 API 链接' : '当前没有可复制的采集源 API 链接'}>
|
||||
<Button
|
||||
disabled={!viewingSource.endpoint}
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => viewingSource.endpoint && handleCopyLink(viewingSource.endpoint, '采集源 API 链接已复制')}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
|
||||
<Collapse
|
||||
items={[
|
||||
{
|
||||
|
||||
@@ -3,9 +3,10 @@ function Earth() {
|
||||
<iframe
|
||||
src="/earth/index.html"
|
||||
style={{
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
display: 'block',
|
||||
}}
|
||||
title="3D Earth"
|
||||
/>
|
||||
|
||||
@@ -1,37 +1,23 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
Layout,
|
||||
Menu,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Typography,
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Switch,
|
||||
Select,
|
||||
Divider,
|
||||
message,
|
||||
Spin,
|
||||
Tabs,
|
||||
InputNumber,
|
||||
message,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import {
|
||||
SettingOutlined,
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
UserOutlined,
|
||||
BellOutlined,
|
||||
SafetyOutlined,
|
||||
SaveOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import axios from 'axios'
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
|
||||
const { Header, Sider, Content } = Layout
|
||||
const { Title, Text } = Typography
|
||||
const { TabPane } = Tabs
|
||||
|
||||
interface SystemSettings {
|
||||
system_name: string
|
||||
@@ -55,366 +41,339 @@ interface SecuritySettings {
|
||||
password_policy: string
|
||||
}
|
||||
|
||||
function Settings() {
|
||||
const { user, logout, token, clearAuth } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [systemSettings, setSystemSettings] = useState<SystemSettings>({
|
||||
system_name: '智能星球',
|
||||
refresh_interval: 60,
|
||||
auto_refresh: true,
|
||||
data_retention_days: 30,
|
||||
max_concurrent_tasks: 5,
|
||||
})
|
||||
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings>({
|
||||
email_enabled: false,
|
||||
email_address: '',
|
||||
critical_alerts: true,
|
||||
warning_alerts: true,
|
||||
daily_summary: false,
|
||||
})
|
||||
const [securitySettings, setSecuritySettings] = useState<SecuritySettings>({
|
||||
session_timeout: 60,
|
||||
max_login_attempts: 5,
|
||||
password_policy: 'medium',
|
||||
})
|
||||
const [form] = Form.useForm()
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
navigate('/')
|
||||
return
|
||||
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
|
||||
}
|
||||
fetchSettings()
|
||||
}, [token, navigate])
|
||||
|
||||
function SettingsPanel({
|
||||
loading,
|
||||
children,
|
||||
}: {
|
||||
loading: boolean
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="settings-pane">
|
||||
<Card className="settings-panel-card" loading={loading}>
|
||||
<div className="settings-panel-scroll">{children}</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Settings() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [savingCollectorId, setSavingCollectorId] = useState<number | null>(null)
|
||||
const [collectors, setCollectors] = useState<CollectorSettings[]>([])
|
||||
const collectorTableRegionRef = useRef<HTMLDivElement | null>(null)
|
||||
const [collectorTableHeight, setCollectorTableHeight] = useState(360)
|
||||
const [systemForm] = Form.useForm<SystemSettings>()
|
||||
const [notificationForm] = Form.useForm<NotificationSettings>()
|
||||
const [securityForm] = Form.useForm<SecuritySettings>()
|
||||
|
||||
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()
|
||||
}, [])
|
||||
|
||||
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 {
|
||||
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 {
|
||||
await axios.put(`/api/v1/settings/${section}`, values)
|
||||
message.success('配置已保存')
|
||||
await fetchSettings()
|
||||
} catch (error) {
|
||||
message.error('保存失败')
|
||||
}
|
||||
} catch (err) {
|
||||
message.error('保存设置失败')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
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: <DashboardOutlined />, label: <Link to="/">仪表盘</Link> },
|
||||
{ key: '/datasources', icon: <DatabaseOutlined />, label: <Link to="/datasources">数据源</Link> },
|
||||
{ key: '/users', icon: <UserOutlined />, label: <Link to="/users">用户管理</Link> },
|
||||
{ key: '/settings', icon: <SettingOutlined />, label: '系统配置' },
|
||||
]
|
||||
|
||||
if (loading && !token) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<Spin size="large" tip="加载中..." />
|
||||
</div>
|
||||
const updateCollectorField = (id: number, field: keyof CollectorSettings, value: string | number | boolean) => {
|
||||
setCollectors((prev) =>
|
||||
prev.map((collector) => (collector.id === id ? { ...collector, [field]: value } : collector))
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout className="dashboard-layout">
|
||||
<Sider width={240} className="dashboard-sider">
|
||||
<div style={{ height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Title level={4} style={{ color: 'white', margin: 0 }}>智能星球</Title>
|
||||
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) => (
|
||||
<div>
|
||||
<div>{record.name}</div>
|
||||
<Text type="secondary">{record.source}</Text>
|
||||
</div>
|
||||
<Menu theme="dark" mode="inline" defaultSelectedKeys={['/settings']} items={menuItems} />
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header className="dashboard-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text strong>欢迎, {user?.username}</Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Button type="link" danger onClick={handleLogout}>退出登录</Button>
|
||||
</div>
|
||||
</Header>
|
||||
<Content className="dashboard-content">
|
||||
<Title level={3}><SettingOutlined /> 系统设置</Title>
|
||||
<Tabs defaultActiveKey="system" tabPosition="left">
|
||||
<TabPane
|
||||
tab={<span><SettingOutlined /> 系统配置</span>}
|
||||
key="system"
|
||||
>
|
||||
<Card title="基本设置">
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSaveSystem}
|
||||
initialValues={systemSettings}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="system_name"
|
||||
label="系统名称"
|
||||
rules={[{ required: true, message: '请输入系统名称' }]}
|
||||
>
|
||||
<Input placeholder="智能星球" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '层级',
|
||||
dataIndex: 'module',
|
||||
key: 'module',
|
||||
width: 90,
|
||||
render: (module: string) => <Tag color="blue">{module}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '优先级',
|
||||
dataIndex: 'priority',
|
||||
key: 'priority',
|
||||
width: 130,
|
||||
render: (priority: string, record: CollectorSettings) => (
|
||||
<Select
|
||||
value={priority}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => updateCollectorField(record.id, 'priority', value)}
|
||||
options={[
|
||||
{ value: 'P0', label: 'P0' },
|
||||
{ value: 'P1', label: 'P1' },
|
||||
{ value: 'P2', label: 'P2' },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '频率(分钟)',
|
||||
dataIndex: 'frequency_minutes',
|
||||
key: 'frequency_minutes',
|
||||
width: 150,
|
||||
render: (value: number, record: CollectorSettings) => (
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={10080}
|
||||
value={value}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(nextValue) => updateCollectorField(record.id, 'frequency_minutes', nextValue || 1)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '启用',
|
||||
dataIndex: 'is_active',
|
||||
key: 'is_active',
|
||||
width: 90,
|
||||
render: (value: boolean, record: CollectorSettings) => (
|
||||
<Switch checked={value} onChange={(checked) => updateCollectorField(record.id, 'is_active', checked)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '上次执行',
|
||||
dataIndex: 'last_run_at',
|
||||
key: 'last_run_at',
|
||||
width: 180,
|
||||
render: (value: string | null) => (value ? new Date(value).toLocaleString('zh-CN') : '-'),
|
||||
},
|
||||
{
|
||||
title: '下次执行',
|
||||
dataIndex: 'next_run_at',
|
||||
key: 'next_run_at',
|
||||
width: 180,
|
||||
render: (value: string | null) => (value ? new Date(value).toLocaleString('zh-CN') : '-'),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'last_status',
|
||||
key: 'last_status',
|
||||
width: 120,
|
||||
render: (value: string | null) => {
|
||||
if (!value) return <Tag>未执行</Tag>
|
||||
const color = value === 'success' ? 'success' : value === 'failed' ? 'error' : 'default'
|
||||
return <Tag color={color}>{value}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 92,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, record: CollectorSettings) => (
|
||||
<Button type="primary" loading={savingCollectorId === record.id} onClick={() => saveCollector(record)}>
|
||||
保存
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'system',
|
||||
label: '系统显示',
|
||||
children: (
|
||||
<SettingsPanel loading={loading}>
|
||||
<Form form={systemForm} layout="vertical" onFinish={(values) => saveSection('system', values)}>
|
||||
<Form.Item name="system_name" label="系统名称" rules={[{ required: true, message: '请输入系统名称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="refresh_interval"
|
||||
label="数据刷新间隔 (秒)"
|
||||
>
|
||||
<Form.Item name="refresh_interval" label="默认刷新间隔(秒)">
|
||||
<InputNumber min={10} max={3600} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="data_retention_days"
|
||||
label="数据保留天数"
|
||||
>
|
||||
<InputNumber min={1} max={365} style={{ width: '100%' }} />
|
||||
<Form.Item name="data_retention_days" label="数据保留天数">
|
||||
<InputNumber min={1} max={3650} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="max_concurrent_tasks"
|
||||
label="最大并发任务数"
|
||||
>
|
||||
<InputNumber min={1} max={20} style={{ width: '100%' }} />
|
||||
<Form.Item name="max_concurrent_tasks" label="最大并发任务数">
|
||||
<InputNumber min={1} max={50} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item
|
||||
name="auto_refresh"
|
||||
label="自动刷新"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Form.Item name="auto_refresh" label="自动刷新" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={saving}>
|
||||
保存设置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit">保存系统配置</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</TabPane>
|
||||
<TabPane
|
||||
tab={<span><BellOutlined /> 通知设置</span>}
|
||||
key="notifications"
|
||||
>
|
||||
<Card title="通知配置">
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSaveNotifications}
|
||||
initialValues={notificationSettings}
|
||||
>
|
||||
<Divider orientation="left">邮件通知</Divider>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="email_enabled"
|
||||
label="启用邮件通知"
|
||||
valuePropName="checked"
|
||||
>
|
||||
</SettingsPanel>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'notifications',
|
||||
label: '通知策略',
|
||||
children: (
|
||||
<SettingsPanel loading={loading}>
|
||||
<Form form={notificationForm} layout="vertical" onFinish={(values) => saveSection('notifications', values)}>
|
||||
<Form.Item name="email_enabled" label="启用邮件通知" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="email_address"
|
||||
label="通知邮箱"
|
||||
rules={[{ type: 'email', message: '请输入有效的邮箱地址' }]}
|
||||
>
|
||||
<Input placeholder="admin@example.com" disabled={!notificationSettings.email_enabled} />
|
||||
<Form.Item name="email_address" label="通知邮箱">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Divider orientation="left">告警通知</Divider>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item
|
||||
name="critical_alerts"
|
||||
label="严重告警"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Form.Item name="critical_alerts" label="严重告警通知" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item
|
||||
name="warning_alerts"
|
||||
label="警告告警"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Form.Item name="warning_alerts" label="警告告警通知" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item
|
||||
name="daily_summary"
|
||||
label="每日摘要"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Form.Item name="daily_summary" label="每日摘要" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={saving}>
|
||||
保存设置
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit">保存通知配置</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</TabPane>
|
||||
<TabPane
|
||||
tab={<span><SafetyOutlined /> 安全设置</span>}
|
||||
key="security"
|
||||
>
|
||||
<Card title="安全配置">
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSaveSecurity}
|
||||
initialValues={securitySettings}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="session_timeout"
|
||||
label="会话超时 (分钟)"
|
||||
>
|
||||
</SettingsPanel>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'security',
|
||||
label: '安全策略',
|
||||
children: (
|
||||
<SettingsPanel loading={loading}>
|
||||
<Form form={securityForm} layout="vertical" onFinish={(values) => saveSection('security', values)}>
|
||||
<Form.Item name="session_timeout" label="会话超时(分钟)">
|
||||
<InputNumber min={5} max={1440} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="max_login_attempts"
|
||||
label="最大登录尝试次数"
|
||||
>
|
||||
<InputNumber min={1} max={10} style={{ width: '100%' }} />
|
||||
<Form.Item name="max_login_attempts" label="最大登录尝试次数">
|
||||
<InputNumber min={1} max={20} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item
|
||||
name="password_policy"
|
||||
label="密码策略"
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="low">简单 (最低6位)</Select.Option>
|
||||
<Select.Option value="medium">中等 (8位以上,含数字字母)</Select.Option>
|
||||
<Select.Option value="high">严格 (12位以上,含大小写数字特殊字符)</Select.Option>
|
||||
</Select>
|
||||
<Form.Item name="password_policy" label="密码策略">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'low', label: '简单' },
|
||||
{ value: 'medium', label: '中等' },
|
||||
{ value: 'high', label: '严格' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Divider />
|
||||
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={saving}>
|
||||
保存设置
|
||||
</Button>
|
||||
<Button type="primary" htmlType="submit">保存安全配置</Button>
|
||||
</Form>
|
||||
</SettingsPanel>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'collectors',
|
||||
label: '采集调度',
|
||||
children: (
|
||||
<div className="settings-pane">
|
||||
<Card
|
||||
className="settings-panel-card settings-panel-card--table"
|
||||
loading={loading}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
<div ref={collectorTableRegionRef} className="table-scroll-region data-source-table-region">
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={collectorColumns}
|
||||
dataSource={collectors}
|
||||
pagination={false}
|
||||
scroll={{ x: 1200, y: collectorTableHeight }}
|
||||
tableLayout="fixed"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="page-shell settings-shell">
|
||||
<div className="page-shell__header">
|
||||
<div>
|
||||
<Title level={3} style={{ marginBottom: 4 }}>系统配置中心</Title>
|
||||
<Text type="secondary">这一页现在已经直接连接数据库配置和采集调度,不再只是演示表单。</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-shell__body settings-tabs-shell">
|
||||
<Tabs className="settings-tabs" items={tabItems} />
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -145,7 +145,9 @@ function Tasks() {
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table columns={columns} dataSource={tasks} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content' }} tableLayout="fixed" />
|
||||
<div className="table-scroll-region">
|
||||
<Table columns={columns} dataSource={tasks} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content', y: 'calc(100% - 360px)' }} tableLayout="fixed" />
|
||||
</div>
|
||||
</Card>
|
||||
</AppLayout>
|
||||
)
|
||||
|
||||
@@ -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<User | null>(null)
|
||||
const tableRegionRef = useRef<HTMLDivElement | null>(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<string, string> = {
|
||||
super_admin: 'red',
|
||||
@@ -97,6 +118,7 @@ function Users() {
|
||||
title: '状态',
|
||||
dataIndex: 'is_active',
|
||||
key: 'is_active',
|
||||
width: 120,
|
||||
render: (active: boolean) => (
|
||||
<Tag color={active ? 'green' : 'red'}>{active ? '活跃' : '禁用'}</Tag>
|
||||
),
|
||||
@@ -104,6 +126,7 @@ function Users() {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
render: (_: unknown, record: User) => (
|
||||
<Space>
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => handleEdit(record)}>编辑</Button>
|
||||
@@ -115,11 +138,24 @@ function Users() {
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<h2>用户管理</h2>
|
||||
<div className="page-shell">
|
||||
<div className="page-shell__header">
|
||||
<h2 style={{ margin: 0 }}>用户管理</h2>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>添加用户</Button>
|
||||
</div>
|
||||
<Table columns={columns} dataSource={users} rowKey="id" loading={loading} scroll={{ x: 'max-content' }} tableLayout="fixed" />
|
||||
<div className="page-shell__body">
|
||||
<div ref={tableRegionRef} className="table-scroll-region data-source-table-region" style={{ height: '100%' }}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content', y: tableHeight }}
|
||||
tableLayout="fixed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
title={editingUser ? '编辑用户' : '添加用户'}
|
||||
open={modalVisible}
|
||||
|
||||
@@ -1,9 +1,43 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react(),
|
||||
{
|
||||
name: 'legacy-earth',
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/legacy/earth', (req, res, next) => {
|
||||
let url = req.url || '/'
|
||||
|
||||
if (url === '' || url === '/') {
|
||||
url = '/3dearthmult.html'
|
||||
}
|
||||
|
||||
const filePath = path.resolve(__dirname, 'legacy/3dearthmult' + url)
|
||||
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
const contentTypes = {
|
||||
'.html': 'text/html',
|
||||
'.json': 'application/json',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.js': 'application/javascript',
|
||||
'.css': 'text/css'
|
||||
}
|
||||
res.setHeader('Content-Type', contentTypes[ext] || 'text/plain')
|
||||
fs.createReadStream(filePath).pipe(res)
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
@@ -29,4 +63,7 @@ export default defineConfig({
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['satellite.js'],
|
||||
},
|
||||
})
|
||||
|
||||
176
planet.sh
Executable file
@@ -0,0 +1,176 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
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}"
|
||||
|
||||
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}"
|
||||
|
||||
echo -e "${BLUE}🗄️ 启动数据库...${NC}"
|
||||
docker start planet_postgres planet_redis 2>/dev/null || docker-compose up -d postgres redis
|
||||
|
||||
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 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
|
||||
|
||||
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"
|
||||
nohup bun run dev --port 3000 > /tmp/planet_frontend.log 2>&1 &
|
||||
|
||||
sleep 3
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}✅ 启动完成!${NC}"
|
||||
echo " 前端: http://localhost:3000"
|
||||
echo " 后端: http://localhost:8000"
|
||||
}
|
||||
|
||||
stop() {
|
||||
echo -e "${YELLOW}🛑 停止服务...${NC}"
|
||||
pkill -f "uvicorn" 2>/dev/null || true
|
||||
pkill -f "vite" 2>/dev/null || true
|
||||
pkill -f "bun run dev" 2>/dev/null || true
|
||||
docker stop planet_postgres planet_redis 2>/dev/null || true
|
||||
echo -e "${GREEN}✅ 已停止${NC}"
|
||||
}
|
||||
|
||||
restart() {
|
||||
stop
|
||||
sleep 1
|
||||
start
|
||||
}
|
||||
|
||||
health() {
|
||||
echo "📊 容器状态:"
|
||||
docker ps --filter "name=planet_" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
|
||||
|
||||
echo ""
|
||||
echo "🔍 服务状态:"
|
||||
if curl -s http://localhost:8000/health > /dev/null 2>&1; then
|
||||
echo -e " 后端: ${GREEN}✅ 运行中${NC}"
|
||||
else
|
||||
echo -e " 后端: ${RED}❌ 未运行${NC}"
|
||||
fi
|
||||
|
||||
if curl -s http://localhost:3000 > /dev/null 2>&1; then
|
||||
echo -e " 前端: ${GREEN}✅ 运行中${NC}"
|
||||
else
|
||||
echo -e " 前端: ${RED}❌ 未运行${NC}"
|
||||
fi
|
||||
}
|
||||
|
||||
log() {
|
||||
case "$1" in
|
||||
-f|--frontend)
|
||||
echo "📝 前端日志 (Ctrl+C 退出):"
|
||||
tail -f /tmp/planet_frontend.log
|
||||
;;
|
||||
-b|--backend)
|
||||
echo "📝 后端日志 (Ctrl+C 退出):"
|
||||
tail -f /tmp/planet_backend.log
|
||||
;;
|
||||
*)
|
||||
echo "📝 最近日志:"
|
||||
echo "--- 后端 ---"
|
||||
tail -20 /tmp/planet_backend.log 2>/dev/null || echo "无日志"
|
||||
echo "--- 前端 ---"
|
||||
tail -20 /tmp/planet_frontend.log 2>/dev/null || echo "无日志"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
start
|
||||
;;
|
||||
stop)
|
||||
stop
|
||||
;;
|
||||
restart)
|
||||
restart
|
||||
;;
|
||||
health)
|
||||
health
|
||||
;;
|
||||
log)
|
||||
log "$2"
|
||||
;;
|
||||
*)
|
||||
echo "用法: ./planet.sh {start|stop|restart|health|log}"
|
||||
echo ""
|
||||
echo "命令:"
|
||||
echo " start 启动服务"
|
||||
echo " stop 停止服务"
|
||||
echo " restart 重启服务"
|
||||
echo " health 检查健康状态"
|
||||
echo " log 查看日志"
|
||||
echo " log -f 查看前端日志"
|
||||
echo " log -b 查看后端日志"
|
||||
;;
|
||||
esac
|
||||
@@ -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"
|
||||
|
||||
134
restart.sh
@@ -1,134 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Planet 重启脚本 - 停止并重启所有服务
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${BLUE}🔄 重启智能星球计划...${NC}"
|
||||
echo ""
|
||||
|
||||
# 停止服务
|
||||
echo -e "${YELLOW}🛑 停止服务...${NC}"
|
||||
|
||||
# 停止后端
|
||||
if pgrep -f "uvicorn.*app.main:app" > /dev/null 2>&1; then
|
||||
pkill -f "uvicorn.*app.main:app" 2>/dev/null || true
|
||||
echo " ✅ 后端已停止"
|
||||
else
|
||||
echo " ℹ️ 后端未运行"
|
||||
fi
|
||||
|
||||
# 停止前端
|
||||
if pgrep -f "vite" > /dev/null 2>&1; then
|
||||
pkill -f "vite" 2>/dev/null || true
|
||||
echo " ✅ 前端已停止"
|
||||
else
|
||||
echo " ℹ️ 前端未运行"
|
||||
fi
|
||||
|
||||
# 停止 Docker 服务
|
||||
if command -v docker &> /dev/null && docker ps &> /dev/null 2>&1; then
|
||||
if command -v docker-compose &> /dev/null; then
|
||||
docker-compose down > /dev/null 2>&1 || true
|
||||
else
|
||||
docker compose down > /dev/null 2>&1 || true
|
||||
fi
|
||||
echo " ✅ Docker 容器已停止"
|
||||
fi
|
||||
|
||||
# 等待服务完全停止
|
||||
echo ""
|
||||
echo -e "${YELLOW}⏳ 等待进程退出...${NC}"
|
||||
sleep 2
|
||||
|
||||
# 检查端口是否已释放
|
||||
if lsof -i :8000 > /dev/null 2>&1 || netstat -tlnp 2>/dev/null | grep -q ":8000" || ss -tlnp 2>/dev/null | grep -q ":8000"; then
|
||||
echo -e "${YELLOW}⚠️ 端口 8000 仍被占用,强制杀死...${NC}"
|
||||
fuser -k 8000/tcp 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
if lsof -i :3000 > /dev/null 2>&1 || netstat -tlnp 2>/dev/null | grep -q ":3000" || ss -tlnp 2>/dev/null | grep -q ":3000"; then
|
||||
echo -e "${YELLOW}⚠️ 端口 3000 仍被占用,强制杀死...${NC}"
|
||||
fuser -k 3000/tcp 2>/dev/null || true
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}✅ 服务已停止${NC}"
|
||||
echo ""
|
||||
|
||||
# 启动服务
|
||||
echo -e "${BLUE}🚀 启动服务...${NC}"
|
||||
|
||||
# 启动 Docker 服务
|
||||
if command -v docker &> /dev/null && docker ps &> /dev/null 2>&1; then
|
||||
echo -e "${BLUE}🗄️ 启动数据库...${NC}"
|
||||
if command -v docker-compose &> /dev/null; then
|
||||
docker-compose up -d postgres redis > /dev/null 2>&1 || true
|
||||
else
|
||||
docker compose up -d postgres redis > /dev/null 2>&1 || true
|
||||
fi
|
||||
echo -e "${YELLOW}⏳ 等待数据库就绪...${NC}"
|
||||
sleep 5
|
||||
fi
|
||||
|
||||
# 同步 Python 依赖
|
||||
echo -e "${BLUE}🐍 同步 Python 依赖...${NC}"
|
||||
cd "$SCRIPT_DIR/backend"
|
||||
uv sync > /dev/null 2>&1 || true
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
# 初始化数据库
|
||||
echo -e "${BLUE}🗃️ 初始化数据库...${NC}"
|
||||
PYTHONPATH="$SCRIPT_DIR/backend" python "$SCRIPT_DIR/backend/scripts/init_admin.py" > /dev/null 2>&1 || true
|
||||
|
||||
# 启动后端
|
||||
echo -e "${BLUE}🔧 启动后端服务...${NC}"
|
||||
export PYTHONPATH="$SCRIPT_DIR/backend"
|
||||
cd "$SCRIPT_DIR/backend"
|
||||
nohup python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 > /tmp/planet_backend.log 2>&1 &
|
||||
BACKEND_PID=$!
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo -e "${YELLOW}⏳ 等待后端启动...${NC}"
|
||||
sleep 3
|
||||
|
||||
if curl -s http://localhost:8000/health > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✅ 后端已启动${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ 后端启动失败${NC}"
|
||||
tail -10 /tmp/planet_backend.log
|
||||
fi
|
||||
|
||||
# 启动前端
|
||||
echo -e "${BLUE}🌐 启动前端服务...${NC}"
|
||||
cd "$SCRIPT_DIR/frontend"
|
||||
npm run dev > /tmp/planet_frontend.log 2>&1 &
|
||||
FRONTEND_PID=$!
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
sleep 2
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN}✅ 重启完成!${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}🌐 访问地址:${NC}"
|
||||
echo " 后端: http://localhost:8000"
|
||||
echo " API文档: http://localhost:8000/docs"
|
||||
echo " 前端: http://localhost:3000"
|
||||
echo ""
|
||||
echo -e "${BLUE}📝 日志:${NC}"
|
||||
echo " tail -f /tmp/planet_backend.log"
|
||||
echo " tail -f /tmp/planet_frontend.log"
|
||||
57
scripts/backfill_collected_data_metadata.py
Normal file
@@ -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())
|
||||
119
scripts/check_collected_data_column_removal_ready.py
Normal file
@@ -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())
|
||||
41
scripts/drop_collected_data_legacy_columns.py
Normal file
@@ -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())
|
||||
158
start.sh
@@ -1,158 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Planet 一键启动脚本
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo "🚀 启动智能星球计划..."
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 检查 Docker 服务
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo -e "${RED}❌ Docker 未安装,请先安装 Docker${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker info &> /dev/null; then
|
||||
echo -e "${RED}❌ Docker 服务未运行,请先启动 Docker${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查并安装 uv
|
||||
if ! command -v uv &> /dev/null; then
|
||||
echo -e "${YELLOW}📦 安装 uv...${NC}"
|
||||
curl -Ls https://astral.sh/uv | sh
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
fi
|
||||
|
||||
# 检查 npm
|
||||
if ! command -v npm &> /dev/null; then
|
||||
echo -e "${RED}❌ npm 未安装,请先安装 Node.js${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 启动基础设施 (PostgreSQL + Redis)
|
||||
echo -e "${BLUE}🗄️ 启动数据库...${NC}"
|
||||
# 检查 docker compose vs docker-compose
|
||||
if command -v docker-compose &> /dev/null; then
|
||||
docker-compose up -d postgres redis || true
|
||||
else
|
||||
docker compose up -d postgres redis || true
|
||||
fi
|
||||
|
||||
# 等待数据库就绪
|
||||
echo -e "${YELLOW}⏳ 等待数据库就绪...${NC}"
|
||||
sleep 5
|
||||
|
||||
# 检查数据库是否就绪
|
||||
MAX_RETRIES=10
|
||||
RETRY_COUNT=0
|
||||
# 检查 docker compose vs docker-compose
|
||||
if command -v docker-compose &> /dev/null; then
|
||||
while ! docker exec planet_postgres pg_isready -U postgres &> /dev/null; do
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then
|
||||
echo -e "${RED}❌ 数据库启动超时${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${YELLOW} 等待数据库... ($RETRY_COUNT/$MAX_RETRIES)${NC}"
|
||||
sleep 2
|
||||
done
|
||||
else
|
||||
while ! docker exec planet_postgres pg_isready -U postgres &> /dev/null; do
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then
|
||||
echo -e "${RED}❌ 数据库启动超时${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${YELLOW} 等待数据库... ($RETRY_COUNT/$MAX_RETRIES)${NC}"
|
||||
sleep 2
|
||||
done
|
||||
fi
|
||||
echo -e "${GREEN}✅ 数据库已就绪${NC}"
|
||||
|
||||
# 同步 Python 依赖
|
||||
echo -e "${BLUE}🐍 同步 Python 依赖...${NC}"
|
||||
uv sync
|
||||
|
||||
# 初始化数据库
|
||||
echo -e "${BLUE}🗃️ 初始化数据库...${NC}"
|
||||
PYTHONPATH="$SCRIPT_DIR/backend" python "$SCRIPT_DIR/backend/scripts/init_admin.py" > /dev/null 2>&1 || true
|
||||
|
||||
# 设置环境变量
|
||||
export PYTHONPATH="$SCRIPT_DIR/backend"
|
||||
|
||||
# 启动后端服务
|
||||
echo -e "${BLUE}🔧 启动后端服务...${NC}"
|
||||
|
||||
# 检查端口 8000 是否被占用
|
||||
if lsof -i :8000 > /dev/null 2>&1 || netstat -tlnp 2>/dev/null | grep -q ":8000" || ss -tlnp 2>/dev/null | grep -q ":8000"; then
|
||||
echo -e "${RED}❌ 端口 8000 已被占用,请先停止占用端口的进程${NC}"
|
||||
echo " 使用命令: ./kill_port.sh 8000 或 lsof -i :8000 查看"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$SCRIPT_DIR/backend"
|
||||
PYTHONPATH="$SCRIPT_DIR/backend" nohup python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 > /tmp/planet_backend.log 2>&1 &
|
||||
BACKEND_PID=$!
|
||||
cd "$SCRIPT_DIR"
|
||||
echo " 后端 PID: $BACKEND_PID"
|
||||
|
||||
# 等待后端启动
|
||||
echo -e "${YELLOW}⏳ 等待后端服务启动...${NC}"
|
||||
sleep 5
|
||||
|
||||
# 检查后端是否正常运行
|
||||
if ! curl -s http://localhost:8000/health > /dev/null 2>&1; then
|
||||
echo -e "${RED}❌ 后端服务启动失败,查看日志: tail /tmp/planet_backend.log${NC}"
|
||||
tail -20 /tmp/planet_backend.log
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}✅ 后端服务已启动${NC}"
|
||||
|
||||
# 检查并安装前端依赖
|
||||
if [ ! -d "frontend/node_modules" ]; then
|
||||
echo -e "${YELLOW}📦 安装前端依赖...${NC}"
|
||||
cd frontend
|
||||
npm install
|
||||
cd ..
|
||||
fi
|
||||
|
||||
# 启动前端服务
|
||||
echo -e "${BLUE}🌐 启动前端服务...${NC}"
|
||||
cd frontend
|
||||
npm run dev > /tmp/planet_frontend.log 2>&1 &
|
||||
FRONTEND_PID=$!
|
||||
echo " 前端 PID: $FRONTEND_PID"
|
||||
|
||||
cd ..
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN}✅ 智能星球计划启动完成!${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
echo ""
|
||||
echo -e "${BLUE}🌐 访问地址:${NC}"
|
||||
echo " 后端: http://localhost:8000"
|
||||
echo " API文档: http://localhost:8000/docs"
|
||||
echo " 前端: http://localhost:3000"
|
||||
echo ""
|
||||
echo -e "${BLUE}📝 服务进程:${NC}"
|
||||
echo " 后端 PID: $BACKEND_PID"
|
||||
echo " 前端 PID: $FRONTEND_PID"
|
||||
echo ""
|
||||
echo -e "${BLUE}📝 日志查看:${NC}"
|
||||
echo " tail -f /tmp/planet_backend.log"
|
||||
echo " tail -f /tmp/planet_frontend.log"
|
||||
echo ""
|
||||
echo -e "${BLUE}🛑 停止服务:${NC}"
|
||||
echo " pkill -f 'uvicorn' # 停止后端"
|
||||
echo " pkill -f 'vite' # 停止前端"
|
||||
38
stop.sh
@@ -1,38 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Planet 停止脚本
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo "🛑 停止智能星球计划..."
|
||||
|
||||
# 停止后端服务
|
||||
if pgrep -f "uvicorn" > /dev/null; then
|
||||
echo "🔧 停止后端服务..."
|
||||
pkill -f "uvicorn" || true
|
||||
echo "✅ 后端服务已停止"
|
||||
else
|
||||
echo "ℹ️ 后端服务未运行"
|
||||
fi
|
||||
|
||||
# 停止前端服务
|
||||
if pgrep -f "vite" > /dev/null; then
|
||||
echo "🌐 停止前端服务..."
|
||||
pkill -f "vite" || true
|
||||
echo "✅ 前端服务已停止"
|
||||
else
|
||||
echo "ℹ️ 前端服务未运行"
|
||||
fi
|
||||
|
||||
# 停止 Docker 容器
|
||||
echo "🗄️ 停止数据库容器..."
|
||||
if command -v docker-compose &> /dev/null; then
|
||||
docker-compose stop postgres redis || true
|
||||
else
|
||||
docker compose stop postgres redis || true
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ 所有服务已停止"
|
||||
614
uv.lock
generated
@@ -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" },
|
||||
]
|
||||
|
||||