citrascope 0.1.0__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- citrascope/__main__.py +8 -5
- citrascope/api/abstract_api_client.py +7 -0
- citrascope/api/citra_api_client.py +30 -1
- citrascope/citra_scope_daemon.py +214 -61
- citrascope/hardware/abstract_astro_hardware_adapter.py +70 -2
- citrascope/hardware/adapter_registry.py +94 -0
- citrascope/hardware/indi_adapter.py +456 -16
- citrascope/hardware/kstars_dbus_adapter.py +179 -0
- citrascope/hardware/nina_adv_http_adapter.py +593 -0
- citrascope/hardware/nina_adv_http_survey_template.json +328 -0
- citrascope/logging/__init__.py +2 -1
- citrascope/logging/_citrascope_logger.py +80 -1
- citrascope/logging/web_log_handler.py +74 -0
- citrascope/settings/citrascope_settings.py +145 -0
- citrascope/settings/settings_file_manager.py +126 -0
- citrascope/tasks/runner.py +124 -28
- citrascope/tasks/scope/base_telescope_task.py +25 -10
- citrascope/tasks/scope/static_telescope_task.py +11 -3
- citrascope/web/__init__.py +1 -0
- citrascope/web/app.py +470 -0
- citrascope/web/server.py +123 -0
- citrascope/web/static/api.js +82 -0
- citrascope/web/static/app.js +500 -0
- citrascope/web/static/config.js +362 -0
- citrascope/web/static/img/citra.png +0 -0
- citrascope/web/static/img/favicon.png +0 -0
- citrascope/web/static/style.css +120 -0
- citrascope/web/static/websocket.js +127 -0
- citrascope/web/templates/dashboard.html +354 -0
- {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/METADATA +68 -36
- citrascope-0.3.0.dist-info/RECORD +38 -0
- {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/WHEEL +1 -1
- citrascope/settings/_citrascope_settings.py +0 -42
- citrascope-0.1.0.dist-info/RECORD +0 -21
- {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/entry_points.txt +0 -0
citrascope/web/app.py
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""FastAPI web application for CitraScope monitoring and configuration."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
|
11
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
12
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
13
|
+
from fastapi.staticfiles import StaticFiles
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from citrascope.logging import CITRASCOPE_LOGGER
|
|
17
|
+
|
|
18
|
+
# ============================================================================
|
|
19
|
+
# URL CONSTANTS - Single source of truth for Citra web URLs
|
|
20
|
+
# ============================================================================
|
|
21
|
+
PROD_APP_URL = "https://app.citra.space"
|
|
22
|
+
DEV_APP_URL = "https://dev.app.citra.space"
|
|
23
|
+
# ============================================================================
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SystemStatus(BaseModel):
|
|
27
|
+
"""Current system status."""
|
|
28
|
+
|
|
29
|
+
telescope_connected: bool = False
|
|
30
|
+
camera_connected: bool = False
|
|
31
|
+
current_task: Optional[str] = None
|
|
32
|
+
tasks_pending: int = 0
|
|
33
|
+
hardware_adapter: str = "unknown"
|
|
34
|
+
telescope_ra: Optional[float] = None
|
|
35
|
+
telescope_dec: Optional[float] = None
|
|
36
|
+
ground_station_id: Optional[str] = None
|
|
37
|
+
ground_station_name: Optional[str] = None
|
|
38
|
+
ground_station_url: Optional[str] = None
|
|
39
|
+
last_update: str = ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class HardwareConfig(BaseModel):
|
|
43
|
+
"""Hardware configuration settings."""
|
|
44
|
+
|
|
45
|
+
adapter: str
|
|
46
|
+
indi_server_url: Optional[str] = None
|
|
47
|
+
indi_server_port: Optional[int] = None
|
|
48
|
+
indi_telescope_name: Optional[str] = None
|
|
49
|
+
indi_camera_name: Optional[str] = None
|
|
50
|
+
nina_url_prefix: Optional[str] = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ConnectionManager:
|
|
54
|
+
"""Manages WebSocket connections for real-time updates."""
|
|
55
|
+
|
|
56
|
+
def __init__(self):
|
|
57
|
+
self.active_connections: List[WebSocket] = []
|
|
58
|
+
|
|
59
|
+
async def connect(self, websocket: WebSocket):
|
|
60
|
+
await websocket.accept()
|
|
61
|
+
self.active_connections.append(websocket)
|
|
62
|
+
CITRASCOPE_LOGGER.info(f"WebSocket client connected. Total: {len(self.active_connections)}")
|
|
63
|
+
|
|
64
|
+
def disconnect(self, websocket: WebSocket):
|
|
65
|
+
if websocket in self.active_connections:
|
|
66
|
+
self.active_connections.remove(websocket)
|
|
67
|
+
CITRASCOPE_LOGGER.info(f"WebSocket client disconnected. Total: {len(self.active_connections)}")
|
|
68
|
+
|
|
69
|
+
async def broadcast(self, message: dict):
|
|
70
|
+
"""Broadcast message to all connected clients."""
|
|
71
|
+
disconnected = []
|
|
72
|
+
for connection in self.active_connections:
|
|
73
|
+
try:
|
|
74
|
+
await connection.send_json(message)
|
|
75
|
+
except Exception as e:
|
|
76
|
+
CITRASCOPE_LOGGER.warning(f"Failed to send to WebSocket client: {e}")
|
|
77
|
+
disconnected.append(connection)
|
|
78
|
+
|
|
79
|
+
# Clean up disconnected clients
|
|
80
|
+
for connection in disconnected:
|
|
81
|
+
self.disconnect(connection)
|
|
82
|
+
|
|
83
|
+
async def broadcast_text(self, message: str):
|
|
84
|
+
"""Broadcast text message to all connected clients."""
|
|
85
|
+
disconnected = []
|
|
86
|
+
for connection in self.active_connections:
|
|
87
|
+
try:
|
|
88
|
+
await connection.send_text(message)
|
|
89
|
+
except Exception as e:
|
|
90
|
+
CITRASCOPE_LOGGER.warning(f"Failed to send to WebSocket client: {e}")
|
|
91
|
+
disconnected.append(connection)
|
|
92
|
+
|
|
93
|
+
# Clean up disconnected clients
|
|
94
|
+
for connection in disconnected:
|
|
95
|
+
self.disconnect(connection)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class CitraScopeWebApp:
|
|
99
|
+
"""Web application for CitraScope."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, daemon=None, web_log_handler=None):
|
|
102
|
+
self.app = FastAPI(title="CitraScope", description="Telescope Control and Monitoring")
|
|
103
|
+
self.daemon = daemon
|
|
104
|
+
self.connection_manager = ConnectionManager()
|
|
105
|
+
self.status = SystemStatus()
|
|
106
|
+
self.web_log_handler = web_log_handler
|
|
107
|
+
|
|
108
|
+
# Configure CORS
|
|
109
|
+
self.app.add_middleware(
|
|
110
|
+
CORSMiddleware,
|
|
111
|
+
allow_origins=["*"],
|
|
112
|
+
allow_credentials=True,
|
|
113
|
+
allow_methods=["*"],
|
|
114
|
+
allow_headers=["*"],
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Mount static files
|
|
118
|
+
static_dir = Path(__file__).parent / "static"
|
|
119
|
+
if static_dir.exists():
|
|
120
|
+
self.app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
|
121
|
+
|
|
122
|
+
# Register routes
|
|
123
|
+
self._setup_routes()
|
|
124
|
+
|
|
125
|
+
def set_daemon(self, daemon):
|
|
126
|
+
"""Set the daemon instance after initialization."""
|
|
127
|
+
self.daemon = daemon
|
|
128
|
+
|
|
129
|
+
def _setup_routes(self):
|
|
130
|
+
"""Setup all API routes."""
|
|
131
|
+
|
|
132
|
+
@self.app.get("/", response_class=HTMLResponse)
|
|
133
|
+
async def root():
|
|
134
|
+
"""Serve the main dashboard page."""
|
|
135
|
+
template_path = Path(__file__).parent / "templates" / "dashboard.html"
|
|
136
|
+
if template_path.exists():
|
|
137
|
+
return template_path.read_text()
|
|
138
|
+
else:
|
|
139
|
+
return HTMLResponse(
|
|
140
|
+
content="<h1>CitraScope Dashboard</h1><p>Template file not found</p>", status_code=500
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
@self.app.get("/api/status")
|
|
144
|
+
async def get_status():
|
|
145
|
+
"""Get current system status."""
|
|
146
|
+
if self.daemon:
|
|
147
|
+
self._update_status_from_daemon()
|
|
148
|
+
return self.status
|
|
149
|
+
|
|
150
|
+
@self.app.get("/api/config")
|
|
151
|
+
async def get_config():
|
|
152
|
+
"""Get current configuration."""
|
|
153
|
+
if not self.daemon or not self.daemon.settings:
|
|
154
|
+
return JSONResponse({"error": "Configuration not available"}, status_code=503)
|
|
155
|
+
|
|
156
|
+
settings = self.daemon.settings
|
|
157
|
+
# Determine app URL based on API host
|
|
158
|
+
app_url = DEV_APP_URL if "dev." in settings.host else PROD_APP_URL
|
|
159
|
+
|
|
160
|
+
# Get config file path
|
|
161
|
+
config_path = str(settings.config_manager.get_config_path())
|
|
162
|
+
|
|
163
|
+
# Get current log file path
|
|
164
|
+
log_file_path = (
|
|
165
|
+
str(settings.config_manager.get_current_log_path()) if settings.file_logging_enabled else None
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Get images directory path
|
|
169
|
+
images_dir_path = str(settings.get_images_dir())
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
"host": settings.host,
|
|
173
|
+
"port": settings.port,
|
|
174
|
+
"use_ssl": settings.use_ssl,
|
|
175
|
+
"personal_access_token": settings.personal_access_token,
|
|
176
|
+
"telescope_id": settings.telescope_id,
|
|
177
|
+
"hardware_adapter": settings.hardware_adapter,
|
|
178
|
+
"adapter_settings": settings.adapter_settings,
|
|
179
|
+
"log_level": settings.log_level,
|
|
180
|
+
"keep_images": settings.keep_images,
|
|
181
|
+
"max_task_retries": settings.max_task_retries,
|
|
182
|
+
"initial_retry_delay_seconds": settings.initial_retry_delay_seconds,
|
|
183
|
+
"max_retry_delay_seconds": settings.max_retry_delay_seconds,
|
|
184
|
+
"app_url": app_url,
|
|
185
|
+
"config_file_path": config_path,
|
|
186
|
+
"log_file_path": log_file_path,
|
|
187
|
+
"images_dir_path": images_dir_path,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
@self.app.get("/api/config/status")
|
|
191
|
+
async def get_config_status():
|
|
192
|
+
"""Get configuration status."""
|
|
193
|
+
if not self.daemon or not self.daemon.settings:
|
|
194
|
+
return {"configured": False, "error": "Settings not available"}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
"configured": self.daemon.settings.is_configured(),
|
|
198
|
+
"error": getattr(self.daemon, "configuration_error", None),
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
@self.app.get("/api/hardware-adapters")
|
|
202
|
+
async def get_hardware_adapters():
|
|
203
|
+
"""Get list of available hardware adapters."""
|
|
204
|
+
from citrascope.hardware.adapter_registry import list_adapters
|
|
205
|
+
|
|
206
|
+
adapters_info = list_adapters()
|
|
207
|
+
return {
|
|
208
|
+
"adapters": list(adapters_info.keys()),
|
|
209
|
+
"descriptions": {name: info["description"] for name, info in adapters_info.items()},
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
@self.app.get("/api/hardware-adapters/{adapter_name}/schema")
|
|
213
|
+
async def get_adapter_schema(adapter_name: str):
|
|
214
|
+
"""Get configuration schema for a specific hardware adapter."""
|
|
215
|
+
from citrascope.hardware.adapter_registry import get_adapter_schema as get_schema
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
schema = get_schema(adapter_name)
|
|
219
|
+
return {"schema": schema}
|
|
220
|
+
except ValueError as e:
|
|
221
|
+
# Invalid adapter name
|
|
222
|
+
return JSONResponse({"error": str(e)}, status_code=404)
|
|
223
|
+
except Exception as e:
|
|
224
|
+
CITRASCOPE_LOGGER.error(f"Error getting schema for {adapter_name}: {e}", exc_info=True)
|
|
225
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
226
|
+
|
|
227
|
+
@self.app.post("/api/config")
|
|
228
|
+
async def update_config(config: Dict[str, Any]):
|
|
229
|
+
"""Update configuration and trigger hot-reload."""
|
|
230
|
+
try:
|
|
231
|
+
if not self.daemon:
|
|
232
|
+
return JSONResponse({"error": "Daemon not available"}, status_code=503)
|
|
233
|
+
|
|
234
|
+
# Validate required fields
|
|
235
|
+
required_fields = ["personal_access_token", "telescope_id", "hardware_adapter"]
|
|
236
|
+
for field in required_fields:
|
|
237
|
+
if field not in config or not config[field]:
|
|
238
|
+
return JSONResponse(
|
|
239
|
+
{"error": f"Missing required field: {field}"},
|
|
240
|
+
status_code=400,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# Validate adapter_settings against schema if adapter is specified
|
|
244
|
+
adapter_name = config.get("hardware_adapter")
|
|
245
|
+
adapter_settings = config.get("adapter_settings", {})
|
|
246
|
+
|
|
247
|
+
if adapter_name:
|
|
248
|
+
# Get schema for validation
|
|
249
|
+
schema_response = await get_adapter_schema(adapter_name)
|
|
250
|
+
if isinstance(schema_response, JSONResponse):
|
|
251
|
+
return schema_response # Error getting schema
|
|
252
|
+
|
|
253
|
+
schema = schema_response.get("schema", [])
|
|
254
|
+
|
|
255
|
+
# Validate required fields in adapter settings
|
|
256
|
+
for field_schema in schema:
|
|
257
|
+
field_name = field_schema.get("name")
|
|
258
|
+
is_required = field_schema.get("required", False)
|
|
259
|
+
|
|
260
|
+
if is_required and field_name not in adapter_settings:
|
|
261
|
+
return JSONResponse(
|
|
262
|
+
{"error": f"Missing required adapter setting: {field_name}"},
|
|
263
|
+
status_code=400,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# Validate type and constraints if present
|
|
267
|
+
if field_name in adapter_settings:
|
|
268
|
+
value = adapter_settings[field_name]
|
|
269
|
+
field_type = field_schema.get("type")
|
|
270
|
+
|
|
271
|
+
# Type validation
|
|
272
|
+
if field_type == "int":
|
|
273
|
+
try:
|
|
274
|
+
value = int(value)
|
|
275
|
+
adapter_settings[field_name] = value
|
|
276
|
+
except (ValueError, TypeError):
|
|
277
|
+
return JSONResponse(
|
|
278
|
+
{"error": f"Field '{field_name}' must be an integer"},
|
|
279
|
+
status_code=400,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
# Range validation
|
|
283
|
+
if "min" in field_schema and value < field_schema["min"]:
|
|
284
|
+
return JSONResponse(
|
|
285
|
+
{"error": f"Field '{field_name}' must be >= {field_schema['min']}"},
|
|
286
|
+
status_code=400,
|
|
287
|
+
)
|
|
288
|
+
if "max" in field_schema and value > field_schema["max"]:
|
|
289
|
+
return JSONResponse(
|
|
290
|
+
{"error": f"Field '{field_name}' must be <= {field_schema['max']}"},
|
|
291
|
+
status_code=400,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
elif field_type == "float":
|
|
295
|
+
try:
|
|
296
|
+
value = float(value)
|
|
297
|
+
adapter_settings[field_name] = value
|
|
298
|
+
except (ValueError, TypeError):
|
|
299
|
+
return JSONResponse(
|
|
300
|
+
{"error": f"Field '{field_name}' must be a number"},
|
|
301
|
+
status_code=400,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# Save configuration to file
|
|
305
|
+
from citrascope.settings.settings_file_manager import SettingsFileManager
|
|
306
|
+
|
|
307
|
+
config_manager = SettingsFileManager()
|
|
308
|
+
config_manager.save_config(config)
|
|
309
|
+
|
|
310
|
+
# Trigger hot-reload
|
|
311
|
+
success, error = self.daemon.reload_configuration()
|
|
312
|
+
|
|
313
|
+
if success:
|
|
314
|
+
return {
|
|
315
|
+
"status": "success",
|
|
316
|
+
"message": "Configuration updated and reloaded successfully",
|
|
317
|
+
}
|
|
318
|
+
else:
|
|
319
|
+
return JSONResponse(
|
|
320
|
+
{
|
|
321
|
+
"status": "error",
|
|
322
|
+
"message": f"Configuration saved but reload failed: {error}",
|
|
323
|
+
"error": error,
|
|
324
|
+
},
|
|
325
|
+
status_code=500,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
except Exception as e:
|
|
329
|
+
CITRASCOPE_LOGGER.error(f"Error updating config: {e}", exc_info=True)
|
|
330
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
331
|
+
|
|
332
|
+
@self.app.get("/api/tasks")
|
|
333
|
+
async def get_tasks():
|
|
334
|
+
"""Get current task queue."""
|
|
335
|
+
if not self.daemon or not hasattr(self.daemon, "task_manager") or self.daemon.task_manager is None:
|
|
336
|
+
return []
|
|
337
|
+
|
|
338
|
+
task_manager = self.daemon.task_manager
|
|
339
|
+
tasks = []
|
|
340
|
+
|
|
341
|
+
with task_manager.heap_lock:
|
|
342
|
+
for start_time, stop_time, task_id, task in task_manager.task_heap:
|
|
343
|
+
tasks.append(
|
|
344
|
+
{
|
|
345
|
+
"id": task_id,
|
|
346
|
+
"start_time": datetime.fromtimestamp(start_time, tz=timezone.utc).isoformat(),
|
|
347
|
+
"stop_time": (
|
|
348
|
+
datetime.fromtimestamp(stop_time, tz=timezone.utc).isoformat() if stop_time else None
|
|
349
|
+
),
|
|
350
|
+
"status": task.status,
|
|
351
|
+
"target": getattr(task, "satelliteName", getattr(task, "target", "unknown")),
|
|
352
|
+
}
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
return tasks
|
|
356
|
+
|
|
357
|
+
@self.app.get("/api/logs")
|
|
358
|
+
async def get_logs(limit: int = 100):
|
|
359
|
+
"""Get recent log entries."""
|
|
360
|
+
if self.web_log_handler:
|
|
361
|
+
logs = self.web_log_handler.get_recent_logs(limit)
|
|
362
|
+
return {"logs": logs}
|
|
363
|
+
return {"logs": []}
|
|
364
|
+
|
|
365
|
+
@self.app.websocket("/ws")
|
|
366
|
+
async def websocket_endpoint(websocket: WebSocket):
|
|
367
|
+
"""WebSocket endpoint for real-time updates."""
|
|
368
|
+
await self.connection_manager.connect(websocket)
|
|
369
|
+
try:
|
|
370
|
+
# Send initial status
|
|
371
|
+
if self.daemon:
|
|
372
|
+
self._update_status_from_daemon()
|
|
373
|
+
await websocket.send_json({"type": "status", "data": self.status.dict()})
|
|
374
|
+
|
|
375
|
+
# Keep connection alive and listen for client messages
|
|
376
|
+
while True:
|
|
377
|
+
data = await websocket.receive_text()
|
|
378
|
+
# Handle client requests if needed
|
|
379
|
+
await websocket.send_json({"type": "pong", "data": data})
|
|
380
|
+
|
|
381
|
+
except WebSocketDisconnect:
|
|
382
|
+
self.connection_manager.disconnect(websocket)
|
|
383
|
+
except Exception as e:
|
|
384
|
+
CITRASCOPE_LOGGER.error(f"WebSocket error: {e}")
|
|
385
|
+
self.connection_manager.disconnect(websocket)
|
|
386
|
+
|
|
387
|
+
def _update_status_from_daemon(self):
|
|
388
|
+
"""Update status from daemon state."""
|
|
389
|
+
if not self.daemon:
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
try:
|
|
393
|
+
self.status.hardware_adapter = self.daemon.settings.hardware_adapter
|
|
394
|
+
|
|
395
|
+
if hasattr(self.daemon, "hardware_adapter") and self.daemon.hardware_adapter:
|
|
396
|
+
# Check telescope connection status
|
|
397
|
+
try:
|
|
398
|
+
self.status.telescope_connected = self.daemon.hardware_adapter.is_telescope_connected()
|
|
399
|
+
if self.status.telescope_connected:
|
|
400
|
+
# If connected, try to get position
|
|
401
|
+
ra, dec = self.daemon.hardware_adapter.get_telescope_direction()
|
|
402
|
+
self.status.telescope_ra = ra
|
|
403
|
+
self.status.telescope_dec = dec
|
|
404
|
+
except Exception:
|
|
405
|
+
self.status.telescope_connected = False
|
|
406
|
+
|
|
407
|
+
# Check camera connection status
|
|
408
|
+
try:
|
|
409
|
+
self.status.camera_connected = self.daemon.hardware_adapter.is_camera_connected()
|
|
410
|
+
except Exception:
|
|
411
|
+
self.status.camera_connected = False
|
|
412
|
+
|
|
413
|
+
if hasattr(self.daemon, "task_manager") and self.daemon.task_manager:
|
|
414
|
+
task_manager = self.daemon.task_manager
|
|
415
|
+
self.status.current_task = task_manager.current_task_id
|
|
416
|
+
with task_manager.heap_lock:
|
|
417
|
+
self.status.tasks_pending = len(task_manager.task_heap)
|
|
418
|
+
|
|
419
|
+
# Get ground station information from daemon (available after API validation)
|
|
420
|
+
if hasattr(self.daemon, "ground_station") and self.daemon.ground_station:
|
|
421
|
+
gs_record = self.daemon.ground_station
|
|
422
|
+
gs_id = gs_record.get("id")
|
|
423
|
+
gs_name = gs_record.get("name", "Unknown")
|
|
424
|
+
|
|
425
|
+
# Build the URL based on the API host (dev vs prod)
|
|
426
|
+
api_host = self.daemon.settings.host
|
|
427
|
+
base_url = DEV_APP_URL if "dev." in api_host else PROD_APP_URL
|
|
428
|
+
|
|
429
|
+
self.status.ground_station_id = gs_id
|
|
430
|
+
self.status.ground_station_name = gs_name
|
|
431
|
+
self.status.ground_station_url = f"{base_url}/ground-stations/{gs_id}" if gs_id else None
|
|
432
|
+
|
|
433
|
+
self.status.last_update = datetime.now().isoformat()
|
|
434
|
+
|
|
435
|
+
except Exception as e:
|
|
436
|
+
CITRASCOPE_LOGGER.error(f"Error updating status: {e}")
|
|
437
|
+
|
|
438
|
+
async def broadcast_status(self):
|
|
439
|
+
"""Broadcast current status to all connected clients."""
|
|
440
|
+
if self.daemon:
|
|
441
|
+
self._update_status_from_daemon()
|
|
442
|
+
await self.connection_manager.broadcast({"type": "status", "data": self.status.dict()})
|
|
443
|
+
|
|
444
|
+
async def broadcast_tasks(self):
|
|
445
|
+
"""Broadcast current task queue to all connected clients."""
|
|
446
|
+
if not self.daemon or not hasattr(self.daemon, "task_manager") or self.daemon.task_manager is None:
|
|
447
|
+
return
|
|
448
|
+
|
|
449
|
+
task_manager = self.daemon.task_manager
|
|
450
|
+
tasks = []
|
|
451
|
+
|
|
452
|
+
with task_manager.heap_lock:
|
|
453
|
+
for start_time, stop_time, task_id, task in task_manager.task_heap:
|
|
454
|
+
tasks.append(
|
|
455
|
+
{
|
|
456
|
+
"id": task_id,
|
|
457
|
+
"start_time": datetime.fromtimestamp(start_time, tz=timezone.utc).isoformat(),
|
|
458
|
+
"stop_time": (
|
|
459
|
+
datetime.fromtimestamp(stop_time, tz=timezone.utc).isoformat() if stop_time else None
|
|
460
|
+
),
|
|
461
|
+
"status": task.status,
|
|
462
|
+
"target": getattr(task, "satelliteName", getattr(task, "target", "unknown")),
|
|
463
|
+
}
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
await self.connection_manager.broadcast({"type": "tasks", "data": tasks})
|
|
467
|
+
|
|
468
|
+
async def broadcast_log(self, log_entry: dict):
|
|
469
|
+
"""Broadcast log entry to all connected clients."""
|
|
470
|
+
await self.connection_manager.broadcast({"type": "log", "data": log_entry})
|
citrascope/web/server.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Web server management for CitraScope."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import uvicorn
|
|
9
|
+
|
|
10
|
+
from citrascope.logging import CITRASCOPE_LOGGER, WebLogHandler
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CitraScopeWebServer:
|
|
14
|
+
"""Manages the web server and its configuration."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, daemon, host: str = "0.0.0.0", port: int = 24872):
|
|
17
|
+
self.daemon = daemon
|
|
18
|
+
self.host = host
|
|
19
|
+
self.port = port
|
|
20
|
+
self.web_app = None
|
|
21
|
+
self.web_log_handler = None
|
|
22
|
+
|
|
23
|
+
# Set up web log handler
|
|
24
|
+
self._setup_log_handler()
|
|
25
|
+
|
|
26
|
+
def _setup_log_handler(self):
|
|
27
|
+
"""Set up the web log handler."""
|
|
28
|
+
self.web_log_handler = WebLogHandler(max_logs=1000)
|
|
29
|
+
# Use a simpler format for web display
|
|
30
|
+
formatter = logging.Formatter("%(levelname)s - %(name)s - %(message)s")
|
|
31
|
+
self.web_log_handler.setFormatter(formatter)
|
|
32
|
+
# Set handler level to DEBUG so it captures everything
|
|
33
|
+
self.web_log_handler.setLevel(logging.DEBUG)
|
|
34
|
+
CITRASCOPE_LOGGER.addHandler(self.web_log_handler)
|
|
35
|
+
CITRASCOPE_LOGGER.info("Web log handler attached to CITRASCOPE_LOGGER")
|
|
36
|
+
|
|
37
|
+
def ensure_log_handler(self):
|
|
38
|
+
"""Ensure the web log handler is still attached to the logger."""
|
|
39
|
+
if self.web_log_handler and self.web_log_handler not in CITRASCOPE_LOGGER.handlers:
|
|
40
|
+
CITRASCOPE_LOGGER.addHandler(self.web_log_handler)
|
|
41
|
+
CITRASCOPE_LOGGER.info("Re-attached web log handler to CITRASCOPE_LOGGER")
|
|
42
|
+
|
|
43
|
+
def configure_uvicorn_logging(self):
|
|
44
|
+
"""Configure Uvicorn to use CITRASCOPE_LOGGER."""
|
|
45
|
+
uvicorn_logger = logging.getLogger("uvicorn")
|
|
46
|
+
uvicorn_logger.handlers = CITRASCOPE_LOGGER.handlers
|
|
47
|
+
uvicorn_logger.setLevel(CITRASCOPE_LOGGER.level)
|
|
48
|
+
uvicorn_logger.propagate = False
|
|
49
|
+
|
|
50
|
+
uvicorn_access = logging.getLogger("uvicorn.access")
|
|
51
|
+
uvicorn_access.handlers = CITRASCOPE_LOGGER.handlers
|
|
52
|
+
uvicorn_access.setLevel(CITRASCOPE_LOGGER.level)
|
|
53
|
+
uvicorn_access.propagate = False
|
|
54
|
+
|
|
55
|
+
uvicorn_error = logging.getLogger("uvicorn.error")
|
|
56
|
+
uvicorn_error.handlers = CITRASCOPE_LOGGER.handlers
|
|
57
|
+
uvicorn_error.setLevel(CITRASCOPE_LOGGER.level)
|
|
58
|
+
uvicorn_error.propagate = False
|
|
59
|
+
|
|
60
|
+
def start(self):
|
|
61
|
+
"""Start web server in a separate thread with its own event loop."""
|
|
62
|
+
|
|
63
|
+
def run_async_server():
|
|
64
|
+
loop = asyncio.new_event_loop()
|
|
65
|
+
asyncio.set_event_loop(loop)
|
|
66
|
+
try:
|
|
67
|
+
loop.run_until_complete(self.run())
|
|
68
|
+
except KeyboardInterrupt:
|
|
69
|
+
pass
|
|
70
|
+
finally:
|
|
71
|
+
loop.close()
|
|
72
|
+
|
|
73
|
+
thread = threading.Thread(target=run_async_server, daemon=True)
|
|
74
|
+
thread.start()
|
|
75
|
+
CITRASCOPE_LOGGER.info("Web server thread started")
|
|
76
|
+
# Give the web server a moment to start up
|
|
77
|
+
time.sleep(1)
|
|
78
|
+
|
|
79
|
+
async def run(self):
|
|
80
|
+
"""Run the web server."""
|
|
81
|
+
try:
|
|
82
|
+
from citrascope.web.app import CitraScopeWebApp
|
|
83
|
+
|
|
84
|
+
self.web_app = CitraScopeWebApp(daemon=self.daemon, web_log_handler=self.web_log_handler)
|
|
85
|
+
|
|
86
|
+
# Connect the log handler to the web app for broadcasting
|
|
87
|
+
# Pass the current event loop so logs can be broadcast from other threads
|
|
88
|
+
if self.web_log_handler:
|
|
89
|
+
current_loop = asyncio.get_event_loop()
|
|
90
|
+
self.web_log_handler.set_web_app(self.web_app, current_loop)
|
|
91
|
+
|
|
92
|
+
CITRASCOPE_LOGGER.info(f"Starting web server on http://{self.host}:{self.port}")
|
|
93
|
+
|
|
94
|
+
# Configure Uvicorn logging
|
|
95
|
+
self.configure_uvicorn_logging()
|
|
96
|
+
|
|
97
|
+
config = uvicorn.Config(self.web_app.app, host=self.host, port=self.port, log_config=None, access_log=True)
|
|
98
|
+
server = uvicorn.Server(config)
|
|
99
|
+
|
|
100
|
+
# Start status broadcast loop
|
|
101
|
+
asyncio.create_task(self._status_broadcast_loop())
|
|
102
|
+
|
|
103
|
+
await server.serve()
|
|
104
|
+
except Exception as e:
|
|
105
|
+
CITRASCOPE_LOGGER.error(f"Web server error: {e}", exc_info=True)
|
|
106
|
+
|
|
107
|
+
async def _status_broadcast_loop(self):
|
|
108
|
+
"""Periodically broadcast status and tasks to web clients."""
|
|
109
|
+
check_counter = 0
|
|
110
|
+
while True:
|
|
111
|
+
try:
|
|
112
|
+
await asyncio.sleep(2) # Update every 2 seconds
|
|
113
|
+
if self.web_app:
|
|
114
|
+
await self.web_app.broadcast_status()
|
|
115
|
+
await self.web_app.broadcast_tasks()
|
|
116
|
+
|
|
117
|
+
# Every 10 iterations (20 seconds), check if log handler is still attached
|
|
118
|
+
check_counter += 1
|
|
119
|
+
if check_counter >= 10:
|
|
120
|
+
check_counter = 0
|
|
121
|
+
self.ensure_log_handler()
|
|
122
|
+
except Exception as e:
|
|
123
|
+
CITRASCOPE_LOGGER.error(f"Status broadcast error: {e}")
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// API client for CitraScope backend
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wrapper around fetch that handles JSON parsing and error responses
|
|
5
|
+
* @param {string} url - The URL to fetch
|
|
6
|
+
* @param {object} options - Fetch options (method, headers, body, etc.)
|
|
7
|
+
* @returns {Promise<{ok: boolean, status: number, data: any}>}
|
|
8
|
+
*/
|
|
9
|
+
export async function fetchJSON(url, options = {}) {
|
|
10
|
+
try {
|
|
11
|
+
const response = await fetch(url, options);
|
|
12
|
+
const data = await response.json();
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
ok: response.ok,
|
|
16
|
+
status: response.status,
|
|
17
|
+
data: data
|
|
18
|
+
};
|
|
19
|
+
} catch (error) {
|
|
20
|
+
console.error(`API request failed: ${url}`, error);
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get current configuration
|
|
27
|
+
*/
|
|
28
|
+
export async function getConfig() {
|
|
29
|
+
const result = await fetchJSON('/api/config');
|
|
30
|
+
return result.data;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Save configuration
|
|
35
|
+
*/
|
|
36
|
+
export async function saveConfig(config) {
|
|
37
|
+
return await fetchJSON('/api/config', {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: {'Content-Type': 'application/json'},
|
|
40
|
+
body: JSON.stringify(config)
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Get configuration status
|
|
46
|
+
*/
|
|
47
|
+
export async function getConfigStatus() {
|
|
48
|
+
const result = await fetchJSON('/api/config/status');
|
|
49
|
+
return result.data;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get available hardware adapters
|
|
54
|
+
*/
|
|
55
|
+
export async function getHardwareAdapters() {
|
|
56
|
+
const result = await fetchJSON('/api/hardware-adapters');
|
|
57
|
+
return result.data;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get hardware adapter schema
|
|
62
|
+
*/
|
|
63
|
+
export async function getAdapterSchema(adapterName) {
|
|
64
|
+
const result = await fetchJSON(`/api/hardware-adapters/${adapterName}/schema`);
|
|
65
|
+
return result.data;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get task queue
|
|
70
|
+
*/
|
|
71
|
+
export async function getTasks() {
|
|
72
|
+
const result = await fetchJSON('/api/tasks');
|
|
73
|
+
return result.data;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get recent logs
|
|
78
|
+
*/
|
|
79
|
+
export async function getLogs(limit = 100) {
|
|
80
|
+
const result = await fetchJSON(`/api/logs?limit=${limit}`);
|
|
81
|
+
return result.data;
|
|
82
|
+
}
|