mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat(app): unified demo mode + public-demo build for Vercel
- Central demo-mode helper (lib/demo-mode.ts): ?demo=1 query flag plus
VITE_PUBLIC_DEMO build flag that makes every route demo for the
public Vercel showcase.
- Single demo fixture source (lib/demo-data.ts): projects, services,
builds, databases, cron, HA, security, host monitoring, templates.
Fixes cross-page ID drift that broke project navigation.
- Demo support on Projects, HA, Security, Usage, Templates, Landing,
Service detail; DemoRestricted gate on People/Settings/Audit/Admin.
- Demo-aware PlatformShell: nav filtering, env:demo, demo account menu,
?demo=1 preserved through internal links.
- TemplatesPage: selection moved into ?template= URL param for deep
links; template fixtures shared via demo-data.
- requestJson returns {} for unparseable 200 bodies (SPA fallback) so
envelope unwraps degrade instead of dereferencing null.
- Recharts ResponsiveContainer initialDimension kills -1 warnings.
- vercel.json: vite framework + SPA rewrites for direct deep links.
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
84b102a2c6
commit
5b0a8e0759
20 files changed
+873
-525
No files matched your search
@@ -23,7 +23,8 @@ import {
|
||||
type ProjectEntity,
|
||||
} from '@/lib/api-client';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { LoadingState, useToast } from '@/shared/components';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { DemoRestricted, LoadingState, useToast } from '@/shared/components';
|
||||
|
||||
const statLabels: Record<string, string> = {
|
||||
users: 'Users',
|
||||
@@ -42,12 +43,13 @@ const statLabels: Record<string, string> = {
|
||||
export function AdminPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const sessionQuery = useAuthSession();
|
||||
const isDemoMode = useDemoMode();
|
||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||
const myId = sessionQuery.data?.user.id;
|
||||
|
||||
const overviewQuery = useQuery({ queryKey: ['admin-overview'], queryFn: getAdminOverview });
|
||||
const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers });
|
||||
const projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }) });
|
||||
const overviewQuery = useQuery({ queryKey: ['admin-overview'], queryFn: getAdminOverview, enabled: !isDemoMode });
|
||||
const usersQuery = useQuery({ queryKey: ['admin-users'], queryFn: listAdminUsers, enabled: !isDemoMode });
|
||||
const projectsQuery = useQuery({ queryKey: ['admin-projects'], queryFn: () => listProjects({ limit: 100 }), enabled: !isDemoMode });
|
||||
const [editingProject, setEditingProject] = useState<ProjectEntity | null>(null);
|
||||
const [editForm, setEditForm] = useState({ name: '', description: '' });
|
||||
|
||||
@@ -99,6 +101,10 @@ export function AdminPage() {
|
||||
onError: (error) => showToast('error', 'Delete failed', error instanceof Error ? error.message : undefined),
|
||||
});
|
||||
|
||||
if (isDemoMode) {
|
||||
return <DemoRestricted feature="The admin console" />;
|
||||
}
|
||||
|
||||
if (overviewQuery.isPending) {
|
||||
return <LoadingState message="Loading admin console…" className="py-24" />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useDeferredValue, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { listAuditLogs } from '@/lib/api-client';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { formatDate, formatRelative } from '@/lib/time';
|
||||
import { DemoRestricted } from '@/shared/components';
|
||||
import { ScrollText, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
@@ -17,6 +19,7 @@ const inputClass =
|
||||
'h-9 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all';
|
||||
|
||||
export function AuditLogsPage() {
|
||||
const isDemoMode = useDemoMode();
|
||||
const [resource, setResource] = useState('');
|
||||
const [action, setAction] = useState('');
|
||||
const [actor, setActor] = useState('');
|
||||
@@ -42,8 +45,13 @@ export function AuditLogsPage() {
|
||||
const since = option && 'ms' in option ? new Date(Date.now() - option.ms).toISOString() : undefined;
|
||||
return listAuditLogs({ ...filters, since });
|
||||
},
|
||||
enabled: !isDemoMode,
|
||||
});
|
||||
|
||||
if (isDemoMode) {
|
||||
return <DemoRestricted feature="Audit logs" />;
|
||||
}
|
||||
|
||||
const logs = logsQuery.data ?? [];
|
||||
const hasNext = logs.length === PAGE_SIZE;
|
||||
const hasFilters = Boolean(resource || action || actor || range);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
cancelBuild,
|
||||
getBuildLogs,
|
||||
@@ -9,6 +8,8 @@ import {
|
||||
type BuildStatus,
|
||||
} from '@/lib/api-client';
|
||||
import { useBuildUpdates } from '@/lib/use-build-updates';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { demoBuilds } from '@/lib/demo-data';
|
||||
import { formatRelative } from '@/lib/time';
|
||||
import {
|
||||
Check,
|
||||
@@ -33,36 +34,6 @@ const statusOptions: Array<{ value: '' | BuildStatus; label: string }> = [
|
||||
{ value: 'cancelled', label: 'Cancelled' },
|
||||
];
|
||||
|
||||
const demoBuilds: BuildEntity[] = [
|
||||
{
|
||||
id: 'build-demo-01',
|
||||
projectId: 'project-demo',
|
||||
serviceId: 'service-api',
|
||||
status: 'success',
|
||||
progress: 100,
|
||||
startedAt: new Date(Date.now() - 20 * 60_000).toISOString(),
|
||||
completedAt: new Date(Date.now() - 18 * 60_000).toISOString(),
|
||||
imageName: 'ghcr.io/containr/api',
|
||||
imageTag: 'sha-1f2e3d4',
|
||||
size: 156_000_000,
|
||||
log: '[demo] Build finished successfully.',
|
||||
metadata: { branch: 'main' },
|
||||
},
|
||||
{
|
||||
id: 'build-demo-02',
|
||||
projectId: 'project-demo',
|
||||
serviceId: 'service-worker',
|
||||
status: 'running',
|
||||
progress: 54,
|
||||
startedAt: new Date(Date.now() - 2 * 60_000).toISOString(),
|
||||
imageName: 'ghcr.io/containr/worker',
|
||||
imageTag: 'sha-9a8b7c6',
|
||||
size: 0,
|
||||
log: '[demo] Building image layers...',
|
||||
metadata: { branch: 'feature/queue' },
|
||||
},
|
||||
];
|
||||
|
||||
function StatusBadge({ status }: { status: BuildStatus }) {
|
||||
const config = {
|
||||
success: { color: 'var(--success)', bg: 'var(--success-soft)', Icon: Check, animate: false },
|
||||
@@ -99,8 +70,7 @@ function bytesToHumanReadable(bytes: number): string {
|
||||
|
||||
export function BuildsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams] = useSearchParams();
|
||||
const isDemoMode = searchParams.get('demo') === '1';
|
||||
const isDemoMode = useDemoMode();
|
||||
|
||||
const [projectFilter, setProjectFilter] = useState('');
|
||||
const [serviceFilter, setServiceFilter] = useState('');
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
import { listProjects, listTemplates } from '@/lib/api-client';
|
||||
import { DocsBrowser } from '@/features/docs/DocsBrowser';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { demoProjects, demoTemplates } from '@/lib/demo-data';
|
||||
|
||||
const features = [
|
||||
{ icon: Rocket, title: 'Git-push deploys', text: 'Connect a repository and ship. Builds, rollouts, and rollbacks are handled for you.' },
|
||||
@@ -25,14 +27,16 @@ const features = [
|
||||
];
|
||||
|
||||
export function LandingPage() {
|
||||
const sessionQuery = useAuthSession();
|
||||
const signedIn = Boolean(sessionQuery.data);
|
||||
const isDemoMode = useDemoMode();
|
||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||
const signedIn = !isDemoMode && Boolean(sessionQuery.data);
|
||||
const demo = isDemoMode ? '?demo=1' : '';
|
||||
|
||||
const projectsQuery = useQuery({ queryKey: ['landing-projects'], queryFn: () => listProjects(), staleTime: 60_000 });
|
||||
const templatesQuery = useQuery({ queryKey: ['landing-templates'], queryFn: () => listTemplates(), staleTime: 60_000 });
|
||||
const projectsQuery = useQuery({ queryKey: ['landing-projects'], queryFn: () => listProjects(), staleTime: 60_000, enabled: !isDemoMode });
|
||||
const templatesQuery = useQuery({ queryKey: ['landing-templates'], queryFn: () => listTemplates(), staleTime: 60_000, enabled: !isDemoMode });
|
||||
|
||||
const projects = projectsQuery.data ?? [];
|
||||
const templates = (templatesQuery.data ?? []).slice(0, 12);
|
||||
const projects = isDemoMode ? demoProjects : (projectsQuery.data ?? []);
|
||||
const templates = (isDemoMode ? demoTemplates : (templatesQuery.data ?? [])).slice(0, 12);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--bg-void)] text-[var(--text-secondary)]">
|
||||
@@ -51,7 +55,7 @@ export function LandingPage() {
|
||||
<a href="#templates" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Templates</a>
|
||||
<a href="#docs" className="rounded-[var(--radius-md)] px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)] max-sm:hidden">Docs</a>
|
||||
<Link
|
||||
to="/projects"
|
||||
to={`/projects${demo}`}
|
||||
className="rounded-[var(--radius-md)] px-3 py-1.5 font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
Browse
|
||||
@@ -65,10 +69,10 @@ export function LandingPage() {
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to="/auth/sign-in"
|
||||
to={isDemoMode ? `/projects${demo}` : '/auth/sign-in'}
|
||||
className="ml-1 inline-flex items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-3.5 py-1.5 font-semibold text-[var(--accent-on)]"
|
||||
>
|
||||
Sign in <ArrowRight size={13} />
|
||||
{isDemoMode ? 'Live demo' : 'Sign in'} <ArrowRight size={13} />
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
@@ -91,10 +95,10 @@ export function LandingPage() {
|
||||
</p>
|
||||
<div className="mt-8 flex items-center justify-center gap-3">
|
||||
<Link
|
||||
to="/projects"
|
||||
to={`/projects${demo}`}
|
||||
className="inline-flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] px-5 py-2.5 text-sm font-semibold text-[var(--accent-on)] transition-transform hover:scale-[1.02]"
|
||||
>
|
||||
Browse projects <ArrowRight size={15} />
|
||||
{isDemoMode ? 'Explore the live demo' : 'Browse projects'} <ArrowRight size={15} />
|
||||
</Link>
|
||||
<a
|
||||
href="#docs"
|
||||
@@ -152,7 +156,7 @@ export function LandingPage() {
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/templates"
|
||||
to={`/templates${demo}`}
|
||||
className="inline-flex items-center gap-1.5 text-[13px] font-medium text-[var(--accent-primary)] hover:underline"
|
||||
>
|
||||
Browse all <ArrowRight size={13} />
|
||||
@@ -162,7 +166,7 @@ export function LandingPage() {
|
||||
{templates.map((template) => (
|
||||
<Link
|
||||
key={template.id}
|
||||
to="/templates"
|
||||
to={`/templates?template=${template.id}${isDemoMode ? '&demo=1' : ''}`}
|
||||
className="group rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-4 transition-colors hover:border-[var(--border-strong)]"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
@@ -212,9 +216,9 @@ export function LandingPage() {
|
||||
Containr — self-hosted container platform
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to="/projects" className="hover:text-[var(--text-secondary)]">Projects</Link>
|
||||
<Link to="/templates" className="hover:text-[var(--text-secondary)]">Templates</Link>
|
||||
<Link to="/admin" className="hover:text-[var(--text-secondary)]">Admin</Link>
|
||||
<Link to={`/projects${demo}`} className="hover:text-[var(--text-secondary)]">Projects</Link>
|
||||
<Link to={`/templates${demo}`} className="hover:text-[var(--text-secondary)]">Templates</Link>
|
||||
{!isDemoMode ? <Link to="/admin" className="hover:text-[var(--text-secondary)]">Admin</Link> : null}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
createProject,
|
||||
@@ -15,33 +15,14 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
|
||||
const demoProjects: ProjectEntity[] = [
|
||||
{
|
||||
id: 'project-demo',
|
||||
name: 'core-services',
|
||||
description: 'Sample project with mock services for UI preview.',
|
||||
isApproved: true,
|
||||
createdAt: new Date(Date.now() - 14 * 86_400_000).toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
stats: { service_count: 5, deployment_count: 12, running_services: 5, last_deployment: null },
|
||||
},
|
||||
{
|
||||
id: 'project-staging',
|
||||
name: 'ml-pipeline',
|
||||
description: 'Pre-production environment for testing new releases.',
|
||||
isApproved: true,
|
||||
createdAt: new Date(Date.now() - 7 * 86_400_000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 2 * 86_400_000).toISOString(),
|
||||
stats: { service_count: 2, deployment_count: 5, running_services: 1, last_deployment: null },
|
||||
},
|
||||
];
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { demoProjects } from '@/lib/demo-data';
|
||||
|
||||
const demoDeploys = [
|
||||
{ id: 'd1', name: 'api-gateway', project: 'core-services', status: 'DEPLOYED', when: '4m' },
|
||||
{ id: 'd2', name: 'web-frontend', project: 'core-services', status: 'BUILDING', when: '6m' },
|
||||
{ id: 'd3', name: 'worker', project: 'ml-pipeline', status: 'FAILED', when: '1h' },
|
||||
{ id: 'd4', name: 'site', project: 'growth-site', status: 'DEPLOYED', when: '2h' },
|
||||
{ id: 'd1', name: 'API Gateway', project: 'Core Platform', status: 'DEPLOYED', when: '4m' },
|
||||
{ id: 'd2', name: 'Web Frontend', project: 'Core Platform', status: 'BUILDING', when: '6m' },
|
||||
{ id: 'd3', name: 'Batch Evaluator', project: 'Inference Lab', status: 'FAILED', when: '1h' },
|
||||
{ id: 'd4', name: 'Marketing Site', project: 'Growth Surface', status: 'DEPLOYED', when: '2h' },
|
||||
];
|
||||
|
||||
function getHealthStatus(stats: ProjectStats): 'healthy' | 'degraded' | 'critical' {
|
||||
@@ -137,9 +118,8 @@ function ProjectCard({ project, href }: { project: ProjectEntity; href: string }
|
||||
|
||||
export function ProjectsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const isDemoMode = searchParams.get('demo') === '1';
|
||||
const isDemoMode = useDemoMode();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [isCreateOpen, setCreateOpen] = useState(false);
|
||||
|
||||
@@ -50,13 +50,27 @@ import {
|
||||
type DatabaseEntity,
|
||||
type FailoverPolicy,
|
||||
} from '@/lib/api-client';
|
||||
import { demoDatabases } from '@/lib/demo-data';
|
||||
import {
|
||||
demoBuilds,
|
||||
demoDatabases,
|
||||
demoHAAlerts,
|
||||
demoHAHealthResults,
|
||||
demoHAPolicies,
|
||||
demoHAStatus,
|
||||
demoHostMonitoring,
|
||||
demoProjects,
|
||||
demoSecurityMetrics,
|
||||
demoSecurityScans,
|
||||
demoVulnerabilities,
|
||||
getDemoServicesByProject,
|
||||
} from '@/lib/demo-data';
|
||||
import { DocsBrowser } from '@/features/docs/DocsBrowser';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { formatRelative } from '@/lib/time';
|
||||
import { getAuthBaseUrl, signOutAuthSession } from '@/lib/auth-client';
|
||||
import { useBuildUpdates } from '@/lib/use-build-updates';
|
||||
import { EnhancedMetricCard, LineAreaChart, DonutChart, SegmentedBar, BarChart } from '@/shared/components';
|
||||
import { EnhancedMetricCard, LineAreaChart, DonutChart, SegmentedBar, BarChart, DemoRestricted } from '@/shared/components';
|
||||
import {
|
||||
Clock,
|
||||
Activity,
|
||||
@@ -203,25 +217,27 @@ function formatUptime(seconds: number): string {
|
||||
|
||||
export function UsagePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const sessionQuery = useAuthSession();
|
||||
const isDemoMode = useDemoMode();
|
||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||
const profileQuery = useQuery({
|
||||
queryKey: ['user-profile'],
|
||||
queryFn: getCurrentUserProfile,
|
||||
enabled: Boolean(sessionQuery.data),
|
||||
enabled: !isDemoMode && Boolean(sessionQuery.data),
|
||||
retry: false,
|
||||
});
|
||||
const signedIn = Boolean(sessionQuery.data);
|
||||
const isAdmin = Boolean(profileQuery.data?.isAdmin);
|
||||
const signedIn = isDemoMode || Boolean(sessionQuery.data);
|
||||
const isAdmin = !isDemoMode && Boolean(profileQuery.data?.isAdmin);
|
||||
const buildsQuery = useQuery({
|
||||
queryKey: ['usage-builds'],
|
||||
queryFn: () => listBuilds({ page: 1, limit: 100 }),
|
||||
enabled: signedIn,
|
||||
enabled: signedIn && !isDemoMode,
|
||||
retry: false,
|
||||
});
|
||||
const hostQuery = useQuery({
|
||||
queryKey: ['usage-host-monitoring'],
|
||||
queryFn: getHostMonitoring,
|
||||
refetchInterval: 15_000,
|
||||
enabled: !isDemoMode,
|
||||
});
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: ['usage-agents'],
|
||||
@@ -251,9 +267,12 @@ export function UsagePage() {
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['agent-tokens'] }),
|
||||
});
|
||||
|
||||
const builds = buildsQuery.data?.builds ?? [];
|
||||
const builds = useMemo(
|
||||
() => (isDemoMode ? demoBuilds : buildsQuery.data?.builds ?? []),
|
||||
[isDemoMode, buildsQuery.data],
|
||||
);
|
||||
const liveStatus = useBuildUpdates(
|
||||
builds.map((build) => build.id),
|
||||
isDemoMode ? [] : builds.map((build) => build.id),
|
||||
() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['usage-builds'] });
|
||||
},
|
||||
@@ -280,20 +299,19 @@ export function UsagePage() {
|
||||
const avgBuildMinutes = averageBuildDurationMs / 60_000;
|
||||
const activeBuildPressure = runningBuilds + pendingBuilds;
|
||||
const uniqueServices = new Set(builds.map((build) => build.serviceId).filter(Boolean)).size;
|
||||
const host = hostQuery.data;
|
||||
const host = isDemoMode ? demoHostMonitoring : hostQuery.data;
|
||||
const agents = agentsQuery.data ?? [];
|
||||
|
||||
// Accumulate host samples for the load sparkline (polled every 15s).
|
||||
const [loadHistory, setLoadHistory] = useState<number[]>([]);
|
||||
useEffect(() => {
|
||||
const sample = hostQuery.data;
|
||||
if (!sample || sample.cpu.cores <= 0) {
|
||||
if (!host || host.cpu.cores <= 0) {
|
||||
return;
|
||||
}
|
||||
const loadPercent = Math.min(100, (sample.load.load1m / sample.cpu.cores) * 100);
|
||||
const loadPercent = Math.min(100, (host.load.load1m / host.cpu.cores) * 100);
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- accumulating polled samples is a legitimate sync-to-external pattern
|
||||
setLoadHistory((prev) => [...prev, loadPercent].slice(-24));
|
||||
}, [hostQuery.data]);
|
||||
}, [host]);
|
||||
|
||||
const hostLoadPercent = host && host.cpu.cores > 0
|
||||
? Math.min(100, (host.load.load1m / host.cpu.cores) * 100)
|
||||
@@ -307,14 +325,14 @@ export function UsagePage() {
|
||||
const buildsPerDay = useMemo(() => {
|
||||
const days = 14;
|
||||
const counts = new Array<number>(days).fill(0);
|
||||
const timestamps = (buildsQuery.data?.builds ?? [])
|
||||
const timestamps = builds
|
||||
.map((build) => new Date((build.startedAt ?? build.completedAt) as string).getTime())
|
||||
.filter((t) => Number.isFinite(t));
|
||||
if (timestamps.length === 0) {
|
||||
return counts;
|
||||
}
|
||||
const anchor = Math.max(...timestamps);
|
||||
(buildsQuery.data?.builds ?? []).forEach((build) => {
|
||||
builds.forEach((build) => {
|
||||
const timestamp = build.startedAt ?? build.completedAt;
|
||||
if (!timestamp) {
|
||||
return;
|
||||
@@ -327,7 +345,7 @@ export function UsagePage() {
|
||||
});
|
||||
const peak = Math.max(...counts, 1);
|
||||
return counts.map((count) => (count / peak) * 100);
|
||||
}, [buildsQuery.data]);
|
||||
}, [builds]);
|
||||
const onlineAgents = agents.filter((agent) => agent.status === 'online' || agent.status === 'connecting').length;
|
||||
const totalAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.total, 0);
|
||||
const availableAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.available, 0);
|
||||
@@ -382,12 +400,12 @@ containr-agent`;
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{buildsQuery.isLoading ? (
|
||||
{!isDemoMode && buildsQuery.isLoading ? (
|
||||
<div className="py-16 text-center">
|
||||
<Loader2 size={24} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading usage data...</p>
|
||||
</div>
|
||||
) : buildsQuery.isError ? (
|
||||
) : !isDemoMode && buildsQuery.isError ? (
|
||||
<div className="panel p-8 text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--error-soft)] flex items-center justify-center">
|
||||
<AlertCircle size={24} className="text-[var(--error)]" />
|
||||
@@ -455,7 +473,7 @@ containr-agent`;
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hostQuery.isError ? (
|
||||
{!isDemoMode && hostQuery.isError ? (
|
||||
<div className="rounded-[var(--radius-md)] bg-[var(--error-soft)] px-4 py-3 text-sm text-[var(--error)]">
|
||||
Host monitoring unavailable.
|
||||
</div>
|
||||
@@ -672,9 +690,11 @@ containr-agent`;
|
||||
|
||||
export function PeoplePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const isDemoMode = useDemoMode();
|
||||
const profileQuery = useQuery({
|
||||
queryKey: ['user-profile'],
|
||||
queryFn: getCurrentUserProfile,
|
||||
enabled: !isDemoMode,
|
||||
});
|
||||
const [newUserName, setNewUserName] = useState('');
|
||||
const [newUserEmail, setNewUserEmail] = useState('');
|
||||
@@ -699,12 +719,18 @@ export function PeoplePage() {
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: ['people-projects'],
|
||||
queryFn: () => listProjects(),
|
||||
enabled: !isDemoMode,
|
||||
});
|
||||
const auditLogsQuery = useQuery({
|
||||
queryKey: ['people-audit-logs'],
|
||||
queryFn: () => listAuditLogs({ page: 1, limit: 20 }),
|
||||
enabled: !isDemoMode,
|
||||
});
|
||||
|
||||
if (isDemoMode) {
|
||||
return <DemoRestricted feature="People and access management" />;
|
||||
}
|
||||
|
||||
let membersBody = 'Owner-only mode currently. Multi-user collaboration is planned.';
|
||||
|
||||
if (profileQuery.isLoading) {
|
||||
@@ -1242,9 +1268,11 @@ function PlatformSettingsSection() {
|
||||
export function SettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const isDemoMode = useDemoMode();
|
||||
const profileQuery = useQuery({
|
||||
queryKey: ['settings-profile'],
|
||||
queryFn: getCurrentUserProfile,
|
||||
enabled: !isDemoMode,
|
||||
});
|
||||
|
||||
const [nameDraft, setNameDraft] = useState<string | null>(null);
|
||||
@@ -1308,6 +1336,10 @@ export function SettingsPage() {
|
||||
refreshStorage();
|
||||
};
|
||||
|
||||
if (isDemoMode) {
|
||||
return <DemoRestricted feature="Account and platform settings" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<SecondaryPageHeader
|
||||
@@ -1561,8 +1593,7 @@ const DATABASE_PLANS = ['hobby', 'starter', 'standard', 'business'] as const;
|
||||
|
||||
export function DatabasesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams] = useSearchParams();
|
||||
const isDemoMode = searchParams.get('demo') === '1';
|
||||
const isDemoMode = useDemoMode();
|
||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||
const signedIn = isDemoMode || Boolean(sessionQuery.data);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
@@ -2074,14 +2105,15 @@ const failoverStrategies = [
|
||||
|
||||
export function HighAvailabilityPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const sessionQuery = useAuthSession();
|
||||
const isDemoMode = useDemoMode();
|
||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||
const profileQuery = useQuery({
|
||||
queryKey: ['user-profile'],
|
||||
queryFn: getCurrentUserProfile,
|
||||
enabled: Boolean(sessionQuery.data),
|
||||
enabled: !isDemoMode && Boolean(sessionQuery.data),
|
||||
retry: false,
|
||||
});
|
||||
const isAdmin = Boolean(profileQuery.data?.isAdmin);
|
||||
const isAdmin = !isDemoMode && Boolean(profileQuery.data?.isAdmin);
|
||||
const [failoverReason, setFailoverReason] = useState('');
|
||||
const [policyForm, setPolicyForm] = useState<{
|
||||
serviceId: string;
|
||||
@@ -2093,11 +2125,11 @@ export function HighAvailabilityPage() {
|
||||
} | null>(null);
|
||||
const [policyProjectId, setPolicyProjectId] = useState('');
|
||||
|
||||
const statusQuery = useQuery({ queryKey: ['ha-status'], queryFn: getHAStatus, refetchInterval: 15_000 });
|
||||
const policiesQuery = useQuery({ queryKey: ['ha-policies'], queryFn: listFailoverPolicies });
|
||||
const alertsQuery = useQuery({ queryKey: ['ha-alerts'], queryFn: listActiveAlerts, refetchInterval: 15_000 });
|
||||
const healthQuery = useQuery({ queryKey: ['ha-health'], queryFn: listHealthResults, refetchInterval: 30_000 });
|
||||
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects() });
|
||||
const statusQuery = useQuery({ queryKey: ['ha-status'], queryFn: getHAStatus, refetchInterval: 15_000, enabled: !isDemoMode });
|
||||
const policiesQuery = useQuery({ queryKey: ['ha-policies'], queryFn: listFailoverPolicies, enabled: !isDemoMode });
|
||||
const alertsQuery = useQuery({ queryKey: ['ha-alerts'], queryFn: listActiveAlerts, refetchInterval: 15_000, enabled: !isDemoMode });
|
||||
const healthQuery = useQuery({ queryKey: ['ha-health'], queryFn: listHealthResults, refetchInterval: 30_000, enabled: !isDemoMode });
|
||||
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects(), enabled: !isDemoMode });
|
||||
const servicesQuery = useQuery({
|
||||
queryKey: ['services', policyProjectId],
|
||||
queryFn: () => listServicesByProject(policyProjectId),
|
||||
@@ -2142,7 +2174,10 @@ export function HighAvailabilityPage() {
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const status = statusQuery.data;
|
||||
const status = isDemoMode ? demoHAStatus : statusQuery.data;
|
||||
const alerts = isDemoMode ? demoHAAlerts : alertsQuery.data ?? [];
|
||||
const policies = isDemoMode ? demoHAPolicies : policiesQuery.data ?? [];
|
||||
const healthResults = isDemoMode ? demoHAHealthResults : healthQuery.data ?? [];
|
||||
const pageError =
|
||||
statusQuery.error ?? policiesQuery.error ?? alertsQuery.error ?? healthQuery.error ??
|
||||
toggleMutation.error ?? failoverMutation.error ?? savePolicyMutation.error ??
|
||||
@@ -2158,7 +2193,7 @@ export function HighAvailabilityPage() {
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
|
||||
<ShieldCheck size={13} className="text-[var(--accent-primary)]" />
|
||||
<span>HA state feeds the platform security posture.</span>
|
||||
<Link to="/security" className="font-medium text-[var(--accent-primary)] hover:underline">
|
||||
<Link to={isDemoMode ? '/security?demo=1' : '/security'} className="font-medium text-[var(--accent-primary)] hover:underline">
|
||||
Open Security →
|
||||
</Link>
|
||||
</div>
|
||||
@@ -2246,13 +2281,13 @@ export function HighAvailabilityPage() {
|
||||
<AlertCircle size={18} className="text-[var(--accent-primary)]" />
|
||||
<h2 className="text-base font-semibold text-[var(--text-primary)]">Active alerts</h2>
|
||||
</div>
|
||||
{alertsQuery.isLoading ? (
|
||||
{!isDemoMode && alertsQuery.isLoading ? (
|
||||
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
|
||||
) : (alertsQuery.data ?? []).length === 0 ? (
|
||||
) : alerts.length === 0 ? (
|
||||
<p className="text-sm text-[var(--text-tertiary)]">No active alerts.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(alertsQuery.data ?? []).map((alert) => (
|
||||
{alerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3"
|
||||
@@ -2398,13 +2433,13 @@ export function HighAvailabilityPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{policiesQuery.isLoading ? (
|
||||
{!isDemoMode && policiesQuery.isLoading ? (
|
||||
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
|
||||
) : (policiesQuery.data ?? []).length === 0 ? (
|
||||
) : policies.length === 0 ? (
|
||||
<p className="text-sm text-[var(--text-tertiary)]">No failover policies configured.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(policiesQuery.data ?? []).map((policy: FailoverPolicy) => (
|
||||
{policies.map((policy: FailoverPolicy) => (
|
||||
<div
|
||||
key={policy.service_id}
|
||||
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3"
|
||||
@@ -2461,13 +2496,13 @@ export function HighAvailabilityPage() {
|
||||
<HeartPulse size={18} className="text-[var(--accent-primary)]" />
|
||||
<h2 className="text-base font-semibold text-[var(--text-primary)]">Health check results</h2>
|
||||
</div>
|
||||
{healthQuery.isLoading ? (
|
||||
{!isDemoMode && healthQuery.isLoading ? (
|
||||
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
|
||||
) : (healthQuery.data ?? []).length === 0 ? (
|
||||
) : healthResults.length === 0 ? (
|
||||
<p className="text-sm text-[var(--text-tertiary)]">No health checks configured.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(healthQuery.data ?? []).slice(0, 20).map((result, i) => (
|
||||
{healthResults.slice(0, 20).map((result, i) => (
|
||||
<div
|
||||
key={`${result.check_id}-${i}`}
|
||||
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3"
|
||||
@@ -2509,34 +2544,37 @@ const severityColor: Record<string, string> = {
|
||||
|
||||
export function SecurityPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const sessionQuery = useAuthSession();
|
||||
const signedIn = Boolean(sessionQuery.data);
|
||||
const [projectId, setProjectId] = useState('');
|
||||
const isDemoMode = useDemoMode();
|
||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||
const signedIn = !isDemoMode && Boolean(sessionQuery.data);
|
||||
const [projectId, setProjectId] = useState(isDemoMode ? 'demo-project-core' : '');
|
||||
const [scanType, setScanType] = useState<string>('comprehensive');
|
||||
const [scanServiceId, setScanServiceId] = useState('');
|
||||
|
||||
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects() });
|
||||
const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: () => listProjects(), enabled: !isDemoMode });
|
||||
const projects = isDemoMode ? demoProjects : projectsQuery.data ?? [];
|
||||
const servicesQuery = useQuery({
|
||||
queryKey: ['services', projectId],
|
||||
queryFn: () => listServicesByProject(projectId),
|
||||
enabled: Boolean(projectId),
|
||||
enabled: !isDemoMode && Boolean(projectId),
|
||||
});
|
||||
const services = isDemoMode ? getDemoServicesByProject(projectId) : servicesQuery.data ?? [];
|
||||
const metricsQuery = useQuery({
|
||||
queryKey: ['security-metrics', projectId],
|
||||
queryFn: () => getSecurityMetrics(projectId),
|
||||
enabled: Boolean(projectId),
|
||||
enabled: !isDemoMode && Boolean(projectId),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
const historyQuery = useQuery({
|
||||
queryKey: ['security-history', projectId],
|
||||
queryFn: () => getSecurityHistory(projectId),
|
||||
enabled: Boolean(projectId),
|
||||
enabled: !isDemoMode && Boolean(projectId),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
const vulnsQuery = useQuery({
|
||||
queryKey: ['security-vulns', projectId],
|
||||
queryFn: () => listVulnerabilities(projectId),
|
||||
enabled: Boolean(projectId),
|
||||
enabled: !isDemoMode && Boolean(projectId),
|
||||
});
|
||||
|
||||
const invalidate = () => {
|
||||
@@ -2559,8 +2597,14 @@ export function SecurityPage() {
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const metrics = metricsQuery.data;
|
||||
const vulns = (vulnsQuery.data ?? []).filter((v) => v.status !== 'resolved');
|
||||
const metrics = isDemoMode ? demoSecurityMetrics : metricsQuery.data;
|
||||
const scanHistory = isDemoMode
|
||||
? demoSecurityScans.filter((s) => s.project_id === projectId)
|
||||
: historyQuery.data ?? [];
|
||||
const vulns = (isDemoMode
|
||||
? demoVulnerabilities.filter((v) => v.project_id === projectId)
|
||||
: vulnsQuery.data ?? []
|
||||
).filter((v) => v.status !== 'resolved');
|
||||
const pageError =
|
||||
projectsQuery.error ?? metricsQuery.error ?? historyQuery.error ?? vulnsQuery.error ??
|
||||
scanMutation.error ?? vulnMutation.error;
|
||||
@@ -2575,7 +2619,7 @@ export function SecurityPage() {
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
|
||||
<HeartPulse size={13} className="text-[var(--accent-primary)]" />
|
||||
<span>Platform availability and failover live under High availability.</span>
|
||||
<Link to="/ha" className="font-medium text-[var(--accent-primary)] hover:underline">
|
||||
<Link to={isDemoMode ? '/ha?demo=1' : '/ha'} className="font-medium text-[var(--accent-primary)] hover:underline">
|
||||
Open HA →
|
||||
</Link>
|
||||
</div>
|
||||
@@ -2594,7 +2638,7 @@ export function SecurityPage() {
|
||||
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"
|
||||
>
|
||||
<option value="">Select project…</option>
|
||||
{(projectsQuery.data ?? []).map((p) => (
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -2621,7 +2665,7 @@ export function SecurityPage() {
|
||||
className="px-3 py-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] border border-[var(--border-subtle)] text-sm text-[var(--text-primary)]"
|
||||
>
|
||||
<option value="">All services</option>
|
||||
{(servicesQuery.data ?? []).map((s) => (
|
||||
{services.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -2636,7 +2680,7 @@ export function SecurityPage() {
|
||||
{scanMutation.isPending ? 'Starting…' : 'Run scan'}
|
||||
</button>
|
||||
) : (
|
||||
<span className="self-end pb-2 text-xs text-[var(--text-tertiary)]">Sign in to run scans.</span>
|
||||
<span className="self-end pb-2 text-xs text-[var(--text-tertiary)]">{isDemoMode ? 'Read-only in demo.' : 'Sign in to run scans.'}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -2679,7 +2723,7 @@ export function SecurityPage() {
|
||||
<Shield size={18} className="text-[var(--accent-primary)]" />
|
||||
<h2 className="text-base font-semibold text-[var(--text-primary)]">Findings</h2>
|
||||
</div>
|
||||
{vulnsQuery.isLoading ? (
|
||||
{!isDemoMode && vulnsQuery.isLoading ? (
|
||||
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
|
||||
) : vulns.length === 0 ? (
|
||||
<p className="text-sm text-[var(--text-tertiary)]">No open findings.</p>
|
||||
@@ -2735,13 +2779,13 @@ export function SecurityPage() {
|
||||
<Clock size={18} className="text-[var(--accent-primary)]" />
|
||||
<h2 className="text-base font-semibold text-[var(--text-primary)]">Scan history</h2>
|
||||
</div>
|
||||
{historyQuery.isLoading ? (
|
||||
{!isDemoMode && historyQuery.isLoading ? (
|
||||
<p className="text-sm text-[var(--text-secondary)]">Loading…</p>
|
||||
) : (historyQuery.data ?? []).length === 0 ? (
|
||||
) : scanHistory.length === 0 ? (
|
||||
<p className="text-sm text-[var(--text-tertiary)]">No scans yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(historyQuery.data ?? []).map((scan) => (
|
||||
{scanHistory.map((scan) => (
|
||||
<div
|
||||
key={scan.id}
|
||||
className="flex items-center justify-between rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-4 py-3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
createDeployment,
|
||||
deleteService,
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
type CronJobEntity,
|
||||
} from '@/lib/api-client';
|
||||
import { getDemoProjectById, getDemoServiceById, getDemoCronJobsByService } from '@/lib/demo-data';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { getCurrentUserProfile } from '@/lib/api-client';
|
||||
import { parseDotenv, validateVariableRows, type VariableDraft } from '../variable-utils';
|
||||
@@ -120,10 +121,9 @@ function StatusBadge({ status }: { status: string }) {
|
||||
|
||||
export function ServiceDetailPage() {
|
||||
const { projectId = '', serviceId = '' } = useParams<{ projectId: string; serviceId: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const isDemoMode = searchParams.get('demo') === '1';
|
||||
const isDemoMode = useDemoMode();
|
||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||
const signedIn = isDemoMode || Boolean(sessionQuery.data);
|
||||
const profileQuery = useQuery({
|
||||
|
||||
@@ -10,11 +10,12 @@ import {
|
||||
listProjects,
|
||||
listTemplates,
|
||||
updateTemplate,
|
||||
type TemplateDetailEntity,
|
||||
type TemplateEntity,
|
||||
type TemplateWriteInput,
|
||||
} from '@/lib/api-client';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { demoProjects, demoTemplates, demoTemplateDetails } from '@/lib/demo-data';
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
@@ -37,327 +38,6 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
const demoTemplates: TemplateEntity[] = [
|
||||
{
|
||||
id: 'tpl-react',
|
||||
name: 'React Application',
|
||||
description: 'Single-page frontend with Vite and static serving runtime.',
|
||||
category: 'frontend',
|
||||
logo: 'https://cdn.simpleicons.org/react',
|
||||
configRaw: '{"runtime":"node"}',
|
||||
variablesRaw: '[]',
|
||||
isOfficial: true,
|
||||
ownerId: null,
|
||||
},
|
||||
{
|
||||
id: 'tpl-go',
|
||||
name: 'Go API Service',
|
||||
description: 'API-ready Go runtime with direct binary startup.',
|
||||
category: 'web',
|
||||
logo: 'https://cdn.simpleicons.org/go',
|
||||
configRaw: '{"runtime":"go"}',
|
||||
variablesRaw: '[]',
|
||||
isOfficial: true,
|
||||
ownerId: null,
|
||||
},
|
||||
{
|
||||
id: 'tpl-postgres',
|
||||
name: 'PostgreSQL Database',
|
||||
description: 'Managed PostgreSQL service with credential setup variables.',
|
||||
category: 'database',
|
||||
logo: 'https://cdn.simpleicons.org/postgresql',
|
||||
configRaw: '{"runtime":"postgres"}',
|
||||
variablesRaw: '[]',
|
||||
isOfficial: true,
|
||||
ownerId: null,
|
||||
},
|
||||
{
|
||||
id: 'tpl-mysql',
|
||||
name: 'MySQL Database',
|
||||
description: 'Managed MySQL service for transactional workloads.',
|
||||
category: 'database',
|
||||
logo: 'https://cdn.simpleicons.org/mysql',
|
||||
configRaw: '{"runtime":"mysql"}',
|
||||
variablesRaw: '[]',
|
||||
isOfficial: true,
|
||||
ownerId: null,
|
||||
},
|
||||
{
|
||||
id: 'tpl-mariadb',
|
||||
name: 'MariaDB Database',
|
||||
description: 'Managed MariaDB service with MySQL compatibility.',
|
||||
category: 'database',
|
||||
logo: 'https://cdn.simpleicons.org/mariadb',
|
||||
configRaw: '{"runtime":"mariadb"}',
|
||||
variablesRaw: '[]',
|
||||
isOfficial: true,
|
||||
ownerId: null,
|
||||
},
|
||||
{
|
||||
id: 'tpl-clickhouse',
|
||||
name: 'ClickHouse Database',
|
||||
description: 'Columnar analytics database template for high-speed queries.',
|
||||
category: 'database',
|
||||
logo: 'https://cdn.simpleicons.org/clickhouse',
|
||||
configRaw: '{"runtime":"clickhouse"}',
|
||||
variablesRaw: '[]',
|
||||
isOfficial: true,
|
||||
ownerId: null,
|
||||
},
|
||||
{
|
||||
id: 'tpl-dragonfly',
|
||||
name: 'Dragonfly Database',
|
||||
description: 'Redis-compatible in-memory store powered by Dragonfly.',
|
||||
category: 'database',
|
||||
logo: 'https://cdn.simpleicons.org/redis',
|
||||
configRaw: '{"runtime":"dragonfly"}',
|
||||
variablesRaw: '[]',
|
||||
isOfficial: true,
|
||||
ownerId: null,
|
||||
},
|
||||
];
|
||||
|
||||
const demoTemplateDetails: Record<string, TemplateDetailEntity> = {
|
||||
'tpl-react': {
|
||||
template: demoTemplates[0],
|
||||
config: {
|
||||
type: 'web',
|
||||
runtime: 'node',
|
||||
buildCommand: 'npm install && npm run build',
|
||||
startCommand: 'npx serve -s dist',
|
||||
port: 3000,
|
||||
healthCheck: '/health',
|
||||
environment: {},
|
||||
nixpacksConfig: {},
|
||||
},
|
||||
variables: [
|
||||
{
|
||||
key: 'VITE_API_URL',
|
||||
label: 'API URL',
|
||||
defaultValue: 'https://api.example.com',
|
||||
required: true,
|
||||
secret: false,
|
||||
description: 'Public API endpoint for frontend calls',
|
||||
},
|
||||
],
|
||||
},
|
||||
'tpl-go': {
|
||||
template: demoTemplates[1],
|
||||
config: {
|
||||
type: 'web',
|
||||
runtime: 'go',
|
||||
buildCommand: 'go build -o app .',
|
||||
startCommand: './app',
|
||||
port: 8080,
|
||||
healthCheck: '/health',
|
||||
environment: {},
|
||||
nixpacksConfig: {},
|
||||
},
|
||||
variables: [
|
||||
{
|
||||
key: 'GO_ENV',
|
||||
label: 'Go Environment',
|
||||
defaultValue: 'production',
|
||||
required: false,
|
||||
secret: false,
|
||||
description: 'Runtime environment value',
|
||||
},
|
||||
],
|
||||
},
|
||||
'tpl-postgres': {
|
||||
template: demoTemplates[2],
|
||||
config: {
|
||||
type: 'database',
|
||||
runtime: 'postgres',
|
||||
buildCommand: '',
|
||||
startCommand: '',
|
||||
port: 5432,
|
||||
healthCheck: '',
|
||||
environment: {},
|
||||
nixpacksConfig: {},
|
||||
},
|
||||
variables: [
|
||||
{
|
||||
key: 'POSTGRES_USER',
|
||||
label: 'Username',
|
||||
defaultValue: 'postgres',
|
||||
required: true,
|
||||
secret: false,
|
||||
description: 'Database user',
|
||||
},
|
||||
{
|
||||
key: 'POSTGRES_PASSWORD',
|
||||
label: 'Password',
|
||||
defaultValue: '',
|
||||
required: true,
|
||||
secret: true,
|
||||
description: 'Database password',
|
||||
},
|
||||
],
|
||||
},
|
||||
'tpl-mysql': {
|
||||
template: demoTemplates[3],
|
||||
config: {
|
||||
type: 'database',
|
||||
runtime: 'mysql',
|
||||
buildCommand: '',
|
||||
startCommand: '',
|
||||
port: 3306,
|
||||
healthCheck: '',
|
||||
environment: {},
|
||||
nixpacksConfig: {},
|
||||
},
|
||||
variables: [
|
||||
{
|
||||
key: 'MYSQL_DATABASE',
|
||||
label: 'Database Name',
|
||||
defaultValue: 'app',
|
||||
required: true,
|
||||
secret: false,
|
||||
description: 'Initial database to create',
|
||||
},
|
||||
{
|
||||
key: 'MYSQL_USER',
|
||||
label: 'Username',
|
||||
defaultValue: 'app',
|
||||
required: true,
|
||||
secret: false,
|
||||
description: 'Application DB user',
|
||||
},
|
||||
{
|
||||
key: 'MYSQL_PASSWORD',
|
||||
label: 'User Password',
|
||||
defaultValue: '',
|
||||
required: true,
|
||||
secret: true,
|
||||
description: 'Application DB password',
|
||||
},
|
||||
{
|
||||
key: 'MYSQL_ROOT_PASSWORD',
|
||||
label: 'Root Password',
|
||||
defaultValue: '',
|
||||
required: true,
|
||||
secret: true,
|
||||
description: 'Root account password',
|
||||
},
|
||||
],
|
||||
},
|
||||
'tpl-mariadb': {
|
||||
template: demoTemplates[4],
|
||||
config: {
|
||||
type: 'database',
|
||||
runtime: 'mariadb',
|
||||
buildCommand: '',
|
||||
startCommand: '',
|
||||
port: 3306,
|
||||
healthCheck: '',
|
||||
environment: {},
|
||||
nixpacksConfig: {},
|
||||
},
|
||||
variables: [
|
||||
{
|
||||
key: 'MARIADB_DATABASE',
|
||||
label: 'Database Name',
|
||||
defaultValue: 'app',
|
||||
required: true,
|
||||
secret: false,
|
||||
description: 'Initial database to create',
|
||||
},
|
||||
{
|
||||
key: 'MARIADB_USER',
|
||||
label: 'Username',
|
||||
defaultValue: 'app',
|
||||
required: true,
|
||||
secret: false,
|
||||
description: 'Application DB user',
|
||||
},
|
||||
{
|
||||
key: 'MARIADB_PASSWORD',
|
||||
label: 'User Password',
|
||||
defaultValue: '',
|
||||
required: true,
|
||||
secret: true,
|
||||
description: 'Application DB password',
|
||||
},
|
||||
{
|
||||
key: 'MARIADB_ROOT_PASSWORD',
|
||||
label: 'Root Password',
|
||||
defaultValue: '',
|
||||
required: true,
|
||||
secret: true,
|
||||
description: 'Root account password',
|
||||
},
|
||||
],
|
||||
},
|
||||
'tpl-clickhouse': {
|
||||
template: demoTemplates[5],
|
||||
config: {
|
||||
type: 'database',
|
||||
runtime: 'clickhouse',
|
||||
buildCommand: '',
|
||||
startCommand: '',
|
||||
port: 8123,
|
||||
healthCheck: '',
|
||||
environment: {},
|
||||
nixpacksConfig: {},
|
||||
},
|
||||
variables: [
|
||||
{
|
||||
key: 'CLICKHOUSE_DB',
|
||||
label: 'Database Name',
|
||||
defaultValue: 'app',
|
||||
required: false,
|
||||
secret: false,
|
||||
description: 'Default database name',
|
||||
},
|
||||
{
|
||||
key: 'CLICKHOUSE_USER',
|
||||
label: 'Username',
|
||||
defaultValue: 'default',
|
||||
required: false,
|
||||
secret: false,
|
||||
description: 'ClickHouse user',
|
||||
},
|
||||
{
|
||||
key: 'CLICKHOUSE_PASSWORD',
|
||||
label: 'Password',
|
||||
defaultValue: '',
|
||||
required: false,
|
||||
secret: true,
|
||||
description: 'ClickHouse password',
|
||||
},
|
||||
],
|
||||
},
|
||||
'tpl-dragonfly': {
|
||||
template: demoTemplates[6],
|
||||
config: {
|
||||
type: 'database',
|
||||
runtime: 'dragonfly',
|
||||
buildCommand: '',
|
||||
startCommand: '',
|
||||
port: 6379,
|
||||
healthCheck: '',
|
||||
environment: {},
|
||||
nixpacksConfig: {},
|
||||
},
|
||||
variables: [
|
||||
{
|
||||
key: 'DRAGONFLY_PASSWORD',
|
||||
label: 'Password',
|
||||
defaultValue: '',
|
||||
required: false,
|
||||
secret: true,
|
||||
description: 'Optional Redis-compatible password',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const demoProjects = [
|
||||
{ id: 'project-demo', name: 'Demo Project' },
|
||||
{ id: 'project-internal', name: 'Internal Tooling' },
|
||||
];
|
||||
|
||||
function toServiceName(value: string): string {
|
||||
const normalized = value
|
||||
.toLowerCase()
|
||||
@@ -428,8 +108,8 @@ const EMPTY_TEMPLATE_JSON = JSON.stringify(
|
||||
|
||||
export function TemplatesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams] = useSearchParams();
|
||||
const isDemoMode = searchParams.get('demo') === '1';
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const isDemoMode = useDemoMode();
|
||||
|
||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||
const signedIn = isDemoMode || Boolean(sessionQuery.data);
|
||||
@@ -449,7 +129,18 @@ export function TemplatesPage() {
|
||||
|
||||
const [categoryFilter, setCategoryFilter] = useState('');
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
const [selectedTemplateIdState, setSelectedTemplateId] = useState<string | null>(null);
|
||||
// Selection lives in the URL (?template=<id>) so catalog deep links and
|
||||
// card clicks share one source of truth.
|
||||
const templateParam = searchParams.get('template');
|
||||
const selectTemplate = (id: string | null) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (id) {
|
||||
next.set('template', id);
|
||||
} else {
|
||||
next.delete('template');
|
||||
}
|
||||
setSearchParams(next, { replace: true });
|
||||
};
|
||||
const [deployProjectIdState, setDeployProjectId] = useState(searchParams.get('project') ?? '');
|
||||
const [deployNameByTemplate, setDeployNameByTemplate] = useState<Record<string, string>>({});
|
||||
const [variableValuesByTemplate, setVariableValuesByTemplate] = useState<
|
||||
@@ -507,11 +198,11 @@ export function TemplatesPage() {
|
||||
if (filteredTemplates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (selectedTemplateIdState && filteredTemplates.some((template) => template.id === selectedTemplateIdState)) {
|
||||
return selectedTemplateIdState;
|
||||
if (templateParam && filteredTemplates.some((template) => template.id === templateParam)) {
|
||||
return templateParam;
|
||||
}
|
||||
return filteredTemplates[0].id;
|
||||
}, [filteredTemplates, selectedTemplateIdState]);
|
||||
}, [filteredTemplates, templateParam]);
|
||||
|
||||
const templateDetailQuery = useQuery({
|
||||
queryKey: ['template-detail-page', selectedTemplateId],
|
||||
@@ -637,7 +328,7 @@ export function TemplatesPage() {
|
||||
setEditorOpen(false);
|
||||
setEditingTemplate(null);
|
||||
setEditorError(null);
|
||||
setSelectedTemplateId(template.id);
|
||||
selectTemplate(template.id);
|
||||
},
|
||||
onError: (error) => {
|
||||
setEditorError(error instanceof Error ? error.message : 'Failed to save template');
|
||||
@@ -648,7 +339,7 @@ export function TemplatesPage() {
|
||||
mutationFn: (id: string) => deleteTemplate(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['templates-page'] });
|
||||
setSelectedTemplateId(null);
|
||||
selectTemplate(null);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -846,7 +537,7 @@ export function TemplatesPage() {
|
||||
return (
|
||||
<button
|
||||
key={template.id}
|
||||
onClick={() => setSelectedTemplateId(template.id)}
|
||||
onClick={() => selectTemplate(template.id)}
|
||||
className={`w-full p-4 rounded-[var(--radius-lg)] border text-left transition-all duration-300 group ${
|
||||
selected
|
||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] shadow-lg shadow-[var(--accent-primary-glow)]'
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type CreateServiceInput,
|
||||
} from '@/lib/api-client';
|
||||
import { getDemoProjectById, getDemoServicesByProject, getDemoVariablesByProject } from '@/lib/demo-data';
|
||||
import { useDemoMode } from '@/lib/demo-mode';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { formatDate, formatRelative } from '@/lib/time';
|
||||
import type { ServiceVariable } from '../auto-connections';
|
||||
@@ -466,7 +467,7 @@ export function ProjectWorkspacePage() {
|
||||
const toast = useToast();
|
||||
|
||||
const activeView = (searchParams.get('view') as WorkspaceView | null) ?? 'canvas';
|
||||
const isDemoMode = searchParams.get('demo') === '1';
|
||||
const isDemoMode = useDemoMode();
|
||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||
const signedIn = isDemoMode || Boolean(sessionQuery.data);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
Reference in new issue
Block a user