feat: Implemented (un)install,start/stop flow and fixed actions flow
This commit is contained in:
@@ -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
@@ -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 →
|
||||
</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 }
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user