Compare commits

...

4 Commits

Author SHA1 Message Date
Michel Roegl-Brunner
45ba67c827 Add minimize buttons to FilterBar and Newest Scripts sections
- Add minimize/collapse functionality to FilterBar component
- Add minimize/collapse functionality to Newest Scripts section
- Hide Newest Scripts section when user is searching, filtering, or viewing a category
- Both sections can now be minimized to save screen space
2025-11-07 12:58:22 +01:00
Michel Roegl-Brunner
6bd402abea Merge pull request #279 from community-scripts/fix/262
Fix stale LXC entries and improve orphaned script cleanup
2025-11-07 12:50:35 +01:00
Michel Roegl-Brunner
bd3ca74175 Fix stale LXC entries and improve orphaned script cleanup
- Improved cleanupOrphanedScripts to use pct list for more reliable container verification
- Added batch processing by server for better efficiency
- Added double-check with config file existence before deletion
- Added manual cleanup button in Installed Scripts tab for on-demand cleanup
- Improved error handling and logging throughout cleanup process
- Fixes issue where deleted containers (like Planka) were still showing in the UI
2025-11-07 12:47:19 +01:00
Michel Roegl-Brunner
9fc61bb416 Merge pull request #278 from community-scripts/fix/253
Fix: Include 'New' scripts in Download All Filtered when no filters are active
2025-11-07 12:44:32 +01:00
4 changed files with 247 additions and 115 deletions

View File

@@ -41,6 +41,7 @@ export function FilterBar({
}: FilterBarProps) {
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
const updateFilters = (updates: Partial<FilterState>) => {
onFiltersChange({ ...filters, ...updates });
@@ -98,44 +99,17 @@ export function FilterBar({
{!isLoadingFilters && (
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-medium text-foreground">Filter Scripts</h3>
<ContextualHelpIcon section="available-scripts" tooltip="Help with filtering and searching" />
</div>
)}
{/* Search Bar */}
<div className="mb-4">
<div className="relative max-w-md w-full">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg
className="h-5 w-5 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<input
type="text"
placeholder="Search scripts..."
value={filters.searchQuery}
onChange={(e) => updateFilters({ searchQuery: e.target.value })}
className="block w-full rounded-lg border border-input bg-background py-3 pr-10 pl-10 text-sm leading-5 text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-2 focus:ring-primary focus:outline-none"
/>
{filters.searchQuery && (
<div className="flex items-center gap-2">
<ContextualHelpIcon section="available-scripts" tooltip="Help with filtering and searching" />
<Button
onClick={() => updateFilters({ searchQuery: "" })}
onClick={() => setIsMinimized(!isMinimized)}
variant="ghost"
size="icon"
className="absolute inset-y-0 right-0 pr-3 text-muted-foreground hover:text-foreground"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
title={isMinimized ? "Expand filters" : "Minimize filters"}
>
<svg
className="h-5 w-5"
className={`h-4 w-4 transition-transform ${isMinimized ? "" : "rotate-180"}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -144,16 +118,69 @@ export function FilterBar({
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
d="M5 15l7-7 7 7"
/>
</svg>
</Button>
)}
</div>
</div>
</div>
)}
{/* Filter Buttons */}
<div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
{/* Filter Content - Conditionally rendered based on minimized state */}
{!isMinimized && !isLoadingFilters && (
<>
{/* Search Bar */}
<div className="mb-4">
<div className="relative max-w-md w-full">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg
className="h-5 w-5 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<input
type="text"
placeholder="Search scripts..."
value={filters.searchQuery}
onChange={(e) => updateFilters({ searchQuery: e.target.value })}
className="block w-full rounded-lg border border-input bg-background py-3 pr-10 pl-10 text-sm leading-5 text-foreground placeholder-muted-foreground focus:border-primary focus:placeholder-muted-foreground focus:ring-2 focus:ring-primary focus:outline-none"
/>
{filters.searchQuery && (
<Button
onClick={() => updateFilters({ searchQuery: "" })}
variant="ghost"
size="icon"
className="absolute inset-y-0 right-0 pr-3 text-muted-foreground hover:text-foreground"
>
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</Button>
)}
</div>
</div>
{/* Filter Buttons */}
<div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
{/* Updateable Filter */}
<Button
onClick={() => {
@@ -431,6 +458,8 @@ export function FilterBar({
</Button>
)}
</div>
</>
)}
{/* Click outside to close dropdowns */}
{(isTypeDropdownOpen || isSortDropdownOpen) && (

View File

@@ -935,6 +935,18 @@ export function InstalledScriptsTab() {
>
{showAutoDetectForm ? 'Cancel Auto-Detect' : '🔍 Auto-Detect LXC Containers (Must contain a tag with "community-script")'}
</Button>
<Button
onClick={() => {
cleanupRunRef.current = false; // Allow cleanup to run again
void cleanupMutation.mutate();
}}
disabled={cleanupMutation.isPending}
variant="outline"
size="default"
className="border-warning/30 text-warning hover:bg-warning/10"
>
{cleanupMutation.isPending ? '🧹 Cleaning up...' : '🧹 Cleanup Orphaned Scripts'}
</Button>
<Button
onClick={() => {
// Trigger status check by calling the mutation directly

View File

@@ -34,6 +34,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
});
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
const [isNewestMinimized, setIsNewestMinimized] = useState(false);
const gridRef = useRef<HTMLDivElement>(null);
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
@@ -689,8 +690,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
onViewModeChange={setViewMode}
/>
{/* Newest Scripts Carousel - Always show when there are newest scripts */}
{newestScripts.length > 0 && (
{/* Newest Scripts Carousel - Only show when no search, filters, or category is active */}
{newestScripts.length > 0 && !hasActiveFilters && !selectedCategory && (
<div className="mb-8">
<div className="bg-card border-l-4 border-l-primary border border-border rounded-lg p-6 shadow-lg">
<div className="flex items-center justify-between mb-4">
@@ -698,39 +699,64 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
<Clock className="h-6 w-6 text-primary" />
Newest Scripts
</h2>
<span className="text-sm text-muted-foreground">
{newestScripts.length} recently added
</span>
</div>
<div className="overflow-x-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent">
<div className="flex gap-4 pb-2" style={{ minWidth: 'max-content' }}>
{newestScripts.map((script, index) => {
if (!script || typeof script !== 'object') {
return null;
}
const uniqueKey = `newest-${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
return (
<div key={uniqueKey} className="flex-shrink-0 w-64 sm:w-72 md:w-80">
<div className="relative">
<ScriptCard
script={script}
onClick={handleCardClick}
isSelected={selectedSlugs.has(script.slug ?? '')}
onToggleSelect={toggleScriptSelection}
/>
{/* NEW badge */}
<div className="absolute top-2 right-2 bg-success text-success-foreground text-xs font-semibold px-2 py-1 rounded-md shadow-md z-10">
NEW
</div>
</div>
</div>
);
})}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{newestScripts.length} recently added
</span>
<Button
onClick={() => setIsNewestMinimized(!isNewestMinimized)}
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
title={isNewestMinimized ? "Expand newest scripts" : "Minimize newest scripts"}
>
<svg
className={`h-4 w-4 transition-transform ${isNewestMinimized ? "" : "rotate-180"}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 15l7-7 7 7"
/>
</svg>
</Button>
</div>
</div>
{!isNewestMinimized && (
<div className="overflow-x-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent">
<div className="flex gap-4 pb-2" style={{ minWidth: 'max-content' }}>
{newestScripts.map((script, index) => {
if (!script || typeof script !== 'object') {
return null;
}
const uniqueKey = `newest-${script.slug ?? 'unknown'}-${script.name ?? 'unnamed'}-${index}`;
return (
<div key={uniqueKey} className="flex-shrink-0 w-64 sm:w-72 md:w-80">
<div className="relative">
<ScriptCard
script={script}
onClick={handleCardClick}
isSelected={selectedSlugs.has(script.slug ?? '')}
onToggleSelect={toggleScriptSelection}
/>
{/* NEW badge */}
<div className="absolute top-2 right-2 bg-success text-success-foreground text-xs font-semibold px-2 py-1 rounded-md shadow-md z-10">
NEW
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
)}

View File

@@ -887,77 +887,142 @@ export const installedScriptsRouter = createTRPCRouter({
);
// Group scripts by server to batch check containers
const scriptsByServer = new Map<number, any[]>();
for (const script of scriptsToCheck) {
const scriptData = script as any;
if (!scriptData.server_id) continue;
if (!scriptsByServer.has(scriptData.server_id)) {
scriptsByServer.set(scriptData.server_id, []);
}
scriptsByServer.get(scriptData.server_id)!.push(scriptData);
}
// Process each server
for (const [serverId, serverScripts] of scriptsByServer.entries()) {
try {
const scriptData = script as any;
const server = allServers.find((s: any) => s.id === scriptData.server_id);
const server = allServers.find((s: any) => s.id === serverId);
if (!server) {
await db.deleteInstalledScript(Number(scriptData.id));
deletedScripts.push(String(scriptData.script_name));
// Server doesn't exist, delete all scripts for this server
for (const scriptData of serverScripts) {
await db.deleteInstalledScript(Number(scriptData.id));
deletedScripts.push(String(scriptData.script_name));
}
continue;
}
// Test SSH connection
const connectionTest = await sshService.testSSHConnection(server as Server);
if (!(connectionTest as any).success) {
console.warn(`cleanupOrphanedScripts: SSH connection failed for server ${String((server as any).name)}, skipping ${serverScripts.length} scripts`);
continue;
}
// Check if the container config file still exists
const checkCommand = `test -f "/etc/pve/lxc/${scriptData.container_id}.conf" && echo "exists" || echo "not_found"`;
// Get all existing containers from pct list (more reliable than checking config files)
const listCommand = 'pct list';
let listOutput = '';
// Await full command completion to avoid early false negatives
const containerExists = await new Promise<boolean>((resolve) => {
let combinedOutput = '';
let resolved = false;
const finish = () => {
if (resolved) return;
resolved = true;
const out = combinedOutput.trim();
if (out.includes('exists')) {
resolve(true);
} else if (out.includes('not_found')) {
resolve(false);
} else {
// Unknown output; treat as not found but log for diagnostics
console.warn(`cleanupOrphanedScripts: unexpected output for ${String(scriptData.script_name)} (${String(scriptData.container_id)}): ${out}`);
resolve(false);
}
};
// Add a guard timeout so we don't hang indefinitely
const timer = setTimeout(() => {
console.warn(`cleanupOrphanedScripts: timeout while checking ${String(scriptData.script_name)} on server ${String((server as any).name)}`);
finish();
}, 15000);
const existingContainerIds = await new Promise<Set<string>>((resolve, reject) => {
const timeout = setTimeout(() => {
console.warn(`cleanupOrphanedScripts: timeout while getting container list from server ${String((server as any).name)}`);
resolve(new Set()); // Treat timeout as no containers found
}, 20000);
void sshExecutionService.executeCommand(
server as Server,
checkCommand,
listCommand,
(data: string) => {
combinedOutput += data;
listOutput += data;
},
(error: string) => {
combinedOutput += error;
console.error(`cleanupOrphanedScripts: error getting container list from server ${String((server as any).name)}:`, error);
clearTimeout(timeout);
resolve(new Set()); // Treat error as no containers found
},
(_exitCode: number) => {
clearTimeout(timer);
finish();
clearTimeout(timeout);
// Parse pct list output to extract container IDs
const containerIds = new Set<string>();
const lines = listOutput.split('\n').filter(line => line.trim());
for (const line of lines) {
// pct list format: CTID Status Name
// Skip header line if present
if (line.includes('CTID') || line.includes('VMID')) continue;
const parts = line.trim().split(/\s+/);
if (parts.length > 0) {
const containerId = parts[0]?.trim();
if (containerId && /^\d{3,4}$/.test(containerId)) {
containerIds.add(containerId);
}
}
}
resolve(containerIds);
}
);
});
if (!containerExists) {
await db.deleteInstalledScript(Number(scriptData.id));
deletedScripts.push(String(scriptData.script_name));
} else {
}
// Check each script against the list of existing containers
for (const scriptData of serverScripts) {
try {
const containerId = String(scriptData.container_id).trim();
// Check if container exists in pct list
if (!existingContainerIds.has(containerId)) {
// Also verify config file doesn't exist as a double-check
const checkCommand = `test -f "/etc/pve/lxc/${containerId}.conf" && echo "exists" || echo "not_found"`;
const configExists = await new Promise<boolean>((resolve) => {
let combinedOutput = '';
let resolved = false;
const finish = () => {
if (resolved) return;
resolved = true;
const out = combinedOutput.trim();
resolve(out.includes('exists'));
};
const timer = setTimeout(() => {
finish();
}, 10000);
void sshExecutionService.executeCommand(
server as Server,
checkCommand,
(data: string) => {
combinedOutput += data;
},
(_error: string) => {
// Ignore errors, just check output
},
(_exitCode: number) => {
clearTimeout(timer);
finish();
}
);
});
// If container is not in pct list AND config file doesn't exist, it's orphaned
if (!configExists) {
console.log(`cleanupOrphanedScripts: Removing orphaned script ${String(scriptData.script_name)} (container ${containerId}) from server ${String((server as any).name)}`);
await db.deleteInstalledScript(Number(scriptData.id));
deletedScripts.push(String(scriptData.script_name));
} else {
// Config exists but not in pct list - might be in a transitional state, log but don't delete
console.warn(`cleanupOrphanedScripts: Container ${containerId} (${String(scriptData.script_name)}) config exists but not in pct list - may be in transitional state`);
}
}
} catch (error) {
console.error(`cleanupOrphanedScripts: Error checking script ${String((scriptData as any).script_name)}:`, error);
}
}
} catch (error) {
console.error(`Error checking script ${(script as any).script_name}:`, error);
console.error(`cleanupOrphanedScripts: Error processing server ${serverId}:`, error);
}
}