Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2b74c8db8 | ||
|
|
4ed3e42148 | ||
|
|
a09f331d5f | ||
|
|
36beb427c0 | ||
|
|
ca2cbd5a7f | ||
|
|
d6803b99a6 | ||
|
|
8b630c9201 | ||
|
|
5eaafbde48 | ||
|
|
92f78c7008 |
@@ -16,4 +16,8 @@ ALLOWED_SCRIPT_PATHS="scripts/"
|
||||
|
||||
# WebSocket Configuration
|
||||
WEBSOCKET_PORT="3001"
|
||||
GITHUB_TOKEN=your_github_token_here
|
||||
|
||||
# User settings
|
||||
GITHUB_TOKEN=
|
||||
SAVE_FILTER=false
|
||||
FILTERS=
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 64 KiB |
43
scripts/ct/debian.sh
Normal file
43
scripts/ct/debian.sh
Normal file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
SCRIPT_DIR="$(dirname "$0")"
|
||||
source "$SCRIPT_DIR/../core/build.func"
|
||||
# Copyright (c) 2021-2025 tteck
|
||||
# Author: tteck (tteckster)
|
||||
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||
# Source: https://www.debian.org/
|
||||
|
||||
APP="Debian"
|
||||
var_tags="${var_tags:-os}"
|
||||
var_cpu="${var_cpu:-1}"
|
||||
var_ram="${var_ram:-512}"
|
||||
var_disk="${var_disk:-2}"
|
||||
var_os="${var_os:-debian}"
|
||||
var_version="${var_version:-13}"
|
||||
var_unprivileged="${var_unprivileged:-1}"
|
||||
|
||||
header_info "$APP"
|
||||
variables
|
||||
color
|
||||
catch_errors
|
||||
|
||||
function update_script() {
|
||||
header_info
|
||||
check_container_storage
|
||||
check_container_resources
|
||||
if [[ ! -d /var ]]; then
|
||||
msg_error "No ${APP} Installation Found!"
|
||||
exit
|
||||
fi
|
||||
msg_info "Updating $APP LXC"
|
||||
$STD apt update
|
||||
$STD apt -y upgrade
|
||||
msg_ok "Updated $APP LXC"
|
||||
exit
|
||||
}
|
||||
|
||||
start
|
||||
build_container
|
||||
description
|
||||
|
||||
msg_ok "Completed Successfully!\n"
|
||||
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
|
||||
24
scripts/install/debian-install.sh
Normal file
24
scripts/install/debian-install.sh
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Copyright (c) 2021-2025 tteck
|
||||
# Author: tteck (tteckster)
|
||||
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||
# Source: https://www.debian.org/
|
||||
|
||||
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
|
||||
color
|
||||
verb_ip6
|
||||
catch_errors
|
||||
setting_up_container
|
||||
network_check
|
||||
update_os
|
||||
|
||||
motd_ssh
|
||||
customize
|
||||
|
||||
msg_info "Cleaning up"
|
||||
$STD apt -y autoremove
|
||||
$STD apt -y autoclean
|
||||
$STD apt -y clean
|
||||
msg_ok "Cleaned"
|
||||
|
||||
29
server.log
29
server.log
@@ -1,29 +0,0 @@
|
||||
|
||||
> pve-scripts-local@0.1.0 dev
|
||||
> node server.js
|
||||
|
||||
Error: listen EADDRINUSE: address already in use 0.0.0.0:3000
|
||||
at <unknown> (Error: listen EADDRINUSE: address already in use 0.0.0.0:3000) {
|
||||
code: 'EADDRINUSE',
|
||||
errno: -98,
|
||||
syscall: 'listen',
|
||||
address: '0.0.0.0',
|
||||
port: 3000
|
||||
}
|
||||
⨯ uncaughtException: Error: listen EADDRINUSE: address already in use 0.0.0.0:3000
|
||||
at <unknown> (Error: listen EADDRINUSE: address already in use 0.0.0.0:3000) {
|
||||
code: 'EADDRINUSE',
|
||||
errno: -98,
|
||||
syscall: 'listen',
|
||||
address: '0.0.0.0',
|
||||
port: 3000
|
||||
}
|
||||
⨯ uncaughtException: Error: listen EADDRINUSE: address already in use 0.0.0.0:3000
|
||||
at <unknown> (Error: listen EADDRINUSE: address already in use 0.0.0.0:3000) {
|
||||
code: 'EADDRINUSE',
|
||||
errno: -98,
|
||||
syscall: 'listen',
|
||||
address: '0.0.0.0',
|
||||
port: 3000
|
||||
}
|
||||
Terminated
|
||||
@@ -9,7 +9,16 @@ import { FilterBar, type FilterState } from './FilterBar';
|
||||
import { Button } from './ui/button';
|
||||
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
||||
|
||||
export function DownloadedScriptsTab() {
|
||||
interface DownloadedScriptsTabProps {
|
||||
onInstallScript?: (
|
||||
scriptPath: string,
|
||||
scriptName: string,
|
||||
mode?: "local" | "ssh",
|
||||
server?: any,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabProps) {
|
||||
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
@@ -20,6 +29,8 @@ export function DownloadedScriptsTab() {
|
||||
sortBy: 'name',
|
||||
sortOrder: 'asc',
|
||||
});
|
||||
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
|
||||
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||
@@ -29,6 +40,62 @@ export function DownloadedScriptsTab() {
|
||||
{ enabled: !!selectedSlug }
|
||||
);
|
||||
|
||||
// Load SAVE_FILTER setting and saved filters on component mount
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
// Load SAVE_FILTER setting
|
||||
const saveFilterResponse = await fetch('/api/settings/save-filter');
|
||||
let saveFilterEnabled = false;
|
||||
if (saveFilterResponse.ok) {
|
||||
const saveFilterData = await saveFilterResponse.json();
|
||||
saveFilterEnabled = saveFilterData.enabled ?? false;
|
||||
setSaveFiltersEnabled(saveFilterEnabled);
|
||||
}
|
||||
|
||||
// Load saved filters if SAVE_FILTER is enabled
|
||||
if (saveFilterEnabled) {
|
||||
const filtersResponse = await fetch('/api/settings/filters');
|
||||
if (filtersResponse.ok) {
|
||||
const filtersData = await filtersResponse.json();
|
||||
if (filtersData.filters) {
|
||||
setFilters(filtersData.filters as FilterState);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
} finally {
|
||||
setIsLoadingFilters(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadSettings();
|
||||
}, []);
|
||||
|
||||
// Save filters when they change (if SAVE_FILTER is enabled)
|
||||
useEffect(() => {
|
||||
if (!saveFiltersEnabled || isLoadingFilters) return;
|
||||
|
||||
const saveFilters = async () => {
|
||||
try {
|
||||
await fetch('/api/settings/filters', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ filters }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving filters:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Debounce the save operation
|
||||
const timeoutId = setTimeout(() => void saveFilters(), 500);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [filters, saveFiltersEnabled, isLoadingFilters]);
|
||||
|
||||
// Extract categories from metadata
|
||||
const categories = React.useMemo((): string[] => {
|
||||
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
||||
@@ -341,6 +408,8 @@ export function DownloadedScriptsTab() {
|
||||
totalScripts={downloadedScripts.length}
|
||||
filteredCount={filteredScripts.length}
|
||||
updatableCount={filterCounts.updatableCount}
|
||||
saveFiltersEnabled={saveFiltersEnabled}
|
||||
isLoadingFilters={isLoadingFilters}
|
||||
/>
|
||||
|
||||
{/* Scripts Grid */}
|
||||
@@ -402,9 +471,7 @@ export function DownloadedScriptsTab() {
|
||||
script={scriptData?.success ? scriptData.script : null}
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
onInstallScript={() => {
|
||||
// Downloaded scripts don't need installation
|
||||
}}
|
||||
onInstallScript={onInstallScript}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,8 @@ interface FilterBarProps {
|
||||
totalScripts: number;
|
||||
filteredCount: number;
|
||||
updatableCount?: number;
|
||||
saveFiltersEnabled?: boolean;
|
||||
isLoadingFilters?: boolean;
|
||||
}
|
||||
|
||||
const SCRIPT_TYPES = [
|
||||
@@ -33,6 +35,8 @@ export function FilterBar({
|
||||
totalScripts,
|
||||
filteredCount,
|
||||
updatableCount = 0,
|
||||
saveFiltersEnabled = false,
|
||||
isLoadingFilters = false,
|
||||
}: FilterBarProps) {
|
||||
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
|
||||
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
|
||||
@@ -78,6 +82,28 @@ export function FilterBar({
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-border bg-card p-4 sm:p-6 shadow-sm">
|
||||
{/* Loading State */}
|
||||
{isLoadingFilters && (
|
||||
<div className="mb-4 flex items-center justify-center py-2">
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
||||
<span>Loading saved filters...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Persistence Status */}
|
||||
{!isLoadingFilters && saveFiltersEnabled && (
|
||||
<div className="mb-4 flex items-center justify-center py-1">
|
||||
<div className="flex items-center space-x-2 text-xs text-green-600">
|
||||
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Filters are being saved automatically</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="mb-4">
|
||||
<div className="relative max-w-md w-full">
|
||||
|
||||
308
src/app/_components/GeneralSettingsModal.tsx
Normal file
308
src/app/_components/GeneralSettingsModal.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Toggle } from './ui/toggle';
|
||||
|
||||
interface GeneralSettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'github'>('general');
|
||||
const [githubToken, setGithubToken] = useState('');
|
||||
const [saveFilter, setSaveFilter] = useState(false);
|
||||
const [savedFilters, setSavedFilters] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
// Load existing settings when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadGithubToken();
|
||||
void loadSaveFilter();
|
||||
void loadSavedFilters();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadGithubToken = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/settings/github-token');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setGithubToken((data.token as string) ?? '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading GitHub token:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSaveFilter = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/settings/save-filter');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSaveFilter((data.enabled as boolean) ?? false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading save filter setting:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSaveFilter = async (enabled: boolean) => {
|
||||
try {
|
||||
const response = await fetch('/api/settings/save-filter', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setSaveFilter(enabled);
|
||||
setMessage({ type: 'success', text: 'Save filter setting updated!' });
|
||||
|
||||
// If disabling save filters, clear saved filters
|
||||
if (!enabled) {
|
||||
await clearSavedFilters();
|
||||
}
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setMessage({ type: 'error', text: errorData.error ?? 'Failed to save setting' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Failed to save setting' });
|
||||
}
|
||||
};
|
||||
|
||||
const loadSavedFilters = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/settings/filters');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSavedFilters(data.filters);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading saved filters:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const clearSavedFilters = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/settings/filters', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setSavedFilters(null);
|
||||
setMessage({ type: 'success', text: 'Saved filters cleared!' });
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setMessage({ type: 'error', text: errorData.error ?? 'Failed to clear filters' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Failed to clear filters' });
|
||||
}
|
||||
};
|
||||
|
||||
const saveGithubToken = async () => {
|
||||
setIsSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings/github-token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ token: githubToken }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setMessage({ type: 'success', text: 'GitHub token saved successfully!' });
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setMessage({ type: 'error', text: errorData.error ?? 'Failed to save token' });
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Failed to save token' });
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-2 sm:p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-4xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-border">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-card-foreground">Settings</h2>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg className="w-5 h-5 sm:w-6 sm:h-6" 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>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex flex-col sm:flex-row space-y-1 sm:space-y-0 sm:space-x-8 px-4 sm:px-6">
|
||||
<Button
|
||||
onClick={() => setActiveTab('general')}
|
||||
variant="ghost"
|
||||
size="null"
|
||||
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
|
||||
activeTab === 'general'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
>
|
||||
General
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setActiveTab('github')}
|
||||
variant="ghost"
|
||||
size="null"
|
||||
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
|
||||
activeTab === 'github'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
>
|
||||
GitHub
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(95vh-180px)] sm:max-h-[calc(90vh-200px)]">
|
||||
{activeTab === 'general' && (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
|
||||
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">General Settings</h3>
|
||||
<p className="text-sm sm:text-base text-muted-foreground mb-4">
|
||||
Configure general application preferences and behavior.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Save Filters</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4">Save your configured script filters.</p>
|
||||
<Toggle
|
||||
checked={saveFilter}
|
||||
onCheckedChange={saveSaveFilter}
|
||||
label="Enable filter saving"
|
||||
/>
|
||||
|
||||
{saveFilter && (
|
||||
<div className="mt-4 p-3 bg-muted rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Saved Filters</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{savedFilters ? 'Filters are currently saved' : 'No filters saved yet'}
|
||||
</p>
|
||||
{savedFilters && (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
<div>Search: {savedFilters.searchQuery ?? 'None'}</div>
|
||||
<div>Types: {savedFilters.selectedTypes?.length ?? 0} selected</div>
|
||||
<div>Sort: {savedFilters.sortBy} ({savedFilters.sortOrder})</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{savedFilters && (
|
||||
<Button
|
||||
onClick={clearSavedFilters}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-red-600 hover:text-red-800"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'github' && (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">GitHub Integration</h3>
|
||||
<p className="text-sm sm:text-base text-muted-foreground mb-4">
|
||||
Configure GitHub integration for script management and updates.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">GitHub Personal Access Token</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4">Save a GitHub Personal Access Token to circumvent GitHub API rate limits.</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="github-token" className="block text-sm font-medium text-foreground mb-1">
|
||||
Token
|
||||
</label>
|
||||
<Input
|
||||
id="github-token"
|
||||
type="password"
|
||||
placeholder="Enter your GitHub Personal Access Token"
|
||||
value={githubToken}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setGithubToken(e.target.value)}
|
||||
disabled={isLoading || isSaving}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`p-3 rounded-md text-sm ${
|
||||
message.type === 'success'
|
||||
? 'bg-green-50 text-green-800 border border-green-200'
|
||||
: 'bg-red-50 text-red-800 border border-red-200'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={saveGithubToken}
|
||||
disabled={isSaving || isLoading || !githubToken.trim()}
|
||||
className="flex-1"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Token'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={loadGithubToken}
|
||||
disabled={isLoading || isSaving}
|
||||
variant="outline"
|
||||
>
|
||||
{isLoading ? 'Loading...' : 'Refresh'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { Terminal } from './Terminal';
|
||||
import { StatusBadge } from './Badge';
|
||||
@@ -26,11 +26,18 @@ export function InstalledScriptsTab() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'success' | 'failed' | 'in_progress'>('all');
|
||||
const [serverFilter, setServerFilter] = useState<string>('all');
|
||||
const [sortField, setSortField] = useState<'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date'>('script_name');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any } | null>(null);
|
||||
const [editingScriptId, setEditingScriptId] = useState<number | null>(null);
|
||||
const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' });
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [addFormData, setAddFormData] = useState<{ script_name: string; container_id: string; server_id: string }>({ script_name: '', container_id: '', server_id: 'local' });
|
||||
const [showAutoDetectForm, setShowAutoDetectForm] = useState(false);
|
||||
const [autoDetectServerId, setAutoDetectServerId] = useState<string>('');
|
||||
const [autoDetectStatus, setAutoDetectStatus] = useState<{ type: 'success' | 'error' | null; message: string }>({ type: null, message: '' });
|
||||
const [cleanupStatus, setCleanupStatus] = useState<{ type: 'success' | 'error' | null; message: string }>({ type: null, message: '' });
|
||||
const cleanupRunRef = useRef(false);
|
||||
|
||||
// Fetch installed scripts
|
||||
const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery();
|
||||
@@ -68,24 +75,139 @@ export function InstalledScriptsTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-detect LXC containers mutation
|
||||
const autoDetectMutation = api.installedScripts.autoDetectLXCContainers.useMutation({
|
||||
onSuccess: (data) => {
|
||||
console.log('Auto-detect success:', data);
|
||||
void refetchScripts();
|
||||
setShowAutoDetectForm(false);
|
||||
setAutoDetectServerId('');
|
||||
|
||||
// Show detailed message about what was added/skipped
|
||||
let statusMessage = data.message ?? 'Auto-detection completed successfully!';
|
||||
if (data.skippedContainers && data.skippedContainers.length > 0) {
|
||||
const skippedNames = data.skippedContainers.map((c: any) => String(c.hostname)).join(', ');
|
||||
statusMessage += ` Skipped duplicates: ${skippedNames}`;
|
||||
}
|
||||
|
||||
setAutoDetectStatus({
|
||||
type: 'success',
|
||||
message: statusMessage
|
||||
});
|
||||
// Clear status after 8 seconds (longer for detailed info)
|
||||
setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 8000);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Auto-detect mutation error:', error);
|
||||
console.error('Error details:', {
|
||||
message: error.message,
|
||||
data: error.data
|
||||
});
|
||||
setAutoDetectStatus({
|
||||
type: 'error',
|
||||
message: error.message ?? 'Auto-detection failed. Please try again.'
|
||||
});
|
||||
// Clear status after 5 seconds
|
||||
setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 5000);
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup orphaned scripts mutation
|
||||
const cleanupMutation = api.installedScripts.cleanupOrphanedScripts.useMutation({
|
||||
onSuccess: (data) => {
|
||||
console.log('Cleanup success:', data);
|
||||
void refetchScripts();
|
||||
|
||||
if (data.deletedCount > 0) {
|
||||
setCleanupStatus({
|
||||
type: 'success',
|
||||
message: `Cleanup completed! Removed ${data.deletedCount} orphaned script(s): ${data.deletedScripts.join(', ')}`
|
||||
});
|
||||
} else {
|
||||
setCleanupStatus({
|
||||
type: 'success',
|
||||
message: 'Cleanup completed! No orphaned scripts found.'
|
||||
});
|
||||
}
|
||||
// Clear status after 8 seconds (longer for cleanup info)
|
||||
setTimeout(() => setCleanupStatus({ type: null, message: '' }), 8000);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Cleanup mutation error:', error);
|
||||
setCleanupStatus({
|
||||
type: 'error',
|
||||
message: error.message ?? 'Cleanup failed. Please try again.'
|
||||
});
|
||||
// Clear status after 5 seconds
|
||||
setTimeout(() => setCleanupStatus({ type: null, message: '' }), 5000);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const scripts: InstalledScript[] = (scriptsData?.scripts as InstalledScript[]) ?? [];
|
||||
const stats = statsData?.stats;
|
||||
|
||||
// Filter scripts based on search and filters
|
||||
const filteredScripts = scripts.filter((script: InstalledScript) => {
|
||||
const matchesSearch = script.script_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(script.container_id?.includes(searchTerm) ?? false) ||
|
||||
(script.server_name?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false);
|
||||
|
||||
const matchesStatus = statusFilter === 'all' || script.status === statusFilter;
|
||||
|
||||
const matchesServer = serverFilter === 'all' ||
|
||||
(serverFilter === 'local' && !script.server_name) ||
|
||||
(script.server_name === serverFilter);
|
||||
|
||||
return matchesSearch && matchesStatus && matchesServer;
|
||||
});
|
||||
// Run cleanup when component mounts and scripts are loaded (only once)
|
||||
useEffect(() => {
|
||||
if (scripts.length > 0 && serversData?.servers && !cleanupMutation.isPending && !cleanupRunRef.current) {
|
||||
console.log('Running automatic cleanup check...');
|
||||
cleanupRunRef.current = true;
|
||||
void cleanupMutation.mutate();
|
||||
}
|
||||
}, [scripts.length, serversData?.servers, cleanupMutation]);
|
||||
|
||||
// Filter and sort scripts
|
||||
const filteredScripts = scripts
|
||||
.filter((script: InstalledScript) => {
|
||||
const matchesSearch = script.script_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(script.container_id?.includes(searchTerm) ?? false) ||
|
||||
(script.server_name?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false);
|
||||
|
||||
const matchesStatus = statusFilter === 'all' || script.status === statusFilter;
|
||||
|
||||
const matchesServer = serverFilter === 'all' ||
|
||||
(serverFilter === 'local' && !script.server_name) ||
|
||||
(script.server_name === serverFilter);
|
||||
|
||||
return matchesSearch && matchesStatus && matchesServer;
|
||||
})
|
||||
.sort((a: InstalledScript, b: InstalledScript) => {
|
||||
let aValue: any;
|
||||
let bValue: any;
|
||||
|
||||
switch (sortField) {
|
||||
case 'script_name':
|
||||
aValue = a.script_name.toLowerCase();
|
||||
bValue = b.script_name.toLowerCase();
|
||||
break;
|
||||
case 'container_id':
|
||||
aValue = a.container_id ?? '';
|
||||
bValue = b.container_id ?? '';
|
||||
break;
|
||||
case 'server_name':
|
||||
aValue = a.server_name ?? 'Local';
|
||||
bValue = b.server_name ?? 'Local';
|
||||
break;
|
||||
case 'status':
|
||||
aValue = a.status;
|
||||
bValue = b.status;
|
||||
break;
|
||||
case 'installation_date':
|
||||
aValue = new Date(a.installation_date).getTime();
|
||||
bValue = new Date(b.installation_date).getTime();
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (aValue < bValue) {
|
||||
return sortDirection === 'asc' ? -1 : 1;
|
||||
}
|
||||
if (aValue > bValue) {
|
||||
return sortDirection === 'asc' ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Get unique servers for filter
|
||||
const uniqueServers: string[] = [];
|
||||
@@ -197,6 +319,34 @@ export function InstalledScriptsTab() {
|
||||
setAddFormData({ script_name: '', container_id: '', server_id: 'local' });
|
||||
};
|
||||
|
||||
const handleAutoDetect = () => {
|
||||
if (!autoDetectServerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoDetectMutation.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAutoDetectStatus({ type: null, message: '' });
|
||||
console.log('Starting auto-detect for server ID:', autoDetectServerId);
|
||||
autoDetectMutation.mutate({ serverId: Number(autoDetectServerId) });
|
||||
};
|
||||
|
||||
const handleCancelAutoDetect = () => {
|
||||
setShowAutoDetectForm(false);
|
||||
setAutoDetectServerId('');
|
||||
};
|
||||
|
||||
const handleSort = (field: 'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date') => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
@@ -251,8 +401,8 @@ export function InstalledScriptsTab() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Script Button */}
|
||||
<div className="mb-4">
|
||||
{/* Add Script and Auto-Detect Buttons */}
|
||||
<div className="mb-4 flex flex-col sm:flex-row gap-3">
|
||||
<Button
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
variant={showAddForm ? "outline" : "default"}
|
||||
@@ -260,6 +410,13 @@ export function InstalledScriptsTab() {
|
||||
>
|
||||
{showAddForm ? 'Cancel Add Script' : '+ Add Manual Script Entry'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowAutoDetectForm(!showAutoDetectForm)}
|
||||
variant={showAutoDetectForm ? "outline" : "secondary"}
|
||||
size="default"
|
||||
>
|
||||
{showAutoDetectForm ? 'Cancel Auto-Detect' : '🔍 Auto-Detect LXC Containers (Must contain a tag with "community-script")'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Add Script Form */}
|
||||
@@ -331,6 +488,145 @@ export function InstalledScriptsTab() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Messages */}
|
||||
{(autoDetectStatus.type ?? cleanupStatus.type) && (
|
||||
<div className="mb-4 space-y-2">
|
||||
{/* Auto-Detect Status Message */}
|
||||
{autoDetectStatus.type && (
|
||||
<div className={`p-4 rounded-lg border ${
|
||||
autoDetectStatus.type === 'success'
|
||||
? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800'
|
||||
: 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-800'
|
||||
}`}>
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
{autoDetectStatus.type === 'success' ? (
|
||||
<svg className="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className={`text-sm font-medium ${
|
||||
autoDetectStatus.type === 'success'
|
||||
? 'text-green-800 dark:text-green-200'
|
||||
: 'text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{autoDetectStatus.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cleanup Status Message */}
|
||||
{cleanupStatus.type && (
|
||||
<div className={`p-4 rounded-lg border ${
|
||||
cleanupStatus.type === 'success'
|
||||
? 'bg-slate-50 dark:bg-slate-900/50 border-slate-200 dark:border-slate-700'
|
||||
: 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-800'
|
||||
}`}>
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
{cleanupStatus.type === 'success' ? (
|
||||
<svg className="h-5 w-5 text-slate-500 dark:text-slate-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className={`text-sm font-medium ${
|
||||
cleanupStatus.type === 'success'
|
||||
? 'text-slate-700 dark:text-slate-300'
|
||||
: 'text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{cleanupStatus.message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Auto-Detect LXC Containers Form */}
|
||||
{showAutoDetectForm && (
|
||||
<div className="mb-6 p-4 sm:p-6 bg-card rounded-lg border border-border shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4 sm:mb-6">Auto-Detect LXC Containers (Must contain a tag with "community-script")</h3>
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div className="bg-slate-50 dark:bg-slate-900/30 border border-slate-200 dark:border-slate-700 rounded-lg p-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-slate-500 dark:text-slate-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
How it works
|
||||
</h4>
|
||||
<div className="mt-2 text-sm text-slate-600 dark:text-slate-400">
|
||||
<p>This feature will:</p>
|
||||
<ul className="list-disc list-inside mt-1 space-y-1">
|
||||
<li>Connect to the selected server via SSH</li>
|
||||
<li>Scan all LXC config files in /etc/pve/lxc/</li>
|
||||
<li>Find containers with "community-script" in their tags</li>
|
||||
<li>Extract the container ID and hostname</li>
|
||||
<li>Add them as installed script entries</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Select Server *
|
||||
</label>
|
||||
<select
|
||||
value={autoDetectServerId}
|
||||
onChange={(e) => setAutoDetectServerId(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
||||
>
|
||||
<option value="">Choose a server...</option>
|
||||
{serversData?.servers?.map((server: any) => (
|
||||
<option key={server.id} value={server.id}>
|
||||
{server.name} ({server.ip})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row justify-end gap-3 mt-4 sm:mt-6">
|
||||
<Button
|
||||
onClick={handleCancelAutoDetect}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAutoDetect}
|
||||
disabled={autoDetectMutation.isPending || !autoDetectServerId}
|
||||
variant="default"
|
||||
size="default"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{autoDetectMutation.isPending ? '🔍 Scanning...' : '🔍 Start Auto-Detection'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="space-y-4">
|
||||
{/* Search Input - Full Width on Mobile */}
|
||||
@@ -405,20 +701,70 @@ export function InstalledScriptsTab() {
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Script Name
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
|
||||
onClick={() => handleSort('script_name')}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>Script Name</span>
|
||||
{sortField === 'script_name' && (
|
||||
<span className="text-primary">
|
||||
{sortDirection === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Container ID
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
|
||||
onClick={() => handleSort('container_id')}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>Container ID</span>
|
||||
{sortField === 'container_id' && (
|
||||
<span className="text-primary">
|
||||
{sortDirection === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Server
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
|
||||
onClick={() => handleSort('server_name')}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>Server</span>
|
||||
{sortField === 'server_name' && (
|
||||
<span className="text-primary">
|
||||
{sortDirection === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Status
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
|
||||
onClick={() => handleSort('status')}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>Status</span>
|
||||
{sortField === 'status' && (
|
||||
<span className="text-primary">
|
||||
{sortDirection === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Installation Date
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted/80 select-none"
|
||||
onClick={() => handleSort('installation_date')}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>Installation Date</span>
|
||||
{sortField === 'installation_date' && (
|
||||
<span className="text-primary">
|
||||
{sortDirection === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Actions
|
||||
|
||||
@@ -120,9 +120,6 @@ export function ScriptDetailModal({
|
||||
// Pass execution mode and server info to the parent
|
||||
onInstallScript(scriptPath, scriptName, mode, server);
|
||||
|
||||
// Scroll to top of the page to see the terminal
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
|
||||
onClose(); // Close the modal when starting installation
|
||||
}
|
||||
};
|
||||
|
||||
@@ -26,6 +26,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
sortBy: 'name',
|
||||
sortOrder: 'asc',
|
||||
});
|
||||
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
|
||||
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||
@@ -35,6 +37,62 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
{ enabled: !!selectedSlug }
|
||||
);
|
||||
|
||||
// Load SAVE_FILTER setting and saved filters on component mount
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
// Load SAVE_FILTER setting
|
||||
const saveFilterResponse = await fetch('/api/settings/save-filter');
|
||||
let saveFilterEnabled = false;
|
||||
if (saveFilterResponse.ok) {
|
||||
const saveFilterData = await saveFilterResponse.json();
|
||||
saveFilterEnabled = saveFilterData.enabled ?? false;
|
||||
setSaveFiltersEnabled(saveFilterEnabled);
|
||||
}
|
||||
|
||||
// Load saved filters if SAVE_FILTER is enabled
|
||||
if (saveFilterEnabled) {
|
||||
const filtersResponse = await fetch('/api/settings/filters');
|
||||
if (filtersResponse.ok) {
|
||||
const filtersData = await filtersResponse.json();
|
||||
if (filtersData.filters) {
|
||||
setFilters(filtersData.filters as FilterState);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
} finally {
|
||||
setIsLoadingFilters(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadSettings();
|
||||
}, []);
|
||||
|
||||
// Save filters when they change (if SAVE_FILTER is enabled)
|
||||
useEffect(() => {
|
||||
if (!saveFiltersEnabled || isLoadingFilters) return;
|
||||
|
||||
const saveFilters = async () => {
|
||||
try {
|
||||
await fetch('/api/settings/filters', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ filters }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error saving filters:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Debounce the save operation
|
||||
const timeoutId = setTimeout(() => void saveFilters(), 500);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [filters, saveFiltersEnabled, isLoadingFilters]);
|
||||
|
||||
// Extract categories from metadata
|
||||
const categories = React.useMemo((): string[] => {
|
||||
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
||||
@@ -337,6 +395,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
totalScripts={scriptsWithStatus.length}
|
||||
filteredCount={filteredScripts.length}
|
||||
updatableCount={filterCounts.updatableCount}
|
||||
saveFiltersEnabled={saveFiltersEnabled}
|
||||
isLoadingFilters={isLoadingFilters}
|
||||
/>
|
||||
|
||||
{/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
|
||||
|
||||
50
src/app/_components/ServerSettingsButton.tsx
Normal file
50
src/app/_components/ServerSettingsButton.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { SettingsModal } from './SettingsModal';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export function ServerSettingsButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="text-sm text-muted-foreground font-medium">
|
||||
Add and manage PVE Servers:
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="inline-flex items-center"
|
||||
title="Add PVE Server"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
Manage PVE Servers
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { SettingsModal } from './SettingsModal';
|
||||
import { GeneralSettingsModal } from './GeneralSettingsModal';
|
||||
import { Button } from './ui/button';
|
||||
import { Settings } from 'lucide-react';
|
||||
|
||||
export function SettingsButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -11,41 +12,21 @@ export function SettingsButton() {
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="text-sm text-muted-foreground font-medium">
|
||||
Add and manage PVE Servers:
|
||||
Application Settings:
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="inline-flex items-center"
|
||||
title="Add PVE Server"
|
||||
title="Open Settings"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
Manage PVE Servers
|
||||
<Settings className="w-5 h-5 mr-2" />
|
||||
Settings
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
||||
<GeneralSettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
const [servers, setServers] = useState<Server[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'servers' | 'general'>('servers');
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -116,35 +115,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex flex-col sm:flex-row space-y-1 sm:space-y-0 sm:space-x-8 px-4 sm:px-6">
|
||||
<Button
|
||||
onClick={() => setActiveTab('servers')}
|
||||
variant="ghost"
|
||||
size="null"
|
||||
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
|
||||
activeTab === 'servers'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
>
|
||||
Server Settings
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setActiveTab('general')}
|
||||
variant="ghost"
|
||||
size="null"
|
||||
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
|
||||
activeTab === 'general'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
}`}
|
||||
>
|
||||
General
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(95vh-180px)] sm:max-h-[calc(90vh-200px)]">
|
||||
@@ -164,37 +134,28 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'servers' && (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Server Configurations</h3>
|
||||
<ServerForm onSubmit={handleCreateServer} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Saved Servers</h3>
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<p className="mt-2 text-gray-600">Loading servers...</p>
|
||||
</div>
|
||||
) : (
|
||||
<ServerList
|
||||
servers={servers}
|
||||
onUpdate={handleUpdateServer}
|
||||
onDelete={handleDeleteServer}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'general' && (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">General Settings</h3>
|
||||
<p className="text-sm sm:text-base text-muted-foreground">General settings will be available in a future update.</p>
|
||||
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Server Configurations</h3>
|
||||
<ServerForm onSubmit={handleCreateServer} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Saved Servers</h3>
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
<p className="mt-2 text-gray-600">Loading servers...</p>
|
||||
</div>
|
||||
) : (
|
||||
<ServerList
|
||||
servers={servers}
|
||||
onUpdate={handleUpdateServer}
|
||||
onDelete={handleDeleteServer}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
const [lastInputSent, setLastInputSent] = useState<string | null>(null);
|
||||
const [inWhiptailSession, setInWhiptailSession] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const [isStopped, setIsStopped] = useState(false);
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<any>(null);
|
||||
const fitAddonRef = useRef<any>(null);
|
||||
@@ -64,23 +65,8 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
if (message.data.includes('\x1b[2J') || message.data.includes('\x1b[H\x1b[2J')) {
|
||||
// This is a clear screen sequence, ensure it's processed correctly
|
||||
xtermRef.current.write(message.data);
|
||||
} else if (message.data.includes('\x1b[') && message.data.includes('H')) {
|
||||
// This is a cursor positioning sequence, often implies a redraw of the entire screen
|
||||
if (inWhiptailSession) {
|
||||
// In whiptail session, completely reset the terminal
|
||||
// Completely clear everything
|
||||
xtermRef.current.clear();
|
||||
xtermRef.current.write('\x1b[2J\x1b[H\x1b[3J\x1b[2J');
|
||||
// Reset the terminal state
|
||||
xtermRef.current.reset();
|
||||
// Write the new content after reset
|
||||
setTimeout(() => {
|
||||
xtermRef.current.write(message.data);
|
||||
}, 100);
|
||||
} else {
|
||||
xtermRef.current.write(message.data);
|
||||
}
|
||||
} else {
|
||||
// Let xterm handle all ANSI sequences naturally
|
||||
xtermRef.current.write(message.data);
|
||||
}
|
||||
break;
|
||||
@@ -153,9 +139,27 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
|
||||
const terminal = new XTerm({
|
||||
theme: {
|
||||
background: '#000000',
|
||||
foreground: '#00ff00',
|
||||
cursor: '#00ff00',
|
||||
background: '#0d1117',
|
||||
foreground: '#e6edf3',
|
||||
cursor: '#58a6ff',
|
||||
cursorAccent: '#0d1117',
|
||||
// Let ANSI colors work naturally - only define basic colors
|
||||
black: '#484f58',
|
||||
red: '#f85149',
|
||||
green: '#3fb950',
|
||||
yellow: '#d29922',
|
||||
blue: '#58a6ff',
|
||||
magenta: '#bc8cff',
|
||||
cyan: '#39d353',
|
||||
white: '#b1bac4',
|
||||
brightBlack: '#6e7681',
|
||||
brightRed: '#ff7b72',
|
||||
brightGreen: '#56d364',
|
||||
brightYellow: '#e3b341',
|
||||
brightBlue: '#79c0ff',
|
||||
brightMagenta: '#d2a8ff',
|
||||
brightCyan: '#56d364',
|
||||
brightWhite: '#f0f6fc',
|
||||
},
|
||||
fontSize: isMobile ? 7 : 14,
|
||||
fontFamily: 'JetBrains Mono, Fira Code, Cascadia Code, Monaco, Menlo, Ubuntu Mono, monospace',
|
||||
@@ -189,6 +193,13 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
// Open terminal
|
||||
terminal.open(terminalElement);
|
||||
|
||||
// Ensure proper terminal rendering
|
||||
setTimeout(() => {
|
||||
terminal.refresh(0, terminal.rows - 1);
|
||||
// Ensure cursor is properly positioned
|
||||
terminal.focus();
|
||||
}, 100);
|
||||
|
||||
// Fit after a small delay to ensure proper sizing
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
@@ -269,6 +280,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
}
|
||||
|
||||
isConnectingRef.current = true;
|
||||
const isInitialConnection = !hasConnectedRef.current;
|
||||
hasConnectedRef.current = true;
|
||||
|
||||
// Small delay to prevent rapid reconnection
|
||||
@@ -284,17 +296,19 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
setIsConnected(true);
|
||||
isConnectingRef.current = false;
|
||||
|
||||
// Send start message immediately after connection
|
||||
const message = {
|
||||
action: 'start',
|
||||
scriptPath,
|
||||
executionId,
|
||||
mode,
|
||||
server,
|
||||
isUpdate,
|
||||
containerId
|
||||
};
|
||||
ws.send(JSON.stringify(message));
|
||||
// Only auto-start on initial connection, not on reconnections
|
||||
if (isInitialConnection && !isRunning) {
|
||||
const message = {
|
||||
action: 'start',
|
||||
scriptPath,
|
||||
executionId,
|
||||
mode,
|
||||
server,
|
||||
isUpdate,
|
||||
containerId
|
||||
};
|
||||
ws.send(JSON.stringify(message));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
@@ -335,7 +349,8 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
}, [scriptPath, executionId, mode, server, isUpdate, containerId, handleMessage, isMobile]);
|
||||
|
||||
const startScript = () => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) {
|
||||
setIsStopped(false);
|
||||
wsRef.current.send(JSON.stringify({
|
||||
action: 'start',
|
||||
scriptPath,
|
||||
@@ -350,6 +365,8 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
|
||||
const stopScript = () => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
setIsStopped(true);
|
||||
setIsRunning(false);
|
||||
wsRef.current.send(JSON.stringify({
|
||||
action: 'stop',
|
||||
executionId
|
||||
@@ -586,10 +603,10 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
<div className="flex flex-wrap gap-1 sm:gap-2">
|
||||
<Button
|
||||
onClick={startScript}
|
||||
disabled={!isConnected || isRunning}
|
||||
disabled={!isConnected || (isRunning && !isStopped)}
|
||||
variant="default"
|
||||
size="sm"
|
||||
className={`text-xs sm:text-sm ${isConnected && !isRunning ? 'bg-green-600 hover:bg-green-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}`}
|
||||
className={`text-xs sm:text-sm ${isConnected && (!isRunning || isStopped) ? 'bg-green-600 hover:bg-green-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}`}
|
||||
>
|
||||
<Play className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||
<span className="hidden sm:inline">Start</span>
|
||||
|
||||
23
src/app/_components/ui/input.tsx
Normal file
23
src/app/_components/ui/input.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../../../lib/utils"
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
41
src/app/_components/ui/toggle.tsx
Normal file
41
src/app/_components/ui/toggle.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../../../lib/utils"
|
||||
|
||||
export interface ToggleProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const Toggle = React.forwardRef<HTMLInputElement, ToggleProps>(
|
||||
({ className, checked, onCheckedChange, label, ...props }, ref) => {
|
||||
return (
|
||||
<div className="flex items-center space-x-3">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only"
|
||||
checked={checked}
|
||||
onChange={(e) => onCheckedChange?.(e.target.checked)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
<div className={cn(
|
||||
"w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-transform after:duration-300 after:ease-in-out peer-checked:bg-blue-600 transition-colors duration-300 ease-in-out",
|
||||
checked && "bg-blue-600 after:translate-x-full",
|
||||
className
|
||||
)} />
|
||||
</label>
|
||||
{label && (
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
Toggle.displayName = "Toggle"
|
||||
|
||||
export { Toggle }
|
||||
141
src/app/api/settings/filters/route.ts
Normal file
141
src/app/api/settings/filters/route.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { filters } = await request.json();
|
||||
|
||||
if (!filters || typeof filters !== 'object') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Filters object is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate filter structure
|
||||
const requiredFields = ['searchQuery', 'showUpdatable', 'selectedTypes', 'sortBy', 'sortOrder'];
|
||||
for (const field of requiredFields) {
|
||||
if (!(field in filters)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Missing required field: ${field}` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Path to the .env file
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
// Read existing .env file
|
||||
let envContent = '';
|
||||
if (fs.existsSync(envPath)) {
|
||||
envContent = fs.readFileSync(envPath, 'utf8');
|
||||
}
|
||||
|
||||
// Serialize filters to JSON string
|
||||
const filtersJson = JSON.stringify(filters);
|
||||
|
||||
// Check if FILTERS already exists
|
||||
const filtersRegex = /^FILTERS=.*$/m;
|
||||
const filtersMatch = filtersRegex.exec(envContent);
|
||||
|
||||
if (filtersMatch) {
|
||||
// Replace existing FILTERS
|
||||
envContent = envContent.replace(filtersRegex, `FILTERS=${filtersJson}`);
|
||||
} else {
|
||||
// Add new FILTERS
|
||||
envContent += (envContent.endsWith('\n') ? '' : '\n') + `FILTERS=${filtersJson}\n`;
|
||||
}
|
||||
|
||||
// Write back to .env file
|
||||
fs.writeFileSync(envPath, envContent);
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Filters saved successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error saving filters:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save filters' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Path to the .env file
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
if (!fs.existsSync(envPath)) {
|
||||
return NextResponse.json({ filters: null });
|
||||
}
|
||||
|
||||
// Read .env file and extract FILTERS
|
||||
const envContent = fs.readFileSync(envPath, 'utf8');
|
||||
const filtersRegex = /^FILTERS=(.*)$/m;
|
||||
const filtersMatch = filtersRegex.exec(envContent);
|
||||
|
||||
if (!filtersMatch) {
|
||||
return NextResponse.json({ filters: null });
|
||||
}
|
||||
|
||||
try {
|
||||
const filters = JSON.parse(filtersMatch[1]!);
|
||||
|
||||
// Validate the parsed filters
|
||||
const requiredFields = ['searchQuery', 'showUpdatable', 'selectedTypes', 'sortBy', 'sortOrder'];
|
||||
const isValid = requiredFields.every(field => field in filters);
|
||||
|
||||
if (!isValid) {
|
||||
return NextResponse.json({ filters: null });
|
||||
}
|
||||
|
||||
return NextResponse.json({ filters });
|
||||
} catch (parseError) {
|
||||
console.error('Error parsing saved filters:', parseError);
|
||||
return NextResponse.json({ filters: null });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading filters:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to read filters' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
try {
|
||||
// Path to the .env file
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
if (!fs.existsSync(envPath)) {
|
||||
return NextResponse.json({ success: true, message: 'No filters to clear' });
|
||||
}
|
||||
|
||||
// Read existing .env file
|
||||
let envContent = fs.readFileSync(envPath, 'utf8');
|
||||
|
||||
// Remove FILTERS line
|
||||
const filtersRegex = /^FILTERS=.*$/m;
|
||||
const filtersMatch = filtersRegex.exec(envContent);
|
||||
if (filtersMatch) {
|
||||
envContent = envContent.replace(filtersRegex, '');
|
||||
}
|
||||
|
||||
// Clean up extra newlines
|
||||
envContent = envContent.replace(/\n\n+/g, '\n');
|
||||
|
||||
// Write back to .env file
|
||||
fs.writeFileSync(envPath, envContent);
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Filters cleared successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error clearing filters:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to clear filters' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
75
src/app/api/settings/github-token/route.ts
Normal file
75
src/app/api/settings/github-token/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { token } = await request.json();
|
||||
|
||||
if (!token || typeof token !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Token is required and must be a string' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Path to the .env file
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
// Read existing .env file
|
||||
let envContent = '';
|
||||
if (fs.existsSync(envPath)) {
|
||||
envContent = fs.readFileSync(envPath, 'utf8');
|
||||
}
|
||||
|
||||
// Check if GITHUB_TOKEN already exists
|
||||
const githubTokenRegex = /^GITHUB_TOKEN=.*$/m;
|
||||
const githubTokenMatch = githubTokenRegex.exec(envContent);
|
||||
|
||||
if (githubTokenMatch) {
|
||||
// Replace existing GITHUB_TOKEN
|
||||
envContent = envContent.replace(githubTokenRegex, `GITHUB_TOKEN=${token}`);
|
||||
} else {
|
||||
// Add new GITHUB_TOKEN
|
||||
envContent += (envContent.endsWith('\n') ? '' : '\n') + `GITHUB_TOKEN=${token}\n`;
|
||||
}
|
||||
|
||||
// Write back to .env file
|
||||
fs.writeFileSync(envPath, envContent);
|
||||
|
||||
return NextResponse.json({ success: true, message: 'GitHub token saved successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error saving GitHub token:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save GitHub token' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Path to the .env file
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
if (!fs.existsSync(envPath)) {
|
||||
return NextResponse.json({ token: null });
|
||||
}
|
||||
|
||||
// Read .env file and extract GITHUB_TOKEN
|
||||
const envContent = fs.readFileSync(envPath, 'utf8');
|
||||
const githubTokenRegex = /^GITHUB_TOKEN=(.*)$/m;
|
||||
const githubTokenMatch = githubTokenRegex.exec(envContent);
|
||||
|
||||
const token = githubTokenMatch ? githubTokenMatch[1] : null;
|
||||
|
||||
return NextResponse.json({ token });
|
||||
} catch (error) {
|
||||
console.error('Error reading GitHub token:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to read GitHub token' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
75
src/app/api/settings/save-filter/route.ts
Normal file
75
src/app/api/settings/save-filter/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { enabled } = await request.json();
|
||||
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Enabled value must be a boolean' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Path to the .env file
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
// Read existing .env file
|
||||
let envContent = '';
|
||||
if (fs.existsSync(envPath)) {
|
||||
envContent = fs.readFileSync(envPath, 'utf8');
|
||||
}
|
||||
|
||||
// Check if SAVE_FILTER already exists
|
||||
const saveFilterRegex = /^SAVE_FILTER=.*$/m;
|
||||
const saveFilterMatch = saveFilterRegex.exec(envContent);
|
||||
|
||||
if (saveFilterMatch) {
|
||||
// Replace existing SAVE_FILTER
|
||||
envContent = envContent.replace(saveFilterRegex, `SAVE_FILTER=${enabled}`);
|
||||
} else {
|
||||
// Add new SAVE_FILTER
|
||||
envContent += (envContent.endsWith('\n') ? '' : '\n') + `SAVE_FILTER=${enabled}\n`;
|
||||
}
|
||||
|
||||
// Write back to .env file
|
||||
fs.writeFileSync(envPath, envContent);
|
||||
|
||||
return NextResponse.json({ success: true, message: 'Save filter setting saved successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error saving save filter setting:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save save filter setting' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Path to the .env file
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
if (!fs.existsSync(envPath)) {
|
||||
return NextResponse.json({ enabled: false });
|
||||
}
|
||||
|
||||
// Read .env file and extract SAVE_FILTER
|
||||
const envContent = fs.readFileSync(envPath, 'utf8');
|
||||
const saveFilterRegex = /^SAVE_FILTER=(.*)$/m;
|
||||
const saveFilterMatch = saveFilterRegex.exec(envContent);
|
||||
|
||||
const enabled = saveFilterMatch ? saveFilterMatch[1] === 'true' : false;
|
||||
|
||||
return NextResponse.json({ enabled });
|
||||
} catch (error) {
|
||||
console.error('Error reading save filter setting:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to read save filter setting' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import { ScriptsGrid } from './_components/ScriptsGrid';
|
||||
import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
|
||||
import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
|
||||
import { ResyncButton } from './_components/ResyncButton';
|
||||
import { Terminal } from './_components/Terminal';
|
||||
import { ServerSettingsButton } from './_components/ServerSettingsButton';
|
||||
import { SettingsButton } from './_components/SettingsButton';
|
||||
import { VersionDisplay } from './_components/VersionDisplay';
|
||||
import { Button } from './_components/ui/button';
|
||||
@@ -15,9 +16,25 @@ import { Rocket, Package, HardDrive, FolderOpen } from 'lucide-react';
|
||||
export default function Home() {
|
||||
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>('scripts');
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToTerminal = () => {
|
||||
if (terminalRef.current) {
|
||||
// Get the element's position and scroll with a small offset for better mobile experience
|
||||
const elementTop = terminalRef.current.offsetTop;
|
||||
const offset = window.innerWidth < 768 ? 20 : 0; // Small offset on mobile
|
||||
|
||||
window.scrollTo({
|
||||
top: elementTop - offset,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunScript = (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => {
|
||||
setRunningScript({ path: scriptPath, name: scriptName, mode, server });
|
||||
// Scroll to terminal after a short delay to ensure it's rendered
|
||||
setTimeout(scrollToTerminal, 100);
|
||||
};
|
||||
|
||||
const handleCloseTerminal = () => {
|
||||
@@ -43,13 +60,10 @@ export default function Home() {
|
||||
|
||||
{/* Controls */}
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<div className="flex flex-col gap-4 p-4 sm:p-6 bg-card rounded-lg shadow-sm border border-border">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<SettingsButton />
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<ResyncButton />
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-4 p-4 sm:p-6 bg-card rounded-lg shadow-sm border border-border">
|
||||
<ServerSettingsButton />
|
||||
<SettingsButton />
|
||||
<ResyncButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -104,7 +118,7 @@ export default function Home() {
|
||||
|
||||
{/* Running Script Terminal */}
|
||||
{runningScript && (
|
||||
<div className="mb-8">
|
||||
<div ref={terminalRef} className="mb-8">
|
||||
<Terminal
|
||||
scriptPath={runningScript.path}
|
||||
onClose={handleCloseTerminal}
|
||||
@@ -120,7 +134,7 @@ export default function Home() {
|
||||
)}
|
||||
|
||||
{activeTab === 'downloaded' && (
|
||||
<DownloadedScriptsTab />
|
||||
<DownloadedScriptsTab onInstallScript={handleRunScript} />
|
||||
)}
|
||||
|
||||
{activeTab === 'installed' && (
|
||||
|
||||
@@ -203,5 +203,349 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
stats: null
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Auto-detect LXC containers with community-script tag
|
||||
autoDetectLXCContainers: publicProcedure
|
||||
.input(z.object({ serverId: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
console.log('=== AUTO-DETECT API ENDPOINT CALLED ===');
|
||||
console.log('Input received:', input);
|
||||
console.log('Timestamp:', new Date().toISOString());
|
||||
|
||||
try {
|
||||
console.log('Starting auto-detect LXC containers for server ID:', input.serverId);
|
||||
|
||||
const db = getDatabase();
|
||||
const server = db.getServerById(input.serverId);
|
||||
|
||||
if (!server) {
|
||||
console.error('Server not found for ID:', input.serverId);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Server not found',
|
||||
detectedContainers: []
|
||||
};
|
||||
}
|
||||
|
||||
console.log('Found server:', (server as any).name, 'at', (server as any).ip);
|
||||
|
||||
// Import SSH services
|
||||
const { default: SSHService } = await import('~/server/ssh-service');
|
||||
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||
const sshService = new SSHService();
|
||||
const sshExecutionService = new SSHExecutionService();
|
||||
|
||||
// Test SSH connection first
|
||||
console.log('Testing SSH connection...');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
const connectionTest = await sshService.testSSHConnection(server as any);
|
||||
console.log('SSH connection test result:', connectionTest);
|
||||
|
||||
if (!(connectionTest as any).success) {
|
||||
return {
|
||||
success: false,
|
||||
error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`,
|
||||
detectedContainers: []
|
||||
};
|
||||
}
|
||||
|
||||
console.log('SSH connection successful, scanning for LXC containers...');
|
||||
|
||||
// Use the working approach - manual loop through all config files
|
||||
const command = `for file in /etc/pve/lxc/*.conf; do if [ -f "$file" ]; then if grep -q "community-script" "$file"; then echo "$file"; fi; fi; done`;
|
||||
let detectedContainers: any[] = [];
|
||||
|
||||
console.log('Executing manual loop command...');
|
||||
console.log('Command:', command);
|
||||
|
||||
let commandOutput = '';
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
|
||||
void sshExecutionService.executeCommand(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
server as any,
|
||||
command,
|
||||
(data: string) => {
|
||||
console.log('Command output chunk:', data);
|
||||
commandOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
console.error('Command error:', error);
|
||||
},
|
||||
(exitCode: number) => {
|
||||
console.log('Command exit code:', exitCode);
|
||||
console.log('Full command output:', commandOutput);
|
||||
|
||||
// Parse the complete output to get config file paths that contain community-script tag
|
||||
const configFiles = commandOutput.split('\n')
|
||||
.filter((line: string) => line.trim())
|
||||
.map((line: string) => line.trim())
|
||||
.filter((line: string) => line.endsWith('.conf'));
|
||||
|
||||
console.log('Found config files with community-script tag:', configFiles.length);
|
||||
console.log('Config files:', configFiles);
|
||||
|
||||
// Process each config file to extract hostname
|
||||
const processPromises = configFiles.map(async (configPath: string) => {
|
||||
try {
|
||||
const containerId = configPath.split('/').pop()?.replace('.conf', '');
|
||||
if (!containerId) return null;
|
||||
|
||||
console.log('Processing container:', containerId, 'from', configPath);
|
||||
|
||||
// Read the config file content
|
||||
const readCommand = `cat "${configPath}" 2>/dev/null`;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return new Promise<any>((readResolve) => {
|
||||
|
||||
void sshExecutionService.executeCommand(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
server as any,
|
||||
readCommand,
|
||||
(configData: string) => {
|
||||
console.log('Config data for', containerId, ':', configData.substring(0, 300) + '...');
|
||||
|
||||
// Parse config file for hostname
|
||||
const lines = configData.split('\n');
|
||||
let hostname = '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (trimmedLine.startsWith('hostname:')) {
|
||||
hostname = trimmedLine.substring(9).trim();
|
||||
console.log('Found hostname for', containerId, ':', hostname);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hostname) {
|
||||
const container = {
|
||||
containerId,
|
||||
hostname,
|
||||
configPath,
|
||||
serverId: (server as any).id,
|
||||
serverName: (server as any).name
|
||||
};
|
||||
console.log('Adding container to detected list:', container);
|
||||
readResolve(container);
|
||||
} else {
|
||||
console.log('No hostname found for', containerId);
|
||||
readResolve(null);
|
||||
}
|
||||
},
|
||||
(readError: string) => {
|
||||
console.error(`Error reading config file ${configPath}:`, readError);
|
||||
readResolve(null);
|
||||
},
|
||||
(_exitCode: number) => {
|
||||
readResolve(null);
|
||||
}
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error processing config file ${configPath}:`, error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all config files to be processed
|
||||
void Promise.all(processPromises).then((results) => {
|
||||
detectedContainers = results.filter(result => result !== null);
|
||||
console.log('Final detected containers:', detectedContainers.length);
|
||||
resolve();
|
||||
}).catch((error) => {
|
||||
console.error('Error processing config files:', error);
|
||||
reject(new Error(`Error processing config files: ${error}`));
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
console.log('Detected containers:', detectedContainers.length);
|
||||
|
||||
// Get existing scripts to check for duplicates
|
||||
const existingScripts = db.getAllInstalledScripts();
|
||||
console.log('Existing scripts in database:', existingScripts.length);
|
||||
|
||||
// Create installed script records for detected containers (skip duplicates)
|
||||
const createdScripts = [];
|
||||
const skippedScripts = [];
|
||||
|
||||
for (const container of detectedContainers) {
|
||||
try {
|
||||
// Check if a script with this container_id and server_id already exists
|
||||
const duplicate = existingScripts.find((script: any) =>
|
||||
script.container_id === container.containerId &&
|
||||
script.server_id === container.serverId
|
||||
);
|
||||
|
||||
if (duplicate) {
|
||||
console.log(`Skipping duplicate: ${container.hostname} (${container.containerId}) already exists`);
|
||||
skippedScripts.push({
|
||||
containerId: container.containerId,
|
||||
hostname: container.hostname,
|
||||
serverName: container.serverName
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log('Creating script record for:', container.hostname, container.containerId);
|
||||
const result = db.createInstalledScript({
|
||||
script_name: container.hostname,
|
||||
script_path: `detected/${container.hostname}`,
|
||||
container_id: container.containerId,
|
||||
server_id: container.serverId,
|
||||
execution_mode: 'ssh',
|
||||
status: 'success',
|
||||
output_log: `Auto-detected from LXC config: ${container.configPath}`
|
||||
});
|
||||
|
||||
createdScripts.push({
|
||||
id: result.lastInsertRowid,
|
||||
containerId: container.containerId,
|
||||
hostname: container.hostname,
|
||||
serverName: container.serverName
|
||||
});
|
||||
console.log('Created script record with ID:', result.lastInsertRowid);
|
||||
} catch (error) {
|
||||
console.error(`Error creating script record for ${container.hostname}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const message = skippedScripts.length > 0
|
||||
? `Auto-detection completed. Found ${detectedContainers.length} containers with community-script tag. Added ${createdScripts.length} new scripts, skipped ${skippedScripts.length} duplicates.`
|
||||
: `Auto-detection completed. Found ${detectedContainers.length} containers with community-script tag. Added ${createdScripts.length} new scripts.`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: message,
|
||||
detectedContainers: createdScripts,
|
||||
skippedContainers: skippedScripts
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in autoDetectLXCContainers:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to auto-detect LXC containers',
|
||||
detectedContainers: []
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Cleanup orphaned scripts (check if LXC containers still exist on servers)
|
||||
cleanupOrphanedScripts: publicProcedure
|
||||
.mutation(async () => {
|
||||
try {
|
||||
console.log('=== CLEANUP ORPHANED SCRIPTS API ENDPOINT CALLED ===');
|
||||
console.log('Timestamp:', new Date().toISOString());
|
||||
|
||||
const db = getDatabase();
|
||||
const allScripts = db.getAllInstalledScripts();
|
||||
const allServers = db.getAllServers();
|
||||
|
||||
console.log('Found scripts:', allScripts.length);
|
||||
console.log('Found servers:', allServers.length);
|
||||
|
||||
if (allScripts.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'No scripts to check',
|
||||
deletedCount: 0,
|
||||
deletedScripts: []
|
||||
};
|
||||
}
|
||||
|
||||
// Import SSH services
|
||||
const { default: SSHService } = await import('~/server/ssh-service');
|
||||
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||
const sshService = new SSHService();
|
||||
const sshExecutionService = new SSHExecutionService();
|
||||
|
||||
const deletedScripts: string[] = [];
|
||||
const scriptsToCheck = allScripts.filter((script: any) =>
|
||||
script.execution_mode === 'ssh' &&
|
||||
script.server_id &&
|
||||
script.container_id
|
||||
);
|
||||
|
||||
console.log('Scripts to check for cleanup:', scriptsToCheck.length);
|
||||
|
||||
for (const script of scriptsToCheck) {
|
||||
try {
|
||||
const scriptData = script as any;
|
||||
const server = allServers.find((s: any) => s.id === scriptData.server_id);
|
||||
if (!server) {
|
||||
console.log(`Server not found for script ${scriptData.script_name}, marking for deletion`);
|
||||
db.deleteInstalledScript(Number(scriptData.id));
|
||||
deletedScripts.push(String(scriptData.script_name));
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Checking script ${scriptData.script_name} on server ${(server as any).name}`);
|
||||
|
||||
// Test SSH connection
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
const connectionTest = await sshService.testSSHConnection(server as any);
|
||||
if (!(connectionTest as any).success) {
|
||||
console.log(`SSH connection failed for server ${(server as any).name}, skipping script ${scriptData.script_name}`);
|
||||
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"`;
|
||||
|
||||
const containerExists = await new Promise<boolean>((resolve) => {
|
||||
|
||||
void sshExecutionService.executeCommand(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
server as any,
|
||||
checkCommand,
|
||||
(data: string) => {
|
||||
console.log(`Container check result for ${scriptData.script_name}:`, data.trim());
|
||||
resolve(data.trim() === 'exists');
|
||||
},
|
||||
(error: string) => {
|
||||
console.error(`Error checking container ${scriptData.script_name}:`, error);
|
||||
resolve(false);
|
||||
},
|
||||
(_exitCode: number) => {
|
||||
resolve(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (!containerExists) {
|
||||
console.log(`Container ${scriptData.container_id} not found on server ${(server as any).name}, deleting script ${scriptData.script_name}`);
|
||||
db.deleteInstalledScript(Number(scriptData.id));
|
||||
deletedScripts.push(String(scriptData.script_name));
|
||||
} else {
|
||||
console.log(`Container ${scriptData.container_id} still exists on server ${(server as any).name}, keeping script ${scriptData.script_name}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error checking script ${(script as any).script_name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Cleanup completed. Deleted scripts:', deletedScripts);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Cleanup completed. ${deletedScripts.length} orphaned script(s) removed.`,
|
||||
deletedCount: deletedScripts.length,
|
||||
deletedScripts: deletedScripts
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in cleanupOrphanedScripts:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to cleanup orphaned scripts',
|
||||
deletedCount: 0,
|
||||
deletedScripts: []
|
||||
};
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
|
||||
/* Terminal-specific styles for ANSI escape code rendering */
|
||||
.terminal-output {
|
||||
font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@@ -142,6 +142,25 @@
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
/* Enhanced terminal styling */
|
||||
.xterm {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* Set basic background - let ANSI colors work naturally */
|
||||
.xterm .xterm-viewport {
|
||||
background-color: #0d1117;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
background-color: #0d1117;
|
||||
}
|
||||
|
||||
/* Better selection colors */
|
||||
.xterm .xterm-selection {
|
||||
background-color: #264f78;
|
||||
}
|
||||
|
||||
/* Mobile-specific improvements */
|
||||
@media (max-width: 640px) {
|
||||
/* Improve touch targets */
|
||||
|
||||
@@ -592,6 +592,7 @@ start_application() {
|
||||
if [ "$SERVICE_WAS_RUNNING" = true ] && check_service; then
|
||||
log "Service was running before update, re-enabling and starting systemd service..."
|
||||
if systemctl enable --now pvescriptslocal.service; then
|
||||
systemctl restart pvescriptslocal.service
|
||||
log_success "Service enabled and started successfully"
|
||||
# Wait a moment and check if it's running
|
||||
sleep 2
|
||||
|
||||
Reference in New Issue
Block a user