603 lines
22 KiB
TypeScript
603 lines
22 KiB
TypeScript
import { useState } from 'react';
|
|
import { useLocation } from 'react-router';
|
|
import { Card } from './ui/card';
|
|
import { Button } from './ui/button';
|
|
import { Badge } from './ui/badge';
|
|
import {
|
|
AppWindow,
|
|
Database,
|
|
Globe,
|
|
Shield,
|
|
BarChart3,
|
|
MessageSquare,
|
|
Search,
|
|
ExternalLink,
|
|
CheckCircle,
|
|
AlertCircle,
|
|
Download,
|
|
Trash2,
|
|
BookOpen,
|
|
Loader2,
|
|
Archive,
|
|
RotateCcw,
|
|
Settings,
|
|
Eye,
|
|
} from 'lucide-react';
|
|
import { useInstanceContext } from '../hooks/useInstanceContext';
|
|
import { useAvailableApps, useDeployedApps, useAppBackups } from '../hooks/useApps';
|
|
import { BackupRestoreModal } from './BackupRestoreModal';
|
|
import { AppConfigDialog } from './apps/AppConfigDialog';
|
|
import { AppDetailModal } from './apps/AppDetailModal';
|
|
import type { App } from '../services/api';
|
|
|
|
interface MergedApp extends App {
|
|
deploymentStatus?: 'added' | 'deployed';
|
|
url?: string;
|
|
}
|
|
|
|
type TabView = 'available' | 'installed';
|
|
|
|
export function AppsComponent() {
|
|
const location = useLocation();
|
|
const { currentInstance } = useInstanceContext();
|
|
const { data: availableAppsData, isLoading: loadingAvailable, error: availableError } = useAvailableApps();
|
|
const {
|
|
apps: deployedApps,
|
|
isLoading: loadingDeployed,
|
|
error: deployedError,
|
|
addApp,
|
|
isAdding,
|
|
deployApp,
|
|
isDeploying,
|
|
deleteApp,
|
|
isDeleting
|
|
} = useDeployedApps(currentInstance);
|
|
|
|
// Determine active tab from URL path
|
|
const activeTab: TabView = location.pathname.endsWith('/installed') ? 'installed' : 'available';
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
|
const [configDialogOpen, setConfigDialogOpen] = useState(false);
|
|
const [selectedAppForConfig, setSelectedAppForConfig] = useState<App | null>(null);
|
|
const [backupModalOpen, setBackupModalOpen] = useState(false);
|
|
const [restoreModalOpen, setRestoreModalOpen] = useState(false);
|
|
const [selectedAppForBackup, setSelectedAppForBackup] = useState<string | null>(null);
|
|
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
|
const [selectedAppForDetail, setSelectedAppForDetail] = useState<string | null>(null);
|
|
|
|
// Fetch backups for the selected app
|
|
const {
|
|
backups,
|
|
isLoading: backupsLoading,
|
|
backup: createBackup,
|
|
isBackingUp,
|
|
restore: restoreBackup,
|
|
isRestoring,
|
|
} = useAppBackups(currentInstance, selectedAppForBackup);
|
|
|
|
// Merge available and deployed apps with URL from deployment
|
|
const applications: MergedApp[] = (availableAppsData?.apps || []).map(app => {
|
|
const deployedApp = deployedApps.find(d => d.name === app.name);
|
|
return {
|
|
...app,
|
|
deploymentStatus: deployedApp?.status as 'added' | 'deployed' | undefined,
|
|
url: deployedApp?.url,
|
|
};
|
|
});
|
|
|
|
const isLoading = loadingAvailable || loadingDeployed;
|
|
|
|
// Filter for available apps (not added or deployed)
|
|
const availableApps = applications.filter(app => !app.deploymentStatus);
|
|
|
|
// Filter for installed apps (added or deployed)
|
|
const installedApps = applications.filter(app => app.deploymentStatus);
|
|
|
|
// Count running apps - apps that are deployed (not just added)
|
|
const runningApps = installedApps.filter(app => app.deploymentStatus === 'deployed').length;
|
|
|
|
const getStatusIcon = (status?: string) => {
|
|
switch (status) {
|
|
case 'running':
|
|
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
|
case 'error':
|
|
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
|
case 'deploying':
|
|
return <Loader2 className="h-5 w-5 text-blue-500 animate-spin" />;
|
|
case 'stopped':
|
|
return <AlertCircle className="h-5 w-5 text-yellow-500" />;
|
|
case 'added':
|
|
return <Settings className="h-5 w-5 text-blue-500" />;
|
|
case 'deployed':
|
|
return <CheckCircle className="h-5 w-5 text-green-500" />;
|
|
case 'available':
|
|
return <Download className="h-5 w-5 text-muted-foreground" />;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const getStatusBadge = (app: MergedApp) => {
|
|
// Determine status: runtime status > deployment status > available
|
|
const status = app.status?.status || app.deploymentStatus || 'available';
|
|
|
|
const variants: Record<string, 'secondary' | 'default' | 'success' | 'destructive' | 'warning' | 'outline'> = {
|
|
available: 'secondary',
|
|
added: 'outline',
|
|
deploying: 'default',
|
|
running: 'success',
|
|
error: 'destructive',
|
|
stopped: 'warning',
|
|
deployed: 'outline',
|
|
};
|
|
|
|
const labels: Record<string, string> = {
|
|
available: 'Available',
|
|
added: 'Added',
|
|
deploying: 'Deploying',
|
|
running: 'Running',
|
|
error: 'Error',
|
|
stopped: 'Stopped',
|
|
deployed: 'Deployed',
|
|
};
|
|
|
|
return (
|
|
<Badge variant={variants[status]}>
|
|
{labels[status] || status}
|
|
</Badge>
|
|
);
|
|
};
|
|
|
|
const getCategoryIcon = (category?: string) => {
|
|
switch (category) {
|
|
case 'database':
|
|
return <Database className="h-4 w-4" />;
|
|
case 'web':
|
|
return <Globe className="h-4 w-4" />;
|
|
case 'security':
|
|
return <Shield className="h-4 w-4" />;
|
|
case 'monitoring':
|
|
return <BarChart3 className="h-4 w-4" />;
|
|
case 'communication':
|
|
return <MessageSquare className="h-4 w-4" />;
|
|
case 'storage':
|
|
return <Database className="h-4 w-4" />;
|
|
default:
|
|
return <AppWindow className="h-4 w-4" />;
|
|
}
|
|
};
|
|
|
|
// Separate component for app icon with error handling
|
|
const AppIcon = ({ app }: { app: MergedApp }) => {
|
|
const [imageError, setImageError] = useState(false);
|
|
|
|
return (
|
|
<div className="h-12 w-12 rounded-lg bg-muted flex items-center justify-center overflow-hidden">
|
|
{app.icon && !imageError ? (
|
|
<img
|
|
src={app.icon}
|
|
alt={app.name}
|
|
className="h-full w-full object-contain p-1"
|
|
onError={() => setImageError(true)}
|
|
/>
|
|
) : (
|
|
getCategoryIcon(app.category)
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const handleAppAction = (app: MergedApp, action: 'configure' | 'deploy' | 'delete' | 'backup' | 'restore' | 'view') => {
|
|
if (!currentInstance) return;
|
|
|
|
switch (action) {
|
|
case 'configure':
|
|
console.log('[AppsComponent] Configuring app:', {
|
|
name: app.name,
|
|
hasDefaultConfig: !!app.defaultConfig,
|
|
defaultConfigKeys: app.defaultConfig ? Object.keys(app.defaultConfig) : [],
|
|
fullApp: app,
|
|
});
|
|
setSelectedAppForConfig(app);
|
|
setConfigDialogOpen(true);
|
|
break;
|
|
case 'deploy':
|
|
deployApp(app.name);
|
|
break;
|
|
case 'delete':
|
|
if (confirm(`Are you sure you want to delete ${app.name}?`)) {
|
|
deleteApp(app.name);
|
|
}
|
|
break;
|
|
case 'backup':
|
|
setSelectedAppForBackup(app.name);
|
|
setBackupModalOpen(true);
|
|
break;
|
|
case 'restore':
|
|
setSelectedAppForBackup(app.name);
|
|
setRestoreModalOpen(true);
|
|
break;
|
|
case 'view':
|
|
setSelectedAppForDetail(app.name);
|
|
setDetailModalOpen(true);
|
|
break;
|
|
}
|
|
};
|
|
|
|
const handleConfigSave = (config: Record<string, string>) => {
|
|
if (!selectedAppForConfig) return;
|
|
|
|
addApp({
|
|
name: selectedAppForConfig.name,
|
|
config: config,
|
|
});
|
|
|
|
setConfigDialogOpen(false);
|
|
setSelectedAppForConfig(null);
|
|
};
|
|
|
|
const handleBackupConfirm = () => {
|
|
createBackup();
|
|
};
|
|
|
|
const handleRestoreConfirm = (backupId?: string) => {
|
|
if (backupId) {
|
|
restoreBackup(backupId);
|
|
}
|
|
};
|
|
|
|
const categories = ['all', 'database', 'web', 'security', 'monitoring', 'communication', 'storage'];
|
|
|
|
const appsToDisplay = activeTab === 'available' ? availableApps : installedApps;
|
|
|
|
const filteredApps = appsToDisplay.filter(app => {
|
|
const matchesSearch = app.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
app.description.toLowerCase().includes(searchTerm.toLowerCase());
|
|
const matchesCategory = selectedCategory === 'all' || app.category === selectedCategory;
|
|
return matchesSearch && matchesCategory;
|
|
});
|
|
|
|
// Show message if no instance is selected
|
|
if (!currentInstance) {
|
|
return (
|
|
<Card className="p-8 text-center">
|
|
<AppWindow className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
|
<h3 className="text-lg font-medium mb-2">No Instance Selected</h3>
|
|
<p className="text-muted-foreground mb-4">
|
|
Please select or create an instance to manage apps.
|
|
</p>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// Show error state
|
|
if (availableError || deployedError) {
|
|
return (
|
|
<Card className="p-8 text-center">
|
|
<AlertCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
|
<h3 className="text-lg font-medium mb-2">Error Loading Apps</h3>
|
|
<p className="text-muted-foreground mb-4">
|
|
{(availableError as Error)?.message || (deployedError as Error)?.message || 'An error occurred'}
|
|
</p>
|
|
<Button onClick={() => window.location.reload()}>Reload Page</Button>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Educational Intro Section */}
|
|
<Card className="p-6 bg-gradient-to-r from-pink-50 to-rose-50 dark:from-pink-950/20 dark:to-rose-950/20 border-pink-200 dark:border-pink-800">
|
|
<div className="flex items-start gap-4">
|
|
<div className="p-3 bg-pink-100 dark:bg-pink-900/30 rounded-lg">
|
|
<BookOpen className="h-6 w-6 text-pink-600 dark:text-pink-400" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<h3 className="text-lg font-semibold text-pink-900 dark:text-pink-100 mb-2">
|
|
What are Apps in your Personal Cloud?
|
|
</h3>
|
|
<p className="text-pink-800 dark:text-pink-200 mb-3 leading-relaxed">
|
|
Apps are the useful programs that make your personal cloud valuable - like having a personal Netflix
|
|
(media server), Google Drive (file storage), or Gmail (email server) running on your own hardware.
|
|
Instead of relying on big tech companies, you control your data and services.
|
|
</p>
|
|
<p className="text-pink-700 dark:text-pink-300 mb-4 text-sm">
|
|
Your cluster can run databases, web servers, photo galleries, password managers, backup services, and much more.
|
|
Each app runs in its own secure container, so they don't interfere with each other and can be easily managed.
|
|
</p>
|
|
<Button variant="outline" size="sm" className="text-pink-700 border-pink-300 hover:bg-pink-100 dark:text-pink-300 dark:border-pink-700 dark:hover:bg-pink-900/20">
|
|
<ExternalLink className="h-4 w-4 mr-2" />
|
|
Learn more about self-hosted applications
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-6">
|
|
<div className="flex items-center gap-4 mb-6">
|
|
<div className="p-2 bg-primary/10 rounded-lg">
|
|
<AppWindow className="h-6 w-6 text-primary" />
|
|
</div>
|
|
<div>
|
|
<h2 className="text-2xl font-semibold">App Management</h2>
|
|
<p className="text-muted-foreground">
|
|
Install and manage applications on your Kubernetes cluster
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search applications..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="w-full pl-10 pr-4 py-2 border rounded-lg bg-background"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-2 overflow-x-auto">
|
|
{categories.map(category => (
|
|
<Button
|
|
key={category}
|
|
variant={selectedCategory === category ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setSelectedCategory(category)}
|
|
className="capitalize whitespace-nowrap"
|
|
>
|
|
{category}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="text-sm text-muted-foreground">
|
|
{isLoading ? (
|
|
<span className="flex items-center gap-2">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Loading apps...
|
|
</span>
|
|
) : (
|
|
`${runningApps} applications running • ${installedApps.length} installed • ${availableApps.length} available`
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{isLoading ? (
|
|
<Card className="p-8 text-center">
|
|
<Loader2 className="h-12 w-12 text-primary mx-auto mb-4 animate-spin" />
|
|
<p className="text-muted-foreground">Loading applications...</p>
|
|
</Card>
|
|
) : activeTab === 'available' ? (
|
|
// Available Apps Grid
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{filteredApps.map((app) => (
|
|
<Card key={app.name} className="p-4 hover:shadow-lg transition-shadow">
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-start gap-3">
|
|
<AppIcon app={app} />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<h3 className="font-medium truncate">{app.name}</h3>
|
|
</div>
|
|
{app.version && (
|
|
<Badge variant="outline" className="text-xs mb-2">
|
|
{app.version}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground line-clamp-2">{app.description}</p>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => handleAppAction(app, 'configure')}
|
|
disabled={isAdding}
|
|
className="w-full"
|
|
>
|
|
{isAdding ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Configure & Add'}
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
) : (
|
|
// Installed Apps List
|
|
<div className="space-y-3">
|
|
{filteredApps.map((app) => (
|
|
<Card key={app.name} className="p-4">
|
|
<div className="flex items-start gap-3">
|
|
<AppIcon app={app} />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<h3 className="font-medium truncate">{app.name}</h3>
|
|
{app.version && (
|
|
<Badge variant="outline" className="text-xs">
|
|
{app.version}
|
|
</Badge>
|
|
)}
|
|
{getStatusIcon(app.status?.status || app.deploymentStatus)}
|
|
</div>
|
|
<p className="text-sm text-muted-foreground mb-2">{app.description}</p>
|
|
|
|
{/* Show ingress URL if available */}
|
|
{app.url && (
|
|
<a
|
|
href={app.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1 text-xs text-primary hover:underline mb-2"
|
|
>
|
|
<ExternalLink className="h-3 w-3" />
|
|
{app.url}
|
|
</a>
|
|
)}
|
|
|
|
{app.status?.status === 'running' && (
|
|
<div className="space-y-1 text-xs text-muted-foreground">
|
|
{app.status.namespace && (
|
|
<div>Namespace: {app.status.namespace}</div>
|
|
)}
|
|
{app.status.replicas && (
|
|
<div>Replicas: {app.status.replicas}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
{getStatusBadge(app)}
|
|
<div className="flex flex-col gap-1">
|
|
{/* Available: not added yet - shouldn't show here */}
|
|
|
|
{/* Added: in config but not deployed */}
|
|
{app.deploymentStatus === 'added' && (
|
|
<>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleAppAction(app, 'configure')}
|
|
title="Edit configuration"
|
|
>
|
|
<Settings className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => handleAppAction(app, 'deploy')}
|
|
disabled={isDeploying}
|
|
>
|
|
{isDeploying ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Deploy'}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
onClick={() => handleAppAction(app, 'delete')}
|
|
disabled={isDeleting}
|
|
>
|
|
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
|
</Button>
|
|
</>
|
|
)}
|
|
|
|
{/* Deployed: running in Kubernetes */}
|
|
{app.deploymentStatus === 'deployed' && (
|
|
<>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleAppAction(app, 'view')}
|
|
title="View details"
|
|
>
|
|
<Eye className="h-4 w-4" />
|
|
</Button>
|
|
{app.status?.status === 'running' && (
|
|
<>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleAppAction(app, 'backup')}
|
|
disabled={isBackingUp}
|
|
title="Create backup"
|
|
>
|
|
<Archive className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => handleAppAction(app, 'restore')}
|
|
disabled={isRestoring}
|
|
title="Restore from backup"
|
|
>
|
|
<RotateCcw className="h-4 w-4" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
onClick={() => handleAppAction(app, 'delete')}
|
|
disabled={isDeleting}
|
|
>
|
|
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{!isLoading && filteredApps.length === 0 && (
|
|
<Card className="p-8 text-center">
|
|
<AppWindow className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
|
<h3 className="text-lg font-medium mb-2">No applications found</h3>
|
|
<p className="text-muted-foreground mb-4">
|
|
{searchTerm || selectedCategory !== 'all'
|
|
? 'Try adjusting your search or category filter'
|
|
: activeTab === 'available'
|
|
? 'All available apps have been installed'
|
|
: 'No apps are currently installed'
|
|
}
|
|
</p>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Backup Modal */}
|
|
<BackupRestoreModal
|
|
isOpen={backupModalOpen}
|
|
onClose={() => {
|
|
setBackupModalOpen(false);
|
|
setSelectedAppForBackup(null);
|
|
}}
|
|
mode="backup"
|
|
appName={selectedAppForBackup || ''}
|
|
onConfirm={handleBackupConfirm}
|
|
isPending={isBackingUp}
|
|
/>
|
|
|
|
{/* Restore Modal */}
|
|
<BackupRestoreModal
|
|
isOpen={restoreModalOpen}
|
|
onClose={() => {
|
|
setRestoreModalOpen(false);
|
|
setSelectedAppForBackup(null);
|
|
}}
|
|
mode="restore"
|
|
appName={selectedAppForBackup || ''}
|
|
backups={backups?.backups || []}
|
|
isLoading={backupsLoading}
|
|
onConfirm={handleRestoreConfirm}
|
|
isPending={isRestoring}
|
|
/>
|
|
|
|
{/* App Configuration Dialog */}
|
|
<AppConfigDialog
|
|
open={configDialogOpen}
|
|
onOpenChange={setConfigDialogOpen}
|
|
app={selectedAppForConfig}
|
|
existingConfig={selectedAppForConfig?.config}
|
|
onSave={handleConfigSave}
|
|
isSaving={isAdding}
|
|
/>
|
|
|
|
{/* App Detail Modal */}
|
|
{selectedAppForDetail && currentInstance && (
|
|
<AppDetailModal
|
|
instanceName={currentInstance}
|
|
appName={selectedAppForDetail}
|
|
open={detailModalOpen}
|
|
onClose={() => {
|
|
setDetailModalOpen(false);
|
|
setSelectedAppForDetail(null);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|