Start on step 17 next
This commit is contained in:
@@ -80,7 +80,7 @@
|
||||
- Display log file metadata (size, creation date, operation count)
|
||||
- _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
|
||||
- Add pagination for large log files using Pagination component
|
||||
@@ -89,7 +89,7 @@
|
||||
- Handle empty log files with helpful messaging
|
||||
- _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
|
||||
- Add search functionality within log content
|
||||
@@ -98,7 +98,7 @@
|
||||
- Add filter status indicators and clear filter options
|
||||
- _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
|
||||
- Implement keyboard navigation for tag selection
|
||||
@@ -107,7 +107,7 @@
|
||||
- Display loading indicators during tag fetching
|
||||
- _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)
|
||||
- Implement tag details view showing products and prices
|
||||
@@ -116,7 +116,7 @@
|
||||
- Add error handling for API connection failures
|
||||
- _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
|
||||
- Add tag selection for immediate use in configuration
|
||||
@@ -125,7 +125,7 @@
|
||||
- Handle tag selection workflow and navigation
|
||||
- _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
|
||||
- Update main menu to remove "coming soon" placeholders
|
||||
@@ -134,7 +134,7 @@
|
||||
- Update help text and keyboard shortcuts documentation
|
||||
- _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
|
||||
- Implement retry logic for API failures in Tag Analysis
|
||||
|
||||
@@ -67,6 +67,8 @@ class TagAnalysisService {
|
||||
analyzeProductTags(products) {
|
||||
const tagCounts = new Map();
|
||||
const tagPrices = new Map();
|
||||
const tagVariantCounts = new Map();
|
||||
const tagTotalValues = new Map();
|
||||
const totalProducts = products.length;
|
||||
|
||||
// Count tags and collect price data
|
||||
@@ -77,18 +79,22 @@ class TagAnalysisService {
|
||||
// Count occurrences
|
||||
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
|
||||
|
||||
// Collect price data
|
||||
// Initialize collections if not exists
|
||||
if (!tagPrices.has(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)) {
|
||||
product.variants.forEach((variant) => {
|
||||
if (variant.price) {
|
||||
const price = parseFloat(variant.price);
|
||||
if (!isNaN(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())
|
||||
.map(([tag, count]) => ({
|
||||
tag,
|
||||
count,
|
||||
percentage: (count / totalProducts) * 100,
|
||||
variantCount: tagVariantCounts.get(tag) || 0,
|
||||
totalValue: tagTotalValues.get(tag) || 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
// Calculate price ranges
|
||||
// Calculate price ranges with enhanced statistics
|
||||
const priceRanges = {};
|
||||
tagPrices.forEach((prices, tag) => {
|
||||
if (prices.length > 0) {
|
||||
const sortedPrices = prices.sort((a, b) => a - b);
|
||||
const totalValue = tagTotalValues.get(tag) || 0;
|
||||
const variantCount = tagVariantCounts.get(tag) || 0;
|
||||
|
||||
priceRanges[tag] = {
|
||||
min: sortedPrices[0],
|
||||
max: sortedPrices[sortedPrices.length - 1],
|
||||
average:
|
||||
prices.reduce((sum, price) => sum + price, 0) / prices.length,
|
||||
count: prices.length,
|
||||
variantCount: variantCount,
|
||||
totalValue: totalValue,
|
||||
median: this.calculateMedian(sortedPrices),
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -385,6 +399,23 @@ class TagAnalysisService {
|
||||
: 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
|
||||
* @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
|
||||
* 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
|
||||
*/
|
||||
|
||||
// Enable Babel for JSX support
|
||||
require("@babel/register");
|
||||
|
||||
// Initialize the TUI application
|
||||
const main = async () => {
|
||||
try {
|
||||
@@ -13,962 +16,18 @@ const main = async () => {
|
||||
|
||||
// Use dynamic imports for ESM modules
|
||||
const React = await import("react");
|
||||
const { render, Text, Box, useInput } = await import("ink");
|
||||
const TextInput = await import("ink-text-input");
|
||||
const { render } = await import("ink");
|
||||
|
||||
console.log("✅ Loaded React and Ink successfully");
|
||||
|
||||
// Load current configuration from .env file
|
||||
const loadConfiguration = () => {
|
||||
try {
|
||||
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"
|
||||
)
|
||||
);
|
||||
};
|
||||
// Import the main TUI application
|
||||
console.log("📦 Loading TUI application...");
|
||||
const TuiApplication = require("./tui/TuiApplication.jsx");
|
||||
|
||||
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
|
||||
await waitUntilExit();
|
||||
|
||||
@@ -51,3 +51,4 @@ const TuiContent = () => {
|
||||
};
|
||||
|
||||
module.exports = TuiApplication;
|
||||
module.exports.default = TuiApplication;
|
||||
|
||||
@@ -8,7 +8,7 @@ const ConfigurationScreen = require("./screens/ConfigurationScreen.jsx");
|
||||
const OperationScreen = require("./screens/OperationScreen.jsx");
|
||||
const SchedulingScreen = require("./screens/SchedulingScreen.jsx");
|
||||
const ViewLogsScreen = require("./screens/ViewLogsScreen.jsx");
|
||||
// const TagAnalysisScreen = require("./screens/TagAnalysisScreen.jsx");
|
||||
const TagAnalysisScreen = require("./screens/TagAnalysisScreen.jsx");
|
||||
|
||||
/**
|
||||
* Router Component
|
||||
@@ -25,7 +25,7 @@ const Router = () => {
|
||||
operation: OperationScreen,
|
||||
scheduling: SchedulingScreen,
|
||||
logs: ViewLogsScreen,
|
||||
// "tag-analysis": TagAnalysisScreen,
|
||||
"tag-analysis": TagAnalysisScreen,
|
||||
};
|
||||
|
||||
// Get the current screen component
|
||||
|
||||
@@ -16,8 +16,10 @@ const ErrorDisplay = ({
|
||||
retryText = "Press 'r' to retry",
|
||||
dismissText = "Press 'd' to dismiss",
|
||||
compact = false,
|
||||
showTroubleshooting = true,
|
||||
}) => {
|
||||
const [dismissed, setDismissed] = React.useState(false);
|
||||
const [showDetails, setShowDetails] = React.useState(false);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (input === "r" && onRetry && showRetry) {
|
||||
@@ -28,6 +30,8 @@ const ErrorDisplay = ({
|
||||
} else {
|
||||
setDismissed(true);
|
||||
}
|
||||
} else if (input === "t" && showTroubleshooting && hasTroubleshooting()) {
|
||||
setShowDetails(!showDetails);
|
||||
} else if (key.escape && showDismiss) {
|
||||
if (onDismiss) {
|
||||
onDismiss();
|
||||
@@ -65,6 +69,29 @@ const ErrorDisplay = ({
|
||||
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) {
|
||||
return (
|
||||
<Box flexDirection="row" alignItems="center">
|
||||
@@ -81,6 +108,11 @@ const ErrorDisplay = ({
|
||||
(d: dismiss)
|
||||
</Text>
|
||||
)}
|
||||
{hasTroubleshooting() && (
|
||||
<Text color="gray" marginLeft={1}>
|
||||
(t: help)
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -105,11 +137,50 @@ const ErrorDisplay = ({
|
||||
</Text>
|
||||
</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">
|
||||
{showRetry && onRetry && <Text color="cyan">• {retryText}</Text>}
|
||||
{showDismiss && (
|
||||
<Text color="cyan">• {dismissText} or press Escape</Text>
|
||||
)}
|
||||
{hasTroubleshooting() && (
|
||||
<Text color="cyan">
|
||||
• Press 't' to {showDetails ? "hide" : "show"} troubleshooting tips
|
||||
</Text>
|
||||
)}
|
||||
</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
|
||||
const ErrorDisplay = require("./ErrorDisplay.jsx");
|
||||
const ErrorBoundary = require("./ErrorBoundary.jsx");
|
||||
const ScreenErrorBoundary = require("./ScreenErrorBoundary.jsx");
|
||||
const { LoadingIndicator, LoadingOverlay } = require("./LoadingIndicator.jsx");
|
||||
const { Pagination, SimplePagination } = require("./Pagination.jsx");
|
||||
const { FormInput, SimpleFormInput } = require("./FormInput.jsx");
|
||||
|
||||
module.exports = {
|
||||
ErrorDisplay,
|
||||
ErrorBoundary,
|
||||
ScreenErrorBoundary,
|
||||
LoadingIndicator,
|
||||
LoadingOverlay,
|
||||
Pagination,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,13 @@ const { Pagination } = require("../common/Pagination.jsx");
|
||||
*/
|
||||
const ViewLogsScreen = () => {
|
||||
const { navigateBack } = useAppState();
|
||||
const { getLogFiles, readLogFile } = useServices();
|
||||
const {
|
||||
getLogFiles,
|
||||
readLogFile,
|
||||
parseLogContent,
|
||||
getFilteredLogs,
|
||||
filterLogs,
|
||||
} = useServices();
|
||||
|
||||
// State management for log files, selected file, and content
|
||||
const [logFiles, setLogFiles] = React.useState([]);
|
||||
@@ -21,12 +27,28 @@ const ViewLogsScreen = () => {
|
||||
const [selectedFile, setSelectedFile] = React.useState(null);
|
||||
const [logContent, setLogContent] = React.useState("");
|
||||
const [parsedLogs, setParsedLogs] = React.useState([]);
|
||||
const [filteredLogs, setFilteredLogs] = React.useState([]);
|
||||
const [currentPage, setCurrentPage] = React.useState(0);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState(null);
|
||||
const [loadingContent, setLoadingContent] = React.useState(false);
|
||||
const [contentError, setContentError] = React.useState(null);
|
||||
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
|
||||
React.useEffect(() => {
|
||||
@@ -35,10 +57,24 @@ const ViewLogsScreen = () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
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
|
||||
const mainLogIndex = files.findIndex((file) => file.isMainLog);
|
||||
const mainLogIndex = transformedFiles.findIndex(
|
||||
(file) => file.isMainLog
|
||||
);
|
||||
if (mainLogIndex !== -1) {
|
||||
setSelectedFileIndex(mainLogIndex);
|
||||
}
|
||||
@@ -52,34 +88,60 @@ const ViewLogsScreen = () => {
|
||||
loadLogFiles();
|
||||
}, [getLogFiles]);
|
||||
|
||||
// Load content for selected file
|
||||
// Load content for selected file with filtering
|
||||
const loadFileContent = React.useCallback(
|
||||
async (file) => {
|
||||
async (file, filterOptions = null) => {
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
setLoadingContent(true);
|
||||
setContentError(null);
|
||||
setCurrentPage(0); // Reset pagination
|
||||
setScrollOffset(0); // Reset scroll
|
||||
setSelectedLogIndex(0); // Reset selection
|
||||
|
||||
const content = await readLogFile(file.filename);
|
||||
setLogContent(content);
|
||||
const currentFilters = filterOptions || filters;
|
||||
|
||||
// Parse the content into structured log entries
|
||||
const { parseLogContent } = useServices();
|
||||
const parsed = parseLogContent(content);
|
||||
setParsedLogs(parsed);
|
||||
// Use getFilteredLogs if we have filtering service available
|
||||
if (getFilteredLogs) {
|
||||
const result = await getFilteredLogs({
|
||||
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);
|
||||
setShowingContent(true);
|
||||
} catch (err) {
|
||||
setContentError(`Failed to read log file: ${err.message}`);
|
||||
setLogContent("");
|
||||
setParsedLogs([]);
|
||||
setFilteredLogs([]);
|
||||
setTotalUnfilteredEntries(0);
|
||||
} finally {
|
||||
setLoadingContent(false);
|
||||
}
|
||||
},
|
||||
[readLogFile, useServices]
|
||||
[readLogFile, parseLogContent, getFilteredLogs, filterLogs, filters]
|
||||
);
|
||||
|
||||
// Helper function to format file size
|
||||
@@ -115,31 +177,276 @@ const ViewLogsScreen = () => {
|
||||
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) => {
|
||||
if (loading) return; // Ignore input while loading
|
||||
|
||||
if (key.escape) {
|
||||
navigateBack();
|
||||
} else 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]);
|
||||
// Handle filter input mode
|
||||
if (filterInputMode === "search") {
|
||||
if (key.escape) {
|
||||
setFilterInputMode(null);
|
||||
setSearchInput(filters.searchTerm); // Reset to current filter
|
||||
} else if (key.return) {
|
||||
handleSearchInput(searchInput);
|
||||
setFilterInputMode(null);
|
||||
} else if (key.backspace) {
|
||||
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
|
||||
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(
|
||||
Box,
|
||||
{ flexDirection: "column", padding: 2, flexGrow: 1 },
|
||||
// Header
|
||||
React.createElement(
|
||||
Box,
|
||||
{ flexDirection: "column", marginBottom: 2 },
|
||||
{ flexDirection: "column", marginBottom: 1 },
|
||||
React.createElement(
|
||||
Text,
|
||||
{ bold: true, color: "cyan" },
|
||||
"📄 Log File Content"
|
||||
Box,
|
||||
{ flexDirection: "row", justifyContent: "space-between" },
|
||||
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(
|
||||
Text,
|
||||
{ color: "gray" },
|
||||
`Viewing: ${selectedFile.filename}`
|
||||
`Viewing: ${selectedFile.filename} (${filteredLogs.length}/${totalUnfilteredEntries} entries)`
|
||||
)
|
||||
),
|
||||
|
||||
// File metadata
|
||||
React.createElement(
|
||||
Box,
|
||||
{
|
||||
borderStyle: "single",
|
||||
borderColor: "blue",
|
||||
paddingX: 2,
|
||||
paddingY: 1,
|
||||
marginBottom: 2,
|
||||
},
|
||||
// Filter panel (if enabled)
|
||||
showFilters &&
|
||||
React.createElement(
|
||||
Box,
|
||||
{ flexDirection: "column" },
|
||||
{
|
||||
borderStyle: "single",
|
||||
borderColor: "yellow",
|
||||
paddingX: 1,
|
||||
paddingY: 1,
|
||||
marginBottom: 1,
|
||||
},
|
||||
React.createElement(
|
||||
Box,
|
||||
{ flexDirection: "row", justifyContent: "space-between" },
|
||||
{ flexDirection: "column" },
|
||||
React.createElement(
|
||||
Text,
|
||||
{ color: "white", bold: true },
|
||||
selectedFile.filename
|
||||
{ color: "yellow", bold: true },
|
||||
"🔍 Filters & Search"
|
||||
),
|
||||
React.createElement(
|
||||
Text,
|
||||
{ color: selectedFile.isMainLog ? "green" : "gray" },
|
||||
selectedFile.isMainLog ? "MAIN LOG" : "ARCHIVE"
|
||||
)
|
||||
),
|
||||
React.createElement(
|
||||
Box,
|
||||
{ flexDirection: "row", marginTop: 1 },
|
||||
React.createElement(
|
||||
Box,
|
||||
{ flexDirection: "column", width: "50%" },
|
||||
{ flexDirection: "row", marginTop: 1 },
|
||||
React.createElement(
|
||||
Text,
|
||||
{ color: "cyan" },
|
||||
`Size: ${formatFileSize(selectedFile.size)}`
|
||||
Box,
|
||||
{ flexDirection: "column", width: "50%" },
|
||||
React.createElement(
|
||||
Text,
|
||||
{ color: "gray" },
|
||||
`Date Range: ${filters.dateRange}`
|
||||
),
|
||||
React.createElement(
|
||||
Text,
|
||||
{ color: "gray" },
|
||||
`Operation: ${filters.operationType}`
|
||||
)
|
||||
),
|
||||
React.createElement(
|
||||
Text,
|
||||
{ color: "cyan" },
|
||||
`Operations: ${selectedFile.operationCount}`
|
||||
Box,
|
||||
{ flexDirection: "column", width: "50%" },
|
||||
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(
|
||||
Box,
|
||||
{ flexDirection: "column", width: "50%" },
|
||||
{ flexDirection: "row", marginTop: 1 },
|
||||
React.createElement(
|
||||
Text,
|
||||
{ color: "cyan" },
|
||||
`Created: ${getRelativeTime(selectedFile.createdAt)}`
|
||||
),
|
||||
React.createElement(
|
||||
Text,
|
||||
{ color: "cyan" },
|
||||
`Modified: ${getRelativeTime(selectedFile.modifiedAt)}`
|
||||
{ color: "gray" },
|
||||
"S-Search D-Date T-Type L-Level C-Clear F-Close"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
),
|
||||
|
||||
// Content display
|
||||
React.createElement(
|
||||
@@ -269,7 +607,7 @@ const ViewLogsScreen = () => {
|
||||
paddingX: 1,
|
||||
paddingY: 1,
|
||||
flexGrow: 1,
|
||||
marginBottom: 2,
|
||||
marginBottom: 1,
|
||||
},
|
||||
loadingContent
|
||||
? React.createElement(LoadingIndicator, {
|
||||
@@ -280,29 +618,201 @@ const ViewLogsScreen = () => {
|
||||
error: { message: contentError },
|
||||
onRetry: () => loadFileContent(selectedFile),
|
||||
})
|
||||
: logContent
|
||||
: !logContent
|
||||
? 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,
|
||||
{ flexDirection: "column" },
|
||||
React.createElement(
|
||||
Text,
|
||||
{ color: "white", wrap: "wrap" },
|
||||
logContent.substring(0, 2000) // Show first 2000 characters
|
||||
),
|
||||
logContent.length > 2000 &&
|
||||
React.createElement(
|
||||
Text,
|
||||
{ color: "yellow", marginTop: 1 },
|
||||
`... (${logContent.length - 2000} more characters)`
|
||||
)
|
||||
)
|
||||
: React.createElement(
|
||||
Text,
|
||||
{ color: "gray" },
|
||||
"File is empty or could not be read"
|
||||
Box,
|
||||
{ flexDirection: "column" },
|
||||
logContent
|
||||
.split("\n")
|
||||
.slice(scrollOffset, scrollOffset + 15)
|
||||
.map((line, index) => {
|
||||
const lineNumber = scrollOffset + index + 1;
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// Determine line color based on content
|
||||
let lineColor = "white";
|
||||
if (trimmedLine.startsWith("##")) {
|
||||
lineColor = "cyan"; // Operation headers
|
||||
} else if (
|
||||
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
|
||||
React.createElement(
|
||||
Box,
|
||||
@@ -311,13 +821,61 @@ const ViewLogsScreen = () => {
|
||||
borderTopStyle: "single",
|
||||
borderColor: "gray",
|
||||
paddingTop: 1,
|
||||
marginTop: 1,
|
||||
},
|
||||
React.createElement(Text, { color: "gray", bold: true }, "Navigation:"),
|
||||
React.createElement(
|
||||
Text,
|
||||
{ color: "gray", marginTop: 1 },
|
||||
" Esc - Back to file list R - Refresh content"
|
||||
)
|
||||
Box,
|
||||
{ flexDirection: "row", marginTop: 1 },
|
||||
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 ErrorHandlingService = require("./ErrorHandlingService");
|
||||
|
||||
/**
|
||||
* LogService - Reads and parses Progress.md files for TUI log viewing
|
||||
@@ -10,6 +15,7 @@ class LogService {
|
||||
this.progressFilePath = progressFilePath;
|
||||
this.cache = new Map();
|
||||
this.cacheExpiry = 2 * 60 * 1000; // 2 minutes
|
||||
this.errorHandler = new ErrorHandlingService();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,7 +28,7 @@ class LogService {
|
||||
|
||||
// Check main Progress.md file
|
||||
try {
|
||||
const stats = await fs.stat(this.progressFilePath);
|
||||
const stats = await stat(this.progressFilePath);
|
||||
files.push({
|
||||
name: "Progress.md",
|
||||
path: this.progressFilePath,
|
||||
@@ -32,20 +38,28 @@ class LogService {
|
||||
isMain: true,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") {
|
||||
throw error;
|
||||
if (error.code === "ENOENT") {
|
||||
// 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)
|
||||
try {
|
||||
const currentDir = await fs.readdir(".");
|
||||
const currentDir = await readdir(".");
|
||||
const logFiles = currentDir.filter((file) =>
|
||||
file.match(/^Progress_\d{4}-\d{2}-\d{2}\.md$/)
|
||||
);
|
||||
|
||||
for (const file of logFiles) {
|
||||
const stats = await fs.stat(file);
|
||||
const stats = await stat(file);
|
||||
files.push({
|
||||
name: file,
|
||||
path: file,
|
||||
@@ -62,7 +76,16 @@ class LogService {
|
||||
// Sort by modification date (newest first)
|
||||
return files.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
||||
} 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) {
|
||||
const targetPath = filePath || this.progressFilePath;
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(targetPath, "utf8");
|
||||
return content;
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
return ""; // Return empty string for non-existent files
|
||||
return this.errorHandler.gracefulFileRead(
|
||||
() => readFile(targetPath, "utf8"),
|
||||
"", // fallback to empty string
|
||||
{
|
||||
filename: targetPath,
|
||||
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 currentSection = null;
|
||||
let lineIndex = 0;
|
||||
let inCodeBlock = false;
|
||||
|
||||
for (const line of lines) {
|
||||
lineIndex++;
|
||||
const trimmedLine = line.trim();
|
||||
|
||||
// Skip empty lines and markdown headers
|
||||
if (
|
||||
!trimmedLine ||
|
||||
trimmedLine.startsWith("#") ||
|
||||
trimmedLine === "---"
|
||||
) {
|
||||
// Handle code block boundaries
|
||||
if (trimmedLine === "```") {
|
||||
inCodeBlock = !inCodeBlock;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse operation headers (## Operation Type - Timestamp)
|
||||
// Parse operation headers first (## Operation Type - Timestamp) - these are outside code blocks
|
||||
const operationMatch = trimmedLine.match(/^## (.+) - (.+)$/);
|
||||
if (operationMatch) {
|
||||
const [, operationType, timestamp] = operationMatch;
|
||||
@@ -140,6 +161,19 @@ class LogService {
|
||||
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
|
||||
if (trimmedLine.startsWith("**") && trimmedLine.endsWith("**")) {
|
||||
const sectionTitle = trimmedLine.slice(2, -2);
|
||||
@@ -219,7 +253,7 @@ class LogService {
|
||||
parseProgressLine(line, operation, entries, lineNumber) {
|
||||
// Parse product update lines with status indicators
|
||||
const updateMatch = line.match(
|
||||
/^- ([✅❌🔄⚠️]) \*\*(.+?)\*\* \((.+?)\)(.*)$/
|
||||
/^-\s*([✅❌🔄⚠️])\s*\*\*(.+?)\*\*\s*\((.+?)\)(.*)$/
|
||||
);
|
||||
if (updateMatch) {
|
||||
const [, status, productTitle, productId, details] = updateMatch;
|
||||
@@ -460,7 +494,17 @@ class LogService {
|
||||
|
||||
return result;
|
||||
} 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()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -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 ErrorHandlingService = require("./ErrorHandlingService");
|
||||
|
||||
/**
|
||||
* ScheduleService - Manages scheduled operations with JSON persistence for TUI
|
||||
@@ -10,6 +15,7 @@ class ScheduleService {
|
||||
this.schedulesFile = "schedules.json";
|
||||
this.schedules = [];
|
||||
this.isLoaded = false;
|
||||
this.errorHandler = new ErrorHandlingService();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -17,19 +23,84 @@ class ScheduleService {
|
||||
* @returns {Promise<Array>} Array of schedules
|
||||
*/
|
||||
async loadSchedules() {
|
||||
try {
|
||||
const data = await fs.readFile(this.schedulesFile, "utf8");
|
||||
this.schedules = JSON.parse(data);
|
||||
this.isLoaded = true;
|
||||
return this.schedules;
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
// File doesn't exist, start with empty array
|
||||
this.schedules = [];
|
||||
return this.errorHandler.gracefulFileRead(
|
||||
async () => {
|
||||
const data = await readFile(this.schedulesFile, "utf8");
|
||||
this.schedules = JSON.parse(data);
|
||||
this.isLoaded = true;
|
||||
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>}
|
||||
*/
|
||||
async saveSchedules(schedules = null) {
|
||||
try {
|
||||
const dataToSave = schedules || this.schedules;
|
||||
await fs.writeFile(
|
||||
this.schedulesFile,
|
||||
JSON.stringify(dataToSave, null, 2)
|
||||
);
|
||||
if (!schedules) {
|
||||
this.schedules = dataToSave;
|
||||
const dataToSave = schedules || this.schedules;
|
||||
|
||||
return this.errorHandler.handleFileOperation(
|
||||
async () => {
|
||||
// Create backup before writing
|
||||
const backupData = JSON.stringify(dataToSave, null, 2);
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const ErrorHandlingService = require("./ErrorHandlingService");
|
||||
|
||||
/**
|
||||
* TagAnalysisService - Fetches and analyzes Shopify product tags for TUI
|
||||
* Requirements: 5.3, 3.1, 3.2, 3.3, 3.4, 3.6
|
||||
@@ -8,6 +10,7 @@ class TagAnalysisService {
|
||||
this.productService = productService;
|
||||
this.cache = new Map();
|
||||
this.cacheExpiry = 5 * 60 * 1000; // 5 minutes
|
||||
this.errorHandler = new ErrorHandlingService();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,13 +27,30 @@ class TagAnalysisService {
|
||||
}
|
||||
|
||||
try {
|
||||
// Use existing ProductService method to fetch products with tags
|
||||
const products = await this.productService.debugFetchAllProductTags(
|
||||
limit
|
||||
// Use error handler for API operations with retry logic
|
||||
const products = await this.errorHandler.handleApiOperation(
|
||||
() => this.productService.debugFetchAllProductTags(limit),
|
||||
{
|
||||
context: {
|
||||
operation: "fetchAllTags",
|
||||
limit,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
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
|
||||
@@ -122,7 +142,8 @@ class TagAnalysisService {
|
||||
|
||||
return result;
|
||||
} 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 {
|
||||
// Fetch products with this specific tag
|
||||
const products = await this.productService.fetchProductsByTag(tag);
|
||||
// Use error handler for API operations
|
||||
const products = await this.errorHandler.handleApiOperation(
|
||||
() => this.productService.fetchProductsByTag(tag),
|
||||
{
|
||||
context: {
|
||||
operation: "getTagDetails",
|
||||
tag,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!products || products.length === 0) {
|
||||
return {
|
||||
@@ -191,8 +220,19 @@ class TagAnalysisService {
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to get tag details for "${tag}": ${error.message}`
|
||||
throw this.createEnhancedError(
|
||||
`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(),
|
||||
};
|
||||
} 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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
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