@datarecce/ui 0.1.40 → 0.1.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/api.d.mts +1 -1
  2. package/dist/{components-DTLQ2djq.js → components-DfXnN1Hx.js} +592 -13
  3. package/dist/components-DfXnN1Hx.js.map +1 -0
  4. package/dist/{components-B6oaPB5f.mjs → components-jh6r4tQn.mjs} +593 -14
  5. package/dist/components-jh6r4tQn.mjs.map +1 -0
  6. package/dist/components.d.mts +1 -1
  7. package/dist/components.js +1 -1
  8. package/dist/components.mjs +1 -1
  9. package/dist/hooks.d.mts +1 -1
  10. package/dist/{index-CbF0x3kW.d.mts → index-B5bpmv0i.d.mts} +70 -70
  11. package/dist/{index-CbF0x3kW.d.mts.map → index-B5bpmv0i.d.mts.map} +1 -1
  12. package/dist/index-B9lSPJTi.d.ts.map +1 -1
  13. package/dist/index.d.mts +1 -1
  14. package/dist/index.js +1 -1
  15. package/dist/index.mjs +1 -1
  16. package/dist/theme.d.mts +1 -1
  17. package/dist/types.d.mts +1 -1
  18. package/package.json +1 -1
  19. package/recce-source/docs/plans/2024-12-31-csv-download-design.md +121 -0
  20. package/recce-source/docs/plans/2024-12-31-csv-download-implementation.md +930 -0
  21. package/recce-source/js/src/components/run/RunResultPane.tsx +138 -14
  22. package/recce-source/js/src/lib/csv/extractors.test.ts +456 -0
  23. package/recce-source/js/src/lib/csv/extractors.ts +468 -0
  24. package/recce-source/js/src/lib/csv/format.test.ts +211 -0
  25. package/recce-source/js/src/lib/csv/format.ts +44 -0
  26. package/recce-source/js/src/lib/csv/index.test.ts +155 -0
  27. package/recce-source/js/src/lib/csv/index.ts +109 -0
  28. package/recce-source/js/src/lib/hooks/useCSVExport.ts +136 -0
  29. package/recce-source/recce/mcp_server.py +54 -30
  30. package/recce-source/recce/models/check.py +10 -2
  31. package/dist/components-B6oaPB5f.mjs.map +0 -1
  32. package/dist/components-DTLQ2djq.js.map +0 -1
@@ -0,0 +1,44 @@
1
+ /**
2
+ * CSV formatting utilities with Excel-friendly output
3
+ */
4
+
5
+ /**
6
+ * Escape a value for CSV format
7
+ * - Wrap in quotes if contains comma, quote, or newline
8
+ * - Escape quotes by doubling them
9
+ */
10
+ function escapeCSVValue(value: unknown): string {
11
+ if (value === null || value === undefined) {
12
+ return "";
13
+ }
14
+
15
+ const stringValue =
16
+ typeof value === "object" ? JSON.stringify(value) : String(value);
17
+
18
+ // Check if escaping is needed
19
+ if (
20
+ stringValue.includes(",") ||
21
+ stringValue.includes('"') ||
22
+ stringValue.includes("\n") ||
23
+ stringValue.includes("\r")
24
+ ) {
25
+ return `"${stringValue.replace(/"/g, '""')}"`;
26
+ }
27
+
28
+ return stringValue;
29
+ }
30
+
31
+ /**
32
+ * Convert tabular data to CSV string
33
+ * @param columns - Column headers
34
+ * @param rows - Row data (array of arrays)
35
+ * @returns CSV string with UTF-8 BOM for Excel compatibility
36
+ */
37
+ export function toCSV(columns: string[], rows: unknown[][]): string {
38
+ const BOM = "\uFEFF";
39
+
40
+ const headerRow = columns.map(escapeCSVValue).join(",");
41
+ const dataRows = rows.map((row) => row.map(escapeCSVValue).join(","));
42
+
43
+ return BOM + [headerRow, ...dataRows].join("\r\n");
44
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Tests for CSV utility functions
3
+ */
4
+ import { generateCSVFilename, generateTimestamp } from "./index";
5
+
6
+ describe("generateTimestamp", () => {
7
+ test("should return timestamp in YYYYMMDD-HHmmss format", () => {
8
+ const timestamp = generateTimestamp();
9
+
10
+ // Should match format like 20240101-123456
11
+ expect(timestamp).toMatch(/^\d{8}-\d{6}$/);
12
+ });
13
+
14
+ test("should generate consistent length timestamps", () => {
15
+ const timestamps = Array.from({ length: 5 }, () => generateTimestamp());
16
+
17
+ for (const ts of timestamps) {
18
+ expect(ts.length).toBe(15); // 8 + 1 + 6 = 15 characters
19
+ }
20
+ });
21
+ });
22
+
23
+ describe("generateCSVFilename", () => {
24
+ describe("basic filename generation", () => {
25
+ test("should include run type in filename", () => {
26
+ const filename = generateCSVFilename("query_diff", {});
27
+
28
+ expect(filename).toMatch(/^query-diff-.*\.csv$/);
29
+ });
30
+
31
+ test("should replace underscores with hyphens in run type", () => {
32
+ const filename = generateCSVFilename("row_count_diff", {});
33
+
34
+ expect(filename).toContain("row-count-diff");
35
+ expect(filename).not.toContain("_");
36
+ });
37
+
38
+ test("should end with .csv extension", () => {
39
+ const filename = generateCSVFilename("query", {});
40
+
41
+ expect(filename).toMatch(/\.csv$/);
42
+ });
43
+
44
+ test("should include timestamp", () => {
45
+ const filename = generateCSVFilename("query", {});
46
+
47
+ // Should have timestamp pattern before .csv
48
+ expect(filename).toMatch(/\d{8}-\d{6}\.csv$/);
49
+ });
50
+ });
51
+
52
+ describe("node name extraction", () => {
53
+ test("should include single node name from node_names array", () => {
54
+ const filename = generateCSVFilename("query", {
55
+ node_names: ["my_model"],
56
+ });
57
+
58
+ expect(filename).toContain("my_model");
59
+ });
60
+
61
+ test("should not include node name if multiple in array", () => {
62
+ const filename = generateCSVFilename("query", {
63
+ node_names: ["model1", "model2"],
64
+ });
65
+
66
+ expect(filename).not.toContain("model1");
67
+ expect(filename).not.toContain("model2");
68
+ expect(filename).toContain("result");
69
+ });
70
+
71
+ test("should include model name from params", () => {
72
+ const filename = generateCSVFilename("profile", {
73
+ model: "customers",
74
+ });
75
+
76
+ expect(filename).toContain("customers");
77
+ });
78
+
79
+ test("should prefer node_names over model", () => {
80
+ const filename = generateCSVFilename("query", {
81
+ node_names: ["from_node"],
82
+ model: "from_model",
83
+ });
84
+
85
+ expect(filename).toContain("from_node");
86
+ expect(filename).not.toContain("from_model");
87
+ });
88
+ });
89
+
90
+ describe("node name sanitization", () => {
91
+ test("should convert to lowercase", () => {
92
+ const filename = generateCSVFilename("query", {
93
+ node_names: ["MyModel"],
94
+ });
95
+
96
+ expect(filename).toContain("mymodel");
97
+ expect(filename).not.toContain("MyModel");
98
+ });
99
+
100
+ test("should preserve dots for schema.table patterns", () => {
101
+ const filename = generateCSVFilename("query", {
102
+ node_names: ["schema.table_name"],
103
+ });
104
+
105
+ expect(filename).toContain("schema.table_name");
106
+ });
107
+
108
+ test("should replace special characters with hyphens", () => {
109
+ const filename = generateCSVFilename("query", {
110
+ node_names: ["model/with/slashes"],
111
+ });
112
+
113
+ expect(filename).toContain("model-with-slashes");
114
+ expect(filename).not.toContain("/");
115
+ });
116
+
117
+ test("should handle spaces", () => {
118
+ const filename = generateCSVFilename("query", {
119
+ node_names: ["model with spaces"],
120
+ });
121
+
122
+ expect(filename).toContain("model-with-spaces");
123
+ });
124
+
125
+ test("should preserve underscores and hyphens", () => {
126
+ const filename = generateCSVFilename("query", {
127
+ node_names: ["my_model-name"],
128
+ });
129
+
130
+ expect(filename).toContain("my_model-name");
131
+ });
132
+ });
133
+
134
+ describe("fallback behavior", () => {
135
+ test("should use 'result' when no node name available", () => {
136
+ const filename = generateCSVFilename("query", {});
137
+
138
+ expect(filename).toMatch(/^query-result-\d{8}-\d{6}\.csv$/);
139
+ });
140
+
141
+ test("should handle undefined params", () => {
142
+ const filename = generateCSVFilename("query", undefined);
143
+
144
+ expect(filename).toMatch(/^query-result-\d{8}-\d{6}\.csv$/);
145
+ });
146
+
147
+ test("should handle empty node_names array", () => {
148
+ const filename = generateCSVFilename("query", {
149
+ node_names: [],
150
+ });
151
+
152
+ expect(filename).toContain("result");
153
+ });
154
+ });
155
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * CSV export utilities
3
+ */
4
+ import saveAs from "file-saver";
5
+
6
+ export { toCSV } from "./format";
7
+
8
+ /**
9
+ * Trigger browser download of CSV file
10
+ */
11
+ export function downloadCSV(content: string, filename: string): void {
12
+ const blob = new Blob([content], { type: "text/csv;charset=utf-8" });
13
+ saveAs(blob, filename);
14
+ }
15
+
16
+ /**
17
+ * Copy CSV content to clipboard
18
+ * Uses modern Clipboard API with fallback for older browsers
19
+ */
20
+ export async function copyCSVToClipboard(content: string): Promise<void> {
21
+ // Prefer modern async Clipboard API when available in a browser context
22
+ if (
23
+ typeof navigator !== "undefined" &&
24
+ navigator.clipboard &&
25
+ typeof navigator.clipboard.writeText === "function"
26
+ ) {
27
+ await navigator.clipboard.writeText(content);
28
+ return;
29
+ }
30
+
31
+ // Fallback for older browsers or non-secure contexts using execCommand
32
+ if (typeof document === "undefined") {
33
+ // In non-DOM environments (e.g., SSR), throw error
34
+ throw new Error("Clipboard API not available in this environment");
35
+ }
36
+
37
+ const textarea = document.createElement("textarea");
38
+ textarea.value = content;
39
+ textarea.style.position = "fixed"; // avoid scrolling to bottom
40
+ textarea.style.opacity = "0";
41
+ textarea.setAttribute("readonly", "");
42
+ document.body.appendChild(textarea);
43
+
44
+ textarea.focus();
45
+ textarea.select();
46
+
47
+ try {
48
+ const success = document.execCommand("copy");
49
+ if (!success) {
50
+ throw new Error("execCommand('copy') failed");
51
+ }
52
+ } finally {
53
+ document.body.removeChild(textarea);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Generate timestamp string for filenames
59
+ * Format: YYYYMMDD-HHmmss
60
+ */
61
+ export function generateTimestamp(): string {
62
+ const now = new Date();
63
+ const year = now.getFullYear();
64
+ const month = String(now.getMonth() + 1).padStart(2, "0");
65
+ const day = String(now.getDate()).padStart(2, "0");
66
+ const hours = String(now.getHours()).padStart(2, "0");
67
+ const minutes = String(now.getMinutes()).padStart(2, "0");
68
+ const seconds = String(now.getSeconds()).padStart(2, "0");
69
+ return `${year}${month}${day}-${hours}${minutes}${seconds}`;
70
+ }
71
+
72
+ /**
73
+ * Generate context-aware CSV filename
74
+ */
75
+ export function generateCSVFilename(
76
+ runType: string,
77
+ params?: Record<string, unknown>,
78
+ ): string {
79
+ const timestamp = generateTimestamp();
80
+ const type = runType.replace(/_/g, "-");
81
+
82
+ // Try to extract node name from params
83
+ let nodeName: string | undefined;
84
+
85
+ if (
86
+ params?.node_names &&
87
+ Array.isArray(params.node_names) &&
88
+ params.node_names.length === 1
89
+ ) {
90
+ nodeName = String(params.node_names[0]);
91
+ } else if (params?.model && typeof params.model === "string") {
92
+ nodeName = params.model;
93
+ }
94
+
95
+ // Sanitize node name for filesystem (preserve dots for schema.table patterns)
96
+ if (nodeName) {
97
+ nodeName = nodeName.replace(/[^a-zA-Z0-9_.-]/g, "-").toLowerCase();
98
+ return `${type}-${nodeName}-${timestamp}.csv`;
99
+ }
100
+
101
+ return `${type}-result-${timestamp}.csv`;
102
+ }
103
+
104
+ export {
105
+ type CSVData,
106
+ type CSVExportOptions,
107
+ extractCSVData,
108
+ supportsCSVExport,
109
+ } from "./extractors";
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Hook for CSV export functionality
3
+ */
4
+ import { useCallback, useMemo } from "react";
5
+ import { toaster } from "@/components/ui/toaster";
6
+ import type { Run } from "@/lib/api/types";
7
+ import {
8
+ type CSVExportOptions,
9
+ copyCSVToClipboard,
10
+ downloadCSV,
11
+ extractCSVData,
12
+ generateCSVFilename,
13
+ supportsCSVExport,
14
+ toCSV,
15
+ } from "@/lib/csv";
16
+
17
+ interface UseCSVExportOptions {
18
+ run?: Run;
19
+ /** View options - displayMode is extracted if present (for query_diff views) */
20
+ viewOptions?: Record<string, unknown>;
21
+ }
22
+
23
+ interface UseCSVExportResult {
24
+ /** Whether CSV export is available for this run type */
25
+ canExportCSV: boolean;
26
+ /** Copy result data as CSV to clipboard */
27
+ copyAsCSV: () => Promise<void>;
28
+ /** Download result data as CSV file */
29
+ downloadAsCSV: () => void;
30
+ }
31
+
32
+ export function useCSVExport({
33
+ run,
34
+ viewOptions,
35
+ }: UseCSVExportOptions): UseCSVExportResult {
36
+ const canExportCSV = useMemo(() => {
37
+ if (!run?.type || !run?.result) return false;
38
+ return supportsCSVExport(run.type);
39
+ }, [run?.type, run?.result]);
40
+
41
+ const getCSVContent = useCallback((): string | null => {
42
+ if (!run?.type || !run?.result) return null;
43
+
44
+ // Extract display_mode from viewOptions if it exists (for query_diff)
45
+ const displayMode = viewOptions?.display_mode as
46
+ | "inline"
47
+ | "side_by_side"
48
+ | undefined;
49
+
50
+ // Extract primary_keys from run params (for query_diff with primary keys)
51
+ const primaryKeys = (run?.params as { primary_keys?: string[] })
52
+ ?.primary_keys;
53
+
54
+ const exportOptions: CSVExportOptions = {
55
+ displayMode,
56
+ primaryKeys,
57
+ };
58
+
59
+ const csvData = extractCSVData(run.type, run.result, exportOptions);
60
+ if (!csvData) return null;
61
+
62
+ return toCSV(csvData.columns, csvData.rows);
63
+ }, [run?.type, run?.result, run?.params, viewOptions]);
64
+
65
+ const copyAsCSV = useCallback(async () => {
66
+ const content = getCSVContent();
67
+ if (!content) {
68
+ toaster.create({
69
+ title: "Export failed",
70
+ description: "Unable to extract data for CSV export",
71
+ type: "error",
72
+ duration: 3000,
73
+ });
74
+ return;
75
+ }
76
+
77
+ try {
78
+ await copyCSVToClipboard(content);
79
+ toaster.create({
80
+ title: "Copied to clipboard",
81
+ description: "CSV data copied successfully",
82
+ type: "success",
83
+ duration: 2000,
84
+ });
85
+ } catch (error) {
86
+ console.error("Failed to copy CSV to clipboard:", error);
87
+ toaster.create({
88
+ title: "Copy failed",
89
+ description: "Failed to copy to clipboard",
90
+ type: "error",
91
+ duration: 3000,
92
+ });
93
+ }
94
+ }, [getCSVContent]);
95
+
96
+ const downloadAsCSV = useCallback(() => {
97
+ const content = getCSVContent();
98
+ if (!content) {
99
+ toaster.create({
100
+ title: "Export failed",
101
+ description: "Unable to extract data for CSV export",
102
+ type: "error",
103
+ duration: 3000,
104
+ });
105
+ return;
106
+ }
107
+
108
+ try {
109
+ const filename = generateCSVFilename(
110
+ run?.type ?? "",
111
+ run?.params as Record<string, unknown>,
112
+ );
113
+ downloadCSV(content, filename);
114
+ toaster.create({
115
+ title: "Downloaded",
116
+ description: filename,
117
+ type: "success",
118
+ duration: 3000,
119
+ });
120
+ } catch (error) {
121
+ console.error("Failed to download CSV file:", error);
122
+ toaster.create({
123
+ title: "Download failed",
124
+ description: "Failed to download CSV file",
125
+ type: "error",
126
+ duration: 3000,
127
+ });
128
+ }
129
+ }, [getCSVContent, run]);
130
+
131
+ return {
132
+ canExportCSV,
133
+ copyAsCSV,
134
+ downloadAsCSV,
135
+ };
136
+ }
@@ -115,8 +115,10 @@ class RecceMCPServer:
115
115
  mode: Optional[RecceServerMode] = None,
116
116
  debug: bool = False,
117
117
  log_file: str = "logs/recce-mcp.json",
118
+ state_loader=None,
118
119
  ):
119
120
  self.context = context
121
+ self.state_loader = state_loader
120
122
  self.mode = mode or RecceServerMode.server
121
123
  self.server = Server("recce")
122
124
  self.mcp_logger = MCPLogger(debug=debug, log_file=log_file)
@@ -733,8 +735,30 @@ class RecceMCPServer:
733
735
 
734
736
  async def run(self):
735
737
  """Run the MCP server in stdio mode"""
736
- async with stdio_server() as (read_stream, write_stream):
737
- await self.server.run(read_stream, write_stream, self.server.create_initialization_options())
738
+ try:
739
+ async with stdio_server() as (read_stream, write_stream):
740
+ await self.server.run(read_stream, write_stream, self.server.create_initialization_options())
741
+ finally:
742
+ # Export state on shutdown if state_loader is available
743
+ if self.state_loader and self.context:
744
+ try:
745
+ from rich.console import Console
746
+
747
+ console = Console()
748
+
749
+ # Export the state
750
+ msg = self.state_loader.export(self.context.export_state())
751
+ if msg is not None:
752
+ console.print(f"[yellow]On shutdown:[/yellow] {msg}")
753
+ else:
754
+ if hasattr(self.state_loader, "state_file") and self.state_loader.state_file:
755
+ console.print(
756
+ f"[yellow]On shutdown:[/yellow] State exported to '{self.state_loader.state_file}'"
757
+ )
758
+ else:
759
+ console.print("[yellow]On shutdown:[/yellow] State exported successfully")
760
+ except Exception as e:
761
+ logger.exception(f"Failed to export state on shutdown: {e}")
738
762
 
739
763
  async def run_sse(self, host: str = "localhost", port: int = 8000):
740
764
  """Run the MCP server in HTTP mode using Server-Sent Events (SSE)
@@ -743,6 +767,8 @@ class RecceMCPServer:
743
767
  host: Host to bind to (default: localhost)
744
768
  port: Port to bind to (default: 8000)
745
769
  """
770
+ from contextlib import asynccontextmanager
771
+
746
772
  import uvicorn
747
773
  from mcp.server.sse import SseServerTransport
748
774
  from starlette.applications import Starlette
@@ -775,7 +801,22 @@ class RecceMCPServer:
775
801
  """Handle health check endpoint (GET /health)"""
776
802
  return Response(content='{"status":"ok"}', media_type="application/json")
777
803
 
778
- # Create Starlette app
804
+ @asynccontextmanager
805
+ async def lifespan(app):
806
+ """Handle startup and shutdown events"""
807
+ # Startup
808
+ yield
809
+ # Shutdown - this runs when server exits (SIGINT, SIGTERM, etc.)
810
+ if self.state_loader and self.context:
811
+ try:
812
+ logger.info("Exporting state on shutdown...")
813
+ msg = self.state_loader.export(self.context.export_state())
814
+ if msg:
815
+ logger.info(f"State export: {msg}")
816
+ except Exception as e:
817
+ logger.exception(f"Failed to export state on shutdown: {e}")
818
+
819
+ # Create Starlette app with lifespan
779
820
  app = Starlette(
780
821
  debug=self.mcp_logger.debug,
781
822
  routes=[
@@ -783,6 +824,7 @@ class RecceMCPServer:
783
824
  Route("/sse", endpoint=handle_sse_request, methods=["GET"]),
784
825
  Mount("/", app=handle_post_message),
785
826
  ],
827
+ lifespan=lifespan,
786
828
  )
787
829
 
788
830
  # Run with uvicorn
@@ -831,31 +873,13 @@ async def run_mcp_server(
831
873
  # Extract debug flag from kwargs
832
874
  debug = kwargs.get("debug", False)
833
875
 
834
- # Create MCP server
835
- server = RecceMCPServer(context, mode=mode, debug=debug)
836
-
837
- try:
838
- # Run in either stdio or SSE mode
839
- if sse:
840
- await server.run_sse(host=host, port=port)
841
- else:
842
- await server.run()
843
- finally:
844
- # Export state on shutdown if state_loader is available
845
- if state_loader:
846
- try:
847
- from rich.console import Console
848
-
849
- console = Console()
876
+ # Create MCP server with state_loader for graceful shutdown
877
+ server = RecceMCPServer(context, mode=mode, debug=debug, state_loader=state_loader)
850
878
 
851
- # Export the state
852
- msg = state_loader.export(context.export_state())
853
- if msg is not None:
854
- console.print(f"[yellow]On shutdown:[/yellow] {msg}")
855
- else:
856
- if hasattr(state_loader, "state_file") and state_loader.state_file:
857
- console.print(f"[yellow]On shutdown:[/yellow] State exported to '{state_loader.state_file}'")
858
- else:
859
- console.print("[yellow]On shutdown:[/yellow] State exported successfully")
860
- except Exception as e:
861
- logger.exception(f"Failed to export state on shutdown: {e}")
879
+ # Run in either stdio or SSE mode
880
+ if sse:
881
+ # SSE mode: lifespan handler in Starlette manages shutdown and state export
882
+ await server.run_sse(host=host, port=port)
883
+ else:
884
+ # Stdio mode: run() method handles shutdown and state export via try-finally
885
+ await server.run()
@@ -183,6 +183,14 @@ class CheckDAO:
183
183
  # Parse the type
184
184
  check_type = RunType(cloud_data.get("type"))
185
185
 
186
+ def parse_iso_datetime(iso_string: str):
187
+ """Parse ISO format datetime string, handling 'Z' suffix for UTC"""
188
+ if not iso_string:
189
+ return None
190
+ # Replace 'Z' with '+00:00' for Python's fromisoformat compatibility
191
+ iso_string = iso_string.replace("Z", "+00:00")
192
+ return datetime.fromisoformat(iso_string)
193
+
186
194
  return Check(
187
195
  check_id=UUID(cloud_data.get("id")),
188
196
  session_id=UUID(cloud_data.get("session_id")),
@@ -195,8 +203,8 @@ class CheckDAO:
195
203
  is_preset=cloud_data.get("is_preset", False),
196
204
  created_by=(cloud_data.get("created_by") or {}).get("email", ""),
197
205
  updated_by=(cloud_data.get("updated_by") or {}).get("email", ""),
198
- created_at=datetime.fromisoformat(cloud_data["created_at"]) if cloud_data.get("created_at") else None,
199
- updated_at=datetime.fromisoformat(cloud_data["updated_at"]) if cloud_data.get("updated_at") else None,
206
+ created_at=parse_iso_datetime(cloud_data.get("created_at")),
207
+ updated_at=parse_iso_datetime(cloud_data.get("updated_at")),
200
208
  )
201
209
 
202
210
  def create(self, check: Check) -> Check: