diff --git a/web/app/(app)/activity/page.tsx b/web/app/(app)/activity/page.tsx index dfdebacd9..dd23ca9cd 100644 --- a/web/app/(app)/activity/page.tsx +++ b/web/app/(app)/activity/page.tsx @@ -1,63 +1,187 @@ 'use client'; +import { useCallback, useEffect, useState } from 'react'; +import Link from 'next/link'; import { Table, Column } from '@/components/ui/Table'; import { Button } from '@/components/ui/Button'; +import { Card } from '@/components/ui/Card'; +import { StatusIndicator } from '@/components/ui/StatusIndicator'; import { Download, RefreshCw } from 'lucide-react'; +import { cloudApiRequest } from '@/lib/api'; +import { eventStatus } from '@/lib/events'; +import { useApiConfig } from '@/hooks/useApiConfig'; +import styles from '../pages.module.css'; -interface Event { +interface ApiEvent { id: string; action: string; - resource: string; - user: string; - status: 'success' | 'failure'; - timestamp: string; + resource_id: string; + resource_type: string; + metadata: unknown; + created_at: string; } -const DUMMY_EVENTS: Event[] = [ - { id: 'evt-1001', action: 'RunInstances', resource: 'i-0x8231', user: 'root', status: 'success', timestamp: '2025-01-14 10:42:01' }, - { id: 'evt-1002', action: 'CreateBucket', resource: 'logs-archive', user: 'admin', status: 'success', timestamp: '2025-01-14 09:15:33' }, - { id: 'evt-1003', action: 'StopInstances', resource: 'i-0x11b2', user: 'root', status: 'success', timestamp: '2025-01-13 18:20:00' }, - { id: 'evt-1004', action: 'AttachVolume', resource: 'vol-0x555', user: 'system', status: 'failure', timestamp: '2025-01-13 18:19:45' }, -]; +function formatDate(value: string): string { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return parsed.toLocaleString(); +} + +function summarizeMetadata(metadata: unknown): string { + if (!metadata) { + return '-'; + } + + if (typeof metadata === 'string') { + return metadata; + } + + try { + const summary = JSON.stringify(metadata); + return summary.length > 110 ? `${summary.slice(0, 107)}...` : summary; + } catch { + return 'metadata unavailable'; + } +} export default function ActivityPage() { - const columns: Column[] = [ - { header: 'Event Name', accessorKey: 'action', width: '25%' }, - { header: 'Resource', accessorKey: 'resource', width: '20%' }, - { header: 'User', accessorKey: 'user' }, + const { config, ready, hasCredentials } = useApiConfig(); + const [events, setEvents] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const loadEvents = useCallback(async () => { + if (!ready) return; + + if (!hasCredentials) { + setEvents([]); + setIsLoading(false); + setError(null); + return; + } + + setIsLoading(true); + setError(null); + + try { + const response = await cloudApiRequest('/events?limit=120', undefined, config); + setEvents(response ?? []); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load activity events.'; + setError(message); + } finally { + setIsLoading(false); + } + }, [config, hasCredentials, ready]); + + useEffect(() => { + void loadEvents(); + }, [loadEvents]); + + const exportCsv = () => { + if (events.length === 0) { + return; + } + + const escapeCsv = (value: unknown) => { + const raw = String(value ?? ''); + const guarded = /^[=+\-@\t\r]/.test(raw) ? `'${raw}` : raw; + return `"${guarded.replace(/"/g, '""')}"`; + }; + + const header = ['id', 'action', 'resource_type', 'resource_id', 'metadata', 'created_at']; + const rows = events.map((event) => [ + event.id, + event.action, + event.resource_type, + event.resource_id, + summarizeMetadata(event.metadata), + event.created_at, + ]); + + const csv = [header, ...rows] + .map((line) => line.map(escapeCsv).join(',')) + .join('\n'); + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `thecloud-events-${new Date().toISOString().slice(0, 10)}.csv`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + setTimeout(() => URL.revokeObjectURL(url), 0); + }; + + const columns: Column[] = [ + { header: 'Action', accessorKey: 'action', width: '20%' }, + { + header: 'Resource', + width: '24%', + cell: (item) => ( +
+
{item.resource_type}
+
{item.resource_id}
+
+ ), + }, { header: 'Status', cell: (item) => ( - - {item.status.toUpperCase()} - + ) }, - { header: 'Timestamp', accessorKey: 'timestamp' }, + { + header: 'Metadata', + width: '28%', + cell: (item) => {summarizeMetadata(item.metadata)}, + }, + { + header: 'Timestamp', + width: '16%', + cell: (item) => formatDate(item.created_at), + }, ]; return ( -
-
+
+
-

Activity

-

Audit logs and system events.

+

Activity

+

Live event timeline from backend audit and orchestration services.

-
- - +
+ +
- + {!hasCredentials ? ( +
+
+ Activity API access is not configured. +

Add API key and tenant details in Settings to load event streams.

+
+ + Go to Settings + +
+ ) : null} + + {error ?
{error}
: null} + + +
+ ); } diff --git a/web/app/(app)/compute/page.tsx b/web/app/(app)/compute/page.tsx index c91d1cc0e..aab8a3500 100644 --- a/web/app/(app)/compute/page.tsx +++ b/web/app/(app)/compute/page.tsx @@ -1,83 +1,315 @@ 'use client'; -import { useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; import { Table, Column } from '@/components/ui/Table'; import { StatusIndicator } from '@/components/ui/StatusIndicator'; import { Button } from '@/components/ui/Button'; import { LaunchInstanceModal } from '@/components/compute/LaunchInstanceModal'; +import { Card } from '@/components/ui/Card'; import { Plus, RefreshCw } from 'lucide-react'; +import { cloudApiRequest } from '@/lib/api'; +import { useApiConfig } from '@/hooks/useApiConfig'; +import styles from '../pages.module.css'; -interface Instance { +interface ApiInstance { id: string; name: string; - type: string; - status: 'running' | 'stopped' | 'pending' | 'error'; - ip: string; + image?: string; + instance_type?: string; + status: string; + private_ip?: string; created_at: string; } -const DUMMY_INSTANCES: Instance[] = [ - { id: 'i-0x8231', name: 'Web Server 01', type: 't2.micro', status: 'running', ip: '10.0.1.12', created_at: '2025-01-10' }, - { id: 'i-0x992a', name: 'Worker Node', type: 't3.medium', status: 'running', ip: '10.0.1.15', created_at: '2025-01-11' }, - { id: 'i-0x11b2', name: 'DB Replica', type: 'm5.large', status: 'stopped', ip: '10.0.2.4', created_at: '2025-01-12' }, - { id: 'i-0x33c4', name: 'Cache Layer', type: 't2.small', status: 'error', ip: '-', created_at: '2025-01-14' }, -]; +interface ApiVpc { + id: string; + name: string; + cidr_block?: string; +} + +interface LaunchPayload { + name: string; + image: string; + ports: string; + vpcId?: string; +} + +type InstanceIndicator = 'running' | 'stopped' | 'pending' | 'error'; + +function mapStatus(status: string): InstanceIndicator { + const normalized = status.toLowerCase(); + if (normalized.includes('running')) return 'running'; + if (normalized.includes('stop') || normalized.includes('deleted')) return 'stopped'; + if (normalized.includes('start') || normalized.includes('pending') || normalized.includes('creating')) { + return 'pending'; + } + return 'error'; +} + +function formatDate(value: string): string { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return parsed.toLocaleString(); +} export default function ComputePage() { + const { config, ready, hasCredentials } = useApiConfig(); const [isModalOpen, setIsModalOpen] = useState(false); - const [instances, setInstances] = useState(DUMMY_INSTANCES); + const [instances, setInstances] = useState([]); + const [vpcs, setVpcs] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isLaunching, setIsLaunching] = useState(false); + const [pendingInstanceIDs, setPendingInstanceIDs] = useState>(new Set()); + const [error, setError] = useState(null); + + const loadData = useCallback(async () => { + if (!ready) return; + if (!hasCredentials) { + setInstances([]); + setVpcs([]); + setIsLoading(false); + setError(null); + return; + } + + setIsLoading(true); + setError(null); + + try { + const [instanceData, vpcData] = await Promise.all([ + cloudApiRequest('/instances', undefined, config), + cloudApiRequest('/vpcs', undefined, config), + ]); + setInstances(instanceData ?? []); + setVpcs(vpcData ?? []); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load instances.'; + setError(message); + } finally { + setIsLoading(false); + } + }, [config, hasCredentials, ready]); + + useEffect(() => { + void loadData(); + }, [loadData]); - const handleLaunch = (data: { name: string }) => { - // Simulate instance launch - const newInstance: Instance = { - id: `i-0x${Math.floor(Math.random() * 10000).toString(16)}`, + const runningCount = useMemo( + () => instances.filter((instance) => mapStatus(instance.status) === 'running').length, + [instances] + ); + + const handleLaunch = async (data: LaunchPayload) => { + setIsLaunching(true); + setError(null); + + const payload: Record = { name: data.name, - type: 't2.micro', - status: 'pending', - ip: '-', - created_at: new Date().toISOString().split('T')[0] + image: data.image, }; - setInstances([newInstance, ...instances]); + + if (data.ports) { + payload.ports = data.ports; + } + + if (data.vpcId) { + payload.vpc_id = data.vpcId; + } + + try { + await cloudApiRequest('/instances', { + method: 'POST', + body: JSON.stringify(payload), + }, config); + await loadData(); + setIsModalOpen(false); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to launch instance.'; + setError(message); + throw err; + } finally { + setIsLaunching(false); + } + }; + + const stopInstance = async (id: string) => { + setPendingInstanceIDs((previous) => { + const next = new Set(previous); + next.add(id); + return next; + }); + setError(null); + try { + await cloudApiRequest(`/instances/${id}/stop`, { method: 'POST' }, config); + await loadData(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to stop instance.'; + setError(message); + } finally { + setPendingInstanceIDs((previous) => { + const next = new Set(previous); + next.delete(id); + return next; + }); + } + }; + + const terminateInstance = async (id: string) => { + setPendingInstanceIDs((previous) => { + const next = new Set(previous); + next.add(id); + return next; + }); + setError(null); + try { + await cloudApiRequest(`/instances/${id}`, { method: 'DELETE' }, config); + await loadData(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to terminate instance.'; + setError(message); + } finally { + setPendingInstanceIDs((previous) => { + const next = new Set(previous); + next.delete(id); + return next; + }); + } }; - const columns: Column[] = [ - { header: 'Name', accessorKey: 'name', width: '25%' }, - { header: 'Instance ID', accessorKey: 'id', width: '20%' }, - { header: 'Type', accessorKey: 'type', width: '15%' }, + const columns: Column[] = [ + { + header: 'Name', + width: '22%', + cell: (item) => ( +
+
{item.name}
+
{item.image ?? 'custom image'}
+
+ ), + }, + { header: 'Instance ID', accessorKey: 'id', width: '22%' }, + { + header: 'Type', + width: '13%', + cell: (item) => item.instance_type ?? 'standard', + }, { header: 'Status', - cell: (item) => + width: '13%', + cell: (item) => + }, + { + header: 'Private IP', + width: '11%', + cell: (item) => item.private_ip || '-', + }, + { + header: 'Created', + width: '13%', + cell: (item) => formatDate(item.created_at), + }, + { + header: 'Actions', + width: '16%', + cell: (item) => ( +
+ + +
+ ), }, - { header: 'Private IP', accessorKey: 'ip' }, - { header: 'Created', accessorKey: 'created_at' }, ]; return ( -
-
+
+
-

Compute

-

Manage your virtual machines.

+

Compute

+

Manage real instances with live backend synchronization.

-
- +
+
-
+ {!hasCredentials ? ( +
+
+ Compute API access is not configured. +

Add API key and tenant details in Settings to query live instances.

+
+ + Go to Settings + +
+ ) : null} + + {error ?
{error}
: null} + +
+
+
Total Instances
+
{instances.length}
+
All known resources
+
+
+
Running
+
{runningCount}
+
Healthy active compute
+
+
+
Stopped / Other
+
{Math.max(instances.length - runningCount, 0)}
+
Needs intervention or idle
+
+
+
VPC Options
+
{vpcs.length}
+
Available attach targets
+
+
+ + +
+ setIsModalOpen(false)} onSubmit={handleLaunch} + isSubmitting={isLaunching} + vpcs={vpcs} /> ); diff --git a/web/app/(app)/dashboard/page.tsx b/web/app/(app)/dashboard/page.tsx index 873c5499e..7b1c1190a 100644 --- a/web/app/(app)/dashboard/page.tsx +++ b/web/app/(app)/dashboard/page.tsx @@ -1,171 +1,230 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { Activity, HardDrive, Network, RefreshCw, Server } from 'lucide-react'; import { Card } from '@/components/ui/Card'; import { StatusIndicator } from '@/components/ui/StatusIndicator'; import { Button } from '@/components/ui/Button'; -import Link from 'next/link'; -import { Activity, Server, HardDrive, Cpu } from 'lucide-react'; +import { cloudApiRequest } from '@/lib/api'; +import { eventStatus } from '@/lib/events'; +import { useApiConfig } from '@/hooks/useApiConfig'; +import styles from '../pages.module.css'; + +interface ApiInstance { + id: string; + status: string; +} + +interface ApiBucket { + id: string; +} + +interface ApiVpc { + id: string; +} + +interface ApiEvent { + id: string; + action: string; + resource_id: string; + resource_type: string; + created_at: string; +} + +function relativeTime(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return value; + } + + const diff = Date.now() - date.getTime(); + const minutes = Math.floor(diff / 60000); + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +export default function DashboardPage() { + const { config, ready, hasCredentials } = useApiConfig(); + const [instances, setInstances] = useState([]); + const [buckets, setBuckets] = useState([]); + const [vpcs, setVpcs] = useState([]); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadDashboard = useCallback(async () => { + if (!ready) { + return; + } + + if (!hasCredentials) { + setLoading(false); + setInstances([]); + setBuckets([]); + setVpcs([]); + setEvents([]); + setError(null); + return; + } + + setLoading(true); + setError(null); + + try { + const [instanceData, bucketData, vpcData, eventData] = await Promise.all([ + cloudApiRequest('/instances', undefined, config), + cloudApiRequest('/storage/buckets', undefined, config), + cloudApiRequest('/vpcs', undefined, config), + cloudApiRequest('/events?limit=5', undefined, config), + ]); + + setInstances(instanceData ?? []); + setBuckets(bucketData ?? []); + setVpcs(vpcData ?? []); + setEvents(eventData ?? []); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load dashboard data.'; + setError(message); + } finally { + setLoading(false); + } + }, [config, hasCredentials, ready]); + + useEffect(() => { + void loadDashboard(); + }, [loadDashboard]); + + const runningInstances = useMemo( + () => instances.filter((item) => item.status?.toLowerCase().includes('running')).length, + [instances] + ); + + const healthyEventRatio = useMemo(() => { + if (events.length === 0) { + return 'n/a'; + } + + const healthyEvents = events.filter((event) => eventStatus(event.action) === 'success').length; + const ratio = Math.round((healthyEvents / events.length) * 100); + return `${ratio}%`; + }, [events]); -export default function Home() { return ( -
-
-

- Dashboard -

-

Overview

+
+
+
+

Dashboard

+

Live control plane summary across compute, storage, and network.

+
+
+ +
- - {/* Metrics Grid */} -
- -
-
- -
-
-
12
-
Active Instances
-
-
-
- - -
-
- -
-
-
98.2%
-
Healthy Services
-
-
-
- -
-
- -
-
-
450 GB
-
Storage Used
-
+ {!hasCredentials ? ( +
+
+ API access not configured. +

Add your API key and optional tenant ID in Settings to load live data.

- - - -
-
- -
-
-
45%
-
CPU Load
-
-
-
-
- - {/* Recent Activity & Resources */} -
- -
- {[1, 2, 3].map((i) => ( -
-
- -
-
Instance i-0x823 launched
-
2 minutes ago
-
+ + Open Settings + +
+ ) : null} + + {error ?
{error}
: null} + +
+ +
+
Instances
+
{runningInstances}
+
Running now
+
+ + +
+
Storage Buckets
+
{buckets.length}
+
Object stores
+
+ + +
+
VPC Networks
+
{vpcs.length}
+
Isolated network spaces
+
+ + +
+
Recent Event Health
+
{healthyEventRatio}
+
From latest control-plane events
+
+ +
+ +
+ + {events.length === 0 ? ( +
{loading ? 'Loading events...' : 'No recent events found.'}
+ ) : ( +
+ {events.map((event) => ( +
+
+ {event.action} + +
+
+ {event.resource_type}: {event.resource_id} +
- -
- ))} -
+ ))} +
+ )}
- -
- - + +
+ + Compute + + Open + + + + Storage + + Open + - - + + Network + + Open + - - + + Activity + + Open +
-
+
); } diff --git a/web/app/(app)/error.tsx b/web/app/(app)/error.tsx new file mode 100644 index 000000000..6b6a6c2eb --- /dev/null +++ b/web/app/(app)/error.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useEffect } from 'react'; +import Link from 'next/link'; + +export default function AppError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + console.error(error); + }, [error]); + + return ( +
+

Something went wrong in the console.

+

+ Something went wrong. Please try again. +

+ {error.digest ?

Reference: {error.digest}

: null} +
+ + + Back to dashboard + +
+
+ ); +} diff --git a/web/app/(app)/layout.module.css b/web/app/(app)/layout.module.css new file mode 100644 index 000000000..b6f4703b4 --- /dev/null +++ b/web/app/(app)/layout.module.css @@ -0,0 +1,27 @@ +.shell { + display: flex; + min-height: 100vh; +} + +.main { + flex: 1; + margin-left: var(--sidebar-width); + padding: 32px 28px 44px; + min-height: 100vh; +} + +.mainInner { + width: min(1240px, 100%); + margin: 0 auto; +} + +@media (max-width: 1024px) { + .main { + margin-left: 0; + padding: 82px 16px 24px; + } + + .mainInner { + width: 100%; + } +} diff --git a/web/app/(app)/layout.tsx b/web/app/(app)/layout.tsx index f48ceb135..c0c707d6a 100644 --- a/web/app/(app)/layout.tsx +++ b/web/app/(app)/layout.tsx @@ -1,5 +1,6 @@ import { Sidebar } from '@/components/ui/Sidebar'; +import styles from './layout.module.css'; export default function AppLayout({ children, @@ -7,15 +8,10 @@ export default function AppLayout({ children: React.ReactNode; }) { return ( -
+
-
- {children} +
+
{children}
); diff --git a/web/app/(app)/loading.tsx b/web/app/(app)/loading.tsx new file mode 100644 index 000000000..8300724f6 --- /dev/null +++ b/web/app/(app)/loading.tsx @@ -0,0 +1,7 @@ +export default function AppLoading() { + return ( +
+ Loading console... +
+ ); +} diff --git a/web/app/(app)/network/page.tsx b/web/app/(app)/network/page.tsx index 413e798ce..c28684646 100644 --- a/web/app/(app)/network/page.tsx +++ b/web/app/(app)/network/page.tsx @@ -1,63 +1,244 @@ 'use client'; +import { useCallback, useEffect, useState } from 'react'; +import Link from 'next/link'; import { Table, Column } from '@/components/ui/Table'; import { StatusIndicator } from '@/components/ui/StatusIndicator'; import { Button } from '@/components/ui/Button'; +import { Card } from '@/components/ui/Card'; import { Plus, RefreshCw, Network } from 'lucide-react'; +import { cloudApiRequest } from '@/lib/api'; +import { useApiConfig } from '@/hooks/useApiConfig'; +import styles from '../pages.module.css'; -interface VPC { +interface ApiVpc { id: string; name: string; - cidr: string; - status: 'available' | 'pending'; - subnets: number; + cidr_block: string; + status: string; + network_id?: string; + vxlan_id?: number; + created_at: string; } -const DUMMY_VPCS: VPC[] = [ - { id: 'vpc-0x12a', name: 'default-vpc', cidr: '172.31.0.0/16', status: 'available', subnets: 4 }, - { id: 'vpc-0x44b', name: 'prod-network', cidr: '10.0.0.0/16', status: 'available', subnets: 6 }, - { id: 'vpc-0x99c', name: 'dev-environment', cidr: '192.168.0.0/16', status: 'pending', subnets: 0 }, -]; +function formatDate(value: string): string { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return parsed.toLocaleString(); +} export default function NetworkPage() { - const columns: Column[] = [ + const { config, ready, hasCredentials } = useApiConfig(); + const [vpcs, setVpcs] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [newVpcName, setNewVpcName] = useState(''); + const [newVpcCIDR, setNewVpcCIDR] = useState('10.0.0.0/16'); + const [pendingVpcId, setPendingVpcId] = useState(null); + const [error, setError] = useState(null); + + const loadVpcs = useCallback(async () => { + if (!ready) return; + + if (!hasCredentials) { + setVpcs([]); + setIsLoading(false); + setError(null); + return; + } + + setIsLoading(true); + setError(null); + + try { + const response = await cloudApiRequest('/vpcs', undefined, config); + setVpcs(response ?? []); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load VPCs.'; + setError(message); + } finally { + setIsLoading(false); + } + }, [config, hasCredentials, ready]); + + useEffect(() => { + void loadVpcs(); + }, [loadVpcs]); + + const createVpc = async () => { + if (!newVpcName.trim()) { + setError('VPC name is required.'); + return; + } + + setError(null); + setIsSaving(true); + try { + await cloudApiRequest('/vpcs', { + method: 'POST', + body: JSON.stringify({ + name: newVpcName.trim(), + cidr_block: newVpcCIDR.trim(), + }), + }, config); + setNewVpcName(''); + await loadVpcs(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create VPC.'; + setError(message); + } finally { + setIsSaving(false); + } + }; + + const deleteVpc = async (id: string) => { + const allowed = window.confirm('Delete this VPC? This action cannot be undone.'); + if (!allowed) return; + + setError(null); + setPendingVpcId(id); + + try { + await cloudApiRequest(`/vpcs/${id}`, { method: 'DELETE' }, config); + await loadVpcs(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to delete VPC.'; + setError(message); + } finally { + setPendingVpcId(null); + } + }; + + const columns: Column[] = [ { header: 'Name', cell: (item) => ( -
- - {item.name} +
+
+ + {item.name} +
+
{item.network_id || 'backend auto-assigned'}
) }, { header: 'VPC ID', accessorKey: 'id' }, - { header: 'IPv4 CIDR', accessorKey: 'cidr' }, + { header: 'IPv4 CIDR', accessorKey: 'cidr_block' }, { header: 'Status', - cell: (item) => + cell: (item) => { + const normalized = item.status.toLowerCase(); + let indicator: 'running' | 'pending' | 'stopped' | 'error' = 'error'; + if (normalized.includes('active') || normalized.includes('running')) { + indicator = 'running'; + } else if (normalized.includes('pending') || normalized.includes('creating') || normalized.includes('provisioning')) { + indicator = 'pending'; + } else if ( + normalized.includes('stop') || + normalized.includes('deleted') || + normalized.includes('inactive') || + normalized.includes('terminated') + ) { + indicator = 'stopped'; + } + return ( + + ); + }, + }, + { + header: 'VXLAN', + cell: (item) => item.vxlan_id ?? '-', + }, + { + header: 'Created', + cell: (item) => formatDate(item.created_at), + }, + { + header: 'Actions', + cell: (item) => ( + + ), }, - { header: 'Subnets', accessorKey: 'subnets' }, ]; return ( -
-
+
+
-

Network

-

Virtual Private Clouds and subnets.

+

Network

+

Provision tenant-isolated VPC segments and inspect networking metadata.

-
- - +
+
-
+ {!hasCredentials ? ( +
+
+ Network API access is not configured. +

Add your API key and tenant details in Settings to load VPC resources.

+
+ + Go to Settings + +
+ ) : null} + + {error ?
{error}
: null} + + +
+
+ + setNewVpcName(event.target.value)} + /> +
+
+ + setNewVpcCIDR(event.target.value)} + /> +
+
+ Action + +
+
+
+ + +
+ ); } diff --git a/web/app/(app)/not-found.tsx b/web/app/(app)/not-found.tsx new file mode 100644 index 000000000..c81966c09 --- /dev/null +++ b/web/app/(app)/not-found.tsx @@ -0,0 +1,15 @@ +import Link from 'next/link'; + +export default function AppNotFound() { + return ( +
+

Page not found

+

+ The requested console page does not exist. +

+ + Go to dashboard + +
+ ); +} diff --git a/web/app/(app)/pages.module.css b/web/app/(app)/pages.module.css new file mode 100644 index 000000000..1f504d597 --- /dev/null +++ b/web/app/(app)/pages.module.css @@ -0,0 +1,377 @@ +.page { + display: flex; + flex-direction: column; + gap: 22px; +} + +.header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + flex-wrap: wrap; +} + +.title { + margin: 0; + font-size: clamp(1.6rem, 2.3vw, 2.35rem); + font-family: var(--font-display); + letter-spacing: -0.02em; + line-height: 1.05; +} + +.subtitle { + margin: 8px 0 0; + color: var(--muted-foreground); + font-size: 0.95rem; +} + +.headerActions { + display: inline-flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.notice { + border: 1px solid rgba(15, 23, 42, 0.13); + background: linear-gradient(140deg, rgba(255, 255, 255, 0.95), rgba(241, 245, 249, 0.7)); + border-radius: 16px; + padding: 14px 16px; + color: var(--foreground); + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; + box-shadow: 0 10px 22px rgba(15, 23, 42, 0.06); +} + +.noticeText { + font-size: 0.92rem; + color: var(--muted-foreground); +} + +.notice strong { + color: var(--foreground); +} + +.statsGrid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; +} + +.statLink { + color: inherit; + text-decoration: none; + display: block; + border-radius: 18px; + transition: transform 160ms ease; +} + +.statLink:hover .stat { + border-color: rgba(15, 107, 255, 0.28); + box-shadow: 0 14px 30px rgba(15, 107, 255, 0.13); +} + +.statLink:focus-visible { + outline: none; +} + +.statLink:focus-visible .stat { + border-color: rgba(15, 107, 255, 0.36); + box-shadow: 0 0 0 3px rgba(15, 107, 255, 0.16), 0 16px 30px rgba(15, 107, 255, 0.12); +} + +.stat { + border-radius: 18px; + border: 1px solid rgba(15, 23, 42, 0.1); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.92)); + box-shadow: 0 10px 26px rgba(15, 23, 42, 0.07); + padding: 16px; + transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; +} + +.statLink:hover { + transform: translateY(-1px); +} + +.statLabel { + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted-foreground); +} + +.statValue { + margin-top: 8px; + font-size: 1.75rem; + font-family: var(--font-display); + letter-spacing: -0.02em; +} + +.statHint { + margin-top: 4px; + color: var(--muted-foreground); + font-size: 0.84rem; +} + +.panel { + border-radius: 20px; + border: 1px solid rgba(15, 23, 42, 0.1); + background: rgba(255, 255, 255, 0.86); + backdrop-filter: blur(10px); + box-shadow: 0 14px 30px rgba(15, 23, 42, 0.08); + padding: 16px; + transition: border-color 160ms ease, box-shadow 160ms ease; +} + +.panel:hover { + border-color: rgba(15, 23, 42, 0.16); + box-shadow: 0 16px 34px rgba(15, 23, 42, 0.1); +} + +.panelHeader { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.panelTitle { + margin: 0; + font-size: 1.02rem; + font-family: var(--font-display); +} + +.panelMeta { + color: var(--muted-foreground); + font-size: 0.84rem; +} + +.formRow { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.field label { + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--muted-foreground); +} + +.input, +.select, +.textarea { + height: 40px; + border: 1px solid rgba(15, 23, 42, 0.14); + border-radius: 12px; + background: rgba(255, 255, 255, 0.95); + color: var(--foreground); + padding: 0 11px; + font-size: 0.92rem; + outline: none; + transition: border-color 140ms ease, box-shadow 140ms ease; +} + +.input::placeholder, +.textarea::placeholder { + color: rgba(71, 85, 105, 0.75); +} + +.textarea { + min-height: 88px; + resize: vertical; + padding: 10px 11px; +} + +.input:focus, +.select:focus, +.textarea:focus { + border-color: rgba(15, 107, 255, 0.7); + box-shadow: 0 0 0 3px rgba(15, 107, 255, 0.15); +} + +.formActions { + margin-top: 10px; + display: inline-flex; + gap: 10px; +} + +.error { + border: 1px solid rgba(225, 29, 72, 0.25); + background: rgba(255, 241, 242, 0.95); + color: #be123c; + padding: 11px 12px; + border-radius: 12px; + font-size: 0.9rem; +} + +.empty { + padding: 30px 16px; + border-radius: 14px; + border: 1px dashed rgba(15, 23, 42, 0.22); + text-align: center; + color: var(--muted-foreground); + background: rgba(255, 255, 255, 0.75); +} + +.gridTwo { + display: grid; + grid-template-columns: 1.8fr 1fr; + gap: 14px; +} + +.infoList { + display: flex; + flex-direction: column; + gap: 10px; +} + +.infoRow { + display: flex; + justify-content: space-between; + gap: 12px; + border-bottom: 1px dashed rgba(15, 23, 42, 0.12); + padding-bottom: 9px; +} + +.infoRowLink { + display: flex; + justify-content: space-between; + gap: 12px; + border-bottom: 1px dashed rgba(15, 23, 42, 0.12); + padding-bottom: 9px; + color: inherit; + text-decoration: none; + transition: background-color 140ms ease, border-color 140ms ease; +} + +.infoRowLink:hover { + background: rgba(15, 107, 255, 0.05); + border-bottom-color: rgba(15, 107, 255, 0.18); + border-radius: 10px; +} + +.infoRowOpen { + color: var(--brand); + text-decoration: underline; + text-underline-offset: 3px; +} + +.infoKey { + color: var(--muted-foreground); + font-size: 0.85rem; +} + +.infoValue { + font-family: var(--font-mono); + font-size: 0.84rem; + color: var(--foreground); +} + +.activityList { + display: flex; + flex-direction: column; + gap: 9px; +} + +.activityItem { + border-radius: 12px; + border: 1px solid rgba(15, 23, 42, 0.08); + background: rgba(248, 250, 252, 0.9); + padding: 10px 12px; + transition: background-color 140ms ease, border-color 140ms ease; +} + +.activityItem:hover { + background: rgba(255, 255, 255, 0.95); + border-color: rgba(15, 107, 255, 0.2); +} + +.activityTop { + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 0.9rem; +} + +.activityMeta { + margin-top: 6px; + color: var(--muted-foreground); + font-size: 0.8rem; +} + +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid rgba(15, 23, 42, 0.12); + border-radius: 999px; + padding: 4px 10px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.badgeNeutral { + background: rgba(248, 250, 252, 0.9); + color: #0f172a; +} + +.badgeGood { + background: rgba(236, 253, 245, 0.95); + color: #047857; +} + +.badgeWarn { + background: rgba(255, 251, 235, 0.95); + color: #a16207; +} + +.badgeDanger { + background: rgba(255, 241, 242, 0.95); + color: #be123c; +} + +@media (max-width: 1200px) { + .statsGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .gridTwo { + grid-template-columns: 1fr; + } +} + +@media (max-width: 900px) { + .formRow { + grid-template-columns: 1fr; + } +} + +@media (max-width: 720px) { + .statsGrid { + grid-template-columns: 1fr; + } + + .headerActions { + width: 100%; + justify-content: flex-start; + } + + .headerActions > * { + flex: 1 1 auto; + } +} diff --git a/web/app/(app)/settings/page.tsx b/web/app/(app)/settings/page.tsx index 728bae2cd..7f285cb39 100644 --- a/web/app/(app)/settings/page.tsx +++ b/web/app/(app)/settings/page.tsx @@ -1,67 +1,225 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { KeyRound, Link2, Server, UserRound } from 'lucide-react'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; -import { User, Key, Globe } from 'lucide-react'; +import { cloudApiRequest, type CloudApiConfig } from '@/lib/api'; +import { useApiConfig } from '@/hooks/useApiConfig'; +import styles from '../pages.module.css'; + +interface Profile { + id?: string; + email?: string; + name?: string; +} + +interface Tenant { + id: string; + name: string; + slug?: string; +} export default function SettingsPage() { + const { config, ready, updateConfig } = useApiConfig(); + + const [baseUrl, setBaseUrl] = useState('http://localhost:8080'); + const [apiKey, setApiKey] = useState(''); + const [tenantId, setTenantId] = useState(''); + const [showKey, setShowKey] = useState(false); + + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + const [profile, setProfile] = useState(null); + const [tenants, setTenants] = useState([]); + + useEffect(() => { + if (!ready) return; + setBaseUrl(config.baseUrl); + setApiKey(config.apiKey); + setTenantId(config.tenantId); + }, [config, ready]); + + const maskedKey = useMemo(() => { + if (!apiKey) return 'Not configured'; + if (apiKey.length < 10) return `${apiKey.slice(0, 2)}****`; + return `${apiKey.slice(0, 6)}...${apiKey.slice(-4)}`; + }, [apiKey]); + + const saveSettings = () => { + setSaving(true); + setError(null); + setMessage(null); + + try { + updateConfig({ + baseUrl, + apiKey, + tenantId, + }); + setMessage('Connection settings saved locally in your browser.'); + } finally { + setSaving(false); + } + }; + + const testConnection = async () => { + const candidate: CloudApiConfig = { + baseUrl, + apiKey, + tenantId, + }; + + setTesting(true); + setError(null); + setMessage(null); + setProfile(null); + setTenants([]); + + try { + const me = await cloudApiRequest('/auth/me', undefined, candidate); + let tenantWarning: string | null = null; + let tenantData: Tenant[] = []; + try { + tenantData = await cloudApiRequest('/tenants', undefined, candidate); + } catch (tenantErr) { + const tenantReason = tenantErr instanceof Error ? tenantErr.message : 'Tenant list unavailable.'; + tenantWarning = `Authentication succeeded, but loading tenants failed: ${tenantReason}`; + } + + setProfile(me ?? null); + setTenants(tenantData ?? []); + if (!tenantWarning) { + setMessage('Connection successful. Credentials and endpoint are valid.'); + } else { + setError(tenantWarning); + } + } catch (err) { + const reason = err instanceof Error ? err.message : 'Connection test failed.'; + setProfile(null); + setTenants([]); + setError(reason); + } finally { + setTesting(false); + } + }; + return ( -
-
-

Settings

-

Manage your account and preferences.

+
+
+
+

Settings

+

Configure API endpoint, credentials, and tenant scope for console data access.

+
+
+ +
-
- -
-
- + {message ?
Status: {message}
: null} + {error ?
{error}
: null} + + +
+
+ + setBaseUrl(event.target.value)} + placeholder="http://localhost:8080" + /> +
+
+ + setApiKey(event.target.value)} + placeholder="thecloud_xxxxx" + /> +
+
+ + setTenantId(event.target.value)} + placeholder="uuid" + /> +
+
+ +
+ + +
+
+ +
+ +
+
+ Endpoint + {baseUrl || 'Not set'} +
+
+ API Key + {maskedKey}
-
-
Root User
-
root@thecloud.local
+
+ Tenant Header + {tenantId || 'None (default tenant)'}
-
- -
-

- Use these keys to access The Cloud via the CLI or SDKs. Do not share your secret key. -

-
-
- - AKIA-THE-CLOUD-DEMO-KEY -
- Active + +
+
+ User + {profile?.name || 'Unknown'}
-
- +
+ Email + {profile?.email || 'Unavailable'} +
+
+ Tenant Memberships + {tenants.length}
+
- -
-
- -
-
US East (N. Virginia)
-
us-east-1
+ + {tenants.length === 0 ? ( +
No tenant records loaded yet. Run Test Connection to fetch memberships.
+ ) : ( +
+ {tenants.map((tenant) => ( +
+
+ {tenant.name} + {tenant.slug || 'no-slug'} +
+
{tenant.id}
-
- + ))}
- -
+ )} +
); } diff --git a/web/app/(app)/storage/page.tsx b/web/app/(app)/storage/page.tsx index b2b4acbad..9a52f20c7 100644 --- a/web/app/(app)/storage/page.tsx +++ b/web/app/(app)/storage/page.tsx @@ -1,59 +1,230 @@ 'use client'; +import { useCallback, useEffect, useState } from 'react'; +import Link from 'next/link'; import { Table, Column } from '@/components/ui/Table'; import { Button } from '@/components/ui/Button'; +import { Card } from '@/components/ui/Card'; import { Plus, RefreshCw, HardDrive } from 'lucide-react'; +import { cloudApiRequest } from '@/lib/api'; +import { useApiConfig } from '@/hooks/useApiConfig'; +import styles from '../pages.module.css'; -interface Bucket { +interface ApiBucket { + id: string; name: string; - region: string; - objects: number; - size: string; + is_public: boolean; + versioning_enabled: boolean; + encryption_enabled: boolean; created_at: string; } -const DUMMY_BUCKETS: Bucket[] = [ - { name: 'assets-prod-v1', region: 'us-east-1', objects: 1240, size: '4.2 GB', created_at: '2024-12-01' }, - { name: 'user-uploads', region: 'us-east-1', objects: 8502, size: '156 GB', created_at: '2024-12-15' }, - { name: 'logs-archive', region: 'us-west-2', objects: 450, size: '240 MB', created_at: '2025-01-02' }, -]; +function formatDate(value: string): string { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + return parsed.toLocaleString(); +} export default function StoragePage() { - const columns: Column[] = [ + const { config, ready, hasCredentials } = useApiConfig(); + const [buckets, setBuckets] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [newBucketName, setNewBucketName] = useState(''); + const [createPublic, setCreatePublic] = useState(false); + const [error, setError] = useState(null); + + const loadBuckets = useCallback(async () => { + if (!ready) return; + + if (!hasCredentials) { + setBuckets([]); + setIsLoading(false); + setError(null); + return; + } + + setIsLoading(true); + setError(null); + + try { + const bucketData = await cloudApiRequest('/storage/buckets', undefined, config); + setBuckets(bucketData ?? []); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to load buckets.'; + setError(message); + } finally { + setIsLoading(false); + } + }, [config, hasCredentials, ready]); + + useEffect(() => { + void loadBuckets(); + }, [loadBuckets]); + + const createBucket = async () => { + if (!newBucketName.trim()) { + setError('Bucket name is required.'); + return; + } + + setError(null); + setIsSaving(true); + try { + await cloudApiRequest('/storage/buckets', { + method: 'POST', + body: JSON.stringify({ + name: newBucketName.trim(), + is_public: createPublic, + }), + }, config); + setNewBucketName(''); + setCreatePublic(false); + await loadBuckets(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create bucket.'; + setError(message); + } finally { + setIsSaving(false); + } + }; + + const deleteBucket = async (name: string) => { + const allowed = window.confirm(`Delete bucket "${name}"?`); + if (!allowed) return; + + setError(null); + + try { + await cloudApiRequest(`/storage/buckets/${encodeURIComponent(name)}`, { + method: 'DELETE', + }, config); + await loadBuckets(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to delete bucket.'; + setError(message); + } + }; + + const columns: Column[] = [ { header: 'Name', cell: (item) => ( -
- - {item.name} +
+
+ + {item.name} +
+
{item.id}
) }, - { header: 'Region', accessorKey: 'region' }, - { header: 'Objects', accessorKey: 'objects' }, - { header: 'Size', accessorKey: 'size' }, - { header: 'Created', accessorKey: 'created_at' }, + { + header: 'Visibility', + cell: (item) => ( + + {item.is_public ? 'Public' : 'Private'} + + ), + }, + { + header: 'Versioning', + cell: (item) => ( + + {item.versioning_enabled ? 'Enabled' : 'Disabled'} + + ), + }, + { + header: 'Encryption', + cell: (item) => ( + + {item.encryption_enabled ? 'Enabled' : 'Disabled'} + + ), + }, + { + header: 'Created', + cell: (item) => formatDate(item.created_at), + }, + { + header: 'Actions', + cell: (item) => ( + + ), + }, ]; return ( -
-
+
+
-

Storage

-

S3-compatible object storage.

+

Storage

+

Manage S3-compatible buckets with versioning and encryption metadata.

-
- - +
+
-
+ {!hasCredentials ? ( +
+
+ Storage API access is not configured. +

Open Settings and add API credentials to load your buckets.

+
+ + Go to Settings + +
+ ) : null} + + {error ?
{error}
: null} + + +
+
+ + setNewBucketName(event.target.value)} + /> +
+
+ + +
+
+ Action + +
+
+
+ + +
+ ); } diff --git a/web/app/(marketing)/layout.tsx b/web/app/(marketing)/layout.tsx index 0f7a00207..af15514b4 100644 --- a/web/app/(marketing)/layout.tsx +++ b/web/app/(marketing)/layout.tsx @@ -6,14 +6,5 @@ export default function MarketingLayout({ }: { children: React.ReactNode; }) { - return ( -
- {children} -
- ); + return
{children}
; } diff --git a/web/app/(marketing)/marketing.module.css b/web/app/(marketing)/marketing.module.css new file mode 100644 index 000000000..33753d24c --- /dev/null +++ b/web/app/(marketing)/marketing.module.css @@ -0,0 +1,559 @@ +.page { + position: relative; + overflow: clip; +} + +.hero { + max-width: 1100px; + margin: 0 auto; + padding: 96px 20px 52px; + display: grid; + grid-template-columns: 1.2fr 1fr; + gap: 24px; + align-items: center; +} + +.heroLeft { + display: flex; + flex-direction: column; + gap: 18px; +} + +.kicker { + width: fit-content; + padding: 7px 12px; + border-radius: 999px; + border: 1px solid rgba(9, 29, 73, 0.16); + background: rgba(255, 255, 255, 0.85); + color: #1e3a8a; + font-size: 0.74rem; + letter-spacing: 0.08em; + text-transform: uppercase; + font-weight: 700; +} + +.title { + margin: 0; + font-size: clamp(2.2rem, 5.6vw, 4.9rem); + line-height: 0.95; + letter-spacing: -0.03em; + font-family: var(--font-display); + text-wrap: balance; +} + +.subtitle { + margin: 0; + color: rgba(15, 23, 42, 0.78); + line-height: 1.6; + max-width: 62ch; + font-size: 1rem; +} + +.heroActions { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-top: 6px; +} + +.actionLink { + display: inline-flex; + align-items: center; + justify-content: center; + height: 40px; + padding: 0 14px; + border-radius: 999px; + border: 1px solid rgba(15, 23, 42, 0.16); + background: rgba(255, 255, 255, 0.8); + color: var(--foreground); + font-size: 0.9rem; + font-weight: 700; + transition: transform 150ms ease, box-shadow 150ms ease, border-color 150ms ease, background-color 150ms ease; +} + +.actionLinkPrimary { + background: linear-gradient(145deg, var(--brand), var(--brand-strong)); + border-color: transparent; + color: #fff; +} + +.actionLinkDark { + border-color: rgba(219, 234, 254, 0.4); + background: rgba(255, 255, 255, 0.12); + color: #eff6ff; +} + +.actionLink:hover { + transform: translateY(-1px); + border-color: rgba(15, 23, 42, 0.28); + background: rgba(255, 255, 255, 0.94); +} + +.actionLinkPrimary:hover { + border-color: transparent; + background: linear-gradient(145deg, #0f6bff, #0a46a6); + box-shadow: 0 14px 26px rgba(15, 107, 255, 0.3); +} + +.actionLinkDark:hover { + border-color: rgba(239, 246, 255, 0.62); + background: rgba(255, 255, 255, 0.18); +} + +.actionLink:focus-visible { + outline: none; + box-shadow: 0 0 0 3px rgba(15, 107, 255, 0.2); +} + +.heroPanel { + border-radius: 24px; + border: 1px solid rgba(15, 23, 42, 0.12); + background: + radial-gradient(130% 90% at 10% 8%, rgba(56, 189, 248, 0.23), transparent 58%), + radial-gradient(95% 90% at 90% 90%, rgba(34, 197, 94, 0.2), transparent 55%), + linear-gradient(160deg, rgba(255, 255, 255, 0.95), rgba(248, 250, 252, 0.9)); + min-height: 315px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 12px; + box-shadow: 0 22px 42px rgba(15, 23, 42, 0.11); + position: relative; + overflow: hidden; +} + +.heroPanel::after { + content: ''; + position: absolute; + right: -48px; + bottom: -48px; + width: 180px; + height: 180px; + border-radius: 50%; + background: radial-gradient(circle, rgba(15, 107, 255, 0.16), rgba(15, 107, 255, 0)); + pointer-events: none; +} + +.signalCard { + border-radius: 16px; + border: 1px solid rgba(15, 23, 42, 0.12); + background: rgba(255, 255, 255, 0.84); + padding: 10px 12px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + transition: transform 130ms ease, border-color 130ms ease, background-color 130ms ease; +} + +.signalCard:hover { + transform: translateY(-1px); + border-color: rgba(15, 107, 255, 0.2); + background: rgba(255, 255, 255, 0.95); +} + +.signalTitle { + font-size: 0.82rem; + color: rgba(15, 23, 42, 0.74); +} + +.signalValue { + font-size: 1.3rem; + font-family: var(--font-display); + letter-spacing: -0.02em; +} + +.metricsStrip { + max-width: 1100px; + margin: 0 auto; + padding: 0 20px 28px; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.metric { + border-radius: 12px; + border: 1px solid rgba(15, 23, 42, 0.1); + background: rgba(255, 255, 255, 0.85); + padding: 12px; + transition: border-color 140ms ease, transform 140ms ease; +} + +.metric:hover { + border-color: rgba(15, 107, 255, 0.2); + transform: translateY(-1px); +} + +.metricLabel { + color: rgba(15, 23, 42, 0.6); + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.metricValue { + margin-top: 5px; + font-family: var(--font-display); + font-size: 1.2rem; +} + +.blueBannerWrap { + max-width: 1100px; + margin: 0 auto; + padding: 0 20px 8px; +} + +.blueBanner { + border-radius: 16px; + border: 1px solid rgba(147, 197, 253, 0.42); + background: linear-gradient(145deg, #2463d8, #1d4ed8); + color: #dbeafe; + padding: 12px 18px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + min-height: 98px; + box-shadow: 0 16px 28px rgba(30, 64, 175, 0.28); + overflow: hidden; +} + +.blueTexture { + width: 42%; + height: 66px; + overflow: hidden; + background-image: url('/cloudd.png'); + background-repeat: no-repeat; + background-size: contain; + background-position: center; + opacity: 0.82; + user-select: none; +} + +.blueTextureLeft { + transform: skewX(-9deg); +} + +.blueTextureRight { + background-position: right center; + transform: skewX(9deg); +} + +.blueGlyph { + flex: 0 0 auto; + color: #eff6ff; + font-size: 62px; + line-height: 1; + font-family: var(--font-display); + font-weight: 700; + letter-spacing: -0.04em; +} + +@media (max-width: 760px) { + .blueBanner { + min-height: 86px; + padding: 10px 12px; + gap: 10px; + } + + .blueTexture { + width: 36%; + height: 52px; + } + + .blueGlyph { + font-size: 46px; + } +} + +.section { + max-width: 1100px; + margin: 0 auto; + padding: 56px 20px; +} + +.sectionTitle { + margin: 0; + font-family: var(--font-display); + font-size: clamp(1.7rem, 3vw, 2.35rem); + letter-spacing: -0.02em; +} + +.sectionLead { + margin: 10px 0 0; + color: rgba(15, 23, 42, 0.72); + line-height: 1.6; +} + +.featureGrid { + margin-top: 22px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.featureCard { + border-radius: 16px; + border: 1px solid rgba(15, 23, 42, 0.11); + background: rgba(255, 255, 255, 0.86); + padding: 14px; + min-height: 146px; + box-shadow: 0 10px 25px rgba(15, 23, 42, 0.05); + transition: border-color 150ms ease, box-shadow 150ms ease, transform 150ms ease; +} + +.featureCard:hover { + border-color: rgba(15, 107, 255, 0.22); + box-shadow: 0 16px 30px rgba(15, 107, 255, 0.12); + transform: translateY(-2px); +} + +.featureCard h3 { + margin: 0; + font-size: 1.03rem; + font-family: var(--font-display); +} + +.featureCard p { + margin: 8px 0 0; + color: rgba(15, 23, 42, 0.74); + font-size: 0.92rem; + line-height: 1.55; +} + +.storyGrid { + margin-top: 22px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.storyStep { + border-radius: 16px; + border: 1px solid rgba(15, 23, 42, 0.11); + background: linear-gradient(170deg, rgba(255, 255, 255, 0.95), rgba(241, 245, 249, 0.9)); + padding: 14px; + transition: border-color 150ms ease, transform 150ms ease; +} + +.storyStep:hover { + border-color: rgba(15, 107, 255, 0.2); + transform: translateY(-2px); +} + +.stepTag { + display: inline-block; + padding: 4px 8px; + border-radius: 999px; + background: rgba(15, 107, 255, 0.11); + color: #1d4ed8; + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.storyStep h3 { + margin: 10px 0 0; + font-family: var(--font-display); + font-size: 1.08rem; +} + +.storyStep p { + margin: 8px 0 0; + color: rgba(15, 23, 42, 0.76); + line-height: 1.55; + font-size: 0.91rem; +} + +.regionMap { + margin-top: 24px; + border-radius: 22px; + border: 1px solid rgba(15, 23, 42, 0.12); + min-height: 300px; + position: relative; + overflow: hidden; + background: + radial-gradient(circle at 20% 18%, rgba(56, 189, 248, 0.3), transparent 32%), + radial-gradient(circle at 80% 70%, rgba(34, 197, 94, 0.26), transparent 35%), + linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(248, 250, 252, 0.92)); +} + +.mapGrid { + position: absolute; + inset: 0; + background-image: linear-gradient(rgba(15, 23, 42, 0.09) 1px, transparent 1px), + linear-gradient(90deg, rgba(15, 23, 42, 0.09) 1px, transparent 1px); + background-size: 54px 54px; + mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), transparent 92%); +} + +.regionDot { + position: absolute; + width: 14px; + height: 14px; + border-radius: 50%; + background: #0f6bff; + box-shadow: 0 0 0 8px rgba(15, 107, 255, 0.18); + animation: dotPulse 2.6s ease-in-out infinite; +} + +@keyframes dotPulse { + 0%, + 100% { + transform: scale(1); + box-shadow: 0 0 0 8px rgba(15, 107, 255, 0.18); + } + + 50% { + transform: scale(1.08); + box-shadow: 0 0 0 11px rgba(15, 107, 255, 0.12); + } +} + +.cta { + max-width: 1100px; + margin: 0 auto; + padding: 36px 20px 72px; +} + +.ctaCard { + border-radius: 24px; + border: 1px solid rgba(15, 23, 42, 0.14); + background: linear-gradient(145deg, rgba(9, 29, 73, 0.95), rgba(12, 74, 110, 0.94)); + color: #eff6ff; + padding: clamp(22px, 4vw, 42px); + box-shadow: 0 24px 54px rgba(2, 6, 23, 0.35); + display: flex; + justify-content: space-between; + gap: 20px; + flex-wrap: wrap; + position: relative; + overflow: hidden; +} + +.ctaCard::before { + content: ''; + position: absolute; + width: 280px; + height: 280px; + border-radius: 50%; + top: -170px; + right: -120px; + background: radial-gradient(circle, rgba(125, 211, 252, 0.22), transparent 70%); + pointer-events: none; +} + +.ctaTitle { + margin: 0; + font-size: clamp(1.6rem, 3vw, 2.4rem); + font-family: var(--font-display); + letter-spacing: -0.02em; +} + +.ctaLead { + margin: 8px 0 0; + color: rgba(219, 234, 254, 0.85); + line-height: 1.6; +} + +.pricingGrid { + margin-top: 24px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.priceCard { + border-radius: 18px; + border: 1px solid rgba(15, 23, 42, 0.12); + background: rgba(255, 255, 255, 0.9); + padding: 16px; + display: flex; + flex-direction: column; + gap: 10px; + transition: border-color 150ms ease, box-shadow 150ms ease, transform 150ms ease; +} + +.priceCard:hover { + border-color: rgba(15, 107, 255, 0.22); + box-shadow: 0 16px 30px rgba(15, 107, 255, 0.12); + transform: translateY(-2px); +} + +.priceFeatured { + border-color: rgba(15, 107, 255, 0.38); + box-shadow: 0 16px 32px rgba(15, 107, 255, 0.16); + transform: translateY(-4px); +} + +.priceName { + margin: 0; + font-family: var(--font-display); + font-size: 1.1rem; +} + +.priceValue { + margin: 0; + font-family: var(--font-display); + font-size: 2.2rem; + letter-spacing: -0.02em; +} + +.priceMeta { + color: rgba(15, 23, 42, 0.62); + font-size: 0.9rem; +} + +.priceList { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 7px; +} + +.priceList li { + font-size: 0.9rem; + color: rgba(15, 23, 42, 0.78); +} + +.footer { + max-width: 1100px; + margin: 0 auto; + padding: 10px 20px 26px; + color: rgba(15, 23, 42, 0.66); + font-size: 0.83rem; +} + +@media (max-width: 1040px) { + .hero { + grid-template-columns: 1fr; + } + + .metricsStrip, + .featureGrid, + .storyGrid, + .pricingGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .hero { + padding-top: 74px; + } + + .metricsStrip, + .featureGrid, + .storyGrid, + .pricingGrid { + grid-template-columns: 1fr; + } + + .priceFeatured { + transform: none; + } +} diff --git a/web/app/(marketing)/page.tsx b/web/app/(marketing)/page.tsx index 0c37feb10..6a1b8e0d5 100644 --- a/web/app/(marketing)/page.tsx +++ b/web/app/(marketing)/page.tsx @@ -1,22 +1,190 @@ -import { Features } from '@/components/marketing/Features'; -import { ProductStory } from '@/components/marketing/ProductStory'; -import { EdgeMap } from '@/components/marketing/EdgeMap'; -import { FinalCTA } from '@/components/marketing/FinalCTA'; - -import { Hero } from '@/components/marketing/Hero'; -import { SignalStrip } from '@/components/marketing/SignalStrip'; +import Link from 'next/link'; +import styles from './marketing.module.css'; export const runtime = 'edge'; -export default function EdgePulseHome() { +const METRICS = [ + { label: 'P95 API Latency', value: '< 200ms' }, + { label: 'Edge-ready Services', value: '20+' }, + { label: 'End-to-end Coverage', value: '59.7%' }, + { label: 'Scale Architecture', value: 'K8s + VMs' }, +]; + +const FEATURES = [ + { + title: 'Compute Across Backends', + body: 'Run container workloads with Docker or full virtual machines with Libvirt/KVM under one API contract.', + }, + { + title: 'Distributed S3-Compatible Storage', + body: 'Use consistent hashing, gossip discovery, replication quorum, and object versioning for resilient storage.', + }, + { + title: 'Software-Defined Networking', + body: 'Build isolated VPCs with OVS, subnet control, peering, and elastic IP management.', + }, + { + title: 'Managed Platform Services', + body: 'Provision databases, caches, queues, serverless functions, and cloud-native workflows from one control plane.', + }, + { + title: 'Operations and Security', + body: 'API key auth, tenant boundaries, RBAC and IAM, audit trails, and observability by default.', + }, + { + title: 'Production Delivery Stack', + body: 'Deploy through Docker and Kubernetes with built-in health checks, metrics, tracing, and worker orchestration.', + }, +]; + +const JOURNEY = [ + { + tag: 'Provision', + title: 'Declare Infrastructure Once', + body: 'Launch instances, volumes, networks, and managed services through a single API and tenant-aware model.', + }, + { + tag: 'Operate', + title: 'Observe and Automate Continuously', + body: 'Track health and events in real time while workers handle failover, scaling, and long-running orchestration.', + }, + { + tag: 'Evolve', + title: 'Expand to Multi-Region Scale', + body: 'Route traffic globally, add clusters, and grow service surface without redesigning the architecture.', + }, +]; + +const SIGNALS = [ + { name: 'Control Plane', value: 'Healthy' }, + { name: 'Storage Nodes', value: 'Replica Quorum' }, + { name: 'Event Stream', value: 'Realtime' }, + { name: 'Worker Fleet', value: 'Autoscaled' }, +]; + +function BlueStripe() { return ( -
- - - - - - + + ); +} + +export default function HomePage() { + return ( +
+
+
+ Open Source Cloud Platform +

Run Your Cloud, Not Someone Else's Rules.

+

+ The Cloud is a full-stack infrastructure platform with compute, storage, networking, and managed + services. Self-host it, adapt it, and ship production-grade cloud features with total control. +

+
+ + Open Console + + + Explore Plans + +
+
+ + +
+ +
+ {METRICS.map((metric) => ( +
+
{metric.label}
+
{metric.value}
+
+ ))} +
+ + + +
+

Everything Needed For a Real Cloud Surface

+

+ Purpose-built around clean architecture and modular adapters, so you can extend capabilities without + rewriting core services. +

+
+ {FEATURES.map((feature) => ( +
+

{feature.title}

+

{feature.body}

+
+ ))} +
+
+ + + +
+

From Provisioning To Global Runtime

+

A practical workflow to launch and scale cloud services with confidence.

+
+ {JOURNEY.map((step) => ( +
+ {step.tag} +

{step.title}

+

{step.body}

+
+ ))} +
+
+ + + +
+

Global Footprint By Design

+

+ Build regional and global routing with distributed storage and traffic policies aligned to performance. +

+
+
+ + + + + +
+
+ +
+
+
+

Start Building On Infrastructure You Actually Own

+

+ Open source, service-rich, and architecture-first. Deploy your own cloud control plane in minutes. +

+
+
+ + Launch Console + + + Review Pricing + +
+
+
+ +
Copyright 2026 The Cloud. Built for ownership, portability, and scale.
); } diff --git a/web/app/(marketing)/pricing/page.tsx b/web/app/(marketing)/pricing/page.tsx index 32fd24b3f..101bc968e 100644 --- a/web/app/(marketing)/pricing/page.tsx +++ b/web/app/(marketing)/pricing/page.tsx @@ -1,12 +1,84 @@ -import { Pricing } from '@/components/marketing/Pricing'; +import Link from 'next/link'; +import styles from '../marketing.module.css'; export const runtime = 'edge'; +const PLANS = [ + { + name: 'Starter', + price: '$0', + meta: 'For local labs and early prototypes', + features: ['Single user workspace', 'Core compute + storage APIs', 'Community support'], + }, + { + name: 'Pro', + price: '$29', + meta: 'Per workspace / month', + features: [ + 'Multi-tenant management', + 'Managed services surface', + 'Priority issue support', + 'Advanced monitoring views', + ], + featured: true, + }, + { + name: 'Enterprise', + price: 'Custom', + meta: 'Security + architecture partnership', + features: ['Private onboarding', 'Dedicated support channel', 'Custom integrations', 'SLA-backed operations'], + }, +]; + export default function PricingPage() { return ( -
- +
+
+

Clear Plans For Every Stage

+

+ Start free, scale when your platform grows, and move to enterprise when governance and operations demand it. +

+ +
+ {PLANS.map((plan) => ( +
+

{plan.name}

+

{plan.price}

+

{plan.meta}

+
    + {plan.features.map((feature) => ( +
  • - {feature}
  • + ))} +
+
+ ))} +
+
+ +
+
+
+

Need a Guided Rollout?

+

+ We can help map your migration path from public cloud dependencies to an owned cloud runtime. +

+
+
+ + Continue to Console + + + Back to Overview + +
+
+
+ +
Need procurement details? Contact the maintainer team from the repository.
); } diff --git a/web/app/globals.css b/web/app/globals.css index c05fff291..bf02311b4 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -1,70 +1,141 @@ -/* App Store Connect / Light Theme Design System */ :root { - /* Metrics */ - --sidebar-width: 260px; - --header-height: 52px; - - /* System Greys (Light Mode) */ - --system-gray-6: #F5F5F7; /* App Background */ - --system-gray-5: #E5E5E7; - --system-gray-4: #D2D2D7; /* Borders */ - --system-gray-3: #C7C7CC; - --system-gray-2: #AEAEB2; - --system-gray-1: #8E8E93; /* Secondary Label */ - - /* Semantic Colors */ - --bg-app: var(--system-gray-6); - --bg-material-sidebar: rgba(255, 255, 255, 0.85); /* Subtle translucency */ - --bg-material-platter: #FFFFFF; - - --text-primary: #1D1D1F; - --text-secondary: #6E6E73; - --text-tertiary: #86868B; - - /* System Accents */ - --accent-blue: #007AFF; - --accent-green: #34C759; - --accent-orange: #FF9500; - --accent-red: #FF3B30; - - /* Materials & Effects */ - --glass-blur-heavy: 50px; - --glass-blur-med: 20px; - --border-subtle: rgba(0, 0, 0, 0.05); - --shadow-platter: 0 2px 8px rgba(0, 0, 0, 0.04); - - /* Animation */ - --ease-apple: cubic-bezier(0.2, 0, 0, 1); - --duration-normal: 0.3s; - --transition-fast: 0.2s ease; -} - -html, body { + --sidebar-width: 286px; + --foreground: #0f172a; + --muted-foreground: #475569; + --app-background: #eef3fb; + + --brand: #0f6bff; + --brand-strong: #0b4fbe; + --good: #16a34a; + --warn: #ca8a04; + --danger: #e11d48; + + --radius-sm: 10px; + --radius-md: 14px; + --radius-lg: 20px; + --radius-pill: 999px; + + --shadow-soft: 0 12px 32px rgba(15, 23, 42, 0.08); + --shadow-lift: 0 20px 48px rgba(15, 23, 42, 0.14); +} + +* { + box-sizing: border-box; +} + +html, +body { margin: 0; padding: 0; - background-color: var(--bg-app); - color: var(--text-primary); - font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + min-height: 100%; + color: var(--foreground); + background: + radial-gradient(circle at 8% 2%, rgba(56, 189, 248, 0.15), transparent 32%), + radial-gradient(circle at 100% 100%, rgba(34, 197, 94, 0.16), transparent 28%), + linear-gradient(180deg, #f8fbff, #eef3fb 42%, #eef2ff 100%); + font-family: var(--font-body); letter-spacing: -0.01em; -webkit-font-smoothing: antialiased; - height: 100%; - overflow-y: auto; + text-rendering: optimizelegibility; } -*, *::before, *::after { - box-sizing: border-box; +body { + position: relative; +} + +body::before { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + opacity: 0.28; + background-image: + linear-gradient(rgba(15, 23, 42, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(15, 23, 42, 0.03) 1px, transparent 1px); + background-size: 56px 56px; + mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.85), transparent 92%); + z-index: -1; +} + +a { + color: inherit; + text-decoration: none; +} + +::selection { + background: rgba(15, 107, 255, 0.2); + color: #0b1220; +} + +h1, +h2, +h3, +h4 { + font-family: var(--font-display); +} + +code, +pre, +kbd, +samp { + font-family: var(--font-mono); +} + +button, +input, +select, +textarea { + font: inherit; +} + +a:focus-visible, +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 2px solid rgba(15, 107, 255, 0.55); + outline-offset: 2px; +} + +.marketing-root { + min-height: 100vh; + color: var(--foreground); + background: + radial-gradient(circle at 20% 14%, rgba(56, 189, 248, 0.16), transparent 36%), + radial-gradient(circle at 96% 64%, rgba(34, 197, 94, 0.16), transparent 28%), + linear-gradient(180deg, #fbfdff 0%, #f4f8ff 48%, #eff4ff 100%); } -/* Material Utilities */ .material-sidebar { - background-color: var(--bg-material-sidebar); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-right: 1px solid #E5E5E5; + background: rgba(255, 255, 255, 0.78); + backdrop-filter: blur(18px); + border-right: 1px solid rgba(15, 23, 42, 0.1); } .material-platter { - background-color: var(--bg-material-platter); - border: 1px solid rgba(0, 0, 0, 0.05); - box-shadow: var(--shadow-platter); + border-radius: var(--radius-lg); + border: 1px solid rgba(15, 23, 42, 0.11); + background: rgba(255, 255, 255, 0.86); + box-shadow: var(--shadow-soft); +} + +.linkAccent { + color: var(--brand); + text-decoration: underline; + text-underline-offset: 3px; +} + +.muted { + color: var(--muted-foreground); +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } } diff --git a/web/app/layout.tsx b/web/app/layout.tsx index e60e309e2..1a127adb6 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,13 +1,27 @@ import type { Metadata } from 'next'; -import { Inter } from 'next/font/google'; +import { IBM_Plex_Mono, Manrope, Space_Grotesk } from 'next/font/google'; import './globals.css'; -const inter = Inter({ subsets: ['latin'] }); +const display = Space_Grotesk({ + subsets: ['latin'], + variable: '--font-display', +}); + +const body = Manrope({ + subsets: ['latin'], + variable: '--font-body', +}); + +const mono = IBM_Plex_Mono({ + subsets: ['latin'], + variable: '--font-mono', + weight: ['400', '500'], +}); export const metadata: Metadata = { - title: 'The Cloud - Open Source Cloud Platform', - description: 'A free, open-source cloud platform that anyone can run, modify, and own.', + title: 'The Cloud Console', + description: 'Open-source cloud control plane and elegant console experience.', }; export default function RootLayout({ @@ -17,7 +31,7 @@ export default function RootLayout({ }) { return ( - + {children} diff --git a/web/components/compute/LaunchInstanceModal.module.css b/web/components/compute/LaunchInstanceModal.module.css index 7bf7184d7..b99c3d9fa 100644 --- a/web/components/compute/LaunchInstanceModal.module.css +++ b/web/components/compute/LaunchInstanceModal.module.css @@ -5,29 +5,31 @@ left: 0; right: 0; bottom: 0; - background-color: rgba(0, 0, 0, 0.4); - backdrop-filter: blur(4px); - z-index: 100; + background-color: rgba(15, 23, 42, 0.42); + -webkit-backdrop-filter: blur(6px); + backdrop-filter: blur(6px); + z-index: 130; display: flex; align-items: flex-start; justify-content: center; - padding-top: 100px; + padding-top: 72px; animation: fadeIn 0.2s ease-out; } .modal { - width: 500px; - background: #FFFFFF; - border-radius: 12px; - box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2); + width: min(560px, calc(100vw - 24px)); + background: rgba(255, 255, 255, 0.94); + border-radius: 20px; + border: 1px solid rgba(15, 23, 42, 0.14); + box-shadow: 0 28px 44px rgba(15, 23, 42, 0.28); display: flex; flex-direction: column; animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1); } .header { - padding: 16px 24px; - border-bottom: 1px solid var(--system-gray-5); + padding: 16px 18px; + border-bottom: 1px solid rgba(15, 23, 42, 0.1); display: flex; align-items: center; justify-content: space-between; @@ -35,68 +37,80 @@ .title { margin: 0; - font-size: 17px; - font-weight: 600; + font-size: 1.05rem; + font-weight: 700; } .closeBtn { - padding: 4px; - color: var(--text-secondary); + padding: 0; + color: var(--muted-foreground); } .form { - padding: 24px; + padding: 16px 18px 18px; display: flex; flex-direction: column; - gap: 20px; + gap: 14px; } .row { display: grid; grid-template-columns: 1fr 1fr; - gap: 20px; + gap: 12px; } .field { display: flex; flex-direction: column; - gap: 8px; + gap: 6px; } .label { - font-size: 13px; - font-weight: 500; - color: var(--text-primary); + font-size: 0.74rem; + font-weight: 700; + color: var(--muted-foreground); + letter-spacing: 0.08em; + text-transform: uppercase; } .input, .select { - padding: 8px 12px; - border-radius: 6px; - border: 1px solid var(--system-gray-4); - font-size: 14px; + height: 40px; + padding: 0 11px; + border-radius: 12px; + border: 1px solid rgba(15, 23, 42, 0.16); + font-size: 0.92rem; font-family: inherit; - color: var(--text-primary); - background: white; + color: var(--foreground); + background: rgba(255, 255, 255, 0.95); outline: none; - transition: border-color 0.1s; + transition: border-color 0.1s, box-shadow 0.1s; } .input:focus, .select:focus { - border-color: var(--accent-blue); - box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.1); + border-color: rgba(15, 107, 255, 0.65); + box-shadow: 0 0 0 3px rgba(15, 107, 255, 0.16); } .helpText { margin: 0; - font-size: 12px; - color: var(--text-secondary); + font-size: 0.8rem; + color: var(--muted-foreground); +} + +.error { + border: 1px solid rgba(225, 29, 72, 0.25); + background: rgba(255, 241, 242, 0.95); + color: #be123c; + font-size: 0.86rem; + border-radius: 12px; + padding: 10px 12px; } .footer { display: flex; justify-content: flex-end; - gap: 12px; - margin-top: 12px; + gap: 10px; + margin-top: 4px; } @keyframes fadeIn { @@ -108,3 +122,20 @@ from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } + +@media (max-width: 720px) { + .overlay { + padding-top: 24px; + } + + .row { + grid-template-columns: 1fr; + } +} + +@media (prefers-reduced-motion: reduce) { + .overlay, + .modal { + animation: none; + } +} diff --git a/web/components/compute/LaunchInstanceModal.tsx b/web/components/compute/LaunchInstanceModal.tsx index fe1a0303d..290696085 100644 --- a/web/components/compute/LaunchInstanceModal.tsx +++ b/web/components/compute/LaunchInstanceModal.tsx @@ -10,29 +10,71 @@ interface LaunchInstanceData { name: string; image: string; ports: string; - vpc: string; + vpcId?: string; +} + +interface VpcOption { + id: string; + name: string; + cidr_block?: string; } interface LaunchInstanceModalProps { isOpen: boolean; onClose: () => void; - onSubmit: (data: LaunchInstanceData) => void; + onSubmit: (data: LaunchInstanceData) => Promise; + isSubmitting?: boolean; + vpcs: VpcOption[]; } -export const LaunchInstanceModal: React.FC = ({ isOpen, onClose, onSubmit }) => { +export const LaunchInstanceModal: React.FC = ({ + isOpen, + onClose, + onSubmit, + isSubmitting = false, + vpcs, +}) => { const [formData, setFormData] = useState({ name: '', image: 'ubuntu-22.04', - ports: '80:80', - vpc: 'default-vpc', + ports: '', + vpcId: undefined, }); + const [localError, setLocalError] = useState(null); + + const handleClose = () => { + setLocalError(null); + onClose(); + }; if (!isOpen) return null; - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - onSubmit(formData); - onClose(); + + if (!formData.name.trim()) { + setLocalError('Instance name is required.'); + return; + } + + setLocalError(null); + + try { + await onSubmit({ + ...formData, + name: formData.name.trim(), + ports: formData.ports.trim(), + }); + handleClose(); + setFormData({ + name: '', + image: 'ubuntu-22.04', + ports: '', + vpcId: undefined, + }); + } catch { + // Parent component shows the API error in page-level banner. + } }; return ( @@ -40,7 +82,7 @@ export const LaunchInstanceModal: React.FC = ({ isOpen

Launch Instance

-
@@ -74,14 +116,23 @@ export const LaunchInstanceModal: React.FC = ({ isOpen
- +
@@ -91,16 +142,20 @@ export const LaunchInstanceModal: React.FC = ({ isOpen setFormData({...formData, ports: e.target.value})} /> -

Comma separated list of port mappings.

+

Comma-separated host:container mappings. Leave empty for internal-only instance.

+ {localError ?
{localError}
: null} +
- - + +
diff --git a/web/components/ui/Button.module.css b/web/components/ui/Button.module.css index 2a80b1e8c..eca3610ef 100644 --- a/web/components/ui/Button.module.css +++ b/web/components/ui/Button.module.css @@ -3,60 +3,108 @@ display: inline-flex; align-items: center; justify-content: center; - border-radius: 8px; - font-weight: 500; - transition: all var(--transition-fast); + gap: 8px; + border-radius: 12px; + font-weight: 700; + letter-spacing: 0.01em; + transition: transform 140ms ease, box-shadow 140ms ease, background-color 140ms ease, + border-color 140ms ease; cursor: pointer; - border: none; + border: 1px solid transparent; font-family: inherit; outline: none; + white-space: nowrap; } -.button:active { - transform: scale(0.98); +.button:disabled { + opacity: 0.65; + cursor: not-allowed; + transform: none; + box-shadow: none; } -/* Variants */ .primary { - background-color: var(--accent-blue); /* System Blue */ - color: #FFFFFF; + background: linear-gradient(145deg, var(--brand), var(--brand-strong)); + color: #fff; + box-shadow: 0 12px 22px rgba(15, 107, 255, 0.34); } .primary:hover { - opacity: 0.85; /* Standard shrink/dim effect */ + transform: translateY(-1px); + box-shadow: 0 16px 26px rgba(15, 107, 255, 0.4); } .secondary { - background-color: rgba(0, 0, 0, 0.06); /* Light Grey */ - color: var(--accent-blue); - backdrop-filter: none; /* No glass needed on white */ + border-color: rgba(15, 23, 42, 0.14); + background: rgba(255, 255, 255, 0.85); + color: var(--foreground); } .secondary:hover { - background-color: rgba(0, 0, 0, 0.12); /* Darker Grey */ + transform: translateY(-1px); + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 10px 18px rgba(15, 23, 42, 0.12); } .ghost { - background-color: transparent; - color: var(--accent-blue); + background: rgba(248, 250, 252, 0.75); + border-color: rgba(15, 23, 42, 0.1); + color: var(--muted-foreground); } .ghost:hover { - background-color: rgba(0, 118, 255, 0.1); /* Light Blue Tint */ + color: var(--foreground); + border-color: rgba(15, 23, 42, 0.2); + transform: translateY(-1px); } -/* Sizes */ .sm { - padding: 6px 12px; + height: 34px; + padding: 0 12px; font-size: 13px; } .md { - padding: 8px 16px; + height: 40px; + padding: 0 14px; font-size: 14px; } .lg { - padding: 12px 24px; + height: 46px; + padding: 0 18px; font-size: 16px; } + +.spinner { + width: 12px; + height: 12px; + border: 2px solid rgba(255, 255, 255, 0.6); + border-bottom-color: transparent; + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +.secondary .spinner, +.ghost .spinner { + border-color: rgba(15, 23, 42, 0.35); + border-bottom-color: transparent; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/web/components/ui/Button.tsx b/web/components/ui/Button.tsx index a19e9dc52..da0b239e2 100644 --- a/web/components/ui/Button.tsx +++ b/web/components/ui/Button.tsx @@ -4,23 +4,34 @@ import styles from "./Button.module.css"; interface ButtonProps extends React.ButtonHTMLAttributes { variant?: "primary" | "secondary" | "ghost"; size?: "sm" | "md" | "lg"; + loading?: boolean; children: React.ReactNode; } export const Button: React.FC = ({ variant = "primary", size = "md", + loading = false, className, children, + disabled, ...props }) => { + const isDisabled = disabled || loading; + return ( ); diff --git a/web/components/ui/Card.module.css b/web/components/ui/Card.module.css index c8792e7b1..c98bd53cd 100644 --- a/web/components/ui/Card.module.css +++ b/web/components/ui/Card.module.css @@ -1,21 +1,38 @@ .card { - border-radius: 12px; + border-radius: 18px; overflow: hidden; display: flex; flex-direction: column; - background-color: #FFFFFF; /* Explicit white */ - /* Border and shadow handled by .material-platter */ } .header { - padding: 16px 20px; /* Reduced side padding */ - border-bottom: 1px solid var(--system-gray-5); - font-weight: 600; - font-size: 15px; - color: var(--text-primary); - background-color: transparent; + padding: 14px 16px; + border-bottom: 1px solid rgba(15, 23, 42, 0.08); + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; } .content { - padding: 20px; + padding: 16px; +} + +.title { + font-weight: 700; + font-size: 0.98rem; + letter-spacing: -0.01em; +} + +.subtitle { + margin-top: 4px; + color: var(--muted-foreground); + font-size: 0.83rem; +} + +.action { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 8px; } diff --git a/web/components/ui/Card.tsx b/web/components/ui/Card.tsx index 0877b14a9..5f58d0d1f 100644 --- a/web/components/ui/Card.tsx +++ b/web/components/ui/Card.tsx @@ -5,12 +5,29 @@ import styles from './Card.module.css'; interface CardProps extends React.HTMLAttributes { children: React.ReactNode; title?: string; + subtitle?: string; + action?: React.ReactNode; } -export const Card: React.FC = ({ children, className, title, ...props }) => { +export const Card: React.FC = ({ + children, + className, + title, + subtitle, + action, + ...props +}) => { return (
- {title &&
{title}
} + {title ? ( +
+
+
{title}
+ {subtitle ?
{subtitle}
: null} +
+ {action ?
{action}
: null} +
+ ) : null}
{children}
diff --git a/web/components/ui/Sidebar.module.css b/web/components/ui/Sidebar.module.css index 7a2b7626d..71bf93466 100644 --- a/web/components/ui/Sidebar.module.css +++ b/web/components/ui/Sidebar.module.css @@ -6,61 +6,78 @@ position: fixed; left: 0; top: 0; - z-index: 50; - padding-top: 16px; - /* Background and border handled by .material-sidebar in globals.css for consistency */ + z-index: 80; + padding: 14px 12px; } .logo { - height: 44px; /* Slightly taller header */ display: flex; align-items: center; - padding: 0 20px; - gap: 12px; - margin-bottom: 16px; + gap: 10px; + padding: 8px 10px; + margin-bottom: 10px; +} + +.logoIcon { + width: 30px; + height: 30px; + border-radius: 8px; + display: grid; + place-items: center; + color: #fff; + background: linear-gradient(145deg, var(--brand), var(--brand-strong)); + box-shadow: 0 8px 18px rgba(15, 107, 255, 0.32); } .logoText { font-weight: 600; - font-size: 15px; /* Clearer, larger text */ + font-size: 0.9rem; letter-spacing: -0.01em; - color: var(--text-primary); + color: var(--foreground); +} + +.logoSub { + margin-top: 1px; + font-size: 0.72rem; + color: var(--muted-foreground); } .nav { flex: 1; - padding: 0 12px; + padding: 2px; display: flex; flex-direction: column; - gap: 2px; + gap: 4px; } .navItem { display: flex; align-items: center; - gap: 12px; - padding: 8px 12px; - border-radius: 6px; - color: var(--text-primary); /* Dark text on white */ + gap: 10px; + padding: 10px 12px; + border-radius: 11px; + border: 1px solid transparent; + color: var(--muted-foreground); text-decoration: none; - font-size: 14px; - font-weight: 400; - transition: all 0.1s ease-out; + font-size: 0.9rem; + font-weight: 600; + transition: border-color 130ms ease, background-color 130ms ease, transform 130ms ease; } .navItem:hover { - background-color: rgba(0, 0, 0, 0.04); + border-color: rgba(15, 23, 42, 0.09); + background: rgba(255, 255, 255, 0.7); } .active { - background-color: rgba(0, 122, 255, 0.1) !important; /* Light blue background */ - color: var(--accent-blue) !important; - font-weight: 500; + background: rgba(15, 107, 255, 0.09) !important; + border-color: rgba(15, 107, 255, 0.24) !important; + color: var(--brand) !important; } .footer { - padding: 20px; - border-top: 1px solid var(--system-gray-5); + padding: 12px 10px 4px; + border-top: 1px solid rgba(15, 23, 42, 0.08); margin-top: auto; } @@ -68,14 +85,69 @@ display: flex; align-items: center; gap: 8px; - font-size: 12px; - color: var(--text-secondary); + font-size: 0.78rem; + color: var(--muted-foreground); } .statusDot { - width: 8px; - height: 8px; + width: 7px; + height: 7px; border-radius: 50%; - background-color: var(--accent-green); - /* No glow in light mode, just clean color */ + background-color: var(--good); +} + +.footerText { + margin: 8px 0 0; + color: var(--muted-foreground); + font-size: 0.74rem; + line-height: 1.4; +} + +.mobileToggle { + position: fixed; + top: 14px; + left: 14px; + z-index: 90; + border: 1px solid rgba(15, 23, 42, 0.12); + border-radius: 999px; + background: rgba(255, 255, 255, 0.92); + color: var(--foreground); + padding: 7px 12px; + display: none; + align-items: center; + gap: 8px; + font-weight: 700; +} + +.backdrop { + position: fixed; + inset: 0; + border: 0; + padding: 0; + background: rgba(15, 23, 42, 0.36); + opacity: 0; + pointer-events: none; + z-index: 70; + transition: opacity 150ms ease; +} + +.backdropVisible { + opacity: 1; + pointer-events: auto; +} + +@media (max-width: 1024px) { + .mobileToggle { + display: inline-flex; + } + + .sidebar { + transform: translateX(-106%); + transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1); + box-shadow: var(--shadow-lift); + } + + .mobileOpen { + transform: translateX(0); + } } diff --git a/web/components/ui/Sidebar.tsx b/web/components/ui/Sidebar.tsx index 7db00b24e..cc075cf75 100644 --- a/web/components/ui/Sidebar.tsx +++ b/web/components/ui/Sidebar.tsx @@ -1,10 +1,10 @@ 'use client'; -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import Link from 'next/link'; -import { usePathname } from 'next/navigation'; -import { LayoutGrid, Server, HardDrive, Network, Settings, Activity } from 'lucide-react'; +import { usePathname, useRouter } from 'next/navigation'; +import { Cloud, LayoutGrid, Server, HardDrive, Network, Settings, Activity, Menu, X } from 'lucide-react'; import styles from './Sidebar.module.css'; const MENU_ITEMS = [ @@ -18,38 +18,126 @@ const MENU_ITEMS = [ export const Sidebar: React.FC = () => { const pathname = usePathname(); + const router = useRouter(); + const [open, setOpen] = useState(false); + const toggleRef = useRef(null); + const sidebarRef = useRef(null); + + useEffect(() => { + MENU_ITEMS.forEach((item) => { + router.prefetch(item.href); + }); + }, [router]); + + useEffect(() => { + if (!open) { + if (window.matchMedia('(max-width: 1024px)').matches) { + toggleRef.current?.focus(); + } + return; + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpen(false); + } + }; + + window.addEventListener('keydown', onKeyDown); + + const root = sidebarRef.current; + const firstFocusable = root?.querySelector('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'); + firstFocusable?.focus(); + + return () => { + window.removeEventListener('keydown', onKeyDown); + }; + }, [open]); + + const handleNavigate = (href: string) => (event: React.MouseEvent) => { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) { + setOpen(false); + return; + } + + setOpen(false); + + if (pathname === href) { + event.preventDefault(); + return; + } + + event.preventDefault(); + router.push(href); + }; return ( - + ); }; diff --git a/web/components/ui/StatusIndicator.module.css b/web/components/ui/StatusIndicator.module.css index b023d311e..dec1b7e21 100644 --- a/web/components/ui/StatusIndicator.module.css +++ b/web/components/ui/StatusIndicator.module.css @@ -6,35 +6,46 @@ } .dot { - width: 8px; - height: 8px; + width: 9px; + height: 9px; border-radius: 50%; box-shadow: 0 0 8px currentColor; } .label { - font-size: 13px; - color: var(--text-secondary); + font-size: 12px; + color: var(--muted-foreground); text-transform: capitalize; + letter-spacing: 0.02em; } .running { - background-color: var(--accent-green); - color: var(--accent-green); + background-color: var(--good); + color: var(--good); } .stopped { - background-color: var(--text-tertiary); - color: var(--text-tertiary); + background-color: #64748b; + color: #64748b; box-shadow: none; } .pending { - background-color: var(--accent-orange); - color: var(--accent-orange); + background-color: var(--warn); + color: var(--warn); } .error { - background-color: var(--accent-red); - color: var(--accent-red); + background-color: var(--danger); + color: var(--danger); +} + +.success { + background-color: var(--good); + color: var(--good); +} + +.failure { + background-color: var(--danger); + color: var(--danger); } diff --git a/web/components/ui/StatusIndicator.tsx b/web/components/ui/StatusIndicator.tsx index f7339ce5b..320d8e768 100644 --- a/web/components/ui/StatusIndicator.tsx +++ b/web/components/ui/StatusIndicator.tsx @@ -3,7 +3,7 @@ import React from 'react'; import styles from './StatusIndicator.module.css'; interface StatusIndicatorProps { - status: 'running' | 'stopped' | 'pending' | 'error'; + status: 'running' | 'stopped' | 'pending' | 'error' | 'success' | 'failure'; label?: string; } diff --git a/web/components/ui/Table.module.css b/web/components/ui/Table.module.css index c6d2de572..db64fcdd7 100644 --- a/web/components/ui/Table.module.css +++ b/web/components/ui/Table.module.css @@ -2,33 +2,33 @@ .container { width: 100%; overflow-x: auto; - background: #FFFFFF; - border-radius: 12px; - border: 1px solid var(--system-gray-4); - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); + border-radius: 16px; + border: 1px solid rgba(15, 23, 42, 0.1); + background: rgba(255, 255, 255, 0.88); + box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08); } .table { width: 100%; border-collapse: collapse; - font-size: 14px; + font-size: 13px; } .table th { text-align: left; - padding: 12px 20px; - border-bottom: 1px solid var(--system-gray-5); - color: var(--text-secondary); - font-weight: 500; - font-size: 12px; + padding: 12px 14px; + border-bottom: 1px solid rgba(15, 23, 42, 0.09); + color: var(--muted-foreground); + font-weight: 700; + font-size: 11px; text-transform: uppercase; - letter-spacing: 0.02em; + letter-spacing: 0.08em; } .table td { - padding: 16px 20px; - border-bottom: 1px solid var(--system-gray-5); - color: var(--text-primary); + padding: 12px 14px; + border-bottom: 1px solid rgba(15, 23, 42, 0.08); + color: var(--foreground); vertical-align: middle; } @@ -37,10 +37,17 @@ } .table tr { - transition: background-color 0.1s ease-out; + transition: background-color 120ms ease; } .clickable:hover { - background-color: rgba(0, 122, 255, 0.04); + background-color: rgba(15, 107, 255, 0.06); cursor: pointer; } + +.table td.emptyCell { + text-align: center; + color: var(--muted-foreground); + font-size: 0.9rem; + padding: 24px 10px; +} diff --git a/web/components/ui/Table.tsx b/web/components/ui/Table.tsx index 236ca02bc..92d921add 100644 --- a/web/components/ui/Table.tsx +++ b/web/components/ui/Table.tsx @@ -14,9 +14,17 @@ interface TableProps { data: T[]; columns: Column[]; onRowClick?: (item: T) => void; + emptyMessage?: string; + getRowKey?: (item: T, index: number) => React.Key; } -export function Table({ data, columns, onRowClick }: TableProps) { +export function Table({ + data, + columns, + onRowClick, + emptyMessage = 'No data found.', + getRowKey, +}: TableProps) { return (
@@ -30,17 +38,32 @@ export function Table({ data, columns, onRowClick }: TableProps) { + {data.length === 0 ? ( + + + + ) : null} {data.map((item, rowIndex) => ( - ) + ? String((item as Record).id) + : rowIndex + } onClick={() => onRowClick && onRowClick(item)} className={onRowClick ? styles.clickable : ''} > {columns.map((col, colIndex) => ( ))} diff --git a/web/hooks/useApiConfig.ts b/web/hooks/useApiConfig.ts new file mode 100644 index 000000000..b8cee7566 --- /dev/null +++ b/web/hooks/useApiConfig.ts @@ -0,0 +1,48 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + type CloudApiConfig, + getDefaultCloudApiConfig, + getStoredCloudApiConfig, + saveStoredCloudApiConfig, +} from '@/lib/api'; + +export function useApiConfig() { + const [config, setConfig] = useState(() => getDefaultCloudApiConfig()); + const [ready, setReady] = useState(false); + + useEffect(() => { + const hydrate = () => { + setConfig(getStoredCloudApiConfig()); + setReady(true); + }; + + const hydrationTimer = window.setTimeout(hydrate, 0); + + const syncFromStorage = () => { + setConfig(getStoredCloudApiConfig()); + }; + + window.addEventListener('storage', syncFromStorage); + return () => { + window.clearTimeout(hydrationTimer); + window.removeEventListener('storage', syncFromStorage); + }; + }, []); + + const updateConfig = useCallback((update: Partial) => { + const next = saveStoredCloudApiConfig(update); + setConfig(next); + return next; + }, []); + + const hasCredentials = useMemo(() => Boolean(config.apiKey), [config.apiKey]); + + return { + config, + ready, + hasCredentials, + updateConfig, + }; +} diff --git a/web/lib/api.ts b/web/lib/api.ts new file mode 100644 index 000000000..a989571ce --- /dev/null +++ b/web/lib/api.ts @@ -0,0 +1,256 @@ +export interface CloudApiConfig { + baseUrl: string; + apiKey: string; + tenantId: string; +} + +export interface ApiEnvelope { + data?: T; + error?: { + message?: string; + code?: string; + }; + meta?: { + request_id?: string; + timestamp?: string; + }; +} + +export class CloudApiError extends Error { + status: number; + code?: string; + + constructor(message: string, status: number, code?: string) { + super(message); + this.name = "CloudApiError"; + this.status = status; + this.code = code; + } +} + +const STORAGE_KEY = "thecloud.console.api.v1"; +const DEFAULT_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; +const RESPONSE_CACHE_TTL_MS = 4000; +let sessionApiKey = ""; + +interface ResponseCacheEntry { + timestamp: number; + value: unknown; +} + +const responseCache = new Map(); + +function apiKeyFingerprint(apiKey: string): string { + if (!apiKey) return "no-key"; + let hash = 0; + for (let index = 0; index < apiKey.length; index += 1) { + hash = (hash * 31 + apiKey.charCodeAt(index)) >>> 0; + } + return hash.toString(16); +} + +function cacheKeyForRequest(config: CloudApiConfig, path: string): string { + return `${config.baseUrl}|${config.tenantId}|${apiKeyFingerprint(config.apiKey)}|${path}`; +} + +export function clearApiResponseCache(): void { + responseCache.clear(); +} + +function normalizeBaseUrl(url: string): string { + return url.trim().replace(/\/+$/, "") || DEFAULT_BASE_URL; +} + +export function getDefaultCloudApiConfig(): CloudApiConfig { + return { + baseUrl: normalizeBaseUrl(DEFAULT_BASE_URL), + apiKey: "", + tenantId: "", + }; +} + +function safeParseConfig(raw: string | null): Partial { + if (!raw) { + return {}; + } + + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + return parsed as Partial; + } catch { + return {}; + } +} + +function getSessionApiKey(): string { + return sessionApiKey; +} + +function setSessionApiKey(value: string): void { + sessionApiKey = value.trim(); +} + +export function getStoredCloudApiConfig(): CloudApiConfig { + if (typeof window === "undefined") { + return getDefaultCloudApiConfig(); + } + + const parsed = safeParseConfig(window.localStorage.getItem(STORAGE_KEY)); + const storedApiKey = getSessionApiKey(); + + return { + baseUrl: normalizeBaseUrl(parsed.baseUrl ?? DEFAULT_BASE_URL), + apiKey: storedApiKey, + tenantId: (parsed.tenantId ?? "").trim(), + }; +} + +export function saveStoredCloudApiConfig(update: Partial): CloudApiConfig { + const current = getStoredCloudApiConfig(); + const nextApiKey = (update.apiKey ?? current.apiKey).trim(); + const next: CloudApiConfig = { + ...current, + ...update, + baseUrl: normalizeBaseUrl(update.baseUrl ?? current.baseUrl), + apiKey: nextApiKey, + tenantId: (update.tenantId ?? current.tenantId).trim(), + }; + + setSessionApiKey(nextApiKey); + + if (typeof window !== "undefined") { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + baseUrl: next.baseUrl, + tenantId: next.tenantId, + }) + ); + } + + clearApiResponseCache(); + + return next; +} + +export function clearStoredCloudApiConfig(): void { + setSessionApiKey(""); + + if (typeof window !== "undefined") { + window.localStorage.removeItem(STORAGE_KEY); + } + + clearApiResponseCache(); +} + +function extractErrorMessage(payload: unknown, fallback: string): { message: string; code?: string } { + if (!payload || typeof payload !== "object") { + return { message: fallback }; + } + + const candidate = payload as { + message?: string; + error?: { + message?: string; + code?: string; + }; + }; + + if (candidate.error?.message) { + return { + message: candidate.error.message, + code: candidate.error.code, + }; + } + + if (candidate.message) { + return { message: candidate.message }; + } + + return { message: fallback }; +} + +export async function cloudApiRequest( + path: string, + init?: RequestInit, + providedConfig?: CloudApiConfig +): Promise { + const config = providedConfig ?? getStoredCloudApiConfig(); + const method = (init?.method ?? "GET").toUpperCase(); + const isReadRequest = method === "GET" && !init?.body; + const requestCacheKey = cacheKeyForRequest(config, path); + + if (isReadRequest) { + const cached = responseCache.get(requestCacheKey); + if (cached && Date.now() - cached.timestamp < RESPONSE_CACHE_TTL_MS) { + return cached.value as T; + } + } + + if (!config.apiKey) { + throw new CloudApiError("Missing API key. Configure access in Settings.", 401, "MISSING_API_KEY"); + } + + const headers = new Headers(init?.headers); + headers.set("X-API-Key", config.apiKey); + + if (config.tenantId) { + headers.set("X-Tenant-ID", config.tenantId); + } + + if (init?.body && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + const response = await fetch(`${config.baseUrl}${path}`, { + ...init, + headers, + cache: "no-store", + }); + + if (response.status === 204) { + return null as T; + } + + const contentType = response.headers.get("content-type") ?? ""; + const isJson = contentType.includes("application/json"); + const payload: unknown = isJson ? await response.json().catch(() => null) : await response.text().catch(() => ""); + + if (!response.ok) { + const { message, code } = extractErrorMessage(payload, `Request failed with status ${response.status}`); + throw new CloudApiError(message, response.status, code); + } + + let result: T; + if ( + isJson && + payload && + typeof payload === "object" && + "data" in (payload as ApiEnvelope) && + "meta" in (payload as ApiEnvelope) && + typeof (payload as ApiEnvelope).meta === "object" && + !!(payload as ApiEnvelope).meta && + ( + Boolean((payload as ApiEnvelope).meta?.request_id) || + Boolean((payload as ApiEnvelope).meta?.timestamp) + ) + ) { + result = (payload as ApiEnvelope).data as T; + } else { + result = payload as T; + } + + if (isReadRequest) { + responseCache.set(requestCacheKey, { + timestamp: Date.now(), + value: result, + }); + } else { + clearApiResponseCache(); + } + + return result; +} diff --git a/web/lib/events.ts b/web/lib/events.ts new file mode 100644 index 000000000..7e59e2dad --- /dev/null +++ b/web/lib/events.ts @@ -0,0 +1,9 @@ +export type EventStatus = 'success' | 'failure'; + +export function eventStatus(action: string): EventStatus { + const normalized = action.toLowerCase(); + if (normalized.includes('fail') || normalized.includes('error') || normalized.includes('deny')) { + return 'failure'; + } + return 'success'; +} diff --git a/web/package-lock.json b/web/package-lock.json index cb7d24ff1..62cfbf237 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -58,7 +58,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1292,7 +1291,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1352,7 +1350,6 @@ "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", @@ -1852,7 +1849,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2193,7 +2189,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2756,7 +2751,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2942,7 +2936,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3535,8 +3528,7 @@ "version": "3.14.2", "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.2.tgz", "integrity": "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==", - "license": "Standard 'no charge' license: https://gsap.com/standard-license.", - "peer": true + "license": "Standard 'no charge' license: https://gsap.com/standard-license." }, "node_modules/has-bigints": { "version": "1.1.0", @@ -4856,7 +4848,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4866,7 +4857,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5544,7 +5534,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5707,7 +5696,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5983,7 +5971,6 @@ "integrity": "sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/public/cloudd.png b/web/public/cloudd.png new file mode 100644 index 000000000..c6cf9d962 Binary files /dev/null and b/web/public/cloudd.png differ
+ {emptyMessage} +
- {col.cell - ? col.cell(item) - : (col.accessorKey ? String(item[col.accessorKey]) : '')} + {col.cell + ? col.cell(item) + : col.accessorKey + ? (item[col.accessorKey] == null ? '' : String(item[col.accessorKey])) + : ''}