Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af58ba856e |
@@ -16,8 +16,4 @@ ALLOWED_SCRIPT_PATHS="scripts/"
|
|||||||
|
|
||||||
# WebSocket Configuration
|
# WebSocket Configuration
|
||||||
WEBSOCKET_PORT="3001"
|
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: 64 KiB After Width: | Height: | Size: 63 KiB |
@@ -1,43 +0,0 @@
|
|||||||
#!/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}"
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/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
Normal file
29
server.log
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
|
||||||
|
> 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,16 +9,7 @@ import { FilterBar, type FilterState } from './FilterBar';
|
|||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
import type { ScriptCard as ScriptCardType } from '~/types/script';
|
||||||
|
|
||||||
interface DownloadedScriptsTabProps {
|
export function DownloadedScriptsTab() {
|
||||||
onInstallScript?: (
|
|
||||||
scriptPath: string,
|
|
||||||
scriptName: string,
|
|
||||||
mode?: "local" | "ssh",
|
|
||||||
server?: any,
|
|
||||||
) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabProps) {
|
|
||||||
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
|
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
@@ -29,8 +20,6 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
sortBy: 'name',
|
sortBy: 'name',
|
||||||
sortOrder: 'asc',
|
sortOrder: 'asc',
|
||||||
});
|
});
|
||||||
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
|
|
||||||
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
|
|
||||||
const gridRef = useRef<HTMLDivElement>(null);
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||||
@@ -40,62 +29,6 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
{ enabled: !!selectedSlug }
|
{ 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
|
// Extract categories from metadata
|
||||||
const categories = React.useMemo((): string[] => {
|
const categories = React.useMemo((): string[] => {
|
||||||
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
||||||
@@ -408,8 +341,6 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
totalScripts={downloadedScripts.length}
|
totalScripts={downloadedScripts.length}
|
||||||
filteredCount={filteredScripts.length}
|
filteredCount={filteredScripts.length}
|
||||||
updatableCount={filterCounts.updatableCount}
|
updatableCount={filterCounts.updatableCount}
|
||||||
saveFiltersEnabled={saveFiltersEnabled}
|
|
||||||
isLoadingFilters={isLoadingFilters}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Scripts Grid */}
|
{/* Scripts Grid */}
|
||||||
@@ -471,7 +402,9 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
|||||||
script={scriptData?.success ? scriptData.script : null}
|
script={scriptData?.success ? scriptData.script : null}
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
onClose={handleCloseModal}
|
onClose={handleCloseModal}
|
||||||
onInstallScript={onInstallScript}
|
onInstallScript={() => {
|
||||||
|
// Downloaded scripts don't need installation
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ interface FilterBarProps {
|
|||||||
totalScripts: number;
|
totalScripts: number;
|
||||||
filteredCount: number;
|
filteredCount: number;
|
||||||
updatableCount?: number;
|
updatableCount?: number;
|
||||||
saveFiltersEnabled?: boolean;
|
|
||||||
isLoadingFilters?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCRIPT_TYPES = [
|
const SCRIPT_TYPES = [
|
||||||
@@ -35,8 +33,6 @@ export function FilterBar({
|
|||||||
totalScripts,
|
totalScripts,
|
||||||
filteredCount,
|
filteredCount,
|
||||||
updatableCount = 0,
|
updatableCount = 0,
|
||||||
saveFiltersEnabled = false,
|
|
||||||
isLoadingFilters = false,
|
|
||||||
}: FilterBarProps) {
|
}: FilterBarProps) {
|
||||||
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
|
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
|
||||||
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
|
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
|
||||||
@@ -82,28 +78,6 @@ export function FilterBar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 rounded-lg border border-border bg-card p-4 sm:p-6 shadow-sm">
|
<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 */}
|
{/* Search Bar */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="relative max-w-md w-full">
|
<div className="relative max-w-md w-full">
|
||||||
|
|||||||
@@ -1,308 +0,0 @@
|
|||||||
'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';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState } from 'react';
|
||||||
import { api } from '~/trpc/react';
|
import { api } from '~/trpc/react';
|
||||||
import { Terminal } from './Terminal';
|
import { Terminal } from './Terminal';
|
||||||
import { StatusBadge } from './Badge';
|
import { StatusBadge } from './Badge';
|
||||||
@@ -26,18 +26,11 @@ export function InstalledScriptsTab() {
|
|||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<'all' | 'success' | 'failed' | 'in_progress'>('all');
|
const [statusFilter, setStatusFilter] = useState<'all' | 'success' | 'failed' | 'in_progress'>('all');
|
||||||
const [serverFilter, setServerFilter] = useState<string>('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 [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any } | null>(null);
|
||||||
const [editingScriptId, setEditingScriptId] = useState<number | null>(null);
|
const [editingScriptId, setEditingScriptId] = useState<number | null>(null);
|
||||||
const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' });
|
const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' });
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
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 [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
|
// Fetch installed scripts
|
||||||
const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery();
|
const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery();
|
||||||
@@ -75,139 +68,24 @@ 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 scripts: InstalledScript[] = (scriptsData?.scripts as InstalledScript[]) ?? [];
|
||||||
const stats = statsData?.stats;
|
const stats = statsData?.stats;
|
||||||
|
|
||||||
// Run cleanup when component mounts and scripts are loaded (only once)
|
// Filter scripts based on search and filters
|
||||||
useEffect(() => {
|
const filteredScripts = scripts.filter((script: InstalledScript) => {
|
||||||
if (scripts.length > 0 && serversData?.servers && !cleanupMutation.isPending && !cleanupRunRef.current) {
|
const matchesSearch = script.script_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
console.log('Running automatic cleanup check...');
|
(script.container_id?.includes(searchTerm) ?? false) ||
|
||||||
cleanupRunRef.current = true;
|
(script.server_name?.toLowerCase().includes(searchTerm.toLowerCase()) ?? false);
|
||||||
void cleanupMutation.mutate();
|
|
||||||
}
|
const matchesStatus = statusFilter === 'all' || script.status === statusFilter;
|
||||||
}, [scripts.length, serversData?.servers, cleanupMutation]);
|
|
||||||
|
const matchesServer = serverFilter === 'all' ||
|
||||||
// Filter and sort scripts
|
(serverFilter === 'local' && !script.server_name) ||
|
||||||
const filteredScripts = scripts
|
(script.server_name === serverFilter);
|
||||||
.filter((script: InstalledScript) => {
|
|
||||||
const matchesSearch = script.script_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
return matchesSearch && matchesStatus && matchesServer;
|
||||||
(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
|
// Get unique servers for filter
|
||||||
const uniqueServers: string[] = [];
|
const uniqueServers: string[] = [];
|
||||||
@@ -319,34 +197,6 @@ export function InstalledScriptsTab() {
|
|||||||
setAddFormData({ script_name: '', container_id: '', server_id: 'local' });
|
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) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleString();
|
return new Date(dateString).toLocaleString();
|
||||||
@@ -401,8 +251,8 @@ export function InstalledScriptsTab() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add Script and Auto-Detect Buttons */}
|
{/* Add Script Button */}
|
||||||
<div className="mb-4 flex flex-col sm:flex-row gap-3">
|
<div className="mb-4">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowAddForm(!showAddForm)}
|
onClick={() => setShowAddForm(!showAddForm)}
|
||||||
variant={showAddForm ? "outline" : "default"}
|
variant={showAddForm ? "outline" : "default"}
|
||||||
@@ -410,13 +260,6 @@ export function InstalledScriptsTab() {
|
|||||||
>
|
>
|
||||||
{showAddForm ? 'Cancel Add Script' : '+ Add Manual Script Entry'}
|
{showAddForm ? 'Cancel Add Script' : '+ Add Manual Script Entry'}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Add Script Form */}
|
{/* Add Script Form */}
|
||||||
@@ -488,145 +331,6 @@ export function InstalledScriptsTab() {
|
|||||||
</div>
|
</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 */}
|
{/* Filters */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Search Input - Full Width on Mobile */}
|
{/* Search Input - Full Width on Mobile */}
|
||||||
@@ -701,70 +405,20 @@ export function InstalledScriptsTab() {
|
|||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-muted">
|
<thead className="bg-muted">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
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"
|
Script Name
|
||||||
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>
|
||||||
<th
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
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"
|
Container ID
|
||||||
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>
|
||||||
<th
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
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"
|
Server
|
||||||
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>
|
||||||
<th
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
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"
|
Status
|
||||||
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>
|
||||||
<th
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
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"
|
Installation Date
|
||||||
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>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
Actions
|
Actions
|
||||||
|
|||||||
@@ -120,6 +120,9 @@ export function ScriptDetailModal({
|
|||||||
// Pass execution mode and server info to the parent
|
// Pass execution mode and server info to the parent
|
||||||
onInstallScript(scriptPath, scriptName, mode, server);
|
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
|
onClose(); // Close the modal when starting installation
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,8 +26,6 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
sortBy: 'name',
|
sortBy: 'name',
|
||||||
sortOrder: 'asc',
|
sortOrder: 'asc',
|
||||||
});
|
});
|
||||||
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
|
|
||||||
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
|
|
||||||
const gridRef = useRef<HTMLDivElement>(null);
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||||
@@ -37,62 +35,6 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
{ enabled: !!selectedSlug }
|
{ 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
|
// Extract categories from metadata
|
||||||
const categories = React.useMemo((): string[] => {
|
const categories = React.useMemo((): string[] => {
|
||||||
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
||||||
@@ -395,8 +337,6 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
totalScripts={scriptsWithStatus.length}
|
totalScripts={scriptsWithStatus.length}
|
||||||
filteredCount={filteredScripts.length}
|
filteredCount={filteredScripts.length}
|
||||||
updatableCount={filterCounts.updatableCount}
|
updatableCount={filterCounts.updatableCount}
|
||||||
saveFiltersEnabled={saveFiltersEnabled}
|
|
||||||
isLoadingFilters={isLoadingFilters}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
|
{/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
'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,9 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { GeneralSettingsModal } from './GeneralSettingsModal';
|
import { SettingsModal } from './SettingsModal';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Settings } from 'lucide-react';
|
|
||||||
|
|
||||||
export function SettingsButton() {
|
export function SettingsButton() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -12,21 +11,41 @@ export function SettingsButton() {
|
|||||||
<>
|
<>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
<div className="text-sm text-muted-foreground font-medium">
|
<div className="text-sm text-muted-foreground font-medium">
|
||||||
Application Settings:
|
Add and manage PVE Servers:
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
className="inline-flex items-center"
|
className="inline-flex items-center"
|
||||||
title="Open Settings"
|
title="Add PVE Server"
|
||||||
>
|
>
|
||||||
<Settings className="w-5 h-5 mr-2" />
|
<svg
|
||||||
Settings
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GeneralSettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
<SettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
const [servers, setServers] = useState<Server[]>([]);
|
const [servers, setServers] = useState<Server[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<'servers' | 'general'>('servers');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -115,6 +116,35 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 */}
|
{/* Content */}
|
||||||
<div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(95vh-180px)] sm:max-h-[calc(90vh-200px)]">
|
<div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(95vh-180px)] sm:max-h-[calc(90vh-200px)]">
|
||||||
@@ -134,28 +164,37 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-4 sm:space-y-6">
|
{activeTab === 'servers' && (
|
||||||
<div>
|
<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">Server Configurations</h3>
|
<div>
|
||||||
<ServerForm onSubmit={handleCreateServer} />
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'general' && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Saved Servers</h3>
|
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">General Settings</h3>
|
||||||
{loading ? (
|
<p className="text-sm sm:text-base text-muted-foreground">General settings will be available in a future update.</p>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
const [lastInputSent, setLastInputSent] = useState<string | null>(null);
|
const [lastInputSent, setLastInputSent] = useState<string | null>(null);
|
||||||
const [inWhiptailSession, setInWhiptailSession] = useState(false);
|
const [inWhiptailSession, setInWhiptailSession] = useState(false);
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
const [isStopped, setIsStopped] = useState(false);
|
|
||||||
const terminalRef = useRef<HTMLDivElement>(null);
|
const terminalRef = useRef<HTMLDivElement>(null);
|
||||||
const xtermRef = useRef<any>(null);
|
const xtermRef = useRef<any>(null);
|
||||||
const fitAddonRef = useRef<any>(null);
|
const fitAddonRef = useRef<any>(null);
|
||||||
@@ -65,8 +64,23 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
if (message.data.includes('\x1b[2J') || message.data.includes('\x1b[H\x1b[2J')) {
|
if (message.data.includes('\x1b[2J') || message.data.includes('\x1b[H\x1b[2J')) {
|
||||||
// This is a clear screen sequence, ensure it's processed correctly
|
// This is a clear screen sequence, ensure it's processed correctly
|
||||||
xtermRef.current.write(message.data);
|
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 {
|
} else {
|
||||||
// Let xterm handle all ANSI sequences naturally
|
|
||||||
xtermRef.current.write(message.data);
|
xtermRef.current.write(message.data);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -139,27 +153,9 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
|
|
||||||
const terminal = new XTerm({
|
const terminal = new XTerm({
|
||||||
theme: {
|
theme: {
|
||||||
background: '#0d1117',
|
background: '#000000',
|
||||||
foreground: '#e6edf3',
|
foreground: '#00ff00',
|
||||||
cursor: '#58a6ff',
|
cursor: '#00ff00',
|
||||||
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,
|
fontSize: isMobile ? 7 : 14,
|
||||||
fontFamily: 'JetBrains Mono, Fira Code, Cascadia Code, Monaco, Menlo, Ubuntu Mono, monospace',
|
fontFamily: 'JetBrains Mono, Fira Code, Cascadia Code, Monaco, Menlo, Ubuntu Mono, monospace',
|
||||||
@@ -193,13 +189,6 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
// Open terminal
|
// Open terminal
|
||||||
terminal.open(terminalElement);
|
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
|
// Fit after a small delay to ensure proper sizing
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
@@ -280,7 +269,6 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
}
|
}
|
||||||
|
|
||||||
isConnectingRef.current = true;
|
isConnectingRef.current = true;
|
||||||
const isInitialConnection = !hasConnectedRef.current;
|
|
||||||
hasConnectedRef.current = true;
|
hasConnectedRef.current = true;
|
||||||
|
|
||||||
// Small delay to prevent rapid reconnection
|
// Small delay to prevent rapid reconnection
|
||||||
@@ -296,19 +284,17 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
isConnectingRef.current = false;
|
isConnectingRef.current = false;
|
||||||
|
|
||||||
// Only auto-start on initial connection, not on reconnections
|
// Send start message immediately after connection
|
||||||
if (isInitialConnection && !isRunning) {
|
const message = {
|
||||||
const message = {
|
action: 'start',
|
||||||
action: 'start',
|
scriptPath,
|
||||||
scriptPath,
|
executionId,
|
||||||
executionId,
|
mode,
|
||||||
mode,
|
server,
|
||||||
server,
|
isUpdate,
|
||||||
isUpdate,
|
containerId
|
||||||
containerId
|
};
|
||||||
};
|
ws.send(JSON.stringify(message));
|
||||||
ws.send(JSON.stringify(message));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
@@ -349,8 +335,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
}, [scriptPath, executionId, mode, server, isUpdate, containerId, handleMessage, isMobile]);
|
}, [scriptPath, executionId, mode, server, isUpdate, containerId, handleMessage, isMobile]);
|
||||||
|
|
||||||
const startScript = () => {
|
const startScript = () => {
|
||||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) {
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||||
setIsStopped(false);
|
|
||||||
wsRef.current.send(JSON.stringify({
|
wsRef.current.send(JSON.stringify({
|
||||||
action: 'start',
|
action: 'start',
|
||||||
scriptPath,
|
scriptPath,
|
||||||
@@ -365,8 +350,6 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
|
|
||||||
const stopScript = () => {
|
const stopScript = () => {
|
||||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||||
setIsStopped(true);
|
|
||||||
setIsRunning(false);
|
|
||||||
wsRef.current.send(JSON.stringify({
|
wsRef.current.send(JSON.stringify({
|
||||||
action: 'stop',
|
action: 'stop',
|
||||||
executionId
|
executionId
|
||||||
@@ -603,10 +586,10 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
<div className="flex flex-wrap gap-1 sm:gap-2">
|
<div className="flex flex-wrap gap-1 sm:gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={startScript}
|
onClick={startScript}
|
||||||
disabled={!isConnected || (isRunning && !isStopped)}
|
disabled={!isConnected || isRunning}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={`text-xs sm:text-sm ${isConnected && (!isRunning || isStopped) ? 'bg-green-600 hover:bg-green-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}`}
|
className={`text-xs sm:text-sm ${isConnected && !isRunning ? '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" />
|
<Play className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||||
<span className="hidden sm:inline">Start</span>
|
<span className="hidden sm:inline">Start</span>
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
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 }
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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 }
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
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,13 +1,12 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef } from 'react';
|
import { useState } from 'react';
|
||||||
import { ScriptsGrid } from './_components/ScriptsGrid';
|
import { ScriptsGrid } from './_components/ScriptsGrid';
|
||||||
import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
|
import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
|
||||||
import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
|
import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
|
||||||
import { ResyncButton } from './_components/ResyncButton';
|
import { ResyncButton } from './_components/ResyncButton';
|
||||||
import { Terminal } from './_components/Terminal';
|
import { Terminal } from './_components/Terminal';
|
||||||
import { ServerSettingsButton } from './_components/ServerSettingsButton';
|
|
||||||
import { SettingsButton } from './_components/SettingsButton';
|
import { SettingsButton } from './_components/SettingsButton';
|
||||||
import { VersionDisplay } from './_components/VersionDisplay';
|
import { VersionDisplay } from './_components/VersionDisplay';
|
||||||
import { Button } from './_components/ui/button';
|
import { Button } from './_components/ui/button';
|
||||||
@@ -16,25 +15,9 @@ import { Rocket, Package, HardDrive, FolderOpen } from 'lucide-react';
|
|||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
|
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>('scripts');
|
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) => {
|
const handleRunScript = (scriptPath: string, scriptName: string, mode?: 'local' | 'ssh', server?: any) => {
|
||||||
setRunningScript({ path: scriptPath, name: scriptName, mode, server });
|
setRunningScript({ path: scriptPath, name: scriptName, mode, server });
|
||||||
// Scroll to terminal after a short delay to ensure it's rendered
|
|
||||||
setTimeout(scrollToTerminal, 100);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseTerminal = () => {
|
const handleCloseTerminal = () => {
|
||||||
@@ -60,10 +43,13 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="mb-6 sm:mb-8">
|
<div className="mb-6 sm:mb-8">
|
||||||
<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">
|
<div className="flex flex-col gap-4 p-4 sm:p-6 bg-card rounded-lg shadow-sm border border-border">
|
||||||
<ServerSettingsButton />
|
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
<ResyncButton />
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||||
|
<ResyncButton />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -118,7 +104,7 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* Running Script Terminal */}
|
{/* Running Script Terminal */}
|
||||||
{runningScript && (
|
{runningScript && (
|
||||||
<div ref={terminalRef} className="mb-8">
|
<div className="mb-8">
|
||||||
<Terminal
|
<Terminal
|
||||||
scriptPath={runningScript.path}
|
scriptPath={runningScript.path}
|
||||||
onClose={handleCloseTerminal}
|
onClose={handleCloseTerminal}
|
||||||
@@ -134,7 +120,7 @@ export default function Home() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'downloaded' && (
|
{activeTab === 'downloaded' && (
|
||||||
<DownloadedScriptsTab onInstallScript={handleRunScript} />
|
<DownloadedScriptsTab />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'installed' && (
|
{activeTab === 'installed' && (
|
||||||
|
|||||||
@@ -203,349 +203,5 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
stats: null
|
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-specific styles for ANSI escape code rendering */
|
||||||
.terminal-output {
|
.terminal-output {
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
font-family: 'Courier New', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,25 +142,6 @@
|
|||||||
background-color: inherit;
|
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 */
|
/* Mobile-specific improvements */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
/* Improve touch targets */
|
/* Improve touch targets */
|
||||||
|
|||||||
@@ -592,7 +592,6 @@ start_application() {
|
|||||||
if [ "$SERVICE_WAS_RUNNING" = true ] && check_service; then
|
if [ "$SERVICE_WAS_RUNNING" = true ] && check_service; then
|
||||||
log "Service was running before update, re-enabling and starting systemd service..."
|
log "Service was running before update, re-enabling and starting systemd service..."
|
||||||
if systemctl enable --now pvescriptslocal.service; then
|
if systemctl enable --now pvescriptslocal.service; then
|
||||||
systemctl restart pvescriptslocal.service
|
|
||||||
log_success "Service enabled and started successfully"
|
log_success "Service enabled and started successfully"
|
||||||
# Wait a moment and check if it's running
|
# Wait a moment and check if it's running
|
||||||
sleep 2
|
sleep 2
|
||||||
|
|||||||
Reference in New Issue
Block a user