feat: add app detail page with deploy, logs, and status
Full AppDetailPage with tabbed layout (Overview / Deployments / Logs), current deployment status with auto-poll, action bar (deploy/stop/restart/re-upload/delete), agent status card, routing card with edit modal, deployment history table, and container log viewer with stream filter. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,737 @@
|
|||||||
export function AppDetailPage() {
|
import React, { useRef, useState } from 'react';
|
||||||
return <div>App Detail</div>;
|
import { useNavigate, useParams, Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
ConfirmDialog,
|
||||||
|
DataTable,
|
||||||
|
EmptyState,
|
||||||
|
FormField,
|
||||||
|
Input,
|
||||||
|
LogViewer,
|
||||||
|
Modal,
|
||||||
|
Spinner,
|
||||||
|
StatusDot,
|
||||||
|
Tabs,
|
||||||
|
useToast,
|
||||||
|
} from '@cameleer/design-system';
|
||||||
|
import type { Column, LogEntry as DSLogEntry } from '@cameleer/design-system';
|
||||||
|
import { useAuthStore } from '../auth/auth-store';
|
||||||
|
import {
|
||||||
|
useApp,
|
||||||
|
useDeployment,
|
||||||
|
useDeployments,
|
||||||
|
useDeploy,
|
||||||
|
useStop,
|
||||||
|
useRestart,
|
||||||
|
useDeleteApp,
|
||||||
|
useUpdateRouting,
|
||||||
|
useAgentStatus,
|
||||||
|
useObservabilityStatus,
|
||||||
|
useLogs,
|
||||||
|
useCreateApp,
|
||||||
|
} from '../api/hooks';
|
||||||
|
import { RequirePermission } from '../components/RequirePermission';
|
||||||
|
import { DeploymentStatusBadge } from '../components/DeploymentStatusBadge';
|
||||||
|
import { usePermissions } from '../hooks/usePermissions';
|
||||||
|
import type { DeploymentResponse } from '../types/api';
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface DeploymentRow {
|
||||||
|
id: string;
|
||||||
|
version: number;
|
||||||
|
observedStatus: string;
|
||||||
|
desiredStatus: string;
|
||||||
|
deployedAt: string | null;
|
||||||
|
stoppedAt: string | null;
|
||||||
|
errorMessage: string | null;
|
||||||
|
_raw: DeploymentResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Deployment history columns ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
const deploymentColumns: Column<DeploymentRow>[] = [
|
||||||
|
{
|
||||||
|
key: 'version',
|
||||||
|
header: 'Version',
|
||||||
|
render: (_val, row) => (
|
||||||
|
<span className="font-mono text-sm text-white">v{row.version}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'observedStatus',
|
||||||
|
header: 'Status',
|
||||||
|
render: (_val, row) => <DeploymentStatusBadge status={row.observedStatus} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'desiredStatus',
|
||||||
|
header: 'Desired',
|
||||||
|
render: (_val, row) => (
|
||||||
|
<Badge label={row.desiredStatus} color="primary" variant="outlined" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'deployedAt',
|
||||||
|
header: 'Deployed',
|
||||||
|
render: (_val, row) =>
|
||||||
|
row.deployedAt
|
||||||
|
? new Date(row.deployedAt).toLocaleString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
: '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stoppedAt',
|
||||||
|
header: 'Stopped',
|
||||||
|
render: (_val, row) =>
|
||||||
|
row.stoppedAt
|
||||||
|
? new Date(row.stoppedAt).toLocaleString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
: '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'errorMessage',
|
||||||
|
header: 'Error',
|
||||||
|
render: (_val, row) =>
|
||||||
|
row.errorMessage ? (
|
||||||
|
<span className="text-xs text-red-400 font-mono">{row.errorMessage}</span>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Main page component ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function AppDetailPage() {
|
||||||
|
const { envId = '', appId = '' } = useParams<{ envId: string; appId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const tenantId = useAuthStore((s) => s.tenantId);
|
||||||
|
const { canManageApps, canDeploy } = usePermissions();
|
||||||
|
|
||||||
|
// Active tab
|
||||||
|
const [activeTab, setActiveTab] = useState('overview');
|
||||||
|
|
||||||
|
// App data
|
||||||
|
const { data: app, isLoading: appLoading } = useApp(envId, appId);
|
||||||
|
|
||||||
|
// Current deployment (auto-polls while BUILDING/STARTING)
|
||||||
|
const { data: currentDeployment } = useDeployment(
|
||||||
|
appId,
|
||||||
|
app?.currentDeploymentId ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Deployment history
|
||||||
|
const { data: deployments = [] } = useDeployments(appId);
|
||||||
|
|
||||||
|
// Agent and observability status
|
||||||
|
const { data: agentStatus } = useAgentStatus(appId);
|
||||||
|
const { data: obsStatus } = useObservabilityStatus(appId);
|
||||||
|
|
||||||
|
// Log stream filter
|
||||||
|
const [logStream, setLogStream] = useState<string | undefined>(undefined);
|
||||||
|
const { data: logEntries = [] } = useLogs(appId, {
|
||||||
|
limit: 500,
|
||||||
|
stream: logStream,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const deployMutation = useDeploy(appId);
|
||||||
|
const stopMutation = useStop(appId);
|
||||||
|
const restartMutation = useRestart(appId);
|
||||||
|
const deleteMutation = useDeleteApp(envId, appId);
|
||||||
|
const updateRoutingMutation = useUpdateRouting(envId, appId);
|
||||||
|
const reuploadMutation = useCreateApp(envId);
|
||||||
|
|
||||||
|
// Dialog / modal state
|
||||||
|
const [stopConfirmOpen, setStopConfirmOpen] = useState(false);
|
||||||
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
|
const [routingModalOpen, setRoutingModalOpen] = useState(false);
|
||||||
|
const [reuploadModalOpen, setReuploadModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Routing form
|
||||||
|
const [portInput, setPortInput] = useState('');
|
||||||
|
|
||||||
|
// Re-upload form
|
||||||
|
const [reuploadFile, setReuploadFile] = useState<File | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// ─── Handlers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleDeploy() {
|
||||||
|
try {
|
||||||
|
await deployMutation.mutateAsync();
|
||||||
|
toast({ title: 'Deployment triggered', variant: 'success' });
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to trigger deployment', variant: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStop() {
|
||||||
|
try {
|
||||||
|
await stopMutation.mutateAsync();
|
||||||
|
toast({ title: 'App stopped', variant: 'success' });
|
||||||
|
setStopConfirmOpen(false);
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to stop app', variant: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRestart() {
|
||||||
|
try {
|
||||||
|
await restartMutation.mutateAsync();
|
||||||
|
toast({ title: 'App restarting', variant: 'success' });
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to restart app', variant: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
try {
|
||||||
|
await deleteMutation.mutateAsync();
|
||||||
|
toast({ title: 'App deleted', variant: 'success' });
|
||||||
|
navigate(`/environments/${envId}`);
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to delete app', variant: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateRouting(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const port = portInput.trim() === '' ? null : parseInt(portInput, 10);
|
||||||
|
if (port !== null && (isNaN(port) || port < 1 || port > 65535)) {
|
||||||
|
toast({ title: 'Invalid port number', variant: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await updateRoutingMutation.mutateAsync({ exposedPort: port });
|
||||||
|
toast({ title: 'Routing updated', variant: 'success' });
|
||||||
|
setRoutingModalOpen(false);
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to update routing', variant: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRoutingModal() {
|
||||||
|
setPortInput(app?.exposedPort != null ? String(app.exposedPort) : '');
|
||||||
|
setRoutingModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReupload(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!reuploadFile) return;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('jar', reuploadFile);
|
||||||
|
if (app?.slug) formData.append('slug', app.slug);
|
||||||
|
if (app?.displayName) formData.append('displayName', app.displayName);
|
||||||
|
try {
|
||||||
|
await reuploadMutation.mutateAsync(formData);
|
||||||
|
toast({ title: 'JAR uploaded', variant: 'success' });
|
||||||
|
setReuploadModalOpen(false);
|
||||||
|
setReuploadFile(null);
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Failed to upload JAR', variant: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Derived data ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const deploymentRows: DeploymentRow[] = deployments.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
version: d.version,
|
||||||
|
observedStatus: d.observedStatus,
|
||||||
|
desiredStatus: d.desiredStatus,
|
||||||
|
deployedAt: d.deployedAt,
|
||||||
|
stoppedAt: d.stoppedAt,
|
||||||
|
errorMessage: d.errorMessage,
|
||||||
|
_raw: d,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Map API LogEntry to design system LogEntry
|
||||||
|
const dsLogEntries: DSLogEntry[] = logEntries.map((entry) => ({
|
||||||
|
timestamp: entry.timestamp,
|
||||||
|
level: entry.stream === 'stderr' ? ('error' as const) : ('info' as const),
|
||||||
|
message: entry.message,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Agent state → StatusDot variant
|
||||||
|
function agentDotVariant(): 'live' | 'stale' | 'dead' | 'success' | 'warning' | 'error' | 'running' {
|
||||||
|
if (!agentStatus?.registered) return 'dead';
|
||||||
|
switch (agentStatus.state) {
|
||||||
|
case 'CONNECTED': return 'live';
|
||||||
|
case 'DISCONNECTED': return 'stale';
|
||||||
|
default: return 'stale';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Loading / not-found states ────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (appLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="App not found"
|
||||||
|
description="The requested app does not exist or you do not have access."
|
||||||
|
action={
|
||||||
|
<Button variant="secondary" onClick={() => navigate(`/environments/${envId}`)}>
|
||||||
|
Back to Environment
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Breadcrumb ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const breadcrumb = (
|
||||||
|
<nav className="flex items-center gap-1.5 text-sm text-white/50 mb-6">
|
||||||
|
<Link to="/" className="hover:text-white/80 transition-colors">Home</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<Link to="/environments" className="hover:text-white/80 transition-colors">Environments</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<Link to={`/environments/${envId}`} className="hover:text-white/80 transition-colors">
|
||||||
|
{envId}
|
||||||
|
</Link>
|
||||||
|
<span>/</span>
|
||||||
|
<span className="text-white/90">{app.displayName}</span>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Tabs ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ label: 'Overview', value: 'overview' },
|
||||||
|
{ label: 'Deployments', value: 'deployments' },
|
||||||
|
{ label: 'Logs', value: 'logs' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ─── Render ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
{breadcrumb}
|
||||||
|
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-white">{app.displayName}</h1>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Badge label={app.slug} color="primary" variant="outlined" />
|
||||||
|
{app.jarOriginalFilename && (
|
||||||
|
<span className="text-xs text-white/50 font-mono">{app.jarOriginalFilename}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab navigation */}
|
||||||
|
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
|
||||||
|
|
||||||
|
{/* ── Tab: Overview ── */}
|
||||||
|
{activeTab === 'overview' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Status card */}
|
||||||
|
<Card title="Current Deployment">
|
||||||
|
{!app.currentDeploymentId ? (
|
||||||
|
<div className="py-4 text-center text-white/50">No deployments yet</div>
|
||||||
|
) : !currentDeployment ? (
|
||||||
|
<div className="py-4 text-center">
|
||||||
|
<Spinner size="sm" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap items-center gap-6 py-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-white/50 mb-1">Version</div>
|
||||||
|
<span className="font-mono font-semibold text-white">
|
||||||
|
v{currentDeployment.version}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-white/50 mb-1">Status</div>
|
||||||
|
<DeploymentStatusBadge status={currentDeployment.observedStatus} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-white/50 mb-1">Image</div>
|
||||||
|
<span className="font-mono text-xs text-white/70">
|
||||||
|
{currentDeployment.imageRef}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{currentDeployment.deployedAt && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-white/50 mb-1">Deployed</div>
|
||||||
|
<span className="text-sm text-white/70">
|
||||||
|
{new Date(currentDeployment.deployedAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{currentDeployment.errorMessage && (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="text-xs text-white/50 mb-1">Error</div>
|
||||||
|
<span className="text-xs text-red-400 font-mono">
|
||||||
|
{currentDeployment.errorMessage}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Action bar */}
|
||||||
|
<Card title="Actions">
|
||||||
|
<div className="flex flex-wrap gap-2 py-2">
|
||||||
|
<RequirePermission permission="apps:deploy">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
loading={deployMutation.isPending}
|
||||||
|
onClick={handleDeploy}
|
||||||
|
>
|
||||||
|
Deploy
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
|
|
||||||
|
<RequirePermission permission="apps:deploy">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
loading={restartMutation.isPending}
|
||||||
|
onClick={handleRestart}
|
||||||
|
disabled={!app.currentDeploymentId}
|
||||||
|
>
|
||||||
|
Restart
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
|
|
||||||
|
<RequirePermission permission="apps:deploy">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setStopConfirmOpen(true)}
|
||||||
|
disabled={!app.currentDeploymentId}
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
|
|
||||||
|
<RequirePermission permission="apps:deploy">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setReuploadFile(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
setReuploadModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Re-upload JAR
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
|
|
||||||
|
<RequirePermission permission="apps:manage">
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDeleteConfirmOpen(true)}
|
||||||
|
>
|
||||||
|
Delete App
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Agent status card */}
|
||||||
|
<Card title="Agent Status">
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<StatusDot variant={agentDotVariant()} pulse={agentStatus?.state === 'CONNECTED'} />
|
||||||
|
<span className="text-sm text-white/80">
|
||||||
|
{agentStatus?.registered ? 'Registered' : 'Not registered'}
|
||||||
|
</span>
|
||||||
|
{agentStatus?.state && (
|
||||||
|
<Badge
|
||||||
|
label={agentStatus.state}
|
||||||
|
color={agentStatus.state === 'CONNECTED' ? 'success' : 'auto'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{agentStatus?.lastHeartbeat && (
|
||||||
|
<div className="text-xs text-white/50">
|
||||||
|
Last heartbeat: {new Date(agentStatus.lastHeartbeat).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{agentStatus?.routeIds && agentStatus.routeIds.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-white/50 mb-1">Routes</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{agentStatus.routeIds.map((rid) => (
|
||||||
|
<Badge key={rid} label={rid} color="primary" variant="outlined" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{obsStatus && (
|
||||||
|
<div className="flex flex-wrap gap-4 pt-1">
|
||||||
|
<span className="text-xs text-white/50">
|
||||||
|
Traces:{' '}
|
||||||
|
<span className={obsStatus.hasTraces ? 'text-green-400' : 'text-white/30'}>
|
||||||
|
{obsStatus.hasTraces ? `${obsStatus.traceCount24h} (24h)` : 'none'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-white/50">
|
||||||
|
Metrics:{' '}
|
||||||
|
<span className={obsStatus.hasMetrics ? 'text-green-400' : 'text-white/30'}>
|
||||||
|
{obsStatus.hasMetrics ? 'yes' : 'none'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-white/50">
|
||||||
|
Diagrams:{' '}
|
||||||
|
<span className={obsStatus.hasDiagrams ? 'text-green-400' : 'text-white/30'}>
|
||||||
|
{obsStatus.hasDiagrams ? 'yes' : 'none'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-1">
|
||||||
|
<Link
|
||||||
|
to="/dashboard"
|
||||||
|
className="text-sm text-blue-400 hover:text-blue-300 transition-colors"
|
||||||
|
>
|
||||||
|
View in Dashboard →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Routing card */}
|
||||||
|
<Card title="Routing">
|
||||||
|
<div className="flex items-center justify-between py-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{app.exposedPort ? (
|
||||||
|
<>
|
||||||
|
<div className="text-xs text-white/50">Port</div>
|
||||||
|
<span className="font-mono text-white">{app.exposedPort}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-white/40">No port configured</span>
|
||||||
|
)}
|
||||||
|
{app.routeUrl && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="text-xs text-white/50 mb-0.5">Route URL</div>
|
||||||
|
<a
|
||||||
|
href={app.routeUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-sm text-blue-400 hover:text-blue-300 font-mono transition-colors"
|
||||||
|
>
|
||||||
|
{app.routeUrl}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<RequirePermission permission="apps:manage">
|
||||||
|
<Button variant="secondary" size="sm" onClick={openRoutingModal}>
|
||||||
|
Edit Routing
|
||||||
|
</Button>
|
||||||
|
</RequirePermission>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Tab: Deployments ── */}
|
||||||
|
{activeTab === 'deployments' && (
|
||||||
|
<Card title="Deployment History">
|
||||||
|
{deploymentRows.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No deployments yet"
|
||||||
|
description="Deploy your app to see history here."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataTable<DeploymentRow>
|
||||||
|
columns={deploymentColumns}
|
||||||
|
data={deploymentRows}
|
||||||
|
pageSize={20}
|
||||||
|
rowAccent={(row) =>
|
||||||
|
row.observedStatus === 'FAILED' ? 'error' : undefined
|
||||||
|
}
|
||||||
|
flush
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Tab: Logs ── */}
|
||||||
|
{activeTab === 'logs' && (
|
||||||
|
<Card title="Container Logs">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Stream filter */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[
|
||||||
|
{ label: 'All', value: undefined },
|
||||||
|
{ label: 'stdout', value: 'stdout' },
|
||||||
|
{ label: 'stderr', value: 'stderr' },
|
||||||
|
].map((opt) => (
|
||||||
|
<Button
|
||||||
|
key={String(opt.value)}
|
||||||
|
variant={logStream === opt.value ? 'primary' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setLogStream(opt.value)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dsLogEntries.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No logs available"
|
||||||
|
description="Logs will appear here once the app is running."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<LogViewer entries={dsLogEntries} maxHeight={500} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Dialogs / Modals ── */}
|
||||||
|
|
||||||
|
{/* Stop confirmation */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={stopConfirmOpen}
|
||||||
|
onClose={() => setStopConfirmOpen(false)}
|
||||||
|
onConfirm={handleStop}
|
||||||
|
title="Stop App"
|
||||||
|
message={`Are you sure you want to stop "${app.displayName}"?`}
|
||||||
|
confirmText="Stop"
|
||||||
|
confirmLabel="Stop"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
variant="warning"
|
||||||
|
loading={stopMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteConfirmOpen}
|
||||||
|
onClose={() => setDeleteConfirmOpen(false)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
title="Delete App"
|
||||||
|
message={`Are you sure you want to delete "${app.displayName}"? This action cannot be undone.`}
|
||||||
|
confirmText="Delete"
|
||||||
|
confirmLabel="Delete"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
variant="danger"
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Routing modal */}
|
||||||
|
<Modal
|
||||||
|
open={routingModalOpen}
|
||||||
|
onClose={() => setRoutingModalOpen(false)}
|
||||||
|
title="Edit Routing"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleUpdateRouting} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
label="Exposed Port"
|
||||||
|
htmlFor="exposed-port"
|
||||||
|
hint="Leave empty to remove the exposed port."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="exposed-port"
|
||||||
|
type="number"
|
||||||
|
value={portInput}
|
||||||
|
onChange={(e) => setPortInput(e.target.value)}
|
||||||
|
placeholder="e.g. 8080"
|
||||||
|
min={1}
|
||||||
|
max={65535}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setRoutingModalOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
loading={updateRoutingMutation.isPending}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Re-upload JAR modal */}
|
||||||
|
<Modal
|
||||||
|
open={reuploadModalOpen}
|
||||||
|
onClose={() => setReuploadModalOpen(false)}
|
||||||
|
title="Re-upload JAR"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleReupload} className="space-y-4">
|
||||||
|
<FormField label="JAR File" htmlFor="reupload-jar" required>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
id="reupload-jar"
|
||||||
|
type="file"
|
||||||
|
accept=".jar"
|
||||||
|
className="block w-full text-sm text-white/70 file:mr-3 file:py-1 file:px-3 file:rounded file:border-0 file:text-sm file:bg-white/10 file:text-white hover:file:bg-white/20 cursor-pointer"
|
||||||
|
onChange={(e) => setReuploadFile(e.target.files?.[0] ?? null)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setReuploadModalOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
loading={reuploadMutation.isPending}
|
||||||
|
disabled={!reuploadFile}
|
||||||
|
>
|
||||||
|
Upload
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user