feat: Implemented (un)install,start/stop flow and fixed actions flow

This commit is contained in:
2026-08-01 20:39:27 +01:00
parent eb01569f7d
commit d24fe0ffbf
29 changed files with 2331 additions and 166 deletions
+5 -5
View File
@@ -13,11 +13,11 @@
"iconLibrary": "lucide",
"rtl": true,
"aliases": {
"components": "src/lib/shadcn/components",
"utils": "src/lib/shadcn/lib/utils",
"ui": "src/lib/shadcn/components/ui",
"lib": "src/lib/shadcn/lib",
"hooks": "src/lib/shadcn/hooks"
"components": "@shadcn/components",
"utils": "@shadcn/lib/utils",
"ui": "@shadcn/components/ui",
"lib": "@shadcn/lib",
"hooks": "@shadcn/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
+41 -59
View File
@@ -1,20 +1,22 @@
import './app.css'
import { ConnectionBadge } from '@lib/components/connectionBadge'
import installContainer from '@lib/maestro/installContainer'
import { registry } from '@lib/ws/registry'
import { useEffect, useState, useMemo } from 'preact/hooks'
import {
type Container,
type RegistryItem,
STATUS_PRIORITY
import {
type Container,
type RegistryItem,
STATUS_PRIORITY
} from '@lib/types'
import semver from 'semver'
// Shadcn UI Component Imports
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@shadcn/components/ui/card'
import { Button } from '@shadcn/components/ui/button'
import { Input } from '@shadcn/components/ui/input'
import { Badge } from '@shadcn/components/ui/badge'
import { InstallButton } from '@lib/components/installButton'
import { UninstallButton } from '@lib/components/uninstallButton'
import { StartButton } from '@lib/components/startButton'
import { StopButton } from '@lib/components/stopButton'
// Helper to extract base image without tag or digest
function getBaseImage(imageRef?: string): string {
@@ -127,6 +129,7 @@ export function App() {
id: `remote-${r.name}`,
status: 'uninstalled',
origin: 'remote',
version: '',
latestVersion: r.version,
remoteSource: r.source
})
@@ -139,7 +142,7 @@ export function App() {
const filteredContainers = useMemo(() => {
return containers.filter((c) => {
const searchStr = searchQuery.toLowerCase()
const matchesSearch =
const matchesSearch =
c.name.toLowerCase().includes(searchStr) ||
c.image?.toLowerCase().includes(searchStr) ||
c.source?.toLowerCase().includes(searchStr) ||
@@ -194,8 +197,8 @@ export function App() {
<button
onClick={() => setFilter('all')}
className={`inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${filter === 'all'
? 'bg-background text-foreground shadow'
: 'hover:text-foreground'
? 'bg-background text-foreground shadow'
: 'hover:text-foreground'
}`}
>
All ({containers.length})
@@ -203,8 +206,8 @@ export function App() {
<button
onClick={() => setFilter('running')}
className={`inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${filter === 'running'
? 'bg-background text-foreground shadow'
: 'hover:text-foreground'
? 'bg-background text-foreground shadow'
: 'hover:text-foreground'
}`}
>
Running
@@ -212,8 +215,8 @@ export function App() {
<button
onClick={() => setFilter('updates')}
className={`inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${filter === 'updates'
? 'bg-background text-foreground shadow'
: 'hover:text-foreground'
? 'bg-background text-foreground shadow'
: 'hover:text-foreground'
}`}
>
Updates ({totalUpdates})
@@ -221,8 +224,8 @@ export function App() {
<button
onClick={() => setFilter('missing')}
className={`inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-xs font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${filter === 'missing'
? 'bg-background text-foreground shadow'
: 'hover:text-foreground'
? 'bg-background text-foreground shadow'
: 'hover:text-foreground'
}`}
>
Git Registry ({totalMissing})
@@ -238,16 +241,15 @@ export function App() {
return (
<Card
key={container.id}
className={`transition-all duration-200 hover:shadow-md flex flex-col justify-between ${
isRemoteOnly
? 'border-dashed border-border bg-card/40 hover:border-muted-foreground/50'
key={container.id || `installed-${container.name}`}
className={`transition-all duration-200 hover:shadow-md flex flex-col justify-between ${isRemoteOnly
? 'border-dashed border-border bg-card/40 hover:border-muted-foreground/50'
: container.hasUpdate
? 'border-blue-500/60 shadow-blue-500/5'
: isInGitRegistry
? 'border-primary/50'
: isInGitRegistry
? 'border-primary/50'
: 'border-border'
}`}
}`}
>
<CardHeader className="space-y-0 pb-3">
<div className="flex items-start justify-between gap-3">
@@ -256,13 +258,12 @@ export function App() {
</CardTitle>
<Badge
variant="outline"
className={`inline-flex items-center px-2.5 py-0.5 text-xs font-semibold ${
isRemoteOnly
className={`inline-flex items-center px-2.5 py-0.5 text-xs font-semibold ${isRemoteOnly
? 'text-muted-foreground bg-muted'
: isRunning
? 'border-emerald-500/20 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-amber-500/20 bg-amber-500/10 text-amber-600 dark:text-amber-400'
}`}
}`}
>
{!isRemoteOnly && (
<span className={`w-1.5 h-1.5 rounded-full mr-1.5 ${isRunning ? 'bg-emerald-500 animate-pulse' : 'bg-amber-500'}`} />
@@ -285,16 +286,22 @@ export function App() {
<div className="flex justify-between items-center py-1">
<span className="text-muted-foreground/80 font-medium">Source:</span>
<span className="font-mono text-foreground/80 truncate max-w-[170px]" title={container.source || container.remoteSource}>
{container.source || container.remoteSource}
<a href={container.source || container.remoteSource} target="_blank">{container.source || container.remoteSource}</a>
</span>
</div>
)}
{container.version && (
{!isRemoteOnly && container.version && (
<div className="flex justify-between items-center py-1">
<span className="text-muted-foreground/80 font-medium">Installed Ver:</span>
<span className="font-mono text-foreground/80">{container.version}</span>
</div>
)}
{isRemoteOnly && container.latestVersion && (
<div className="flex justify-between items-center py-1">
<span className="text-muted-foreground/80 font-medium">Available Ver:</span>
<span className="font-mono text-foreground/80">{container.latestVersion}</span>
</div>
)}
{container.hasUpdate && (
<div className="flex justify-between items-center py-1 text-blue-600 dark:text-blue-400 font-semibold">
<span>Latest Ver:</span>
@@ -314,38 +321,13 @@ export function App() {
Repo &rarr;
</a>
{isRemoteOnly ? (
<Button
size="sm"
className="text-xs h-8 px-3 gap-1.5"
onClick={async () => {
installContainer(container)
}}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Install
</Button>
) : container.hasUpdate ? (
<Button
size="sm"
variant="default"
className="text-xs h-8 px-3 gap-1.5 bg-blue-600 hover:bg-blue-700 text-white"
onClick={async () => {
installContainer({
...container,
version: container.latestVersion || container.version,
source: container.remoteSource || container.source
})
}}
>
<svg className="w-3.5 h-3.5" 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>
Update to {container.latestVersion}
</Button>
) : null}
<div className="flex gap-1.5">
{ isRemoteOnly ? <InstallButton container={container} /> : null }
{ container.hasUpdate ? <InstallButton container={container} type='update' /> : null}
{ container.status === 'installed' ? <><UninstallButton container={container} /><StartButton container={container} /></>: null}
{ isRunning ? <StopButton container={container} /> : null}
{ container.status === 'stopped' || container.status === 'exited' ? <><UninstallButton container={container} /><StartButton container={container} /></>: null}
</div>
</CardFooter>
)}
</Card>
@@ -0,0 +1,52 @@
import { useState } from "react"
import { uninstallContainer, installContainer } from "@lib/maestro/containers"
import type { RegistryItem } from "@lib/types"
import { Button } from "@lib/shadcn/components/ui/button"
import { Spinner } from "@lib/shadcn/components/ui/spinner"
import { Download } from "lucide-react"
interface InstallProps {
container: RegistryItem,
type?: "install" | "update"
}
export function InstallButton({ container, type = 'install' }: InstallProps) {
const [isInstalling, setIsInstalling] = useState(false)
const handleInstall = async () => {
if(type === 'update') uninstallContainer(container)
setIsInstalling(true)
try {
await installContainer(container)
} catch (error) {
console.error(error)
} finally {
setIsInstalling(false)
}
}
const buttonString = () => {
if(type === 'install') return isInstalling ? "Installing..." : "Install"
return isInstalling ? "Updating..." : "Update"
}
return (
<Button
size="sm"
className="text-xs h-8 px-3 gap-1.5"
isDisabled={isInstalling} // React Aria uses `isDisabled` instead of `disabled`
onPress={handleInstall} // React Aria prefers `onPress` over `onClick`
>
{/* Group inside a fragment or span to satisfy React Aria type definitions */}
<>
{isInstalling ? (
<Spinner />
) : (
<Download />
)}
<span>{buttonString()}</span>
</>
</Button>
)
}
@@ -0,0 +1,44 @@
import { useState } from "react"
import { startContainer } from "@lib/maestro/containers"
import type { RegistryItem } from "@lib/types"
import { Button } from "@lib/shadcn/components/ui/button"
import { Play } from "lucide-react"
import { Spinner } from "@lib/shadcn/components/ui/spinner"
interface StartProps {
container: RegistryItem,
}
export function StartButton({ container }: StartProps) {
const [isStarting, setIsStarting] = useState(false)
const handleStart = async () => {
setIsStarting(true)
try {
await startContainer(container)
} catch (error) {
console.error(error)
} finally {
setIsStarting(false)
}
}
return (
<Button
size="sm"
className="text-xs h-8 px-3 gap-1.5 bg-emerald-600 hover:bg-emerald-700 text-emerald-100 hover:text-emerald-300"
isDisabled={isStarting} // React Aria uses `isDisabled` instead of `disabled`
onPress={handleStart} // React Aria prefers `onPress` over `onClick`
>
{/* Group inside a fragment or span to satisfy React Aria type definitions */}
<>
{isStarting ? (
<Spinner />
) : (
<Play />
)}
<span>{isStarting ? "Starting..." : "Start" }</span>
</>
</Button>
)
}
@@ -0,0 +1,45 @@
import { useState } from "react"
import { stopContainer } from "@lib/maestro/containers"
import type { RegistryItem } from "@lib/types"
import { Button } from "@lib/shadcn/components/ui/button"
import { Square } from "lucide-react"
import { Spinner } from "@lib/shadcn/components/ui/spinner"
interface StopProps {
container: RegistryItem,
}
export function StopButton({ container }: StopProps) {
const [isStopping, setIsStopping] = useState(false)
const handleStop = async () => {
setIsStopping(true)
try {
await stopContainer(container)
} catch (error) {
console.error(error)
} finally {
setIsStopping(false)
}
}
return (
<Button
size="sm"
className="text-xs h-8 px-3 gap-1.5"
variant="destructive"
isDisabled={isStopping} // React Aria uses `isDisabled` instead of `disabled`
onPress={handleStop} // React Aria prefers `onPress` over `onClick`
>
{/* Group inside a fragment or span to satisfy React Aria type definitions */}
<>
{isStopping ? (
<Spinner />
) : (
<Square />
)}
<span>{isStopping ? "Stopping..." : "Stop"}</span>
</>
</Button>
)
}
@@ -0,0 +1,45 @@
import { useState } from "react"
import { uninstallContainer } from "@lib/maestro/containers"
import type { RegistryItem } from "@lib/types"
import { Button } from "@lib/shadcn/components/ui/button"
import { Trash2 } from "lucide-react"
import { Spinner } from "@lib/shadcn/components/ui/spinner"
interface UninstallProps {
container: RegistryItem,
}
export function UninstallButton({ container }: UninstallProps) {
const [isUninstalling, setIsUninstalling] = useState(false)
const handleInstall = async () => {
setIsUninstalling(true)
try {
await uninstallContainer(container)
} catch (error) {
console.error(error)
} finally {
setIsUninstalling(false)
}
}
return (
<Button
size="sm"
className="text-xs h-8 px-3 gap-1.5"
variant='destructive'
isDisabled={isUninstalling} // React Aria uses `isDisabled` instead of `disabled`
onPress={handleInstall} // React Aria prefers `onPress` over `onClick`
>
{/* Group inside a fragment or span to satisfy React Aria type definitions */}
<>
{isUninstalling ? (
<Spinner />
) : (
<Trash2 />
)}
<span>{isUninstalling ? "Uninstalling..." : "Uninstall"}</span>
</>
</Button>
)
}
@@ -0,0 +1,56 @@
import type { RegistryItem } from "@lib/types";
export function installContainer(remoteItem: RegistryItem): Promise<void> {
// For now simply call /api/containers/install with body { item: RemoteItem } to install the container
return fetch('/api/containers/install', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(remoteItem),
}).then(response => {
if (!response.ok) {
throw new Error(`Failed to install container ${remoteItem.name}: ${response.statusText}`);
}
});
}
export function uninstallContainer(remoteItem: RegistryItem): Promise<void> {
return fetch("/api/containers/uninstall", {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(remoteItem),
}).then(response => {
if (!response.ok) {
throw new Error(`Failed to uninstall container ${remoteItem.name}: ${response.statusText}`)
}
})
}
export function startContainer(remoteItem: RegistryItem): Promise<void> {
return fetch("/api/containers/start", {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(remoteItem),
}).then(response => {
if (!response.ok) {
throw new Error(`Failed to start container ${remoteItem.name}: ${response.statusText}`)
}
})}
export function stopContainer(remoteItem: RegistryItem): Promise<void> {
return fetch("/api/containers/stop", {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(remoteItem),
}).then(response => {
if (!response.ok) {
throw new Error(`Failed to stop container ${remoteItem.name}: ${response.statusText}`)
}
})}
@@ -1,17 +0,0 @@
import type { RegistryItem } from "@lib/types";
export default function installContainer(remoteItem: RegistryItem): Promise<void> {
// For now simply call /api/containers/install with body { item: RemoteItem } to install the container
return fetch('/api/containers/install', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ item: remoteItem }),
}).then(response => {
if (!response.ok) {
throw new Error(`Failed to install container ${remoteItem.name}: ${response.statusText}`);
}
});
}
@@ -0,0 +1,10 @@
import { cn } from "@shadcn/lib/utils"
import { Loader2Icon } from "lucide-react"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon data-slot="spinner" role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
)
}
export { Spinner }
+3
View File
@@ -35,6 +35,8 @@ export const STATUS_PRIORITY: Record<string, number> = {
starting: 2,
stopped: 1,
exited: 1,
created: 1,
installed: 1,
}
// --- SINGLE UNIFIED CONTAINER TYPE ---
@@ -44,6 +46,7 @@ export type ContainerOrigin = 'local' | 'remote' | 'both';
export interface Container {
id: string;
name: string;
service_name?: string;
// Status can be from Docker ("running", "stopped") or our UI ("uninstalled")
status: string;