diff --git a/packages/browser-tests/cypress/commands.js b/packages/browser-tests/cypress/commands.js
index 156208b2e..6b80eafbe 100644
--- a/packages/browser-tests/cypress/commands.js
+++ b/packages/browser-tests/cypress/commands.js
@@ -546,3 +546,35 @@ Cypress.Commands.add("createTabWithContent", (content, title) => {
cy.get(".chrome-tab[active] .chrome-tab-rename").should("not.be.visible");
}
});
+
+Cypress.Commands.add("uploadParquetFile", (filenames) => {
+ const files = Array.isArray(filenames) ? filenames : [filenames];
+ files.forEach((file) => {
+ cy.get('[data-hook="import-dropbox"] input[type="file"]')
+ .last()
+ .selectFile(`cypress/fixtures/${file}`, {
+ force: true,
+ });
+ });
+});
+
+Cypress.Commands.add("mockParquetUploadSuccess", (options = {}) => {
+ cy.intercept("POST", "**/api/v1/imports*", {
+ statusCode: 201,
+ body: {
+ data: [
+ {
+ type: "import",
+ id: options.fileName || "trades-original.parquet",
+ },
+ ],
+ },
+ }).as("parquetUpload");
+});
+
+Cypress.Commands.add("mockParquetUploadError", ({ status, body }) => {
+ cy.intercept("POST", "**/api/v1/imports*", {
+ statusCode: status,
+ body: body,
+ }).as("parquetUploadError");
+});
diff --git a/packages/browser-tests/cypress/fixtures/trades-original.parquet b/packages/browser-tests/cypress/fixtures/trades-original.parquet
new file mode 100644
index 000000000..d1d1b45b5
Binary files /dev/null and b/packages/browser-tests/cypress/fixtures/trades-original.parquet differ
diff --git a/packages/browser-tests/cypress/fixtures/trades-original2.parquet b/packages/browser-tests/cypress/fixtures/trades-original2.parquet
new file mode 100644
index 000000000..d1d1b45b5
Binary files /dev/null and b/packages/browser-tests/cypress/fixtures/trades-original2.parquet differ
diff --git a/packages/browser-tests/cypress/fixtures/trades-original3.parquet b/packages/browser-tests/cypress/fixtures/trades-original3.parquet
new file mode 100644
index 000000000..d1d1b45b5
Binary files /dev/null and b/packages/browser-tests/cypress/fixtures/trades-original3.parquet differ
diff --git a/packages/browser-tests/cypress/integration/console/import.spec.js b/packages/browser-tests/cypress/integration/console/import.spec.js
index 87d51997b..c2c051242 100644
--- a/packages/browser-tests/cypress/integration/console/import.spec.js
+++ b/packages/browser-tests/cypress/integration/console/import.spec.js
@@ -1,80 +1,460 @@
///
-describe("questdb import", () => {
- beforeEach(() => {
- cy.loadConsoleWithAuth();
- });
+describe("import", () => {
+ describe("CSV Import", () => {
+ beforeEach(() => {
+ cy.loadConsoleWithAuth();
+ });
- afterEach(() => {
- cy.loadConsoleWithAuth();
- cy.typeQueryDirectly("drop all tables;");
- cy.clickRunIconInLine(1);
- cy.getByDataHook("success-notification").should("be.visible");
- cy.clearEditor();
- });
+ afterEach(() => {
+ cy.loadConsoleWithAuth();
+ cy.typeQueryDirectly("drop all tables;");
+ cy.clickRunIconInLine(1);
+ cy.getByDataHook("success-notification").should("be.visible");
+ cy.clearEditor();
+ });
+
+ it("display import panel", () => {
+ cy.getByDataHook("import-panel-button").click();
+ cy.getByDataHook("import-csv-button").should("be.visible");
+ cy.getByDataHook("import-parquet-button").should("be.visible");
+
+ cy.getByDataHook("import-csv-button").click();
+ cy.getByDataHook("import-dropbox").should("be.visible");
+ cy.getByDataHook("import-browse-from-disk").should("be.visible");
+
+ cy.get('input[type="file"]').selectFile("cypress/fixtures/test.csv", {
+ force: true,
+ });
+ cy.getByDataHook("import-table-column-schema").should("be.visible");
+ cy.getByDataHook("import-table-column-owner").should("not.exist");
+ });
+
+ it("should import csv with a nanosecond timestamp", () => {
+ cy.getByDataHook("import-panel-button").click();
+ cy.getByDataHook("import-csv-button").should("be.visible");
+ cy.getByDataHook("import-parquet-button").should("be.visible");
+
+ cy.getByDataHook("import-csv-button").click();
+ cy.getByDataHook("import-dropbox").should("be.visible");
+ cy.getByDataHook("import-browse-from-disk").should("be.visible");
+
+ cy.get('input[type="file"]').selectFile("cypress/fixtures/nanos.csv", {
+ force: true,
+ });
+ cy.getByDataHook("import-table-column-schema").should("be.visible");
+ cy.getByDataHook("import-upload-button").should("be.enabled");
+ cy.getByDataHook("import-upload-button").click();
+
+ cy.getByDataHook("import-file-status").should(
+ "contain",
+ "Imported 7 rows"
+ );
+ cy.getByDataHook("schema-table-title").should("contain", "nanos.csv");
+
+ cy.getByDataHook("schema-table-title").dblclick();
+ cy.getByDataHook("schema-folder-title").contains("Columns").dblclick();
+ cy.get('[data-id="questdb:expanded:tables:nanos.csv:columns:timestamp"]')
+ .should("be.visible")
+ .should("contain", "timestamp")
+ .should("contain", "TIMESTAMP_NS");
+ cy.getByDataHook("designated-timestamp-icon").should("not.exist");
+
+ cy.getByDataHook("table-schema-dialog-trigger")
+ .should("be.visible")
+ .should("contain", "4 cols");
+ cy.getByDataHook("table-schema-dialog-trigger").click();
+
+ cy.getByDataHook("create-table-panel").should("be.visible");
+ cy.getByDataHook("table-schema-dialog-column-0").should("be.visible");
+ cy.get("input[name='schemaColumns.0.name']").should(
+ "have.value",
+ "timestamp"
+ );
+
+ cy.get("select[name='schemaColumns.0.type']")
+ .get("option[value='TIMESTAMP_NS']")
+ .should("be.selected");
+
+ cy.getByDataHook(
+ "table-schema-dialog-column-0-designated-button"
+ ).click();
+ cy.getByDataHook("form-submit-button").click();
+
+ cy.getByDataHook("create-table-panel").should("not.be.visible");
+
+ cy.get('select[name="overwrite"]').select("true");
- it("display import panel", () => {
- cy.getByDataHook("import-panel-button").click();
- cy.getByDataHook("import-dropbox").should("be.visible");
- cy.getByDataHook("import-browse-from-disk").should("be.visible");
+ cy.getByDataHook("import-upload-button").should("be.enabled");
+ cy.getByDataHook("import-upload-button").click();
- cy.get('input[type="file"]').selectFile("cypress/fixtures/test.csv", {
- force: true,
+ cy.getByDataHook("import-file-status").should(
+ "contain",
+ "Imported 7 rows"
+ );
+ cy.getByDataHook("designated-timestamp-icon").should("be.visible");
});
- cy.getByDataHook("import-table-column-schema").should("be.visible");
- cy.getByDataHook("import-table-column-owner").should("not.exist");
});
+ describe("Parquet import", () => {
+ beforeEach(() => {
+ cy.loadConsoleWithAuth();
+ });
+
+ describe("Basic Upload Operations", () => {
+ it("should display parquet import panel", () => {
+ cy.getByDataHook("import-panel-button").click();
+ cy.getByDataHook("import-csv-button").should("be.visible");
+ cy.getByDataHook("import-parquet-button").should("be.visible");
+
+ cy.getByDataHook("import-parquet-button").click();
+ cy.getByDataHook("import-dropbox").should("be.visible");
+ cy.getByDataHook("import-browse-from-disk").should("be.visible");
+ });
+
+ it("should successfully upload a single parquet file", () => {
+ cy.mockParquetUploadSuccess();
+ cy.getByDataHook("import-panel-button").click();
+ cy.getByDataHook("import-parquet-button").click();
+
+ cy.uploadParquetFile("trades-original.parquet");
+ cy.getByDataHook("import-parquet-file-name").should("be.visible");
+ cy.getByDataHook("import-parquet-file-name").should(
+ "contain",
+ "trades-original.parquet"
+ );
+
+ cy.getByDataHook("import-parquet-upload-all").click();
+ cy.wait("@parquetUpload");
+
+ cy.getByDataHook("import-parquet-status", { timeout: 3000 }).should(
+ "contain",
+ "Uploaded 1 file successfully"
+ );
+ });
+
+ it("should handle multiple parquet file uploads", () => {
+ cy.mockParquetUploadSuccess();
+ cy.getByDataHook("import-panel-button").click();
+ cy.getByDataHook("import-parquet-button").click();
+
+ cy.uploadParquetFile("trades-original.parquet");
+ cy.uploadParquetFile("trades-original2.parquet");
+ cy.uploadParquetFile("trades-original3.parquet");
- it("should import csv with a nanosecond timestamp", () => {
- cy.getByDataHook("import-panel-button").click();
- cy.getByDataHook("import-dropbox").should("be.visible");
- cy.getByDataHook("import-browse-from-disk").should("be.visible");
+ cy.getByDataHook("import-parquet-file-name").should("have.length", 3);
+ cy.getByDataHook("import-parquet-upload-all").click();
+ cy.wait("@parquetUpload");
+ cy.getByDataHook("import-parquet-status", { timeout: 3000 }).should(
+ "contain",
+ "Uploaded 3 files successfully"
+ );
- cy.get('input[type="file"]').selectFile("cypress/fixtures/nanos.csv", {
- force: true,
+ cy.getByDataHook("import-file-status").should(($statuses) => {
+ expect($statuses).to.have.length(3);
+ $statuses.each((_, status) => {
+ expect(status).to.contain.text("Imported");
+ });
+ });
+ });
});
- cy.getByDataHook("import-table-column-schema").should("be.visible");
- cy.getByDataHook("import-upload-button").should("be.enabled");
- cy.getByDataHook("import-upload-button").click();
- cy.getByDataHook("import-file-status").should("contain", "Imported 7 rows");
- cy.getByDataHook("schema-table-title").should("contain", "nanos.csv");
+ describe("Error Handling", () => {
+ it("should handle 409 conflict when file already exists", () => {
+ cy.mockParquetUploadError({
+ status: 409,
+ body: {
+ errors: [
+ {
+ meta: { name: "trades-original.parquet" },
+ detail: "file already exists [file=trades-original]",
+ status: "409",
+ },
+ ],
+ },
+ });
- cy.getByDataHook("schema-table-title").dblclick();
- cy.getByDataHook("schema-folder-title").contains("Columns").dblclick();
- cy.get('[data-id="questdb:expanded:tables:nanos.csv:columns:timestamp"]')
- .should("be.visible")
- .should("contain", "timestamp")
- .should("contain", "TIMESTAMP_NS");
- cy.getByDataHook("designated-timestamp-icon").should("not.exist");
+ cy.getByDataHook("import-panel-button").click();
+ cy.getByDataHook("import-parquet-button").click();
+ cy.uploadParquetFile("trades-original.parquet");
+ cy.getByDataHook("import-parquet-upload-all").click();
+ cy.wait("@parquetUploadError");
- cy.getByDataHook("table-schema-dialog-trigger")
- .should("be.visible")
- .should("contain", "4 cols");
- cy.getByDataHook("table-schema-dialog-trigger").click();
+ cy.getByDataHook("import-file-status").should("be.visible");
- cy.getByDataHook("create-table-panel").should("be.visible");
- cy.getByDataHook("table-schema-dialog-column-0").should("be.visible");
- cy.get("input[name='schemaColumns.0.name']").should(
- "have.value",
- "timestamp"
- );
+ cy.getByDataHook("import-file-status").within(() => {
+ cy.getByDataHook("import-file-status-expand").click({ force: true });
+ });
- cy.get("select[name='schemaColumns.0.type']")
- .get("option[value='TIMESTAMP_NS']")
- .should("be.selected");
+ cy.getByDataHook("import-file-status-details").should(
+ "contain",
+ "file already exists"
+ );
+ cy.getByDataHook("import-parquet-retry-upload").should("be.visible");
+ });
- cy.getByDataHook("table-schema-dialog-column-0-designated-button").click();
- cy.getByDataHook("form-submit-button").click();
+ it("should show network error message", () => {
+ cy.intercept("POST", "**/api/v1/imports*", (req) => {
+ req.destroy(); // Prevent the request from reaching the server
+ }).as("networkError");
+
+ cy.getByDataHook("import-panel-button").click();
+ cy.getByDataHook("import-parquet-button").click();
+ cy.uploadParquetFile("trades-original.parquet");
+ cy.getByDataHook("import-parquet-upload-all").click();
+ cy.wait("@networkError");
+
+ cy.getByDataHook("import-file-status").should("be.visible");
+ cy.getByDataHook("import-file-status").should("contain", "Cancelled");
+
+ cy.getByDataHook("import-parquet-status").should("be.visible");
+ cy.getByDataHook("import-parquet-status").should(
+ "contain",
+ "Upload error"
+ );
+ });
+
+ it("should handle server error gracefully", () => {
+ cy.mockParquetUploadError({
+ status: 500,
+ body: {
+ errors: [
+ {
+ meta: { name: "trades-original.parquet" },
+ detail: "internal server error",
+ status: "500",
+ },
+ ],
+ },
+ });
+
+ cy.getByDataHook("import-panel-button").click();
+ cy.getByDataHook("import-parquet-button").click();
+ cy.uploadParquetFile("trades-original.parquet");
+ cy.getByDataHook("import-parquet-upload-all").click();
+ cy.wait("@parquetUploadError");
+
+ cy.getByDataHook("import-file-status").should("be.visible");
+
+ cy.getByDataHook("import-file-status").within(() => {
+ cy.getByDataHook("import-file-status-expand").click({ force: true });
+ });
+
+ cy.getByDataHook("import-file-status-details").should(
+ "contain",
+ "internal server error"
+ );
+ });
+ });
- cy.getByDataHook("create-table-panel").should("not.be.visible");
+ describe("File Operations", () => {
+ it("should allow renaming files before upload", () => {
+ cy.getByDataHook("import-panel-button").click();
+ cy.getByDataHook("import-parquet-button").click();
+ cy.uploadParquetFile("trades-original.parquet");
+ cy.getByDataHook("import-parquet-file-name").should("be.visible");
- cy.get('select[name="overwrite"]').select("true");
+ cy.getByDataHook("import-parquet-rename-file").first().click();
- cy.getByDataHook("import-upload-button").should("be.enabled");
- cy.getByDataHook("import-upload-button").click();
+ cy.getByDataHook("import-parquet-rename-file-input").should(
+ "be.visible"
+ );
+ cy.getByDataHook("import-parquet-rename-file-input")
+ .clear()
+ .type("btc_trades_2024");
+ cy.getByDataHook("import-parquet-rename-file-submit").click();
- cy.getByDataHook("import-file-status").should("contain", "Imported 7 rows");
- cy.getByDataHook("designated-timestamp-icon").should("be.visible");
+ cy.getByDataHook("import-parquet-rename-file")
+ .first()
+ .should("contain", "btc_trades_2024");
+ });
+
+ it("should remove files from upload queue", () => {
+ cy.getByDataHook("import-panel-button").click();
+ cy.getByDataHook("import-parquet-button").click();
+
+ cy.uploadParquetFile("trades-original.parquet");
+ cy.uploadParquetFile("trades-original2.parquet");
+ cy.getByDataHook("import-parquet-file-name").should("have.length", 2);
+
+ cy.getByDataHook("import-parquet-remove-file").first().click();
+ cy.getByDataHook("import-parquet-file-name").should("have.length", 1);
+ cy.getByDataHook("import-parquet-file-name").should(
+ "not.contain",
+ "trades-original.parquet"
+ );
+
+ cy.getByDataHook("import-parquet-remove-file").should("have.length", 1);
+ cy.getByDataHook("import-parquet-remove-file").click();
+ cy.getByDataHook("import-dropbox").should("be.visible");
+ });
+
+ it("should respect overwrite mode in API calls", () => {
+ cy.intercept("POST", "**/api/v1/imports?overwrite=false", {
+ statusCode: 201,
+ body: { data: [{ type: "import", id: "trades-original.parquet" }] },
+ }).as("overwriteFalse");
+
+ cy.getByDataHook("import-panel-button").click();
+ cy.getByDataHook("import-parquet-button").click();
+ cy.uploadParquetFile("trades-original.parquet");
+ cy.getByDataHook("import-parquet-upload-all").click();
+ cy.wait("@overwriteFalse");
+
+ cy.getByDataHook("import-parquet-overwrite").click();
+
+ cy.intercept("POST", "**/api/v1/imports?overwrite=true", {
+ statusCode: 201,
+ body: { data: [{ type: "import", id: "trades-original2.parquet" }] },
+ }).as("overwriteTrue");
+
+ cy.uploadParquetFile("trades-original2.parquet");
+ cy.getByDataHook("import-parquet-upload-all").click();
+ cy.wait("@overwriteTrue");
+ });
+ });
+
+ describe("Batch Upload with Retry Mechanism", () => {
+ it("should continue with remaining files after partial failure", () => {
+ let callCount = 0;
+ cy.intercept("POST", "**/api/v1/imports*", (req) => {
+ if (callCount === 0) {
+ callCount++;
+ req.reply({
+ statusCode: 409,
+ body: {
+ errors: [
+ {
+ meta: { name: "trades-original2.parquet" },
+ detail:
+ "file already exists [file=trades-original2.parquet]",
+ status: "409",
+ },
+ ],
+ },
+ });
+ } else {
+ req.reply({
+ statusCode: 201,
+ body: {
+ data: [{ type: "import", id: "trades-original3.parquet" }],
+ },
+ });
+ }
+ }).as("batchUpload");
+
+ cy.getByDataHook("import-panel-button").click();
+ cy.getByDataHook("import-parquet-button").click();
+
+ cy.uploadParquetFile("trades-original.parquet");
+ cy.uploadParquetFile("trades-original2.parquet");
+ cy.uploadParquetFile("trades-original3.parquet");
+
+ cy.getByDataHook("import-parquet-upload-all").click();
+ cy.wait("@batchUpload");
+ cy.wait("@batchUpload");
+
+ cy.getByDataHook("import-file-status").should("have.length", 3);
+
+ cy.getByDataHook("import-file-status")
+ .eq(0)
+ .should("contain", "Imported");
+ cy.getByDataHook("import-file-status")
+ .eq(1)
+ .should("contain", "Upload error");
+ cy.getByDataHook("import-file-status")
+ .eq(2)
+ .should("contain", "Imported");
+ });
+
+ it("should allow retrying failed single file upload", () => {
+ cy.mockParquetUploadError({
+ status: 409,
+ body: {
+ errors: [
+ {
+ meta: { name: "trades-original.parquet" },
+ detail: "file already exists [file=trades-original.parquet]",
+ status: "409",
+ },
+ ],
+ },
+ });
+
+ cy.getByDataHook("import-panel-button").click();
+ cy.getByDataHook("import-parquet-button").click();
+ cy.uploadParquetFile("trades-original.parquet");
+ cy.getByDataHook("import-parquet-upload-all").click();
+ cy.wait("@parquetUploadError");
+
+ cy.getByDataHook("import-parquet-retry-upload").should("be.visible");
+
+ cy.mockParquetUploadSuccess();
+ cy.getByDataHook("import-parquet-retry-upload").click();
+ cy.wait("@parquetUpload");
+
+ cy.getByDataHook("import-file-status").should("contain", "Imported");
+ });
+ });
+
+ describe("UI States and Interactions", () => {
+ it("should show loading state during upload", () => {
+ cy.intercept("POST", "**/api/v1/imports*", (req) => {
+ req.reply({
+ delay: 2000,
+ statusCode: 201,
+ body: { data: [{ type: "import", id: "trades-original.parquet" }] },
+ });
+ }).as("slowUpload");
+
+ cy.getByDataHook("import-panel-button").click();
+ cy.getByDataHook("import-parquet-button").click();
+ cy.uploadParquetFile("trades-original.parquet");
+ cy.getByDataHook("import-parquet-upload-all").click();
+
+ cy.getByDataHook("import-parquet-upload-all").should("be.disabled");
+
+ cy.getByDataHook("import-parquet-status").should(
+ "contain",
+ "Uploading..."
+ );
+
+ cy.wait("@slowUpload");
+
+ cy.getByDataHook("import-parquet-upload-all").should("not.be.disabled");
+ });
+
+ it("should allow viewing data after successful upload", () => {
+ cy.getByDataHook("import-panel-button").click();
+ cy.getByDataHook("import-parquet-button").click();
+ cy.uploadParquetFile("trades-original.parquet");
+
+ cy.getByDataHook("import-parquet-overwrite").click();
+ cy.getByDataHook("import-parquet-upload-all").click();
+
+ cy.getByDataHook("import-parquet-view-data").first().click();
+ cy.getByDataHook("success-notification").should(
+ "contain",
+ "SELECT * FROM read_parquet('trades-original.parquet')"
+ );
+
+ cy.getGrid().should("be.visible");
+ cy.getColumnName(0).should("contain", "symbol");
+ });
+
+ it("should handle empty response gracefully", () => {
+ cy.intercept("POST", "**/api/v1/imports*", {
+ statusCode: 201,
+ body: {},
+ }).as("emptyResponse");
+
+ cy.getByDataHook("import-panel-button").click();
+ cy.getByDataHook("import-parquet-button").click();
+ cy.uploadParquetFile("trades-original.parquet");
+ cy.getByDataHook("import-parquet-upload-all").click();
+ cy.wait("@emptyResponse");
+
+ cy.getByDataHook("import-file-status").should("contain", "Imported");
+ });
+ });
});
});
diff --git a/packages/browser-tests/questdb b/packages/browser-tests/questdb
index c113299ed..6b16453c4 160000
--- a/packages/browser-tests/questdb
+++ b/packages/browser-tests/questdb
@@ -1 +1 @@
-Subproject commit c113299ed19ad020bc57415ef00658a2a17af147
+Subproject commit 6b16453c412a964f2f1bc8ea72f278bd26bac807
diff --git a/packages/web-console/assets/csv-file.svg b/packages/web-console/assets/csv-file.svg
new file mode 100644
index 000000000..7713717a7
--- /dev/null
+++ b/packages/web-console/assets/csv-file.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/packages/web-console/assets/parquet-file.svg b/packages/web-console/assets/parquet-file.svg
new file mode 100644
index 000000000..3c62a6676
--- /dev/null
+++ b/packages/web-console/assets/parquet-file.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/packages/web-console/serve-dist.js b/packages/web-console/serve-dist.js
index a1a5fe2c9..1e35140a4 100644
--- a/packages/web-console/serve-dist.js
+++ b/packages/web-console/serve-dist.js
@@ -22,7 +22,8 @@ const server = http.createServer((req, res) => {
reqPathName.startsWith("/settings") ||
reqPathName.startsWith("/warnings") ||
reqPathName.startsWith("/chk") ||
- reqPathName.startsWith("/imp")
+ reqPathName.startsWith("/imp") ||
+ reqPathName.startsWith("/api/")
) {
// proxy /exec requests to localhost:9000
const options = {
diff --git a/packages/web-console/src/components/Toast/index.tsx b/packages/web-console/src/components/Toast/index.tsx
index 4468e7ad0..9af7c356b 100644
--- a/packages/web-console/src/components/Toast/index.tsx
+++ b/packages/web-console/src/components/Toast/index.tsx
@@ -9,7 +9,7 @@ import {
} from "react-toastify"
import { useNotificationCenter as RTNotificationCenter } from "react-toastify/addons/use-notification-center"
import { NotificationCenterItem as RNotificationCenterItem } from "react-toastify/addons/use-notification-center/useNotificationCenter"
-import { BadgeType } from "../../scenes/Import/ImportCSVFiles/types"
+import { BadgeType } from "../../scenes/Import/FileStatus"
import {
CloseCircle,
ErrorWarning,
diff --git a/packages/web-console/src/consts/index.ts b/packages/web-console/src/consts/index.ts
index 481421172..b06b19892 100644
--- a/packages/web-console/src/consts/index.ts
+++ b/packages/web-console/src/consts/index.ts
@@ -38,4 +38,6 @@ export const API = `https://${BASE}.questdb.io`
// the console will understand
export const API_VERSION = "2"
+export const API_ROUTE_V1 = "/api/v1"
+
export const BUTTON_ICON_SIZE = "26px"
diff --git a/packages/web-console/src/modules/ZeroState/start.tsx b/packages/web-console/src/modules/ZeroState/start.tsx
index 3a3a48987..4939bdbb0 100644
--- a/packages/web-console/src/modules/ZeroState/start.tsx
+++ b/packages/web-console/src/modules/ZeroState/start.tsx
@@ -66,28 +66,32 @@ export const Start = () => {
+ onClick={() => {
+ dispatch(actions.console.setImportType("parquet"))
dispatch(actions.console.setActiveBottomPanel("import"))
- }
+ }}
>
- Import CSV
+ Import Parquet
dispatch(actions.console.setActiveSidebar("create"))}
+ onClick={() => {
+ dispatch(actions.console.setImportType("csv"))
+ dispatch(actions.console.setActiveBottomPanel("import"))
+ }}
>
- Create table
+ Import CSV
diff --git a/packages/web-console/src/scenes/Console/import.tsx b/packages/web-console/src/scenes/Console/import.tsx
deleted file mode 100644
index 21cf4a0e9..000000000
--- a/packages/web-console/src/scenes/Console/import.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import React from "react"
-import { PaneContent, PaneWrapper } from "../../components"
-import { ImportCSVFiles } from "../Import/ImportCSVFiles"
-import { eventBus } from "../../modules/EventBus"
-import { EventType } from "../../modules/EventBus/types"
-
-export const Import = () => (
-
-
- {
- eventBus.publish(EventType.MSG_QUERY_SCHEMA)
- }}
- onViewData={(result) => {
- if (result.status === "OK") {
- eventBus.publish(EventType.MSG_QUERY_SCHEMA)
- eventBus.publish(EventType.MSG_QUERY_FIND_N_EXEC, {
- query: `"${result.location}"`,
- options: { appendAt: "end" },
- })
- }
- }}
- />
-
-
-)
diff --git a/packages/web-console/src/scenes/Console/index.tsx b/packages/web-console/src/scenes/Console/index.tsx
index 31b499d86..8cf1182c9 100644
--- a/packages/web-console/src/scenes/Console/index.tsx
+++ b/packages/web-console/src/scenes/Console/index.tsx
@@ -13,11 +13,12 @@ import { actions, selectors } from "../../store"
import { Tooltip } from "../../components"
import { Sidebar } from "../../components/Sidebar"
import { Navigation } from "../../components/Sidebar/navigation"
-import { Database2, Grid, PieChart, Search, FileSearch } from "@styled-icons/remix-line"
+import { Database2, Grid, PieChart, FileSearch } from "@styled-icons/remix-line"
import { ResultViewMode } from "./types"
import { BUTTON_ICON_SIZE } from "../../consts"
import { PrimaryToggleButton } from "../../components"
-import { Import } from "./import"
+import { Import } from "../Import"
+import { DropdownMenu } from "../../components/DropdownMenu"
import { BottomPanel } from "../../store/Console/types"
import { Allotment, AllotmentHandle } from "allotment"
import { Import as ImportIcon } from "../../components/icons/import"
@@ -78,6 +79,7 @@ const Console = () => {
useLocalStorage()
const result = useSelector(selectors.query.getResult)
const activeBottomPanel = useSelector(selectors.console.getActiveBottomPanel)
+ const importType = useSelector(selectors.console.getImportType)
const { consoleConfig } = useSettings()
const { isSearchPanelOpen, setSearchPanelOpen, searchPanelRef } = useSearch()
@@ -231,29 +233,64 @@ const Console = () => {
{tooltipText}
))}
- {
- dispatch(actions.console.setActiveBottomPanel("import"))
- },
- })}
- selected={activeBottomPanel === "import"}
- data-hook="import-panel-button"
+ {consoleConfig.readOnly ? (
+
+
+
+ }
+ >
+
+ To use this feature, turn off read-only mode in the configuration file
+
+
+ ) : (
+
+
+
+
+
+
+ }
>
-
-
- }
- >
-
- {consoleConfig.readOnly
- ? "To use this feature, turn off read-only mode in the configuration file"
- : "Import files from CSV"}
-
-
+ Import data
+
+
+
+ {
+ dispatch(actions.console.setImportType("parquet"))
+ dispatch(actions.console.setActiveBottomPanel("import"))
+ }}
+ >
+ Import Parquet
+
+ {
+ dispatch(actions.console.setImportType("csv"))
+ dispatch(actions.console.setActiveBottomPanel("import"))
+ }}
+ >
+ Import CSV
+
+
+
+
+ )}
{result && }
@@ -262,7 +299,7 @@ const Console = () => {
-
+
diff --git a/packages/web-console/src/scenes/Import/ImportCSVFiles/dropbox.tsx b/packages/web-console/src/scenes/Import/Dropbox.tsx
similarity index 73%
rename from packages/web-console/src/scenes/Import/ImportCSVFiles/dropbox.tsx
rename to packages/web-console/src/scenes/Import/Dropbox.tsx
index a1f3596c2..8a12c20c1 100644
--- a/packages/web-console/src/scenes/Import/ImportCSVFiles/dropbox.tsx
+++ b/packages/web-console/src/scenes/Import/Dropbox.tsx
@@ -1,7 +1,6 @@
import React, { useRef, useState, useEffect } from "react"
import styled from "styled-components"
-import { Box } from "../../../components/Box"
-import { ProcessedFile } from "./types"
+import { Box } from "../../components/Box"
const getFileDuplicates = (
inputFiles: FileList,
@@ -13,39 +12,37 @@ const getFileDuplicates = (
return duplicates
}
-const Root = styled(Box).attrs({ flexDirection: "column" })<{
- isDragging: boolean
-}>`
+const Root = styled(Box).attrs({ flexDirection: "column" })<{ $isDragging: boolean }>`
flex: 1;
width: 100%;
- padding: 4rem 0 0;
gap: 2rem;
- background: ${({ theme }) => theme.color.backgroundLighter};
- border: 3px dashed ${({ isDragging }) => (isDragging ? "#7f839b" : "#333543")};
+ background: ${({ theme, $isDragging }) => $isDragging ? theme.color.selectionDarker : theme.color.backgroundLighter};
box-shadow: inset 0 0 10px 0 #1b1c23;
transition: all 0.15s ease-in-out;
`
type Props = {
- files: ProcessedFile[]
+ existingFileNames: string[]
onFilesDropped: (files: File[]) => void
- dialogOpen: boolean
+ dialogOpen?: boolean
+ enablePaste?: boolean
render: (props: {
duplicates: File[]
addToQueue: (inputFiles: FileList) => void
+ uploadInputRef: React.RefObject
}) => React.ReactNode
}
-export const DropBox = ({
- files,
+export const Dropbox = ({
+ existingFileNames,
onFilesDropped,
- dialogOpen,
+ dialogOpen = false,
+ enablePaste = true,
render,
}: Props) => {
const [isDragging, setIsDragging] = useState(false)
const [duplicates, setDuplicates] = useState([])
const uploadInputRef = useRef(null)
- const filenames = useRef(files.map((f) => f.table_name))
const handleDrag = (e: React.DragEvent) => {
e.preventDefault()
@@ -61,7 +58,7 @@ export const DropBox = ({
}
const addToQueue = (inputFiles: FileList) => {
- const duplicates = getFileDuplicates(inputFiles, filenames.current)
+ const duplicates = getFileDuplicates(inputFiles, existingFileNames)
setDuplicates(duplicates)
onFilesDropped(
Array.from(inputFiles).filter((f) => !duplicates.includes(f)),
@@ -78,22 +75,22 @@ export const DropBox = ({
}
useEffect(() => {
+ if (!enablePaste) return
+
return () => {
window.removeEventListener("paste", handlePaste)
}
- }, [])
+ }, [enablePaste])
useEffect(() => {
+ if (!enablePaste) return
+
if (dialogOpen) {
window.removeEventListener("paste", handlePaste)
} else {
window.addEventListener("paste", handlePaste)
}
- }, [dialogOpen])
-
- useEffect(() => {
- filenames.current = files.map((f) => f.table_name)
- }, [files])
+ }, [dialogOpen, enablePaste])
return (
- {render({ duplicates, addToQueue })}
+ {render({ duplicates, addToQueue, uploadInputRef })}
)
-}
+}
\ No newline at end of file
diff --git a/packages/web-console/src/scenes/Import/DropboxUploadArea.tsx b/packages/web-console/src/scenes/Import/DropboxUploadArea.tsx
new file mode 100644
index 000000000..d264e5c7a
--- /dev/null
+++ b/packages/web-console/src/scenes/Import/DropboxUploadArea.tsx
@@ -0,0 +1,97 @@
+import React from "react"
+import { Button, Heading, Text } from "@questdb/react-components"
+import { Box } from "../../components/Box"
+import { Upload2 } from "@styled-icons/remix-line"
+import styled from "styled-components"
+
+const BrowseTextLink = styled.span`
+ text-decoration: underline;
+ cursor: pointer;
+
+ &:hover {
+ text-decoration: none;
+ }
+`
+
+type Props = {
+ title: string
+ accept: string
+ uploadInputRef: React.RefObject
+ addToQueue: (inputFiles: FileList) => void
+ duplicates: File[]
+ mode?: "initial" | "list"
+ children?: React.ReactNode
+}
+
+export const DropboxUploadArea = ({
+ title,
+ accept,
+ uploadInputRef,
+ addToQueue,
+ duplicates,
+ mode = "initial",
+ children
+}: Props) => {
+ const handleFileChange = (e: React.ChangeEvent) => {
+ if (e.target.files) {
+ addToQueue(e.target.files)
+ e.target.value = ""
+ }
+ }
+
+ return (
+ <>
+
+
+ {mode === "initial" ? (
+
+
+
+
+ {title}
+
+
+
+ {duplicates.length > 0 && (
+
+ File{duplicates.length > 1 ? "s" : ""} already added to queue:{" "}
+ {duplicates.map((f) => f.name).join(", ")}. Change target table
+ name and try again.
+
+ )}
+ {children}
+
+ ) : (
+ <>
+
+ You can drag and drop more files or{" "}
+ uploadInputRef.current?.click()}>
+ browse from disk
+
+
+ {duplicates.length > 0 && (
+
+ File{duplicates.length > 1 ? "s" : ""} already added to queue:{" "}
+ {duplicates.map((f) => f.name).join(", ")}. Change import name
+ and try again.
+
+ )}
+ >
+ )}
+ >
+ )
+}
\ No newline at end of file
diff --git a/packages/web-console/src/scenes/Import/FileStatus.tsx b/packages/web-console/src/scenes/Import/FileStatus.tsx
new file mode 100644
index 000000000..33c589537
--- /dev/null
+++ b/packages/web-console/src/scenes/Import/FileStatus.tsx
@@ -0,0 +1,196 @@
+import React, { useState } from "react"
+import { FileCheckStatus as FileStatusType, CSVUploadResult } from "../../utils"
+import { Badge } from "@questdb/react-components"
+import { Box } from "../../components/Box"
+import styled from "styled-components"
+import { ChevronDown } from "@styled-icons/boxicons-solid"
+import { Error as ErrorIcon } from "@styled-icons/boxicons-regular"
+import { CheckboxCircle } from "@styled-icons/remix-fill"
+import { Text } from "../../components/Text"
+import { ColorShape } from "../../types/styled"
+import { ProcessedCSV } from "./ImportCSVFiles/types"
+import { ProcessedParquet } from "./ImportParquet/types"
+
+export enum BadgeType {
+ SUCCESS = "success",
+ INFO = "info",
+ WARNING = "warning",
+ ERROR = "error",
+}
+
+const CheckboxCircleIcon = styled(CheckboxCircle)`
+ color: ${({ theme }) => theme.color.green};
+`
+
+const ChevronIcon = styled(ChevronDown)<{ $expanded?: boolean; $color?: keyof ColorShape }>`
+ transform: rotate(${({ $expanded }) => $expanded ? "180deg" : "0deg"});
+ cursor: pointer;
+ position: relative;
+ z-index: 1;
+ color: ${({ $color, theme }) => theme.color[$color ?? "gray2"]};
+`
+
+const ExclamationCircleIcon = styled(ErrorIcon)`
+ color: ${({ theme }) => theme.color.red};
+`
+
+const StyledBadge = styled(Badge)`
+ display: flex;
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 0.5rem;
+ min-height: 3rem;
+ height: unset;
+`
+
+const StyledBox = styled(Box)`
+ gap: 0.5rem;
+ white-space: nowrap;
+ height: 3rem;
+`
+
+const FileTextBox = styled(Box)`
+ padding: 0.5rem 0;
+ text-align: left;
+ width: 350px;
+`
+
+const mapStatusToLabel = (
+ file: ProcessedCSV | ProcessedParquet,
+):
+ | {
+ label: string
+ type: BadgeType
+ icon?: React.ReactNode
+ }
+ | undefined => {
+ // For Parquet files
+ if ('file_name' in file) {
+ if (file.isUploading) {
+ return {
+ label: "Uploading...",
+ type: BadgeType.WARNING,
+ }
+ }
+ if (file.uploaded) {
+ return {
+ label: "Imported",
+ type: BadgeType.SUCCESS,
+ icon: ,
+ }
+ }
+ if (file.error) {
+ return {
+ label: "Upload error",
+ type: BadgeType.ERROR,
+ icon: ,
+ }
+ }
+ if (file.cancelled) {
+ return {
+ label: "Cancelled",
+ type: BadgeType.ERROR,
+ icon: ,
+ }
+ }
+ return {
+ label: "Ready to upload",
+ type: BadgeType.SUCCESS,
+ icon: ,
+ }
+ }
+ // For CSV files
+ else if (!file.isUploading && file.uploaded && file.uploadResult) {
+ let label = "Imported"
+ const csvResult = file.uploadResult
+ label += ` ${csvResult.rowsImported.toLocaleString()} row${
+ csvResult.rowsImported > 1 ||
+ csvResult.rowsImported === 0
+ ? "s"
+ : ""
+ }`
+ return {
+ label,
+ type: BadgeType.SUCCESS,
+ icon: ,
+ }
+ }
+
+ if (file.error) {
+ return {
+ label: "Upload error",
+ type: BadgeType.ERROR,
+ icon: ,
+ }
+ }
+
+ if (file.isUploading) {
+ return {
+ label: `Uploading: ${(file.uploadProgress || 0).toFixed(2)}%`,
+ type: BadgeType.WARNING,
+ }
+ }
+
+ if ('status' in file) {
+ switch (file.status) {
+ case FileStatusType.EXISTS:
+ return {
+ label: "Table already exists",
+ type: BadgeType.WARNING,
+ }
+ case FileStatusType.RESERVED_NAME:
+ return {
+ label: "Reserved table name",
+ type: BadgeType.ERROR,
+ }
+ case FileStatusType.DOES_NOT_EXIST:
+ return {
+ label: "Ready to upload",
+ type: BadgeType.SUCCESS,
+ }
+ }
+ }
+}
+
+const mapStatusToColor = (type: BadgeType): keyof ColorShape => {
+ switch (type) {
+ case BadgeType.SUCCESS:
+ return "green"
+ case BadgeType.ERROR:
+ return "red"
+ case BadgeType.WARNING:
+ return "orange"
+ case BadgeType.INFO:
+ return "cyan"
+ default:
+ return "gray2"
+ }
+}
+
+export const FileStatus = ({ file }: { file: ProcessedCSV | ProcessedParquet }) => {
+ const [expanded, setExpanded] = useState(false)
+ const mappedStatus = mapStatusToLabel(file)
+ const statusDetails = file.error
+
+ if (!mappedStatus) {
+ return null
+ }
+
+ return (
+
+
+
+ {mappedStatus.icon} {mappedStatus.label}
+ {statusDetails && setExpanded(!expanded)} $color={mapStatusToColor(mappedStatus.type)} />}
+
+ {expanded && statusDetails && (
+
+
+ {statusDetails}
+
+
+ )}
+
+
+ )
+}
diff --git a/packages/web-console/src/scenes/Import/ImportCSVFiles/CSVUploadList.tsx b/packages/web-console/src/scenes/Import/ImportCSVFiles/CSVUploadList.tsx
new file mode 100644
index 000000000..6d6dba6f5
--- /dev/null
+++ b/packages/web-console/src/scenes/Import/ImportCSVFiles/CSVUploadList.tsx
@@ -0,0 +1,316 @@
+import React, { useState, useEffect } from "react"
+import styled from "styled-components"
+import { Table, Button, Select } from "@questdb/react-components"
+import { Column } from "@questdb/react-components/dist/components/Table"
+import { Box } from "../../../components/Box"
+import { Text } from "../../../components/Text"
+import { Grid, Information } from "@styled-icons/remix-line"
+import { ProcessedCSV } from "./types"
+import { CSVUploadResult, UploadModeSettings } from "../../../utils"
+import { RenameTableDialog } from "./rename-table-dialog"
+import { FileStatus } from "../FileStatus"
+import { UploadActions } from "./upload-actions"
+import { UploadResultDialog } from "./upload-result-dialog"
+import { shortenText } from "../../../utils"
+import { bytesWithSuffix } from "../../../utils/bytesWithSuffix"
+import { PopperHover, Tooltip } from "../../../components"
+import { Dialog as TableSchemaDialog } from "../../../components/TableSchemaDialog/dialog"
+
+const StyledTable = styled(Table)`
+ width: 100%;
+ table-layout: fixed;
+ border-collapse: separate;
+ border-spacing: 0 2rem;
+
+ th {
+ padding: 0 1.5rem;
+ }
+
+ td {
+ padding: 1.5rem;
+ }
+
+ tbody td {
+ background: #242531;
+
+ &:first-child {
+ border-top-left-radius: ${({ theme }) => theme.borderRadius};
+ border-bottom-left-radius: ${({ theme }) => theme.borderRadius};
+ }
+
+ &:last-child {
+ border-top-right-radius: ${({ theme }) => theme.borderRadius};
+ border-bottom-right-radius: ${({ theme }) => theme.borderRadius};
+ }
+ }
+`
+
+const FileTextBox = styled(Box)`
+ padding: 0 1.1rem;
+`
+
+interface Props {
+ files: ProcessedCSV[]
+ ownedByList: string[]
+ onFileRemove: (id: string) => void
+ onFileUpload: (id: string) => void
+ onFilePropertyChange: (id: string, file: Partial) => void
+ onViewData: (query: string) => void
+ onDialogToggle: (open: boolean) => void
+}
+
+export const CSVUploadList = ({
+ files,
+ ownedByList,
+ onFileRemove,
+ onFileUpload,
+ onFilePropertyChange,
+ onViewData,
+ onDialogToggle,
+}: Props) => {
+ const [renameDialogOpen, setRenameDialogOpen] = useState()
+ const [schemaDialogOpen, setSchemaDialogOpen] = useState()
+
+ useEffect(() => {
+ onDialogToggle(renameDialogOpen !== undefined || schemaDialogOpen !== undefined)
+ }, [renameDialogOpen, schemaDialogOpen, onDialogToggle])
+
+ if (files.length === 0) {
+ return null
+ }
+
+ const columns: Column[] = [
+ {
+ header: "CSV File",
+ align: "flex-start",
+ width: "400px",
+ render: ({ data }) => {
+ const needsTooltip = data.fileObject.name.length > 20
+ const fileInfo = (
+
+
+ {shortenText(data.fileObject.name, 20)}
+
+
+ {bytesWithSuffix(data.fileObject.size)}
+
+
+ )
+
+ return (
+
+
+
+
+ {needsTooltip ? (
+
+ {data.fileObject.name}
+
+ ) : (
+ fileInfo
+ )}
+
+
+ {!data.isUploading && data.uploadResult && (
+ <>
+
+ }
+ onClick={() => {
+ const csvResult = data.uploadResult
+ onViewData(`"${csvResult?.location}"`)
+ }}
+ >
+ Result
+
+ >
+ )}
+
+
+
+ {data.uploadResult && data.uploadResult.rowsRejected > 0 && (
+
+
+ {data.uploadResult.rowsRejected.toLocaleString()} row
+ {data.uploadResult.rowsRejected > 1 ? "s" : ""} rejected
+
+
+ )}
+
+ )
+ },
+ },
+ {
+ header: "Table name",
+ align: "flex-end",
+ width: "180px",
+ render: ({ data }) => (
+ setRenameDialogOpen(f?.id)}
+ onNameChange={(name) => {
+ onFilePropertyChange(data.id, { table_name: name })
+ }}
+ file={data}
+ />
+ ),
+ },
+ ]
+
+ if (ownedByList.length > 0) {
+ columns.push({
+ header: (
+
+ Table owner
+
+
+ }
+ >
+ Required for external (non-database) users.
+
+ ),
+ align: "center",
+ width: "150px",
+ render: ({ data }) => (
+