diff --git a/src/App.js b/src/App.js
index dda415e..d7879c1 100644
--- a/src/App.js
+++ b/src/App.js
@@ -18,7 +18,13 @@ import {
saveDatasetAsJson,
saveToIndexedDB,
} from "./localStorage";
-import { exportToGoogleSheet, requestSheetsToken } from "./GoogleSheets";
+import {
+ exportToGoogleSheet,
+ requestSheetsToken,
+ extractSpreadsheetId,
+ importFromGoogleSheet,
+ getApiType,
+} from "./GoogleSheets";
import _ from "lodash";
import { getQueryStringValue, setQueryStringValue } from "./queryString";
import "./global.css";
@@ -177,6 +183,16 @@ class App extends React.Component {
this.updateMapAndAssociatedData();
});
+ const pendingSheetId = getQueryStringValue("sheetId");
+ if (pendingSheetId) {
+ // Remove the sheetId from the URL to prevent reload loops
+ const url = new URL(window.location.href);
+ url.searchParams.delete("sheetId");
+ window.history.replaceState({}, document.title, url.toString());
+
+ this.loadSharedSheet(pendingSheetId);
+ }
+
// Initialize the Broadcast Channel
this.syncChannel = new BroadcastChannel("app_playback_sync");
this.syncChannel.onmessage = this.handleSyncMessage;
@@ -203,6 +219,50 @@ class App extends React.Component {
}
};
+ loadSharedSheet = async (sheetId) => {
+ this.setState({ pendingSheetId: null });
+ try {
+ toast.info("Loading Google Sheet...");
+ const token = await requestSheetsToken();
+ const logs = await importFromGoogleSheet(sheetId, token);
+
+ const logsWithTimestamp = logs.map((logEntry) => ({
+ ...logEntry,
+ timestamp: logEntry.timestamp || new Date().toISOString(),
+ }));
+
+ await uploadCloudLogs(logsWithTimestamp, "_clipboard");
+
+ toast.success("Dataset from Google Sheet loaded. You can now Paste into any dataset.", { autoClose: false });
+ } catch (error) {
+ log(`Error loading shared sheet: ${error.message}`, error);
+
+ // If the browser blocks the popup (often happens on page load without user gesture)
+ if (error.message && error.message.includes("Failed to open login popup window")) {
+ toast.warning(
+
+ Popup blocked by browser.
+
+
+
+
,
+ { autoClose: false, closeOnClick: false }
+ );
+ } else {
+ toast.error(`Failed to load shared sheet: ${error.message}`);
+ }
+ }
+ };
+
updateToggleState(newValue, toggleName, jsonPaths) {
this.setState((prevState) => {
const newToggleOptions = {
@@ -587,12 +647,16 @@ class App extends React.Component {
try {
const token = await requestSheetsToken();
const sheetUrl = await exportToGoogleSheet(index, token);
+ const spreadsheetId = extractSpreadsheetId(sheetUrl);
+ const deepLink = `${window.location.origin}${window.location.pathname}?sheetId=${spreadsheetId}`;
toast.success(
Exported to{" "}
Google Sheet
+
+ Shareable Fleet Debugger URL {deepLink}
,
{ autoClose: false }
);
@@ -602,6 +666,71 @@ class App extends React.Component {
}
};
+ const handleCopyClick = async (e) => {
+ e.stopPropagation();
+ log(`Copy dataset ${index} initiated`);
+ this.setState({ activeMenuIndex: null });
+
+ try {
+ const data = await getUploadedData(index);
+ if (!data || !data.rawLogs || data.rawLogs.length === 0) {
+ toast.warning("Dataset is empty, nothing to copy.");
+ return;
+ }
+
+ let logsToCopy = data.rawLogs;
+ const { logTypes } = this.state.filters;
+ const totalFilterCount = Object.keys(logTypes).length;
+ const activeFilterCount = Object.values(logTypes).filter(Boolean).length;
+
+ if (activeFilterCount !== totalFilterCount) {
+ if (window.confirm("Do you want to copy only the currently filtered log types?")) {
+ logsToCopy = data.rawLogs.filter((logEntry) => {
+ const apiType = getApiType(logEntry);
+ return logTypes[apiType] !== false;
+ });
+ }
+ }
+
+ const dataToSave = { ...data, rawLogs: logsToCopy };
+ await saveToIndexedDB(dataToSave, "_clipboard");
+ toast.success(`Dataset ${index + 1} copied to clipboard!`);
+ } catch (error) {
+ log(`Error copying dataset: ${error.message}`, error);
+ toast.error(`Error copying dataset: ${error.message}`);
+ }
+ };
+
+ const handlePasteClick = async (e) => {
+ e.stopPropagation();
+ log(`Paste to dataset ${index} initiated`);
+ this.setState({ activeMenuIndex: null });
+
+ try {
+ const clipboardData = await getUploadedData("_clipboard");
+ if (!clipboardData || !clipboardData.rawLogs) {
+ toast.warning("Clipboard is empty.");
+ return;
+ }
+ await saveToIndexedDB(clipboardData, index);
+ toast.success(`Pasted into Dataset ${index + 1}!`);
+
+ this.setState(
+ (prevState) => {
+ const newUploadedDatasets = [...prevState.uploadedDatasets];
+ newUploadedDatasets[index] = "Uploaded";
+ return { uploadedDatasets: newUploadedDatasets };
+ },
+ () => {
+ this.switchDataset(index);
+ }
+ );
+ } catch (error) {
+ log(`Error pasting dataset: ${error.message}`, error);
+ toast.error(`Error pasting dataset: ${error.message}`);
+ }
+ };
+
const handlePruneClick = async (e) => {
e.stopPropagation();
log(`Prune initiated for dataset ${index}`);
@@ -680,6 +809,25 @@ class App extends React.Component {
}
try {
+ if (result.pasteClipboard) {
+ const clipboardData = await getUploadedData("_clipboard");
+ if (!clipboardData || !clipboardData.rawLogs) {
+ toast.warning("Clipboard is empty.");
+ return;
+ }
+ await saveToIndexedDB(clipboardData, index);
+ toast.success(`Pasted into Dataset ${index + 1}!`);
+ this.setState(
+ (prevState) => {
+ const newUploadedDatasets = [...prevState.uploadedDatasets];
+ newUploadedDatasets[index] = "Uploaded";
+ return { uploadedDatasets: newUploadedDatasets };
+ },
+ () => this.switchDataset(index)
+ );
+ return;
+ }
+
if (result.file) {
const uploadEvent = { target: { files: [result.file] } };
await this.handleFileUpload(uploadEvent, index);
@@ -706,7 +854,9 @@ class App extends React.Component {
newUploadedDatasets[index] = "Uploaded";
return { uploadedDatasets: newUploadedDatasets };
},
- () => this.switchDataset(index)
+ () => {
+ this.switchDataset(index);
+ }
);
}
} catch (error) {
@@ -748,29 +898,34 @@ class App extends React.Component {
isActive ? "dataset-button-active" : isUploaded ? "dataset-button-uploaded" : "dataset-button-empty"
}`}
>
- {isUploaded ? `Dataset ${index + 1}` : `Select Dataset ${index + 1}`}
-
+ {isUploaded ? `Dataset ${index + 1}` : `Select Data ${index + 1}`}
{isUploaded && isActive && (
▼
- {isMenuOpen && (
-
-
- Export File
-
-
- Export GSheet
-
-
- Prune
-
-
- Delete
-
-
- )}
)}
+ {isMenuOpen && (
+
+
+ Paste Dataset
+
+
+ Copy Dataset
+
+
+ Export File
+
+
+ Export GSheet
+
+
+ Prune
+
+
+ Delete
+
+
+ )}
);
@@ -858,6 +1013,7 @@ class App extends React.Component {
onLogsReceived: handleCloudLogsReceived,
onExtraLogsReceived: handleExtraLogsReceived,
onFileUpload: handleFileUpload,
+ onPasteClipboard: () => cleanupAndResolve({ pasteClipboard: true }),
hasExtraDataSource: HAS_EXTRA_DATA_SOURCE,
});
dialogRoot.render(datasetLoadingComponent);
diff --git a/src/DatasetLoading.js b/src/DatasetLoading.js
index 8504811..ecde73a 100644
--- a/src/DatasetLoading.js
+++ b/src/DatasetLoading.js
@@ -8,7 +8,7 @@ import { isTokenValid, fetchLogsWithToken, useCloudLoggingLogin, buildQueryFilte
import DatasetSideLoading from "./DatasetSideLoading";
import { GOOGLE_CLIENT_ID } from "./constants";
-const CloudLoggingFormComponent = ({ onLogsReceived, onFileUpload }) => {
+const CloudLoggingFormComponent = ({ onLogsReceived, onFileUpload, onPasteClipboard }) => {
const getStoredValue = (key, defaultValue = "") => localStorage.getItem(`datasetLoading_${key}`) || defaultValue;
const [fetching, setFetching] = useState(false);
@@ -162,7 +162,12 @@ const CloudLoggingFormComponent = ({ onLogsReceived, onFileUpload }) => {
)}
-
+
diff --git a/src/DatasetSideLoading.js b/src/DatasetSideLoading.js
index e8dafed..dc92ffb 100644
--- a/src/DatasetSideLoading.js
+++ b/src/DatasetSideLoading.js
@@ -1,7 +1,8 @@
// src/DatasetSideLoading.js
-import { useState } from "react";
+import { useState, useEffect } from "react";
import { toast } from "react-toastify";
import { useSheetsLogin, isSheetsTokenValid, getSheetsToken, importFromGoogleSheet } from "./GoogleSheets";
+import { getUploadedData } from "./localStorage";
import { log } from "./Utils";
/**
@@ -14,10 +15,21 @@ import { log } from "./Utils";
* @param {Function} props.setLocalError Callback to set error messages in the parent.
* @param {React.ReactNode} props.children The primary fetch button(s) to show alongside sideload buttons.
*/
-export const DatasetSideLoading = ({ onLogsReceived, onFileUpload, setLocalError, children }) => {
+export const DatasetSideLoading = ({ onLogsReceived, onFileUpload, setLocalError, onPasteClipboard, children }) => {
const [sheetFormVisible, setSheetFormVisible] = useState(false);
const [sheetUrl, setSheetUrl] = useState(localStorage.getItem("datasetLoading_sheetUrl") || "");
const [sheetLoading, setSheetLoading] = useState(false);
+ const [hasClipboard, setHasClipboard] = useState(false);
+
+ useEffect(() => {
+ getUploadedData("_clipboard")
+ .then((data) => {
+ if (data && data.rawLogs && data.rawLogs.length > 0) {
+ setHasClipboard(true);
+ }
+ })
+ .catch(() => {});
+ }, []);
const handleSheetImport = (token) => {
setSheetLoading(true);
@@ -69,6 +81,11 @@ export const DatasetSideLoading = ({ onLogsReceived, onFileUpload, setLocalError
<>
{children}
+ {onPasteClipboard && hasClipboard && (
+
+ )}
diff --git a/src/GoogleSheets.js b/src/GoogleSheets.js
index 33bcd53..562e934 100644
--- a/src/GoogleSheets.js
+++ b/src/GoogleSheets.js
@@ -25,7 +25,7 @@ const API_TYPE_REGEX_MAP = [
{ name: "updateTask", regex: /updateTask/i },
];
-function getApiType(logEntry) {
+export function getApiType(logEntry) {
const typeField = logEntry["@type"] || logEntry.jsonpayload?.["@type"] || "";
for (const { name, regex } of API_TYPE_REGEX_MAP) {
if (regex.test(typeField)) return name;
@@ -266,7 +266,7 @@ export async function exportToGoogleSheet(index, token) {
// --- Import ---
-function extractSpreadsheetId(input) {
+export function extractSpreadsheetId(input) {
const trimmed = input.trim();
const urlMatch = trimmed.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/);
if (urlMatch) return urlMatch[1];