Skip to content

Commit 15997e4

Browse files
authored
feat: Implement Time-To-Live (TTL) enforcement for specific datasets (#264)
* Implement Time-To-Live (TTL) enforcement for local datasets This change introduces a strict 55-day retention policy for local datasets to align with data privacy guidelines. - Datasets are assigned a retention date upon import (Oldest Log + 55 days). - Fresh imports get a minimum 1-hour grace period. - Expired datasets are automatically deleted on startup or when switching datasets. - Warnings are displayed 10 days before expiration. - Adds 'HAS_EXTRA_DATA_SOURCE' constant to simplify environment checks. * fix: clean up DatasetLoading and use HAS_EXTRA_DATA_SOURCE constant
1 parent b26c9e8 commit 15997e4

File tree

5 files changed

+153
-27
lines changed

5 files changed

+153
-27
lines changed

src/App.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { log } from "./Utils";
2424
import { toast, ToastContainer } from "react-toastify";
2525
import "react-toastify/dist/ReactToastify.css";
2626
import { ALL_TOGGLES, getVisibleToggles } from "./MapToggles";
27+
import { HAS_EXTRA_DATA_SOURCE } from "./constants";
2728

2829
const MARKER_COLORS = [
2930
"#EA4335", // Red
@@ -414,6 +415,22 @@ class App extends React.Component {
414415
const data = await getUploadedData(index);
415416
log(`Dataset ${index}:`, data);
416417
if (data && data.rawLogs && Array.isArray(data.rawLogs) && data.rawLogs.length > 0) {
418+
if (HAS_EXTRA_DATA_SOURCE && data.retentionDate) {
419+
const retentionDate = new Date(data.retentionDate);
420+
if (retentionDate <= new Date()) {
421+
log(`Startup: Dataset ${index} expired. Deleting...`);
422+
await deleteUploadedData(index);
423+
log(`Dataset ${index + 1} has expired and was deleted.`, "error");
424+
return { status: null, index };
425+
} else {
426+
const now = new Date();
427+
const timeLeftMs = retentionDate - now;
428+
const daysLeft = timeLeftMs / (1000 * 60 * 60 * 24);
429+
if (daysLeft <= 10) {
430+
log(`Dataset ${index + 1} will expire in ${Math.ceil(daysLeft)} days.`, "warn");
431+
}
432+
}
433+
}
417434
return { status: "Uploaded", index };
418435
}
419436
return { status: null, index };
@@ -786,8 +803,48 @@ class App extends React.Component {
786803
}
787804
};
788805

806+
checkAndEnforceTTL = async (index) => {
807+
if (!HAS_EXTRA_DATA_SOURCE) return "Valid";
808+
809+
try {
810+
const data = await getUploadedData(index);
811+
if (!data || !data.retentionDate) return "Valid";
812+
813+
const retentionDate = new Date(data.retentionDate);
814+
const now = new Date();
815+
const timeLeftMs = retentionDate - now;
816+
const daysLeft = timeLeftMs / (1000 * 60 * 60 * 24);
817+
818+
if (timeLeftMs <= 0) {
819+
log(`Dataset ${index} expired. Deleting...`);
820+
await deleteUploadedData(index);
821+
this.setState((prevState) => {
822+
const newUploadedDatasets = [...prevState.uploadedDatasets];
823+
newUploadedDatasets[index] = null;
824+
return {
825+
uploadedDatasets: newUploadedDatasets,
826+
activeDatasetIndex: prevState.activeDatasetIndex === index ? null : prevState.activeDatasetIndex,
827+
};
828+
});
829+
log(`Dataset ${index + 1} has expired and was deleted (Retention limit reached).`, "error");
830+
return "Expired";
831+
} else if (daysLeft <= 10) {
832+
log(`Dataset ${index + 1} will expire in ${Math.ceil(daysLeft)} days.`, "warn");
833+
return "Warning";
834+
}
835+
return "Valid";
836+
} catch (e) {
837+
console.error("Error verifying TTL:", e);
838+
return "Error";
839+
}
840+
};
841+
789842
switchDataset = async (index) => {
790843
log(`Attempting to switch to dataset ${index}`);
844+
845+
const ttlStatus = await this.checkAndEnforceTTL(index);
846+
if (ttlStatus === "Expired") return;
847+
791848
if (this.state.uploadedDatasets[index] !== "Uploaded") {
792849
console.error(`Attempted to switch to dataset ${index}, but it's not uploaded or is empty`);
793850
return;

src/DatasetLoading.js

Lines changed: 22 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,7 @@ import { log } from "./Utils";
66
import { toast, ToastContainer } from "react-toastify";
77
import "react-toastify/dist/ReactToastify.css";
88
import { isTokenValid, fetchLogsWithToken, useCloudLoggingLogin, buildQueryFilter } from "./CloudLogging";
9-
10-
const DATA_SOURCES = {
11-
CLOUD_LOGGING: "cloudLogging",
12-
EXTRA: "extra",
13-
};
9+
import { HAS_EXTRA_DATA_SOURCE } from "./constants";
1410

1511
const CloudLoggingFormComponent = ({ onLogsReceived, onFileUpload }) => {
1612
const getStoredValue = (key, defaultValue = "") => localStorage.getItem(`datasetLoading_${key}`) || defaultValue;
@@ -181,38 +177,37 @@ const CloudLoggingFormComponent = ({ onLogsReceived, onFileUpload }) => {
181177

182178
export default function DatasetLoading(props) {
183179
const [activeDataSource, setActiveDataSource] = useState(
184-
localStorage.getItem("datasetLoading_dataSource") ||
185-
(ExtraDataSource.isAvailable() ? DATA_SOURCES.EXTRA : DATA_SOURCES.CLOUD_LOGGING)
180+
localStorage.getItem("lastUsedDataSource") || (HAS_EXTRA_DATA_SOURCE ? "extra" : "cloudLogging")
186181
);
187182

188183
useEffect(() => {
189-
localStorage.setItem("datasetLoading_dataSource", activeDataSource);
184+
localStorage.setItem("lastUsedDataSource", activeDataSource);
190185
}, [activeDataSource]);
191186

192-
const isExtra = activeDataSource === DATA_SOURCES.EXTRA;
187+
const isExtra = activeDataSource === "extra";
193188
const ExtraFormComponent = isExtra ? ExtraDataSource.getFormComponent(props) : null;
194-
const isExtraAvailable = ExtraDataSource.isAvailable();
189+
190+
const renderSourceSelection = () => {
191+
if (!HAS_EXTRA_DATA_SOURCE) {
192+
return <button className="active static">Cloud Logging</button>;
193+
}
194+
195+
return (
196+
<>
197+
<button onClick={() => setActiveDataSource("cloudLogging")} className={!isExtra ? "active" : ""}>
198+
Cloud Logging
199+
</button>
200+
<button onClick={() => setActiveDataSource("extra")} className={isExtra ? "active" : ""}>
201+
{ExtraDataSource.getDisplayName()}
202+
</button>
203+
</>
204+
);
205+
};
195206

196207
return (
197208
<>
198209
<ToastContainer position="top-right" autoClose={5000} />
199-
<div className="data-source-toggle">
200-
{isExtraAvailable ? (
201-
<>
202-
<button
203-
onClick={() => setActiveDataSource(DATA_SOURCES.CLOUD_LOGGING)}
204-
className={!isExtra ? "active" : ""}
205-
>
206-
Cloud Logging
207-
</button>
208-
<button onClick={() => setActiveDataSource(DATA_SOURCES.EXTRA)} className={isExtra ? "active" : ""}>
209-
{ExtraDataSource.getDisplayName()}
210-
</button>
211-
</>
212-
) : (
213-
<button className="active static">Cloud Logging</button>
214-
)}
215-
</div>
210+
<div className="data-source-toggle">{renderSourceSelection()}</div>
216211

217212
{isExtra ? (
218213
ExtraFormComponent

src/constants.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
// constants.js
2+
import ExtraDataSource from "./ExtraDataSource";
23

34
export const DEFAULT_API_KEY = "";
45
export const DEFAULT_MAP_ID = "e6ead35a6ace8599";
6+
export const HAS_EXTRA_DATA_SOURCE = ExtraDataSource.isAvailable();

src/localStorage.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,27 @@ export function ensureCorrectFormat(data) {
337337

338338
if (!hasPoints) log("Bounds Calculation Failed: Could not find vehicle location data in any row.");
339339

340+
// Calculate retention date
341+
let oldestTimestamp = Infinity;
342+
logsArray.forEach((row) => {
343+
const ts = new Date(row.timestamp || row.insertTimestamp || row.jsonPayload?.timestamp).getTime();
344+
if (!isNaN(ts) && ts < oldestTimestamp) {
345+
oldestTimestamp = ts;
346+
}
347+
});
348+
349+
let retentionDateIdentifier = null;
350+
if (oldestTimestamp !== Infinity) {
351+
const fiftyFiveDaysMs = 55 * 24 * 60 * 60 * 1000;
352+
const oneHourMs = 60 * 60 * 1000;
353+
const expirationBasedOnLogs = oldestTimestamp + fiftyFiveDaysMs;
354+
const minimumRetention = Date.now() + oneHourMs;
355+
retentionDateIdentifier = new Date(Math.max(expirationBasedOnLogs, minimumRetention)).toISOString();
356+
} else {
357+
// If no valid timestamps found, default to 1 hour from now for safety
358+
retentionDateIdentifier = new Date(Date.now() + 60 * 60 * 1000).toISOString();
359+
}
360+
340361
return {
341362
APIKEY: DEFAULT_API_KEY,
342363
vehicle: "",
@@ -345,6 +366,7 @@ export function ensureCorrectFormat(data) {
345366
solutionType: solutionType,
346367
rawLogs: fullyNormalizedLogs,
347368
bounds: hasPoints ? bounds : null,
369+
retentionDate: retentionDateIdentifier,
348370
};
349371
}
350372

src/localStorage.test.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,53 @@ describe("ensureCorrectFormat realistic merge scenarios", () => {
208208
expect(finalPayload.request.vehicle.name).toBe("test-vehicle");
209209
});
210210
});
211+
212+
describe("ensureCorrectFormat TTL Logic", () => {
213+
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
214+
const FIFTY_FIVE_DAYS_MS = 55 * ONE_DAY_MS;
215+
const ONE_HOUR_MS = 60 * 60 * 1000;
216+
217+
it("should set retentionDate to Now + 1h for very old logs (Grace Period)", () => {
218+
const oldTimestamp = new Date(Date.now() - 100 * ONE_DAY_MS).toISOString();
219+
const mockLogs = [{ timestamp: oldTimestamp, jsonPayload: { test: 1 } }];
220+
const result = ensureCorrectFormat(mockLogs);
221+
222+
const retention = new Date(result.retentionDate).getTime();
223+
const expectedMin = Date.now() + ONE_HOUR_MS;
224+
225+
// Allow small delta for execution time
226+
expect(retention).toBeGreaterThanOrEqual(expectedMin - 1000);
227+
expect(retention).toBeLessThanOrEqual(expectedMin + 5000);
228+
});
229+
230+
it("should set retentionDate to Log + 55d for recent logs", () => {
231+
const tenDaysAgo = new Date(Date.now() - 10 * ONE_DAY_MS).getTime();
232+
const mockLogs = [{ timestamp: new Date(tenDaysAgo).toISOString(), jsonPayload: { test: 1 } }];
233+
const result = ensureCorrectFormat(mockLogs);
234+
235+
const retention = new Date(result.retentionDate).getTime();
236+
const expected = tenDaysAgo + FIFTY_FIVE_DAYS_MS;
237+
238+
expect(retention).toBe(expected);
239+
});
240+
241+
it("should default to Now + 1h if no timestamps found", () => {
242+
const mockLogs = [{ jsonPayload: { no_timestamp: true } }];
243+
const result = ensureCorrectFormat(mockLogs);
244+
245+
const retention = new Date(result.retentionDate).getTime();
246+
const expectedMin = Date.now() + ONE_HOUR_MS;
247+
248+
expect(retention).toBeGreaterThanOrEqual(expectedMin - 1000);
249+
expect(retention).toBeLessThanOrEqual(expectedMin + 5000);
250+
});
251+
252+
it("should store retentionDate as a valid ISO string", () => {
253+
const mockLogs = [{ timestamp: new Date().toISOString(), jsonPayload: { test: 1 } }];
254+
const result = ensureCorrectFormat(mockLogs);
255+
256+
expect(typeof result.retentionDate).toBe("string");
257+
// Simple regex to check ISO format YYYY-MM-DDTHH:mm:ss.sssZ
258+
expect(result.retentionDate).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/);
259+
});
260+
});

0 commit comments

Comments
 (0)