qbit_manage/web-ui/js/utils/history-manager.js
bobokun 1e12a1610f
4.5.4 (#910)
# Improvements
- Support cross-platform binary builds (Linux/Windows/MacOS)
- Adds desktop app installers (Linux/Windows/MacOS)
- Container images for latest now pointed to newest version
automatically (Fixes #897)
- Enable automatic open of webUI in local installs
- Add persistence toggling for webUI scheduler

# Bug Fixes
- Fix schedule.yml not loaded upon restarting Docker container (Fixes
#906)
- Fix bug where torrents were not being paused after share limits
reached (Fixes #901)
- Fix(api): prevent path traversal vulnerability in backup restore
endpoint (Fixes CWE-22 Security Vulnerability)
- Fix scheduler to run interval jobs immediately on startup

**Full Changelog**:
https://github.com/StuffAnThings/qbit_manage/compare/v4.5.3...v4.5.4

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-08-16 22:28:26 -04:00

423 lines
14 KiB
JavaScript

/**
* History Manager for Config Undo/Redo Functionality
* Manages undo/redo state for each config file with hybrid approach:
* - In-memory storage for recent changes (fast operations)
* - Integration with existing backup system for persistence
*/
export class HistoryManager {
constructor(api) {
this.api = api;
this.histories = new Map(); // Per config file: { configName: HistoryState }
this.maxInMemoryStates = 50; // Maximum states to keep in memory
this.maxBackupStates = 200; // Maximum backup files to track
}
/**
* Get or create history state for a config file
*/
_getHistoryState(configName) {
if (!this.histories.has(configName)) {
this.histories.set(configName, {
states: [], // Array of state objects
currentIndex: -1, // Current position in history
savedIndex: -1, // Index of last saved state
backupMapping: new Map(), // Maps state indices to backup file names
});
}
return this.histories.get(configName);
}
/**
* Create a checkpoint in history
* @param {string} configName - Name of the config file
* @param {Object} state - Current config state
* @param {string} description - Description of the change
* @param {boolean} isSavePoint - Whether this is a save operation
*/
createCheckpoint(configName, state, description = 'Config change', isSavePoint = false) {
const history = this._getHistoryState(configName);
// Create state object
const stateEntry = {
data: JSON.parse(JSON.stringify(state)), // Deep clone
timestamp: Date.now(),
description,
isSavePoint,
id: this._generateStateId()
};
// If we're not at the end of history, remove future states (redo history)
if (history.currentIndex < history.states.length - 1) {
history.states.splice(history.currentIndex + 1);
// Clean up backup mappings for removed states
for (let i = history.currentIndex + 1; i < history.states.length; i++) {
history.backupMapping.delete(i);
}
}
// Add new state
history.states.push(stateEntry);
history.currentIndex = history.states.length - 1;
// Mark save point
if (isSavePoint) {
history.savedIndex = history.currentIndex;
}
// Manage memory by removing old states if we exceed the limit
this._manageMemoryLimit(configName);
console.log(`History checkpoint created for ${configName}: ${description}`);
return stateEntry.id;
}
/**
* Undo the last change
* @param {string} configName - Name of the config file
* @returns {Object|null} - Previous state or null if can't undo
*/
async undo(configName) {
const history = this._getHistoryState(configName);
if (!this.canUndo(configName)) {
return null;
}
history.currentIndex--;
const targetState = history.states[history.currentIndex];
// If state is not in memory, try to load from backup
if (!targetState.data && history.backupMapping.has(history.currentIndex)) {
const backupName = history.backupMapping.get(history.currentIndex);
try {
targetState.data = await this._loadFromBackup(configName, backupName);
} catch (error) {
console.error(`Failed to load backup for undo: ${error.message}`);
// Revert index change
history.currentIndex++;
return null;
}
}
console.log(`Undo: ${targetState.description} (${configName})`);
// Notify app of state change
const event = new CustomEvent('history-state-change', {
detail: {
configName,
data: targetState.data
}
});
document.dispatchEvent(event);
return {
data: targetState.data,
description: targetState.description,
timestamp: targetState.timestamp
};
}
/**
* Redo the next change
* @param {string} configName - Name of the config file
* @returns {Object|null} - Next state or null if can't redo
*/
async redo(configName) {
const history = this._getHistoryState(configName);
if (!this.canRedo(configName)) {
return null;
}
history.currentIndex++;
const targetState = history.states[history.currentIndex];
// If state is not in memory, try to load from backup
if (!targetState.data && history.backupMapping.has(history.currentIndex)) {
const backupName = history.backupMapping.get(history.currentIndex);
try {
targetState.data = await this._loadFromBackup(configName, backupName);
} catch (error) {
console.error(`Failed to load backup for redo: ${error.message}`);
// Revert index change
history.currentIndex--;
return null;
}
}
console.log(`Redo: ${targetState.description} (${configName})`);
// Notify app of state change
const event = new CustomEvent('history-state-change', {
detail: {
configName,
data: targetState.data
}
});
document.dispatchEvent(event);
return {
data: targetState.data,
description: targetState.description,
timestamp: targetState.timestamp
};
}
/**
* Check if undo is possible
* @param {string} configName - Name of the config file
* @returns {boolean}
*/
canUndo(configName) {
const history = this._getHistoryState(configName);
return history.currentIndex > 0;
}
/**
* Check if redo is possible
* @param {string} configName - Name of the config file
* @returns {boolean}
*/
canRedo(configName) {
const history = this._getHistoryState(configName);
const canRedo = history.currentIndex < history.states.length - 1;
return canRedo;
}
/**
* Check if there are unsaved changes
* @param {string} configName - Name of the config file
* @returns {boolean}
*/
hasUnsavedChanges(configName) {
const history = this._getHistoryState(configName);
return history.currentIndex !== history.savedIndex;
}
/**
* Get current state description
* @param {string} configName - Name of the config file
* @returns {string}
*/
getCurrentStateDescription(configName) {
const history = this._getHistoryState(configName);
if (history.currentIndex >= 0 && history.currentIndex < history.states.length) {
return history.states[history.currentIndex].description;
}
return 'Initial state';
}
/**
* Get undo description (what would be undone)
* @param {string} configName - Name of the config file
* @returns {string|null}
*/
getUndoDescription(configName) {
const history = this._getHistoryState(configName);
if (this.canUndo(configName)) {
return history.states[history.currentIndex - 1].description;
}
return null;
}
/**
* Get redo description (what would be redone)
* @param {string} configName - Name of the config file
* @returns {string|null}
*/
getRedoDescription(configName) {
const history = this._getHistoryState(configName);
if (this.canRedo(configName)) {
return history.states[history.currentIndex + 1].description;
}
return null;
}
/**
* Clear history for a config file
* @param {string} configName - Name of the config file
*/
clearHistory(configName) {
this.histories.delete(configName);
console.log(`History cleared for ${configName}`);
}
/**
* Initialize history from existing backups
* @param {string} configName - Name of the config file
*/
async initializeFromBackups(configName, initialData) {
const history = this._getHistoryState(configName);
// Skip backup initialization if API doesn't support backups
if (!this.api.supportsBackups) {
console.info(`Backup feature not available. Skipping initialization for ${configName}.`);
// Create initial state if history is empty
if (history.states.length === 0) {
this._createInitialState(history, initialData);
}
return;
}
try {
const backups = await this.api.listBackups(configName);
// Sort backups by timestamp (oldest first)
const sortedBackups = backups.backups.sort((a, b) =>
new Date(a.timestamp) - new Date(b.timestamp)
);
// Create history entries for existing backups
sortedBackups.forEach((backup, index) => {
const stateEntry = {
data: null, // Will be loaded on demand
timestamp: new Date(backup.timestamp).getTime(),
description: `Backup: ${backup.filename}`,
isSavePoint: true,
id: this._generateStateId()
};
history.states.push(stateEntry);
history.backupMapping.set(index, backup.filename);
});
if (history.states.length > 0) {
// Create an additional state for the current config (beyond backups)
const currentStateEntry = {
data: initialData, // Current config data
timestamp: Date.now(),
description: `Latest config (${configName})`,
isSavePoint: true,
id: this._generateStateId()
};
history.states.push(currentStateEntry);
history.currentIndex = history.states.length - 1; // Point to current state
history.savedIndex = history.currentIndex;
} else {
// Create initial state if no backups found
this._createInitialState(history, initialData);
}
console.log(`Initialized history for ${configName} with ${history.states.length} backup states`);
} catch (error) {
if (error.message.includes('Not Found')) {
console.info(`Backup feature not implemented for ${configName}. Proceeding without backup history.`);
// Mark API as not supporting backups for future calls
this.api.supportsBackups = false;
// Create initial state if history is empty
if (history.states.length === 0) {
this._createInitialState(history, initialData);
}
} else {
console.warn(`Failed to initialize history from backups for ${configName}:`, error);
}
}
}
_createInitialState(history, initialData) {
// Create an initial backup-like state
const initialStateEntry = {
data: null, // Will be loaded on demand like backups
timestamp: Date.now() - 1000, // Slightly in the past
description: 'Initial backup state',
isSavePoint: true,
id: this._generateStateId()
};
// Create current state entry
const currentStateEntry = {
data: initialData,
timestamp: Date.now(),
description: `Current state`,
isSavePoint: true,
id: this._generateStateId()
};
history.states.push(initialStateEntry);
history.states.push(currentStateEntry);
history.currentIndex = 1; // Point to current state
history.savedIndex = 1;
console.log('Created initial history state with current state beyond initial backup');
}
/**
* Associate a backup file with the current state
* @param {string} configName - Name of the config file
* @param {string} backupFilename - Name of the backup file
*/
associateBackup(configName, backupFilename) {
const history = this._getHistoryState(configName);
if (history.currentIndex >= 0) {
history.backupMapping.set(history.currentIndex, backupFilename);
console.log(`Associated backup ${backupFilename} with state ${history.currentIndex} for ${configName}`);
}
}
/**
* Manage memory by removing old states
* @private
*/
_manageMemoryLimit(configName) {
const history = this._getHistoryState(configName);
if (history.states.length > this.maxInMemoryStates) {
const removeCount = history.states.length - this.maxInMemoryStates;
// Remove old states but keep their metadata for backup references
for (let i = 0; i < removeCount; i++) {
const state = history.states[i];
if (state.data) {
// Clear data but keep metadata
state.data = null;
}
}
console.log(`Cleared data for ${removeCount} old states in ${configName} to manage memory`);
}
}
/**
* Load state from backup file
* @private
*/
async _loadFromBackup(configName, backupFilename) {
try {
const response = await this.api.restoreConfig(backupFilename);
return response.data;
} catch (error) {
throw new Error(`Failed to load backup ${backupFilename}: ${error.message}`);
}
}
/**
* Generate unique state ID
* @private
*/
_generateStateId() {
return `state_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* Get history statistics for debugging
* @param {string} configName - Name of the config file
* @returns {Object}
*/
getHistoryStats(configName) {
const history = this._getHistoryState(configName);
return {
totalStates: history.states.length,
currentIndex: history.currentIndex,
savedIndex: history.savedIndex,
canUndo: this.canUndo(configName),
canRedo: this.canRedo(configName),
hasUnsavedChanges: this.hasUnsavedChanges(configName),
memoryStates: history.states.filter(s => s.data !== null).length,
backupStates: history.backupMapping.size
};
}
}