mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
fix(ui): UI/UX pass — connectivity probe, mobile overflow, real metrics, deep links (#85)
Found during a browser-driven audit of the whole app:
- connectivity banner showed permanently: pingServer() resolved
undefined, which React Query treats as an error; returns boolean now
- Vite dev proxy now forwards /health so the probe actually measures
the backend, and the API target is env-overridable (VITE_API_PROXY)
- auth-session removed from the persisted query cache — stale sessions
no longer rehydrate into a false signed-in flash
- workspace Observability tab no longer shows fabricated random metrics
("87K Active User", fake performance/speed-test cards); the dashboard
now aggregates real per-service metrics (CPU, memory, network, instances)
- service detail tabs write ?section= so tab state is shareable/deep-linked
- Security page auto-selects the first project instead of rendering blank
- People page user-creation form no longer overflows its card
(minmax(0,1fr) grid tracks; same fix on env-var rows)
- page padding is responsive (px-4 sm:px-8) so mobile keeps content width;
Projects header wraps instead of clipping the "+ new project" button
- template deploy modal shows a loading state while the session resolves
instead of flashing the sign-in prompt
- audit log table strips the CIDR suffix from displayed IPs
- docs list excerpts no longer repeat the document title
- activity filter placeholder "Search search…" → "Search events…"
- workspace view tabs get more room: ⌘K button now waits for lg:
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
0dc5857b5f
commit
f88b4f1bf3
18 files changed
+251
-330
No files matched your search
+1
-1
@@ -537,7 +537,7 @@ and wired refs, one click or one CLI call.
|
|||||||
workspace, CLI `environments` + MCP parity. Per-env vars/domains ride the
|
workspace, CLI `environments` + MCP parity. Per-env vars/domains ride the
|
||||||
service's own scope (one env per service); promote ships via preview
|
service's own scope (one env per service); promote ships via preview
|
||||||
promote.
|
promote.
|
||||||
- [ ] **sqlc port** — ~265 raw `database/sql` sites → `sqlc/queries/`. Done in tranches (dynamic queries stay raw). Tranches merged: metrics storage + service domains (#75), service clone/move ops (#76), preview environments (#77), deployments + rollback (#78). Remaining: runtime, compliance, cron, git, security, auth, sleeper, scanner, gateway, and smaller files.
|
- [x] **sqlc port** — all static `database/sql` call sites → `sqlc/queries/` + generated `sqlcdb`. Merged: metrics + domains (#75), service clone/move (#76), preview environments (#77), deployments + rollback (#78), runtime ops (#79), cron (#80), sleep/wake sweeper (#81), git providers/repos/webhooks (#82), auth + security (#83), operations/settings/audit/activity/builds/misc + apwhy gateway (#84). Raw SQL remains only for runtime-assembled statements (filtered lists, sparse updates) and Better Auth `auth_users` probes.
|
||||||
- [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/
|
- [ ] **Tests** — canvas/wizard frontend coverage, deployment/template/
|
||||||
webhook table tests, Playwright e2e golden path.
|
webhook table tests, Playwright e2e golden path.
|
||||||
- [x] **First-run wizard** — public `GET /setup/status` + authed
|
- [x] **First-run wizard** — public `GET /setup/status` + authed
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const SEVERITY_STYLE: Record<string, { icon: typeof Info; cls: string; dot: stri
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SCHEMA: FilterConfig<ActivityEntry>[] = [
|
const SCHEMA: FilterConfig<ActivityEntry>[] = [
|
||||||
{ key: 'q', label: 'Search', type: 'search', searchFields: ['label', 'action', 'user_email'] },
|
{ key: 'q', label: 'events', type: 'search', searchFields: ['label', 'action', 'user_email'] },
|
||||||
{
|
{
|
||||||
key: 'severity', label: 'Severity', type: 'select',
|
key: 'severity', label: 'Severity', type: 'select',
|
||||||
options: [
|
options: [
|
||||||
@@ -67,7 +67,7 @@ export function ActivityPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-8 py-6 space-y-4">
|
<div className="px-4 py-6 sm:px-8 space-y-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<FilterBar {...filter} />
|
<FilterBar {...filter} />
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function AuditLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full px-8 py-6">
|
<div className="w-full px-4 py-6 sm:px-8">
|
||||||
<section className="panel p-6">
|
<section className="panel p-6">
|
||||||
<FilterBar {...filter} />
|
<FilterBar {...filter} />
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ export function AuditLogsPage() {
|
|||||||
<td className="px-3 py-2.5 mono text-xs text-[var(--text-tertiary)] max-w-[140px] truncate" title={log.resourceId}>
|
<td className="px-3 py-2.5 mono text-xs text-[var(--text-tertiary)] max-w-[140px] truncate" title={log.resourceId}>
|
||||||
{log.resourceId || '—'}
|
{log.resourceId || '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2.5 mono text-xs text-[var(--text-tertiary)]">{log.ipAddress || '—'}</td>
|
<td className="px-3 py-2.5 mono text-xs text-[var(--text-tertiary)]">{log.ipAddress?.replace(/\/\d+$/, '') || '—'}</td>
|
||||||
<td className="px-3 py-2.5 mono text-xs text-[var(--text-tertiary)] max-w-[220px] truncate" title={log.details}>
|
<td className="px-3 py-2.5 mono text-xs text-[var(--text-tertiary)] max-w-[220px] truncate" title={log.details}>
|
||||||
{log.details && log.details !== '{}' ? log.details : '—'}
|
{log.details && log.details !== '{}' ? log.details : '—'}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export function BuildsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats Overview */}
|
{/* Stats Overview */}
|
||||||
<div className="w-full px-8 py-6">
|
<div className="w-full px-4 py-6 sm:px-8">
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<div className="panel p-4">
|
<div className="panel p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
@@ -263,7 +263,7 @@ export function BuildsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Build Table */}
|
{/* Build Table */}
|
||||||
<div className="w-full px-8 py-6">
|
<div className="w-full px-4 py-6 sm:px-8">
|
||||||
<div className="panel overflow-hidden">
|
<div className="panel overflow-hidden">
|
||||||
{!isDemoMode && buildsQuery.isLoading ? (
|
{!isDemoMode && buildsQuery.isLoading ? (
|
||||||
<div className="p-12 text-center">
|
<div className="p-12 text-center">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ function renderMarkdown(markdown: string): string {
|
|||||||
|
|
||||||
function excerpt(markdown: string, length = 140): string {
|
function excerpt(markdown: string, length = 140): string {
|
||||||
const text = markdown
|
const text = markdown
|
||||||
|
.replace(/^#[^\n]*\n/, ' ')
|
||||||
.replace(/```[\s\S]*?```/g, ' ')
|
.replace(/```[\s\S]*?```/g, ' ')
|
||||||
.replace(/[#>*`\-[\]()]/g, ' ')
|
.replace(/[#>*`\-[\]()]/g, ' ')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
@@ -119,7 +120,7 @@ export function DocsBrowser() {
|
|||||||
|
|
||||||
<div className="min-w-0 flex-1 overflow-y-auto max-md:hidden">
|
<div className="min-w-0 flex-1 overflow-y-auto max-md:hidden">
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<article className="docs-article px-8 py-6">
|
<article className="docs-article px-4 py-6 sm:px-8">
|
||||||
<p className="mb-1 text-[11px] font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
|
<p className="mb-1 text-[11px] font-medium uppercase tracking-wider text-[var(--text-tertiary)]">
|
||||||
{selected.section} / {selected.path}
|
{selected.section} / {selected.path}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ export function NodeDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-8 py-6 space-y-6">
|
<div className="px-4 py-6 sm:px-8 space-y-6">
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<Stat label="cpu" value={latest ? `${latest.cpu.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${latest.cpu.cores} cores` : ''} />
|
<Stat label="cpu" value={latest ? `${latest.cpu.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${latest.cpu.cores} cores` : ''} />
|
||||||
<Stat label="memory" value={latest ? `${latest.memory.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${formatBytes(latest.memory.available)} free of ${formatBytes(latest.memory.limit)}` : ''} />
|
<Stat label="memory" value={latest ? `${latest.memory.usage_percent.toFixed(0)}%` : '—'} sub={latest ? `${formatBytes(latest.memory.available)} free of ${formatBytes(latest.memory.limit)}` : ''} />
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export function OperationsPage() {
|
|||||||
<Loader2 size={20} className="animate-spin text-[var(--text-muted)]" />
|
<Loader2 size={20} className="animate-spin text-[var(--text-muted)]" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full px-8 py-6 space-y-6">
|
<div className="w-full px-4 py-6 sm:px-8 space-y-6">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div className="panel-soft p-4">
|
<div className="panel-soft p-4">
|
||||||
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">In flight</p>
|
<p className="text-xs uppercase tracking-wider text-[var(--text-muted)]">In flight</p>
|
||||||
|
|||||||
@@ -211,9 +211,9 @@ export function ProjectsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<div className="w-full px-8 py-6">
|
<div className="w-full px-4 py-6 sm:px-8">
|
||||||
{/* Page head */}
|
{/* Page head */}
|
||||||
<div className="mb-6 flex items-center justify-between gap-5">
|
<div className="mb-6 flex flex-wrap items-center justify-between gap-x-5 gap-y-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="v-title">Projects<span className="v-cursor">_</span></h1>
|
<h1 className="v-title">Projects<span className="v-cursor">_</span></h1>
|
||||||
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
|
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
|
||||||
|
|||||||
@@ -436,7 +436,7 @@ export function UsagePage() {
|
|||||||
title="Usage"
|
title="Usage"
|
||||||
description="Platform usage metrics and operational summaries"
|
description="Platform usage metrics and operational summaries"
|
||||||
/>
|
/>
|
||||||
<div className="w-full px-8 py-6">
|
<div className="w-full px-4 py-6 sm:px-8">
|
||||||
<div className="flex items-center gap-2 mb-6">
|
<div className="flex items-center gap-2 mb-6">
|
||||||
<div className={`w-2 h-2 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
|
<div className={`w-2 h-2 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
|
||||||
<span className={`text-sm ${liveStatus === 'live' ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}`}>
|
<span className={`text-sm ${liveStatus === 'live' ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}`}>
|
||||||
@@ -844,7 +844,7 @@ export function PeoplePage() {
|
|||||||
title="People"
|
title="People"
|
||||||
description="Team management and access control"
|
description="Team management and access control"
|
||||||
/>
|
/>
|
||||||
<div className="w-full px-8 py-6">
|
<div className="w-full px-4 py-6 sm:px-8">
|
||||||
{profileQuery.isLoading ? (
|
{profileQuery.isLoading ? (
|
||||||
<div className="py-16 text-center">
|
<div className="py-16 text-center">
|
||||||
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||||
@@ -888,7 +888,7 @@ export function PeoplePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="grid gap-3 lg:grid-cols-[1fr_1fr_1fr_auto]"
|
className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_auto]"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
createUserMutation.mutate();
|
createUserMutation.mutate();
|
||||||
@@ -2145,7 +2145,7 @@ export function SettingsPage() {
|
|||||||
title="Settings"
|
title="Settings"
|
||||||
description="Manage account profile and local configuration"
|
description="Manage account profile and local configuration"
|
||||||
/>
|
/>
|
||||||
<div className="w-full px-8 py-6">
|
<div className="w-full px-4 py-6 sm:px-8">
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||||
{/* Profile Section */}
|
{/* Profile Section */}
|
||||||
<section className="panel p-6">
|
<section className="panel p-6">
|
||||||
@@ -2376,7 +2376,7 @@ export function DocsPage() {
|
|||||||
title="Docs"
|
title="Docs"
|
||||||
description="Guides and references — synced from GitHub, cached offline"
|
description="Guides and references — synced from GitHub, cached offline"
|
||||||
/>
|
/>
|
||||||
<div className="w-full px-8 py-6">
|
<div className="w-full px-4 py-6 sm:px-8">
|
||||||
<DocsBrowser />
|
<DocsBrowser />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2513,7 +2513,7 @@ export function DatabasesPage() {
|
|||||||
title="Databases"
|
title="Databases"
|
||||||
description="Managed database services — connection info, runtime actions and backups"
|
description="Managed database services — connection info, runtime actions and backups"
|
||||||
/>
|
/>
|
||||||
<div className="w-full px-8 py-6">
|
<div className="w-full px-4 py-6 sm:px-8">
|
||||||
<div className="mb-5 flex items-center justify-between">
|
<div className="mb-5 flex items-center justify-between">
|
||||||
<p className="text-xs text-[var(--text-tertiary)]">
|
<p className="text-xs text-[var(--text-tertiary)]">
|
||||||
{databases.length} {databases.length === 1 ? 'database' : 'databases'}
|
{databases.length} {databases.length === 1 ? 'database' : 'databases'}
|
||||||
@@ -3251,7 +3251,7 @@ export function HighAvailabilityPage() {
|
|||||||
title="High availability"
|
title="High availability"
|
||||||
description="Failover manager, policies, and active alerts"
|
description="Failover manager, policies, and active alerts"
|
||||||
/>
|
/>
|
||||||
<div className="p-8 space-y-8">
|
<div className="p-4 space-y-8 sm:p-8">
|
||||||
<div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
|
<div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
|
||||||
<ShieldCheck size={13} className="text-[var(--accent-primary)]" />
|
<ShieldCheck size={13} className="text-[var(--accent-primary)]" />
|
||||||
<span>HA state feeds the platform security posture.</span>
|
<span>HA state feeds the platform security posture.</span>
|
||||||
@@ -3615,39 +3615,43 @@ export function SecurityPage() {
|
|||||||
|
|
||||||
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects(), enabled: !isDemoMode });
|
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects(), enabled: !isDemoMode });
|
||||||
const projects = isDemoMode ? demoProjects : projectsQuery.data ?? [];
|
const projects = isDemoMode ? demoProjects : projectsQuery.data ?? [];
|
||||||
|
|
||||||
|
// Auto-select the first project so the page never renders empty — derived, not
|
||||||
|
// stored, so clearing the select still shows the empty state explicitly.
|
||||||
|
const effectiveProjectId = projectId || projects[0]?.id || '';
|
||||||
const servicesQuery = useQuery({
|
const servicesQuery = useQuery({
|
||||||
queryKey: ['services', projectId],
|
queryKey: ['services', effectiveProjectId],
|
||||||
queryFn: () => listServicesByProject(projectId),
|
queryFn: () => listServicesByProject(effectiveProjectId),
|
||||||
enabled: !isDemoMode && Boolean(projectId),
|
enabled: !isDemoMode && Boolean(effectiveProjectId),
|
||||||
});
|
});
|
||||||
const services = isDemoMode ? getDemoServicesByProject(projectId) : servicesQuery.data ?? [];
|
const services = isDemoMode ? getDemoServicesByProject(effectiveProjectId) : servicesQuery.data ?? [];
|
||||||
const metricsQuery = useQuery({
|
const metricsQuery = useQuery({
|
||||||
queryKey: ['security-metrics', projectId],
|
queryKey: ['security-metrics', effectiveProjectId],
|
||||||
queryFn: () => getSecurityMetrics(projectId),
|
queryFn: () => getSecurityMetrics(effectiveProjectId),
|
||||||
enabled: !isDemoMode && Boolean(projectId),
|
enabled: !isDemoMode && Boolean(effectiveProjectId),
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
});
|
});
|
||||||
const historyQuery = useQuery({
|
const historyQuery = useQuery({
|
||||||
queryKey: ['security-history', projectId],
|
queryKey: ['security-history', effectiveProjectId],
|
||||||
queryFn: () => getSecurityHistory(projectId),
|
queryFn: () => getSecurityHistory(effectiveProjectId),
|
||||||
enabled: !isDemoMode && Boolean(projectId),
|
enabled: !isDemoMode && Boolean(effectiveProjectId),
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
});
|
});
|
||||||
const vulnsQuery = useQuery({
|
const vulnsQuery = useQuery({
|
||||||
queryKey: ['security-vulns', projectId],
|
queryKey: ['security-vulns', effectiveProjectId],
|
||||||
queryFn: () => listVulnerabilities(projectId),
|
queryFn: () => listVulnerabilities(effectiveProjectId),
|
||||||
enabled: !isDemoMode && Boolean(projectId),
|
enabled: !isDemoMode && Boolean(effectiveProjectId),
|
||||||
});
|
});
|
||||||
|
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['security-metrics', projectId] });
|
queryClient.invalidateQueries({ queryKey: ['security-metrics', effectiveProjectId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['security-history', projectId] });
|
queryClient.invalidateQueries({ queryKey: ['security-history', effectiveProjectId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['security-vulns', projectId] });
|
queryClient.invalidateQueries({ queryKey: ['security-vulns', effectiveProjectId] });
|
||||||
};
|
};
|
||||||
|
|
||||||
const scanMutation = useMutation({
|
const scanMutation = useMutation({
|
||||||
mutationFn: () => startSecurityScan({
|
mutationFn: () => startSecurityScan({
|
||||||
project_id: projectId,
|
project_id: effectiveProjectId,
|
||||||
service_id: scanServiceId || undefined,
|
service_id: scanServiceId || undefined,
|
||||||
scan_type: scanType as 'dependency' | 'configuration' | 'comprehensive',
|
scan_type: scanType as 'dependency' | 'configuration' | 'comprehensive',
|
||||||
}),
|
}),
|
||||||
@@ -3661,10 +3665,10 @@ export function SecurityPage() {
|
|||||||
|
|
||||||
const metrics = isDemoMode ? demoSecurityMetrics : metricsQuery.data;
|
const metrics = isDemoMode ? demoSecurityMetrics : metricsQuery.data;
|
||||||
const scanHistory = isDemoMode
|
const scanHistory = isDemoMode
|
||||||
? demoSecurityScans.filter((s) => s.project_id === projectId)
|
? demoSecurityScans.filter((s) => s.project_id === effectiveProjectId)
|
||||||
: historyQuery.data ?? [];
|
: historyQuery.data ?? [];
|
||||||
const vulns = (isDemoMode
|
const vulns = (isDemoMode
|
||||||
? demoVulnerabilities.filter((v) => v.project_id === projectId)
|
? demoVulnerabilities.filter((v) => v.project_id === effectiveProjectId)
|
||||||
: vulnsQuery.data ?? []
|
: vulnsQuery.data ?? []
|
||||||
).filter((v) => v.status !== 'resolved');
|
).filter((v) => v.status !== 'resolved');
|
||||||
const pageError =
|
const pageError =
|
||||||
@@ -3677,7 +3681,7 @@ export function SecurityPage() {
|
|||||||
title="Security"
|
title="Security"
|
||||||
description="Scan findings, security posture, and compliance status"
|
description="Scan findings, security posture, and compliance status"
|
||||||
/>
|
/>
|
||||||
<div className="p-8 space-y-8">
|
<div className="p-4 space-y-8 sm:p-8">
|
||||||
<div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
|
<div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
|
||||||
<HeartPulse size={13} className="text-[var(--accent-primary)]" />
|
<HeartPulse size={13} className="text-[var(--accent-primary)]" />
|
||||||
<span>Platform availability and failover live under High availability.</span>
|
<span>Platform availability and failover live under High availability.</span>
|
||||||
@@ -3695,7 +3699,7 @@ export function SecurityPage() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Project</label>
|
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Project</label>
|
||||||
<select
|
<select
|
||||||
value={projectId}
|
value={effectiveProjectId}
|
||||||
onChange={(e) => { setProjectId(e.target.value); setScanServiceId(''); }}
|
onChange={(e) => { setProjectId(e.target.value); setScanServiceId(''); }}
|
||||||
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)] w-56"
|
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)] w-56"
|
||||||
>
|
>
|
||||||
@@ -3705,7 +3709,7 @@ export function SecurityPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{projectId && (
|
{effectiveProjectId && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Scan type</label>
|
<label className="block text-xs text-[var(--text-tertiary)] mb-1">Scan type</label>
|
||||||
@@ -3748,7 +3752,7 @@ export function SecurityPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{projectId && metrics && (
|
{effectiveProjectId && metrics && (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Security score"
|
title="Security score"
|
||||||
@@ -3779,7 +3783,7 @@ export function SecurityPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{projectId && (
|
{effectiveProjectId && (
|
||||||
<div className="panel p-6">
|
<div className="panel p-6">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<Shield size={18} className="text-[var(--accent-primary)]" />
|
<Shield size={18} className="text-[var(--accent-primary)]" />
|
||||||
@@ -3835,7 +3839,7 @@ export function SecurityPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{projectId && (
|
{effectiveProjectId && (
|
||||||
<div className="panel p-6">
|
<div className="panel p-6">
|
||||||
<div className="flex items-center gap-3 mb-4">
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<Clock size={18} className="text-[var(--accent-primary)]" />
|
<Clock size={18} className="text-[var(--accent-primary)]" />
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ function StatusBadge({ status }: { status: string }) {
|
|||||||
export function ServiceDetailPage() {
|
export function ServiceDetailPage() {
|
||||||
const { projectId = '', serviceId = '' } = useParams<{ projectId: string; serviceId: string }>();
|
const { projectId = '', serviceId = '' } = useParams<{ projectId: string; serviceId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isDemoMode = useDemoMode();
|
const isDemoMode = useDemoMode();
|
||||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||||
@@ -791,7 +791,7 @@ export function ServiceDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Metrics Overview */}
|
{/* Metrics Overview */}
|
||||||
<div className="w-full px-8 py-6">
|
<div className="w-full px-4 py-6 sm:px-8">
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<EnhancedMetricCard
|
<EnhancedMetricCard
|
||||||
title="CPU Usage"
|
title="CPU Usage"
|
||||||
@@ -904,7 +904,18 @@ export function ServiceDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.key}
|
key={item.key}
|
||||||
onClick={() => setActiveSection(item.key)}
|
onClick={() => {
|
||||||
|
setActiveSection(item.key);
|
||||||
|
setSearchParams(
|
||||||
|
(prev) => {
|
||||||
|
const next = new URLSearchParams(prev);
|
||||||
|
if (item.key === 'metrics') next.delete('section');
|
||||||
|
else next.set('section', item.key);
|
||||||
|
return next;
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
}}
|
||||||
className={`tab ${active ? 'active' : ''}`}
|
className={`tab ${active ? 'active' : ''}`}
|
||||||
>
|
>
|
||||||
<Icon size={14} />
|
<Icon size={14} />
|
||||||
@@ -1189,7 +1200,7 @@ export function ServiceDetailPage() {
|
|||||||
{' '}
|
{' '}
|
||||||
<span className="text-[var(--accent-primary)]">{entry.stream}</span>
|
<span className="text-[var(--accent-primary)]">{entry.stream}</span>
|
||||||
{' '}
|
{' '}
|
||||||
{entry.message}
|
{entry.message.replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z?\s+/, '')}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1362,7 +1373,7 @@ export function ServiceDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="grid grid-cols-[1fr_1fr_auto_auto] gap-2 px-1">
|
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto_auto] gap-2 px-1">
|
||||||
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Key</span>
|
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Key</span>
|
||||||
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Value</span>
|
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Value</span>
|
||||||
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Secret</span>
|
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Secret</span>
|
||||||
@@ -1370,7 +1381,7 @@ export function ServiceDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
{varRows.map((row, i) => (
|
{varRows.map((row, i) => (
|
||||||
<div key={i}>
|
<div key={i}>
|
||||||
<div className="grid grid-cols-[1fr_1fr_auto_auto] gap-2 items-center">
|
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto_auto] gap-2 items-center">
|
||||||
<input
|
<input
|
||||||
value={row.key}
|
value={row.key}
|
||||||
onChange={(e) => updateVarRow(i, { key: e.target.value })}
|
onChange={(e) => updateVarRow(i, { key: e.target.value })}
|
||||||
|
|||||||
@@ -541,7 +541,7 @@ export function TemplatesPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="w-full px-8 py-6">
|
<div className="w-full px-4 py-6 sm:px-8">
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-[380px_1fr] gap-6">
|
<div className="grid grid-cols-1 xl:grid-cols-[380px_1fr] gap-6">
|
||||||
{/* Template List */}
|
{/* Template List */}
|
||||||
<section className="panel overflow-hidden">
|
<section className="panel overflow-hidden">
|
||||||
@@ -857,7 +857,11 @@ export function TemplatesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!signedIn ? (
|
{sessionQuery.isPending && !isDemoMode ? (
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 py-5 text-center">
|
||||||
|
<Loader2 size={16} className="mx-auto animate-spin text-[var(--text-muted)]" />
|
||||||
|
</div>
|
||||||
|
) : !signedIn ? (
|
||||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 py-5 text-center">
|
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-4 py-5 text-center">
|
||||||
<p className="text-sm text-[var(--text-secondary)]">
|
<p className="text-sm text-[var(--text-secondary)]">
|
||||||
Sign in to deploy this template into one of your projects.
|
Sign in to deploy this template into one of your projects.
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ export function WebhooksPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-full px-8 py-6 space-y-4 max-w-4xl">
|
<div className="w-full px-4 py-6 sm:px-8 space-y-4 max-w-4xl">
|
||||||
{secret && (
|
{secret && (
|
||||||
<div className="panel-soft p-4 flex items-center gap-3">
|
<div className="panel-soft p-4 flex items-center gap-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|||||||
@@ -1,306 +1,205 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import { useQueries } from '@tanstack/react-query';
|
||||||
LineMetricChart,
|
import { Cpu, HardDrive, ArrowDownUp, Boxes } from 'lucide-react';
|
||||||
DualLineChart,
|
import { LineMetricChart, DonutChart } from '@/shared/components';
|
||||||
DonutChart,
|
import { getServiceMetrics, type ServiceMetrics } from '@/lib/api-client';
|
||||||
} from '@/shared/components';
|
import { formatBytes } from '@/lib/time';
|
||||||
|
|
||||||
interface MetricData {
|
|
||||||
cpu: number[];
|
|
||||||
ram: number;
|
|
||||||
ramUsed: string;
|
|
||||||
cache: number;
|
|
||||||
cacheBreakdown: { cache: number; nonCache: number; total: number };
|
|
||||||
users: number[];
|
|
||||||
performance: number[];
|
|
||||||
performanceAlt: number[];
|
|
||||||
upSpeed: number;
|
|
||||||
downSpeed: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MetricsDashboardProps {
|
interface MetricsDashboardProps {
|
||||||
projectId?: string;
|
services: { id: string; name: string; status: string }[];
|
||||||
isRunning?: boolean;
|
isDemoMode?: boolean;
|
||||||
onStop?: () => void;
|
|
||||||
onRestart?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRandom(min: number, max: number) {
|
const cardStyle: React.CSSProperties = {
|
||||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
background: 'var(--surface-card, rgba(255,255,255,0.03))',
|
||||||
|
border: '1px solid var(--border-subtle, rgba(255,255,255,0.09))',
|
||||||
|
borderRadius: '14px',
|
||||||
|
padding: '18px 20px',
|
||||||
|
};
|
||||||
|
|
||||||
|
function healthLabel(pct: number): { text: string; color: string } {
|
||||||
|
if (pct < 50) return { text: 'Good', color: 'var(--success, #5ee6a0)' };
|
||||||
|
if (pct < 80) return { text: 'Average', color: 'var(--warning, #f2c94c)' };
|
||||||
|
return { text: 'High', color: 'var(--error, #ff6b6b)' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateInitialData(): MetricData {
|
export function MetricsDashboard({ services, isDemoMode = false }: MetricsDashboardProps) {
|
||||||
return {
|
const liveServices = services.filter((s) => s.status === 'running');
|
||||||
cpu: Array.from({ length: 24 }, () => getRandom(10, 45)),
|
|
||||||
ram: getRandom(50, 85),
|
|
||||||
ramUsed: '5.4 GB',
|
|
||||||
cache: 352,
|
|
||||||
cacheBreakdown: { cache: 212, nonCache: 85.5, total: 1750 },
|
|
||||||
users: Array.from({ length: 15 }, () => getRandom(40, 110)),
|
|
||||||
performance: Array.from({ length: 12 }, () => getRandom(70, 95)),
|
|
||||||
performanceAlt: Array.from({ length: 12 }, () => getRandom(60, 85)),
|
|
||||||
upSpeed: 10.4,
|
|
||||||
downSpeed: 5.2,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MetricsDashboard({
|
const metricsQueries = useQueries({
|
||||||
isRunning = true,
|
queries: liveServices.map((service) => ({
|
||||||
}: MetricsDashboardProps) {
|
queryKey: ['service-metrics', service.id],
|
||||||
const [data, setData] = useState<MetricData>(generateInitialData);
|
queryFn: () => getServiceMetrics(service.id),
|
||||||
const [timeRange, setTimeRange] = useState<'day' | 'month' | 'year'>('day');
|
enabled: !isDemoMode,
|
||||||
|
refetchInterval: 5000,
|
||||||
useEffect(() => {
|
retry: false,
|
||||||
if (!isRunning) return;
|
})),
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
setData((prev) => {
|
|
||||||
const newCpu = [...prev.cpu.slice(1), getRandom(10, 45)];
|
|
||||||
const newUsers = [...prev.users.slice(1), getRandom(60, 110)];
|
|
||||||
const newPerf = [...prev.performance.slice(1), getRandom(82, 99)];
|
|
||||||
const newPerfAlt = [...prev.performanceAlt.slice(1), getRandom(60, 85)];
|
|
||||||
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
cpu: newCpu,
|
|
||||||
ram: getRandom(50, 85),
|
|
||||||
ramUsed: `${(8 * (prev.ram / 100)).toFixed(1)} GB`,
|
|
||||||
users: newUsers,
|
|
||||||
performance: newPerf,
|
|
||||||
performanceAlt: newPerfAlt,
|
|
||||||
upSpeed: parseFloat((Math.random() * 5 + 8).toFixed(1)),
|
|
||||||
downSpeed: parseFloat((Math.random() * 3 + 4).toFixed(1)),
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
const samples = metricsQueries
|
||||||
}, [isRunning]);
|
.map((q) => q.data)
|
||||||
|
.filter((m): m is ServiceMetrics => Boolean(m) && m!.status === 'ok');
|
||||||
|
|
||||||
const currentCpu = data.cpu[data.cpu.length - 1];
|
const totalCpu = samples.reduce((sum, m) => sum + m.cpu_percent, 0);
|
||||||
const currentUsers = data.users[data.users.length - 1];
|
const avgCpu = samples.length > 0 ? totalCpu / samples.length : 0;
|
||||||
const currentPerf = data.performance[data.performance.length - 1];
|
const memUsed = samples.reduce((sum, m) => sum + m.memory_usage_bytes, 0);
|
||||||
|
const memLimit = samples.reduce((sum, m) => sum + m.memory_limit_bytes, 0);
|
||||||
|
const memPct = memLimit > 0 ? (memUsed / memLimit) * 100 : 0;
|
||||||
|
const netRx = samples.reduce((sum, m) => sum + m.network_rx_bytes, 0);
|
||||||
|
const netTx = samples.reduce((sum, m) => sum + m.network_tx_bytes, 0);
|
||||||
|
const instanceCount = samples.reduce((sum, m) => sum + m.instances.length, 0);
|
||||||
|
|
||||||
|
// Rolling history for the sparklines — accumulated from real samples.
|
||||||
|
const [cpuHistory, setCpuHistory] = useState<number[]>([]);
|
||||||
|
const [netHistory, setNetHistory] = useState<{ rx: number[]; tx: number[] }>({ rx: [], tx: [] });
|
||||||
|
const lastNetRef = useRef<{ rx: number; tx: number; at: number } | null>(null);
|
||||||
|
|
||||||
|
const samplesKey = samples.map((s) => `${s.service_id}:${s.collected_at}`).join('|');
|
||||||
|
const avgCpuRef = avgCpu;
|
||||||
|
const netRxRef = netRx;
|
||||||
|
const netTxRef = netTx;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!samplesKey) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const prev = lastNetRef.current;
|
||||||
|
const elapsed = prev ? Math.max(1, (now - prev.at) / 1000) : 0;
|
||||||
|
const rxRate = prev && elapsed > 0 ? Math.max(0, (netRxRef - prev.rx) / elapsed) : 0;
|
||||||
|
const txRate = prev && elapsed > 0 ? Math.max(0, (netTxRef - prev.tx) / elapsed) : 0;
|
||||||
|
lastNetRef.current = { rx: netRxRef, tx: netTxRef, at: now };
|
||||||
|
setCpuHistory((h) => [...h, Math.min(100, avgCpuRef)].slice(-24));
|
||||||
|
setNetHistory((h) => ({ rx: [...h.rx, rxRate].slice(-24), tx: [...h.tx, txRate].slice(-24) }));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the samples' collected_at signature
|
||||||
|
}, [samplesKey]);
|
||||||
|
|
||||||
|
const cpuHealth = healthLabel(avgCpu);
|
||||||
|
const memHealth = healthLabel(memPct);
|
||||||
|
const reporting = samples.length;
|
||||||
|
const noData = !isDemoMode && liveServices.length > 0 && reporting === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Metrics Header - self.html exact match */}
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
|
<span className="text-[15px] font-bold text-[var(--text-primary)]">Metrics</span>
|
||||||
<span style={{ fontSize: '15px', fontWeight: 700, color: '#e8e9f0' }}>Metrics</span>
|
<span className="text-xs text-[var(--text-muted)]">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
{isDemoMode
|
||||||
<button style={{
|
? 'sample data'
|
||||||
height: '32px',
|
: reporting > 0
|
||||||
padding: '0 12px',
|
? `${reporting} service${reporting === 1 ? '' : 's'} reporting · every 5s`
|
||||||
borderRadius: '9px',
|
: 'no running services'}
|
||||||
border: '1px solid rgba(255,255,255,0.09)',
|
</span>
|
||||||
background: 'rgba(255,255,255,0.04)',
|
|
||||||
color: '#9295a4',
|
|
||||||
fontSize: '12.5px',
|
|
||||||
fontWeight: 500,
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '6px',
|
|
||||||
}}>
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
||||||
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
|
|
||||||
</svg>
|
|
||||||
Filter
|
|
||||||
</button>
|
|
||||||
<div className="pill-group">
|
|
||||||
{(['day', 'month', 'year'] as const).map((range) => (
|
|
||||||
<div
|
|
||||||
key={range}
|
|
||||||
className={`pill ${timeRange === range ? 'active' : ''}`}
|
|
||||||
onClick={() => setTimeRange(range)}
|
|
||||||
>
|
|
||||||
{range.charAt(0).toUpperCase() + range.slice(1)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{/* CPU */}
|
||||||
|
<div style={cardStyle}>
|
||||||
|
<div className="flex items-center gap-2.5 mb-3">
|
||||||
|
<span className="card-icon"><Cpu size={16} /></span>
|
||||||
|
<span className="text-sm font-semibold text-[var(--text-primary)]">CPU Usage</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-4xl font-black tracking-tight text-[var(--text-primary)]">
|
||||||
|
{avgCpu.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-[var(--text-tertiary)]">
|
||||||
|
<span style={{ color: cpuHealth.color, fontWeight: 700 }}>{cpuHealth.text}</span>
|
||||||
|
{' '}avg across running services
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 64, marginTop: 12 }}>
|
||||||
|
{cpuHistory.length > 1 ? (
|
||||||
|
<LineMetricChart data={cpuHistory} color="var(--accent-primary)" fillOpacity={0.15} showArea height={64} />
|
||||||
|
) : (
|
||||||
|
<div className="h-full rounded bg-[var(--surface-muted)] opacity-40" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 1: CPU, RAM, Cache - self.html exact: 13px gap */}
|
{/* Memory */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1.95fr', gap: '13px', marginBottom: '13px' }}>
|
<div style={cardStyle}>
|
||||||
{/* CPU Card */}
|
<div className="flex items-center gap-2.5 mb-3">
|
||||||
<div className="card">
|
<span className="card-icon"><HardDrive size={16} /></span>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
|
<span className="text-sm font-semibold text-[var(--text-primary)]">Memory</span>
|
||||||
<div className="card-icon"><svg viewBox="0 0 24 24"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/></svg></div>
|
|
||||||
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>CPU Usage</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '38px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{currentCpu}%</div>
|
<div className="text-4xl font-black tracking-tight text-[var(--text-primary)]">
|
||||||
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
|
{memPct.toFixed(0)}%
|
||||||
<span style={{ color: currentCpu < 50 ? '#5ee6a0' : currentCpu < 75 ? '#f2c94c' : '#b4e34a', fontWeight: 700 }}>
|
|
||||||
{currentCpu < 50 ? 'Good' : currentCpu < 75 ? 'Average' : 'High'}
|
|
||||||
</span>{' '}
|
|
||||||
Daily usage
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ height: 76, margin: '12px 0 6px' }}>
|
<div className="mt-1 text-xs text-[var(--text-tertiary)]">
|
||||||
<LineMetricChart data={data.cpu} color="#b4e34a" height={76} />
|
<span style={{ color: memHealth.color, fontWeight: 700 }}>{memHealth.text}</span>
|
||||||
|
{' '}{formatBytes(memUsed)} used
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '4px' }}>
|
<div className="mt-2 flex justify-center">
|
||||||
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
|
<DonutChart percentage={memPct} size={110} thickness={12} />
|
||||||
<div className="arrow-btn">
|
|
||||||
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-1 text-center text-xs text-[var(--text-tertiary)] mono">
|
||||||
|
{formatBytes(memUsed)} / {memLimit > 0 ? formatBytes(memLimit) : '∞'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RAM Card */}
|
{/* Network */}
|
||||||
<div className="card">
|
<div style={cardStyle}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
|
<div className="flex items-center gap-2.5 mb-3">
|
||||||
<div className="card-icon"><svg viewBox="0 0 24 24"><rect x="2" y="8" width="20" height="8" rx="2"/><path d="M6 8V6M10 8V6M14 8V6M18 8V6M6 16v2M18 16v2"/></svg></div>
|
<span className="card-icon"><ArrowDownUp size={16} /></span>
|
||||||
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>RAM Usage</span>
|
<span className="text-sm font-semibold text-[var(--text-primary)]">Network</span>
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '38px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{data.ram}%</div>
|
|
||||||
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
|
|
||||||
<span style={{ color: data.ram < 60 ? '#5ee6a0' : '#f2c94c', fontWeight: 700 }}>
|
|
||||||
{data.ram < 60 ? 'Good' : 'Average'}
|
|
||||||
</span>{' '}
|
|
||||||
Daily usage
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', margin: '10px 0 4px', position: 'relative' }}>
|
|
||||||
<DonutChart percent={data.ram} color="#f2c94c" size={160} thickness={16} />
|
|
||||||
<div style={{ position: 'absolute', bottom: 14, textAlign: 'center' }}>
|
|
||||||
<div style={{ fontSize: '10.5px', color: '#6b6e7d', marginBottom: 1 }}>Used</div>
|
|
||||||
<div style={{ fontSize: '12.5px', fontWeight: 700, color: '#e8e9f0' }}>{data.ramUsed} / 8GB</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '4px' }}>
|
|
||||||
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
|
|
||||||
<div className="arrow-btn">
|
|
||||||
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Cache Card */}
|
|
||||||
<div className="card">
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
|
|
||||||
<div className="card-icon"><svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></div>
|
|
||||||
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>Cache</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '38px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{data.cache} MB</div>
|
|
||||||
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
|
|
||||||
<span style={{ color: '#f2c94c', fontWeight: 700 }}>220MB Average</span>{' '}
|
|
||||||
cached images and files
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Segmented Bar */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', margin: '16px 0 15px', height: '32px' }}>
|
|
||||||
<div className="cache-seg" style={{ width: '43%', background: '#ff6b5b', borderRadius: '10px 4px 4px 10px' }} />
|
|
||||||
<div className="cache-seg" style={{ width: '13%', background: '#8c6ef0', borderRadius: '5px' }} />
|
|
||||||
<div className="cache-seg" style={{ flex: 1, background: 'rgba(255,255,255,0.07)', borderRadius: '4px 10px 10px 4px' }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats row */}
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto 1fr auto 1fr', gap: 0, alignItems: 'stretch' }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px', color: '#6b6e7d', marginBottom: '5px' }}>
|
|
||||||
<div className="stat-dot" style={{ background: '#ff6b5b' }} />
|
|
||||||
Cache
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '4px' }}>
|
|
||||||
<span style={{ fontSize: '15px', fontWeight: 800, color: '#e8e9f0' }}>212 MB</span>
|
|
||||||
<span style={{ fontSize: '11px', color: '#6b6e7d' }}>12%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ width: '1px', background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
|
|
||||||
<div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '11px', color: '#6b6e7d', marginBottom: '5px' }}>
|
|
||||||
<div className="stat-dot" style={{ background: '#8c6ef0' }} />
|
|
||||||
Non-Cache
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '4px' }}>
|
|
||||||
<span style={{ fontSize: '15px', fontWeight: 800, color: '#e8e9f0' }}>85.5 MB</span>
|
|
||||||
<span style={{ fontSize: '11px', color: '#6b6e7d' }}>4%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ width: '1px', background: 'rgba(255,255,255,0.08)', margin: '0 16px' }} />
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '11px', color: '#6b6e7d', marginBottom: '5px' }}>Total</div>
|
|
||||||
<div style={{ fontSize: '15px', fontWeight: 800, color: '#e8e9f0' }}>1.75 GB</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '16px' }}>
|
|
||||||
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
|
|
||||||
<div className="arrow-btn">
|
|
||||||
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-4xl font-black tracking-tight text-[var(--text-primary)]">
|
||||||
|
{formatBytes(netRx)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-[var(--text-tertiary)]">
|
||||||
|
<span style={{ color: 'var(--accent-primary)', fontWeight: 700 }}>RX</span>
|
||||||
|
{' '}total · TX {formatBytes(netTx)}
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 64, marginTop: 12 }}>
|
||||||
|
{netHistory.rx.length > 1 ? (
|
||||||
|
<LineMetricChart data={netHistory.rx} color="#7ab8ff" fillOpacity={0.12} showArea height={64} />
|
||||||
|
) : (
|
||||||
|
<div className="h-full rounded bg-[var(--surface-muted)] opacity-40" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[11px] text-[var(--text-muted)] mono">
|
||||||
|
{netHistory.rx.length > 0
|
||||||
|
? `↓ ${formatBytes(netHistory.rx[netHistory.rx.length - 1])}/s · ↑ ${formatBytes(netHistory.tx[netHistory.tx.length - 1])}/s`
|
||||||
|
: 'rate accumulating…'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: Active Users, Performance - self.html exact: 13px gap */}
|
{/* Instances */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '13px' }}>
|
<div style={cardStyle}>
|
||||||
{/* Active Users - horizontal layout */}
|
<div className="flex items-center gap-2.5 mb-3">
|
||||||
<div className="card" style={{ flexDirection: 'row', padding: 0, overflow: 'hidden' }}>
|
<span className="card-icon"><Boxes size={16} /></span>
|
||||||
<div style={{ flex: 1, padding: '20px 18px 18px 20px', display: 'flex', flexDirection: 'column' }}>
|
<span className="text-sm font-semibold text-[var(--text-primary)]">Instances</span>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
|
|
||||||
<div className="card-icon"><svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg></div>
|
|
||||||
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>Active User</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '36px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{currentUsers} K</div>
|
<div className="text-4xl font-black tracking-tight text-[var(--text-primary)]">
|
||||||
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>User active right now</div>
|
{isDemoMode ? liveServices.length : instanceCount}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', marginTop: '12px', flexWrap: 'wrap' }}>
|
|
||||||
{['🇨🇳', '🇮🇩', '🇲🇲', '🇲🇾', '🇯🇵', '🇮🇳', '🇰🇷', '🇵🇭'].map((flag, i) => (
|
|
||||||
<span key={i} className="flag">{flag}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginTop: 'auto', paddingTop: '14px' }}>
|
<div className="mt-1 text-xs text-[var(--text-tertiary)]">
|
||||||
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Details</span>
|
<span style={{ color: 'var(--success, #5ee6a0)', fontWeight: 700 }}>Running</span>
|
||||||
<div className="arrow-btn">
|
{' '}containers across {liveServices.length} service{liveServices.length === 1 ? '' : 's'}
|
||||||
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<ul className="mt-3 space-y-1.5">
|
||||||
</div>
|
{liveServices.slice(0, 4).map((service, i) => {
|
||||||
<div style={{ width: '50%', padding: '16px 14px 46px 0', display: 'flex', alignItems: 'flex-end' }}>
|
const m = samples.find((s) => s.service_id === service.id);
|
||||||
<LineMetricChart data={data.users} color="var(--accent-primary)" fillOpacity={0.15} showArea height={130} />
|
return (
|
||||||
|
<li key={service.id} className="flex items-center justify-between text-xs">
|
||||||
|
<span className="truncate text-[var(--text-secondary)]">
|
||||||
|
<span className="mr-1.5 inline-block h-1.5 w-1.5 rounded-full bg-[var(--success,#5ee6a0)] align-middle" />
|
||||||
|
{service.name}
|
||||||
|
</span>
|
||||||
|
<span className="mono text-[var(--text-muted)]">
|
||||||
|
{m ? `${m.cpu_percent.toFixed(1)}%` : i < 3 ? '—' : ''}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{liveServices.length > 4 && (
|
||||||
|
<li className="text-[11px] text-[var(--text-muted)]">+{liveServices.length - 4} more</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Performance Card */}
|
{noData && (
|
||||||
<div className="card">
|
<p className="mt-3 text-xs text-[var(--text-muted)]">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '12px' }}>
|
Metrics collector is not reporting yet — check that the node agent is online.
|
||||||
<div className="card-icon"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div>
|
</p>
|
||||||
<span style={{ fontSize: '14px', fontWeight: 600, color: '#e8e9f0' }}>Performance</span>
|
)}
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '16px', flex: 1 }}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ fontSize: '36px', fontWeight: 900, letterSpacing: '-1.5px', lineHeight: 1, color: '#e8e9f0' }}>{currentPerf}%</div>
|
|
||||||
<div style={{ fontSize: '12px', color: '#6b6e7d', marginTop: '4px' }}>
|
|
||||||
<span style={{ color: currentPerf > 85 ? '#5ee6a0' : '#f2c94c', fontWeight: 700 }}>
|
|
||||||
{currentPerf > 85 ? 'Good' : 'Average'}
|
|
||||||
</span>{' '}
|
|
||||||
Last scan
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '8px' }}>
|
|
||||||
<div style={{ width: 134, height: 58 }}>
|
|
||||||
<DualLineChart data1={data.performance} data2={data.performanceAlt} height={58} />
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', alignItems: 'flex-end' }}>
|
|
||||||
<div className="speed-row" style={{ color: '#7ab8ff' }}>
|
|
||||||
<svg viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 19 19 12"/></svg>
|
|
||||||
<span>{data.upSpeed}</span> Mbps
|
|
||||||
</div>
|
|
||||||
<div className="speed-row" style={{ color: 'var(--accent-primary)' }}>
|
|
||||||
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 5 5 12"/></svg>
|
|
||||||
<span>{data.downSpeed}</span> Mbps
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '14px' }}>
|
|
||||||
<span style={{ fontSize: '13px', color: '#6b6e7d', fontWeight: 500, cursor: 'pointer' }}>Check Speed</span>
|
|
||||||
<div className="arrow-btn">
|
|
||||||
<svg viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"/></svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -78,7 +78,7 @@ export function SharedVariablesSection({ projectId }: { projectId: string }) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{rows.map((row, i) => (
|
{rows.map((row, i) => (
|
||||||
<div key={i} className="grid grid-cols-[1fr_1fr_auto_auto] gap-2 items-center">
|
<div key={i} className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto_auto] gap-2 items-center">
|
||||||
<input
|
<input
|
||||||
value={row.key}
|
value={row.key}
|
||||||
onChange={(e) => updateRow(i, { key: e.target.value })}
|
onChange={(e) => updateRow(i, { key: e.target.value })}
|
||||||
|
|||||||
@@ -765,7 +765,7 @@ export function ProjectWorkspacePage() {
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCommandPaletteOpen(true)}
|
onClick={() => setCommandPaletteOpen(true)}
|
||||||
className="hidden md:flex items-center gap-2 h-8 px-2.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-xs hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors"
|
className="hidden lg:flex items-center gap-2 h-8 px-2.5 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-muted)] text-xs hover:border-[var(--border-default)] hover:text-[var(--text-secondary)] transition-colors"
|
||||||
>
|
>
|
||||||
<Search size={12} />
|
<Search size={12} />
|
||||||
<kbd className="px-1 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
|
<kbd className="px-1 py-0.5 rounded bg-[var(--surface-card)] text-[10px] font-mono">⌘K</kbd>
|
||||||
@@ -826,7 +826,7 @@ export function ProjectWorkspacePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="w-full px-8 py-6">
|
<div className="w-full px-4 py-6 sm:px-8">
|
||||||
<section className="min-w-0">
|
<section className="min-w-0">
|
||||||
{activeView === 'observability' && (
|
{activeView === 'observability' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -860,11 +860,7 @@ export function ProjectWorkspacePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metrics Dashboard */}
|
{/* Metrics Dashboard */}
|
||||||
<MetricsDashboard
|
<MetricsDashboard services={visibleServices} isDemoMode={isDemoMode} />
|
||||||
isRunning={runningServices > 0}
|
|
||||||
onStop={() => toast.showToast('Services stopped', 'warning')}
|
|
||||||
onRestart={() => toast.showToast('Services restarted', 'success')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Service List */}
|
{/* Service List */}
|
||||||
<div className="panel p-6">
|
<div className="panel p-6">
|
||||||
|
|||||||
@@ -492,11 +492,12 @@ export function getApiBaseUrl(): string {
|
|||||||
|
|
||||||
// pingServer hits the public /health route — any HTTP response proves
|
// pingServer hits the public /health route — any HTTP response proves
|
||||||
// reachability, and no auth means no 401 noise in the connectivity probe.
|
// reachability, and no auth means no 401 noise in the connectivity probe.
|
||||||
export async function pingServer(): Promise<void> {
|
export async function pingServer(): Promise<boolean> {
|
||||||
const res = await fetch(`${API_ROOT}/health`);
|
const res = await fetch(`${API_ROOT}/health`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Server returned ${res.status}`);
|
throw new Error(`Server returned ${res.status}`);
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SetupStatus = {
|
export type SetupStatus = {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const queryClient = new QueryClient({
|
|||||||
// High-churn or bulky keys never hit localStorage — a stale log tail is
|
// High-churn or bulky keys never hit localStorage — a stale log tail is
|
||||||
// worse than none.
|
// worse than none.
|
||||||
const NEVER_PERSIST = new Set([
|
const NEVER_PERSIST = new Set([
|
||||||
|
'auth-session',
|
||||||
'shell-notifications',
|
'shell-notifications',
|
||||||
'notifications',
|
'notifications',
|
||||||
'service-logs',
|
'service-logs',
|
||||||
|
|||||||
@@ -59,8 +59,12 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
// Same-origin API path for dev - matches the nginx proxy in the shipped image
|
// Same-origin API path for dev - matches the nginx proxy in the shipped image
|
||||||
|
'/health': {
|
||||||
|
target: process.env.VITE_API_PROXY ?? 'http://localhost:8082',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8082',
|
target: process.env.VITE_API_PROXY ?? 'http://localhost:8082',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
ws: true,
|
ws: true,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user