Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions dash/src/components/applications/app-info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,73 @@ export const AppInfo = ({ app, latestCommit }: Props) => {
</InfoItem>
)}
</>
) : app.appType === 'compose' ? (
<>
<SectionDivider title="Repository & Configuration" />

{/* Git Repository */}
<InfoItem icon={Github} label="Repository">
{app.gitRepository ? (
<a
href={`https://github.com/${app.gitRepository}`}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-xs text-primary hover:underline flex items-center gap-2 group w-fit"
>
<span className="truncate">{app.gitRepository}</span>
<ExternalLink className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-all group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</a>
) : (
<p className="text-muted-foreground text-xs">Not connected</p>
)}
</InfoItem>

{/* Branch */}
<InfoItem icon={GitBranch} label="Branch">
<Badge variant="outline" className="font-mono text-xs px-3 py-1">
{app.gitBranch || "Not specified"}
</Badge>
</InfoItem>

{/* Latest Commit */}
{latestCommit && (
<>
<SectionDivider title="Latest Commit" />

<div className="md:col-span-2">
<InfoItem icon={GitCommit} label="Commit Details">
<div className="space-y-2 p-3 rounded-lg bg-muted/30 border border-border/50">
<div className="flex items-center gap-3 flex-wrap">
<a
href={latestCommit.html_url}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-xs text-primary hover:underline inline-flex items-center gap-1.5 group"
>
<span className="font-semibold">{latestCommit.sha.slice(0, 7)}</span>
<ExternalLink className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-all group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</a>
{latestCommit.author && (
<span className="text-xs text-muted-foreground">
by {latestCommit.author}
</span>
)}
{latestCommit.timestamp && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="w-3 h-3" />
{new Date(latestCommit.timestamp).toLocaleString()}
</span>
)}
</div>
{latestCommit.message && (
<p className="text-xs text-foreground/80 leading-relaxed">{latestCommit.message}</p>
)}
</div>
</InfoItem>
</div>
</>
)}
</>
) : (
<>
{/* Git Configuration Section */}
Expand Down
81 changes: 81 additions & 0 deletions dash/src/components/applications/compose-app-settings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { applicationsService } from "@/services";
import type { App } from "@/types";

interface ComposeAppSettingsProps {
app: App;
onUpdate: () => void;
}

export const ComposeAppSettings = ({ app, onUpdate }: ComposeAppSettingsProps) => {
const [rootDirectory, setRootDirectory] = useState(app.rootDirectory || "");
const [saving, setSaving] = useState(false);

const handleSave = async () => {
try {
setSaving(true);

await applicationsService.update(app.id, {
rootDirectory,
});

toast.success("Settings updated successfully");
onUpdate();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to update settings");
} finally {
setSaving(false);
}
};

return (
<Card>
<CardHeader className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div className="flex flex-col gap-2">
<CardTitle>Application Settings</CardTitle>
<CardDescription>
Configure your compose application settings.
</CardDescription>
</div>
<div className="flex justify-start sm:justify-end w-full sm:w-auto">
<Button onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save Settings"}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="rootDirectory">Root Directory</Label>
<Input
id="rootDirectory"
placeholder="/"
value={rootDirectory}
onChange={(e) => setRootDirectory(e.target.value)}
/>
<p className="text-sm text-muted-foreground">
The directory containing your docker-compose.yml file
</p>
</div>

<div className="pt-4 border-t">
<h3 className="text-sm font-medium mb-3">Read-only Configuration</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground block">Repository</span>
<span className="font-mono">{app.gitRepository || "Not connected"}</span>
</div>
<div>
<span className="text-muted-foreground block">Branch</span>
<span className="font-mono">{app.gitBranch}</span>
</div>
</div>
</div>
</CardContent>
</Card>
);
};
229 changes: 229 additions & 0 deletions dash/src/components/applications/compose-status.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { useState, useEffect } from "react"
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { applicationsService } from "@/services"
import { toast } from "sonner"
import {
Activity,
RefreshCw,
Server,
Loader2,
Power,
Square,
RotateCw,
Box,
} from "lucide-react"

interface ComposeService {
name: string
status: string
state: string
}

interface ComposeStatusData {
name: string
status: string
state: string
uptime: string
services: ComposeService[]
error?: string
}

interface ComposeStatusProps {
appId: number
onStatusChange?: () => void
}

export const ComposeStatus = ({ appId, onStatusChange }: ComposeStatusProps) => {
const [status, setStatus] = useState<ComposeStatusData | null>(null)
const [loading, setLoading] = useState(false)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [lastUpdated, setLastUpdated] = useState<Date>(new Date())

const fetchStatus = async () => {
try {
setLoading(true)
// We rely on the same endpoint, but it returns different structure for compose
const data = await applicationsService.getContainerStatus(appId) as ComposeStatusData
setStatus(data)
setLastUpdated(new Date())
} catch (error) {
console.error("Failed to fetch compose status:", error)
} finally {
setLoading(false)
}
}

useEffect(() => {
fetchStatus()
const interval = setInterval(fetchStatus, 15000)
return () => clearInterval(interval)
}, [appId])

const handleStart = async () => {
try {
setActionLoading("start")
await applicationsService.startContainer(appId)
toast.success("Stack start triggered successfully")
await fetchStatus()
onStatusChange?.()
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to start stack")
} finally {
setActionLoading(null)
}
}

const handleStop = async () => {
try {
setActionLoading("stop")
await applicationsService.stopContainer(appId)
toast.success("Stack stop triggered successfully")
await fetchStatus()
onStatusChange?.()
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to stop stack")
} finally {
setActionLoading(null)
}
}

const handleRestart = async () => {
try {
setActionLoading("restart")
await applicationsService.restartContainer(appId)
toast.success("Stack restart triggered successfully")
await fetchStatus()
onStatusChange?.()
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to restart stack")
} finally {
setActionLoading(null)
}
}

const getOverallStateBadge = () => {
if (!status) return null
if (status.error) return <Badge variant="destructive">Error</Badge>

switch (status.state) {
case "running":
return <Badge className="bg-green-500 text-white">Running</Badge>
case "stopped":
return <Badge variant="secondary">Stopped</Badge>
case "partial":
return <Badge className="bg-yellow-500 text-white">Partial</Badge>
default:
return <Badge variant="outline">{status.state}</Badge>
}
}

return (
<Card className="border-border/50">
<CardHeader className="border-b border-border/50 bg-muted/30">
<div className="flex items-center justify-between">
<CardTitle className="text-lg font-semibold flex items-center gap-2">
<Activity className="h-5 w-5 text-primary" />
Stack Status
</CardTitle>
<Button
variant="outline"
size="sm"
onClick={fetchStatus}
disabled={loading}
>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button>
</div>
</CardHeader>

<CardContent className="p-6">
{!status && loading && (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
Checking status...
</div>
)}

{status && (
<div className="space-y-6">
{/* Controls */}
<div className="flex gap-2 pb-4 border-b border-border/50">
<Button
onClick={handleStart}
disabled={status.state === "running" || actionLoading !== null || loading}
size="sm"
className="flex-1"
>
{actionLoading === "start" ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Power className="h-4 w-4 mr-2" />}
Start
</Button>
<Button
onClick={handleStop}
disabled={status.state === "stopped" || actionLoading !== null || loading}
size="sm"
variant="destructive"
className="flex-1"
>
{actionLoading === "stop" ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Square className="h-4 w-4 mr-2" />}
Stop
</Button>
<Button
onClick={handleRestart}
disabled={actionLoading !== null || loading}
size="sm"
variant="outline"
className="flex-1"
>
{actionLoading === "restart" ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <RotateCw className="h-4 w-4 mr-2" />}
Restart
</Button>
</div>

{/* Overall Status */}
<div className="flex items-center justify-between py-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Server className="h-4 w-4" />
<span>Overall Status</span>
</div>
<div className="flex items-center gap-2">
{getOverallStateBadge()}
<span className="text-xs text-muted-foreground">({status.status})</span>
</div>
</div>

{/* Services List */}
{status.services && status.services.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
<Box className="h-4 w-4" /> Services
</h4>
<div className="space-y-2">
{status.services.map((svc, idx) => (
<div key={idx} className="flex items-center justify-between p-3 rounded-md bg-muted/40 border border-border/40">
<span className="text-sm font-mono font-medium">{svc.name}</span>
<Badge variant={svc.state === 'running' ? 'default' : 'secondary'} className={svc.state === 'running' ? 'bg-green-500/10 text-green-600 hover:bg-green-500/20 border-green-500/20' : ''}>
{svc.state}
</Badge>
</div>
))}
</div>
</div>
)}

{status.error && (
<div className="p-3 bg-destructive/10 text-destructive text-sm rounded-md">
Error: {status.error}
</div>
)}

<div className="pt-2 text-xs text-muted-foreground text-center">
Last updated: {lastUpdated.toLocaleTimeString()}
</div>
</div>
)}
</CardContent>
</Card>
)
}
Loading
Loading