Major update to Apps page. Add instance switcher.

This commit is contained in:
2025-10-22 22:28:02 +00:00
parent 1d2f0b7891
commit 35296b3bd2
11 changed files with 1882 additions and 45 deletions

View File

@@ -32,6 +32,7 @@
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-hook-form": "^7.58.1", "react-hook-form": "^7.58.1",
"react-markdown": "^10.1.0",
"react-router": "^7.9.4", "react-router": "^7.9.4",
"react-router-dom": "^7.9.4", "react-router-dom": "^7.9.4",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
@@ -40,6 +41,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.25.0", "@eslint/js": "^9.25.0",
"@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@types/node": "^24.0.3", "@types/node": "^24.0.3",

706
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,7 @@ import {
} from './ui/sidebar'; } from './ui/sidebar';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import { InstanceSwitcher } from './InstanceSwitcher';
export function AppSidebar() { export function AppSidebar() {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
@@ -61,15 +62,17 @@ export function AppSidebar() {
return ( return (
<Sidebar variant="sidebar" collapsible="icon"> <Sidebar variant="sidebar" collapsible="icon">
<SidebarHeader> <SidebarHeader>
<div className="flex items-center gap-2 px-2"> <div className="flex items-center gap-2 px-2 pb-2">
<div className="p-1 bg-primary/10 rounded-lg"> <div className="p-1 bg-primary/10 rounded-lg">
<CloudLightning className="h-6 w-6 text-primary" /> <CloudLightning className="h-6 w-6 text-primary" />
</div> </div>
<div className="group-data-[collapsible=icon]:hidden"> <div className="group-data-[collapsible=icon]:hidden">
<h2 className="text-lg font-bold text-foreground">Wild Cloud</h2> <h2 className="text-lg font-bold text-foreground">Wild Cloud</h2>
<p className="text-sm text-muted-foreground">{instanceId}</p>
</div> </div>
</div> </div>
<div className="px-2 group-data-[collapsible=icon]:px-2">
<InstanceSwitcher />
</div>
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>

View File

@@ -20,17 +20,22 @@ import {
Archive, Archive,
RotateCcw, RotateCcw,
Settings, Settings,
Eye,
} from 'lucide-react'; } from 'lucide-react';
import { useInstanceContext } from '../hooks/useInstanceContext'; import { useInstanceContext } from '../hooks/useInstanceContext';
import { useAvailableApps, useDeployedApps, useAppBackups } from '../hooks/useApps'; import { useAvailableApps, useDeployedApps, useAppBackups } from '../hooks/useApps';
import { BackupRestoreModal } from './BackupRestoreModal'; import { BackupRestoreModal } from './BackupRestoreModal';
import { AppConfigDialog } from './apps/AppConfigDialog'; import { AppConfigDialog } from './apps/AppConfigDialog';
import { AppDetailModal } from './apps/AppDetailModal';
import type { App } from '../services/api'; import type { App } from '../services/api';
interface MergedApp extends App { interface MergedApp extends App {
deploymentStatus?: 'added' | 'deployed'; deploymentStatus?: 'added' | 'deployed';
url?: string;
} }
type TabView = 'available' | 'installed';
export function AppsComponent() { export function AppsComponent() {
const { currentInstance } = useInstanceContext(); const { currentInstance } = useInstanceContext();
const { data: availableAppsData, isLoading: loadingAvailable, error: availableError } = useAvailableApps(); const { data: availableAppsData, isLoading: loadingAvailable, error: availableError } = useAvailableApps();
@@ -46,6 +51,7 @@ export function AppsComponent() {
isDeleting isDeleting
} = useDeployedApps(currentInstance); } = useDeployedApps(currentInstance);
const [activeTab, setActiveTab] = useState<TabView>('available');
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('all'); const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [configDialogOpen, setConfigDialogOpen] = useState(false); const [configDialogOpen, setConfigDialogOpen] = useState(false);
@@ -53,6 +59,8 @@ export function AppsComponent() {
const [backupModalOpen, setBackupModalOpen] = useState(false); const [backupModalOpen, setBackupModalOpen] = useState(false);
const [restoreModalOpen, setRestoreModalOpen] = useState(false); const [restoreModalOpen, setRestoreModalOpen] = useState(false);
const [selectedAppForBackup, setSelectedAppForBackup] = useState<string | null>(null); 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 // Fetch backups for the selected app
const { const {
@@ -64,18 +72,27 @@ export function AppsComponent() {
isRestoring, isRestoring,
} = useAppBackups(currentInstance, selectedAppForBackup); } = useAppBackups(currentInstance, selectedAppForBackup);
// Merge available and deployed apps // Merge available and deployed apps with URL from deployment
// DeployedApps now includes status: 'added' | 'deployed'
const applications: MergedApp[] = (availableAppsData?.apps || []).map(app => { const applications: MergedApp[] = (availableAppsData?.apps || []).map(app => {
const deployedApp = deployedApps.find(d => d.name === app.name); const deployedApp = deployedApps.find(d => d.name === app.name);
return { return {
...app, ...app,
deploymentStatus: deployedApp?.status as 'added' | 'deployed' | undefined, // 'added' or 'deployed' from API deploymentStatus: deployedApp?.status as 'added' | 'deployed' | undefined,
url: deployedApp?.url,
}; };
}); });
const isLoading = loadingAvailable || loadingDeployed; 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) => { const getStatusIcon = (status?: string) => {
switch (status) { switch (status) {
case 'running': case 'running':
@@ -88,6 +105,8 @@ export function AppsComponent() {
return <AlertCircle className="h-5 w-5 text-yellow-500" />; return <AlertCircle className="h-5 w-5 text-yellow-500" />;
case 'added': case 'added':
return <Settings className="h-5 w-5 text-blue-500" />; return <Settings className="h-5 w-5 text-blue-500" />;
case 'deployed':
return <CheckCircle className="h-5 w-5 text-green-500" />;
case 'available': case 'available':
return <Download className="h-5 w-5 text-muted-foreground" />; return <Download className="h-5 w-5 text-muted-foreground" />;
default: default:
@@ -145,12 +164,37 @@ export function AppsComponent() {
} }
}; };
const handleAppAction = (app: MergedApp, action: 'configure' | 'deploy' | 'delete' | 'backup' | 'restore') => { // 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; if (!currentInstance) return;
switch (action) { switch (action) {
case 'configure': case 'configure':
// Open config dialog for adding or reconfiguring app console.log('[AppsComponent] Configuring app:', {
name: app.name,
hasDefaultConfig: !!app.defaultConfig,
defaultConfigKeys: app.defaultConfig ? Object.keys(app.defaultConfig) : [],
fullApp: app,
});
setSelectedAppForConfig(app); setSelectedAppForConfig(app);
setConfigDialogOpen(true); setConfigDialogOpen(true);
break; break;
@@ -170,19 +214,21 @@ export function AppsComponent() {
setSelectedAppForBackup(app.name); setSelectedAppForBackup(app.name);
setRestoreModalOpen(true); setRestoreModalOpen(true);
break; break;
case 'view':
setSelectedAppForDetail(app.name);
setDetailModalOpen(true);
break;
} }
}; };
const handleConfigSave = (config: Record<string, string>) => { const handleConfigSave = (config: Record<string, string>) => {
if (!selectedAppForConfig) return; if (!selectedAppForConfig) return;
// Call addApp with the configuration
addApp({ addApp({
name: selectedAppForConfig.name, name: selectedAppForConfig.name,
config: config, config: config,
}); });
// Close dialog
setConfigDialogOpen(false); setConfigDialogOpen(false);
setSelectedAppForConfig(null); setSelectedAppForConfig(null);
}; };
@@ -199,15 +245,15 @@ export function AppsComponent() {
const categories = ['all', 'database', 'web', 'security', 'monitoring', 'communication', 'storage']; const categories = ['all', 'database', 'web', 'security', 'monitoring', 'communication', 'storage'];
const filteredApps = applications.filter(app => { const appsToDisplay = activeTab === 'available' ? availableApps : installedApps;
const filteredApps = appsToDisplay.filter(app => {
const matchesSearch = app.name.toLowerCase().includes(searchTerm.toLowerCase()) || const matchesSearch = app.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
app.description.toLowerCase().includes(searchTerm.toLowerCase()); app.description.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = selectedCategory === 'all' || app.category === selectedCategory; const matchesCategory = selectedCategory === 'all' || app.category === selectedCategory;
return matchesSearch && matchesCategory; return matchesSearch && matchesCategory;
}); });
const runningApps = applications.filter(app => app.status?.status === 'running').length;
// Show message if no instance is selected // Show message if no instance is selected
if (!currentInstance) { if (!currentInstance) {
return ( return (
@@ -248,12 +294,12 @@ export function AppsComponent() {
What are Apps in your Personal Cloud? What are Apps in your Personal Cloud?
</h3> </h3>
<p className="text-pink-800 dark:text-pink-200 mb-3 leading-relaxed"> <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 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. (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. Instead of relying on big tech companies, you control your data and services.
</p> </p>
<p className="text-pink-700 dark:text-pink-300 mb-4 text-sm"> <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. 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. Each app runs in its own secure container, so they don't interfere with each other and can be easily managed.
</p> </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"> <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">
@@ -277,6 +323,22 @@ export function AppsComponent() {
</div> </div>
</div> </div>
{/* Tab Navigation */}
<div className="flex gap-2 mb-6 border-b pb-4">
<Button
variant={activeTab === 'available' ? 'default' : 'outline'}
onClick={() => setActiveTab('available')}
>
Available Apps ({availableApps.length})
</Button>
<Button
variant={activeTab === 'installed' ? 'default' : 'outline'}
onClick={() => setActiveTab('installed')}
>
Installed Apps ({installedApps.length})
</Button>
</div>
<div className="flex flex-col sm:flex-row gap-4 mb-6"> <div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="relative flex-1"> <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" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
@@ -288,14 +350,14 @@ export function AppsComponent() {
className="w-full pl-10 pr-4 py-2 border rounded-lg bg-background" className="w-full pl-10 pr-4 py-2 border rounded-lg bg-background"
/> />
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2 overflow-x-auto">
{categories.map(category => ( {categories.map(category => (
<Button <Button
key={category} key={category}
variant={selectedCategory === category ? 'default' : 'outline'} variant={selectedCategory === category ? 'default' : 'outline'}
size="sm" size="sm"
onClick={() => setSelectedCategory(category)} onClick={() => setSelectedCategory(category)}
className="capitalize" className="capitalize whitespace-nowrap"
> >
{category} {category}
</Button> </Button>
@@ -311,7 +373,7 @@ export function AppsComponent() {
Loading apps... Loading apps...
</span> </span>
) : ( ) : (
`${runningApps} applications running • ${applications.length} total available` `${runningApps} applications running • ${installedApps.length} installed • ${availableApps.length} available`
)} )}
</div> </div>
</div> </div>
@@ -322,14 +384,45 @@ export function AppsComponent() {
<Loader2 className="h-12 w-12 text-primary mx-auto mb-4 animate-spin" /> <Loader2 className="h-12 w-12 text-primary mx-auto mb-4 animate-spin" />
<p className="text-muted-foreground">Loading applications...</p> <p className="text-muted-foreground">Loading applications...</p>
</Card> </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>
) : ( ) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> // Installed Apps List
<div className="space-y-3">
{filteredApps.map((app) => ( {filteredApps.map((app) => (
<Card key={app.name} className="p-4"> <Card key={app.name} className="p-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="p-2 bg-muted rounded-lg"> <AppIcon app={app} />
{getCategoryIcon(app.category)}
</div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<h3 className="font-medium truncate">{app.name}</h3> <h3 className="font-medium truncate">{app.name}</h3>
@@ -338,10 +431,23 @@ export function AppsComponent() {
{app.version} {app.version}
</Badge> </Badge>
)} )}
{getStatusIcon(app.status?.status)} {getStatusIcon(app.status?.status || app.deploymentStatus)}
</div> </div>
<p className="text-sm text-muted-foreground mb-2">{app.description}</p> <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' && ( {app.status?.status === 'running' && (
<div className="space-y-1 text-xs text-muted-foreground"> <div className="space-y-1 text-xs text-muted-foreground">
{app.status.namespace && ( {app.status.namespace && (
@@ -350,31 +456,14 @@ export function AppsComponent() {
{app.status.replicas && ( {app.status.replicas && (
<div>Replicas: {app.status.replicas}</div> <div>Replicas: {app.status.replicas}</div>
)} )}
{app.status.resources && (
<div>
Resources: {app.status.resources.cpu} CPU, {app.status.resources.memory} RAM
</div>
)}
</div> </div>
)} )}
{app.status?.message && (
<p className="text-xs text-muted-foreground mt-1">{app.status.message}</p>
)}
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{getStatusBadge(app)} {getStatusBadge(app)}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{/* Available: not added yet */} {/* Available: not added yet - shouldn't show here */}
{!app.deploymentStatus && (
<Button
size="sm"
onClick={() => handleAppAction(app, 'configure')}
disabled={isAdding}
>
{isAdding ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Configure & Add'}
</Button>
)}
{/* Added: in config but not deployed */} {/* Added: in config but not deployed */}
{app.deploymentStatus === 'added' && ( {app.deploymentStatus === 'added' && (
@@ -408,6 +497,14 @@ export function AppsComponent() {
{/* Deployed: running in Kubernetes */} {/* Deployed: running in Kubernetes */}
{app.deploymentStatus === 'deployed' && ( {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' && ( {app.status?.status === 'running' && (
<> <>
<Button <Button
@@ -455,7 +552,9 @@ export function AppsComponent() {
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4">
{searchTerm || selectedCategory !== 'all' {searchTerm || selectedCategory !== 'all'
? 'Try adjusting your search or category filter' ? 'Try adjusting your search or category filter'
: 'No applications available to display' : activeTab === 'available'
? 'All available apps have been installed'
: 'No apps are currently installed'
} }
</p> </p>
</Card> </Card>
@@ -498,6 +597,19 @@ export function AppsComponent() {
onSave={handleConfigSave} onSave={handleConfigSave}
isSaving={isAdding} isSaving={isAdding}
/> />
{/* App Detail Modal */}
{selectedAppForDetail && currentInstance && (
<AppDetailModal
instanceName={currentInstance}
appName={selectedAppForDetail}
open={detailModalOpen}
onClose={() => {
setDetailModalOpen(false);
setSelectedAppForDetail(null);
}}
/>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,216 @@
import { useState } from 'react';
import { useNavigate, useLocation, useParams } from 'react-router';
import { Plus } from 'lucide-react';
import { useInstances } from '../hooks/useInstances';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
SelectSeparator,
} from './ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
const ADD_INSTANCE_VALUE = '__add_new__';
export function InstanceSwitcher() {
const navigate = useNavigate();
const location = useLocation();
const { instanceId } = useParams<{ instanceId: string }>();
const { instances, isLoading, error, createInstance, isCreating } = useInstances();
const [dialogOpen, setDialogOpen] = useState(false);
const [newInstanceName, setNewInstanceName] = useState('');
const handleInstanceChange = (value: string) => {
// Check if user selected "Add new instance"
if (value === ADD_INSTANCE_VALUE) {
setDialogOpen(true);
return;
}
if (!instanceId) return;
// Extract the page path after /instances/:instanceId
const instancePrefix = `/instances/${instanceId}`;
const pagePath = location.pathname.startsWith(instancePrefix)
? location.pathname.slice(instancePrefix.length)
: '/dashboard';
// Navigate to the same page in the new instance
navigate(`/instances/${value}${pagePath || '/dashboard'}`);
};
const handleCreateInstance = (e: React.FormEvent) => {
e.preventDefault();
if (!newInstanceName.trim()) return;
createInstance(
{ name: newInstanceName.trim() },
{
onSuccess: () => {
setDialogOpen(false);
setNewInstanceName('');
// Navigate to the new instance's dashboard
navigate(`/instances/${newInstanceName.trim()}/dashboard`);
},
}
);
};
// Loading state
if (isLoading) {
return (
<Select disabled value="">
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Loading..." />
</SelectTrigger>
</Select>
);
}
// Error state
if (error) {
return (
<Select disabled value="">
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Error loading instances" />
</SelectTrigger>
</Select>
);
}
// No instances state - show dialog immediately
if (!instances || instances.length === 0) {
return (
<>
<Select disabled value="">
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="No instances" />
</SelectTrigger>
</Select>
<Button
variant="outline"
size="sm"
onClick={() => setDialogOpen(true)}
className="mt-2 w-full h-8 text-sm"
>
<Plus className="h-4 w-4 mr-2" />
Add Instance
</Button>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<form onSubmit={handleCreateInstance}>
<DialogHeader>
<DialogTitle>Create New Instance</DialogTitle>
<DialogDescription>
Enter a name for your new Wild Cloud instance.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Instance Name</Label>
<Input
id="name"
placeholder="my-instance"
value={newInstanceName}
onChange={(e) => setNewInstanceName(e.target.value)}
disabled={isCreating}
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={isCreating}
>
Cancel
</Button>
<Button type="submit" disabled={isCreating || !newInstanceName.trim()}>
{isCreating ? 'Creating...' : 'Create Instance'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}
return (
<>
<Select value={instanceId || ''} onValueChange={handleInstanceChange}>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Select instance" />
</SelectTrigger>
<SelectContent>
{instances.map((instance) => (
<SelectItem key={instance} value={instance}>
{instance}
</SelectItem>
))}
<SelectSeparator />
<SelectItem value={ADD_INSTANCE_VALUE}>
<div className="flex items-center">
<Plus className="h-4 w-4 mr-2" />
Add new instance...
</div>
</SelectItem>
</SelectContent>
</Select>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<form onSubmit={handleCreateInstance}>
<DialogHeader>
<DialogTitle>Create New Instance</DialogTitle>
<DialogDescription>
Enter a name for your new Wild Cloud instance.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Instance Name</Label>
<Input
id="name"
placeholder="my-instance"
value={newInstanceName}
onChange={(e) => setNewInstanceName(e.target.value)}
disabled={isCreating}
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
disabled={isCreating}
>
Cancel
</Button>
<Button type="submit" disabled={isCreating || !newInstanceName.trim()}>
{isCreating ? 'Creating...' : 'Create Instance'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -37,6 +37,15 @@ export function AppConfigDialog({
if (app && open) { if (app && open) {
const initialConfig: Record<string, string> = {}; const initialConfig: Record<string, string> = {};
// Debug logging to diagnose the issue
console.log('[AppConfigDialog] App data:', {
name: app.name,
hasDefaultConfig: !!app.defaultConfig,
defaultConfigKeys: app.defaultConfig ? Object.keys(app.defaultConfig) : [],
hasExistingConfig: !!existingConfig,
existingConfigKeys: existingConfig ? Object.keys(existingConfig) : [],
});
// Start with default config // Start with default config
if (app.defaultConfig) { if (app.defaultConfig) {
Object.entries(app.defaultConfig).forEach(([key, value]) => { Object.entries(app.defaultConfig).forEach(([key, value]) => {

View File

@@ -0,0 +1,606 @@
import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useAppEnhanced, useAppLogs, useAppEvents, useAppReadme } from '@/hooks/useApps';
import {
RefreshCw,
Eye,
Settings,
Activity,
FileText,
ExternalLink,
AlertCircle,
CheckCircle,
} from 'lucide-react';
interface AppDetailModalProps {
instanceName: string;
appName: string;
open: boolean;
onClose: () => void;
}
type ViewMode = 'overview' | 'configuration' | 'status' | 'logs';
export function AppDetailModal({
instanceName,
appName,
open,
onClose,
}: AppDetailModalProps) {
const [viewMode, setViewMode] = useState<ViewMode>('overview');
const [showSecrets, setShowSecrets] = useState(false);
const [logParams, setLogParams] = useState({ tail: 100, sinceSeconds: 3600 });
const { data: appDetails, isLoading, refetch } = useAppEnhanced(instanceName, appName);
const { data: logs, refetch: refetchLogs } = useAppLogs(
instanceName,
appName,
viewMode === 'logs' ? logParams : undefined
);
const { data: eventsData } = useAppEvents(instanceName, appName, 20);
const { data: readmeContent, isLoading: readmeLoading } = useAppReadme(instanceName, appName);
const getPodStatusColor = (status: string) => {
if (status.toLowerCase().includes('running')) return 'text-green-600 dark:text-green-400';
if (status.toLowerCase().includes('pending')) return 'text-yellow-600 dark:text-yellow-400';
if (status.toLowerCase().includes('failed')) return 'text-red-600 dark:text-red-400';
return 'text-muted-foreground';
};
const getStatusBadge = (status: string) => {
const variants: Record<string, 'success' | 'destructive' | 'warning' | 'outline'> = {
running: 'success',
error: 'destructive',
deploying: 'outline',
stopped: 'warning',
added: 'outline',
deployed: 'outline',
};
return (
<Badge variant={variants[status] || 'outline'}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</Badge>
);
};
return (
<Dialog open={open} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-3">
{appName}
{appDetails && getStatusBadge(appDetails.status)}
</DialogTitle>
<DialogDescription>
{appDetails?.description || 'Application details and configuration'}
</DialogDescription>
</DialogHeader>
{/* View Mode Selector */}
<div className="flex gap-2 border-b pb-4">
<Button
variant={viewMode === 'overview' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('overview')}
>
<Eye className="h-4 w-4 mr-2" />
Overview
</Button>
<Button
variant={viewMode === 'configuration' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('configuration')}
>
<Settings className="h-4 w-4 mr-2" />
Configuration
</Button>
<Button
variant={viewMode === 'status' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('status')}
>
<Activity className="h-4 w-4 mr-2" />
Status
</Button>
<Button
variant={viewMode === 'logs' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('logs')}
>
<FileText className="h-4 w-4 mr-2" />
Logs
</Button>
</div>
{/* Overview Tab */}
{viewMode === 'overview' && (
<div className="space-y-4">
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
{isLoading ? (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-48 w-full" />
</div>
) : appDetails ? (
<>
<Card>
<CardHeader>
<CardTitle className="text-lg">Application Information</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-muted-foreground">Name</p>
<p className="text-sm">{appDetails.name}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Version</p>
<p className="text-sm">{appDetails.version || 'N/A'}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Namespace</p>
<p className="text-sm">{appDetails.namespace}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Status</p>
<p className="text-sm">{appDetails.status}</p>
</div>
</div>
{appDetails.url && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-1">URL</p>
<a
href={appDetails.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
<ExternalLink className="h-3 w-3" />
{appDetails.url}
</a>
</div>
)}
{appDetails.description && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-1">Description</p>
<p className="text-sm">{appDetails.description}</p>
</div>
)}
{appDetails.manifest?.dependencies && appDetails.manifest.dependencies.length > 0 && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Dependencies</p>
<div className="flex flex-wrap gap-2">
{appDetails.manifest.dependencies.map((dep) => (
<Badge key={dep} variant="outline">
{dep}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* README Documentation */}
{readmeContent && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<FileText className="h-5 w-5" />
README
</CardTitle>
</CardHeader>
<CardContent>
{readmeLoading ? (
<Skeleton className="h-40 w-full" />
) : (
<div className="prose prose-sm max-w-none dark:prose-invert overflow-auto max-h-96 p-4 bg-muted/30 rounded-lg">
<ReactMarkdown
components={{
// Style code blocks
code: ({node, inline, className, children, ...props}) => {
return inline ? (
<code className="bg-muted px-1 py-0.5 rounded text-sm" {...props}>
{children}
</code>
) : (
<code className="block bg-muted p-3 rounded text-sm overflow-x-auto" {...props}>
{children}
</code>
);
},
// Make links open in new tab
a: ({node, children, href, ...props}) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline" {...props}>
{children}
</a>
),
}}
>
{readmeContent}
</ReactMarkdown>
</div>
)}
</CardContent>
</Card>
)}
</>
) : (
<p className="text-center text-muted-foreground py-8">No information available</p>
)}
</div>
)}
{/* Configuration Tab */}
{viewMode === 'configuration' && (
<div className="space-y-4">
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
{isLoading ? (
<div className="space-y-4">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-32 w-full" />
</div>
) : appDetails ? (
<>
{/* Configuration Values */}
{((appDetails.config && Object.keys(appDetails.config).length > 0) ||
(appDetails.manifest?.defaultConfig && Object.keys(appDetails.manifest.defaultConfig).length > 0)) && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Configuration</CardTitle>
<CardDescription>Current configuration values</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{Object.entries(appDetails.config || appDetails.manifest?.defaultConfig || {}).map(([key, value]) => (
<div key={key} className="flex justify-between text-sm border-b pb-2">
<span className="font-medium text-muted-foreground">{key}:</span>
<span className="font-mono text-xs break-all">
{typeof value === 'object' && value !== null
? JSON.stringify(value, null, 2)
: String(value)}
</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Secrets */}
{appDetails.manifest?.requiredSecrets && appDetails.manifest.requiredSecrets.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center justify-between">
<span>Secrets</span>
<Button
variant="outline"
size="sm"
onClick={() => setShowSecrets(!showSecrets)}
>
{showSecrets ? 'Hide' : 'Show'}
</Button>
</CardTitle>
<CardDescription>Sensitive configuration values (redacted)</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{appDetails.manifest.requiredSecrets.map((secret) => (
<div key={secret} className="flex justify-between text-sm border-b pb-2">
<span className="font-medium text-muted-foreground">{secret}:</span>
<span className="font-mono text-xs">
{showSecrets ? '**hidden**' : '••••••••'}
</span>
</div>
))}
</div>
</CardContent>
</Card>
)}
</>
) : (
<p className="text-center text-muted-foreground py-8">No configuration available</p>
)}
</div>
)}
{/* Status Tab */}
{viewMode === 'status' && (
<div className="space-y-4">
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
{isLoading ? (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-48 w-full" />
</div>
) : appDetails?.runtime ? (
<>
{/* Replicas */}
{appDetails.runtime.replicas && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Replicas</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-4 gap-2 text-sm">
<div className="bg-muted rounded p-2">
<p className="text-xs text-muted-foreground">Desired</p>
<p className="font-semibold">{appDetails.runtime.replicas.desired}</p>
</div>
<div className="bg-muted rounded p-2">
<p className="text-xs text-muted-foreground">Current</p>
<p className="font-semibold">{appDetails.runtime.replicas.current}</p>
</div>
<div className="bg-muted rounded p-2">
<p className="text-xs text-muted-foreground">Ready</p>
<p className="font-semibold">{appDetails.runtime.replicas.ready}</p>
</div>
<div className="bg-muted rounded p-2">
<p className="text-xs text-muted-foreground">Available</p>
<p className="font-semibold">{appDetails.runtime.replicas.available}</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Pods */}
{appDetails.runtime.pods && appDetails.runtime.pods.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Pods</CardTitle>
<CardDescription>{appDetails.runtime.pods.length} pod(s)</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{appDetails.runtime.pods.map((pod) => (
<div
key={pod.name}
className="border rounded-lg p-3 hover:bg-muted/50 transition-colors"
>
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{pod.name}</p>
{pod.node && (
<p className="text-xs text-muted-foreground">Node: {pod.node}</p>
)}
</div>
<div className="flex gap-2 ml-2">
<Badge variant="outline" className={getPodStatusColor(pod.status)}>
{pod.status}
</Badge>
</div>
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div>
<span className="text-muted-foreground">Ready:</span>{' '}
<span className="font-medium">{pod.ready}</span>
</div>
<div>
<span className="text-muted-foreground">Restarts:</span>{' '}
<span className="font-medium">{pod.restarts}</span>
</div>
<div>
<span className="text-muted-foreground">Age:</span>{' '}
<span className="font-medium">{pod.age}</span>
</div>
</div>
{pod.ip && (
<div className="text-xs mt-1">
<span className="text-muted-foreground">IP:</span>{' '}
<span className="font-mono">{pod.ip}</span>
</div>
)}
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Resource Usage */}
{appDetails.runtime.resources && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Resource Usage</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{appDetails.runtime.resources.cpu && (
<div>
<div className="flex justify-between text-sm mb-1">
<span>CPU</span>
<span className="font-mono text-xs">
{appDetails.runtime.resources.cpu.used} / {appDetails.runtime.resources.cpu.limit}
</span>
</div>
<div className="w-full bg-muted rounded-full h-2">
<div
className="bg-primary rounded-full h-2 transition-all"
style={{ width: `${Math.min(appDetails.runtime.resources.cpu.percentage, 100)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground mt-1">
{appDetails.runtime.resources.cpu.percentage.toFixed(1)}% used
</p>
</div>
)}
{appDetails.runtime.resources.memory && (
<div>
<div className="flex justify-between text-sm mb-1">
<span>Memory</span>
<span className="font-mono text-xs">
{appDetails.runtime.resources.memory.used} / {appDetails.runtime.resources.memory.limit}
</span>
</div>
<div className="w-full bg-muted rounded-full h-2">
<div
className="bg-primary rounded-full h-2 transition-all"
style={{ width: `${Math.min(appDetails.runtime.resources.memory.percentage, 100)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground mt-1">
{appDetails.runtime.resources.memory.percentage.toFixed(1)}% used
</p>
</div>
)}
{appDetails.runtime.resources.storage && (
<div>
<div className="flex justify-between text-sm mb-1">
<span>Storage</span>
<span className="font-mono text-xs">
{appDetails.runtime.resources.storage.used} / {appDetails.runtime.resources.storage.limit}
</span>
</div>
<div className="w-full bg-muted rounded-full h-2">
<div
className="bg-primary rounded-full h-2 transition-all"
style={{ width: `${Math.min(appDetails.runtime.resources.storage.percentage, 100)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground mt-1">
{appDetails.runtime.resources.storage.percentage.toFixed(1)}% used
</p>
</div>
)}
</CardContent>
</Card>
)}
{/* Recent Events */}
{eventsData?.events && eventsData.events.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Recent Events</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{eventsData.events.map((event, idx) => (
<div key={idx} className="flex items-start gap-2 text-sm border-b pb-2">
{event.type === 'Warning' ? (
<AlertCircle className="h-4 w-4 text-yellow-500 mt-0.5" />
) : (
<CheckCircle className="h-4 w-4 text-green-500 mt-0.5" />
)}
<div className="flex-1 min-w-0">
<p className="font-medium">{event.reason}</p>
<p className="text-muted-foreground text-xs">{event.message}</p>
<p className="text-muted-foreground text-xs mt-1">
{event.timestamp} {event.count > 1 && `(${event.count}x)`}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</>
) : (
<p className="text-center text-muted-foreground py-8">No status information available</p>
)}
</div>
)}
{/* Logs Tab */}
{viewMode === 'logs' && (
<div className="space-y-4">
<div className="flex justify-between items-center">
<div className="flex gap-2">
<select
value={logParams.tail}
onChange={(e) => setLogParams({ ...logParams, tail: parseInt(e.target.value) })}
className="px-3 py-1 border rounded text-sm"
>
<option value={50}>Last 50 lines</option>
<option value={100}>Last 100 lines</option>
<option value={200}>Last 200 lines</option>
<option value={500}>Last 500 lines</option>
</select>
</div>
<Button variant="outline" size="sm" onClick={() => refetchLogs()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
<Card>
<CardContent className="p-4">
<div className="bg-black text-green-400 font-mono text-xs p-4 rounded-lg max-h-96 overflow-y-auto">
{logs && logs.logs && Array.isArray(logs.logs) && logs.logs.length > 0 ? (
logs.logs.map((line, idx) => {
// Handle both string format and object format {timestamp, message, pod}
if (typeof line === 'string') {
return (
<div key={idx} className="whitespace-pre-wrap break-all">
{line}
</div>
);
} else if (line && typeof line === 'object' && 'message' in line) {
// Display timestamp and message nicely
const timestamp = line.timestamp ? new Date(line.timestamp).toLocaleTimeString() : '';
return (
<div key={idx} className="whitespace-pre-wrap break-all">
{timestamp && <span className="text-gray-500">[{timestamp}] </span>}
{line.message}
</div>
);
} else {
return (
<div key={idx} className="whitespace-pre-wrap break-all">
{JSON.stringify(line)}
</div>
);
}
})
) : logs && typeof logs === 'object' && !Array.isArray(logs) ? (
// Handle case where logs might be an object with different structure
<div className="whitespace-pre-wrap break-all">
{JSON.stringify(logs, null, 2)}
</div>
) : (
<p className="text-gray-500">No logs available</p>
)}
</div>
</CardContent>
</Card>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -108,3 +108,58 @@ export function useAppBackups(instanceName: string | null | undefined, appName:
restoreResult: restoreMutation.data, restoreResult: restoreMutation.data,
}; };
} }
// Enhanced hooks for app details and runtime status
export function useAppEnhanced(instanceName: string | null | undefined, appName: string | null | undefined) {
return useQuery({
queryKey: ['instances', instanceName, 'apps', appName, 'enhanced'],
queryFn: () => appsApi.getEnhanced(instanceName!, appName!),
enabled: !!instanceName && !!appName,
refetchInterval: 10000, // Poll every 10 seconds
});
}
export function useAppRuntime(instanceName: string | null | undefined, appName: string | null | undefined) {
return useQuery({
queryKey: ['instances', instanceName, 'apps', appName, 'runtime'],
queryFn: () => appsApi.getRuntime(instanceName!, appName!),
enabled: !!instanceName && !!appName,
refetchInterval: 5000, // Poll every 5 seconds
});
}
export function useAppLogs(
instanceName: string | null | undefined,
appName: string | null | undefined,
params?: { tail?: number; sinceSeconds?: number; pod?: string }
) {
return useQuery({
queryKey: ['instances', instanceName, 'apps', appName, 'logs', params],
queryFn: () => appsApi.getLogs(instanceName!, appName!, params),
enabled: !!instanceName && !!appName,
refetchInterval: false, // Manual refresh only
});
}
export function useAppEvents(
instanceName: string | null | undefined,
appName: string | null | undefined,
limit?: number
) {
return useQuery({
queryKey: ['instances', instanceName, 'apps', appName, 'events', limit],
queryFn: () => appsApi.getEvents(instanceName!, appName!, limit),
enabled: !!instanceName && !!appName,
refetchInterval: 10000, // Poll every 10 seconds
});
}
export function useAppReadme(instanceName: string | null | undefined, appName: string | null | undefined) {
return useQuery({
queryKey: ['instances', instanceName, 'apps', appName, 'readme'],
queryFn: () => appsApi.getReadme(instanceName!, appName!),
enabled: !!instanceName && !!appName,
staleTime: 5 * 60 * 1000, // 5 minutes - READMEs don't change often
retry: false, // Don't retry if README not found (404)
});
}

View File

@@ -1,5 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));

View File

@@ -6,6 +6,10 @@ import type {
AppAddResponse, AppAddResponse,
AppStatus, AppStatus,
OperationResponse, OperationResponse,
EnhancedApp,
RuntimeStatus,
LogEntry,
KubernetesEvent,
} from './types'; } from './types';
export const appsApi = { export const appsApi = {
@@ -39,6 +43,33 @@ export const appsApi = {
return apiClient.get(`/api/v1/instances/${instanceName}/apps/${appName}/status`); return apiClient.get(`/api/v1/instances/${instanceName}/apps/${appName}/status`);
}, },
// Enhanced app details endpoints
async getEnhanced(instanceName: string, appName: string): Promise<EnhancedApp> {
return apiClient.get(`/api/v1/instances/${instanceName}/apps/${appName}/enhanced`);
},
async getRuntime(instanceName: string, appName: string): Promise<RuntimeStatus> {
return apiClient.get(`/api/v1/instances/${instanceName}/apps/${appName}/runtime`);
},
async getLogs(
instanceName: string,
appName: string,
params?: { tail?: number; sinceSeconds?: number; pod?: string }
): Promise<LogEntry> {
const queryParams = new URLSearchParams();
if (params?.tail) queryParams.append('tail', params.tail.toString());
if (params?.sinceSeconds) queryParams.append('sinceSeconds', params.sinceSeconds.toString());
if (params?.pod) queryParams.append('pod', params.pod);
const query = queryParams.toString();
return apiClient.get(`/api/v1/instances/${instanceName}/apps/${appName}/logs${query ? `?${query}` : ''}`);
},
async getEvents(instanceName: string, appName: string, limit = 20): Promise<{ events: KubernetesEvent[] }> {
return apiClient.get(`/api/v1/instances/${instanceName}/apps/${appName}/events?limit=${limit}`);
},
// Backup operations // Backup operations
async backup(instanceName: string, appName: string): Promise<OperationResponse> { async backup(instanceName: string, appName: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/apps/${appName}/backup`); return apiClient.post(`/api/v1/instances/${instanceName}/apps/${appName}/backup`);
@@ -51,4 +82,16 @@ export const appsApi = {
async restore(instanceName: string, appName: string, backupId: string): Promise<OperationResponse> { async restore(instanceName: string, appName: string, backupId: string): Promise<OperationResponse> {
return apiClient.post(`/api/v1/instances/${instanceName}/apps/${appName}/restore`, { backup_id: backupId }); return apiClient.post(`/api/v1/instances/${instanceName}/apps/${appName}/restore`, { backup_id: backupId });
}, },
// README content
async getReadme(instanceName: string, appName: string): Promise<string> {
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://localhost:5055'}/api/v1/instances/${instanceName}/apps/${appName}/readme`);
if (!response.ok) {
if (response.status === 404) {
return ''; // Return empty string if README not found
}
throw new Error(`Failed to fetch README: ${response.statusText}`);
}
return response.text();
},
}; };

View File

@@ -10,6 +10,8 @@ export interface App {
dependencies?: string[]; dependencies?: string[];
config?: Record<string, string>; config?: Record<string, string>;
status?: AppStatus; status?: AppStatus;
readme?: string;
documentation?: string;
} }
export interface AppRequirement { export interface AppRequirement {
@@ -38,6 +40,92 @@ export interface AppResources {
storage?: string; storage?: string;
} }
// Enhanced types for app details with runtime status
export interface ContainerInfo {
name: string;
image: string;
ready: boolean;
restartCount: number;
state: string; // "running", "waiting", "terminated"
}
export interface PodInfo {
name: string;
status: string;
ready: string; // "1/1"
restarts: number;
age: string;
node: string;
ip: string;
containers?: ContainerInfo[];
}
export interface ReplicaInfo {
desired: number;
current: number;
ready: number;
available: number;
}
export interface ResourceMetric {
used: string;
requested: string;
limit: string;
percentage: number;
}
export interface ResourceUsage {
cpu: ResourceMetric;
memory: ResourceMetric;
storage?: ResourceMetric;
}
export interface KubernetesEvent {
type: string;
reason: string;
message: string;
timestamp: string;
count: number;
}
export interface RuntimeStatus {
pods: PodInfo[];
replicas?: ReplicaInfo;
resources?: ResourceUsage;
recentEvents?: KubernetesEvent[];
}
export interface AppManifest {
name: string;
description: string;
version: string;
category?: string;
icon?: string;
dependencies?: string[];
defaultConfig?: Record<string, unknown>;
requiredSecrets?: string[];
}
export interface EnhancedApp {
name: string;
status: string;
version?: string;
namespace: string;
url?: string;
description?: string;
icon?: string;
manifest?: AppManifest;
config?: Record<string, string>;
runtime?: RuntimeStatus;
readme?: string;
documentation?: string;
}
export interface LogEntry {
pod: string;
logs: string[];
}
export interface AppListResponse { export interface AppListResponse {
apps: App[]; apps: App[];
} }