feat: Phase 9 — Frontend React Shell #35
@@ -1,3 +1,318 @@
|
||||
export function EnvironmentDetailPage() {
|
||||
return <div>Environment Detail</div>;
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
ConfirmDialog,
|
||||
DataTable,
|
||||
EmptyState,
|
||||
FormField,
|
||||
InlineEdit,
|
||||
Input,
|
||||
Modal,
|
||||
Spinner,
|
||||
useToast,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { useAuthStore } from '../auth/auth-store';
|
||||
import {
|
||||
useEnvironments,
|
||||
useUpdateEnvironment,
|
||||
useDeleteEnvironment,
|
||||
useApps,
|
||||
useCreateApp,
|
||||
} from '../api/hooks';
|
||||
import { RequirePermission } from '../components/RequirePermission';
|
||||
import { DeploymentStatusBadge } from '../components/DeploymentStatusBadge';
|
||||
import type { AppResponse } from '../types/api';
|
||||
|
||||
interface AppTableRow {
|
||||
id: string;
|
||||
displayName: string;
|
||||
slug: string;
|
||||
deploymentStatus: string;
|
||||
updatedAt: string;
|
||||
_raw: AppResponse;
|
||||
}
|
||||
|
||||
const appColumns: Column<AppTableRow>[] = [
|
||||
{
|
||||
key: 'displayName',
|
||||
header: 'Name',
|
||||
render: (_val, row) => (
|
||||
<span className="font-medium text-white">{row.displayName}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'slug',
|
||||
header: 'Slug',
|
||||
render: (_val, row) => (
|
||||
<Badge label={row.slug} color="primary" variant="outlined" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'deploymentStatus',
|
||||
header: 'Status',
|
||||
render: (_val, row) =>
|
||||
row._raw.currentDeploymentId ? (
|
||||
<DeploymentStatusBadge status={row.deploymentStatus} />
|
||||
) : (
|
||||
<Badge label="Not deployed" color="auto" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'updatedAt',
|
||||
header: 'Last Updated',
|
||||
render: (_val, row) =>
|
||||
new Date(row.updatedAt).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export function EnvironmentDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const { envId } = useParams<{ envId: string }>();
|
||||
const { toast } = useToast();
|
||||
const tenantId = useAuthStore((s) => s.tenantId);
|
||||
|
||||
const { data: environments, isLoading: envsLoading } = useEnvironments(tenantId ?? '');
|
||||
const environment = environments?.find((e) => e.id === envId);
|
||||
|
||||
const { data: apps, isLoading: appsLoading } = useApps(envId ?? '');
|
||||
|
||||
const updateMutation = useUpdateEnvironment(tenantId ?? '', envId ?? '');
|
||||
const deleteMutation = useDeleteEnvironment(tenantId ?? '', envId ?? '');
|
||||
const createAppMutation = useCreateApp(envId ?? '');
|
||||
|
||||
// New app modal
|
||||
const [newAppOpen, setNewAppOpen] = useState(false);
|
||||
const [appSlug, setAppSlug] = useState('');
|
||||
const [appDisplayName, setAppDisplayName] = useState('');
|
||||
const [jarFile, setJarFile] = useState<File | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Delete confirm
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
function openNewApp() {
|
||||
setAppSlug('');
|
||||
setAppDisplayName('');
|
||||
setJarFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
setNewAppOpen(true);
|
||||
}
|
||||
|
||||
function closeNewApp() {
|
||||
setNewAppOpen(false);
|
||||
}
|
||||
|
||||
async function handleCreateApp(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!appSlug.trim() || !appDisplayName.trim()) return;
|
||||
const formData = new FormData();
|
||||
formData.append('slug', appSlug.trim());
|
||||
formData.append('displayName', appDisplayName.trim());
|
||||
if (jarFile) {
|
||||
formData.append('jar', jarFile);
|
||||
}
|
||||
try {
|
||||
await createAppMutation.mutateAsync(formData);
|
||||
toast({ title: 'App created', variant: 'success' });
|
||||
closeNewApp();
|
||||
} catch {
|
||||
toast({ title: 'Failed to create app', variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteEnvironment() {
|
||||
try {
|
||||
await deleteMutation.mutateAsync();
|
||||
toast({ title: 'Environment deleted', variant: 'success' });
|
||||
navigate('/environments');
|
||||
} catch {
|
||||
toast({ title: 'Failed to delete environment', variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRename(value: string) {
|
||||
if (!value.trim() || value === environment?.displayName) return;
|
||||
try {
|
||||
await updateMutation.mutateAsync({ displayName: value.trim() });
|
||||
toast({ title: 'Environment renamed', variant: 'success' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to rename environment', variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
const isLoading = envsLoading || appsLoading;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="Environment not found"
|
||||
description="The requested environment does not exist or you do not have access."
|
||||
action={
|
||||
<Button variant="secondary" onClick={() => navigate('/environments')}>
|
||||
Back to Environments
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const tableData: AppTableRow[] = (apps ?? []).map((app) => ({
|
||||
id: app.id,
|
||||
displayName: app.displayName,
|
||||
slug: app.slug,
|
||||
deploymentStatus: app.currentDeploymentId ? 'RUNNING' : 'STOPPED',
|
||||
updatedAt: app.updatedAt,
|
||||
_raw: app,
|
||||
}));
|
||||
|
||||
const hasApps = (apps?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<RequirePermission
|
||||
permission="apps:manage"
|
||||
fallback={
|
||||
<h1 className="text-2xl font-semibold text-white">
|
||||
{environment.displayName}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineEdit
|
||||
value={environment.displayName}
|
||||
onSave={handleRename}
|
||||
placeholder="Environment name"
|
||||
/>
|
||||
</RequirePermission>
|
||||
<Badge label={environment.slug} color="primary" variant="outlined" />
|
||||
<Badge
|
||||
label={environment.status}
|
||||
color={environment.status === 'ACTIVE' ? 'success' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RequirePermission permission="apps:deploy">
|
||||
<Button variant="primary" size="sm" onClick={openNewApp}>
|
||||
New App
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
<RequirePermission permission="apps:manage">
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
disabled={hasApps}
|
||||
title={hasApps ? 'Remove all apps before deleting this environment' : undefined}
|
||||
>
|
||||
Delete Environment
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Apps table */}
|
||||
{tableData.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No apps yet"
|
||||
description="Deploy your first Camel application to this environment."
|
||||
action={
|
||||
<RequirePermission permission="apps:deploy">
|
||||
<Button variant="primary" onClick={openNewApp}>
|
||||
New App
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Card title="Apps">
|
||||
<DataTable<AppTableRow>
|
||||
columns={appColumns}
|
||||
data={tableData}
|
||||
onRowClick={(row) => navigate(`/environments/${envId}/apps/${row.id}`)}
|
||||
flush
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* New App Modal */}
|
||||
<Modal open={newAppOpen} onClose={closeNewApp} title="New App" size="sm">
|
||||
<form onSubmit={handleCreateApp} className="space-y-4">
|
||||
<FormField label="Slug" htmlFor="app-slug" required>
|
||||
<Input
|
||||
id="app-slug"
|
||||
value={appSlug}
|
||||
onChange={(e) => setAppSlug(e.target.value)}
|
||||
placeholder="e.g. order-router"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Display Name" htmlFor="app-display-name" required>
|
||||
<Input
|
||||
id="app-display-name"
|
||||
value={appDisplayName}
|
||||
onChange={(e) => setAppDisplayName(e.target.value)}
|
||||
placeholder="e.g. Order Router"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="JAR File" htmlFor="app-jar">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id="app-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) => setJarFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="secondary" size="sm" onClick={closeNewApp}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={createAppMutation.isPending}
|
||||
disabled={!appSlug.trim() || !appDisplayName.trim()}
|
||||
>
|
||||
Create App
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
onConfirm={handleDeleteEnvironment}
|
||||
title="Delete Environment"
|
||||
message={`Are you sure you want to delete "${environment.displayName}"? This action cannot be undone.`}
|
||||
confirmText="Delete"
|
||||
confirmLabel="Delete"
|
||||
cancelLabel="Cancel"
|
||||
variant="danger"
|
||||
loading={deleteMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,193 @@
|
||||
export function EnvironmentsPage() {
|
||||
return <div>Environments</div>;
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
EmptyState,
|
||||
FormField,
|
||||
Input,
|
||||
Modal,
|
||||
Spinner,
|
||||
useToast,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { useAuthStore } from '../auth/auth-store';
|
||||
import { useEnvironments, useCreateEnvironment } from '../api/hooks';
|
||||
import { RequirePermission } from '../components/RequirePermission';
|
||||
import type { EnvironmentResponse } from '../types/api';
|
||||
|
||||
interface TableRow {
|
||||
id: string;
|
||||
displayName: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
_raw: EnvironmentResponse;
|
||||
}
|
||||
|
||||
const columns: Column<TableRow>[] = [
|
||||
{
|
||||
key: 'displayName',
|
||||
header: 'Name',
|
||||
render: (_val, row) => (
|
||||
<span className="font-medium text-white">{row.displayName}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'slug',
|
||||
header: 'Slug',
|
||||
render: (_val, row) => (
|
||||
<Badge label={row.slug} color="primary" variant="outlined" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (_val, row) => (
|
||||
<Badge
|
||||
label={row.status}
|
||||
color={row.status === 'ACTIVE' ? 'success' : 'warning'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
header: 'Created',
|
||||
render: (_val, row) =>
|
||||
new Date(row.createdAt).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export function EnvironmentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const tenantId = useAuthStore((s) => s.tenantId);
|
||||
|
||||
const { data: environments, isLoading } = useEnvironments(tenantId ?? '');
|
||||
const createMutation = useCreateEnvironment(tenantId ?? '');
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [slug, setSlug] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
|
||||
const tableData: TableRow[] = (environments ?? []).map((env) => ({
|
||||
id: env.id,
|
||||
displayName: env.displayName,
|
||||
slug: env.slug,
|
||||
status: env.status,
|
||||
createdAt: env.createdAt,
|
||||
_raw: env,
|
||||
}));
|
||||
|
||||
function openModal() {
|
||||
setSlug('');
|
||||
setDisplayName('');
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
setModalOpen(false);
|
||||
}
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!slug.trim() || !displayName.trim()) return;
|
||||
try {
|
||||
await createMutation.mutateAsync({ slug: slug.trim(), displayName: displayName.trim() });
|
||||
toast({ title: 'Environment created', variant: 'success' });
|
||||
closeModal();
|
||||
} catch {
|
||||
toast({ title: 'Failed to create environment', variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold text-white">Environments</h1>
|
||||
<RequirePermission permission="apps:manage">
|
||||
<Button variant="primary" size="sm" onClick={openModal}>
|
||||
Create Environment
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
|
||||
{/* Table / empty state */}
|
||||
{tableData.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No environments yet"
|
||||
description="Create your first environment to start deploying Camel applications."
|
||||
action={
|
||||
<RequirePermission permission="apps:manage">
|
||||
<Button variant="primary" onClick={openModal}>
|
||||
Create Environment
|
||||
</Button>
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<DataTable<TableRow>
|
||||
columns={columns}
|
||||
data={tableData}
|
||||
onRowClick={(row) => navigate(`/environments/${row.id}`)}
|
||||
flush
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Create Environment Modal */}
|
||||
<Modal open={modalOpen} onClose={closeModal} title="Create Environment" size="sm">
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<FormField label="Slug" htmlFor="env-slug" required>
|
||||
<Input
|
||||
id="env-slug"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
placeholder="e.g. production"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Display Name" htmlFor="env-display-name" required>
|
||||
<Input
|
||||
id="env-display-name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="e.g. Production"
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="secondary" size="sm" onClick={closeModal}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={createMutation.isPending}
|
||||
disabled={!slug.trim() || !displayName.trim()}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user