diff --git a/CLAUDE.md b/CLAUDE.md index 99f38ac..c48e4f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -777,13 +777,13 @@ services: - DATABASE_URL=sqlite:///data/astronome.db - RUST_LOG=info ports: - - "3301:3301" + - "3001:3001" frontend: build: ./frontend restart: unless-stopped ports: - - "3300:80" + - "3000:80" depends_on: - backend @@ -801,7 +801,7 @@ services: ```nginx # nginx-conf.conf — reverse proxy, single origin for browser -upstream backend { server backend:3301; } +upstream backend { server backend:3001; } upstream frontend { server frontend:80; } server { diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..6411ade --- /dev/null +++ b/NOTES.md @@ -0,0 +1,2 @@ +# gitea token +4fe62748b43ca89f8bb0472b810aac49fa1fac8d \ No newline at end of file diff --git a/backend/Cargo.toml b/backend/Cargo.toml index d0b2170..95e28f2 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "astronome" version = "0.1.0" -edition = "2021" +edition = "2024" [[bin]] name = "astronome" diff --git a/backend/Dockerfile b/backend/Dockerfile index d430cc9..13cffc7 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,21 +1,18 @@ -FROM rust:latest AS builder - +FROM rust:1.93.0-slim AS builder WORKDIR /app - -ENV CARGO_HOME=/usr/local/cargo -ENV CARGO_TARGET_DIR=/app/target - -RUN apt-get update && apt-get install -y pkg-config libssl-dev - +RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* COPY Cargo.toml ./ - -# clean build (important) -RUN cargo clean || true - +# Create dummy main to cache dependencies RUN mkdir src && echo "fn main() {}" > src/main.rs RUN cargo build --release - RUN rm -rf src COPY src ./src +# Force rebuild of main crate +RUN touch src/main.rs && cargo build --release -RUN cargo build --release \ No newline at end of file +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/* +WORKDIR /app +RUN mkdir -p /data/gallery +COPY --from=builder /app/target/release/astronome /usr/local/bin/ +CMD ["astronome"] diff --git a/backend/src/api/weather.rs b/backend/src/api/weather.rs index a45f60b..65eb6e2 100644 --- a/backend/src/api/weather.rs +++ b/backend/src/api/weather.rs @@ -1,5 +1,5 @@ use axum::{extract::State, Json}; -use chrono::{NaiveDateTime, Utc}; +use chrono::NaiveDateTime; use super::{AppError, AppState}; diff --git a/backend/src/astronomy/mod.rs b/backend/src/astronomy/mod.rs index 7e174f9..df7e8fc 100644 --- a/backend/src/astronomy/mod.rs +++ b/backend/src/astronomy/mod.rs @@ -5,8 +5,7 @@ pub mod solar; pub mod time; pub mod visibility; -pub use coords::{airmass, extinction_mag, radec_to_altaz}; -pub use horizon::{horizon_alt, HorizonPoint}; +pub use horizon::HorizonPoint; pub use lunar::{moon_age_days, moon_altitude, moon_illumination, moon_phase_name, moon_position, moon_rise_set, moon_separation}; pub use solar::astro_twilight; pub use time::julian_date; diff --git a/backend/src/catalog/filter.rs b/backend/src/catalog/filter.rs index 7c7483e..2d358e1 100644 --- a/backend/src/catalog/filter.rs +++ b/backend/src/catalog/filter.rs @@ -110,8 +110,8 @@ pub fn normalize_catalog_id(raw: &str) -> String { pub fn is_suitable(row: &RawCatalogRow) -> bool { // Validate RA/Dec exist — required for all objects - let Some(ra) = row.ra_deg() else { return false }; - let Some(dec) = row.dec_deg() else { return false }; + let Some(_ra) = row.ra_deg() else { return false }; + let Some(_dec) = row.dec_deg() else { return false }; // Declination constraint: −30° ≤ Dec ≤ +75° (spec §5.2) // if dec < -30.0 || dec > 75.0 { diff --git a/backend/src/catalog/vdb.rs b/backend/src/catalog/vdb.rs index 6cd763c..50bf6a1 100644 --- a/backend/src/catalog/vdb.rs +++ b/backend/src/catalog/vdb.rs @@ -55,7 +55,7 @@ fn parse_vizier_tsv(text: &str) -> Vec { let mut header: Vec = Vec::new(); let mut found_separator = false; - for (line_num, line) in text.lines().enumerate() { + for (_line_num, line) in text.lines().enumerate() { // Skip comment/meta lines if line.starts_with('#') { continue; diff --git a/backend/src/main.rs b/backend/src/main.rs index 77771a9..3b7e781 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -37,7 +37,7 @@ async fn main() -> anyhow::Result<()> { let app = api::build_router(pool).layer(cors); - let bind_addr = "0.0.0.0:3301"; + let bind_addr = "0.0.0.0:3001"; tracing::info!("Starting server on {}", bind_addr); let listener = tokio::net::TcpListener::bind(bind_addr).await?; diff --git a/backend/src/phd2/mod.rs b/backend/src/phd2/mod.rs index f5bdfe5..de083dc 100644 --- a/backend/src/phd2/mod.rs +++ b/backend/src/phd2/mod.rs @@ -181,11 +181,16 @@ pub fn parse_phd2_log(content: &str) -> anyhow::Result { snr_vals.iter().sum::() / snr_vals.len() as f64 }; - let duration_sec = match (first_time, last_time) { - (Some(f), Some(l)) => (l - f).max(0.0), - _ => 0.0, - }; - let duration_min = (duration_sec / 60.0) as u32; + // Try to extract duration from guiding session timestamps first + // If not available, fall back to CSV time span + let duration_min = extract_guiding_duration(content) + .unwrap_or_else(|| { + let duration_sec = match (first_time, last_time) { + (Some(f), Some(l)) => (l - f).max(0.0), + _ => 0.0, + }; + (duration_sec / 60.0) as u32 + }); // Simple linear drift: last half minus first half average let drift_ra = if n > 4.0 { @@ -304,3 +309,68 @@ fn extract_date_from_timestamp(timestamp: &str) -> Option { } None } + +fn extract_guiding_duration(content: &str) -> Option { + // Find first "Guiding Begins at YYYY-MM-DD HH:MM:SS" + // Find last "Guiding Ends at YYYY-MM-DD HH:MM:SS" + // Calculate duration from the time span + + let mut begins_timestamp: Option<&str> = None; + let mut ends_timestamp: Option<&str> = None; + + for line in content.lines() { + if line.contains("Guiding Begins at ") && begins_timestamp.is_none() { + if let Some(idx) = line.find("Guiding Begins at ") { + let ts = &line[idx + 18..]; + // Take first 19 chars: YYYY-MM-DD HH:MM:SS + if ts.len() >= 19 { + begins_timestamp = Some(&ts[..19]); + } + } + } + if line.contains("Guiding Ends at ") { + if let Some(idx) = line.find("Guiding Ends at ") { + let ts = &line[idx + 16..]; + // Take first 19 chars: YYYY-MM-DD HH:MM:SS + if ts.len() >= 19 { + ends_timestamp = Some(&ts[..19]); + } + } + } + } + + // If we have both timestamps, calculate duration + if let (Some(begin), Some(end)) = (begins_timestamp, ends_timestamp) { + // Parse timestamps like "2026-03-17 20:04:53" + if let (Some(begin_dt), Some(end_dt)) = (parse_phd2_timestamp(begin), parse_phd2_timestamp(end)) { + let duration_secs = (end_dt - begin_dt).max(0.0); + return Some((duration_secs / 60.0) as u32); + } + } + + None +} + +fn parse_phd2_timestamp(timestamp: &str) -> Option { + // Parse timestamp like "2026-03-17 20:04:53" to Unix seconds (or any consistent float) + // We just need the relative difference, so we can use a simple approach: + // Convert to total seconds for the day + if timestamp.len() < 19 { + return None; + } + + let year: u32 = timestamp[..4].parse().ok()?; + let month: u32 = timestamp[5..7].parse().ok()?; + let day: u32 = timestamp[8..10].parse().ok()?; + let hour: u32 = timestamp[11..13].parse().ok()?; + let min: u32 = timestamp[14..16].parse().ok()?; + let sec: u32 = timestamp[17..19].parse().ok()?; + + // Use chrono to parse it properly + use chrono::NaiveDate; + let naive_date = NaiveDate::from_ymd_opt(year as i32, month, day)?; + let naive_time = chrono::NaiveTime::from_hms_opt(hour, min, sec)?; + let naive_dt = chrono::NaiveDateTime::new(naive_date, naive_time); + + Some(naive_dt.and_utc().timestamp() as f64) +} diff --git a/data/astronome.db-shm b/data/astronome.db-shm new file mode 100644 index 0000000..147dfe2 Binary files /dev/null and b/data/astronome.db-shm differ diff --git a/data/astronome.db-wal b/data/astronome.db-wal new file mode 100644 index 0000000..d466e99 Binary files /dev/null and b/data/astronome.db-wal differ diff --git a/docker-compose.yml b/docker-compose.yml index 4829f45..5f790e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,9 @@ +version: '3.8' + services: backend: build: ./backend + container_name: astronome-backend restart: unless-stopped volumes: - ./data:/data @@ -8,23 +11,25 @@ services: - DATABASE_URL=sqlite:///data/astronome.db - RUST_LOG=info ports: - - "3301:3301" + - 3001:3001 frontend: build: ./frontend + container_name: astronome-frontend restart: unless-stopped ports: - - "3300:80" + - 3000:80 depends_on: - backend nginx: image: nginx:alpine + container_name: astronome-nginx restart: unless-stopped ports: - "80:80" volumes: - - ./nginx-conf.conf:/etc/nginx/nginx-conf.conf:ro + - ./nginx-config.conf:/etc/nginx/nginx-config.conf:ro depends_on: - backend - - frontend + - frontend \ No newline at end of file diff --git a/frontend/nginx-frontend.conf b/frontend/nginx-frontend.conf index 1202162..5755998 100644 --- a/frontend/nginx-frontend.conf +++ b/frontend/nginx-frontend.conf @@ -1,15 +1,28 @@ server { listen 80; + root /usr/share/nginx/html; index index.html; + default_type application/octet-stream; + + location /api/ { + proxy_pass http://backend:3001; + } + location / { try_files $uri $uri/ /index.html; } - # Cache static assets + location = /index.html { + expires -1; + add_header Cache-Control "no-cache"; + } + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { expires 1y; add_header Cache-Control "public, immutable"; } -} + + add_header X-Content-Type-Options nosniff; +} \ No newline at end of file diff --git a/frontend/src/components/phd2/PHD2UploadZone.tsx b/frontend/src/components/phd2/PHD2UploadZone.tsx index 6f49bbb..abc9210 100644 --- a/frontend/src/components/phd2/PHD2UploadZone.tsx +++ b/frontend/src/components/phd2/PHD2UploadZone.tsx @@ -2,6 +2,20 @@ import { useRef, useState } from 'react'; import { api } from '../../api'; import { useQueryClient } from '@tanstack/react-query'; +interface UploadResult { + filename: string; + rms_total: number; + rms_ra: number; + rms_dec: number; + duration_min?: number; + camera_name?: string; + exposure_ms?: number; + mount_name?: string; + session_date?: string; + error?: string; + duplicate?: { id: number; message: string }; +} + interface Props { onUploaded?: (id: number) => void; } @@ -9,54 +23,64 @@ interface Props { export default function PHD2UploadZone({ onUploaded }: Props) { const inputRef = useRef(null); const [uploading, setUploading] = useState(false); - const [error, setError] = useState(null); - const [duplicate, setDuplicate] = useState<{ id: number; message: string } | null>(null); - const [result, setResult] = useState<{ - rms_total: number; - rms_ra: number; - rms_dec: number; - duration_min?: number; - camera_name?: string; - exposure_ms?: number; - mount_name?: string; - session_date?: string; - } | null>(null); + const [results, setResults] = useState([]); const qc = useQueryClient(); - const handleFile = async (file: File) => { - setUploading(true); - setError(null); - setDuplicate(null); - setResult(null); - const fd = new FormData(); - fd.append('file', file); + const handleFiles = async (files: FileList | null) => { + if (!files || files.length === 0) return; - try { - const res = await api.phd2.upload(fd); - - if (res.duplicate) { - setDuplicate({ - id: res.duplicate_id || 0, - message: res.message || `Duplicate session detected (ID: ${res.duplicate_id})` - }); - setResult(null); - } else { - const analysis = res.analysis as any; - setResult({ - rms_total: analysis.rms_total_arcsec, - rms_ra: analysis.rms_ra_arcsec, - rms_dec: analysis.rms_dec_arcsec, - duration_min: analysis.duration_min, - camera_name: analysis.camera_name, - exposure_ms: analysis.exposure_ms, - mount_name: analysis.mount_name, - }); - qc.invalidateQueries({ queryKey: ['phd2'] }); - onUploaded?.(res.id); + setUploading(true); + setResults([]); + + const uploadedIds: number[] = []; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + const fd = new FormData(); + fd.append('file', file); + + try { + const res = await api.phd2.upload(fd); + + if (res.duplicate) { + setResults(prev => [...prev, { + filename: file.name, + rms_total: 0, + rms_ra: 0, + rms_dec: 0, + duplicate: { + id: res.duplicate_id || 0, + message: res.message || `Duplicate session detected (ID: ${res.duplicate_id})` + } + }]); + } else { + const analysis = res.analysis as any; + setResults(prev => [...prev, { + filename: file.name, + rms_total: analysis.rms_total_arcsec, + rms_ra: analysis.rms_ra_arcsec, + rms_dec: analysis.rms_dec_arcsec, + duration_min: analysis.duration_min, + camera_name: analysis.camera_name, + exposure_ms: analysis.exposure_ms, + mount_name: analysis.mount_name, + session_date: analysis.session_date, + }]); + uploadedIds.push(res.id); + } + } catch (e) { + setResults(prev => [...prev, { + filename: file.name, + rms_total: 0, + rms_ra: 0, + rms_dec: 0, + error: `Parse failed: ${e instanceof Error ? e.message : 'Unknown error'}` + }]); } - } catch (e) { - setError(`Parse failed: ${e instanceof Error ? e.message : 'Unknown error'}`); } + + qc.invalidateQueries({ queryKey: ['phd2'] }); + uploadedIds.forEach(id => onUploaded?.(id)); setUploading(false); }; @@ -75,39 +99,54 @@ export default function PHD2UploadZone({ onUploaded }: Props) { background: 'var(--bg-deep)', }} > - {uploading ? 'Parsing PHD2 log...' : '↑ Upload PHD2 log (.log)'} + {uploading ? 'Parsing PHD2 logs...' : '↑ Upload PHD2 log(s) (.log)'} e.target.files?.[0] && handleFile(e.target.files[0])} + onChange={e => handleFiles(e.target.files)} /> - {error &&
{error}
} - {duplicate && ( -
- ⚠ {duplicate.message} -
- )} - {result && ( -
-
✓ RMS Total: {result.rms_total.toFixed(2)}″ (RA: {result.rms_ra.toFixed(2)}″ Dec: {result.rms_dec.toFixed(2)}″)
- {result.session_date && ( -
Date: {result.session_date}
+ {results.map((result, idx) => ( +
+
+ {result.filename} +
+ + {result.error && ( +
+ ✗ {result.error} +
)} - {result.duration_min !== undefined && ( -
Duration: {result.duration_min}m
+ + {result.duplicate && ( +
+ ⚠ {result.duplicate.message} +
)} - {(result.camera_name || result.mount_name) && ( -
- {result.camera_name &&
Camera: {result.camera_name}
} - {result.mount_name &&
Mount: {result.mount_name}
} - {result.exposure_ms &&
Exposure: {result.exposure_ms}ms
} + + {!result.error && !result.duplicate && ( +
+
✓ RMS: {result.rms_total.toFixed(2)}″ (RA: {result.rms_ra.toFixed(2)}″ Dec: {result.rms_dec.toFixed(2)}″)
+ {result.session_date && ( +
Date: {result.session_date}
+ )} + {result.duration_min !== undefined && ( +
Duration: {result.duration_min}m
+ )} + {(result.camera_name || result.mount_name) && ( +
+ {result.camera_name &&
Camera: {result.camera_name}
} + {result.mount_name &&
Mount: {result.mount_name}
} + {result.exposure_ms &&
Exposure: {result.exposure_ms}ms
} +
+ )}
)}
- )} + ))}
); } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index bd97144..7a1efb1 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ server: { proxy: { '/api': { - target: 'http://localhost:3301', + target: 'http://localhost:3001', changeOrigin: true, }, }, diff --git a/nginx-conf.conf b/nginx-conf.conf deleted file mode 100644 index 00a86a3..0000000 --- a/nginx-conf.conf +++ /dev/null @@ -1,25 +0,0 @@ -events { - worker_connections 1024; -} - -http { - upstream backend { server backend:3301; } - upstream frontend { server frontend:80; } - - server { - listen 80; - client_max_body_size 60M; - - location /api/ { - proxy_pass http://backend; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_read_timeout 60s; - } - - location / { - proxy_pass http://frontend; - proxy_set_header Host $host; - } - } -} diff --git a/nginx-config.conf b/nginx-config.conf new file mode 100644 index 0000000..6b0a475 --- /dev/null +++ b/nginx-config.conf @@ -0,0 +1,39 @@ +events { + worker_connections 1024; +} + +http { + upstream backend { server backend:3001; } + upstream frontend { server frontend:80; } + + server { + listen 80; + server_name _; + + client_max_body_size 60M; + + location /api/ { + proxy_pass http://backend:3001/; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_connect_timeout 10s; + proxy_read_timeout 60s; + + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate"; + add_header Pragma "no-cache"; + add_header Expires 0; + + proxy_set_header If-Modified-Since ""; + proxy_set_header If-None-Match ""; + } + + location / { + proxy_pass http://frontend; + proxy_set_header Host $host; + } + } +} \ No newline at end of file