Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,8 @@ installer/startup-data-loader/load_data_progress.json
installer/data-downloader/data/scanner_status.json
installer/data-downloader/data/runs.json
installer/data-downloader/data/sensors.json

installer/*.dbc
# Keep example.dbc
!installer/example.dbc

2 changes: 1 addition & 1 deletion installer/data-downloader/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Both JSON files are shared through the `./data` directory so every service (fron

## Runtime behaviour

- `frontend` serves the compiled React bundle via nginx. The UI calls the API using the `VITE_API_BASE_URL` value that gets baked into the build (defaults to http://localhost:8000). Match this host in `ALLOWED_ORIGINS` so CORS preflights succeed when the UI hits the API from another port.
- `frontend` serves the compiled React bundle via nginx and now proxies `/api` requests (including `/api/scan` and `/api/scanner-status`) directly to the FastAPI container. When the UI is loaded from anything other than `localhost`, the client automatically falls back to relative `/api/...` calls so a single origin on a VPS still reaches the backend. Override `VITE_API_BASE_URL` if you want the UI to talk to a different host (for example when running `npm run dev` locally) and keep that host in `ALLOWED_ORIGINS`.
- `api` runs `uvicorn backend.app:app`, exposing
- `GET /api/runs` and `GET /api/sensors`
- `POST /api/runs/{key}/note` to persist notes per run
Expand Down
16 changes: 16 additions & 0 deletions installer/data-downloader/frontend/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ server {
root /usr/share/nginx/html;
index index.html;

# Proxy API + scanner-triggering endpoints to the FastAPI service
location /api/ {
proxy_pass http://data-downloader-api:8000;
proxy_http_version 1.1;
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_buffering off;
proxy_read_timeout 300;
}

location = /api {
return 301 /api/;
}

location / {
try_files $uri /index.html;
}
Expand Down
1 change: 1 addition & 0 deletions installer/data-downloader/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"luxon": "^3.5.0",
"lucide-react": "^0.446.0",
"papaparse": "^5.4.1",
"react": "^18.3.1",
Expand Down
9 changes: 8 additions & 1 deletion installer/data-downloader/frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@ import {
SensorsResponse
} from "./types";

const API_BASE = import.meta.env.VITE_API_BASE_URL?.replace(/\/$/, "") || "";
const RAW_API_BASE = import.meta.env.VITE_API_BASE_URL?.trim() ?? "";
const SANITIZED_API_BASE = RAW_API_BASE.replace(/\/$/, "");
const LOCAL_BASE_PATTERN = /:\/\/(localhost|127\.0\.0\.1|\[?::1]?)/i;
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
const runningOnLocalhost = typeof window !== "undefined" && LOCAL_HOSTS.has(window.location.hostname);
const preferRelativeBase =
SANITIZED_API_BASE === "" || (!runningOnLocalhost && LOCAL_BASE_PATTERN.test(SANITIZED_API_BASE));
const API_BASE = preferRelativeBase ? "" : SANITIZED_API_BASE;

async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
Expand Down
100 changes: 82 additions & 18 deletions installer/data-downloader/frontend/src/components/data-download.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { DateTime } from "luxon";
import Papa from "papaparse";
import { Download } from "lucide-react";
import Plot from "react-plotly.js";
Expand All @@ -20,23 +21,48 @@ interface Props {
externalSelection?: ExternalSelection;
}

const formatInputValue = (value: string) => {
const INPUT_FORMAT = "yyyy-LL-dd'T'HH:mm";

const getLocalTimeZone = () => {
if (typeof Intl === "undefined" || typeof Intl.DateTimeFormat === "undefined") {
return "UTC";
}
return Intl.DateTimeFormat().resolvedOptions().timeZone;
};

const normalizeZone = (zone?: string | null) => {
if (!zone) return null;
const trimmed = zone.trim();
return trimmed ? trimmed : null;
};

const formatInputValue = (value: string, timeZone?: string | null) => {
if (!value) return "";
const date = new Date(value);
return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
const base = DateTime.fromISO(value, { zone: "utc", setZone: true });
if (!base.isValid) return "";
const zone = timeZone ?? getLocalTimeZone();
return base.setZone(zone).toFormat(INPUT_FORMAT);
};

const toIsoString = (value: string) => {
const toIsoString = (value: string, timeZone?: string | null) => {
if (!value) return "";
const date = new Date(value);
return date.toISOString();
const zone = timeZone ?? getLocalTimeZone();
const dt = DateTime.fromFormat(value, INPUT_FORMAT, { zone });
if (!dt.isValid) return "";
return dt.toUTC().toISO({ suppressMilliseconds: true });
};

const toLocaleTimestamp = (value: string) =>
new Date(value).toLocaleString(undefined, { hour12: false });

const toUtcTooltip = (value: string) => {
const dt = DateTime.fromISO(value, { zone: "utc", setZone: true });
return dt.isValid ? `${dt.toFormat("yyyy-LL-dd HH:mm:ss")} UTC` : value;
};

export function DataDownload({ runs, sensors, externalSelection }: Props) {
const [selectedRunKey, setSelectedRunKey] = useState<string>("");
const [selectedRunTimezone, setSelectedRunTimezone] = useState<string | null>(null);
const [selectedSensor, setSelectedSensor] = useState<string>("");
const [startInput, setStartInput] = useState<string>("");
const [endInput, setEndInput] = useState<string>("");
Expand All @@ -49,6 +75,12 @@ export function DataDownload({ runs, sensors, externalSelection }: Props) {
const lastSelectionVersionRef = useRef<number | null>(null);
const lastSelectionIdentityRef = useRef<ExternalSelection | null>(null);
const lastAppliedRunKeyRef = useRef<string | null>(null);
const systemTimeZone = useMemo(() => getLocalTimeZone(), []);
const manualInputLabel = `Local time - ${systemTimeZone}`;
const timeInputLabelSuffix = selectedRunTimezone ?? manualInputLabel;
const timeMetaText = selectedRunTimezone
? `Times interpreted as ${selectedRunTimezone}.`
: `Times interpreted as ${systemTimeZone} (local system time).`;

useEffect(() => {
if (!selectedSensor && sensors.length > 0) {
Expand Down Expand Up @@ -86,28 +118,31 @@ export function DataDownload({ runs, sensors, externalSelection }: Props) {
setSelectedRunKey(runKey);
const runChanged = runKey !== lastAppliedRunKeyRef.current;
const matchedRun = runs.find((run) => run.key === runKey);
const zone = normalizeZone(matchedRun?.timezone);
setSelectedRunTimezone(zone);
const derivedStart = startUtc ?? matchedRun?.start_utc;
const derivedEnd = endUtc ?? matchedRun?.end_utc;

if (runChanged) {
if (derivedStart) {
setStartInput(formatInputValue(derivedStart));
setStartInput(formatInputValue(derivedStart, zone));
}
if (derivedEnd) {
setEndInput(formatInputValue(derivedEnd));
setEndInput(formatInputValue(derivedEnd, zone));
}
} else {
if (startUtc) {
setStartInput(formatInputValue(startUtc));
setStartInput(formatInputValue(startUtc, zone));
}
if (endUtc) {
setEndInput(formatInputValue(endUtc));
setEndInput(formatInputValue(endUtc, zone));
}
}

lastAppliedRunKeyRef.current = runKey;
} else {
setSelectedRunKey("");
setSelectedRunTimezone(null);
lastAppliedRunKeyRef.current = null;
if (startUtc) {
setStartInput(formatInputValue(startUtc));
Expand All @@ -121,9 +156,28 @@ export function DataDownload({ runs, sensors, externalSelection }: Props) {
const handleRunSelect = (runKey: string) => {
setSelectedRunKey(runKey);
const run = runs.find((r) => r.key === runKey);

if (run) {
setStartInput(formatInputValue(run.start_utc));
setEndInput(formatInputValue(run.end_utc));
// Selecting a run → format timestamps in run's zone
const zone = normalizeZone(run.timezone);
setSelectedRunTimezone(zone);
setStartInput(formatInputValue(run.start_utc, zone));
setEndInput(formatInputValue(run.end_utc, zone));
} else {
// Switching back to manual → convert existing inputs into local zone
const localZone = getLocalTimeZone();

const convertToLocal = (ts: string, prevZone: string | null) => {
if (!ts) return "";
const dt = DateTime.fromFormat(ts, INPUT_FORMAT, { zone: prevZone ?? localZone });
return dt.setZone(localZone).toFormat(INPUT_FORMAT);
};

setStartInput((prev) => convertToLocal(prev, selectedRunTimezone));
setEndInput((prev) => convertToLocal(prev, selectedRunTimezone));

// Now clear timezone
setSelectedRunTimezone(null);
}
};

Expand All @@ -136,10 +190,18 @@ export function DataDownload({ runs, sensors, externalSelection }: Props) {
setError(null);
try {
const parsedLimit = noLimit ? undefined : Number(limitInput) || undefined;
const zone = selectedRunTimezone;
const startIso = toIsoString(startInput, zone);
const endIso = toIsoString(endInput, zone);
if (!startIso || !endIso) {
setError("Unable to parse time selection. Please verify both timestamps.");
setLoading(false);
return;
}
const payload = {
signal: selectedSensor,
start: toIsoString(startInput),
end: toIsoString(endInput),
start: startIso,
end: endIso,
limit: parsedLimit,
no_limit: noLimit || undefined
};
Expand All @@ -164,10 +226,11 @@ export function DataDownload({ runs, sensors, externalSelection }: Props) {
{
x: series.map((point) => point.time),
y: series.map((point) => point.value),
customdata: series.map((point) => toUtcTooltip(point.time)),
type: "scatter",
mode: "lines",
line: { color: "#2563eb", width: 2 },
hovertemplate: "%{y}<br>%{x|%Y-%m-%d %H:%M:%S}<extra></extra>",
hovertemplate: "%{y}<br>%{customdata}<extra></extra>",
name: selectedSensor || "Sensor"
}
],
Expand All @@ -180,7 +243,7 @@ export function DataDownload({ runs, sensors, externalSelection }: Props) {
margin: { t: 10, r: 20, b: 40, l: 50, pad: 4 },
hovermode: "x unified",
xaxis: {
title: "Time",
title: "Time (UTC)",
type: "date",
tickformat: "%H:%M\n%b %d"
},
Expand Down Expand Up @@ -248,9 +311,10 @@ export function DataDownload({ runs, sensors, externalSelection }: Props) {
</option>
))}
</select>
<p className="selector-meta">{timeMetaText}</p>

<div className="selector-field">
<label className="selector-label">Start (UTC)</label>
<label className="selector-label">{`Start (${timeInputLabelSuffix})`}</label>
<input
type="datetime-local"
className="selector-input"
Expand Down Expand Up @@ -279,7 +343,7 @@ export function DataDownload({ runs, sensors, externalSelection }: Props) {
</label>
</div>
<div className="selector-field">
<label className="selector-label">End (UTC)</label>
<label className="selector-label">{`End (${timeInputLabelSuffix})`}</label>
<input
type="datetime-local"
className="selector-input"
Expand Down
Loading