Start on step 17 next
This commit is contained in:
@@ -80,7 +80,7 @@
|
|||||||
- Display log file metadata (size, creation date, operation count)
|
- Display log file metadata (size, creation date, operation count)
|
||||||
- _Requirements: 2.1, 2.8, 4.1, 4.2_
|
- _Requirements: 2.1, 2.8, 4.1, 4.2_
|
||||||
|
|
||||||
- [ ] 10. Add log content viewing functionality
|
- [x] 10. Add log content viewing functionality
|
||||||
|
|
||||||
- Implement log content display with syntax highlighting
|
- Implement log content display with syntax highlighting
|
||||||
- Add pagination for large log files using Pagination component
|
- Add pagination for large log files using Pagination component
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
- Handle empty log files with helpful messaging
|
- Handle empty log files with helpful messaging
|
||||||
- _Requirements: 2.2, 2.4, 2.6, 2.7_
|
- _Requirements: 2.2, 2.4, 2.6, 2.7_
|
||||||
|
|
||||||
- [ ] 11. Add log filtering and search capabilities
|
- [x] 11. Add log filtering and search capabilities
|
||||||
|
|
||||||
- Implement filter interface for date range, operation type, and status
|
- Implement filter interface for date range, operation type, and status
|
||||||
- Add search functionality within log content
|
- Add search functionality within log content
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
- Add filter status indicators and clear filter options
|
- Add filter status indicators and clear filter options
|
||||||
- _Requirements: 2.3, 2.5_
|
- _Requirements: 2.3, 2.5_
|
||||||
|
|
||||||
- [ ] 12. Implement basic Tag Analysis screen structure
|
- [x] 12. Implement basic Tag Analysis screen structure
|
||||||
|
|
||||||
- Create TagAnalysisScreen component with tag list view
|
- Create TagAnalysisScreen component with tag list view
|
||||||
- Implement keyboard navigation for tag selection
|
- Implement keyboard navigation for tag selection
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
- Display loading indicators during tag fetching
|
- Display loading indicators during tag fetching
|
||||||
- _Requirements: 3.1, 3.9, 4.1, 4.2_
|
- _Requirements: 3.1, 3.9, 4.1, 4.2_
|
||||||
|
|
||||||
- [ ] 13. Add tag statistics and analysis features
|
- [x] 13. Add tag statistics and analysis features
|
||||||
|
|
||||||
- Display tag statistics (product count, variant count, total value)
|
- Display tag statistics (product count, variant count, total value)
|
||||||
- Implement tag details view showing products and prices
|
- Implement tag details view showing products and prices
|
||||||
@@ -116,7 +116,7 @@
|
|||||||
- Add error handling for API connection failures
|
- Add error handling for API connection failures
|
||||||
- _Requirements: 3.2, 3.3, 3.4, 3.6, 3.9_
|
- _Requirements: 3.2, 3.3, 3.4, 3.6, 3.9_
|
||||||
|
|
||||||
- [ ] 14. Add tag search and configuration integration
|
- [x] 14. Add tag search and configuration integration
|
||||||
|
|
||||||
- Implement search/filter functionality for tag list
|
- Implement search/filter functionality for tag list
|
||||||
- Add tag selection for immediate use in configuration
|
- Add tag selection for immediate use in configuration
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
- Handle tag selection workflow and navigation
|
- Handle tag selection workflow and navigation
|
||||||
- _Requirements: 3.7, 3.8, 5.5_
|
- _Requirements: 3.7, 3.8, 5.5_
|
||||||
|
|
||||||
- [ ] 15. Update main TUI entry point with new screens
|
- [x] 15. Update main TUI entry point with new screens
|
||||||
|
|
||||||
- Modify tui-entry.js to include new screen navigation options
|
- Modify tui-entry.js to include new screen navigation options
|
||||||
- Update main menu to remove "coming soon" placeholders
|
- Update main menu to remove "coming soon" placeholders
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
- Update help text and keyboard shortcuts documentation
|
- Update help text and keyboard shortcuts documentation
|
||||||
- _Requirements: 4.1, 4.2, 4.6_
|
- _Requirements: 4.1, 4.2, 4.6_
|
||||||
|
|
||||||
- [ ] 16. Implement comprehensive error handling
|
- [x] 16. Implement comprehensive error handling
|
||||||
|
|
||||||
- Add error boundaries for each new screen
|
- Add error boundaries for each new screen
|
||||||
- Implement retry logic for API failures in Tag Analysis
|
- Implement retry logic for API failures in Tag Analysis
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ class TagAnalysisService {
|
|||||||
analyzeProductTags(products) {
|
analyzeProductTags(products) {
|
||||||
const tagCounts = new Map();
|
const tagCounts = new Map();
|
||||||
const tagPrices = new Map();
|
const tagPrices = new Map();
|
||||||
|
const tagVariantCounts = new Map();
|
||||||
|
const tagTotalValues = new Map();
|
||||||
const totalProducts = products.length;
|
const totalProducts = products.length;
|
||||||
|
|
||||||
// Count tags and collect price data
|
// Count tags and collect price data
|
||||||
@@ -77,18 +79,22 @@ class TagAnalysisService {
|
|||||||
// Count occurrences
|
// Count occurrences
|
||||||
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
|
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
|
||||||
|
|
||||||
// Collect price data
|
// Initialize collections if not exists
|
||||||
if (!tagPrices.has(tag)) {
|
if (!tagPrices.has(tag)) {
|
||||||
tagPrices.set(tag, []);
|
tagPrices.set(tag, []);
|
||||||
|
tagVariantCounts.set(tag, 0);
|
||||||
|
tagTotalValues.set(tag, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get prices from variants
|
// Get prices from variants and calculate statistics
|
||||||
if (product.variants && Array.isArray(product.variants)) {
|
if (product.variants && Array.isArray(product.variants)) {
|
||||||
product.variants.forEach((variant) => {
|
product.variants.forEach((variant) => {
|
||||||
if (variant.price) {
|
if (variant.price) {
|
||||||
const price = parseFloat(variant.price);
|
const price = parseFloat(variant.price);
|
||||||
if (!isNaN(price)) {
|
if (!isNaN(price)) {
|
||||||
tagPrices.get(tag).push(price);
|
tagPrices.get(tag).push(price);
|
||||||
|
tagVariantCounts.set(tag, tagVariantCounts.get(tag) + 1);
|
||||||
|
tagTotalValues.set(tag, tagTotalValues.get(tag) + price);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -96,26 +102,34 @@ class TagAnalysisService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert to sorted arrays
|
// Convert to sorted arrays with enhanced statistics
|
||||||
const tagCountsArray = Array.from(tagCounts.entries())
|
const tagCountsArray = Array.from(tagCounts.entries())
|
||||||
.map(([tag, count]) => ({
|
.map(([tag, count]) => ({
|
||||||
tag,
|
tag,
|
||||||
count,
|
count,
|
||||||
percentage: (count / totalProducts) * 100,
|
percentage: (count / totalProducts) * 100,
|
||||||
|
variantCount: tagVariantCounts.get(tag) || 0,
|
||||||
|
totalValue: tagTotalValues.get(tag) || 0,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.count - a.count);
|
.sort((a, b) => b.count - a.count);
|
||||||
|
|
||||||
// Calculate price ranges
|
// Calculate price ranges with enhanced statistics
|
||||||
const priceRanges = {};
|
const priceRanges = {};
|
||||||
tagPrices.forEach((prices, tag) => {
|
tagPrices.forEach((prices, tag) => {
|
||||||
if (prices.length > 0) {
|
if (prices.length > 0) {
|
||||||
const sortedPrices = prices.sort((a, b) => a - b);
|
const sortedPrices = prices.sort((a, b) => a - b);
|
||||||
|
const totalValue = tagTotalValues.get(tag) || 0;
|
||||||
|
const variantCount = tagVariantCounts.get(tag) || 0;
|
||||||
|
|
||||||
priceRanges[tag] = {
|
priceRanges[tag] = {
|
||||||
min: sortedPrices[0],
|
min: sortedPrices[0],
|
||||||
max: sortedPrices[sortedPrices.length - 1],
|
max: sortedPrices[sortedPrices.length - 1],
|
||||||
average:
|
average:
|
||||||
prices.reduce((sum, price) => sum + price, 0) / prices.length,
|
prices.reduce((sum, price) => sum + price, 0) / prices.length,
|
||||||
count: prices.length,
|
count: prices.length,
|
||||||
|
variantCount: variantCount,
|
||||||
|
totalValue: totalValue,
|
||||||
|
median: this.calculateMedian(sortedPrices),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -385,6 +399,23 @@ class TagAnalysisService {
|
|||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate median value from sorted array
|
||||||
|
* @param {Array} sortedArray - Sorted array of numbers
|
||||||
|
* @returns {number} Median value
|
||||||
|
*/
|
||||||
|
calculateMedian(sortedArray) {
|
||||||
|
if (sortedArray.length === 0) return 0;
|
||||||
|
|
||||||
|
const mid = Math.floor(sortedArray.length / 2);
|
||||||
|
|
||||||
|
if (sortedArray.length % 2 === 0) {
|
||||||
|
return (sortedArray[mid - 1] + sortedArray[mid]) / 2;
|
||||||
|
} else {
|
||||||
|
return sortedArray[mid];
|
||||||
|
}
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Calculate impact score for a tag
|
* Calculate impact score for a tag
|
||||||
* @param {Object} tagInfo - Tag information
|
* @param {Object} tagInfo - Tag information
|
||||||
|
|||||||
166
src/tui-entry-simple.js
Normal file
166
src/tui-entry-simple.js
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple TUI Entry Point for testing Tag Analysis Screen
|
||||||
|
*/
|
||||||
|
|
||||||
|
const main = async () => {
|
||||||
|
try {
|
||||||
|
console.log("🚀 Starting Simple TUI...");
|
||||||
|
|
||||||
|
// Use dynamic imports for ESM modules
|
||||||
|
const React = await import("react");
|
||||||
|
const { render, Box, Text, useInput, useApp } = await import("ink");
|
||||||
|
|
||||||
|
console.log("✅ Loaded React and Ink successfully");
|
||||||
|
|
||||||
|
// Create a simple app that shows the tag analysis
|
||||||
|
const SimpleApp = () => {
|
||||||
|
const { exit } = useApp();
|
||||||
|
const [currentScreen, setCurrentScreen] = React.useState("menu");
|
||||||
|
|
||||||
|
useInput((input, key) => {
|
||||||
|
if (key.escape || input === "q") {
|
||||||
|
exit();
|
||||||
|
} else if (key.return && currentScreen === "menu") {
|
||||||
|
setCurrentScreen("tag-analysis");
|
||||||
|
} else if (key.escape && currentScreen === "tag-analysis") {
|
||||||
|
setCurrentScreen("menu");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentScreen === "menu") {
|
||||||
|
return React.createElement(
|
||||||
|
Box,
|
||||||
|
{ flexDirection: "column", padding: 1 },
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "cyan", bold: true },
|
||||||
|
"🎉 Simple TUI - Tag Analysis Test"
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "gray", marginBottom: 1 },
|
||||||
|
"Press Enter to view Tag Analysis, Esc/Q to exit"
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Box,
|
||||||
|
{
|
||||||
|
borderStyle: "single",
|
||||||
|
borderColor: "blue",
|
||||||
|
padding: 1,
|
||||||
|
},
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "blue" },
|
||||||
|
"► Press Enter to test Tag Analysis Screen"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For tag analysis, show a simple message for now
|
||||||
|
return React.createElement(
|
||||||
|
Box,
|
||||||
|
{ flexDirection: "column", padding: 1 },
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "cyan", bold: true },
|
||||||
|
"🏷️ Tag Analysis Screen"
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "gray", marginBottom: 1 },
|
||||||
|
"Enhanced tag analysis with statistics and pricing information"
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Box,
|
||||||
|
{
|
||||||
|
borderStyle: "single",
|
||||||
|
borderColor: "green",
|
||||||
|
padding: 1,
|
||||||
|
},
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "green" },
|
||||||
|
"✅ Tag Analysis Screen Enhanced Successfully!"
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "white", marginTop: 1 },
|
||||||
|
"Features implemented:"
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "white", marginLeft: 2 },
|
||||||
|
"• Display tag statistics (product count, variant count, total value)"
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "white", marginLeft: 2 },
|
||||||
|
"• Tag details view with products and prices"
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "white", marginLeft: 2 },
|
||||||
|
"• Price range calculations and average price display"
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "white", marginLeft: 2 },
|
||||||
|
"• Detailed product information for selected tags"
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "white", marginLeft: 2 },
|
||||||
|
"• Enhanced pricing information with median and spread"
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "white", marginLeft: 2 },
|
||||||
|
"• Error handling for API connection failures"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "gray", marginTop: 1 },
|
||||||
|
"Press Esc to go back to menu"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("🎨 Rendering Simple TUI...");
|
||||||
|
|
||||||
|
// Render the application
|
||||||
|
const { waitUntilExit } = render(React.createElement(SimpleApp));
|
||||||
|
|
||||||
|
// Wait for the application to exit
|
||||||
|
await waitUntilExit();
|
||||||
|
|
||||||
|
console.log("👋 TUI application exited");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to start TUI application:", error);
|
||||||
|
console.error("Stack:", error.stack);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle process signals gracefully
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
console.log("\n👋 Exiting...");
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("SIGTERM", () => {
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the application
|
||||||
|
if (require.main === module) {
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("TUI application error:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = main;
|
||||||
963
src/tui-entry.js
963
src/tui-entry.js
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* TUI Entry Point
|
* TUI Entry Point
|
||||||
* Initializes the Ink-based Terminal User Interface with working configuration
|
* Initializes the Ink-based Terminal User Interface with proper screen components
|
||||||
* Requirements: 2.2, 2.5
|
* Requirements: 2.2, 2.5
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Enable Babel for JSX support
|
||||||
|
require("@babel/register");
|
||||||
|
|
||||||
// Initialize the TUI application
|
// Initialize the TUI application
|
||||||
const main = async () => {
|
const main = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -13,962 +16,18 @@ const main = async () => {
|
|||||||
|
|
||||||
// Use dynamic imports for ESM modules
|
// Use dynamic imports for ESM modules
|
||||||
const React = await import("react");
|
const React = await import("react");
|
||||||
const { render, Text, Box, useInput } = await import("ink");
|
const { render } = await import("ink");
|
||||||
const TextInput = await import("ink-text-input");
|
|
||||||
|
|
||||||
console.log("✅ Loaded React and Ink successfully");
|
console.log("✅ Loaded React and Ink successfully");
|
||||||
|
|
||||||
// Load current configuration from .env file
|
// Import the main TUI application
|
||||||
const loadConfiguration = () => {
|
console.log("📦 Loading TUI application...");
|
||||||
try {
|
const TuiApplication = require("./tui/TuiApplication.jsx");
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
const envPath = path.resolve(process.cwd(), ".env");
|
|
||||||
|
|
||||||
if (!fs.existsSync(envPath)) {
|
|
||||||
return {
|
|
||||||
shopDomain: "",
|
|
||||||
accessToken: "",
|
|
||||||
targetTag: "",
|
|
||||||
priceAdjustment: "",
|
|
||||||
operationMode: "update",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const envContent = fs.readFileSync(envPath, "utf8");
|
|
||||||
const envVars = {};
|
|
||||||
|
|
||||||
envContent.split("\n").forEach((line) => {
|
|
||||||
const trimmedLine = line.trim();
|
|
||||||
if (trimmedLine && !trimmedLine.startsWith("#")) {
|
|
||||||
const [key, ...valueParts] = trimmedLine.split("=");
|
|
||||||
if (key && valueParts.length > 0) {
|
|
||||||
envVars[key.trim()] = valueParts.join("=").trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
shopDomain: envVars.SHOPIFY_SHOP_DOMAIN || "",
|
|
||||||
accessToken: envVars.SHOPIFY_ACCESS_TOKEN || "",
|
|
||||||
targetTag: envVars.TARGET_TAG || "",
|
|
||||||
priceAdjustment: envVars.PRICE_ADJUSTMENT_PERCENTAGE || "",
|
|
||||||
operationMode: envVars.OPERATION_MODE || "update",
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error loading configuration:", error);
|
|
||||||
return {
|
|
||||||
shopDomain: "",
|
|
||||||
accessToken: "",
|
|
||||||
targetTag: "",
|
|
||||||
priceAdjustment: "",
|
|
||||||
operationMode: "update",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save configuration to .env file
|
|
||||||
const saveConfiguration = (config) => {
|
|
||||||
try {
|
|
||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
const envPath = path.resolve(process.cwd(), ".env");
|
|
||||||
|
|
||||||
let envContent = "";
|
|
||||||
try {
|
|
||||||
envContent = fs.readFileSync(envPath, "utf8");
|
|
||||||
} catch (err) {
|
|
||||||
envContent = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const envVars = {
|
|
||||||
SHOPIFY_SHOP_DOMAIN: config.shopDomain,
|
|
||||||
SHOPIFY_ACCESS_TOKEN: config.accessToken,
|
|
||||||
TARGET_TAG: config.targetTag,
|
|
||||||
PRICE_ADJUSTMENT_PERCENTAGE: config.priceAdjustment,
|
|
||||||
OPERATION_MODE: config.operationMode,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(envVars)) {
|
|
||||||
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
const regex = new RegExp(`^${escapedKey}=.*$`, "m");
|
|
||||||
const line = `${key}=${value}`;
|
|
||||||
|
|
||||||
if (envContent.match(regex)) {
|
|
||||||
envContent = envContent.replace(regex, line);
|
|
||||||
} else {
|
|
||||||
if (envContent && !envContent.endsWith("\n")) {
|
|
||||||
envContent += "\n";
|
|
||||||
}
|
|
||||||
envContent += `${line}\n`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(envPath, envContent, "utf8");
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error saving configuration:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Execute operations function
|
|
||||||
const executeOperation = async (
|
|
||||||
operation,
|
|
||||||
config,
|
|
||||||
setOperationStatus,
|
|
||||||
setOperationProgress,
|
|
||||||
setOperationResults
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
setOperationStatus(`🚀 Starting ${operation} operation...`);
|
|
||||||
setOperationProgress({
|
|
||||||
current: 0,
|
|
||||||
total: 100,
|
|
||||||
message: "Initializing...",
|
|
||||||
});
|
|
||||||
setOperationResults(null);
|
|
||||||
|
|
||||||
// Simulate progress updates
|
|
||||||
const updateProgress = (current, message) => {
|
|
||||||
setOperationProgress({ current, total: 100, message });
|
|
||||||
};
|
|
||||||
|
|
||||||
if (operation === "test") {
|
|
||||||
// Test connection
|
|
||||||
updateProgress(25, "Testing Shopify API connection...");
|
|
||||||
|
|
||||||
// Set up environment for testing
|
|
||||||
const originalEnv = { ...process.env };
|
|
||||||
process.env.SHOPIFY_SHOP_DOMAIN = config.shopDomain;
|
|
||||||
process.env.SHOPIFY_ACCESS_TOKEN = config.accessToken;
|
|
||||||
process.env.TARGET_TAG = config.targetTag;
|
|
||||||
process.env.PRICE_ADJUSTMENT_PERCENTAGE = config.priceAdjustment;
|
|
||||||
process.env.OPERATION_MODE = config.operationMode;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const ShopifyService = require("../../services/shopify");
|
|
||||||
const shopifyService = new ShopifyService();
|
|
||||||
|
|
||||||
updateProgress(50, "Connecting to Shopify...");
|
|
||||||
const testResult = await shopifyService.testConnection();
|
|
||||||
|
|
||||||
updateProgress(75, "Verifying permissions...");
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000)); // Brief delay for UX
|
|
||||||
|
|
||||||
updateProgress(100, "Connection test complete!");
|
|
||||||
|
|
||||||
if (testResult) {
|
|
||||||
setOperationStatus("✅ Connection test successful!");
|
|
||||||
setOperationResults({
|
|
||||||
success: true,
|
|
||||||
message: "Successfully connected to Shopify API",
|
|
||||||
details: [
|
|
||||||
`Store: ${config.shopDomain}`,
|
|
||||||
"API access verified",
|
|
||||||
"All permissions working correctly",
|
|
||||||
],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setOperationStatus("❌ Connection test failed!");
|
|
||||||
setOperationResults({
|
|
||||||
success: false,
|
|
||||||
message: "Failed to connect to Shopify API",
|
|
||||||
details: [
|
|
||||||
"Please check your credentials",
|
|
||||||
"Verify your access token is valid",
|
|
||||||
"Ensure your store domain is correct",
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setOperationStatus("❌ Connection test error!");
|
|
||||||
setOperationResults({
|
|
||||||
success: false,
|
|
||||||
message: `Error: ${error.message}`,
|
|
||||||
details: [
|
|
||||||
"Check your network connection",
|
|
||||||
"Verify your Shopify credentials",
|
|
||||||
"Try again in a few moments",
|
|
||||||
],
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
// Restore original environment
|
|
||||||
process.env = originalEnv;
|
|
||||||
}
|
|
||||||
} else if (operation === "analyze") {
|
|
||||||
// Analyze products
|
|
||||||
updateProgress(25, "Fetching products with target tag...");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const originalEnv = { ...process.env };
|
|
||||||
process.env.SHOPIFY_SHOP_DOMAIN = config.shopDomain;
|
|
||||||
process.env.SHOPIFY_ACCESS_TOKEN = config.accessToken;
|
|
||||||
process.env.TARGET_TAG = config.targetTag;
|
|
||||||
process.env.PRICE_ADJUSTMENT_PERCENTAGE = config.priceAdjustment;
|
|
||||||
process.env.OPERATION_MODE = config.operationMode;
|
|
||||||
|
|
||||||
const ProductService = require("../../services/product");
|
|
||||||
const productService = new ProductService();
|
|
||||||
|
|
||||||
updateProgress(50, "Analyzing product prices...");
|
|
||||||
const products = await productService.fetchProductsWithTag(
|
|
||||||
config.targetTag
|
|
||||||
);
|
|
||||||
|
|
||||||
updateProgress(75, "Calculating price changes...");
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
updateProgress(100, "Analysis complete!");
|
|
||||||
|
|
||||||
const adjustment = parseFloat(config.priceAdjustment);
|
|
||||||
let affectedProducts = 0;
|
|
||||||
let totalVariants = 0;
|
|
||||||
|
|
||||||
products.forEach((product) => {
|
|
||||||
product.variants.forEach((variant) => {
|
|
||||||
totalVariants++;
|
|
||||||
if (variant.price && parseFloat(variant.price) > 0) {
|
|
||||||
affectedProducts++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
setOperationStatus("✅ Product analysis complete!");
|
|
||||||
setOperationResults({
|
|
||||||
success: true,
|
|
||||||
message: `Found ${products.length} products with tag "${config.targetTag}"`,
|
|
||||||
details: [
|
|
||||||
`Total products: ${products.length}`,
|
|
||||||
`Total variants: ${totalVariants}`,
|
|
||||||
`Variants with prices: ${affectedProducts}`,
|
|
||||||
`Price adjustment: ${adjustment > 0 ? "+" : ""}${adjustment}%`,
|
|
||||||
`Operation mode: ${config.operationMode}`,
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
process.env = originalEnv;
|
|
||||||
} catch (error) {
|
|
||||||
setOperationStatus("❌ Analysis failed!");
|
|
||||||
setOperationResults({
|
|
||||||
success: false,
|
|
||||||
message: `Error: ${error.message}`,
|
|
||||||
details: [
|
|
||||||
"Could not fetch product data",
|
|
||||||
"Check your API credentials",
|
|
||||||
"Verify the target tag exists",
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (operation === "update" || operation === "rollback") {
|
|
||||||
// Run actual price update/rollback
|
|
||||||
updateProgress(10, "Preparing operation...");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const originalEnv = { ...process.env };
|
|
||||||
process.env.SHOPIFY_SHOP_DOMAIN = config.shopDomain;
|
|
||||||
process.env.SHOPIFY_ACCESS_TOKEN = config.accessToken;
|
|
||||||
process.env.TARGET_TAG = config.targetTag;
|
|
||||||
process.env.PRICE_ADJUSTMENT_PERCENTAGE = config.priceAdjustment;
|
|
||||||
process.env.OPERATION_MODE = operation;
|
|
||||||
|
|
||||||
updateProgress(25, "Starting price operation...");
|
|
||||||
|
|
||||||
// Import and run the main application logic
|
|
||||||
const mainApp = require("../../index");
|
|
||||||
|
|
||||||
// Capture console output for progress tracking
|
|
||||||
let progressMessages = [];
|
|
||||||
const originalLog = console.log;
|
|
||||||
console.log = (...args) => {
|
|
||||||
const message = args.join(" ");
|
|
||||||
progressMessages.push(message);
|
|
||||||
originalLog(...args);
|
|
||||||
|
|
||||||
// Update progress based on log messages
|
|
||||||
if (message.includes("Fetching products")) {
|
|
||||||
updateProgress(35, "Fetching products...");
|
|
||||||
} else if (message.includes("Processing batch")) {
|
|
||||||
updateProgress(60, "Processing price updates...");
|
|
||||||
} else if (message.includes("Successfully updated")) {
|
|
||||||
updateProgress(90, "Finalizing updates...");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run the operation
|
|
||||||
await mainApp();
|
|
||||||
|
|
||||||
// Restore console.log
|
|
||||||
console.log = originalLog;
|
|
||||||
|
|
||||||
updateProgress(100, "Operation complete!");
|
|
||||||
|
|
||||||
setOperationStatus(
|
|
||||||
`✅ ${
|
|
||||||
operation === "update" ? "Price update" : "Rollback"
|
|
||||||
} completed successfully!`
|
|
||||||
);
|
|
||||||
setOperationResults({
|
|
||||||
success: true,
|
|
||||||
message: `${
|
|
||||||
operation === "update" ? "Price update" : "Rollback"
|
|
||||||
} operation completed`,
|
|
||||||
details: progressMessages.slice(-5), // Show last 5 log messages
|
|
||||||
});
|
|
||||||
|
|
||||||
process.env = originalEnv;
|
|
||||||
} catch (error) {
|
|
||||||
setOperationStatus(
|
|
||||||
`❌ ${
|
|
||||||
operation === "update" ? "Price update" : "Rollback"
|
|
||||||
} failed!`
|
|
||||||
);
|
|
||||||
setOperationResults({
|
|
||||||
success: false,
|
|
||||||
message: `Error: ${error.message}`,
|
|
||||||
details: [
|
|
||||||
"Operation could not complete",
|
|
||||||
"Check the console for detailed error logs",
|
|
||||||
"Verify your configuration and try again",
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear progress after a delay
|
|
||||||
setTimeout(() => {
|
|
||||||
setOperationProgress(null);
|
|
||||||
}, 2000);
|
|
||||||
} catch (error) {
|
|
||||||
setOperationStatus(`❌ ${operation} operation failed!`);
|
|
||||||
setOperationResults({
|
|
||||||
success: false,
|
|
||||||
message: `Unexpected error: ${error.message}`,
|
|
||||||
details: ["Please try again or check the console for more details"],
|
|
||||||
});
|
|
||||||
setOperationProgress(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create the main TUI application
|
|
||||||
const TuiApp = () => {
|
|
||||||
const [currentScreen, setCurrentScreen] = React.useState("main-menu");
|
|
||||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
|
||||||
const [config, setConfig] = React.useState(loadConfiguration());
|
|
||||||
const [editingField, setEditingField] = React.useState(null);
|
|
||||||
const [tempValue, setTempValue] = React.useState("");
|
|
||||||
const [saveStatus, setSaveStatus] = React.useState("");
|
|
||||||
const [operationStatus, setOperationStatus] = React.useState("");
|
|
||||||
const [operationProgress, setOperationProgress] = React.useState(null);
|
|
||||||
const [operationResults, setOperationResults] = React.useState(null);
|
|
||||||
|
|
||||||
// Handle keyboard input
|
|
||||||
useInput((input, key) => {
|
|
||||||
if (editingField !== null) {
|
|
||||||
// Handle input editing mode
|
|
||||||
if (key.escape) {
|
|
||||||
setEditingField(null);
|
|
||||||
setTempValue("");
|
|
||||||
} else if (key.return) {
|
|
||||||
// Save the edited value
|
|
||||||
const fields = [
|
|
||||||
"shopDomain",
|
|
||||||
"accessToken",
|
|
||||||
"targetTag",
|
|
||||||
"priceAdjustment",
|
|
||||||
"operationMode",
|
|
||||||
];
|
|
||||||
const fieldName = fields[editingField];
|
|
||||||
setConfig((prev) => ({ ...prev, [fieldName]: tempValue }));
|
|
||||||
setEditingField(null);
|
|
||||||
setTempValue("");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentScreen === "main-menu") {
|
|
||||||
if (key.upArrow) {
|
|
||||||
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
|
||||||
} else if (key.downArrow) {
|
|
||||||
setSelectedIndex((prev) => Math.min(4, prev + 1));
|
|
||||||
} else if (key.return) {
|
|
||||||
const screens = [
|
|
||||||
"configuration",
|
|
||||||
"operation",
|
|
||||||
"scheduling",
|
|
||||||
"logs",
|
|
||||||
"tag-analysis",
|
|
||||||
];
|
|
||||||
if (selectedIndex < screens.length) {
|
|
||||||
setCurrentScreen(screens[selectedIndex]);
|
|
||||||
setSelectedIndex(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (currentScreen === "configuration") {
|
|
||||||
if (key.escape) {
|
|
||||||
setCurrentScreen("main-menu");
|
|
||||||
setSelectedIndex(0);
|
|
||||||
} else if (key.upArrow) {
|
|
||||||
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
|
||||||
} else if (key.downArrow) {
|
|
||||||
setSelectedIndex((prev) => Math.min(6, prev + 1));
|
|
||||||
} else if (key.return) {
|
|
||||||
if (selectedIndex < 5) {
|
|
||||||
// Edit field
|
|
||||||
const fields = [
|
|
||||||
"shopDomain",
|
|
||||||
"accessToken",
|
|
||||||
"targetTag",
|
|
||||||
"priceAdjustment",
|
|
||||||
"operationMode",
|
|
||||||
];
|
|
||||||
const fieldName = fields[selectedIndex];
|
|
||||||
setEditingField(selectedIndex);
|
|
||||||
setTempValue(config[fieldName]);
|
|
||||||
} else if (selectedIndex === 5) {
|
|
||||||
// Save configuration
|
|
||||||
const saved = saveConfiguration(config);
|
|
||||||
setSaveStatus(
|
|
||||||
saved
|
|
||||||
? "✅ Configuration saved successfully!"
|
|
||||||
: "❌ Failed to save configuration"
|
|
||||||
);
|
|
||||||
setTimeout(() => setSaveStatus(""), 3000);
|
|
||||||
} else if (selectedIndex === 6) {
|
|
||||||
// Back to menu
|
|
||||||
setCurrentScreen("main-menu");
|
|
||||||
setSelectedIndex(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (currentScreen === "operation") {
|
|
||||||
if (key.escape) {
|
|
||||||
setCurrentScreen("main-menu");
|
|
||||||
setSelectedIndex(0);
|
|
||||||
// Clear operation state when leaving
|
|
||||||
setOperationStatus("");
|
|
||||||
setOperationProgress(null);
|
|
||||||
setOperationResults(null);
|
|
||||||
} else if (key.upArrow) {
|
|
||||||
setSelectedIndex((prev) => Math.max(0, prev - 1));
|
|
||||||
} else if (key.downArrow) {
|
|
||||||
setSelectedIndex((prev) => Math.min(3, prev + 1));
|
|
||||||
} else if (key.return) {
|
|
||||||
// Execute selected operation
|
|
||||||
const operations = ["update", "rollback", "test", "analyze"];
|
|
||||||
const selectedOperation = operations[selectedIndex];
|
|
||||||
executeOperation(
|
|
||||||
selectedOperation,
|
|
||||||
config,
|
|
||||||
setOperationStatus,
|
|
||||||
setOperationProgress,
|
|
||||||
setOperationResults
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (key.escape) {
|
|
||||||
setCurrentScreen("main-menu");
|
|
||||||
setSelectedIndex(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (currentScreen === "main-menu") {
|
|
||||||
const menuItems = [
|
|
||||||
"⚙️ Configuration",
|
|
||||||
"🔧 Operations",
|
|
||||||
"📅 Scheduling",
|
|
||||||
"📋 View Logs",
|
|
||||||
"🏷️ Tag Analysis",
|
|
||||||
];
|
|
||||||
|
|
||||||
return React.createElement(
|
|
||||||
Box,
|
|
||||||
{ flexDirection: "column", padding: 1 },
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ color: "cyan", bold: true },
|
|
||||||
"🎉 Shopify Price Updater TUI"
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ color: "gray", marginBottom: 1 },
|
|
||||||
"Use ↑/↓ arrows to navigate, Enter to select, Esc to go back"
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Box,
|
|
||||||
{
|
|
||||||
flexDirection: "column",
|
|
||||||
borderStyle: "single",
|
|
||||||
borderColor: "blue",
|
|
||||||
padding: 1,
|
|
||||||
},
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ color: "blue", bold: true },
|
|
||||||
"Main Menu"
|
|
||||||
),
|
|
||||||
...menuItems.map((item, index) =>
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{
|
|
||||||
key: index,
|
|
||||||
color: index === selectedIndex ? "black" : "white",
|
|
||||||
backgroundColor: index === selectedIndex ? "blue" : undefined,
|
|
||||||
marginLeft: 1,
|
|
||||||
},
|
|
||||||
`${index === selectedIndex ? "► " : " "}${item}`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Box,
|
|
||||||
{
|
|
||||||
marginTop: 1,
|
|
||||||
borderStyle: "single",
|
|
||||||
borderColor: "green",
|
|
||||||
padding: 1,
|
|
||||||
},
|
|
||||||
React.createElement(Text, { color: "green" }, "✅ Status: Ready"),
|
|
||||||
React.createElement(Text, { color: "gray" }, "Press Ctrl+C to exit")
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configuration screen with working input fields
|
|
||||||
if (currentScreen === "configuration") {
|
|
||||||
const fields = [
|
|
||||||
{
|
|
||||||
key: "shopDomain",
|
|
||||||
label: "Shopify Domain",
|
|
||||||
placeholder: "your-store.myshopify.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "accessToken",
|
|
||||||
label: "Access Token",
|
|
||||||
placeholder: "shpat_...",
|
|
||||||
secret: true,
|
|
||||||
},
|
|
||||||
{ key: "targetTag", label: "Target Tag", placeholder: "sale" },
|
|
||||||
{
|
|
||||||
key: "priceAdjustment",
|
|
||||||
label: "Price Adjustment %",
|
|
||||||
placeholder: "10",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "operationMode",
|
|
||||||
label: "Operation Mode",
|
|
||||||
placeholder: "update/rollback",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return React.createElement(
|
|
||||||
Box,
|
|
||||||
{ flexDirection: "column", padding: 1 },
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ color: "cyan", bold: true },
|
|
||||||
"⚙️ Configuration"
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ color: "gray", marginBottom: 1 },
|
|
||||||
"Edit your Shopify store settings (Press Enter to edit, Esc to cancel)"
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Box,
|
|
||||||
{
|
|
||||||
flexDirection: "column",
|
|
||||||
borderStyle: "single",
|
|
||||||
borderColor: "yellow",
|
|
||||||
padding: 1,
|
|
||||||
marginBottom: 1,
|
|
||||||
},
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ color: "yellow", bold: true },
|
|
||||||
"📋 Current Configuration:"
|
|
||||||
),
|
|
||||||
...fields.map((field, index) => {
|
|
||||||
const isSelected = selectedIndex === index;
|
|
||||||
const isEditing = editingField === index;
|
|
||||||
const value = config[field.key] || "";
|
|
||||||
const displayValue =
|
|
||||||
field.secret && value ? "*".repeat(value.length) : value;
|
|
||||||
|
|
||||||
return React.createElement(
|
|
||||||
Box,
|
|
||||||
{ key: field.key, marginLeft: 2, marginY: 0 },
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{
|
|
||||||
color: isSelected ? "blue" : "white",
|
|
||||||
backgroundColor: isSelected ? "gray" : undefined,
|
|
||||||
bold: isSelected,
|
|
||||||
},
|
|
||||||
`${isSelected ? "► " : " "}${field.label}: `
|
|
||||||
),
|
|
||||||
isEditing
|
|
||||||
? React.createElement(TextInput.default, {
|
|
||||||
value: tempValue,
|
|
||||||
placeholder: field.placeholder,
|
|
||||||
onChange: setTempValue,
|
|
||||||
mask: field.secret ? "*" : undefined,
|
|
||||||
})
|
|
||||||
: React.createElement(
|
|
||||||
Text,
|
|
||||||
{ color: value ? "green" : "red" },
|
|
||||||
value ? displayValue : "[Not configured]"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Box,
|
|
||||||
{
|
|
||||||
flexDirection: "column",
|
|
||||||
borderStyle: "single",
|
|
||||||
borderColor: "green",
|
|
||||||
padding: 1,
|
|
||||||
marginBottom: 1,
|
|
||||||
},
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{
|
|
||||||
color: selectedIndex === 5 ? "black" : "green",
|
|
||||||
backgroundColor: selectedIndex === 5 ? "green" : undefined,
|
|
||||||
bold: selectedIndex === 5,
|
|
||||||
},
|
|
||||||
`${selectedIndex === 5 ? "► " : " "}💾 Save Configuration`
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{
|
|
||||||
color: selectedIndex === 6 ? "black" : "blue",
|
|
||||||
backgroundColor: selectedIndex === 6 ? "blue" : undefined,
|
|
||||||
bold: selectedIndex === 6,
|
|
||||||
},
|
|
||||||
`${selectedIndex === 6 ? "► " : " "}🔙 Back to Menu`
|
|
||||||
)
|
|
||||||
),
|
|
||||||
saveStatus &&
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{
|
|
||||||
color: saveStatus.includes("✅") ? "green" : "red",
|
|
||||||
marginBottom: 1,
|
|
||||||
},
|
|
||||||
saveStatus
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ color: "gray" },
|
|
||||||
editingField !== null
|
|
||||||
? "Type your value and press Enter to save, Esc to cancel"
|
|
||||||
: "Use ↑/↓ to navigate, Enter to edit/select, Esc to go back"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Operations screen
|
|
||||||
if (currentScreen === "operation") {
|
|
||||||
const isConfigured =
|
|
||||||
config.shopDomain &&
|
|
||||||
config.accessToken &&
|
|
||||||
config.targetTag &&
|
|
||||||
config.priceAdjustment;
|
|
||||||
|
|
||||||
const operations = [
|
|
||||||
{
|
|
||||||
key: "update",
|
|
||||||
label: "Update Prices",
|
|
||||||
description: "Apply percentage adjustment to product prices",
|
|
||||||
icon: "💰",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "rollback",
|
|
||||||
label: "Rollback Prices",
|
|
||||||
description: "Revert prices to compare-at values",
|
|
||||||
icon: "↩️",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "test",
|
|
||||||
label: "Test Connection",
|
|
||||||
description: "Verify Shopify API access and credentials",
|
|
||||||
icon: "🔗",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "analyze",
|
|
||||||
label: "Analyze Products",
|
|
||||||
description: "Preview products that will be affected",
|
|
||||||
icon: "📊",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return React.createElement(
|
|
||||||
Box,
|
|
||||||
{ flexDirection: "column", padding: 1 },
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ color: "cyan", bold: true },
|
|
||||||
"🔧 Operations"
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ color: "gray", marginBottom: 1 },
|
|
||||||
"Select and execute price update operations"
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Box,
|
|
||||||
{
|
|
||||||
flexDirection: "column",
|
|
||||||
borderStyle: "single",
|
|
||||||
borderColor: isConfigured ? "green" : "red",
|
|
||||||
padding: 1,
|
|
||||||
marginBottom: 1,
|
|
||||||
},
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ color: isConfigured ? "green" : "red", bold: true },
|
|
||||||
isConfigured
|
|
||||||
? "✅ Configuration Status: Ready"
|
|
||||||
: "⚠️ Configuration Status: Incomplete"
|
|
||||||
),
|
|
||||||
isConfigured &&
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ marginLeft: 2, color: "gray" },
|
|
||||||
`Domain: ${config.shopDomain}`
|
|
||||||
),
|
|
||||||
isConfigured &&
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ marginLeft: 2, color: "gray" },
|
|
||||||
`Tag: ${config.targetTag}`
|
|
||||||
),
|
|
||||||
isConfigured &&
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ marginLeft: 2, color: "gray" },
|
|
||||||
`Adjustment: ${config.priceAdjustment}%`
|
|
||||||
),
|
|
||||||
isConfigured &&
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ marginLeft: 2, color: "gray" },
|
|
||||||
`Mode: ${config.operationMode}`
|
|
||||||
)
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Box,
|
|
||||||
{
|
|
||||||
flexDirection: "column",
|
|
||||||
borderStyle: "single",
|
|
||||||
borderColor: "blue",
|
|
||||||
padding: 1,
|
|
||||||
marginBottom: 1,
|
|
||||||
},
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ color: "blue", bold: true },
|
|
||||||
"🚀 Select Operation:"
|
|
||||||
),
|
|
||||||
...operations.map((operation, index) => {
|
|
||||||
const isSelected = selectedIndex === index;
|
|
||||||
const isEnabled = isConfigured || operation.key === "test";
|
|
||||||
|
|
||||||
return React.createElement(
|
|
||||||
Box,
|
|
||||||
{ key: operation.key, marginLeft: 1, marginY: 0 },
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{
|
|
||||||
color: isSelected ? "black" : isEnabled ? "white" : "gray",
|
|
||||||
backgroundColor: isSelected ? "blue" : undefined,
|
|
||||||
bold: isSelected,
|
|
||||||
},
|
|
||||||
`${isSelected ? "► " : " "}${operation.icon} ${
|
|
||||||
operation.label
|
|
||||||
}`
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{
|
|
||||||
color: isEnabled ? "gray" : "darkGray",
|
|
||||||
marginLeft: 4,
|
|
||||||
},
|
|
||||||
operation.description
|
|
||||||
)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
),
|
|
||||||
// Operation status and progress
|
|
||||||
operationStatus &&
|
|
||||||
React.createElement(
|
|
||||||
Box,
|
|
||||||
{
|
|
||||||
flexDirection: "column",
|
|
||||||
borderStyle: "single",
|
|
||||||
borderColor: operationStatus.includes("✅")
|
|
||||||
? "green"
|
|
||||||
: operationStatus.includes("❌")
|
|
||||||
? "red"
|
|
||||||
: "yellow",
|
|
||||||
padding: 1,
|
|
||||||
marginBottom: 1,
|
|
||||||
},
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{
|
|
||||||
color: operationStatus.includes("✅")
|
|
||||||
? "green"
|
|
||||||
: operationStatus.includes("❌")
|
|
||||||
? "red"
|
|
||||||
: "yellow",
|
|
||||||
bold: true,
|
|
||||||
},
|
|
||||||
operationStatus
|
|
||||||
),
|
|
||||||
operationProgress &&
|
|
||||||
React.createElement(
|
|
||||||
Box,
|
|
||||||
{ flexDirection: "column", marginTop: 1 },
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ color: "gray" },
|
|
||||||
operationProgress.message
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Box,
|
|
||||||
{ flexDirection: "row", marginTop: 0 },
|
|
||||||
React.createElement(Text, { color: "blue" }, "Progress: "),
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ color: "white" },
|
|
||||||
"█".repeat(Math.floor(operationProgress.current / 5))
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ color: "gray" },
|
|
||||||
"░".repeat(20 - Math.floor(operationProgress.current / 5))
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ color: "blue", marginLeft: 1 },
|
|
||||||
`${operationProgress.current}%`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
operationResults &&
|
|
||||||
React.createElement(
|
|
||||||
Box,
|
|
||||||
{ flexDirection: "column", marginTop: 1 },
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{
|
|
||||||
color: operationResults.success ? "green" : "red",
|
|
||||||
bold: true,
|
|
||||||
},
|
|
||||||
operationResults.message
|
|
||||||
),
|
|
||||||
...operationResults.details.map((detail, index) =>
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ key: index, color: "gray", marginLeft: 2 },
|
|
||||||
`• ${detail}`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
// Help text
|
|
||||||
!isConfigured &&
|
|
||||||
React.createElement(
|
|
||||||
Box,
|
|
||||||
{
|
|
||||||
borderStyle: "single",
|
|
||||||
borderColor: "yellow",
|
|
||||||
padding: 1,
|
|
||||||
marginBottom: 1,
|
|
||||||
},
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ color: "yellow", bold: true },
|
|
||||||
"💡 Configuration Required:"
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ marginLeft: 2, color: "gray" },
|
|
||||||
"Most operations require configuration. Go to Configuration first."
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ marginLeft: 2, color: "gray" },
|
|
||||||
"You can still test your connection without full configuration."
|
|
||||||
)
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ color: "gray" },
|
|
||||||
isConfigured
|
|
||||||
? "Use ↑/↓ to navigate, Enter to execute operation, Esc to go back"
|
|
||||||
: "Configure your settings first, or test connection. Press Esc to go back"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Other screens (simplified for now)
|
|
||||||
return React.createElement(
|
|
||||||
Box,
|
|
||||||
{ flexDirection: "column", padding: 1 },
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ color: "cyan", bold: true },
|
|
||||||
`📱 ${currentScreen.toUpperCase()} Screen`
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ color: "gray" },
|
|
||||||
"This screen is under construction"
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Box,
|
|
||||||
{
|
|
||||||
marginTop: 1,
|
|
||||||
borderStyle: "single",
|
|
||||||
borderColor: "blue",
|
|
||||||
padding: 1,
|
|
||||||
},
|
|
||||||
React.createElement(Text, { color: "blue" }, "🚧 Coming Soon:"),
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ marginLeft: 2 },
|
|
||||||
"• Interactive forms and inputs"
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ marginLeft: 2 },
|
|
||||||
"• Real-time progress tracking"
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ marginLeft: 2 },
|
|
||||||
"• Log viewing and filtering"
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ marginLeft: 2 },
|
|
||||||
"• Advanced scheduling options"
|
|
||||||
)
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ color: "gray", marginTop: 1 },
|
|
||||||
"Press Esc to return to main menu"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log("🎨 Rendering TUI...");
|
console.log("🎨 Rendering TUI...");
|
||||||
const { waitUntilExit } = render(React.createElement(TuiApp));
|
|
||||||
|
// Render the TUI application
|
||||||
|
const { waitUntilExit } = render(React.createElement(TuiApplication));
|
||||||
|
|
||||||
// Wait for the application to exit
|
// Wait for the application to exit
|
||||||
await waitUntilExit();
|
await waitUntilExit();
|
||||||
|
|||||||
@@ -51,3 +51,4 @@ const TuiContent = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
module.exports = TuiApplication;
|
module.exports = TuiApplication;
|
||||||
|
module.exports.default = TuiApplication;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const ConfigurationScreen = require("./screens/ConfigurationScreen.jsx");
|
|||||||
const OperationScreen = require("./screens/OperationScreen.jsx");
|
const OperationScreen = require("./screens/OperationScreen.jsx");
|
||||||
const SchedulingScreen = require("./screens/SchedulingScreen.jsx");
|
const SchedulingScreen = require("./screens/SchedulingScreen.jsx");
|
||||||
const ViewLogsScreen = require("./screens/ViewLogsScreen.jsx");
|
const ViewLogsScreen = require("./screens/ViewLogsScreen.jsx");
|
||||||
// const TagAnalysisScreen = require("./screens/TagAnalysisScreen.jsx");
|
const TagAnalysisScreen = require("./screens/TagAnalysisScreen.jsx");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Router Component
|
* Router Component
|
||||||
@@ -25,7 +25,7 @@ const Router = () => {
|
|||||||
operation: OperationScreen,
|
operation: OperationScreen,
|
||||||
scheduling: SchedulingScreen,
|
scheduling: SchedulingScreen,
|
||||||
logs: ViewLogsScreen,
|
logs: ViewLogsScreen,
|
||||||
// "tag-analysis": TagAnalysisScreen,
|
"tag-analysis": TagAnalysisScreen,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get the current screen component
|
// Get the current screen component
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ const ErrorDisplay = ({
|
|||||||
retryText = "Press 'r' to retry",
|
retryText = "Press 'r' to retry",
|
||||||
dismissText = "Press 'd' to dismiss",
|
dismissText = "Press 'd' to dismiss",
|
||||||
compact = false,
|
compact = false,
|
||||||
|
showTroubleshooting = true,
|
||||||
}) => {
|
}) => {
|
||||||
const [dismissed, setDismissed] = React.useState(false);
|
const [dismissed, setDismissed] = React.useState(false);
|
||||||
|
const [showDetails, setShowDetails] = React.useState(false);
|
||||||
|
|
||||||
useInput((input, key) => {
|
useInput((input, key) => {
|
||||||
if (input === "r" && onRetry && showRetry) {
|
if (input === "r" && onRetry && showRetry) {
|
||||||
@@ -28,6 +30,8 @@ const ErrorDisplay = ({
|
|||||||
} else {
|
} else {
|
||||||
setDismissed(true);
|
setDismissed(true);
|
||||||
}
|
}
|
||||||
|
} else if (input === "t" && showTroubleshooting && hasTroubleshooting()) {
|
||||||
|
setShowDetails(!showDetails);
|
||||||
} else if (key.escape && showDismiss) {
|
} else if (key.escape && showDismiss) {
|
||||||
if (onDismiss) {
|
if (onDismiss) {
|
||||||
onDismiss();
|
onDismiss();
|
||||||
@@ -65,6 +69,29 @@ const ErrorDisplay = ({
|
|||||||
return "Error";
|
return "Error";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasTroubleshooting = () => {
|
||||||
|
return (
|
||||||
|
error &&
|
||||||
|
error.troubleshooting &&
|
||||||
|
Array.isArray(error.troubleshooting) &&
|
||||||
|
error.troubleshooting.length > 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContextInfo = () => {
|
||||||
|
if (!error || !error.context) return null;
|
||||||
|
|
||||||
|
const context = error.context;
|
||||||
|
const info = [];
|
||||||
|
|
||||||
|
if (context.operation) info.push(`Operation: ${context.operation}`);
|
||||||
|
if (context.retries) info.push(`Retries attempted: ${context.retries}`);
|
||||||
|
if (context.file) info.push(`File: ${context.file}`);
|
||||||
|
if (context.filePath) info.push(`File path: ${context.filePath}`);
|
||||||
|
|
||||||
|
return info.length > 0 ? info : null;
|
||||||
|
};
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="row" alignItems="center">
|
<Box flexDirection="row" alignItems="center">
|
||||||
@@ -81,6 +108,11 @@ const ErrorDisplay = ({
|
|||||||
(d: dismiss)
|
(d: dismiss)
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{hasTroubleshooting() && (
|
||||||
|
<Text color="gray" marginLeft={1}>
|
||||||
|
(t: help)
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -105,11 +137,50 @@ const ErrorDisplay = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Context information */}
|
||||||
|
{getContextInfo() && (
|
||||||
|
<Box marginBottom={1}>
|
||||||
|
<Text color="gray" bold>
|
||||||
|
Context:
|
||||||
|
</Text>
|
||||||
|
{getContextInfo().map((info, index) => (
|
||||||
|
<Text key={index} color="gray" marginLeft={2}>
|
||||||
|
• {info}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Troubleshooting section */}
|
||||||
|
{showDetails && hasTroubleshooting() && (
|
||||||
|
<Box
|
||||||
|
flexDirection="column"
|
||||||
|
marginBottom={1}
|
||||||
|
borderStyle="single"
|
||||||
|
borderColor="yellow"
|
||||||
|
padding={1}
|
||||||
|
>
|
||||||
|
<Text color="yellow" bold>
|
||||||
|
💡 Troubleshooting:
|
||||||
|
</Text>
|
||||||
|
{error.troubleshooting.map((tip, index) => (
|
||||||
|
<Text key={index} color="white" marginLeft={2}>
|
||||||
|
• {tip}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<Box flexDirection="column">
|
<Box flexDirection="column">
|
||||||
{showRetry && onRetry && <Text color="cyan">• {retryText}</Text>}
|
{showRetry && onRetry && <Text color="cyan">• {retryText}</Text>}
|
||||||
{showDismiss && (
|
{showDismiss && (
|
||||||
<Text color="cyan">• {dismissText} or press Escape</Text>
|
<Text color="cyan">• {dismissText} or press Escape</Text>
|
||||||
)}
|
)}
|
||||||
|
{hasTroubleshooting() && (
|
||||||
|
<Text color="cyan">
|
||||||
|
• Press 't' to {showDetails ? "hide" : "show"} troubleshooting tips
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
171
src/tui/components/common/ScreenErrorBoundary.jsx
Normal file
171
src/tui/components/common/ScreenErrorBoundary.jsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
const React = require("react");
|
||||||
|
const { Box, Text, useInput } = require("ink");
|
||||||
|
const ErrorDisplay = require("./ErrorDisplay.jsx");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScreenErrorBoundary Component
|
||||||
|
* Specialized error boundary for TUI screens with screen-specific error handling
|
||||||
|
* Requirements: 4.5, 16.1
|
||||||
|
*/
|
||||||
|
class ScreenErrorBoundary extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
errorInfo: null,
|
||||||
|
retryCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error) {
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error, errorInfo) {
|
||||||
|
this.setState({
|
||||||
|
error: error,
|
||||||
|
errorInfo: errorInfo,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log error with screen context
|
||||||
|
console.error(
|
||||||
|
`Screen Error in ${this.props.screenName}:`,
|
||||||
|
error,
|
||||||
|
errorInfo
|
||||||
|
);
|
||||||
|
|
||||||
|
// Call onError callback if provided
|
||||||
|
if (this.props.onError) {
|
||||||
|
this.props.onError(error, errorInfo, this.props.screenName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleRetry = () => {
|
||||||
|
this.setState((prevState) => ({
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
errorInfo: null,
|
||||||
|
retryCount: prevState.retryCount + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (this.props.onRetry) {
|
||||||
|
this.props.onRetry(this.state.retryCount + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleReset = () => {
|
||||||
|
this.setState({
|
||||||
|
hasError: false,
|
||||||
|
error: null,
|
||||||
|
errorInfo: null,
|
||||||
|
retryCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.props.onReset) {
|
||||||
|
this.props.onReset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleExit = () => {
|
||||||
|
if (this.props.onExit) {
|
||||||
|
this.props.onExit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
const screenName = this.props.screenName || "Screen";
|
||||||
|
const maxRetries = this.props.maxRetries || 3;
|
||||||
|
const canRetry = this.state.retryCount < maxRetries;
|
||||||
|
|
||||||
|
// Create enhanced error with screen-specific troubleshooting
|
||||||
|
const enhancedError = {
|
||||||
|
...this.state.error,
|
||||||
|
message: this.state.error.message,
|
||||||
|
troubleshooting: [
|
||||||
|
...(this.state.error.troubleshooting || []),
|
||||||
|
`This error occurred in the ${screenName}`,
|
||||||
|
"Try navigating back to the main menu and returning to this screen",
|
||||||
|
"Check if your system has sufficient resources available",
|
||||||
|
"Restart the application if the problem persists",
|
||||||
|
...(this.props.screenSpecificTips || []),
|
||||||
|
],
|
||||||
|
context: {
|
||||||
|
...(this.state.error.context || {}),
|
||||||
|
screen: screenName,
|
||||||
|
retryCount: this.state.retryCount,
|
||||||
|
maxRetries: maxRetries,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return React.createElement(
|
||||||
|
Box,
|
||||||
|
{ flexDirection: "column", padding: 2, flexGrow: 1 },
|
||||||
|
React.createElement(
|
||||||
|
Box,
|
||||||
|
{ marginBottom: 2 },
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "red", bold: true },
|
||||||
|
`💥 ${screenName} Error`
|
||||||
|
)
|
||||||
|
),
|
||||||
|
React.createElement(ErrorDisplay, {
|
||||||
|
error: enhancedError,
|
||||||
|
title: `${screenName} Crashed`,
|
||||||
|
onRetry: canRetry ? this.handleRetry : null,
|
||||||
|
onDismiss: this.handleExit,
|
||||||
|
showRetry: canRetry,
|
||||||
|
showDismiss: true,
|
||||||
|
retryText: `Press 'r' to retry (${
|
||||||
|
maxRetries - this.state.retryCount
|
||||||
|
} attempts left)`,
|
||||||
|
dismissText: "Press 'd' to exit to main menu",
|
||||||
|
showTroubleshooting: true,
|
||||||
|
}),
|
||||||
|
!canRetry &&
|
||||||
|
React.createElement(
|
||||||
|
Box,
|
||||||
|
{
|
||||||
|
marginTop: 2,
|
||||||
|
borderStyle: "single",
|
||||||
|
borderColor: "red",
|
||||||
|
padding: 1,
|
||||||
|
},
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "red", bold: true },
|
||||||
|
"⚠️ Maximum retry attempts reached"
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "white", marginTop: 1 },
|
||||||
|
"The screen has crashed multiple times. Please exit and try again later."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook component for handling keyboard input in error boundary
|
||||||
|
*/
|
||||||
|
const ErrorBoundaryKeyHandler = ({ onRetry, onExit, canRetry }) => {
|
||||||
|
useInput((input, key) => {
|
||||||
|
if (input === "r" && onRetry && canRetry) {
|
||||||
|
onRetry();
|
||||||
|
} else if (input === "d" || key.escape) {
|
||||||
|
if (onExit) {
|
||||||
|
onExit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = ScreenErrorBoundary;
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
// Export all reusable TUI components
|
// Export all reusable TUI components
|
||||||
const ErrorDisplay = require("./ErrorDisplay.jsx");
|
const ErrorDisplay = require("./ErrorDisplay.jsx");
|
||||||
|
const ErrorBoundary = require("./ErrorBoundary.jsx");
|
||||||
|
const ScreenErrorBoundary = require("./ScreenErrorBoundary.jsx");
|
||||||
const { LoadingIndicator, LoadingOverlay } = require("./LoadingIndicator.jsx");
|
const { LoadingIndicator, LoadingOverlay } = require("./LoadingIndicator.jsx");
|
||||||
const { Pagination, SimplePagination } = require("./Pagination.jsx");
|
const { Pagination, SimplePagination } = require("./Pagination.jsx");
|
||||||
const { FormInput, SimpleFormInput } = require("./FormInput.jsx");
|
const { FormInput, SimpleFormInput } = require("./FormInput.jsx");
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
ErrorDisplay,
|
ErrorDisplay,
|
||||||
|
ErrorBoundary,
|
||||||
|
ScreenErrorBoundary,
|
||||||
LoadingIndicator,
|
LoadingIndicator,
|
||||||
LoadingOverlay,
|
LoadingOverlay,
|
||||||
Pagination,
|
Pagination,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,13 @@ const { Pagination } = require("../common/Pagination.jsx");
|
|||||||
*/
|
*/
|
||||||
const ViewLogsScreen = () => {
|
const ViewLogsScreen = () => {
|
||||||
const { navigateBack } = useAppState();
|
const { navigateBack } = useAppState();
|
||||||
const { getLogFiles, readLogFile } = useServices();
|
const {
|
||||||
|
getLogFiles,
|
||||||
|
readLogFile,
|
||||||
|
parseLogContent,
|
||||||
|
getFilteredLogs,
|
||||||
|
filterLogs,
|
||||||
|
} = useServices();
|
||||||
|
|
||||||
// State management for log files, selected file, and content
|
// State management for log files, selected file, and content
|
||||||
const [logFiles, setLogFiles] = React.useState([]);
|
const [logFiles, setLogFiles] = React.useState([]);
|
||||||
@@ -21,12 +27,28 @@ const ViewLogsScreen = () => {
|
|||||||
const [selectedFile, setSelectedFile] = React.useState(null);
|
const [selectedFile, setSelectedFile] = React.useState(null);
|
||||||
const [logContent, setLogContent] = React.useState("");
|
const [logContent, setLogContent] = React.useState("");
|
||||||
const [parsedLogs, setParsedLogs] = React.useState([]);
|
const [parsedLogs, setParsedLogs] = React.useState([]);
|
||||||
|
const [filteredLogs, setFilteredLogs] = React.useState([]);
|
||||||
const [currentPage, setCurrentPage] = React.useState(0);
|
const [currentPage, setCurrentPage] = React.useState(0);
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [error, setError] = React.useState(null);
|
const [error, setError] = React.useState(null);
|
||||||
const [loadingContent, setLoadingContent] = React.useState(false);
|
const [loadingContent, setLoadingContent] = React.useState(false);
|
||||||
const [contentError, setContentError] = React.useState(null);
|
const [contentError, setContentError] = React.useState(null);
|
||||||
const [viewMode, setViewMode] = React.useState("parsed"); // "parsed" or "raw"
|
const [viewMode, setViewMode] = React.useState("parsed"); // "parsed" or "raw"
|
||||||
|
const [scrollOffset, setScrollOffset] = React.useState(0);
|
||||||
|
const [selectedLogIndex, setSelectedLogIndex] = React.useState(0);
|
||||||
|
const [showingContent, setShowingContent] = React.useState(false);
|
||||||
|
|
||||||
|
// Filter and search state
|
||||||
|
const [showFilters, setShowFilters] = React.useState(false);
|
||||||
|
const [filters, setFilters] = React.useState({
|
||||||
|
dateRange: "all",
|
||||||
|
operationType: "all",
|
||||||
|
status: "all",
|
||||||
|
searchTerm: "",
|
||||||
|
});
|
||||||
|
const [filterInputMode, setFilterInputMode] = React.useState(null); // "search", "dateRange", "operationType", "status"
|
||||||
|
const [searchInput, setSearchInput] = React.useState("");
|
||||||
|
const [totalUnfilteredEntries, setTotalUnfilteredEntries] = React.useState(0);
|
||||||
|
|
||||||
// Load log files on component mount
|
// Load log files on component mount
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -35,10 +57,24 @@ const ViewLogsScreen = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const files = await getLogFiles();
|
const files = await getLogFiles();
|
||||||
setLogFiles(files);
|
|
||||||
|
// Transform the files to match expected format
|
||||||
|
const transformedFiles = files.map((file) => ({
|
||||||
|
filename: file.name,
|
||||||
|
path: file.path,
|
||||||
|
size: file.size,
|
||||||
|
createdAt: file.created,
|
||||||
|
modifiedAt: file.modified,
|
||||||
|
isMainLog: file.isMain,
|
||||||
|
operationCount: 0, // Will be calculated from content if needed
|
||||||
|
}));
|
||||||
|
|
||||||
|
setLogFiles(transformedFiles);
|
||||||
|
|
||||||
// Auto-select the main Progress.md file if it exists
|
// Auto-select the main Progress.md file if it exists
|
||||||
const mainLogIndex = files.findIndex((file) => file.isMainLog);
|
const mainLogIndex = transformedFiles.findIndex(
|
||||||
|
(file) => file.isMainLog
|
||||||
|
);
|
||||||
if (mainLogIndex !== -1) {
|
if (mainLogIndex !== -1) {
|
||||||
setSelectedFileIndex(mainLogIndex);
|
setSelectedFileIndex(mainLogIndex);
|
||||||
}
|
}
|
||||||
@@ -52,34 +88,60 @@ const ViewLogsScreen = () => {
|
|||||||
loadLogFiles();
|
loadLogFiles();
|
||||||
}, [getLogFiles]);
|
}, [getLogFiles]);
|
||||||
|
|
||||||
// Load content for selected file
|
// Load content for selected file with filtering
|
||||||
const loadFileContent = React.useCallback(
|
const loadFileContent = React.useCallback(
|
||||||
async (file) => {
|
async (file, filterOptions = null) => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoadingContent(true);
|
setLoadingContent(true);
|
||||||
setContentError(null);
|
setContentError(null);
|
||||||
setCurrentPage(0); // Reset pagination
|
setCurrentPage(0); // Reset pagination
|
||||||
|
setScrollOffset(0); // Reset scroll
|
||||||
|
setSelectedLogIndex(0); // Reset selection
|
||||||
|
|
||||||
const content = await readLogFile(file.filename);
|
const currentFilters = filterOptions || filters;
|
||||||
setLogContent(content);
|
|
||||||
|
|
||||||
// Parse the content into structured log entries
|
// Use getFilteredLogs if we have filtering service available
|
||||||
const { parseLogContent } = useServices();
|
if (getFilteredLogs) {
|
||||||
const parsed = parseLogContent(content);
|
const result = await getFilteredLogs({
|
||||||
setParsedLogs(parsed);
|
filePath: file.filename,
|
||||||
|
page: 0,
|
||||||
|
pageSize: 1000, // Load all entries for client-side pagination
|
||||||
|
...currentFilters,
|
||||||
|
});
|
||||||
|
|
||||||
|
setLogContent(result.metadata?.rawContent || "");
|
||||||
|
setParsedLogs(result.entries || []);
|
||||||
|
setFilteredLogs(result.entries || []);
|
||||||
|
setTotalUnfilteredEntries(
|
||||||
|
result.metadata?.totalUnfilteredEntries || 0
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Fallback to original method
|
||||||
|
const content = await readLogFile(file.filename);
|
||||||
|
setLogContent(content);
|
||||||
|
|
||||||
|
// Parse the content into structured log entries
|
||||||
|
const parsed = parseLogContent(content);
|
||||||
|
setParsedLogs(parsed);
|
||||||
|
setFilteredLogs(parsed);
|
||||||
|
setTotalUnfilteredEntries(parsed.length);
|
||||||
|
}
|
||||||
|
|
||||||
setSelectedFile(file);
|
setSelectedFile(file);
|
||||||
|
setShowingContent(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setContentError(`Failed to read log file: ${err.message}`);
|
setContentError(`Failed to read log file: ${err.message}`);
|
||||||
setLogContent("");
|
setLogContent("");
|
||||||
setParsedLogs([]);
|
setParsedLogs([]);
|
||||||
|
setFilteredLogs([]);
|
||||||
|
setTotalUnfilteredEntries(0);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingContent(false);
|
setLoadingContent(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[readLogFile, useServices]
|
[readLogFile, parseLogContent, getFilteredLogs, filterLogs, filters]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Helper function to format file size
|
// Helper function to format file size
|
||||||
@@ -115,31 +177,276 @@ const ViewLogsScreen = () => {
|
|||||||
return date.toLocaleDateString();
|
return date.toLocaleDateString();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keyboard navigation for log file selection
|
// Helper function to get color for log level
|
||||||
|
const getLogLevelColor = (level) => {
|
||||||
|
switch (level?.toUpperCase()) {
|
||||||
|
case "ERROR":
|
||||||
|
return "red";
|
||||||
|
case "WARNING":
|
||||||
|
return "yellow";
|
||||||
|
case "SUCCESS":
|
||||||
|
return "green";
|
||||||
|
case "INFO":
|
||||||
|
default:
|
||||||
|
return "white";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to get status icon
|
||||||
|
const getStatusIcon = (level) => {
|
||||||
|
switch (level?.toUpperCase()) {
|
||||||
|
case "ERROR":
|
||||||
|
return "❌";
|
||||||
|
case "WARNING":
|
||||||
|
return "⚠️";
|
||||||
|
case "SUCCESS":
|
||||||
|
return "✅";
|
||||||
|
case "INFO":
|
||||||
|
default:
|
||||||
|
return "ℹ️";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to format timestamp
|
||||||
|
const formatTimestamp = (timestamp) => {
|
||||||
|
try {
|
||||||
|
return timestamp.toLocaleString();
|
||||||
|
} catch (error) {
|
||||||
|
return "Invalid date";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to truncate text
|
||||||
|
const truncateText = (text, maxLength = 80) => {
|
||||||
|
if (!text || text.length <= maxLength) return text;
|
||||||
|
return text.substring(0, maxLength - 3) + "...";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply filters to current log content
|
||||||
|
const applyFilters = React.useCallback(
|
||||||
|
async (newFilters) => {
|
||||||
|
if (!selectedFile) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoadingContent(true);
|
||||||
|
setContentError(null);
|
||||||
|
setCurrentPage(0); // Reset pagination
|
||||||
|
setSelectedLogIndex(0); // Reset selection
|
||||||
|
|
||||||
|
// Use getFilteredLogs if available
|
||||||
|
if (getFilteredLogs) {
|
||||||
|
const result = await getFilteredLogs({
|
||||||
|
filePath: selectedFile.filename,
|
||||||
|
page: 0,
|
||||||
|
pageSize: 1000, // Load all entries for client-side pagination
|
||||||
|
...newFilters,
|
||||||
|
});
|
||||||
|
|
||||||
|
setFilteredLogs(result.entries || []);
|
||||||
|
setTotalUnfilteredEntries(
|
||||||
|
result.metadata?.totalUnfilteredEntries || 0
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Fallback: filter parsed logs client-side
|
||||||
|
if (filterLogs) {
|
||||||
|
const filtered = filterLogs(parsedLogs, newFilters);
|
||||||
|
setFilteredLogs(filtered);
|
||||||
|
} else {
|
||||||
|
setFilteredLogs(parsedLogs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilters(newFilters);
|
||||||
|
} catch (err) {
|
||||||
|
setContentError(`Failed to apply filters: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setLoadingContent(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedFile, getFilteredLogs, parsedLogs, filterLogs]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear all filters
|
||||||
|
const clearFilters = React.useCallback(() => {
|
||||||
|
const defaultFilters = {
|
||||||
|
dateRange: "all",
|
||||||
|
operationType: "all",
|
||||||
|
status: "all",
|
||||||
|
searchTerm: "",
|
||||||
|
};
|
||||||
|
setSearchInput("");
|
||||||
|
applyFilters(defaultFilters);
|
||||||
|
}, [applyFilters]);
|
||||||
|
|
||||||
|
// Handle search input
|
||||||
|
const handleSearchInput = React.useCallback(
|
||||||
|
(newSearchTerm) => {
|
||||||
|
setSearchInput(newSearchTerm);
|
||||||
|
const newFilters = { ...filters, searchTerm: newSearchTerm };
|
||||||
|
applyFilters(newFilters);
|
||||||
|
},
|
||||||
|
[filters, applyFilters]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle filter changes
|
||||||
|
const handleFilterChange = React.useCallback(
|
||||||
|
(filterType, value) => {
|
||||||
|
const newFilters = { ...filters, [filterType]: value };
|
||||||
|
applyFilters(newFilters);
|
||||||
|
},
|
||||||
|
[filters, applyFilters]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keyboard navigation for log file selection and content viewing
|
||||||
useInput((input, key) => {
|
useInput((input, key) => {
|
||||||
if (loading) return; // Ignore input while loading
|
if (loading) return; // Ignore input while loading
|
||||||
|
|
||||||
if (key.escape) {
|
// Handle filter input mode
|
||||||
navigateBack();
|
if (filterInputMode === "search") {
|
||||||
} else if (key.upArrow) {
|
if (key.escape) {
|
||||||
setSelectedFileIndex((prev) => Math.max(0, prev - 1));
|
setFilterInputMode(null);
|
||||||
} else if (key.downArrow) {
|
setSearchInput(filters.searchTerm); // Reset to current filter
|
||||||
setSelectedFileIndex((prev) => Math.min(logFiles.length - 1, prev + 1));
|
} else if (key.return) {
|
||||||
} else if (key.return) {
|
handleSearchInput(searchInput);
|
||||||
// Load content for selected file
|
setFilterInputMode(null);
|
||||||
if (logFiles.length > 0 && selectedFileIndex < logFiles.length) {
|
} else if (key.backspace) {
|
||||||
loadFileContent(logFiles[selectedFileIndex]);
|
setSearchInput((prev) => prev.slice(0, -1));
|
||||||
|
} else if (input && input.length === 1 && !key.ctrl && !key.meta) {
|
||||||
|
setSearchInput((prev) => prev + input);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.escape) {
|
||||||
|
if (showFilters && showingContent) {
|
||||||
|
// Close filters panel
|
||||||
|
setShowFilters(false);
|
||||||
|
} else if (showingContent) {
|
||||||
|
// Go back to file list
|
||||||
|
setShowingContent(false);
|
||||||
|
setSelectedFile(null);
|
||||||
|
setLogContent("");
|
||||||
|
setParsedLogs([]);
|
||||||
|
setFilteredLogs([]);
|
||||||
|
setCurrentPage(0);
|
||||||
|
setScrollOffset(0);
|
||||||
|
setSelectedLogIndex(0);
|
||||||
|
setShowFilters(false);
|
||||||
|
} else {
|
||||||
|
navigateBack();
|
||||||
|
}
|
||||||
|
} else if (showingContent) {
|
||||||
|
// Handle filter panel navigation
|
||||||
|
if (showFilters) {
|
||||||
|
if (input === "s") {
|
||||||
|
// Start search input
|
||||||
|
setFilterInputMode("search");
|
||||||
|
setSearchInput(filters.searchTerm);
|
||||||
|
} else if (input === "d") {
|
||||||
|
// Cycle through date range options
|
||||||
|
const dateOptions = ["all", "today", "yesterday", "week", "month"];
|
||||||
|
const currentIndex = dateOptions.indexOf(filters.dateRange);
|
||||||
|
const nextIndex = (currentIndex + 1) % dateOptions.length;
|
||||||
|
handleFilterChange("dateRange", dateOptions[nextIndex]);
|
||||||
|
} else if (input === "t") {
|
||||||
|
// Cycle through operation type options
|
||||||
|
const typeOptions = ["all", "update", "rollback", "scheduled"];
|
||||||
|
const currentIndex = typeOptions.indexOf(filters.operationType);
|
||||||
|
const nextIndex = (currentIndex + 1) % typeOptions.length;
|
||||||
|
handleFilterChange("operationType", typeOptions[nextIndex]);
|
||||||
|
} else if (input === "l") {
|
||||||
|
// Cycle through status/level options
|
||||||
|
const statusOptions = ["all", "error", "warning", "success", "info"];
|
||||||
|
const currentIndex = statusOptions.indexOf(filters.status);
|
||||||
|
const nextIndex = (currentIndex + 1) % statusOptions.length;
|
||||||
|
handleFilterChange("status", statusOptions[nextIndex]);
|
||||||
|
} else if (input === "c") {
|
||||||
|
// Clear all filters
|
||||||
|
clearFilters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle content viewing navigation
|
||||||
|
if (viewMode === "parsed") {
|
||||||
|
// Navigation for parsed log entries
|
||||||
|
if (key.upArrow) {
|
||||||
|
setSelectedLogIndex((prev) => Math.max(0, prev - 1));
|
||||||
|
} else if (key.downArrow) {
|
||||||
|
setSelectedLogIndex((prev) =>
|
||||||
|
Math.min(filteredLogs.length - 1, prev + 1)
|
||||||
|
);
|
||||||
|
} else if (key.pageUp) {
|
||||||
|
setCurrentPage((prev) => Math.max(0, prev - 1));
|
||||||
|
} else if (key.pageDown) {
|
||||||
|
const totalPages = Math.ceil(filteredLogs.length / 10);
|
||||||
|
setCurrentPage((prev) => Math.min(totalPages - 1, prev + 1));
|
||||||
|
} else if (input === "v") {
|
||||||
|
// Toggle view mode
|
||||||
|
setViewMode(viewMode === "parsed" ? "raw" : "parsed");
|
||||||
|
} else if (input === "f") {
|
||||||
|
// Toggle filters panel
|
||||||
|
setShowFilters(!showFilters);
|
||||||
|
} else if (input === "r") {
|
||||||
|
// Refresh content
|
||||||
|
if (selectedFile) {
|
||||||
|
loadFileContent(selectedFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Navigation for raw content
|
||||||
|
if (key.upArrow) {
|
||||||
|
setScrollOffset((prev) => Math.max(0, prev - 1));
|
||||||
|
} else if (key.downArrow) {
|
||||||
|
const maxLines = logContent.split("\n").length;
|
||||||
|
setScrollOffset((prev) => Math.min(maxLines - 10, prev + 1));
|
||||||
|
} else if (key.pageUp) {
|
||||||
|
setScrollOffset((prev) => Math.max(0, prev - 10));
|
||||||
|
} else if (key.pageDown) {
|
||||||
|
const maxLines = logContent.split("\n").length;
|
||||||
|
setScrollOffset((prev) => Math.min(maxLines - 10, prev + 10));
|
||||||
|
} else if (input === "v") {
|
||||||
|
// Toggle view mode
|
||||||
|
setViewMode(viewMode === "parsed" ? "raw" : "parsed");
|
||||||
|
} else if (input === "r") {
|
||||||
|
// Refresh content
|
||||||
|
if (selectedFile) {
|
||||||
|
loadFileContent(selectedFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle file list navigation
|
||||||
|
if (key.upArrow) {
|
||||||
|
setSelectedFileIndex((prev) => Math.max(0, prev - 1));
|
||||||
|
} else if (key.downArrow) {
|
||||||
|
setSelectedFileIndex((prev) => Math.min(logFiles.length - 1, prev + 1));
|
||||||
|
} else if (key.return) {
|
||||||
|
// Load content for selected file
|
||||||
|
if (logFiles.length > 0 && selectedFileIndex < logFiles.length) {
|
||||||
|
loadFileContent(logFiles[selectedFileIndex]);
|
||||||
|
}
|
||||||
|
} else if (input === "r") {
|
||||||
|
// Refresh log files list
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
getLogFiles()
|
||||||
|
.then((files) => {
|
||||||
|
const transformedFiles = files.map((file) => ({
|
||||||
|
filename: file.name,
|
||||||
|
path: file.path,
|
||||||
|
size: file.size,
|
||||||
|
createdAt: file.created,
|
||||||
|
modifiedAt: file.modified,
|
||||||
|
isMainLog: file.isMain,
|
||||||
|
operationCount: 0,
|
||||||
|
}));
|
||||||
|
setLogFiles(transformedFiles);
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
setError(`Failed to discover log files: ${err.message}`)
|
||||||
|
)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
}
|
}
|
||||||
} else if (input === "r") {
|
|
||||||
// Refresh log files list
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
getLogFiles()
|
|
||||||
.then(setLogFiles)
|
|
||||||
.catch((err) =>
|
|
||||||
setError(`Failed to discover log files: ${err.message}`)
|
|
||||||
)
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -178,87 +485,118 @@ const ViewLogsScreen = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show file content view if a file is selected
|
// Show file content view if a file is selected
|
||||||
if (selectedFile) {
|
if (showingContent && selectedFile) {
|
||||||
|
const pageSize = 10;
|
||||||
|
const totalPages = Math.ceil(filteredLogs.length / pageSize);
|
||||||
|
const startIndex = currentPage * pageSize;
|
||||||
|
const endIndex = Math.min(startIndex + pageSize, filteredLogs.length);
|
||||||
|
const currentPageLogs = filteredLogs.slice(startIndex, endIndex);
|
||||||
|
|
||||||
return React.createElement(
|
return React.createElement(
|
||||||
Box,
|
Box,
|
||||||
{ flexDirection: "column", padding: 2, flexGrow: 1 },
|
{ flexDirection: "column", padding: 2, flexGrow: 1 },
|
||||||
// Header
|
// Header
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Box,
|
Box,
|
||||||
{ flexDirection: "column", marginBottom: 2 },
|
{ flexDirection: "column", marginBottom: 1 },
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Text,
|
Box,
|
||||||
{ bold: true, color: "cyan" },
|
{ flexDirection: "row", justifyContent: "space-between" },
|
||||||
"📄 Log File Content"
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ bold: true, color: "cyan" },
|
||||||
|
"📄 Log File Content"
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "yellow", bold: true },
|
||||||
|
viewMode === "parsed" ? "PARSED VIEW" : "RAW VIEW"
|
||||||
|
)
|
||||||
),
|
),
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Text,
|
Text,
|
||||||
{ color: "gray" },
|
{ color: "gray" },
|
||||||
`Viewing: ${selectedFile.filename}`
|
`Viewing: ${selectedFile.filename} (${filteredLogs.length}/${totalUnfilteredEntries} entries)`
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
||||||
// File metadata
|
// Filter panel (if enabled)
|
||||||
React.createElement(
|
showFilters &&
|
||||||
Box,
|
|
||||||
{
|
|
||||||
borderStyle: "single",
|
|
||||||
borderColor: "blue",
|
|
||||||
paddingX: 2,
|
|
||||||
paddingY: 1,
|
|
||||||
marginBottom: 2,
|
|
||||||
},
|
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Box,
|
Box,
|
||||||
{ flexDirection: "column" },
|
{
|
||||||
|
borderStyle: "single",
|
||||||
|
borderColor: "yellow",
|
||||||
|
paddingX: 1,
|
||||||
|
paddingY: 1,
|
||||||
|
marginBottom: 1,
|
||||||
|
},
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Box,
|
Box,
|
||||||
{ flexDirection: "row", justifyContent: "space-between" },
|
{ flexDirection: "column" },
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Text,
|
Text,
|
||||||
{ color: "white", bold: true },
|
{ color: "yellow", bold: true },
|
||||||
selectedFile.filename
|
"🔍 Filters & Search"
|
||||||
),
|
),
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ color: selectedFile.isMainLog ? "green" : "gray" },
|
|
||||||
selectedFile.isMainLog ? "MAIN LOG" : "ARCHIVE"
|
|
||||||
)
|
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Box,
|
|
||||||
{ flexDirection: "row", marginTop: 1 },
|
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Box,
|
Box,
|
||||||
{ flexDirection: "column", width: "50%" },
|
{ flexDirection: "row", marginTop: 1 },
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Text,
|
Box,
|
||||||
{ color: "cyan" },
|
{ flexDirection: "column", width: "50%" },
|
||||||
`Size: ${formatFileSize(selectedFile.size)}`
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "gray" },
|
||||||
|
`Date Range: ${filters.dateRange}`
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "gray" },
|
||||||
|
`Operation: ${filters.operationType}`
|
||||||
|
)
|
||||||
),
|
),
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Text,
|
Box,
|
||||||
{ color: "cyan" },
|
{ flexDirection: "column", width: "50%" },
|
||||||
`Operations: ${selectedFile.operationCount}`
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "gray" },
|
||||||
|
`Status: ${filters.status}`
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "gray" },
|
||||||
|
`Search: ${filters.searchTerm || "(none)"}`
|
||||||
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
filterInputMode === "search" &&
|
||||||
|
React.createElement(
|
||||||
|
Box,
|
||||||
|
{
|
||||||
|
borderStyle: "single",
|
||||||
|
borderColor: "cyan",
|
||||||
|
paddingX: 1,
|
||||||
|
marginTop: 1,
|
||||||
|
},
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "cyan" },
|
||||||
|
`Search: ${searchInput}█`
|
||||||
|
)
|
||||||
|
),
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Box,
|
Box,
|
||||||
{ flexDirection: "column", width: "50%" },
|
{ flexDirection: "row", marginTop: 1 },
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Text,
|
Text,
|
||||||
{ color: "cyan" },
|
{ color: "gray" },
|
||||||
`Created: ${getRelativeTime(selectedFile.createdAt)}`
|
"S-Search D-Date T-Type L-Level C-Clear F-Close"
|
||||||
),
|
|
||||||
React.createElement(
|
|
||||||
Text,
|
|
||||||
{ color: "cyan" },
|
|
||||||
`Modified: ${getRelativeTime(selectedFile.modifiedAt)}`
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// Content display
|
// Content display
|
||||||
React.createElement(
|
React.createElement(
|
||||||
@@ -269,7 +607,7 @@ const ViewLogsScreen = () => {
|
|||||||
paddingX: 1,
|
paddingX: 1,
|
||||||
paddingY: 1,
|
paddingY: 1,
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
marginBottom: 2,
|
marginBottom: 1,
|
||||||
},
|
},
|
||||||
loadingContent
|
loadingContent
|
||||||
? React.createElement(LoadingIndicator, {
|
? React.createElement(LoadingIndicator, {
|
||||||
@@ -280,29 +618,201 @@ const ViewLogsScreen = () => {
|
|||||||
error: { message: contentError },
|
error: { message: contentError },
|
||||||
onRetry: () => loadFileContent(selectedFile),
|
onRetry: () => loadFileContent(selectedFile),
|
||||||
})
|
})
|
||||||
: logContent
|
: !logContent
|
||||||
? React.createElement(
|
? React.createElement(
|
||||||
|
Box,
|
||||||
|
{ justifyContent: "center", alignItems: "center", padding: 2 },
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "gray", bold: true },
|
||||||
|
"📄 Empty Log File"
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "gray", marginTop: 1 },
|
||||||
|
"This log file is empty or could not be read."
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "gray", marginTop: 1 },
|
||||||
|
"Log entries are created when operations are performed."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: viewMode === "parsed"
|
||||||
|
? // Parsed view with syntax highlighting
|
||||||
|
React.createElement(
|
||||||
|
Box,
|
||||||
|
{ flexDirection: "column" },
|
||||||
|
filteredLogs.length === 0
|
||||||
|
? React.createElement(
|
||||||
|
Box,
|
||||||
|
{
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 2,
|
||||||
|
},
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "gray", bold: true },
|
||||||
|
totalUnfilteredEntries === 0
|
||||||
|
? "📄 No Parsed Entries"
|
||||||
|
: "🔍 No Matching Entries"
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "gray", marginTop: 1 },
|
||||||
|
totalUnfilteredEntries === 0
|
||||||
|
? "The log file contains no parseable entries."
|
||||||
|
: `No entries match the current filters. (${totalUnfilteredEntries} total entries)`
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "gray", marginTop: 1 },
|
||||||
|
totalUnfilteredEntries === 0
|
||||||
|
? "Press 'v' to view raw content."
|
||||||
|
: "Press 'f' to adjust filters or 'c' to clear them."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: currentPageLogs.map((entry, index) => {
|
||||||
|
const globalIndex = startIndex + index;
|
||||||
|
const isSelected = selectedLogIndex === globalIndex;
|
||||||
|
const levelColor = getLogLevelColor(entry.level);
|
||||||
|
const statusIcon = getStatusIcon(entry.level);
|
||||||
|
|
||||||
|
return React.createElement(
|
||||||
|
Box,
|
||||||
|
{
|
||||||
|
key: entry.id,
|
||||||
|
flexDirection: "column",
|
||||||
|
paddingY: 1,
|
||||||
|
paddingX: 1,
|
||||||
|
backgroundColor: isSelected ? "blue" : undefined,
|
||||||
|
borderStyle: isSelected ? "single" : "none",
|
||||||
|
borderColor: isSelected ? "cyan" : "gray",
|
||||||
|
marginBottom: 1,
|
||||||
|
},
|
||||||
|
// Entry header
|
||||||
|
React.createElement(
|
||||||
|
Box,
|
||||||
|
{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
React.createElement(
|
||||||
|
Box,
|
||||||
|
{ flexDirection: "row", alignItems: "center" },
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: levelColor, bold: true },
|
||||||
|
`${statusIcon} ${entry.title}`
|
||||||
|
)
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "gray" },
|
||||||
|
formatTimestamp(entry.timestamp)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// Entry message
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{
|
||||||
|
color: isSelected ? "white" : "gray",
|
||||||
|
marginTop: 1,
|
||||||
|
marginLeft: 2,
|
||||||
|
},
|
||||||
|
truncateText(entry.message, 70)
|
||||||
|
),
|
||||||
|
// Entry details (if available and selected)
|
||||||
|
isSelected &&
|
||||||
|
entry.details &&
|
||||||
|
entry.details.trim() &&
|
||||||
|
React.createElement(
|
||||||
|
Box,
|
||||||
|
{
|
||||||
|
borderStyle: "single",
|
||||||
|
borderColor: "gray",
|
||||||
|
paddingX: 1,
|
||||||
|
paddingY: 1,
|
||||||
|
marginTop: 1,
|
||||||
|
marginLeft: 2,
|
||||||
|
},
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "white", wrap: "wrap" },
|
||||||
|
entry.details.trim()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
: // Raw view with basic syntax highlighting
|
||||||
|
React.createElement(
|
||||||
Box,
|
Box,
|
||||||
{ flexDirection: "column" },
|
{ flexDirection: "column" },
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Text,
|
Box,
|
||||||
{ color: "white", wrap: "wrap" },
|
{ flexDirection: "column" },
|
||||||
logContent.substring(0, 2000) // Show first 2000 characters
|
logContent
|
||||||
),
|
.split("\n")
|
||||||
logContent.length > 2000 &&
|
.slice(scrollOffset, scrollOffset + 15)
|
||||||
React.createElement(
|
.map((line, index) => {
|
||||||
Text,
|
const lineNumber = scrollOffset + index + 1;
|
||||||
{ color: "yellow", marginTop: 1 },
|
const trimmedLine = line.trim();
|
||||||
`... (${logContent.length - 2000} more characters)`
|
|
||||||
)
|
// Determine line color based on content
|
||||||
)
|
let lineColor = "white";
|
||||||
: React.createElement(
|
if (trimmedLine.startsWith("##")) {
|
||||||
Text,
|
lineColor = "cyan"; // Operation headers
|
||||||
{ color: "gray" },
|
} else if (
|
||||||
"File is empty or could not be read"
|
trimmedLine.startsWith("**") &&
|
||||||
|
trimmedLine.endsWith("**")
|
||||||
|
) {
|
||||||
|
lineColor = "yellow"; // Section headers
|
||||||
|
} else if (trimmedLine.includes("❌")) {
|
||||||
|
lineColor = "red"; // Errors
|
||||||
|
} else if (trimmedLine.includes("⚠️")) {
|
||||||
|
lineColor = "yellow"; // Warnings
|
||||||
|
} else if (trimmedLine.includes("✅")) {
|
||||||
|
lineColor = "green"; // Success
|
||||||
|
} else if (trimmedLine.startsWith("-")) {
|
||||||
|
lineColor = "gray"; // List items
|
||||||
|
}
|
||||||
|
|
||||||
|
return React.createElement(
|
||||||
|
Box,
|
||||||
|
{
|
||||||
|
key: lineNumber,
|
||||||
|
flexDirection: "row",
|
||||||
|
},
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "gray", width: 4 },
|
||||||
|
String(lineNumber).padStart(3, " ") + " "
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: lineColor, wrap: "wrap" },
|
||||||
|
line || " "
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Pagination for parsed view
|
||||||
|
viewMode === "parsed" &&
|
||||||
|
filteredLogs.length > pageSize &&
|
||||||
|
React.createElement(Pagination, {
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
totalItems: filteredLogs.length,
|
||||||
|
itemsPerPage: pageSize,
|
||||||
|
onPageChange: setCurrentPage,
|
||||||
|
compact: true,
|
||||||
|
}),
|
||||||
|
|
||||||
// Instructions
|
// Instructions
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Box,
|
Box,
|
||||||
@@ -311,13 +821,61 @@ const ViewLogsScreen = () => {
|
|||||||
borderTopStyle: "single",
|
borderTopStyle: "single",
|
||||||
borderColor: "gray",
|
borderColor: "gray",
|
||||||
paddingTop: 1,
|
paddingTop: 1,
|
||||||
|
marginTop: 1,
|
||||||
},
|
},
|
||||||
React.createElement(Text, { color: "gray", bold: true }, "Navigation:"),
|
React.createElement(Text, { color: "gray", bold: true }, "Navigation:"),
|
||||||
React.createElement(
|
React.createElement(
|
||||||
Text,
|
Box,
|
||||||
{ color: "gray", marginTop: 1 },
|
{ flexDirection: "row", marginTop: 1 },
|
||||||
" Esc - Back to file list R - Refresh content"
|
React.createElement(
|
||||||
)
|
Box,
|
||||||
|
{ flexDirection: "column", width: "33%" },
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "gray" },
|
||||||
|
" ↑/↓ - Navigate entries"
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "gray" },
|
||||||
|
" PgUp/PgDn - Page navigation"
|
||||||
|
)
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Box,
|
||||||
|
{ flexDirection: "column", width: "33%" },
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "gray" },
|
||||||
|
" V - Toggle view mode"
|
||||||
|
),
|
||||||
|
React.createElement(Text, { color: "gray" }, " F - Toggle filters")
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Box,
|
||||||
|
{ flexDirection: "column", width: "34%" },
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "gray" },
|
||||||
|
" R - Refresh content"
|
||||||
|
),
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "gray" },
|
||||||
|
" Esc - Back to file list"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
showFilters &&
|
||||||
|
React.createElement(
|
||||||
|
Box,
|
||||||
|
{ flexDirection: "row", marginTop: 1 },
|
||||||
|
React.createElement(
|
||||||
|
Text,
|
||||||
|
{ color: "yellow" },
|
||||||
|
"Filter Controls: S-Search D-Date T-Type L-Level C-Clear"
|
||||||
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
62
src/tui/components/screens/withErrorBoundary.jsx
Normal file
62
src/tui/components/screens/withErrorBoundary.jsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
const React = require("react");
|
||||||
|
const { ScreenErrorBoundary } = require("../common");
|
||||||
|
const { enhanceError, logError } = require("../../utils/errorHandler");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Higher-order component that wraps screens with error boundaries
|
||||||
|
* Requirements: 4.5, 16.1
|
||||||
|
*/
|
||||||
|
function withErrorBoundary(
|
||||||
|
WrappedComponent,
|
||||||
|
screenName,
|
||||||
|
screenSpecificTips = []
|
||||||
|
) {
|
||||||
|
const WrappedScreenComponent = (props) => {
|
||||||
|
const handleError = (error, errorInfo) => {
|
||||||
|
// Log the error with screen context
|
||||||
|
logError(error, {
|
||||||
|
screen: screenName,
|
||||||
|
errorInfo,
|
||||||
|
props: Object.keys(props),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetry = (retryCount) => {
|
||||||
|
console.info(`Retrying ${screenName}, attempt ${retryCount}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
console.info(`Resetting ${screenName} after error`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExit = () => {
|
||||||
|
// Navigate back to main menu
|
||||||
|
if (props.navigateBack) {
|
||||||
|
props.navigateBack();
|
||||||
|
} else if (props.onExit) {
|
||||||
|
props.onExit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return React.createElement(
|
||||||
|
ScreenErrorBoundary,
|
||||||
|
{
|
||||||
|
screenName,
|
||||||
|
screenSpecificTips,
|
||||||
|
onError: handleError,
|
||||||
|
onRetry: handleRetry,
|
||||||
|
onReset: handleReset,
|
||||||
|
onExit: handleExit,
|
||||||
|
maxRetries: 3,
|
||||||
|
},
|
||||||
|
React.createElement(WrappedComponent, props)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set display name for debugging
|
||||||
|
WrappedScreenComponent.displayName = `withErrorBoundary(${screenName})`;
|
||||||
|
|
||||||
|
return WrappedScreenComponent;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = withErrorBoundary;
|
||||||
313
src/tui/services/ErrorHandlingService.js
Normal file
313
src/tui/services/ErrorHandlingService.js
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
const {
|
||||||
|
enhanceError,
|
||||||
|
isRetryableError,
|
||||||
|
getRetryDelay,
|
||||||
|
createStandardError,
|
||||||
|
logError,
|
||||||
|
} = require("../utils/errorHandler");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ErrorHandlingService - Centralized error handling for TUI operations
|
||||||
|
* Requirements: 4.5, 16.1
|
||||||
|
*/
|
||||||
|
class ErrorHandlingService {
|
||||||
|
constructor() {
|
||||||
|
this.errorHistory = [];
|
||||||
|
this.maxHistorySize = 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute an operation with comprehensive error handling and retry logic
|
||||||
|
* @param {Function} operation - Async operation to execute
|
||||||
|
* @param {Object} options - Error handling options
|
||||||
|
* @returns {Promise} Operation result
|
||||||
|
*/
|
||||||
|
async executeWithRetry(operation, options = {}) {
|
||||||
|
const {
|
||||||
|
maxRetries = 3,
|
||||||
|
retryDelay = null,
|
||||||
|
context = {},
|
||||||
|
onRetry = null,
|
||||||
|
onError = null,
|
||||||
|
retryableCheck = null,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let lastError = null;
|
||||||
|
let attempt = 0;
|
||||||
|
|
||||||
|
while (attempt < maxRetries) {
|
||||||
|
attempt++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await operation();
|
||||||
|
|
||||||
|
// Log successful retry if this wasn't the first attempt
|
||||||
|
if (attempt > 1) {
|
||||||
|
console.info(
|
||||||
|
`Operation succeeded on attempt ${attempt}/${maxRetries}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
|
||||||
|
// Enhance the error with context
|
||||||
|
const enhancedError = enhanceError(error, {
|
||||||
|
...context,
|
||||||
|
attempt,
|
||||||
|
maxRetries,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log the error
|
||||||
|
logError(enhancedError, context);
|
||||||
|
|
||||||
|
// Add to error history
|
||||||
|
this.addToHistory(enhancedError);
|
||||||
|
|
||||||
|
// Call error callback if provided
|
||||||
|
if (onError) {
|
||||||
|
onError(enhancedError, attempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we should retry
|
||||||
|
const shouldRetry = retryableCheck
|
||||||
|
? retryableCheck(enhancedError)
|
||||||
|
: isRetryableError(enhancedError);
|
||||||
|
|
||||||
|
if (attempt >= maxRetries || !shouldRetry) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate delay
|
||||||
|
const delay =
|
||||||
|
retryDelay || getRetryDelay(attempt, enhancedError.category);
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`Operation failed (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms:`,
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
|
||||||
|
// Call retry callback if provided
|
||||||
|
if (onRetry) {
|
||||||
|
onRetry(attempt, delay, enhancedError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait before retrying
|
||||||
|
await this.delay(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All retries failed, throw the last error
|
||||||
|
throw enhanceError(lastError, {
|
||||||
|
...context,
|
||||||
|
finalAttempt: true,
|
||||||
|
totalAttempts: attempt,
|
||||||
|
message: `Operation failed after ${attempt} attempts`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle file operation errors with specific retry logic
|
||||||
|
* @param {Function} fileOperation - File operation to execute
|
||||||
|
* @param {Object} options - Options
|
||||||
|
* @returns {Promise} Operation result
|
||||||
|
*/
|
||||||
|
async handleFileOperation(fileOperation, options = {}) {
|
||||||
|
return this.executeWithRetry(fileOperation, {
|
||||||
|
maxRetries: 3,
|
||||||
|
context: {
|
||||||
|
operation: "file_operation",
|
||||||
|
...options.context,
|
||||||
|
},
|
||||||
|
retryableCheck: (error) => {
|
||||||
|
// File operations are retryable for certain errors
|
||||||
|
const retryableCodes = ["EBUSY", "EMFILE", "ENFILE", "EAGAIN"];
|
||||||
|
return (
|
||||||
|
retryableCodes.includes(error.code) ||
|
||||||
|
error.message.includes("locked") ||
|
||||||
|
error.message.includes("busy")
|
||||||
|
);
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle API operation errors with exponential backoff
|
||||||
|
* @param {Function} apiOperation - API operation to execute
|
||||||
|
* @param {Object} options - Options
|
||||||
|
* @returns {Promise} Operation result
|
||||||
|
*/
|
||||||
|
async handleApiOperation(apiOperation, options = {}) {
|
||||||
|
return this.executeWithRetry(apiOperation, {
|
||||||
|
maxRetries: 5,
|
||||||
|
context: {
|
||||||
|
operation: "api_operation",
|
||||||
|
...options.context,
|
||||||
|
},
|
||||||
|
retryableCheck: (error) => {
|
||||||
|
// API operations are retryable for network and rate limit errors
|
||||||
|
return (
|
||||||
|
isRetryableError(error) ||
|
||||||
|
error.message.includes("rate limit") ||
|
||||||
|
error.message.includes("timeout")
|
||||||
|
);
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle validation errors with user-friendly messages
|
||||||
|
* @param {Function} validationOperation - Operation that might have validation errors
|
||||||
|
* @param {Object} options - Options
|
||||||
|
* @returns {Promise} Operation result
|
||||||
|
*/
|
||||||
|
async handleValidation(validationOperation, options = {}) {
|
||||||
|
try {
|
||||||
|
return await validationOperation();
|
||||||
|
} catch (error) {
|
||||||
|
// Don't retry validation errors, but enhance them
|
||||||
|
const enhancedError = enhanceError(error, {
|
||||||
|
operation: "validation",
|
||||||
|
...options.context,
|
||||||
|
troubleshooting: [
|
||||||
|
"Check that all required fields are filled",
|
||||||
|
"Verify the data format is correct",
|
||||||
|
"Ensure values are within acceptable ranges",
|
||||||
|
...(options.troubleshooting || []),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
this.addToHistory(enhancedError);
|
||||||
|
throw enhancedError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a graceful fallback for missing files
|
||||||
|
* @param {Function} fileReader - Function to read file
|
||||||
|
* @param {*} fallbackValue - Value to return if file doesn't exist
|
||||||
|
* @param {Object} options - Options
|
||||||
|
* @returns {Promise} File content or fallback value
|
||||||
|
*/
|
||||||
|
async gracefulFileRead(fileReader, fallbackValue, options = {}) {
|
||||||
|
try {
|
||||||
|
return await fileReader();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === "ENOENT") {
|
||||||
|
console.info(
|
||||||
|
`File not found, using fallback value: ${
|
||||||
|
options.filename || "unknown file"
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
return fallbackValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other errors, use normal error handling
|
||||||
|
throw enhanceError(error, {
|
||||||
|
operation: "graceful_file_read",
|
||||||
|
filename: options.filename,
|
||||||
|
...options.context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add error to history for debugging
|
||||||
|
* @param {Error} error - Error to add
|
||||||
|
*/
|
||||||
|
addToHistory(error) {
|
||||||
|
this.errorHistory.unshift({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
error: {
|
||||||
|
message: error.message,
|
||||||
|
category: error.category,
|
||||||
|
context: error.context,
|
||||||
|
stack: error.stack,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep history size manageable
|
||||||
|
if (this.errorHistory.length > this.maxHistorySize) {
|
||||||
|
this.errorHistory = this.errorHistory.slice(0, this.maxHistorySize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent error history
|
||||||
|
* @param {number} limit - Number of recent errors to return
|
||||||
|
* @returns {Array} Recent errors
|
||||||
|
*/
|
||||||
|
getErrorHistory(limit = 10) {
|
||||||
|
return this.errorHistory.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear error history
|
||||||
|
*/
|
||||||
|
clearHistory() {
|
||||||
|
this.errorHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get error statistics
|
||||||
|
* @returns {Object} Error statistics
|
||||||
|
*/
|
||||||
|
getErrorStats() {
|
||||||
|
const categories = {};
|
||||||
|
const operations = {};
|
||||||
|
|
||||||
|
this.errorHistory.forEach((entry) => {
|
||||||
|
const category = entry.error.category || "unknown";
|
||||||
|
const operation = entry.error.context?.operation || "unknown";
|
||||||
|
|
||||||
|
categories[category] = (categories[category] || 0) + 1;
|
||||||
|
operations[operation] = (operations[operation] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalErrors: this.errorHistory.length,
|
||||||
|
categories,
|
||||||
|
operations,
|
||||||
|
mostRecentError: this.errorHistory[0] || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a user-friendly error message for display
|
||||||
|
* @param {Error} error - Error to format
|
||||||
|
* @returns {string} User-friendly message
|
||||||
|
*/
|
||||||
|
formatUserMessage(error) {
|
||||||
|
if (!error) return "An unknown error occurred";
|
||||||
|
|
||||||
|
const category = error.category || "system";
|
||||||
|
const baseMessage = error.message;
|
||||||
|
|
||||||
|
const categoryMessages = {
|
||||||
|
network: "Connection problem - please check your internet connection",
|
||||||
|
api: "Shopify API issue - please verify your store settings",
|
||||||
|
file: "File access problem - please check file permissions",
|
||||||
|
validation: "Input validation error - please check your data",
|
||||||
|
system: "System error - please try again",
|
||||||
|
};
|
||||||
|
|
||||||
|
const categoryMessage =
|
||||||
|
categoryMessages[category] || categoryMessages.system;
|
||||||
|
|
||||||
|
return `${categoryMessage}: ${baseMessage}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delay execution
|
||||||
|
* @param {number} ms - Milliseconds to delay
|
||||||
|
* @returns {Promise} Promise that resolves after delay
|
||||||
|
*/
|
||||||
|
delay(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ErrorHandlingService;
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
const fs = require("fs").promises;
|
const fs = require("fs");
|
||||||
|
const { promisify } = require("util");
|
||||||
|
const readFile = promisify(fs.readFile);
|
||||||
|
const stat = promisify(fs.stat);
|
||||||
|
const readdir = promisify(fs.readdir);
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
const ErrorHandlingService = require("./ErrorHandlingService");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LogService - Reads and parses Progress.md files for TUI log viewing
|
* LogService - Reads and parses Progress.md files for TUI log viewing
|
||||||
@@ -10,6 +15,7 @@ class LogService {
|
|||||||
this.progressFilePath = progressFilePath;
|
this.progressFilePath = progressFilePath;
|
||||||
this.cache = new Map();
|
this.cache = new Map();
|
||||||
this.cacheExpiry = 2 * 60 * 1000; // 2 minutes
|
this.cacheExpiry = 2 * 60 * 1000; // 2 minutes
|
||||||
|
this.errorHandler = new ErrorHandlingService();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,7 +28,7 @@ class LogService {
|
|||||||
|
|
||||||
// Check main Progress.md file
|
// Check main Progress.md file
|
||||||
try {
|
try {
|
||||||
const stats = await fs.stat(this.progressFilePath);
|
const stats = await stat(this.progressFilePath);
|
||||||
files.push({
|
files.push({
|
||||||
name: "Progress.md",
|
name: "Progress.md",
|
||||||
path: this.progressFilePath,
|
path: this.progressFilePath,
|
||||||
@@ -32,20 +38,28 @@ class LogService {
|
|||||||
isMain: true,
|
isMain: true,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code !== "ENOENT") {
|
if (error.code === "ENOENT") {
|
||||||
throw error;
|
// Main log file doesn't exist - this is normal for new installations
|
||||||
|
console.info(
|
||||||
|
`Main log file ${this.progressFilePath} not found - no operations have been performed yet`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Other error accessing the file
|
||||||
|
console.warn(
|
||||||
|
`Could not access main log file ${this.progressFilePath}: ${error.message}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look for archived log files (Progress_YYYY-MM-DD.md pattern)
|
// Look for archived log files (Progress_YYYY-MM-DD.md pattern)
|
||||||
try {
|
try {
|
||||||
const currentDir = await fs.readdir(".");
|
const currentDir = await readdir(".");
|
||||||
const logFiles = currentDir.filter((file) =>
|
const logFiles = currentDir.filter((file) =>
|
||||||
file.match(/^Progress_\d{4}-\d{2}-\d{2}\.md$/)
|
file.match(/^Progress_\d{4}-\d{2}-\d{2}\.md$/)
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const file of logFiles) {
|
for (const file of logFiles) {
|
||||||
const stats = await fs.stat(file);
|
const stats = await stat(file);
|
||||||
files.push({
|
files.push({
|
||||||
name: file,
|
name: file,
|
||||||
path: file,
|
path: file,
|
||||||
@@ -62,7 +76,16 @@ class LogService {
|
|||||||
// Sort by modification date (newest first)
|
// Sort by modification date (newest first)
|
||||||
return files.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
return files.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Failed to get log files: ${error.message}`);
|
throw this.createEnhancedError("Failed to get log files", error, {
|
||||||
|
operation: "getLogFiles",
|
||||||
|
progressFile: this.progressFilePath,
|
||||||
|
troubleshooting: [
|
||||||
|
"Check if you have read permissions to the current directory",
|
||||||
|
"Verify the Progress.md file exists (it's created after running operations)",
|
||||||
|
"Run some price update operations to generate log files",
|
||||||
|
"Ensure the application has access to the file system",
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,17 +97,17 @@ class LogService {
|
|||||||
async readLogFile(filePath = null) {
|
async readLogFile(filePath = null) {
|
||||||
const targetPath = filePath || this.progressFilePath;
|
const targetPath = filePath || this.progressFilePath;
|
||||||
|
|
||||||
try {
|
return this.errorHandler.gracefulFileRead(
|
||||||
const content = await fs.readFile(targetPath, "utf8");
|
() => readFile(targetPath, "utf8"),
|
||||||
return content;
|
"", // fallback to empty string
|
||||||
} catch (error) {
|
{
|
||||||
if (error.code === "ENOENT") {
|
filename: targetPath,
|
||||||
return ""; // Return empty string for non-existent files
|
context: {
|
||||||
|
operation: "readLogFile",
|
||||||
|
filePath: targetPath,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
throw new Error(
|
);
|
||||||
`Failed to read log file ${targetPath}: ${error.message}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -102,21 +125,19 @@ class LogService {
|
|||||||
let currentOperation = null;
|
let currentOperation = null;
|
||||||
let currentSection = null;
|
let currentSection = null;
|
||||||
let lineIndex = 0;
|
let lineIndex = 0;
|
||||||
|
let inCodeBlock = false;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
lineIndex++;
|
lineIndex++;
|
||||||
const trimmedLine = line.trim();
|
const trimmedLine = line.trim();
|
||||||
|
|
||||||
// Skip empty lines and markdown headers
|
// Handle code block boundaries
|
||||||
if (
|
if (trimmedLine === "```") {
|
||||||
!trimmedLine ||
|
inCodeBlock = !inCodeBlock;
|
||||||
trimmedLine.startsWith("#") ||
|
|
||||||
trimmedLine === "---"
|
|
||||||
) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse operation headers (## Operation Type - Timestamp)
|
// Parse operation headers first (## Operation Type - Timestamp) - these are outside code blocks
|
||||||
const operationMatch = trimmedLine.match(/^## (.+) - (.+)$/);
|
const operationMatch = trimmedLine.match(/^## (.+) - (.+)$/);
|
||||||
if (operationMatch) {
|
if (operationMatch) {
|
||||||
const [, operationType, timestamp] = operationMatch;
|
const [, operationType, timestamp] = operationMatch;
|
||||||
@@ -140,6 +161,19 @@ class LogService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip empty lines and markdown headers outside code blocks
|
||||||
|
if (
|
||||||
|
!trimmedLine ||
|
||||||
|
(!inCodeBlock && (trimmedLine.startsWith("#") || trimmedLine === "---"))
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process content inside code blocks after finding an operation header
|
||||||
|
if (!inCodeBlock) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Parse section headers
|
// Parse section headers
|
||||||
if (trimmedLine.startsWith("**") && trimmedLine.endsWith("**")) {
|
if (trimmedLine.startsWith("**") && trimmedLine.endsWith("**")) {
|
||||||
const sectionTitle = trimmedLine.slice(2, -2);
|
const sectionTitle = trimmedLine.slice(2, -2);
|
||||||
@@ -219,7 +253,7 @@ class LogService {
|
|||||||
parseProgressLine(line, operation, entries, lineNumber) {
|
parseProgressLine(line, operation, entries, lineNumber) {
|
||||||
// Parse product update lines with status indicators
|
// Parse product update lines with status indicators
|
||||||
const updateMatch = line.match(
|
const updateMatch = line.match(
|
||||||
/^- ([✅❌🔄⚠️]) \*\*(.+?)\*\* \((.+?)\)(.*)$/
|
/^-\s*([✅❌🔄⚠️])\s*\*\*(.+?)\*\*\s*\((.+?)\)(.*)$/
|
||||||
);
|
);
|
||||||
if (updateMatch) {
|
if (updateMatch) {
|
||||||
const [, status, productTitle, productId, details] = updateMatch;
|
const [, status, productTitle, productId, details] = updateMatch;
|
||||||
@@ -460,7 +494,17 @@ class LogService {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Failed to get filtered logs: ${error.message}`);
|
throw this.createEnhancedError("Failed to get filtered logs", error, {
|
||||||
|
operation: "getFilteredLogs",
|
||||||
|
options,
|
||||||
|
troubleshooting: [
|
||||||
|
"Check if the log file exists and is readable",
|
||||||
|
"Verify the file contains valid log format",
|
||||||
|
"Try clearing filters if no results are shown",
|
||||||
|
"Ensure operations have been run to generate logs",
|
||||||
|
"Check if the log file is corrupted",
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,6 +579,27 @@ class LogService {
|
|||||||
keys: Array.from(this.cache.keys()),
|
keys: Array.from(this.cache.keys()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create enhanced error with troubleshooting information
|
||||||
|
* @param {string} message - Base error message
|
||||||
|
* @param {Error} originalError - Original error
|
||||||
|
* @param {Object} context - Additional context
|
||||||
|
* @returns {Error} Enhanced error
|
||||||
|
*/
|
||||||
|
createEnhancedError(message, originalError, context = {}) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.originalError = originalError;
|
||||||
|
error.context = context;
|
||||||
|
error.timestamp = new Date().toISOString();
|
||||||
|
|
||||||
|
// Add troubleshooting information
|
||||||
|
if (context.troubleshooting) {
|
||||||
|
error.troubleshooting = context.troubleshooting;
|
||||||
|
}
|
||||||
|
|
||||||
|
return error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = LogService;
|
module.exports = LogService;
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
const fs = require("fs").promises;
|
const fs = require("fs");
|
||||||
|
const { promisify } = require("util");
|
||||||
|
const readFile = promisify(fs.readFile);
|
||||||
|
const writeFile = promisify(fs.writeFile);
|
||||||
|
const stat = promisify(fs.stat);
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
const ErrorHandlingService = require("./ErrorHandlingService");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ScheduleService - Manages scheduled operations with JSON persistence for TUI
|
* ScheduleService - Manages scheduled operations with JSON persistence for TUI
|
||||||
@@ -10,6 +15,7 @@ class ScheduleService {
|
|||||||
this.schedulesFile = "schedules.json";
|
this.schedulesFile = "schedules.json";
|
||||||
this.schedules = [];
|
this.schedules = [];
|
||||||
this.isLoaded = false;
|
this.isLoaded = false;
|
||||||
|
this.errorHandler = new ErrorHandlingService();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,19 +23,84 @@ class ScheduleService {
|
|||||||
* @returns {Promise<Array>} Array of schedules
|
* @returns {Promise<Array>} Array of schedules
|
||||||
*/
|
*/
|
||||||
async loadSchedules() {
|
async loadSchedules() {
|
||||||
try {
|
return this.errorHandler.gracefulFileRead(
|
||||||
const data = await fs.readFile(this.schedulesFile, "utf8");
|
async () => {
|
||||||
this.schedules = JSON.parse(data);
|
const data = await readFile(this.schedulesFile, "utf8");
|
||||||
this.isLoaded = true;
|
this.schedules = JSON.parse(data);
|
||||||
return this.schedules;
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code === "ENOENT") {
|
|
||||||
// File doesn't exist, start with empty array
|
|
||||||
this.schedules = [];
|
|
||||||
this.isLoaded = true;
|
this.isLoaded = true;
|
||||||
return this.schedules;
|
return this.schedules;
|
||||||
|
},
|
||||||
|
[], // fallback to empty array
|
||||||
|
{
|
||||||
|
filename: this.schedulesFile,
|
||||||
|
context: { operation: 'loadSchedules' }
|
||||||
}
|
}
|
||||||
throw new Error(`Failed to load schedules: ${error.message}`);
|
).catch(error => {
|
||||||
|
if (error.code === "ENOENT") {
|
||||||
|
// File doesn't exist, create it with empty array
|
||||||
|
console.info(
|
||||||
|
`Schedules file ${this.schedulesFile} not found, creating new file`
|
||||||
|
);
|
||||||
|
this.schedules = [];
|
||||||
|
this.isLoaded = true;
|
||||||
|
|
||||||
|
// Create the file to avoid future ENOENT errors
|
||||||
|
try {
|
||||||
|
await this.saveSchedules([]);
|
||||||
|
} catch (saveError) {
|
||||||
|
console.warn(`Could not create schedules file: ${saveError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.schedules;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.name === "SyntaxError") {
|
||||||
|
// JSON parsing error - file is corrupted
|
||||||
|
const backupFile = `${this.schedulesFile}.backup.${Date.now()}`;
|
||||||
|
console.warn(
|
||||||
|
`Schedules file is corrupted, backing up to ${backupFile}`
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const corruptedData = await readFile(this.schedulesFile, "utf8");
|
||||||
|
await writeFile(backupFile, corruptedData);
|
||||||
|
} catch (backupError) {
|
||||||
|
console.error(
|
||||||
|
`Failed to backup corrupted file: ${backupError.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start with empty schedules
|
||||||
|
this.schedules = [];
|
||||||
|
this.isLoaded = true;
|
||||||
|
await this.saveSchedules([]);
|
||||||
|
|
||||||
|
throw this.createEnhancedError(
|
||||||
|
"Schedules file was corrupted and has been reset",
|
||||||
|
error,
|
||||||
|
{
|
||||||
|
operation: "loadSchedules",
|
||||||
|
backupFile,
|
||||||
|
troubleshooting: [
|
||||||
|
`A backup of the corrupted file was saved as ${backupFile}`,
|
||||||
|
"You can manually recover schedules from the backup if needed",
|
||||||
|
"The schedules file has been reset to empty",
|
||||||
|
"This usually happens due to incomplete writes or system crashes",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw this.createEnhancedError("Failed to load schedules", error, {
|
||||||
|
operation: "loadSchedules",
|
||||||
|
file: this.schedulesFile,
|
||||||
|
troubleshooting: [
|
||||||
|
"Check if the schedules.json file exists and is readable",
|
||||||
|
"Verify file permissions allow reading",
|
||||||
|
"Ensure the file is not locked by another process",
|
||||||
|
"Try restarting the application",
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,18 +110,36 @@ class ScheduleService {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
async saveSchedules(schedules = null) {
|
async saveSchedules(schedules = null) {
|
||||||
try {
|
const dataToSave = schedules || this.schedules;
|
||||||
const dataToSave = schedules || this.schedules;
|
|
||||||
await fs.writeFile(
|
return this.errorHandler.handleFileOperation(
|
||||||
this.schedulesFile,
|
async () => {
|
||||||
JSON.stringify(dataToSave, null, 2)
|
// Create backup before writing
|
||||||
);
|
const backupData = JSON.stringify(dataToSave, null, 2);
|
||||||
if (!schedules) {
|
|
||||||
this.schedules = dataToSave;
|
// Validate JSON before writing
|
||||||
|
JSON.parse(backupData); // This will throw if invalid
|
||||||
|
|
||||||
|
await writeFile(this.schedulesFile, backupData);
|
||||||
|
|
||||||
|
if (!schedules) {
|
||||||
|
this.schedules = dataToSave;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
context: {
|
||||||
|
operation: 'saveSchedules',
|
||||||
|
file: this.schedulesFile,
|
||||||
|
troubleshooting: [
|
||||||
|
'Check if you have write permissions to the current directory',
|
||||||
|
'Ensure there is enough disk space available',
|
||||||
|
'Verify the file is not locked by another process',
|
||||||
|
'Try closing other applications that might be using the file',
|
||||||
|
'Check if antivirus software is blocking file writes'
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
);
|
||||||
throw new Error(`Failed to save schedules: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -313,6 +402,36 @@ class ScheduleService {
|
|||||||
lastError: error,
|
lastError: error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create enhanced error with troubleshooting information
|
||||||
|
* @param {string} message - Base error message
|
||||||
|
* @param {Error} originalError - Original error
|
||||||
|
* @param {Object} context - Additional context
|
||||||
|
* @returns {Error} Enhanced error
|
||||||
|
*/
|
||||||
|
createEnhancedError(message, originalError, context = {}) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.originalError = originalError;
|
||||||
|
error.context = context;
|
||||||
|
error.timestamp = new Date().toISOString();
|
||||||
|
|
||||||
|
// Add troubleshooting information
|
||||||
|
if (context.troubleshooting) {
|
||||||
|
error.troubleshooting = context.troubleshooting;
|
||||||
|
}
|
||||||
|
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delay execution for retry logic
|
||||||
|
* @param {number} ms - Milliseconds to delay
|
||||||
|
* @returns {Promise} Promise that resolves after delay
|
||||||
|
*/
|
||||||
|
delay(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = ScheduleService;
|
module.exports = ScheduleService;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
const ErrorHandlingService = require("./ErrorHandlingService");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TagAnalysisService - Fetches and analyzes Shopify product tags for TUI
|
* TagAnalysisService - Fetches and analyzes Shopify product tags for TUI
|
||||||
* Requirements: 5.3, 3.1, 3.2, 3.3, 3.4, 3.6
|
* Requirements: 5.3, 3.1, 3.2, 3.3, 3.4, 3.6
|
||||||
@@ -8,6 +10,7 @@ class TagAnalysisService {
|
|||||||
this.productService = productService;
|
this.productService = productService;
|
||||||
this.cache = new Map();
|
this.cache = new Map();
|
||||||
this.cacheExpiry = 5 * 60 * 1000; // 5 minutes
|
this.cacheExpiry = 5 * 60 * 1000; // 5 minutes
|
||||||
|
this.errorHandler = new ErrorHandlingService();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,13 +27,30 @@ class TagAnalysisService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use existing ProductService method to fetch products with tags
|
// Use error handler for API operations with retry logic
|
||||||
const products = await this.productService.debugFetchAllProductTags(
|
const products = await this.errorHandler.handleApiOperation(
|
||||||
limit
|
() => this.productService.debugFetchAllProductTags(limit),
|
||||||
|
{
|
||||||
|
context: {
|
||||||
|
operation: "fetchAllTags",
|
||||||
|
limit,
|
||||||
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!products || products.length === 0) {
|
if (!products || products.length === 0) {
|
||||||
return [];
|
// Return empty result structure instead of empty array
|
||||||
|
return {
|
||||||
|
tags: [],
|
||||||
|
metadata: {
|
||||||
|
totalProducts: 0,
|
||||||
|
totalVariants: 0,
|
||||||
|
totalTags: 0,
|
||||||
|
analyzedAt: new Date().toISOString(),
|
||||||
|
limit,
|
||||||
|
warning: "No products found in store or no products have tags",
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Analyze tags from products
|
// Analyze tags from products
|
||||||
@@ -122,7 +142,8 @@ class TagAnalysisService {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Failed to fetch tags: ${error.message}`);
|
// Error handler already enhanced the error with troubleshooting
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,8 +161,16 @@ class TagAnalysisService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch products with this specific tag
|
// Use error handler for API operations
|
||||||
const products = await this.productService.fetchProductsByTag(tag);
|
const products = await this.errorHandler.handleApiOperation(
|
||||||
|
() => this.productService.fetchProductsByTag(tag),
|
||||||
|
{
|
||||||
|
context: {
|
||||||
|
operation: "getTagDetails",
|
||||||
|
tag,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!products || products.length === 0) {
|
if (!products || products.length === 0) {
|
||||||
return {
|
return {
|
||||||
@@ -191,8 +220,19 @@ class TagAnalysisService {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw this.createEnhancedError(
|
||||||
`Failed to get tag details for "${tag}": ${error.message}`
|
`Failed to get tag details for "${tag}"`,
|
||||||
|
error,
|
||||||
|
{
|
||||||
|
operation: "getTagDetails",
|
||||||
|
tag,
|
||||||
|
troubleshooting: [
|
||||||
|
"Verify the tag name exists in your store",
|
||||||
|
"Check your Shopify API connection",
|
||||||
|
"Try refreshing the tag analysis",
|
||||||
|
"Ensure the tag contains products with valid data",
|
||||||
|
],
|
||||||
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -443,7 +483,16 @@ class TagAnalysisService {
|
|||||||
analyzedAt: new Date().toISOString(),
|
analyzedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Failed to compare tags: ${error.message}`);
|
throw this.createEnhancedError("Failed to compare tags", error, {
|
||||||
|
operation: "compareTagsAsync",
|
||||||
|
tagNames,
|
||||||
|
troubleshooting: [
|
||||||
|
"Verify all tag names exist in your store",
|
||||||
|
"Check your Shopify API connection",
|
||||||
|
"Try comparing fewer tags at once",
|
||||||
|
"Ensure tags contain products with valid pricing data",
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,6 +568,59 @@ class TagAnalysisService {
|
|||||||
(tag) => tag.averagePrice >= minPrice && tag.averagePrice <= maxPrice
|
(tag) => tag.averagePrice >= minPrice && tag.averagePrice <= maxPrice
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error is retryable (network/API issues)
|
||||||
|
* @param {Error} error - Error to check
|
||||||
|
* @returns {boolean} True if error is retryable
|
||||||
|
*/
|
||||||
|
isRetryableError(error) {
|
||||||
|
const retryablePatterns = [
|
||||||
|
/network/i,
|
||||||
|
/timeout/i,
|
||||||
|
/rate limit/i,
|
||||||
|
/503/,
|
||||||
|
/502/,
|
||||||
|
/500/,
|
||||||
|
/ECONNRESET/,
|
||||||
|
/ENOTFOUND/,
|
||||||
|
/ETIMEDOUT/,
|
||||||
|
];
|
||||||
|
|
||||||
|
return retryablePatterns.some(
|
||||||
|
(pattern) => pattern.test(error.message) || pattern.test(error.code)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create enhanced error with troubleshooting information
|
||||||
|
* @param {string} message - Base error message
|
||||||
|
* @param {Error} originalError - Original error
|
||||||
|
* @param {Object} context - Additional context
|
||||||
|
* @returns {Error} Enhanced error
|
||||||
|
*/
|
||||||
|
createEnhancedError(message, originalError, context = {}) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.originalError = originalError;
|
||||||
|
error.context = context;
|
||||||
|
error.timestamp = new Date().toISOString();
|
||||||
|
|
||||||
|
// Add troubleshooting information
|
||||||
|
if (context.troubleshooting) {
|
||||||
|
error.troubleshooting = context.troubleshooting;
|
||||||
|
}
|
||||||
|
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delay execution for retry logic
|
||||||
|
* @param {number} ms - Milliseconds to delay
|
||||||
|
* @returns {Promise} Promise that resolves after delay
|
||||||
|
*/
|
||||||
|
delay(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = TagAnalysisService;
|
module.exports = TagAnalysisService;
|
||||||
|
|||||||
346
src/tui/utils/errorHandler.js
Normal file
346
src/tui/utils/errorHandler.js
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
/**
|
||||||
|
* Centralized Error Handler Utility
|
||||||
|
* Provides consistent error handling and messaging across TUI screens
|
||||||
|
* Requirements: 4.5, 16.1
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common troubleshooting tips for different error types
|
||||||
|
*/
|
||||||
|
const COMMON_TROUBLESHOOTING = {
|
||||||
|
network: [
|
||||||
|
"Check your internet connection",
|
||||||
|
"Verify your network settings",
|
||||||
|
"Try again in a few moments",
|
||||||
|
"Check if your firewall is blocking the connection",
|
||||||
|
],
|
||||||
|
api: [
|
||||||
|
"Verify your Shopify API credentials are correct",
|
||||||
|
"Check if your Shopify store is accessible",
|
||||||
|
"Ensure your API access token has the required permissions",
|
||||||
|
"Try refreshing your API connection",
|
||||||
|
],
|
||||||
|
file: [
|
||||||
|
"Check if the file exists and is readable",
|
||||||
|
"Verify file permissions allow access",
|
||||||
|
"Ensure the file is not locked by another process",
|
||||||
|
"Check if you have sufficient disk space",
|
||||||
|
],
|
||||||
|
validation: [
|
||||||
|
"Check that all required fields are filled correctly",
|
||||||
|
"Verify the data format matches requirements",
|
||||||
|
"Ensure values are within acceptable ranges",
|
||||||
|
"Review the input for any special characters or formatting issues",
|
||||||
|
],
|
||||||
|
system: [
|
||||||
|
"Check if you have sufficient system resources",
|
||||||
|
"Ensure the application has necessary permissions",
|
||||||
|
"Try restarting the application",
|
||||||
|
"Check system logs for additional error information",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screen-specific troubleshooting tips
|
||||||
|
*/
|
||||||
|
const SCREEN_TROUBLESHOOTING = {
|
||||||
|
SchedulingScreen: [
|
||||||
|
"Ensure scheduled times are in the future",
|
||||||
|
"Check that the schedule format is correct",
|
||||||
|
"Verify you have write permissions to save schedules",
|
||||||
|
"Try creating a simpler schedule first",
|
||||||
|
],
|
||||||
|
TagAnalysisScreen: [
|
||||||
|
"Verify your Shopify store has products with tags",
|
||||||
|
"Check that your API connection is working",
|
||||||
|
"Try reducing the analysis limit if you have many products",
|
||||||
|
"Ensure your store has products with valid pricing data",
|
||||||
|
],
|
||||||
|
ViewLogsScreen: [
|
||||||
|
"Run some operations to generate log files",
|
||||||
|
"Check if Progress.md file exists in the current directory",
|
||||||
|
"Verify you have read permissions for log files",
|
||||||
|
"Try refreshing the log file list",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enhance an error with additional context and troubleshooting information
|
||||||
|
* @param {Error} error - Original error
|
||||||
|
* @param {Object} context - Additional context information
|
||||||
|
* @returns {Error} Enhanced error
|
||||||
|
*/
|
||||||
|
function enhanceError(error, context = {}) {
|
||||||
|
if (!error) {
|
||||||
|
error = new Error("Unknown error occurred");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't enhance if already enhanced
|
||||||
|
if (error.enhanced) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enhanced = new Error(error.message);
|
||||||
|
enhanced.originalError = error;
|
||||||
|
enhanced.enhanced = true;
|
||||||
|
enhanced.timestamp = new Date().toISOString();
|
||||||
|
enhanced.context = {
|
||||||
|
...context,
|
||||||
|
originalStack: error.stack,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine error category and add appropriate troubleshooting
|
||||||
|
const troubleshooting = [];
|
||||||
|
|
||||||
|
// Add category-specific tips
|
||||||
|
const errorCategory = categorizeError(error);
|
||||||
|
if (COMMON_TROUBLESHOOTING[errorCategory]) {
|
||||||
|
troubleshooting.push(...COMMON_TROUBLESHOOTING[errorCategory]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add screen-specific tips
|
||||||
|
if (context.screen && SCREEN_TROUBLESHOOTING[context.screen]) {
|
||||||
|
troubleshooting.push(...SCREEN_TROUBLESHOOTING[context.screen]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add operation-specific tips
|
||||||
|
if (context.operation) {
|
||||||
|
troubleshooting.push(`This error occurred during: ${context.operation}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add custom troubleshooting tips
|
||||||
|
if (context.troubleshooting) {
|
||||||
|
troubleshooting.push(...context.troubleshooting);
|
||||||
|
}
|
||||||
|
|
||||||
|
enhanced.troubleshooting = troubleshooting;
|
||||||
|
enhanced.category = errorCategory;
|
||||||
|
|
||||||
|
return enhanced;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categorize error based on message and properties
|
||||||
|
* @param {Error} error - Error to categorize
|
||||||
|
* @returns {string} Error category
|
||||||
|
*/
|
||||||
|
function categorizeError(error) {
|
||||||
|
if (!error) return "system";
|
||||||
|
|
||||||
|
const message = error.message.toLowerCase();
|
||||||
|
const code = error.code || "";
|
||||||
|
|
||||||
|
// Network errors
|
||||||
|
if (
|
||||||
|
message.includes("network") ||
|
||||||
|
message.includes("timeout") ||
|
||||||
|
message.includes("connection") ||
|
||||||
|
code.includes("ECONNRESET") ||
|
||||||
|
code.includes("ENOTFOUND") ||
|
||||||
|
code.includes("ETIMEDOUT")
|
||||||
|
) {
|
||||||
|
return "network";
|
||||||
|
}
|
||||||
|
|
||||||
|
// API errors
|
||||||
|
if (
|
||||||
|
message.includes("api") ||
|
||||||
|
message.includes("shopify") ||
|
||||||
|
message.includes("rate limit") ||
|
||||||
|
message.includes("unauthorized") ||
|
||||||
|
message.includes("forbidden") ||
|
||||||
|
/[45]\d{2}/.test(code) // 4xx or 5xx HTTP status codes
|
||||||
|
) {
|
||||||
|
return "api";
|
||||||
|
}
|
||||||
|
|
||||||
|
// File system errors
|
||||||
|
if (
|
||||||
|
message.includes("file") ||
|
||||||
|
message.includes("directory") ||
|
||||||
|
message.includes("permission") ||
|
||||||
|
code.includes("ENOENT") ||
|
||||||
|
code.includes("EACCES") ||
|
||||||
|
code.includes("EPERM")
|
||||||
|
) {
|
||||||
|
return "file";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation errors
|
||||||
|
if (
|
||||||
|
message.includes("validation") ||
|
||||||
|
message.includes("invalid") ||
|
||||||
|
message.includes("required") ||
|
||||||
|
message.includes("format") ||
|
||||||
|
error.name === "ValidationError"
|
||||||
|
) {
|
||||||
|
return "validation";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to system error
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error is retryable
|
||||||
|
* @param {Error} error - Error to check
|
||||||
|
* @returns {boolean} True if error is retryable
|
||||||
|
*/
|
||||||
|
function isRetryableError(error) {
|
||||||
|
if (!error) return false;
|
||||||
|
|
||||||
|
const category = categorizeError(error);
|
||||||
|
const message = error.message.toLowerCase();
|
||||||
|
|
||||||
|
// Network errors are usually retryable
|
||||||
|
if (category === "network") return true;
|
||||||
|
|
||||||
|
// Some API errors are retryable
|
||||||
|
if (category === "api") {
|
||||||
|
return (
|
||||||
|
message.includes("rate limit") ||
|
||||||
|
message.includes("timeout") ||
|
||||||
|
message.includes("503") ||
|
||||||
|
message.includes("502") ||
|
||||||
|
message.includes("500")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// File errors might be retryable if temporary
|
||||||
|
if (category === "file") {
|
||||||
|
return (
|
||||||
|
message.includes("locked") ||
|
||||||
|
message.includes("busy") ||
|
||||||
|
error.code === "EBUSY"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation and system errors are usually not retryable
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get retry delay based on attempt number
|
||||||
|
* @param {number} attempt - Attempt number (1-based)
|
||||||
|
* @param {string} errorCategory - Error category
|
||||||
|
* @returns {number} Delay in milliseconds
|
||||||
|
*/
|
||||||
|
function getRetryDelay(attempt, errorCategory = "system") {
|
||||||
|
const baseDelay =
|
||||||
|
{
|
||||||
|
network: 1000,
|
||||||
|
api: 2000,
|
||||||
|
file: 500,
|
||||||
|
system: 1000,
|
||||||
|
}[errorCategory] || 1000;
|
||||||
|
|
||||||
|
// Exponential backoff with jitter
|
||||||
|
const exponentialDelay = baseDelay * Math.pow(2, attempt - 1);
|
||||||
|
const jitter = Math.random() * 0.1 * exponentialDelay;
|
||||||
|
|
||||||
|
return Math.min(exponentialDelay + jitter, 30000); // Cap at 30 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a standardized error for common scenarios
|
||||||
|
* @param {string} scenario - Error scenario
|
||||||
|
* @param {Object} context - Additional context
|
||||||
|
* @returns {Error} Standardized error
|
||||||
|
*/
|
||||||
|
function createStandardError(scenario, context = {}) {
|
||||||
|
const scenarios = {
|
||||||
|
fileNotFound: {
|
||||||
|
message: `File not found: ${context.file || "unknown file"}`,
|
||||||
|
troubleshooting: [
|
||||||
|
"Check if the file exists in the expected location",
|
||||||
|
"Verify the file path is correct",
|
||||||
|
"Ensure you have read permissions for the file",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
apiConnectionFailed: {
|
||||||
|
message: "Failed to connect to Shopify API",
|
||||||
|
troubleshooting: [
|
||||||
|
"Check your internet connection",
|
||||||
|
"Verify your Shopify API credentials",
|
||||||
|
"Ensure your store domain is correct",
|
||||||
|
"Check if Shopify services are operational",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
invalidConfiguration: {
|
||||||
|
message: `Invalid configuration: ${context.field || "unknown field"}`,
|
||||||
|
troubleshooting: [
|
||||||
|
"Check your .env file for correct values",
|
||||||
|
"Verify all required configuration fields are set",
|
||||||
|
"Ensure configuration values are in the correct format",
|
||||||
|
"Review the configuration documentation",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
operationTimeout: {
|
||||||
|
message: `Operation timed out: ${
|
||||||
|
context.operation || "unknown operation"
|
||||||
|
}`,
|
||||||
|
troubleshooting: [
|
||||||
|
"Try the operation again with a smaller scope",
|
||||||
|
"Check your network connection stability",
|
||||||
|
"Increase timeout values if possible",
|
||||||
|
"Break the operation into smaller parts",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const scenario_config = scenarios[scenario];
|
||||||
|
if (!scenario_config) {
|
||||||
|
return enhanceError(
|
||||||
|
new Error(`Unknown error scenario: ${scenario}`),
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = new Error(scenario_config.message);
|
||||||
|
return enhanceError(error, {
|
||||||
|
...context,
|
||||||
|
troubleshooting: scenario_config.troubleshooting,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log error with appropriate level and context
|
||||||
|
* @param {Error} error - Error to log
|
||||||
|
* @param {Object} context - Additional context
|
||||||
|
*/
|
||||||
|
function logError(error, context = {}) {
|
||||||
|
const category = categorizeError(error);
|
||||||
|
const logLevel =
|
||||||
|
{
|
||||||
|
network: "warn",
|
||||||
|
api: "warn",
|
||||||
|
file: "error",
|
||||||
|
validation: "warn",
|
||||||
|
system: "error",
|
||||||
|
}[category] || "error";
|
||||||
|
|
||||||
|
const logMessage = `[${category.toUpperCase()}] ${error.message}`;
|
||||||
|
const logContext = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
category,
|
||||||
|
context,
|
||||||
|
stack: error.stack,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (logLevel === "error") {
|
||||||
|
console.error(logMessage, logContext);
|
||||||
|
} else {
|
||||||
|
console.warn(logMessage, logContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
enhanceError,
|
||||||
|
categorizeError,
|
||||||
|
isRetryableError,
|
||||||
|
getRetryDelay,
|
||||||
|
createStandardError,
|
||||||
|
logError,
|
||||||
|
COMMON_TROUBLESHOOTING,
|
||||||
|
SCREEN_TROUBLESHOOTING,
|
||||||
|
};
|
||||||
179
tests/tui/errorHandling.test.js
Normal file
179
tests/tui/errorHandling.test.js
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
const {
|
||||||
|
enhanceError,
|
||||||
|
categorizeError,
|
||||||
|
isRetryableError,
|
||||||
|
createStandardError,
|
||||||
|
} = require("../../src/tui/utils/errorHandler");
|
||||||
|
const ErrorHandlingService = require("../../src/tui/services/ErrorHandlingService");
|
||||||
|
|
||||||
|
describe("TUI Error Handling", () => {
|
||||||
|
describe("Error Enhancement", () => {
|
||||||
|
test("should enhance basic error with troubleshooting", () => {
|
||||||
|
const originalError = new Error("Network timeout");
|
||||||
|
const enhanced = enhanceError(originalError, {
|
||||||
|
operation: "fetchTags",
|
||||||
|
screen: "TagAnalysisScreen",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(enhanced.troubleshooting).toBeDefined();
|
||||||
|
expect(enhanced.troubleshooting.length).toBeGreaterThan(0);
|
||||||
|
expect(enhanced.context.operation).toBe("fetchTags");
|
||||||
|
expect(enhanced.enhanced).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not double-enhance errors", () => {
|
||||||
|
const originalError = new Error("Test error");
|
||||||
|
const enhanced1 = enhanceError(originalError);
|
||||||
|
const enhanced2 = enhanceError(enhanced1);
|
||||||
|
|
||||||
|
expect(enhanced2).toBe(enhanced1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Error Categorization", () => {
|
||||||
|
test("should categorize network errors correctly", () => {
|
||||||
|
const networkError = new Error("Connection timeout");
|
||||||
|
expect(categorizeError(networkError)).toBe("network");
|
||||||
|
|
||||||
|
const econnError = new Error("ECONNRESET");
|
||||||
|
econnError.code = "ECONNRESET";
|
||||||
|
expect(categorizeError(econnError)).toBe("network");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should categorize API errors correctly", () => {
|
||||||
|
const apiError = new Error("Shopify API rate limit exceeded");
|
||||||
|
expect(categorizeError(apiError)).toBe("api");
|
||||||
|
|
||||||
|
const httpError = new Error("HTTP 503 Service Unavailable");
|
||||||
|
httpError.code = "503";
|
||||||
|
expect(categorizeError(httpError)).toBe("api");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should categorize file errors correctly", () => {
|
||||||
|
const fileError = new Error("File not found");
|
||||||
|
fileError.code = "ENOENT";
|
||||||
|
expect(categorizeError(fileError)).toBe("file");
|
||||||
|
|
||||||
|
const permissionError = new Error("Permission denied");
|
||||||
|
permissionError.code = "EACCES";
|
||||||
|
expect(categorizeError(permissionError)).toBe("file");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should categorize validation errors correctly", () => {
|
||||||
|
const validationError = new Error("Invalid input format");
|
||||||
|
validationError.name = "ValidationError";
|
||||||
|
expect(categorizeError(validationError)).toBe("validation");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Retry Logic", () => {
|
||||||
|
test("should identify retryable errors", () => {
|
||||||
|
const networkError = new Error("Connection timeout");
|
||||||
|
expect(isRetryableError(networkError)).toBe(true);
|
||||||
|
|
||||||
|
const rateLimitError = new Error("Rate limit exceeded");
|
||||||
|
expect(isRetryableError(rateLimitError)).toBe(true);
|
||||||
|
|
||||||
|
const validationError = new Error("Invalid input");
|
||||||
|
validationError.name = "ValidationError";
|
||||||
|
expect(isRetryableError(validationError)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Standard Errors", () => {
|
||||||
|
test("should create file not found error", () => {
|
||||||
|
const error = createStandardError("fileNotFound", {
|
||||||
|
file: "schedules.json",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(error.message).toContain("schedules.json");
|
||||||
|
expect(error.troubleshooting).toBeDefined();
|
||||||
|
expect(error.troubleshooting.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should create API connection error", () => {
|
||||||
|
const error = createStandardError("apiConnectionFailed");
|
||||||
|
|
||||||
|
expect(error.message).toContain("Shopify API");
|
||||||
|
expect(error.troubleshooting).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ErrorHandlingService", () => {
|
||||||
|
let errorHandler;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
errorHandler = new ErrorHandlingService();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should execute operation successfully on first try", async () => {
|
||||||
|
const mockOperation = jest.fn().mockResolvedValue("success");
|
||||||
|
|
||||||
|
const result = await errorHandler.executeWithRetry(mockOperation);
|
||||||
|
|
||||||
|
expect(result).toBe("success");
|
||||||
|
expect(mockOperation).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should retry failed operations", async () => {
|
||||||
|
const mockOperation = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error("Network timeout"))
|
||||||
|
.mockResolvedValue("success");
|
||||||
|
|
||||||
|
const result = await errorHandler.executeWithRetry(mockOperation, {
|
||||||
|
maxRetries: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe("success");
|
||||||
|
expect(mockOperation).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should fail after max retries", async () => {
|
||||||
|
const mockOperation = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new Error("Persistent error"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
errorHandler.executeWithRetry(mockOperation, {
|
||||||
|
maxRetries: 1, // Only 1 retry, so should be called once
|
||||||
|
})
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(mockOperation).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle graceful file reads", async () => {
|
||||||
|
const mockFileReader = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(
|
||||||
|
Object.assign(new Error("File not found"), { code: "ENOENT" })
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await errorHandler.gracefulFileRead(
|
||||||
|
mockFileReader,
|
||||||
|
"fallback"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe("fallback");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should track error history", async () => {
|
||||||
|
const mockOperation = jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new Error("Test error"));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await errorHandler.executeWithRetry(mockOperation, { maxRetries: 1 });
|
||||||
|
} catch (error) {
|
||||||
|
// Expected to fail
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = errorHandler.getErrorHistory();
|
||||||
|
expect(history.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const stats = errorHandler.getErrorStats();
|
||||||
|
expect(stats.totalErrors).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user