UI and plugin fix
This commit is contained in:
@@ -1867,6 +1867,64 @@ export class UnifiedAPIServer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clear all metadata and trigger rescan
|
||||||
|
this.app.post('/api/metadata/clear', async (request, reply) => {
|
||||||
|
if (!this.kvClient) {
|
||||||
|
return reply.status(503).send({
|
||||||
|
status: 'error',
|
||||||
|
message: 'KV client not available'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Clearing all metadata via API');
|
||||||
|
|
||||||
|
// Get all file keys and delete them
|
||||||
|
const hashIds = await this.kvClient.getAllHashIds();
|
||||||
|
let deletedCount = 0;
|
||||||
|
|
||||||
|
for (const hashId of hashIds) {
|
||||||
|
try {
|
||||||
|
await this.kvClient.delete(`/file/${hashId}`);
|
||||||
|
deletedCount++;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Failed to delete key /file/${hashId}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also delete the index key
|
||||||
|
try {
|
||||||
|
await this.kvClient.delete('/file/__index__');
|
||||||
|
} catch (err) {
|
||||||
|
// Index might not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Cleared ${deletedCount} file metadata entries`);
|
||||||
|
|
||||||
|
// Trigger rescan if callback is available
|
||||||
|
if (this.triggerScanCallback) {
|
||||||
|
console.log('Triggering rescan after metadata clear');
|
||||||
|
this.triggerScanCallback().catch(error => {
|
||||||
|
console.error('Error during post-clear rescan:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
message: `Cleared ${deletedCount} files`,
|
||||||
|
deletedCount,
|
||||||
|
rescanTriggered: !!this.triggerScanCallback
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error clearing metadata:', error);
|
||||||
|
return reply.status(500).send({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Failed to clear metadata',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2083,7 +2141,7 @@ export class UnifiedAPIServer {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear plugin cache
|
// Clear plugin cache for a specific plugin
|
||||||
this.app.post<{ Params: { pluginId: string } }>('/api/plugins/:pluginId/clear-cache', async (request, reply) => {
|
this.app.post<{ Params: { pluginId: string } }>('/api/plugins/:pluginId/clear-cache', async (request, reply) => {
|
||||||
const pluginManager = this.getPluginManager?.();
|
const pluginManager = this.getPluginManager?.();
|
||||||
if (!pluginManager) {
|
if (!pluginManager) {
|
||||||
@@ -2099,6 +2157,55 @@ export class UnifiedAPIServer {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clear all plugin caches
|
||||||
|
this.app.post('/api/plugins/clear-cache', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const fs = await import('fs/promises');
|
||||||
|
const fsSync = await import('fs');
|
||||||
|
const path = await import('path');
|
||||||
|
|
||||||
|
const cacheDir = path.join(config.CACHE_FOLDER_PATH, 'plugin-cache');
|
||||||
|
|
||||||
|
let deletedFiles = 0;
|
||||||
|
let deletedDirs = 0;
|
||||||
|
|
||||||
|
// Clear plugin cache directory
|
||||||
|
if (fsSync.existsSync(cacheDir)) {
|
||||||
|
const entries = await fs.readdir(cacheDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = path.join(cacheDir, entry.name);
|
||||||
|
try {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await fs.rm(entryPath, { recursive: true, force: true });
|
||||||
|
deletedDirs++;
|
||||||
|
} else {
|
||||||
|
await fs.unlink(entryPath);
|
||||||
|
deletedFiles++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Failed to delete ${entryPath}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Cleared plugin cache: ${deletedDirs} directories, ${deletedFiles} files`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
message: `Cleared plugin cache`,
|
||||||
|
deletedDirs,
|
||||||
|
deletedFiles
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error clearing plugin cache:', error);
|
||||||
|
return reply.status(500).send({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Failed to clear plugin cache',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Rescan plugins (reload from container manager)
|
// Rescan plugins (reload from container manager)
|
||||||
this.app.post('/api/plugins/rescan', async (request, reply) => {
|
this.app.post('/api/plugins/rescan', async (request, reply) => {
|
||||||
const pluginManager = this.getPluginManager?.();
|
const pluginManager = this.getPluginManager?.();
|
||||||
@@ -2378,6 +2485,134 @@ export class UnifiedAPIServer {
|
|||||||
message: 'Use docker logs command directly for container logs'
|
message: 'Use docker logs command directly for container logs'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add new container plugin
|
||||||
|
this.app.post<{
|
||||||
|
Body: {
|
||||||
|
pluginId: string;
|
||||||
|
image: string;
|
||||||
|
instances?: number;
|
||||||
|
resources?: { memory?: string; cpus?: number };
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
network?: boolean;
|
||||||
|
defaultQueue?: 'fast' | 'background';
|
||||||
|
}
|
||||||
|
}>('/api/plugins/containers', async (request, reply) => {
|
||||||
|
if (!this.containerManager) {
|
||||||
|
return reply.status(503).send({ error: 'Container manager not initialized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pluginId, image, instances, resources, config: pluginConfig, network, defaultQueue } = request.body;
|
||||||
|
|
||||||
|
if (!pluginId) {
|
||||||
|
return reply.status(400).send({ error: 'pluginId is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!image) {
|
||||||
|
return reply.status(400).send({ error: 'image is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.containerManager.addPlugin(pluginId, image, {
|
||||||
|
instances,
|
||||||
|
resources,
|
||||||
|
config: pluginConfig,
|
||||||
|
network,
|
||||||
|
defaultQueue,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Plugin '${pluginId}' added successfully`
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: 'Failed to add plugin',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove container plugin
|
||||||
|
this.app.delete<{
|
||||||
|
Params: { pluginId: string }
|
||||||
|
}>('/api/plugins/containers/:pluginId', async (request, reply) => {
|
||||||
|
if (!this.containerManager) {
|
||||||
|
return reply.status(503).send({ error: 'Container manager not initialized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pluginId } = request.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.containerManager.removePlugin(pluginId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Plugin '${pluginId}' removed successfully`
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
return reply.status(500).send({
|
||||||
|
error: 'Failed to remove plugin',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update container plugin
|
||||||
|
this.app.put<{
|
||||||
|
Params: { pluginId: string };
|
||||||
|
Body: {
|
||||||
|
image?: string;
|
||||||
|
instances?: number;
|
||||||
|
resources?: { memory?: string; cpus?: number };
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
network?: boolean;
|
||||||
|
defaultQueue?: 'fast' | 'background';
|
||||||
|
}
|
||||||
|
}>('/api/plugins/containers/:pluginId', async (request, reply) => {
|
||||||
|
if (!this.containerManager) {
|
||||||
|
return reply.status(503).send({ error: 'Container manager not initialized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pluginId } = request.params;
|
||||||
|
const updates = request.body;
|
||||||
|
|
||||||
|
if (!updates || Object.keys(updates).length === 0) {
|
||||||
|
return reply.status(400).send({ error: 'No updates provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.containerManager.updatePluginConfig(pluginId, updates);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Plugin '${pluginId}' updated successfully`
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
return reply.status(500).send({
|
||||||
|
error: 'Failed to update plugin',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restart all container plugins
|
||||||
|
this.app.post('/api/plugins/containers/restart-all', async (request, reply) => {
|
||||||
|
if (!this.containerManager) {
|
||||||
|
return reply.status(503).send({ error: 'Container manager not initialized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.containerManager.restartAllPlugins();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'All plugins restarted successfully'
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
return reply.status(500).send({
|
||||||
|
error: 'Failed to restart plugins',
|
||||||
|
details: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -115,6 +115,17 @@ interface EnvConfig {
|
|||||||
|
|
||||||
/** Docker Compose project name for grouping containers in Docker Desktop (optional). When set, plugin containers appear grouped as a stack. */
|
/** Docker Compose project name for grouping containers in Docker Desktop (optional). When set, plugin containers appear grouped as a stack. */
|
||||||
PLUGIN_STACK_NAME?: string;
|
PLUGIN_STACK_NAME?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host path for plugin cache folders.
|
||||||
|
* This is the actual host filesystem path that plugin containers will mount.
|
||||||
|
* Required for persistent plugin caches across container restarts.
|
||||||
|
* Example: "/d/workspace/MetaMesh/meta-root-v2/dev/cache/meta-sort/plugin-cache"
|
||||||
|
*/
|
||||||
|
PLUGIN_CACHE_HOST_PATH?: string;
|
||||||
|
|
||||||
|
// NOTE: Plugin output files are written via WebDAV (WEBDAV_URL/plugin/<pluginId>/)
|
||||||
|
// No PLUGIN_OUTPUT_HOST_PATH needed - plugins use HTTP PUT to write files
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse POLLING_PATHS from JSON string
|
// Parse POLLING_PATHS from JSON string
|
||||||
@@ -151,15 +162,15 @@ function parsePollingPaths(): PollingPathConfig[] | undefined {
|
|||||||
|
|
||||||
export const config: EnvConfig = {
|
export const config: EnvConfig = {
|
||||||
WATCH_FOLDER_LIST: process.env.WATCH_FOLDER_LIST,
|
WATCH_FOLDER_LIST: process.env.WATCH_FOLDER_LIST,
|
||||||
INDEX_FOLDER_PATH: process.env.INDEX_FOLDER_PATH || '/data/cache/hash',//default /data/cache/hash
|
INDEX_FOLDER_PATH: process.env.INDEX_FOLDER_PATH || '/data/cache/hash-index',//default /data/cache/hash-index
|
||||||
UPDATE_INTERVAL_MS: parseInt(process.env.UPDATE_INTERVAL_MS || "30000", 10),//default 30000
|
UPDATE_INTERVAL_MS: parseInt(process.env.UPDATE_INTERVAL_MS || "30000", 10),//default 30000
|
||||||
WATCH_FOLDER_POOLING_INTERVAL_MS: parseInt(process.env.WATCH_FOLDER_POOLING_INTERVAL_MS || "0", 10),//default 0
|
WATCH_FOLDER_POOLING_INTERVAL_MS: parseInt(process.env.WATCH_FOLDER_POOLING_INTERVAL_MS || "0", 10),//default 0
|
||||||
CHMOD_FOR_NEW_FOLDER: process.env.CHMOD_FOR_NEW_FOLDER && parseInt(process.env.CHMOD_FOR_NEW_FOLDER, 8),//default 0o777 (this is the default for mkdir)
|
CHMOD_FOR_NEW_FOLDER: process.env.CHMOD_FOR_NEW_FOLDER && parseInt(process.env.CHMOD_FOR_NEW_FOLDER, 8),//default 0o777 (this is the default for mkdir)
|
||||||
CACHE_FOLDER_PATH: process.env.CACHE_FOLDER_PATH || '/data/cache/plugins',//default /data/cache/plugins
|
CACHE_FOLDER_PATH: process.env.CACHE_FOLDER_PATH || '/data/cache',//default /data/cache
|
||||||
JELLYFIN_ENDPOINT: process.env.JELLYFIN_ENDPOINT!,
|
JELLYFIN_ENDPOINT: process.env.JELLYFIN_ENDPOINT!,
|
||||||
JELLYFIN_API_KEY: process.env.JELLYFIN_API_KEY!,
|
JELLYFIN_API_KEY: process.env.JELLYFIN_API_KEY!,
|
||||||
FUSE_API_PORT: parseInt(process.env.FUSE_API_PORT || "3000", 10),//default 3000
|
FUSE_API_PORT: parseInt(process.env.FUSE_API_PORT || "3000", 10),//default 3000
|
||||||
FUSE_API_HOST: process.env.FUSE_API_HOST || 'localhost',//default localhost
|
FUSE_API_HOST: process.env.FUSE_API_HOST || '0.0.0.0',//default 0.0.0.0
|
||||||
METADATA_FORMATS: (process.env.METADATA_FORMATS || 'meta').split(',').map(f => f.trim()).filter(f => f.length > 0),//default 'meta'
|
METADATA_FORMATS: (process.env.METADATA_FORMATS || 'meta').split(',').map(f => f.trim()).filter(f => f.length > 0),//default 'meta'
|
||||||
MAX_WORKER_THREADS: process.env.MAX_WORKER_THREADS ? parseInt(process.env.MAX_WORKER_THREADS, 10) : undefined,
|
MAX_WORKER_THREADS: process.env.MAX_WORKER_THREADS ? parseInt(process.env.MAX_WORKER_THREADS, 10) : undefined,
|
||||||
POLLING_PATHS: parsePollingPaths(),
|
POLLING_PATHS: parsePollingPaths(),
|
||||||
@@ -186,6 +197,7 @@ export const config: EnvConfig = {
|
|||||||
CONTAINER_NETWORK: process.env.CONTAINER_NETWORK || 'meta-network',
|
CONTAINER_NETWORK: process.env.CONTAINER_NETWORK || 'meta-network',
|
||||||
PLUGIN_WEBDAV_URL: process.env.PLUGIN_WEBDAV_URL,
|
PLUGIN_WEBDAV_URL: process.env.PLUGIN_WEBDAV_URL,
|
||||||
PLUGIN_STACK_NAME: process.env.PLUGIN_STACK_NAME,
|
PLUGIN_STACK_NAME: process.env.PLUGIN_STACK_NAME,
|
||||||
|
PLUGIN_CACHE_HOST_PATH: process.env.PLUGIN_CACHE_HOST_PATH,
|
||||||
};
|
};
|
||||||
|
|
||||||
if(process.env.TEST!=='true') {
|
if(process.env.TEST!=='true') {
|
||||||
|
|||||||
@@ -370,3 +370,75 @@ export function getEnabledPlugins(
|
|||||||
export function getDefaultConfigPath(): string {
|
export function getDefaultConfigPath(): string {
|
||||||
return process.env.CONTAINER_PLUGINS_CONFIG || '/app/plugins.yml';
|
return process.env.CONTAINER_PLUGINS_CONFIG || '/app/plugins.yml';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert ContainerPluginConfig back to YAML-friendly format
|
||||||
|
*/
|
||||||
|
function configToYamlObject(config: ContainerPluginConfig): Record<string, unknown> {
|
||||||
|
const obj: Record<string, unknown> = {
|
||||||
|
enabled: config.enabled,
|
||||||
|
image: config.image,
|
||||||
|
instances: config.instances,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.resources) {
|
||||||
|
obj.resources = config.resources;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.config && Object.keys(config.config).length > 0) {
|
||||||
|
obj.config = config.config;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.network) {
|
||||||
|
obj.network = config.network;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.mounts && config.mounts.length > 0) {
|
||||||
|
obj.mounts = config.mounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.healthCheck) {
|
||||||
|
obj.healthCheck = config.healthCheck;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.defaultQueue) {
|
||||||
|
obj.defaultQueue = config.defaultQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save container plugins configuration to YAML file
|
||||||
|
*/
|
||||||
|
export async function saveConfig(
|
||||||
|
configPath: string,
|
||||||
|
config: ContainerPluginsConfig
|
||||||
|
): Promise<void> {
|
||||||
|
// Convert config to YAML-friendly format
|
||||||
|
const yamlConfig: Record<string, unknown> = {
|
||||||
|
version: config.version || '1.0',
|
||||||
|
plugins: {} as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [pluginId, pluginConfig] of Object.entries(config.plugins)) {
|
||||||
|
(yamlConfig.plugins as Record<string, unknown>)[pluginId] = configToYamlObject(pluginConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stringify to YAML
|
||||||
|
const yamlContent = YAML.stringify(yamlConfig, {
|
||||||
|
indent: 2,
|
||||||
|
lineWidth: 0, // Disable line wrapping
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write to file
|
||||||
|
try {
|
||||||
|
await fs.writeFile(configPath, yamlContent, 'utf-8');
|
||||||
|
} catch (error) {
|
||||||
|
throw new ConfigParseError(
|
||||||
|
'Failed to write configuration file',
|
||||||
|
configPath,
|
||||||
|
error instanceof Error ? error : undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
import { DockerClient } from './DockerClient.js';
|
import { DockerClient } from './DockerClient.js';
|
||||||
import {
|
import {
|
||||||
loadConfig,
|
loadConfig,
|
||||||
getEnabledPlugins,
|
getEnabledPlugins,
|
||||||
|
saveConfig,
|
||||||
} from './ConfigParser.js';
|
} from './ConfigParser.js';
|
||||||
import type {
|
import type {
|
||||||
ContainerPluginsConfig,
|
ContainerPluginsConfig,
|
||||||
@@ -298,9 +300,41 @@ export class ContainerManager extends EventEmitter {
|
|||||||
* Sees all mounts including SMB via rclone
|
* Sees all mounts including SMB via rclone
|
||||||
*
|
*
|
||||||
* Note: No volume mounts for /files - plugins use HTTP/WebDAV client
|
* Note: No volume mounts for /files - plugins use HTTP/WebDAV client
|
||||||
|
* Cache: Each plugin gets a dedicated cache folder mounted at /cache
|
||||||
*/
|
*/
|
||||||
const mounts: Array<{ source: string; target: string; readonly: boolean }> = [];
|
const mounts: Array<{ source: string; target: string; readonly: boolean }> = [];
|
||||||
|
|
||||||
|
// Add cache mount for plugin persistence (survives container restarts)
|
||||||
|
// Use PLUGIN_CACHE_HOST_PATH if set (host filesystem path for Docker bind mounts)
|
||||||
|
// Otherwise fall back to internal path (only works if container shares filesystem)
|
||||||
|
if (config.PLUGIN_CACHE_HOST_PATH) {
|
||||||
|
const pluginCacheDir = `${config.PLUGIN_CACHE_HOST_PATH}/${pluginId}`;
|
||||||
|
// Create directory on internal path (which maps to host path)
|
||||||
|
const internalCacheDir = `${config.CACHE_FOLDER_PATH}/plugin-cache/${pluginId}`;
|
||||||
|
await fs.mkdir(internalCacheDir, { recursive: true });
|
||||||
|
mounts.push({
|
||||||
|
source: pluginCacheDir,
|
||||||
|
target: '/cache',
|
||||||
|
readonly: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn(`[ContainerManager] PLUGIN_CACHE_HOST_PATH not set. Plugin '${pluginId}' cache will not persist across restarts.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Plugin output files are written via WebDAV (WEBDAV_URL/plugin/<pluginId>/)
|
||||||
|
// No output mount needed - plugins use HTTP PUT to write files
|
||||||
|
|
||||||
|
// Add any additional mounts from plugin config
|
||||||
|
if (pluginConfig.mounts) {
|
||||||
|
for (const mount of pluginConfig.mounts) {
|
||||||
|
mounts.push({
|
||||||
|
source: mount.source,
|
||||||
|
target: mount.target,
|
||||||
|
readonly: mount.readonly ?? true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// No file mounts needed - plugins access files via WebDAV
|
// No file mounts needed - plugins access files via WebDAV
|
||||||
if (!this.webdavUrl) {
|
if (!this.webdavUrl) {
|
||||||
console.warn(`[ContainerManager] WARNING: WebDAV not configured. Plugin '${pluginId}' won't have access to files. Set PLUGIN_WEBDAV_URL.`);
|
console.warn(`[ContainerManager] WARNING: WebDAV not configured. Plugin '${pluginId}' won't have access to files. Set PLUGIN_WEBDAV_URL.`);
|
||||||
@@ -687,6 +721,199 @@ export class ContainerManager extends EventEmitter {
|
|||||||
instance.tasksFailed++;
|
instance.tasksFailed++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new plugin dynamically
|
||||||
|
*/
|
||||||
|
async addPlugin(
|
||||||
|
pluginId: string,
|
||||||
|
image: string,
|
||||||
|
pluginConfig?: Partial<ContainerPluginConfig>
|
||||||
|
): Promise<void> {
|
||||||
|
// Validate plugin ID format
|
||||||
|
if (!/^[a-z][a-z0-9-]*$/.test(pluginId)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid plugin ID '${pluginId}': must be lowercase alphanumeric with hyphens, starting with a letter`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if plugin already exists
|
||||||
|
if (this.instances.has(pluginId)) {
|
||||||
|
throw new Error(`Plugin '${pluginId}' already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[ContainerManager] Adding new plugin '${pluginId}' with image '${image}'`);
|
||||||
|
|
||||||
|
// Build full plugin config
|
||||||
|
const fullConfig: ContainerPluginConfig = {
|
||||||
|
enabled: pluginConfig?.enabled !== false,
|
||||||
|
image,
|
||||||
|
instances: pluginConfig?.instances ?? 1,
|
||||||
|
resources: pluginConfig?.resources,
|
||||||
|
config: pluginConfig?.config,
|
||||||
|
network: pluginConfig?.network ?? false,
|
||||||
|
mounts: pluginConfig?.mounts,
|
||||||
|
healthCheck: pluginConfig?.healthCheck,
|
||||||
|
defaultQueue: pluginConfig?.defaultQueue,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update in-memory config
|
||||||
|
if (!this.pluginsConfig) {
|
||||||
|
this.pluginsConfig = { version: '1.0', plugins: {} };
|
||||||
|
}
|
||||||
|
this.pluginsConfig.plugins[pluginId] = fullConfig;
|
||||||
|
|
||||||
|
// Save to YAML file
|
||||||
|
await saveConfig(this.configPath, this.pluginsConfig);
|
||||||
|
|
||||||
|
// Spawn the plugin containers
|
||||||
|
if (fullConfig.enabled) {
|
||||||
|
await this.spawnPlugin(pluginId, fullConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[ContainerManager] Plugin '${pluginId}' added successfully`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a plugin
|
||||||
|
*/
|
||||||
|
async removePlugin(pluginId: string): Promise<void> {
|
||||||
|
console.log(`[ContainerManager] Removing plugin '${pluginId}'...`);
|
||||||
|
|
||||||
|
const instances = this.instances.get(pluginId);
|
||||||
|
|
||||||
|
if (instances) {
|
||||||
|
// Stop health check
|
||||||
|
const interval = this.healthCheckIntervals.get(pluginId);
|
||||||
|
if (interval) {
|
||||||
|
clearInterval(interval);
|
||||||
|
this.healthCheckIntervals.delete(pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop all instances
|
||||||
|
for (const instance of instances) {
|
||||||
|
await this.stopInstance(instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.instances.delete(pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from config
|
||||||
|
if (this.pluginsConfig && this.pluginsConfig.plugins[pluginId]) {
|
||||||
|
delete this.pluginsConfig.plugins[pluginId];
|
||||||
|
|
||||||
|
// Save to YAML file
|
||||||
|
await saveConfig(this.configPath, this.pluginsConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset round robin counter
|
||||||
|
this.roundRobinCounters.delete(pluginId);
|
||||||
|
|
||||||
|
console.log(`[ContainerManager] Plugin '${pluginId}' removed successfully`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restart all plugins
|
||||||
|
*/
|
||||||
|
async restartAllPlugins(): Promise<void> {
|
||||||
|
console.log('[ContainerManager] Restarting all plugins...');
|
||||||
|
|
||||||
|
const pluginIds = Array.from(this.instances.keys());
|
||||||
|
|
||||||
|
for (const pluginId of pluginIds) {
|
||||||
|
try {
|
||||||
|
await this.restartPlugin(pluginId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[ContainerManager] Failed to restart plugin '${pluginId}':`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[ContainerManager] All plugins restarted');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update plugin image and restart
|
||||||
|
*/
|
||||||
|
async updatePluginImage(pluginId: string, newImage: string): Promise<void> {
|
||||||
|
console.log(`[ContainerManager] Updating plugin '${pluginId}' to image '${newImage}'...`);
|
||||||
|
|
||||||
|
const pluginConfig = this.pluginsConfig?.plugins[pluginId];
|
||||||
|
|
||||||
|
if (!pluginConfig) {
|
||||||
|
throw new Error(`Plugin '${pluginId}' not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update image in config
|
||||||
|
pluginConfig.image = newImage;
|
||||||
|
|
||||||
|
// Save to YAML file
|
||||||
|
if (this.pluginsConfig) {
|
||||||
|
await saveConfig(this.configPath, this.pluginsConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart the plugin to use the new image
|
||||||
|
await this.restartPlugin(pluginId);
|
||||||
|
|
||||||
|
console.log(`[ContainerManager] Plugin '${pluginId}' updated successfully`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update plugin configuration
|
||||||
|
*/
|
||||||
|
async updatePluginConfig(
|
||||||
|
pluginId: string,
|
||||||
|
updates: Partial<ContainerPluginConfig>
|
||||||
|
): Promise<void> {
|
||||||
|
console.log(`[ContainerManager] Updating configuration for plugin '${pluginId}'...`);
|
||||||
|
|
||||||
|
const pluginConfig = this.pluginsConfig?.plugins[pluginId];
|
||||||
|
|
||||||
|
if (!pluginConfig) {
|
||||||
|
throw new Error(`Plugin '${pluginId}' not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply updates
|
||||||
|
if (updates.image !== undefined) {
|
||||||
|
pluginConfig.image = updates.image;
|
||||||
|
}
|
||||||
|
if (updates.instances !== undefined) {
|
||||||
|
pluginConfig.instances = updates.instances;
|
||||||
|
}
|
||||||
|
if (updates.resources !== undefined) {
|
||||||
|
pluginConfig.resources = updates.resources;
|
||||||
|
}
|
||||||
|
if (updates.config !== undefined) {
|
||||||
|
pluginConfig.config = updates.config;
|
||||||
|
}
|
||||||
|
if (updates.enabled !== undefined) {
|
||||||
|
pluginConfig.enabled = updates.enabled;
|
||||||
|
}
|
||||||
|
if (updates.network !== undefined) {
|
||||||
|
pluginConfig.network = updates.network;
|
||||||
|
}
|
||||||
|
if (updates.defaultQueue !== undefined) {
|
||||||
|
pluginConfig.defaultQueue = updates.defaultQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to YAML file
|
||||||
|
if (this.pluginsConfig) {
|
||||||
|
await saveConfig(this.configPath, this.pluginsConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart the plugin to apply changes
|
||||||
|
if (this.instances.has(pluginId)) {
|
||||||
|
await this.restartPlugin(pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[ContainerManager] Plugin '${pluginId}' configuration updated`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current plugins configuration
|
||||||
|
*/
|
||||||
|
getPluginsConfig(): ContainerPluginsConfig | null {
|
||||||
|
return this.pluginsConfig;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -329,6 +329,14 @@ export class WatchedFileProcessor implements FileProcessorInterface {
|
|||||||
midHash256 = await computeMidHash256(filePath);
|
midHash256 = await computeMidHash256(filePath);
|
||||||
performanceMetrics.recordHashComputation('cid_midhash256', Math.ceil(performance.now() - computeStart));
|
performanceMetrics.recordHashComputation('cid_midhash256', Math.ceil(performance.now() - computeStart));
|
||||||
performanceMetrics.recordCacheMiss('midhash256');
|
performanceMetrics.recordCacheMiss('midhash256');
|
||||||
|
|
||||||
|
// Store in cache for future lookups
|
||||||
|
(this.fileProcessor as any).indexManager?.addFileCid(
|
||||||
|
filePath,
|
||||||
|
fileStats.size,
|
||||||
|
fileStats.mtime.toISOString(),
|
||||||
|
{ cid_midhash256: midHash256 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// STEP 2: Initialize metadata with midhash256
|
// STEP 2: Initialize metadata with midhash256
|
||||||
|
|||||||
@@ -181,6 +181,9 @@ export class FileProcessorPiscina implements FileAnalyzerInterface{
|
|||||||
// Record midhash256 computation time (excludes cache hits)
|
// Record midhash256 computation time (excludes cache hits)
|
||||||
performanceMetrics.recordHashComputation('cid_midhash256', computeTime);
|
performanceMetrics.recordHashComputation('cid_midhash256', computeTime);
|
||||||
performanceMetrics.recordCacheMiss('midhash256');
|
performanceMetrics.recordCacheMiss('midhash256');
|
||||||
|
|
||||||
|
// Store in cache for future lookups
|
||||||
|
this.indexManager.addFileCid(filePath, stats.size, stats.mtime.toISOString(), { cid_midhash256: midHash256 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize metadata with midhash256
|
// Initialize metadata with midhash256
|
||||||
@@ -358,6 +361,9 @@ export class FileProcessorPiscina implements FileAnalyzerInterface{
|
|||||||
// Record midhash256 computation time (excludes cache hits)
|
// Record midhash256 computation time (excludes cache hits)
|
||||||
performanceMetrics.recordHashComputation('cid_midhash256', computeTime);
|
performanceMetrics.recordHashComputation('cid_midhash256', computeTime);
|
||||||
performanceMetrics.recordCacheMiss('midhash256');
|
performanceMetrics.recordCacheMiss('midhash256');
|
||||||
|
|
||||||
|
// Store in cache for future lookups
|
||||||
|
this.indexManager.addFileCid(filePath, stats.size, stats.mtime.toISOString(), { cid_midhash256: midHash256 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize metadata with midhash256
|
// Initialize metadata with midhash256
|
||||||
|
|||||||
@@ -169,6 +169,19 @@ export class PluginManager extends EventEmitter {
|
|||||||
// Save state
|
// Save state
|
||||||
await this.saveState();
|
await this.saveState();
|
||||||
|
|
||||||
|
// Send config to running container plugin
|
||||||
|
if (this.containerManager) {
|
||||||
|
const instance = this.containerManager.getHealthyInstance(pluginId);
|
||||||
|
if (instance) {
|
||||||
|
try {
|
||||||
|
await this.containerManager.sendConfig(instance, config);
|
||||||
|
console.log(`[PluginManager] Sent config update to container plugin '${pluginId}'`);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[PluginManager] Failed to send config to plugin '${pluginId}':`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Emit event
|
// Emit event
|
||||||
this.emit('plugin:config-changed', { pluginId, config });
|
this.emit('plugin:config-changed', { pluginId, config });
|
||||||
}
|
}
|
||||||
@@ -210,6 +223,9 @@ export class PluginManager extends EventEmitter {
|
|||||||
// Load state for plugin active status
|
// Load state for plugin active status
|
||||||
const state = await this.loadState();
|
const state = await this.loadState();
|
||||||
|
|
||||||
|
// Track plugins that need config sent
|
||||||
|
const pluginsWithPersistedConfig: Array<{ pluginId: string; config: Record<string, unknown> }> = [];
|
||||||
|
|
||||||
for (const pluginStatus of status.plugins) {
|
for (const pluginStatus of status.plugins) {
|
||||||
const manifest = this.containerManager.getPluginManifest(pluginStatus.pluginId);
|
const manifest = this.containerManager.getPluginManifest(pluginStatus.pluginId);
|
||||||
if (!manifest) continue;
|
if (!manifest) continue;
|
||||||
@@ -248,9 +264,14 @@ export class PluginManager extends EventEmitter {
|
|||||||
this.activePluginIds.add(pluginId);
|
this.activePluginIds.add(pluginId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store config
|
// Store config from persisted state
|
||||||
const config = stateEntry?.config || {};
|
const persistedConfig = stateEntry?.config || {};
|
||||||
this.configs.set(pluginId, config);
|
this.configs.set(pluginId, persistedConfig);
|
||||||
|
|
||||||
|
// If there's persisted config, queue it for sending to the container
|
||||||
|
if (Object.keys(persistedConfig).length > 0) {
|
||||||
|
pluginsWithPersistedConfig.push({ pluginId, config: persistedConfig });
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`[PluginManager] Loaded plugin: ${pluginId}`);
|
console.log(`[PluginManager] Loaded plugin: ${pluginId}`);
|
||||||
}
|
}
|
||||||
@@ -258,6 +279,20 @@ export class PluginManager extends EventEmitter {
|
|||||||
// Rebuild execution order
|
// Rebuild execution order
|
||||||
this.rebuildExecutionOrder();
|
this.rebuildExecutionOrder();
|
||||||
await this.saveState();
|
await this.saveState();
|
||||||
|
|
||||||
|
// Send persisted configs to running containers
|
||||||
|
// This ensures configs saved via API are applied after container restart
|
||||||
|
for (const { pluginId, config } of pluginsWithPersistedConfig) {
|
||||||
|
const instance = this.containerManager.getHealthyInstance(pluginId);
|
||||||
|
if (instance) {
|
||||||
|
try {
|
||||||
|
await this.containerManager.sendConfig(instance, config);
|
||||||
|
console.log(`[PluginManager] Sent persisted config to plugin '${pluginId}'`);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[PluginManager] Failed to send persisted config to plugin '${pluginId}':`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -103,6 +103,25 @@ function Monitor() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearMetadata = async () => {
|
||||||
|
if (!window.confirm('Are you sure you want to clear all metadata? This will delete all file metadata and trigger a full rescan.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/metadata/clear', { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
alert(`Cleared ${data.deletedCount} files. Rescan triggered.`);
|
||||||
|
} else {
|
||||||
|
alert(`Failed to clear metadata: ${data.message}`);
|
||||||
|
}
|
||||||
|
fetchData();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to clear metadata:', err);
|
||||||
|
alert('Failed to clear metadata');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Calculate progress for 6-stage pipeline (including failed)
|
// Calculate progress for 6-stage pipeline (including failed)
|
||||||
// Discovered = files waiting for fast queue (validated but not yet processing)
|
// Discovered = files waiting for fast queue (validated but not yet processing)
|
||||||
const getProgress = () => {
|
const getProgress = () => {
|
||||||
@@ -187,6 +206,9 @@ function Monitor() {
|
|||||||
<button className="btn btn-primary" onClick={triggerScan}>
|
<button className="btn btn-primary" onClick={triggerScan}>
|
||||||
Trigger Scan
|
Trigger Scan
|
||||||
</button>
|
</button>
|
||||||
|
<button className="btn btn-warning" onClick={clearMetadata}>
|
||||||
|
Clear Metadata
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ import {
|
|||||||
} from '../types';
|
} from '../types';
|
||||||
import { formatMs, formatNumber } from '../utils/format';
|
import { formatMs, formatNumber } from '../utils/format';
|
||||||
|
|
||||||
|
interface AddPluginForm {
|
||||||
|
pluginId: string;
|
||||||
|
image: string;
|
||||||
|
instances: number;
|
||||||
|
memory: string;
|
||||||
|
cpus: string;
|
||||||
|
}
|
||||||
|
|
||||||
function Plugins() {
|
function Plugins() {
|
||||||
const [pluginsData, setPluginsData] = useState<PluginsResponse | null>(null);
|
const [pluginsData, setPluginsData] = useState<PluginsResponse | null>(null);
|
||||||
const [timings, setTimings] = useState<PluginTiming[]>([]);
|
const [timings, setTimings] = useState<PluginTiming[]>([]);
|
||||||
@@ -15,6 +23,18 @@ function Plugins() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [scanStatus, setScanStatus] = useState<string | null>(null);
|
const [scanStatus, setScanStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Container plugin management state
|
||||||
|
const [showAddPluginModal, setShowAddPluginModal] = useState(false);
|
||||||
|
const [showRemoveConfirm, setShowRemoveConfirm] = useState<string | null>(null);
|
||||||
|
const [addPluginForm, setAddPluginForm] = useState<AddPluginForm>({
|
||||||
|
pluginId: '',
|
||||||
|
image: '',
|
||||||
|
instances: 1,
|
||||||
|
memory: '256m',
|
||||||
|
cpus: '0.5',
|
||||||
|
});
|
||||||
|
const [restartAllStatus, setRestartAllStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchPlugins = useCallback(async () => {
|
const fetchPlugins = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/plugins');
|
const res = await fetch('/api/plugins');
|
||||||
@@ -164,6 +184,107 @@ function Plugins() {
|
|||||||
return timings.find(t => t.pluginId === pluginId);
|
return timings.find(t => t.pluginId === pluginId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Container plugin management functions
|
||||||
|
const restartPlugin = async (pluginId: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/plugins/containers/${pluginId}/restart`, { method: 'POST' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.details || data.error || 'Failed to restart plugin');
|
||||||
|
}
|
||||||
|
await fetchPlugins();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const restartAllPlugins = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setRestartAllStatus('Restarting...');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/plugins/containers/restart-all', { method: 'POST' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.details || data.error || 'Failed to restart plugins');
|
||||||
|
}
|
||||||
|
setRestartAllStatus('Restarted');
|
||||||
|
setTimeout(() => setRestartAllStatus(null), 3000);
|
||||||
|
await fetchPlugins();
|
||||||
|
} catch (err: any) {
|
||||||
|
setRestartAllStatus('Failed');
|
||||||
|
alert(err.message);
|
||||||
|
setTimeout(() => setRestartAllStatus(null), 3000);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPlugin = async () => {
|
||||||
|
if (!addPluginForm.pluginId || !addPluginForm.image) {
|
||||||
|
alert('Plugin ID and Image are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/plugins/containers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
pluginId: addPluginForm.pluginId,
|
||||||
|
image: addPluginForm.image,
|
||||||
|
instances: addPluginForm.instances,
|
||||||
|
resources: {
|
||||||
|
memory: addPluginForm.memory,
|
||||||
|
cpus: parseFloat(addPluginForm.cpus) || 0.5,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.details || data.error || 'Failed to add plugin');
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowAddPluginModal(false);
|
||||||
|
setAddPluginForm({
|
||||||
|
pluginId: '',
|
||||||
|
image: '',
|
||||||
|
instances: 1,
|
||||||
|
memory: '256m',
|
||||||
|
cpus: '0.5',
|
||||||
|
});
|
||||||
|
await fetchPlugins();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePlugin = async (pluginId: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/plugins/containers/${pluginId}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.details || data.error || 'Failed to remove plugin');
|
||||||
|
}
|
||||||
|
setShowRemoveConfirm(null);
|
||||||
|
if (selectedPlugin === pluginId) {
|
||||||
|
setSelectedPlugin(null);
|
||||||
|
}
|
||||||
|
await fetchPlugins();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const selectedPluginData = pluginsData?.plugins.find(p => p.id === selectedPlugin);
|
const selectedPluginData = pluginsData?.plugins.find(p => p.id === selectedPlugin);
|
||||||
|
|
||||||
// Group plugins by queue assignment
|
// Group plugins by queue assignment
|
||||||
@@ -202,6 +323,20 @@ function Plugins() {
|
|||||||
<div className="plugins-header">
|
<div className="plugins-header">
|
||||||
<h1>Plugins</h1>
|
<h1>Plugins</h1>
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setShowAddPluginModal(true)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Add Plugin
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={restartAllPlugins}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{restartAllStatus || 'Restart All'}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={rescanPlugins}
|
onClick={rescanPlugins}
|
||||||
@@ -483,6 +618,14 @@ function Plugins() {
|
|||||||
<div className="details-section">
|
<div className="details-section">
|
||||||
<h3>Actions</h3>
|
<h3>Actions</h3>
|
||||||
<div className="action-buttons">
|
<div className="action-buttons">
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => restartPlugin(selectedPluginData.id)}
|
||||||
|
disabled={loading}
|
||||||
|
title="Restart this plugin container"
|
||||||
|
>
|
||||||
|
Restart
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => clearCache(selectedPluginData.id)}
|
onClick={() => clearCache(selectedPluginData.id)}
|
||||||
@@ -498,6 +641,14 @@ function Plugins() {
|
|||||||
>
|
>
|
||||||
{recomputeStatus || 'Recompute All Files'}
|
{recomputeStatus || 'Recompute All Files'}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={() => setShowRemoveConfirm(selectedPluginData.id)}
|
||||||
|
disabled={loading}
|
||||||
|
title="Remove this plugin"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="action-hint">
|
<p className="action-hint">
|
||||||
Recompute will re-run this plugin on all files, bypassing cache checks.
|
Recompute will re-run this plugin on all files, bypassing cache checks.
|
||||||
@@ -622,6 +773,115 @@ function Plugins() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Add Plugin Modal */}
|
||||||
|
{showAddPluginModal && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowAddPluginModal(false)}>
|
||||||
|
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>Add Container Plugin</h2>
|
||||||
|
<button className="modal-close" onClick={() => setShowAddPluginModal(false)}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="pluginId">Plugin ID *</label>
|
||||||
|
<input
|
||||||
|
id="pluginId"
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="my-plugin"
|
||||||
|
value={addPluginForm.pluginId}
|
||||||
|
onChange={e => setAddPluginForm({ ...addPluginForm, pluginId: e.target.value })}
|
||||||
|
/>
|
||||||
|
<span className="form-hint">Lowercase alphanumeric with hyphens (e.g., my-plugin)</span>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="image">Docker Image *</label>
|
||||||
|
<input
|
||||||
|
id="image"
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="my-plugin:latest"
|
||||||
|
value={addPluginForm.image}
|
||||||
|
onChange={e => setAddPluginForm({ ...addPluginForm, image: e.target.value })}
|
||||||
|
/>
|
||||||
|
<span className="form-hint">Docker image name and tag</span>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="instances">Instances</label>
|
||||||
|
<input
|
||||||
|
id="instances"
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
min="1"
|
||||||
|
value={addPluginForm.instances}
|
||||||
|
onChange={e => setAddPluginForm({ ...addPluginForm, instances: parseInt(e.target.value) || 1 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="memory">Memory</label>
|
||||||
|
<input
|
||||||
|
id="memory"
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="256m"
|
||||||
|
value={addPluginForm.memory}
|
||||||
|
onChange={e => setAddPluginForm({ ...addPluginForm, memory: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="cpus">CPUs</label>
|
||||||
|
<input
|
||||||
|
id="cpus"
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="0.5"
|
||||||
|
value={addPluginForm.cpus}
|
||||||
|
onChange={e => setAddPluginForm({ ...addPluginForm, cpus: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="btn btn-secondary" onClick={() => setShowAddPluginModal(false)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" onClick={addPlugin} disabled={loading}>
|
||||||
|
{loading ? 'Adding...' : 'Add Plugin'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Remove Confirmation Dialog */}
|
||||||
|
{showRemoveConfirm && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowRemoveConfirm(null)}>
|
||||||
|
<div className="modal modal-confirm" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h2>Remove Plugin</h2>
|
||||||
|
<button className="modal-close" onClick={() => setShowRemoveConfirm(null)}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<p className="confirm-message">
|
||||||
|
Are you sure you want to remove plugin <strong>'{showRemoveConfirm}'</strong>?
|
||||||
|
</p>
|
||||||
|
<p className="confirm-warning">
|
||||||
|
This will stop the container and remove it from configuration.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="btn btn-secondary" onClick={() => setShowRemoveConfirm(null)}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-danger" onClick={() => removePlugin(showRemoveConfirm)} disabled={loading}>
|
||||||
|
{loading ? 'Removing...' : 'Remove'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
.plugins-header {
|
.plugins-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1253,6 +1513,149 @@ function Plugins() {
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Modal Styles */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 12px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-confirm {
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: calc(90vh - 140px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Styles */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-hint {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Confirm Dialog */
|
||||||
|
.confirm-message {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-warning {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Danger Button */
|
||||||
|
.btn-danger {
|
||||||
|
background: #dc2626;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:disabled {
|
||||||
|
background: #7f1d1d;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -178,8 +178,10 @@ export class HashIndexManager {
|
|||||||
|
|
||||||
public async saveCacheToFile(): Promise<void> {
|
public async saveCacheToFile(): Promise<void> {
|
||||||
if (this.indexOpsInProgress || !this.hasChanged) {
|
if (this.indexOpsInProgress || !this.hasChanged) {
|
||||||
|
console.log(`[HashIndexManager] saveCacheToFile: Skipping (indexOpsInProgress=${this.indexOpsInProgress}, hasChanged=${this.hasChanged}, cacheSize=${this.cache.size})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
console.log(`[HashIndexManager] saveCacheToFile: Starting save, cacheSize=${this.cache.size}`);
|
||||||
this.hasChanged = false;
|
this.hasChanged = false;
|
||||||
this.indexOpsInProgress = true;
|
this.indexOpsInProgress = true;
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
@@ -276,26 +278,28 @@ export class HashIndexManager {
|
|||||||
if (!filePath || !fileSize || !mtime || !hashs) {
|
if (!filePath || !fileSize || !mtime || !hashs) {
|
||||||
throw new Error('Invalid parameters');
|
throw new Error('Invalid parameters');
|
||||||
}
|
}
|
||||||
for (const hash of this.targetHash) {
|
// Check that at least one target hash is provided
|
||||||
if (!hashs[hash]) {
|
const providedHashes = this.targetHash.filter(hash => hashs[hash]);
|
||||||
throw new Error(`Missing hash ${hash}`);
|
if (providedHashes.length === 0) {
|
||||||
}
|
console.log(`[HashIndexManager] addFileCid: No target hashes provided for ${filePath}. targetHashes=${this.targetHash}, providedKeys=${Object.keys(hashs)}`);
|
||||||
|
throw new Error('At least one target hash must be provided');
|
||||||
}
|
}
|
||||||
|
console.log(`[HashIndexManager] addFileCid: Adding ${filePath} with ${providedHashes.length} hashes: ${providedHashes.join(', ')}`);
|
||||||
const size = fileSize + "";
|
const size = fileSize + "";
|
||||||
const baseName = path.basename(filePath);
|
const baseName = path.basename(filePath);
|
||||||
const cacheKey = `${baseName}-${size}-${mtime}`;
|
const cacheKey = `${baseName}-${size}-${mtime}`;
|
||||||
let indexLine = this.cache.get(cacheKey);
|
let indexLine = this.cache.get(cacheKey);
|
||||||
if (!indexLine) {
|
if (!indexLine) {
|
||||||
//filter only the hashes we need
|
// Filter only the hashes we need (and are provided)
|
||||||
let filteredHash = {};
|
let filteredHash = {};
|
||||||
for (const hash of this.targetHash) {
|
for (const hash of providedHashes) {
|
||||||
filteredHash[hash] = hashs[hash];
|
filteredHash[hash] = hashs[hash];
|
||||||
}
|
}
|
||||||
const data = {path: baseName, size: size, mtime: mtime, ...filteredHash};
|
const data = {path: baseName, size: size, mtime: mtime, ...filteredHash};
|
||||||
this.cache.set(cacheKey, data);
|
this.cache.set(cacheKey, data);
|
||||||
} else {
|
} else {
|
||||||
//update the cache with the latest data
|
// Update the cache with the provided hashes (don't overwrite with undefined)
|
||||||
for (const hash of this.targetHash) {
|
for (const hash of providedHashes) {
|
||||||
indexLine[hash] = hashs[hash];
|
indexLine[hash] = hashs[hash];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user