feat: Add GitHub script list functionality with cards and modal
- Add JSON_FOLDER environment variable for GitHub repo JSON path - Create TypeScript types for script JSON structure - Implement GitHub API service to fetch scripts from repository - Add tRPC routes for script management (getScriptCards, getAllScripts, getScriptBySlug, resyncScripts) - Create ScriptCard component for displaying script information - Create ScriptDetailModal for full script details view - Create ScriptsGrid component with loading and error states - Add ResyncButton component for refreshing scripts from upstream - Update main page with tabbed interface (GitHub Scripts vs Local Scripts) - Add proper error handling and loading states throughout
This commit is contained in:
35
scripts/json/debian.json
Normal file
35
scripts/json/debian.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "Debian",
|
||||||
|
"slug": "debian",
|
||||||
|
"categories": [
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"date_created": "2024-05-02",
|
||||||
|
"type": "ct",
|
||||||
|
"updateable": true,
|
||||||
|
"privileged": false,
|
||||||
|
"interface_port": null,
|
||||||
|
"documentation": null,
|
||||||
|
"website": "https://www.debian.org/",
|
||||||
|
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/debian.webp",
|
||||||
|
"config_path": "",
|
||||||
|
"description": "Debian Linux is a distribution that emphasizes free software. It supports many hardware platforms.",
|
||||||
|
"install_methods": [
|
||||||
|
{
|
||||||
|
"type": "default",
|
||||||
|
"script": "ct/debian.sh",
|
||||||
|
"resources": {
|
||||||
|
"cpu": 1,
|
||||||
|
"ram": 512,
|
||||||
|
"hdd": 2,
|
||||||
|
"os": "debian",
|
||||||
|
"version": "12"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default_credentials": {
|
||||||
|
"username": null,
|
||||||
|
"password": null
|
||||||
|
},
|
||||||
|
"notes": []
|
||||||
|
}
|
||||||
79
src/app/_components/ResyncButton.tsx
Normal file
79
src/app/_components/ResyncButton.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { api } from '~/trpc/react';
|
||||||
|
|
||||||
|
export function ResyncButton() {
|
||||||
|
const [isResyncing, setIsResyncing] = useState(false);
|
||||||
|
const [lastSync, setLastSync] = useState<Date | null>(null);
|
||||||
|
const [syncMessage, setSyncMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const resyncMutation = api.scripts.resyncScripts.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setIsResyncing(false);
|
||||||
|
setLastSync(new Date());
|
||||||
|
if (data.success) {
|
||||||
|
setSyncMessage(data.message || 'Scripts synced successfully');
|
||||||
|
} else {
|
||||||
|
setSyncMessage(data.error || 'Failed to sync scripts');
|
||||||
|
}
|
||||||
|
// Clear message after 3 seconds
|
||||||
|
setTimeout(() => setSyncMessage(null), 3000);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setIsResyncing(false);
|
||||||
|
setSyncMessage(`Error: ${error.message}`);
|
||||||
|
setTimeout(() => setSyncMessage(null), 3000);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleResync = async () => {
|
||||||
|
setIsResyncing(true);
|
||||||
|
setSyncMessage(null);
|
||||||
|
resyncMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<button
|
||||||
|
onClick={handleResync}
|
||||||
|
disabled={isResyncing}
|
||||||
|
className={`flex items-center space-x-2 px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
isResyncing
|
||||||
|
? 'bg-gray-400 text-white cursor-not-allowed'
|
||||||
|
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isResyncing ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||||
|
<span>Syncing...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
<span>Resync Scripts</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{lastSync && (
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
Last sync: {lastSync.toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{syncMessage && (
|
||||||
|
<div className={`text-sm px-3 py-1 rounded-lg ${
|
||||||
|
syncMessage.includes('Error') || syncMessage.includes('Failed')
|
||||||
|
? 'bg-red-100 text-red-700'
|
||||||
|
: 'bg-green-100 text-green-700'
|
||||||
|
}`}>
|
||||||
|
{syncMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
src/app/_components/ScriptCard.tsx
Normal file
88
src/app/_components/ScriptCard.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { ScriptCard } from '~/types/script';
|
||||||
|
|
||||||
|
interface ScriptCardProps {
|
||||||
|
script: ScriptCard;
|
||||||
|
onClick: (script: ScriptCard) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScriptCard({ script, onClick }: ScriptCardProps) {
|
||||||
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
|
const handleImageError = () => {
|
||||||
|
setImageError(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-gray-200 hover:border-blue-300"
|
||||||
|
onClick={() => onClick(script)}
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Header with logo and name */}
|
||||||
|
<div className="flex items-start space-x-4 mb-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{script.logo && !imageError ? (
|
||||||
|
<img
|
||||||
|
src={script.logo}
|
||||||
|
alt={`${script.name} logo`}
|
||||||
|
className="w-12 h-12 rounded-lg object-contain"
|
||||||
|
onError={handleImageError}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 bg-gray-200 rounded-lg flex items-center justify-center">
|
||||||
|
<span className="text-gray-500 text-lg font-semibold">
|
||||||
|
{script.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||||
|
{script.name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center space-x-2 mt-1">
|
||||||
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
script.type === 'ct'
|
||||||
|
? 'bg-blue-100 text-blue-800'
|
||||||
|
: 'bg-gray-100 text-gray-800'
|
||||||
|
}`}>
|
||||||
|
{script.type.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
{script.updateable && (
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
Updateable
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-gray-600 text-sm line-clamp-3 mb-4">
|
||||||
|
{script.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Footer with website link */}
|
||||||
|
{script.website && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<a
|
||||||
|
href={script.website}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 hover:text-blue-800 text-sm font-medium flex items-center space-x-1"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<span>Website</span>
|
||||||
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
233
src/app/_components/ScriptDetailModal.tsx
Normal file
233
src/app/_components/ScriptDetailModal.tsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { Script } from '~/types/script';
|
||||||
|
|
||||||
|
interface ScriptDetailModalProps {
|
||||||
|
script: Script | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScriptDetailModal({ script, isOpen, onClose }: ScriptDetailModalProps) {
|
||||||
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
|
if (!isOpen || !script) return null;
|
||||||
|
|
||||||
|
const handleImageError = () => {
|
||||||
|
setImageError(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
|
||||||
|
onClick={handleBackdropClick}
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{script.logo && !imageError ? (
|
||||||
|
<img
|
||||||
|
src={script.logo}
|
||||||
|
alt={`${script.name} logo`}
|
||||||
|
className="w-16 h-16 rounded-lg object-contain"
|
||||||
|
onError={handleImageError}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-16 h-16 bg-gray-200 rounded-lg flex items-center justify-center">
|
||||||
|
<span className="text-gray-500 text-2xl font-semibold">
|
||||||
|
{script.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">{script.name}</h2>
|
||||||
|
<div className="flex items-center space-x-2 mt-1">
|
||||||
|
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${
|
||||||
|
script.type === 'ct'
|
||||||
|
? 'bg-blue-100 text-blue-800'
|
||||||
|
: 'bg-gray-100 text-gray-800'
|
||||||
|
}`}>
|
||||||
|
{script.type.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
{script.updateable && (
|
||||||
|
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
|
||||||
|
Updateable
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{script.privileged && (
|
||||||
|
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800">
|
||||||
|
Privileged
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-6 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>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">Description</h3>
|
||||||
|
<p className="text-gray-600">{script.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Basic Information */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-3">Basic Information</h3>
|
||||||
|
<dl className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-gray-500">Slug</dt>
|
||||||
|
<dd className="text-sm text-gray-900 font-mono">{script.slug}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-gray-500">Date Created</dt>
|
||||||
|
<dd className="text-sm text-gray-900">{script.date_created}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-gray-500">Categories</dt>
|
||||||
|
<dd className="text-sm text-gray-900">{script.categories.join(', ')}</dd>
|
||||||
|
</div>
|
||||||
|
{script.interface_port && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-gray-500">Interface Port</dt>
|
||||||
|
<dd className="text-sm text-gray-900">{script.interface_port}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{script.config_path && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-gray-500">Config Path</dt>
|
||||||
|
<dd className="text-sm text-gray-900 font-mono">{script.config_path}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-3">Links</h3>
|
||||||
|
<dl className="space-y-2">
|
||||||
|
{script.website && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-gray-500">Website</dt>
|
||||||
|
<dd className="text-sm">
|
||||||
|
<a
|
||||||
|
href={script.website}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 hover:text-blue-800 break-all"
|
||||||
|
>
|
||||||
|
{script.website}
|
||||||
|
</a>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{script.documentation && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-gray-500">Documentation</dt>
|
||||||
|
<dd className="text-sm">
|
||||||
|
<a
|
||||||
|
href={script.documentation}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-blue-600 hover:text-blue-800 break-all"
|
||||||
|
>
|
||||||
|
{script.documentation}
|
||||||
|
</a>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Install Methods */}
|
||||||
|
{script.install_methods.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-3">Install Methods</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{script.install_methods.map((method, index) => (
|
||||||
|
<div key={index} className="border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h4 className="font-medium text-gray-900 capitalize">{method.type}</h4>
|
||||||
|
<span className="text-sm text-gray-500 font-mono">{method.script}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<dt className="font-medium text-gray-500">CPU</dt>
|
||||||
|
<dd className="text-gray-900">{method.resources.cpu} cores</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="font-medium text-gray-500">RAM</dt>
|
||||||
|
<dd className="text-gray-900">{method.resources.ram} MB</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="font-medium text-gray-500">HDD</dt>
|
||||||
|
<dd className="text-gray-900">{method.resources.hdd} GB</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="font-medium text-gray-500">OS</dt>
|
||||||
|
<dd className="text-gray-900">{method.resources.os} {method.resources.version}</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Default Credentials */}
|
||||||
|
{(script.default_credentials.username || script.default_credentials.password) && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-3">Default Credentials</h3>
|
||||||
|
<dl className="space-y-2">
|
||||||
|
{script.default_credentials.username && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-gray-500">Username</dt>
|
||||||
|
<dd className="text-sm text-gray-900 font-mono">{script.default_credentials.username}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{script.default_credentials.password && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-gray-500">Password</dt>
|
||||||
|
<dd className="text-sm text-gray-900 font-mono">{script.default_credentials.password}</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{script.notes.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-3">Notes</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{script.notes.map((note, index) => (
|
||||||
|
<li key={index} className="text-sm text-gray-600 bg-gray-50 p-3 rounded-lg">
|
||||||
|
{note}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
src/app/_components/ScriptsGrid.tsx
Normal file
98
src/app/_components/ScriptsGrid.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { api } from '~/trpc/react';
|
||||||
|
import { ScriptCard } from './ScriptCard';
|
||||||
|
import { ScriptDetailModal } from './ScriptDetailModal';
|
||||||
|
import type { ScriptCard as ScriptCardType, Script } from '~/types/script';
|
||||||
|
|
||||||
|
export function ScriptsGrid() {
|
||||||
|
const [selectedScript, setSelectedScript] = useState<Script | null>(null);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data: scriptCardsData, isLoading, error, refetch } = api.scripts.getScriptCards.useQuery();
|
||||||
|
const { data: scriptData } = api.scripts.getScriptBySlug.useQuery(
|
||||||
|
{ slug: selectedScript?.slug ?? '' },
|
||||||
|
{ enabled: !!selectedScript }
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCardClick = (scriptCard: ScriptCardType) => {
|
||||||
|
// We'll fetch the full script data when the modal opens
|
||||||
|
setSelectedScript(scriptCard as any); // Temporary cast, will be replaced with full script data
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setSelectedScript(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
<span className="ml-2 text-gray-600">Loading scripts...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !scriptCardsData?.success) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-red-600 mb-4">
|
||||||
|
<svg className="w-12 h-12 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-lg font-medium">Failed to load scripts</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{scriptCardsData?.error || 'Unknown error occurred'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scripts = scriptCardsData.cards || [];
|
||||||
|
|
||||||
|
if (scripts.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-gray-500">
|
||||||
|
<svg className="w-12 h-12 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-lg font-medium">No scripts found</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
No script files were found in the repository.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
|
{scripts.map((script) => (
|
||||||
|
<ScriptCard
|
||||||
|
key={script.slug}
|
||||||
|
script={script}
|
||||||
|
onClick={handleCardClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScriptDetailModal
|
||||||
|
script={scriptData?.success ? scriptData.script : null}
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,11 +3,14 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ScriptsList } from './_components/ScriptsList';
|
import { ScriptsList } from './_components/ScriptsList';
|
||||||
|
import { ScriptsGrid } from './_components/ScriptsGrid';
|
||||||
import { RepoStatus } from './_components/RepoStatus';
|
import { RepoStatus } from './_components/RepoStatus';
|
||||||
|
import { ResyncButton } from './_components/ResyncButton';
|
||||||
import { Terminal } from './_components/Terminal';
|
import { Terminal } from './_components/Terminal';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [runningScript, setRunningScript] = useState<{ path: string; name: string } | null>(null);
|
const [runningScript, setRunningScript] = useState<{ path: string; name: string } | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<'local' | 'github'>('github');
|
||||||
|
|
||||||
const handleRunScript = (scriptPath: string, scriptName: string) => {
|
const handleRunScript = (scriptPath: string, scriptName: string) => {
|
||||||
setRunningScript({ path: scriptPath, name: scriptName });
|
setRunningScript({ path: scriptPath, name: scriptName });
|
||||||
@@ -35,6 +38,35 @@ export default function Home() {
|
|||||||
<RepoStatus />
|
<RepoStatus />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Script Source Tabs */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex space-x-1 bg-gray-100 p-1 rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('github')}
|
||||||
|
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
activeTab === 'github'
|
||||||
|
? 'bg-white text-gray-900 shadow-sm'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
GitHub Scripts
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('local')}
|
||||||
|
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||||
|
activeTab === 'local'
|
||||||
|
? 'bg-white text-gray-900 shadow-sm'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Local Scripts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{activeTab === 'github' && <ResyncButton />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Running Script Terminal */}
|
{/* Running Script Terminal */}
|
||||||
{runningScript && (
|
{runningScript && (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
@@ -46,7 +78,11 @@ export default function Home() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Scripts List */}
|
{/* Scripts List */}
|
||||||
|
{activeTab === 'github' ? (
|
||||||
|
<ScriptsGrid />
|
||||||
|
) : (
|
||||||
<ScriptsList onRunScript={handleRunScript} />
|
<ScriptsList onRunScript={handleRunScript} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const env = createEnv({
|
|||||||
REPO_URL: z.string().url().optional(),
|
REPO_URL: z.string().url().optional(),
|
||||||
REPO_BRANCH: z.string().default("main"),
|
REPO_BRANCH: z.string().default("main"),
|
||||||
SCRIPTS_DIRECTORY: z.string().default("scripts"),
|
SCRIPTS_DIRECTORY: z.string().default("scripts"),
|
||||||
|
JSON_FOLDER: z.string().default("json"),
|
||||||
ALLOWED_SCRIPT_EXTENSIONS: z.string().default(".sh,.py,.js,.ts,.bash"),
|
ALLOWED_SCRIPT_EXTENSIONS: z.string().default(".sh,.py,.js,.ts,.bash"),
|
||||||
// Security
|
// Security
|
||||||
MAX_SCRIPT_EXECUTION_TIME: z.string().default("300000"), // 5 minutes in ms
|
MAX_SCRIPT_EXECUTION_TIME: z.string().default("300000"), // 5 minutes in ms
|
||||||
@@ -43,6 +44,7 @@ export const env = createEnv({
|
|||||||
REPO_URL: process.env.REPO_URL,
|
REPO_URL: process.env.REPO_URL,
|
||||||
REPO_BRANCH: process.env.REPO_BRANCH,
|
REPO_BRANCH: process.env.REPO_BRANCH,
|
||||||
SCRIPTS_DIRECTORY: process.env.SCRIPTS_DIRECTORY,
|
SCRIPTS_DIRECTORY: process.env.SCRIPTS_DIRECTORY,
|
||||||
|
JSON_FOLDER: process.env.JSON_FOLDER,
|
||||||
ALLOWED_SCRIPT_EXTENSIONS: process.env.ALLOWED_SCRIPT_EXTENSIONS,
|
ALLOWED_SCRIPT_EXTENSIONS: process.env.ALLOWED_SCRIPT_EXTENSIONS,
|
||||||
// Security
|
// Security
|
||||||
MAX_SCRIPT_EXECUTION_TIME: process.env.MAX_SCRIPT_EXECUTION_TIME,
|
MAX_SCRIPT_EXECUTION_TIME: process.env.MAX_SCRIPT_EXECUTION_TIME,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|||||||
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||||
import { scriptManager } from "~/server/lib/scripts";
|
import { scriptManager } from "~/server/lib/scripts";
|
||||||
import { gitManager } from "~/server/lib/git";
|
import { gitManager } from "~/server/lib/git";
|
||||||
|
import { githubService } from "~/server/services/github";
|
||||||
|
|
||||||
export const scriptsRouter = createTRPCRouter({
|
export const scriptsRouter = createTRPCRouter({
|
||||||
// Get all available scripts
|
// Get all available scripts
|
||||||
@@ -55,5 +56,78 @@ export const scriptsRouter = createTRPCRouter({
|
|||||||
getDirectoryInfo: publicProcedure
|
getDirectoryInfo: publicProcedure
|
||||||
.query(async () => {
|
.query(async () => {
|
||||||
return scriptManager.getScriptsDirectoryInfo();
|
return scriptManager.getScriptsDirectoryInfo();
|
||||||
|
}),
|
||||||
|
|
||||||
|
// GitHub-based script routes
|
||||||
|
// Get all script cards from GitHub repo
|
||||||
|
getScriptCards: publicProcedure
|
||||||
|
.query(async () => {
|
||||||
|
try {
|
||||||
|
const cards = await githubService.getScriptCards();
|
||||||
|
return { success: true, cards };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch script cards',
|
||||||
|
cards: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Get all scripts from GitHub repo
|
||||||
|
getAllScripts: publicProcedure
|
||||||
|
.query(async () => {
|
||||||
|
try {
|
||||||
|
const scripts = await githubService.getAllScripts();
|
||||||
|
return { success: true, scripts };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch scripts',
|
||||||
|
scripts: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Get script by slug from GitHub repo
|
||||||
|
getScriptBySlug: publicProcedure
|
||||||
|
.input(z.object({ slug: z.string() }))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
const script = await githubService.getScriptBySlug(input.slug);
|
||||||
|
if (!script) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Script not found',
|
||||||
|
script: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { success: true, script };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to fetch script',
|
||||||
|
script: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Resync scripts from GitHub repo
|
||||||
|
resyncScripts: publicProcedure
|
||||||
|
.mutation(async () => {
|
||||||
|
try {
|
||||||
|
const scripts = await githubService.getAllScripts();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Successfully synced ${scripts.length} scripts`,
|
||||||
|
count: scripts.length
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to resync scripts',
|
||||||
|
count: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
126
src/server/services/github.ts
Normal file
126
src/server/services/github.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { env } from "~/env.js";
|
||||||
|
import type { Script, ScriptCard, GitHubFile } from "~/types/script.js";
|
||||||
|
|
||||||
|
export class GitHubService {
|
||||||
|
private baseUrl: string;
|
||||||
|
private repoUrl: string;
|
||||||
|
private branch: string;
|
||||||
|
private jsonFolder: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.repoUrl = env.REPO_URL || "";
|
||||||
|
this.branch = env.REPO_BRANCH;
|
||||||
|
this.jsonFolder = env.JSON_FOLDER;
|
||||||
|
|
||||||
|
// Extract owner and repo from the URL
|
||||||
|
const urlMatch = this.repoUrl.match(/github\.com\/([^\/]+)\/([^\/]+)/);
|
||||||
|
if (!urlMatch) {
|
||||||
|
throw new Error("Invalid GitHub repository URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, owner, repo] = urlMatch;
|
||||||
|
this.baseUrl = `https://api.github.com/repos/${owner}/${repo}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchFromGitHub<T>(endpoint: string): Promise<T> {
|
||||||
|
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/vnd.github.v3+json',
|
||||||
|
'User-Agent': 'PVEScripts-Local/1.0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getJsonFiles(): Promise<GitHubFile[]> {
|
||||||
|
try {
|
||||||
|
const files = await this.fetchFromGitHub<GitHubFile[]>(
|
||||||
|
`/contents/${this.jsonFolder}?ref=${this.branch}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter for JSON files only
|
||||||
|
return files.filter(file => file.name.endsWith('.json'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching JSON files from GitHub:', error);
|
||||||
|
throw new Error('Failed to fetch script files from repository');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScriptContent(filePath: string): Promise<Script> {
|
||||||
|
try {
|
||||||
|
const file = await this.fetchFromGitHub<GitHubFile>(
|
||||||
|
`/contents/${filePath}?ref=${this.branch}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!file.content) {
|
||||||
|
throw new Error('File content is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode base64 content
|
||||||
|
const content = Buffer.from(file.content, 'base64').toString('utf-8');
|
||||||
|
return JSON.parse(content) as Script;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching script content:', error);
|
||||||
|
throw new Error(`Failed to fetch script: ${filePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllScripts(): Promise<Script[]> {
|
||||||
|
try {
|
||||||
|
const jsonFiles = await this.getJsonFiles();
|
||||||
|
const scripts: Script[] = [];
|
||||||
|
|
||||||
|
for (const file of jsonFiles) {
|
||||||
|
try {
|
||||||
|
const script = await this.getScriptContent(file.path);
|
||||||
|
scripts.push(script);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to parse script ${file.name}:`, error);
|
||||||
|
// Continue with other files even if one fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return scripts;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching all scripts:', error);
|
||||||
|
throw new Error('Failed to fetch scripts from repository');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScriptCards(): Promise<ScriptCard[]> {
|
||||||
|
try {
|
||||||
|
const scripts = await this.getAllScripts();
|
||||||
|
|
||||||
|
return scripts.map(script => ({
|
||||||
|
name: script.name,
|
||||||
|
slug: script.slug,
|
||||||
|
description: script.description,
|
||||||
|
logo: script.logo,
|
||||||
|
type: script.type,
|
||||||
|
updateable: script.updateable,
|
||||||
|
website: script.website,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating script cards:', error);
|
||||||
|
throw new Error('Failed to create script cards');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScriptBySlug(slug: string): Promise<Script | null> {
|
||||||
|
try {
|
||||||
|
const scripts = await this.getAllScripts();
|
||||||
|
return scripts.find(script => script.slug === slug) || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching script by slug:', error);
|
||||||
|
throw new Error(`Failed to fetch script: ${slug}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const githubService = new GitHubService();
|
||||||
61
src/types/script.ts
Normal file
61
src/types/script.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
export interface ScriptResources {
|
||||||
|
cpu: number;
|
||||||
|
ram: number;
|
||||||
|
hdd: number;
|
||||||
|
os: string;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScriptInstallMethod {
|
||||||
|
type: string;
|
||||||
|
script: string;
|
||||||
|
resources: ScriptResources;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScriptCredentials {
|
||||||
|
username: string | null;
|
||||||
|
password: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Script {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
categories: number[];
|
||||||
|
date_created: string;
|
||||||
|
type: string;
|
||||||
|
updateable: boolean;
|
||||||
|
privileged: boolean;
|
||||||
|
interface_port: number | null;
|
||||||
|
documentation: string | null;
|
||||||
|
website: string | null;
|
||||||
|
logo: string | null;
|
||||||
|
config_path: string;
|
||||||
|
description: string;
|
||||||
|
install_methods: ScriptInstallMethod[];
|
||||||
|
default_credentials: ScriptCredentials;
|
||||||
|
notes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScriptCard {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
logo: string | null;
|
||||||
|
type: string;
|
||||||
|
updateable: boolean;
|
||||||
|
website: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitHubFile {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
sha: string;
|
||||||
|
size: number;
|
||||||
|
url: string;
|
||||||
|
html_url: string;
|
||||||
|
git_url: string;
|
||||||
|
download_url: string;
|
||||||
|
type: string;
|
||||||
|
content?: string;
|
||||||
|
encoding?: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user