feat: redesign Deployments tab with Overview + Configuration sub-tabs
Overview sub-tab: - Deployments table with env badge, version, status, URL, deployed time - Actions (Start/Stop) scoped to selected environment; other envs show "switch env to manage" hint with muted rows - Versions list with per-env deploy target picker Configuration sub-tab: - Read-only by default with Edit mode gate (Cancel/Save banner) - Agent observability: engine level, payload capture with size unit selector, log levels, metrics toggle, sampling, replay and route control (default enabled) - Container resources: memory/CPU limits, exposed ports as deletable pills with inline add input - Environment variables: key-value editor with add/remove - Reuses existing ApplicationConfig API for agent config push via SSE Tab renamed from "Apps" to "Deployments" in the tab bar. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,7 @@ export function ContentTabs({ active, onChange, scope }: ContentTabsProps) {
|
|||||||
const canControl = useCanControl();
|
const canControl = useCanControl();
|
||||||
const tabs = useMemo(() => {
|
const tabs = useMemo(() => {
|
||||||
if (!canControl) return BASE_TABS;
|
if (!canControl) return BASE_TABS;
|
||||||
return [...BASE_TABS, { label: 'Apps', value: 'apps' }];
|
return [...BASE_TABS, { label: 'Deployments', value: 'apps' }];
|
||||||
}, [canControl]);
|
}, [canControl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.envSelect {
|
.nativeSelect {
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -47,6 +47,7 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Detail header */
|
||||||
.detailHeader {
|
.detailHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -70,25 +71,91 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metaGrid {
|
.detailActions {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: auto 1fr;
|
gap: 8px;
|
||||||
gap: 6px 16px;
|
}
|
||||||
font-size: 12px;
|
|
||||||
font-family: var(--font-body);
|
/* Sub tabs */
|
||||||
|
.subTabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metaLabel {
|
.subTab {
|
||||||
color: var(--text-muted);
|
padding: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
background: none;
|
||||||
|
font-family: var(--font-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subTab:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subTabActive {
|
||||||
|
color: var(--accent, #6c7aff);
|
||||||
|
border-bottom-color: var(--accent, #6c7aff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
font-size: 13px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tr:hover td {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mutedRow td {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mutedMono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.envHint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Version rows */
|
||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 6px 0;
|
padding: 8px 0;
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
@@ -99,72 +166,174 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rowMeta {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.errorText {
|
|
||||||
color: var(--error);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.emptyNote {
|
.emptyNote {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
margin: 4px 0 8px;
|
margin: 4px 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.configForm {
|
/* Edit mode banner */
|
||||||
margin-top: 8px;
|
.editBanner {
|
||||||
}
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
.configSection {
|
justify-content: space-between;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 6px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.configTitle {
|
.editBannerActive {
|
||||||
font-size: 13px;
|
border-color: var(--warning);
|
||||||
font-weight: 600;
|
background: rgba(251, 191, 36, 0.06);
|
||||||
color: var(--text-primary);
|
|
||||||
margin: 0 0 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editBannerText {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editBannerTextWarn {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editBannerActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Config grid */
|
||||||
.configGrid {
|
.configGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 160px 1fr;
|
grid-template-columns: 160px 1fr;
|
||||||
gap: 8px 12px;
|
gap: 8px 16px;
|
||||||
align-items: start;
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.configLabel {
|
.configLabel {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding-top: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.configField {
|
.configInline {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.configHint {
|
.configHint {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.envVarEditor {
|
.toggleEnabled {
|
||||||
width: 100%;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 8px;
|
color: var(--success);
|
||||||
border: 1px solid var(--border-subtle);
|
}
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--bg-surface);
|
.toggleDisabled {
|
||||||
color: var(--text-primary);
|
font-size: 12px;
|
||||||
resize: vertical;
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Port pills */
|
||||||
|
.portPills {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portPill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portPillDelete {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portPillDelete:hover {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portPillDelete:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portAddInput {
|
||||||
|
width: 70px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border: 1px dashed var(--border-subtle);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portAddInput::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portAddInput:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Env var editor */
|
||||||
|
.envVarRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.envVarKey {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.envVarValue {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.envVarDelete {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.envVarDelete:hover {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.envVarDelete:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo, useRef } from 'react';
|
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
|
||||||
import { useParams } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
@@ -7,7 +7,9 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
MonoText,
|
MonoText,
|
||||||
SectionHeader,
|
SectionHeader,
|
||||||
|
Select,
|
||||||
Spinner,
|
Spinner,
|
||||||
|
Toggle,
|
||||||
useToast,
|
useToast,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import type { Column } from '@cameleer/design-system';
|
import type { Column } from '@cameleer/design-system';
|
||||||
@@ -27,6 +29,8 @@ import {
|
|||||||
} from '../../api/queries/admin/apps';
|
} from '../../api/queries/admin/apps';
|
||||||
import type { App, AppVersion, Deployment } from '../../api/queries/admin/apps';
|
import type { App, AppVersion, Deployment } from '../../api/queries/admin/apps';
|
||||||
import type { Environment } from '../../api/queries/admin/environments';
|
import type { Environment } from '../../api/queries/admin/environments';
|
||||||
|
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
|
||||||
|
import type { ApplicationConfig } from '../../api/queries/commands';
|
||||||
import styles from './AppsTab.module.css';
|
import styles from './AppsTab.module.css';
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
@@ -42,15 +46,11 @@ function timeAgo(date: string): string {
|
|||||||
if (minutes < 60) return `${minutes}m ago`;
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
if (hours < 24) return `${hours}h ago`;
|
if (hours < 24) return `${hours}h ago`;
|
||||||
const days = Math.floor(hours / 24);
|
return `${Math.floor(hours / 24)}d ago`;
|
||||||
return `${days}d ago`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, 'success' | 'warning' | 'error' | 'auto' | 'running'> = {
|
const STATUS_COLORS: Record<string, 'success' | 'warning' | 'error' | 'auto' | 'running'> = {
|
||||||
RUNNING: 'running',
|
RUNNING: 'running', STARTING: 'warning', FAILED: 'error', STOPPED: 'auto',
|
||||||
STARTING: 'warning',
|
|
||||||
FAILED: 'error',
|
|
||||||
STOPPED: 'auto',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AppsTab() {
|
export default function AppsTab() {
|
||||||
@@ -58,39 +58,20 @@ export default function AppsTab() {
|
|||||||
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
||||||
const { data: environments = [] } = useEnvironments();
|
const { data: environments = [] } = useEnvironments();
|
||||||
|
|
||||||
// If an app is selected via sidebar, show detail view
|
if (appId) return <AppDetailView appId={appId} environments={environments} selectedEnv={selectedEnv} />;
|
||||||
if (appId) {
|
return <AppListView selectedEnv={selectedEnv} environments={environments} />;
|
||||||
return (
|
|
||||||
<AppDetailView
|
|
||||||
appId={appId}
|
|
||||||
environments={environments}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise show list view
|
|
||||||
return (
|
|
||||||
<AppListView
|
|
||||||
selectedEnv={selectedEnv}
|
|
||||||
environments={environments}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- List View ---
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// LIST VIEW
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function AppListView({
|
function AppListView({ selectedEnv, environments }: { selectedEnv: string | undefined; environments: Environment[] }) {
|
||||||
selectedEnv,
|
|
||||||
environments,
|
|
||||||
}: {
|
|
||||||
selectedEnv: string | undefined;
|
|
||||||
environments: Environment[];
|
|
||||||
}) {
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { data: allApps = [], isLoading: allLoading } = useAllApps();
|
const { data: allApps = [], isLoading: allLoading } = useAllApps();
|
||||||
const { data: envApps = [], isLoading: envLoading } = useApps(
|
const envId = useMemo(() => environments.find((e) => e.slug === selectedEnv)?.id, [environments, selectedEnv]);
|
||||||
selectedEnv ? environments.find((e) => e.slug === selectedEnv)?.id : undefined,
|
const { data: envApps = [], isLoading: envLoading } = useApps(envId);
|
||||||
);
|
|
||||||
|
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [newSlug, setNewSlug] = useState('');
|
const [newSlug, setNewSlug] = useState('');
|
||||||
@@ -101,70 +82,38 @@ function AppListView({
|
|||||||
const apps = selectedEnv ? envApps : allApps;
|
const apps = selectedEnv ? envApps : allApps;
|
||||||
const isLoading = selectedEnv ? envLoading : allLoading;
|
const isLoading = selectedEnv ? envLoading : allLoading;
|
||||||
|
|
||||||
// Build enriched rows with environment name and latest deployment status
|
const envMap = useMemo(() => new Map(environments.map((e) => [e.id, e])), [environments]);
|
||||||
const envMap = useMemo(() => {
|
|
||||||
const m = new Map<string, Environment>();
|
|
||||||
for (const e of environments) m.set(e.id, e);
|
|
||||||
return m;
|
|
||||||
}, [environments]);
|
|
||||||
|
|
||||||
type AppRow = App & { envName: string };
|
type AppRow = App & { id: string; envName: string };
|
||||||
const rows: AppRow[] = useMemo(
|
const rows: AppRow[] = useMemo(
|
||||||
() => apps.map((a) => ({
|
() => apps.map((a) => ({ ...a, envName: envMap.get(a.environmentId)?.displayName ?? '?' })),
|
||||||
...a,
|
|
||||||
envName: envMap.get(a.environmentId)?.displayName ?? '?',
|
|
||||||
})),
|
|
||||||
[apps, envMap],
|
[apps, envMap],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns: Column<AppRow>[] = useMemo(() => [
|
const columns: Column<AppRow>[] = useMemo(() => [
|
||||||
{
|
{ key: 'displayName', header: 'Name', sortable: true,
|
||||||
key: 'displayName',
|
render: (_v: unknown, row: AppRow) => (
|
||||||
header: 'Name',
|
<div><div className={styles.cellName}>{row.displayName}</div><div className={styles.cellMeta}>{row.slug}</div></div>
|
||||||
render: (_val: unknown, row: AppRow) => (
|
|
||||||
<div>
|
|
||||||
<div className={styles.cellName}>{row.displayName}</div>
|
|
||||||
<div className={styles.cellMeta}>{row.slug}</div>
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
sortable: true,
|
|
||||||
},
|
},
|
||||||
...(!selectedEnv ? [{
|
...(!selectedEnv ? [{ key: 'envName', header: 'Environment', sortable: true,
|
||||||
key: 'envName',
|
render: (_v: unknown, row: AppRow) => <Badge label={row.envName} color={'auto' as const} />,
|
||||||
header: 'Environment',
|
|
||||||
render: (_val: unknown, row: AppRow) => <Badge label={row.envName} color={'auto' as const} />,
|
|
||||||
sortable: true,
|
|
||||||
}] : []),
|
}] : []),
|
||||||
{
|
{ key: 'updatedAt', header: 'Updated', sortable: true,
|
||||||
key: 'updatedAt',
|
render: (_v: unknown, row: AppRow) => <span className={styles.cellMeta}>{timeAgo(row.updatedAt)}</span>,
|
||||||
header: 'Updated',
|
|
||||||
render: (_val: unknown, row: AppRow) => <span className={styles.cellMeta}>{timeAgo(row.updatedAt)}</span>,
|
|
||||||
sortable: true,
|
|
||||||
},
|
},
|
||||||
{
|
{ key: 'createdAt', header: 'Created', sortable: true,
|
||||||
key: 'createdAt',
|
render: (_v: unknown, row: AppRow) => <span className={styles.cellMeta}>{new Date(row.createdAt).toLocaleDateString()}</span>,
|
||||||
header: 'Created',
|
|
||||||
render: (_val: unknown, row: AppRow) => <span className={styles.cellMeta}>{new Date(row.createdAt).toLocaleDateString()}</span>,
|
|
||||||
sortable: true,
|
|
||||||
},
|
},
|
||||||
], [selectedEnv]);
|
], [selectedEnv]);
|
||||||
|
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
if (!newSlug.trim() || !newDisplayName.trim() || !newEnvId) return;
|
if (!newSlug.trim() || !newDisplayName.trim() || !newEnvId) return;
|
||||||
try {
|
try {
|
||||||
await createApp.mutateAsync({
|
await createApp.mutateAsync({ environmentId: newEnvId, slug: newSlug.trim(), displayName: newDisplayName.trim() });
|
||||||
environmentId: newEnvId,
|
|
||||||
slug: newSlug.trim(),
|
|
||||||
displayName: newDisplayName.trim(),
|
|
||||||
});
|
|
||||||
toast({ title: 'App created', description: newSlug.trim(), variant: 'success' });
|
toast({ title: 'App created', description: newSlug.trim(), variant: 'success' });
|
||||||
setCreating(false);
|
setCreating(false); setNewSlug(''); setNewDisplayName(''); setNewEnvId('');
|
||||||
setNewSlug('');
|
} catch { toast({ title: 'Failed to create app', variant: 'error', duration: 86_400_000 }); }
|
||||||
setNewDisplayName('');
|
|
||||||
setNewEnvId('');
|
|
||||||
} catch {
|
|
||||||
toast({ title: 'Failed to create app', variant: 'error', duration: 86_400_000 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) return <Spinner size="md" />;
|
if (isLoading) return <Spinner size="md" />;
|
||||||
@@ -175,60 +124,34 @@ function AppListView({
|
|||||||
<Button size="sm" variant="primary" onClick={() => {
|
<Button size="sm" variant="primary" onClick={() => {
|
||||||
if (!newEnvId && environments.length > 0) setNewEnvId(environments[0].id);
|
if (!newEnvId && environments.length > 0) setNewEnvId(environments[0].id);
|
||||||
setCreating(!creating);
|
setCreating(!creating);
|
||||||
}}>
|
}}>+ Create App</Button>
|
||||||
+ Create App
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{creating && (
|
{creating && (
|
||||||
<div className={styles.createForm}>
|
<div className={styles.createForm}>
|
||||||
<select
|
<select className={styles.nativeSelect} value={newEnvId} onChange={(e) => setNewEnvId(e.target.value)}>
|
||||||
className={styles.envSelect}
|
{environments.map((env) => <option key={env.id} value={env.id}>{env.displayName} ({env.slug})</option>)}
|
||||||
value={newEnvId}
|
|
||||||
onChange={(e) => setNewEnvId(e.target.value)}
|
|
||||||
>
|
|
||||||
{environments.map((env) => (
|
|
||||||
<option key={env.id} value={env.id}>{env.displayName} ({env.slug})</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
<Input placeholder="Slug *" value={newSlug} onChange={(e) => setNewSlug(e.target.value)} />
|
<Input placeholder="Slug *" value={newSlug} onChange={(e) => setNewSlug(e.target.value)} />
|
||||||
<Input placeholder="Display name *" value={newDisplayName} onChange={(e) => setNewDisplayName(e.target.value)} />
|
<Input placeholder="Display name *" value={newDisplayName} onChange={(e) => setNewDisplayName(e.target.value)} />
|
||||||
<div className={styles.createActions}>
|
<div className={styles.createActions}>
|
||||||
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
|
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
|
||||||
<Button
|
<Button size="sm" variant="primary" onClick={handleCreate} loading={createApp.isPending}
|
||||||
size="sm"
|
disabled={!newSlug.trim() || !newDisplayName.trim() || !newEnvId}>Create</Button>
|
||||||
variant="primary"
|
|
||||||
onClick={handleCreate}
|
|
||||||
loading={createApp.isPending}
|
|
||||||
disabled={!newSlug.trim() || !newDisplayName.trim() || !newEnvId}
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<DataTable columns={columns} data={rows} onRowClick={(row) => navigate(`/apps/${row.id}`)} />
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={rows}
|
|
||||||
onRowClick={(row) => {
|
|
||||||
window.location.href = `${window.location.pathname.replace(/\/apps.*/, '')}/apps/${row.id}`;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Detail View ---
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// DETAIL VIEW
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function AppDetailView({
|
function AppDetailView({ appId, environments, selectedEnv }: { appId: string; environments: Environment[]; selectedEnv: string | undefined }) {
|
||||||
appId,
|
|
||||||
environments,
|
|
||||||
}: {
|
|
||||||
appId: string;
|
|
||||||
environments: Environment[];
|
|
||||||
}) {
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { data: allApps = [] } = useAllApps();
|
const { data: allApps = [] } = useAllApps();
|
||||||
const app = useMemo(() => allApps.find((a) => a.id === appId), [allApps, appId]);
|
const app = useMemo(() => allApps.find((a) => a.id === appId), [allApps, appId]);
|
||||||
const { data: versions = [] } = useAppVersions(appId);
|
const { data: versions = [] } = useAppVersions(appId);
|
||||||
@@ -238,21 +161,14 @@ function AppDetailView({
|
|||||||
const stopDeployment = useStopDeployment();
|
const stopDeployment = useStopDeployment();
|
||||||
const deleteApp = useDeleteApp();
|
const deleteApp = useDeleteApp();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [subTab, setSubTab] = useState<'overview' | 'config'>('overview');
|
||||||
|
|
||||||
const envMap = useMemo(() => {
|
const envMap = useMemo(() => new Map(environments.map((e) => [e.id, e])), [environments]);
|
||||||
const m = new Map<string, Environment>();
|
const sortedVersions = useMemo(() => [...versions].sort((a, b) => b.version - a.version), [versions]);
|
||||||
for (const e of environments) m.set(e.id, e);
|
|
||||||
return m;
|
|
||||||
}, [environments]);
|
|
||||||
|
|
||||||
const sortedVersions = useMemo(
|
|
||||||
() => [...versions].sort((a, b) => b.version - a.version),
|
|
||||||
[versions],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!app) return <Spinner size="md" />;
|
if (!app) return <Spinner size="md" />;
|
||||||
|
|
||||||
const envName = envMap.get(app.environmentId)?.displayName ?? '?';
|
const env = envMap.get(app.environmentId);
|
||||||
|
|
||||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -260,9 +176,7 @@ function AppDetailView({
|
|||||||
try {
|
try {
|
||||||
const v = await uploadJar.mutateAsync({ appId, file });
|
const v = await uploadJar.mutateAsync({ appId, file });
|
||||||
toast({ title: `Version ${v.version} uploaded`, description: file.name, variant: 'success' });
|
toast({ title: `Version ${v.version} uploaded`, description: file.name, variant: 'success' });
|
||||||
} catch {
|
} catch { toast({ title: 'Upload failed', variant: 'error', duration: 86_400_000 }); }
|
||||||
toast({ title: 'Upload failed', variant: 'error', duration: 86_400_000 });
|
|
||||||
}
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,28 +184,22 @@ function AppDetailView({
|
|||||||
try {
|
try {
|
||||||
await createDeployment.mutateAsync({ appId, appVersionId: versionId, environmentId });
|
await createDeployment.mutateAsync({ appId, appVersionId: versionId, environmentId });
|
||||||
toast({ title: 'Deployment started', variant: 'success' });
|
toast({ title: 'Deployment started', variant: 'success' });
|
||||||
} catch {
|
} catch { toast({ title: 'Deploy failed', variant: 'error', duration: 86_400_000 }); }
|
||||||
toast({ title: 'Deploy failed', variant: 'error', duration: 86_400_000 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStop(deploymentId: string) {
|
async function handleStop(deploymentId: string) {
|
||||||
try {
|
try {
|
||||||
await stopDeployment.mutateAsync({ appId, deploymentId });
|
await stopDeployment.mutateAsync({ appId, deploymentId });
|
||||||
toast({ title: 'Deployment stopped', variant: 'warning' });
|
toast({ title: 'Deployment stopped', variant: 'warning' });
|
||||||
} catch {
|
} catch { toast({ title: 'Stop failed', variant: 'error', duration: 86_400_000 }); }
|
||||||
toast({ title: 'Stop failed', variant: 'error', duration: 86_400_000 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
try {
|
try {
|
||||||
await deleteApp.mutateAsync(appId);
|
await deleteApp.mutateAsync(appId);
|
||||||
toast({ title: 'App deleted', variant: 'warning' });
|
toast({ title: 'App deleted', variant: 'warning' });
|
||||||
window.history.back();
|
navigate('/apps');
|
||||||
} catch {
|
} catch { toast({ title: 'Delete failed', variant: 'error', duration: 86_400_000 }); }
|
||||||
toast({ title: 'Delete failed', variant: 'error', duration: 86_400_000 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -300,216 +208,364 @@ function AppDetailView({
|
|||||||
<div>
|
<div>
|
||||||
<h2 className={styles.detailTitle}>{app.displayName}</h2>
|
<h2 className={styles.detailTitle}>{app.displayName}</h2>
|
||||||
<div className={styles.detailMeta}>
|
<div className={styles.detailMeta}>
|
||||||
{app.slug} · <Badge label={envName} color="auto" />
|
{app.slug} · <Badge label={env?.displayName ?? '?'} color="auto" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" variant="danger" onClick={handleDelete}>Delete</Button>
|
<div className={styles.detailActions}>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.metaGrid}>
|
|
||||||
<span className={styles.metaLabel}>ID</span>
|
|
||||||
<MonoText size="xs">{app.id}</MonoText>
|
|
||||||
<span className={styles.metaLabel}>Environment</span>
|
|
||||||
<span>{envName}</span>
|
|
||||||
<span className={styles.metaLabel}>Created</span>
|
|
||||||
<span>{new Date(app.createdAt).toLocaleDateString()}</span>
|
|
||||||
<span className={styles.metaLabel}>Updated</span>
|
|
||||||
<span>{timeAgo(app.updatedAt)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Deployments across all environments */}
|
|
||||||
<SectionHeader>Deployments ({deployments.length})</SectionHeader>
|
|
||||||
{deployments.length === 0 && <p className={styles.emptyNote}>No deployments yet.</p>}
|
|
||||||
{deployments.map((d) => (
|
|
||||||
<DeploymentRow
|
|
||||||
key={d.id}
|
|
||||||
deployment={d}
|
|
||||||
versions={versions}
|
|
||||||
envMap={envMap}
|
|
||||||
onStop={() => handleStop(d.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Versions */}
|
|
||||||
<SectionHeader>Versions ({versions.length})</SectionHeader>
|
|
||||||
<div style={{ marginBottom: 8 }}>
|
|
||||||
<input ref={fileInputRef} type="file" accept=".jar" style={{ display: 'none' }} onChange={handleUpload} />
|
<input ref={fileInputRef} type="file" accept=".jar" style={{ display: 'none' }} onChange={handleUpload} />
|
||||||
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} loading={uploadJar.isPending}>
|
<Button size="sm" variant="primary" onClick={() => fileInputRef.current?.click()} loading={uploadJar.isPending}>Upload JAR</Button>
|
||||||
Upload JAR
|
<Button size="sm" variant="danger" onClick={handleDelete}>Delete App</Button>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{sortedVersions.length === 0 && <p className={styles.emptyNote}>No versions uploaded yet.</p>}
|
|
||||||
{sortedVersions.map((v) => (
|
|
||||||
<VersionRow
|
|
||||||
key={v.id}
|
|
||||||
version={v}
|
|
||||||
environments={environments}
|
|
||||||
onDeploy={(envId) => handleDeploy(v.id, envId)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Container Config */}
|
<div className={styles.subTabs}>
|
||||||
<SectionHeader>Container Configuration</SectionHeader>
|
<button className={`${styles.subTab} ${subTab === 'overview' ? styles.subTabActive : ''}`} onClick={() => setSubTab('overview')}>Overview</button>
|
||||||
<ContainerConfigForm app={app} environment={envMap.get(app.environmentId)} />
|
<button className={`${styles.subTab} ${subTab === 'config' ? styles.subTabActive : ''}`} onClick={() => setSubTab('config')}>Configuration</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subTab === 'overview' && (
|
||||||
|
<OverviewSubTab
|
||||||
|
app={app} deployments={deployments} versions={sortedVersions}
|
||||||
|
environments={environments} envMap={envMap} selectedEnv={selectedEnv}
|
||||||
|
onDeploy={handleDeploy} onStop={handleStop}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{subTab === 'config' && (
|
||||||
|
<ConfigSubTab app={app} environment={env} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Deployment Row ---
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// OVERVIEW SUB-TAB
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function DeploymentRow({
|
function OverviewSubTab({ app, deployments, versions, environments, envMap, selectedEnv, onDeploy, onStop }: {
|
||||||
deployment,
|
app: App; deployments: Deployment[]; versions: AppVersion[];
|
||||||
versions,
|
environments: Environment[]; envMap: Map<string, Environment>;
|
||||||
envMap,
|
selectedEnv: string | undefined;
|
||||||
onStop,
|
onDeploy: (versionId: string, envId: string) => void;
|
||||||
}: {
|
onStop: (deploymentId: string) => void;
|
||||||
deployment: Deployment;
|
|
||||||
versions: AppVersion[];
|
|
||||||
envMap: Map<string, Environment>;
|
|
||||||
onStop: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const version = versions.find((v) => v.id === deployment.appVersionId);
|
// Determine which env slug is selected
|
||||||
const env = envMap.get(deployment.environmentId);
|
const selectedEnvId = useMemo(
|
||||||
const canStop = deployment.status === 'RUNNING' || deployment.status === 'STARTING';
|
() => selectedEnv ? environments.find((e) => e.slug === selectedEnv)?.id : undefined,
|
||||||
|
[environments, selectedEnv],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.row}>
|
<>
|
||||||
<Badge label={deployment.status} color={STATUS_COLORS[deployment.status] ?? 'auto'} />
|
<SectionHeader>Deployments</SectionHeader>
|
||||||
<Badge label={env?.displayName ?? '?'} color="auto" />
|
{deployments.length === 0 && <p className={styles.emptyNote}>No deployments yet.</p>}
|
||||||
<span className={styles.rowText}>{version ? `v${version.version}` : '?'}</span>
|
{deployments.length > 0 && (
|
||||||
{deployment.containerName && <MonoText size="xs">{deployment.containerName}</MonoText>}
|
<table className={styles.table}>
|
||||||
{deployment.errorMessage && <span className={styles.errorText}>{deployment.errorMessage}</span>}
|
<thead>
|
||||||
<span className={styles.rowMeta}>{deployment.deployedAt ? timeAgo(deployment.deployedAt) : ''}</span>
|
<tr>
|
||||||
{canStop && <Button size="sm" variant="danger" onClick={onStop}>Stop</Button>}
|
<th>Environment</th>
|
||||||
</div>
|
<th>Version</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>URL</th>
|
||||||
|
<th>Deployed</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{deployments.map((d) => {
|
||||||
|
const dEnv = envMap.get(d.environmentId);
|
||||||
|
const version = versions.find((v) => v.id === d.appVersionId);
|
||||||
|
const isSelectedEnv = !selectedEnvId || d.environmentId === selectedEnvId;
|
||||||
|
const canAct = isSelectedEnv && (d.status === 'RUNNING' || d.status === 'STARTING');
|
||||||
|
const canStart = isSelectedEnv && d.status === 'STOPPED';
|
||||||
|
const url = dEnv ? `/${dEnv.slug}/${app.slug}/` : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={d.id} className={!isSelectedEnv ? styles.mutedRow : undefined}>
|
||||||
|
<td>
|
||||||
|
<Badge label={dEnv?.displayName ?? '?'} color={dEnv?.production ? 'error' : 'auto'} />
|
||||||
|
</td>
|
||||||
|
<td><Badge label={version ? `v${version.version}` : '?'} color="auto" /></td>
|
||||||
|
<td><Badge label={d.status} color={STATUS_COLORS[d.status] ?? 'auto'} /></td>
|
||||||
|
<td>
|
||||||
|
{d.status === 'RUNNING' ? (
|
||||||
|
<MonoText size="xs">{url}</MonoText>
|
||||||
|
) : (
|
||||||
|
<span className={styles.mutedMono}>{url}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td><span className={styles.cellMeta}>{d.deployedAt ? timeAgo(d.deployedAt) : '—'}</span></td>
|
||||||
|
<td style={{ textAlign: 'right' }}>
|
||||||
|
{canAct && <Button size="sm" variant="danger" onClick={() => onStop(d.id)}>Stop</Button>}
|
||||||
|
{canStart && <Button size="sm" variant="secondary" onClick={() => onDeploy(d.appVersionId, d.environmentId)}>Start</Button>}
|
||||||
|
{!isSelectedEnv && <span className={styles.envHint}>switch env to manage</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SectionHeader>Versions ({versions.length})</SectionHeader>
|
||||||
|
{versions.length === 0 && <p className={styles.emptyNote}>No versions uploaded yet.</p>}
|
||||||
|
{versions.map((v) => (
|
||||||
|
<VersionRow key={v.id} version={v} environments={environments} onDeploy={(envId) => onDeploy(v.id, envId)} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Version Row ---
|
function VersionRow({ version, environments, onDeploy }: { version: AppVersion; environments: Environment[]; onDeploy: (envId: string) => void }) {
|
||||||
|
|
||||||
function VersionRow({
|
|
||||||
version,
|
|
||||||
environments,
|
|
||||||
onDeploy,
|
|
||||||
}: {
|
|
||||||
version: AppVersion;
|
|
||||||
environments: Environment[];
|
|
||||||
onDeploy: (environmentId: string) => void;
|
|
||||||
}) {
|
|
||||||
const [deployEnv, setDeployEnv] = useState('');
|
const [deployEnv, setDeployEnv] = useState('');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.row}>
|
<div className={styles.row}>
|
||||||
<Badge label={`v${version.version}`} color="auto" />
|
<Badge label={`v${version.version}`} color="auto" />
|
||||||
<span className={styles.rowText}>
|
<span className={styles.rowText}>{version.jarFilename} ({formatBytes(version.jarSizeBytes)})</span>
|
||||||
{version.jarFilename} ({formatBytes(version.jarSizeBytes)})
|
<MonoText size="xs">{version.jarChecksum.substring(0, 8)}</MonoText>
|
||||||
</span>
|
<span className={styles.cellMeta}>{timeAgo(version.uploadedAt)}</span>
|
||||||
<span className={styles.rowMeta}>{timeAgo(version.uploadedAt)}</span>
|
<select className={styles.nativeSelect} value={deployEnv} onChange={(e) => setDeployEnv(e.target.value)}>
|
||||||
<select
|
|
||||||
className={styles.envSelect}
|
|
||||||
value={deployEnv}
|
|
||||||
onChange={(e) => setDeployEnv(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">Deploy to...</option>
|
<option value="">Deploy to...</option>
|
||||||
{environments.filter((e) => e.enabled).map((e) => (
|
{environments.filter((e) => e.enabled).map((e) => <option key={e.id} value={e.id}>{e.displayName}</option>)}
|
||||||
<option key={e.id} value={e.id}>{e.displayName}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
<Button
|
<Button size="sm" variant="secondary" disabled={!deployEnv} onClick={() => { onDeploy(deployEnv); setDeployEnv(''); }}>Deploy</Button>
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={!deployEnv}
|
|
||||||
onClick={() => { onDeploy(deployEnv); setDeployEnv(''); }}
|
|
||||||
>
|
|
||||||
Deploy
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Container Config Form ---
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// CONFIGURATION SUB-TAB
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function ContainerConfigForm({ app, environment }: { app: App; environment?: Environment }) {
|
function ConfigSubTab({ app, environment }: { app: App; environment?: Environment }) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const updateConfig = useUpdateContainerConfig();
|
const { data: agentConfig } = useApplicationConfig(app.slug);
|
||||||
|
const updateAgentConfig = useUpdateApplicationConfig();
|
||||||
|
const updateContainerConfig = useUpdateContainerConfig();
|
||||||
const isProd = environment?.production ?? false;
|
const isProd = environment?.production ?? false;
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
// Agent config state
|
||||||
|
const [engineLevel, setEngineLevel] = useState('REGULAR');
|
||||||
|
const [payloadCapture, setPayloadCapture] = useState('BOTH');
|
||||||
|
const [payloadSize, setPayloadSize] = useState('4');
|
||||||
|
const [payloadUnit, setPayloadUnit] = useState('KB');
|
||||||
|
const [appLogLevel, setAppLogLevel] = useState('INFO');
|
||||||
|
const [agentLogLevel, setAgentLogLevel] = useState('INFO');
|
||||||
|
const [metricsEnabled, setMetricsEnabled] = useState(true);
|
||||||
|
const [metricsInterval, setMetricsInterval] = useState('60');
|
||||||
|
const [samplingRate, setSamplingRate] = useState('1.0');
|
||||||
|
const [replayEnabled, setReplayEnabled] = useState(true);
|
||||||
|
const [routeControlEnabled, setRouteControlEnabled] = useState(true);
|
||||||
|
|
||||||
|
// Container config state
|
||||||
const defaults = environment?.defaultContainerConfig ?? {};
|
const defaults = environment?.defaultContainerConfig ?? {};
|
||||||
const merged = { ...defaults, ...app.containerConfig };
|
const merged = useMemo(() => ({ ...defaults, ...app.containerConfig }), [defaults, app.containerConfig]);
|
||||||
|
const [memoryLimit, setMemoryLimit] = useState('512');
|
||||||
|
const [memoryReserve, setMemoryReserve] = useState('');
|
||||||
|
const [cpuShares, setCpuShares] = useState('512');
|
||||||
|
const [cpuLimit, setCpuLimit] = useState('');
|
||||||
|
const [ports, setPorts] = useState<number[]>([]);
|
||||||
|
const [newPort, setNewPort] = useState('');
|
||||||
|
const [envVars, setEnvVars] = useState<{ key: string; value: string }[]>([]);
|
||||||
|
|
||||||
const [memoryLimitMb, setMemoryLimitMb] = useState<string>(String(merged.memoryLimitMb ?? '512'));
|
// Sync from server data
|
||||||
const [memoryReserveMb, setMemoryReserveMb] = useState<string>(String(merged.memoryReserveMb ?? ''));
|
const syncFromServer = useCallback(() => {
|
||||||
const [cpuShares, setCpuShares] = useState<string>(String(merged.cpuShares ?? '512'));
|
if (agentConfig) {
|
||||||
const [cpuLimit, setCpuLimit] = useState<string>(String(merged.cpuLimit ?? ''));
|
setEngineLevel(agentConfig.engineLevel ?? 'REGULAR');
|
||||||
const [exposedPorts, setExposedPorts] = useState<string>(
|
setPayloadCapture(agentConfig.payloadCaptureMode ?? 'BOTH');
|
||||||
Array.isArray(merged.exposedPorts) ? (merged.exposedPorts as number[]).join(', ') : '',
|
const raw = agentConfig.payloadCaptureMode !== undefined ? 4096 : 4096; // TODO: read from config when available
|
||||||
);
|
setPayloadSize('4'); setPayloadUnit('KB');
|
||||||
const [customEnvVars, setCustomEnvVars] = useState<string>(
|
setAppLogLevel(agentConfig.applicationLogLevel ?? 'INFO');
|
||||||
merged.customEnvVars ? JSON.stringify(merged.customEnvVars, null, 2) : '{}',
|
setAgentLogLevel(agentConfig.agentLogLevel ?? 'INFO');
|
||||||
);
|
setMetricsEnabled(agentConfig.metricsEnabled);
|
||||||
|
setSamplingRate(String(agentConfig.samplingRate));
|
||||||
|
}
|
||||||
|
setMemoryLimit(String(merged.memoryLimitMb ?? 512));
|
||||||
|
setMemoryReserve(String(merged.memoryReserveMb ?? ''));
|
||||||
|
setCpuShares(String(merged.cpuShares ?? 512));
|
||||||
|
setCpuLimit(String(merged.cpuLimit ?? ''));
|
||||||
|
setPorts(Array.isArray(merged.exposedPorts) ? merged.exposedPorts as number[] : []);
|
||||||
|
const vars = merged.customEnvVars as Record<string, string> | undefined;
|
||||||
|
setEnvVars(vars ? Object.entries(vars).map(([key, value]) => ({ key, value })) : []);
|
||||||
|
}, [agentConfig, merged]);
|
||||||
|
|
||||||
|
useEffect(() => { syncFromServer(); }, [syncFromServer]);
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
syncFromServer();
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadSizeToBytes(): number {
|
||||||
|
const val = parseFloat(payloadSize) || 0;
|
||||||
|
if (payloadUnit === 'KB') return val * 1024;
|
||||||
|
if (payloadUnit === 'MB') return val * 1048576;
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
const config: Record<string, unknown> = {
|
// Save agent config
|
||||||
memoryLimitMb: memoryLimitMb ? parseInt(memoryLimitMb) : null,
|
if (agentConfig) {
|
||||||
memoryReserveMb: memoryReserveMb ? parseInt(memoryReserveMb) : null,
|
try {
|
||||||
|
await updateAgentConfig.mutateAsync({
|
||||||
|
...agentConfig,
|
||||||
|
engineLevel, payloadCaptureMode: payloadCapture,
|
||||||
|
applicationLogLevel: appLogLevel, agentLogLevel,
|
||||||
|
metricsEnabled, samplingRate: parseFloat(samplingRate) || 1.0,
|
||||||
|
});
|
||||||
|
} catch { toast({ title: 'Failed to save agent config', variant: 'error', duration: 86_400_000 }); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save container config
|
||||||
|
const containerConfig: Record<string, unknown> = {
|
||||||
|
memoryLimitMb: memoryLimit ? parseInt(memoryLimit) : null,
|
||||||
|
memoryReserveMb: memoryReserve ? parseInt(memoryReserve) : null,
|
||||||
cpuShares: cpuShares ? parseInt(cpuShares) : null,
|
cpuShares: cpuShares ? parseInt(cpuShares) : null,
|
||||||
cpuLimit: cpuLimit ? parseFloat(cpuLimit) : null,
|
cpuLimit: cpuLimit ? parseFloat(cpuLimit) : null,
|
||||||
exposedPorts: exposedPorts ? exposedPorts.split(',').map((p) => parseInt(p.trim())).filter((p) => !isNaN(p)) : [],
|
exposedPorts: ports,
|
||||||
|
customEnvVars: Object.fromEntries(envVars.filter((v) => v.key.trim()).map((v) => [v.key, v.value])),
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
config.customEnvVars = JSON.parse(customEnvVars);
|
await updateContainerConfig.mutateAsync({ appId: app.id, config: containerConfig });
|
||||||
} catch {
|
toast({ title: 'Configuration saved', variant: 'success' });
|
||||||
toast({ title: 'Invalid JSON in environment variables', variant: 'error' });
|
setEditing(false);
|
||||||
return;
|
} catch { toast({ title: 'Failed to save container config', variant: 'error', duration: 86_400_000 }); }
|
||||||
}
|
|
||||||
try {
|
|
||||||
await updateConfig.mutateAsync({ appId: app.id, config });
|
|
||||||
toast({ title: 'Container config saved', variant: 'success' });
|
|
||||||
} catch {
|
|
||||||
toast({ title: 'Failed to save config', variant: 'error', duration: 86_400_000 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addPort() {
|
||||||
|
const p = parseInt(newPort);
|
||||||
|
if (p && !ports.includes(p)) { setPorts([...ports, p]); setNewPort(''); }
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.configForm}>
|
<>
|
||||||
<div className={styles.configSection}>
|
{!editing && (
|
||||||
<h4 className={styles.configTitle}>Resources</h4>
|
<div className={styles.editBanner}>
|
||||||
|
<span className={styles.editBannerText}>Configuration is read-only. Enter edit mode to make changes.</span>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => setEditing(true)}>Edit</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{editing && (
|
||||||
|
<div className={`${styles.editBanner} ${styles.editBannerActive}`}>
|
||||||
|
<span className={styles.editBannerTextWarn}>Editing configuration. Changes are not saved until you click Save.</span>
|
||||||
|
<div className={styles.editBannerActions}>
|
||||||
|
<Button size="sm" variant="ghost" onClick={handleCancel}>Cancel</Button>
|
||||||
|
<Button size="sm" variant="primary" onClick={handleSave}
|
||||||
|
loading={updateAgentConfig.isPending || updateContainerConfig.isPending}>Save Configuration</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Agent Observability */}
|
||||||
|
<SectionHeader>Agent Observability</SectionHeader>
|
||||||
<div className={styles.configGrid}>
|
<div className={styles.configGrid}>
|
||||||
<label className={styles.configLabel}>Memory Limit (MB)</label>
|
<span className={styles.configLabel}>Engine Level</span>
|
||||||
<Input value={memoryLimitMb} onChange={(e) => setMemoryLimitMb(e.target.value)} placeholder="512" />
|
<Select disabled={!editing} value={engineLevel} onChange={(e) => setEngineLevel(e.target.value)}
|
||||||
<label className={styles.configLabel}>Memory Reserve (MB)</label>
|
options={[{ value: 'NONE', label: 'NONE' }, { value: 'MINIMAL', label: 'MINIMAL' }, { value: 'REGULAR', label: 'REGULAR' }, { value: 'COMPLETE', label: 'COMPLETE' }]} />
|
||||||
<div className={styles.configField}>
|
|
||||||
<Input
|
<span className={styles.configLabel}>Payload Capture</span>
|
||||||
value={memoryReserveMb}
|
<Select disabled={!editing} value={payloadCapture} onChange={(e) => setPayloadCapture(e.target.value)}
|
||||||
onChange={(e) => setMemoryReserveMb(e.target.value)}
|
options={[{ value: 'NONE', label: 'NONE' }, { value: 'INPUT', label: 'INPUT' }, { value: 'OUTPUT', label: 'OUTPUT' }, { value: 'BOTH', label: 'BOTH' }]} />
|
||||||
placeholder="—"
|
|
||||||
disabled={!isProd}
|
<span className={styles.configLabel}>Max Payload Size</span>
|
||||||
/>
|
<div className={styles.configInline}>
|
||||||
|
<Input disabled={!editing} value={payloadSize} onChange={(e) => setPayloadSize(e.target.value)} style={{ width: 70 }} />
|
||||||
|
<Select disabled={!editing} value={payloadUnit} onChange={(e) => setPayloadUnit(e.target.value)} style={{ width: 90 }}
|
||||||
|
options={[{ value: 'bytes', label: 'bytes' }, { value: 'KB', label: 'KB' }, { value: 'MB', label: 'MB' }]} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className={styles.configLabel}>App Log Level</span>
|
||||||
|
<Select disabled={!editing} value={appLogLevel} onChange={(e) => setAppLogLevel(e.target.value)}
|
||||||
|
options={['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].map((l) => ({ value: l, label: l }))} />
|
||||||
|
|
||||||
|
<span className={styles.configLabel}>Agent Log Level</span>
|
||||||
|
<Select disabled={!editing} value={agentLogLevel} onChange={(e) => setAgentLogLevel(e.target.value)}
|
||||||
|
options={['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'].map((l) => ({ value: l, label: l }))} />
|
||||||
|
|
||||||
|
<span className={styles.configLabel}>Metrics</span>
|
||||||
|
<div className={styles.configInline}>
|
||||||
|
<Toggle checked={metricsEnabled} onChange={() => editing && setMetricsEnabled(!metricsEnabled)} disabled={!editing} />
|
||||||
|
<span className={metricsEnabled ? styles.toggleEnabled : styles.toggleDisabled}>{metricsEnabled ? 'Enabled' : 'Disabled'}</span>
|
||||||
|
<span className={styles.cellMeta} style={{ marginLeft: 8 }}>Interval</span>
|
||||||
|
<Input disabled={!editing} value={metricsInterval} onChange={(e) => setMetricsInterval(e.target.value)} style={{ width: 50 }} />
|
||||||
|
<span className={styles.cellMeta}>s</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className={styles.configLabel}>Sampling Rate</span>
|
||||||
|
<Input disabled={!editing} value={samplingRate} onChange={(e) => setSamplingRate(e.target.value)} style={{ width: 80 }} />
|
||||||
|
|
||||||
|
<span className={styles.configLabel}>Replay</span>
|
||||||
|
<div className={styles.configInline}>
|
||||||
|
<Toggle checked={replayEnabled} onChange={() => editing && setReplayEnabled(!replayEnabled)} disabled={!editing} />
|
||||||
|
<span className={replayEnabled ? styles.toggleEnabled : styles.toggleDisabled}>{replayEnabled ? 'Enabled' : 'Disabled'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className={styles.configLabel}>Route Control</span>
|
||||||
|
<div className={styles.configInline}>
|
||||||
|
<Toggle checked={routeControlEnabled} onChange={() => editing && setRouteControlEnabled(!routeControlEnabled)} disabled={!editing} />
|
||||||
|
<span className={routeControlEnabled ? styles.toggleEnabled : styles.toggleDisabled}>{routeControlEnabled ? 'Enabled' : 'Disabled'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Container Resources */}
|
||||||
|
<SectionHeader>Container Resources</SectionHeader>
|
||||||
|
<div className={styles.configGrid}>
|
||||||
|
<span className={styles.configLabel}>Memory Limit</span>
|
||||||
|
<div className={styles.configInline}>
|
||||||
|
<Input disabled={!editing} value={memoryLimit} onChange={(e) => setMemoryLimit(e.target.value)} style={{ width: 80 }} />
|
||||||
|
<span className={styles.cellMeta}>MB</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className={styles.configLabel}>Memory Reserve</span>
|
||||||
|
<div>
|
||||||
|
<div className={styles.configInline}>
|
||||||
|
<Input disabled value={memoryReserve} onChange={(e) => setMemoryReserve(e.target.value)} placeholder="---" style={{ width: 80 }} />
|
||||||
|
<span className={styles.cellMeta}>MB</span>
|
||||||
|
</div>
|
||||||
{!isProd && <span className={styles.configHint}>Available in production environments only</span>}
|
{!isProd && <span className={styles.configHint}>Available in production environments only</span>}
|
||||||
</div>
|
</div>
|
||||||
<label className={styles.configLabel}>CPU Shares</label>
|
|
||||||
<Input value={cpuShares} onChange={(e) => setCpuShares(e.target.value)} placeholder="512" />
|
<span className={styles.configLabel}>CPU Shares</span>
|
||||||
<label className={styles.configLabel}>CPU Limit (cores)</label>
|
<Input disabled={!editing} value={cpuShares} onChange={(e) => setCpuShares(e.target.value)} style={{ width: 80 }} />
|
||||||
<Input value={cpuLimit} onChange={(e) => setCpuLimit(e.target.value)} placeholder="e.g. 1.0" />
|
|
||||||
<label className={styles.configLabel}>Exposed Ports</label>
|
<span className={styles.configLabel}>CPU Limit</span>
|
||||||
<Input value={exposedPorts} onChange={(e) => setExposedPorts(e.target.value)} placeholder="e.g. 8080, 9090" />
|
<div className={styles.configInline}>
|
||||||
|
<Input disabled={!editing} value={cpuLimit} onChange={(e) => setCpuLimit(e.target.value)} placeholder="e.g. 1.0" style={{ width: 80 }} />
|
||||||
|
<span className={styles.cellMeta}>cores</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className={styles.configLabel}>Exposed Ports</span>
|
||||||
|
<div className={styles.portPills}>
|
||||||
|
{ports.map((p) => (
|
||||||
|
<span key={p} className={styles.portPill}>
|
||||||
|
{p}
|
||||||
|
<button className={styles.portPillDelete} disabled={!editing}
|
||||||
|
onClick={() => editing && setPorts(ports.filter((x) => x !== p))}>×</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input className={styles.portAddInput} disabled={!editing} placeholder="+ port" value={newPort}
|
||||||
|
onChange={(e) => setNewPort(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addPort(); } }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.configSection}>
|
{/* Environment Variables */}
|
||||||
<h4 className={styles.configTitle}>Custom Environment Variables</h4>
|
<SectionHeader>Environment Variables</SectionHeader>
|
||||||
<textarea
|
{envVars.map((v, i) => (
|
||||||
className={styles.envVarEditor}
|
<div key={i} className={styles.envVarRow}>
|
||||||
value={customEnvVars}
|
<Input disabled={!editing} value={v.key} onChange={(e) => {
|
||||||
onChange={(e) => setCustomEnvVars(e.target.value)}
|
const next = [...envVars]; next[i] = { ...v, key: e.target.value }; setEnvVars(next);
|
||||||
rows={6}
|
}} className={styles.envVarKey} />
|
||||||
spellCheck={false}
|
<Input disabled={!editing} value={v.value} onChange={(e) => {
|
||||||
/>
|
const next = [...envVars]; next[i] = { ...v, value: e.target.value }; setEnvVars(next);
|
||||||
</div>
|
}} className={styles.envVarValue} />
|
||||||
|
<button className={styles.envVarDelete} disabled={!editing}
|
||||||
<Button variant="primary" onClick={handleSave} loading={updateConfig.isPending}>
|
onClick={() => editing && setEnvVars(envVars.filter((_, j) => j !== i))}>×</button>
|
||||||
Save Configuration
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
{editing && (
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => setEnvVars([...envVars, { key: '', value: '' }])}>+ Add Variable</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user