11import { useState , useEffect } from 'react'
2- import { Plus , Trash2 , Copy , Check , Server , Key , Users , Webhook , Shield , UserPlus , Lock , Eye , EyeOff , Pencil } from 'lucide-react'
2+ import { Plus , Trash2 , Copy , Check , Server , Key , Users , Webhook , Shield , UserPlus , Lock , Eye , EyeOff , Pencil , HardDrive , Loader2 } from 'lucide-react'
33import { useQuery , useMutation , useQueryClient } from '@tanstack/react-query'
44import { Tooltip , TooltipTrigger , TooltipContent } from '@/components/ui/tooltip'
55import { api } from '@/api/client'
66import { formatTimeAgo } from '@/lib/utils'
77import { cn } from '@/lib/utils'
88
9- type Tab = 'api-keys' | 'workers' | 'members' | 'webhooks' | 'credentials' | 'secrets'
9+ type Tab = 'api-keys' | 'workers' | 'members' | 'webhooks' | 'credentials' | 'secrets' | 'infra'
1010
1111export function Settings ( ) {
1212 // Pre-select tab from URL (e.g., /settings/workers)
1313 const pathTab = window . location . pathname . split ( '/' ) [ 2 ] as Tab | undefined
14- const [ tab , setTab ] = useState < Tab > ( pathTab && [ 'api-keys' , 'workers' , 'members' , 'webhooks' , 'credentials' , 'secrets' ] . includes ( pathTab ) ? pathTab : 'api-keys' )
14+ const [ tab , setTab ] = useState < Tab > ( pathTab && [ 'api-keys' , 'workers' , 'members' , 'webhooks' , 'credentials' , 'secrets' , 'infra' ] . includes ( pathTab ) ? pathTab : 'api-keys' )
1515
1616 const tabs : { id : Tab ; label : string ; icon : React . ElementType } [ ] = [
1717 { id : 'api-keys' , label : 'API Keys' , icon : Key } ,
@@ -20,6 +20,7 @@ export function Settings() {
2020 { id : 'members' , label : 'Members' , icon : Users } ,
2121 { id : 'webhooks' , label : 'Webhooks' , icon : Webhook } ,
2222 { id : 'credentials' , label : 'Credentials' , icon : Shield } ,
23+ { id : 'infra' , label : 'Infrastructure' , icon : HardDrive } ,
2324 ]
2425
2526 return (
@@ -54,6 +55,7 @@ export function Settings() {
5455 { tab === 'members' && < MembersTab /> }
5556 { tab === 'webhooks' && < WebhooksTab /> }
5657 { tab === 'credentials' && < CredentialsTab /> }
58+ { tab === 'infra' && < InfraTab /> }
5759 </ div >
5860 )
5961}
@@ -614,6 +616,139 @@ function CredentialsTab() {
614616 )
615617}
616618
619+ // ============================================================
620+ // Infrastructure Tab
621+ // ============================================================
622+
623+ function InfraTab ( ) {
624+ const qc = useQueryClient ( )
625+ const { data, isLoading, error } = useQuery ( {
626+ queryKey : [ 'infra-servers' ] ,
627+ queryFn : api . listInfraServers ,
628+ retry : false ,
629+ } )
630+ const servers = data ?. data || [ ]
631+ const notConfigured = ( error as any ) ?. status === 501
632+
633+ const [ provisioning , setProvisioning ] = useState ( false )
634+
635+ const provisionMutation = useMutation ( {
636+ mutationFn : ( ) => api . provisionServer ( ) ,
637+ onMutate : ( ) => setProvisioning ( true ) ,
638+ onSettled : ( ) => setProvisioning ( false ) ,
639+ onSuccess : ( ) => qc . invalidateQueries ( { queryKey : [ 'infra-servers' ] } ) ,
640+ } )
641+
642+ const destroyMutation = useMutation ( {
643+ mutationFn : ( id : string ) => api . destroyServer ( id ) ,
644+ onSuccess : ( ) => qc . invalidateQueries ( { queryKey : [ 'infra-servers' ] } ) ,
645+ } )
646+
647+ const stateColor : Record < string , string > = {
648+ provisioning : 'bg-amber-400' ,
649+ ready : 'bg-blue-400' ,
650+ active : 'bg-emerald-400' ,
651+ idle : 'bg-zinc-400' ,
652+ destroying : 'bg-red-400' ,
653+ destroyed : 'bg-zinc-600' ,
654+ }
655+
656+ const activeServers = servers . filter ( ( s : any ) => ! [ 'destroyed' ] . includes ( s . state ) )
657+ const totalCost = activeServers . reduce ( ( sum : number , s : any ) => sum + ( parseFloat ( s . monthly_cost ) || 4.5 ) , 0 )
658+
659+ if ( notConfigured ) {
660+ return (
661+ < div className = "space-y-4" >
662+ < div className = "rounded-lg border border-border bg-card p-6 text-center" >
663+ < HardDrive size = { 32 } className = "mx-auto text-muted-foreground mb-3" />
664+ < p className = "text-sm font-medium" > Infrastructure not configured</ p >
665+ < p className = "text-xs text-muted-foreground mt-1" > Set < code className = "font-mono text-xs" > infra.enabled: true</ code > and configure Hetzner API credentials in config.yaml to enable auto-provisioned servers.</ p >
666+ </ div >
667+ </ div >
668+ )
669+ }
670+
671+ return (
672+ < div className = "space-y-4" >
673+ < div className = "flex items-center justify-between" >
674+ < div >
675+ < span className = "text-sm font-medium" > { activeServers . length } servers</ span >
676+ < p className = "text-xs text-muted-foreground mt-0.5" > Auto-provisioned Hetzner CX22 nodes for container execution.</ p >
677+ </ div >
678+ < button
679+ onClick = { ( ) => provisionMutation . mutate ( ) }
680+ disabled = { provisioning }
681+ className = "flex items-center gap-2 px-3 py-1.5 rounded-md bg-indigo-500 text-white text-sm hover:bg-indigo-400 transition-colors disabled:opacity-50"
682+ >
683+ { provisioning ? < Loader2 size = { 14 } className = "animate-spin" /> : < Plus size = { 14 } /> }
684+ Provision Server
685+ </ button >
686+ </ div >
687+
688+ { /* Cost summary */ }
689+ { activeServers . length > 0 && (
690+ < div className = "grid grid-cols-3 gap-3" >
691+ < div className = "rounded-lg border border-border bg-card p-3 text-center" >
692+ < p className = "text-2xl font-semibold" > { activeServers . length } </ p >
693+ < p className = "text-xs text-muted-foreground" > Active servers</ p >
694+ </ div >
695+ < div className = "rounded-lg border border-border bg-card p-3 text-center" >
696+ < p className = "text-2xl font-semibold" > { activeServers . reduce ( ( sum : number , s : any ) => sum + ( s . containers_running || 0 ) , 0 ) } / { activeServers . reduce ( ( sum : number , s : any ) => sum + ( s . max_containers || 4 ) , 0 ) } </ p >
697+ < p className = "text-xs text-muted-foreground" > Containers</ p >
698+ </ div >
699+ < div className = "rounded-lg border border-border bg-card p-3 text-center" >
700+ < p className = "text-2xl font-semibold" > { '\u20AC' } { ( totalCost * 2 ) . toFixed ( 0 ) } /mo</ p >
701+ < p className = "text-xs text-muted-foreground" > Estimated cost</ p >
702+ </ div >
703+ </ div >
704+ ) }
705+
706+ { /* Server list */ }
707+ < div className = "rounded-lg border border-border bg-card divide-y divide-border" >
708+ { isLoading ? (
709+ < div className = "p-4 text-sm text-muted-foreground" > Loading...</ div >
710+ ) : servers . length === 0 ? (
711+ < div className = "p-4 text-sm text-muted-foreground" > No servers provisioned. Servers are created automatically when an orchestra needs container execution.</ div >
712+ ) : servers . map ( ( s : any ) => (
713+ < div key = { s . id } className = "flex items-center gap-4 px-4 py-3" >
714+ < span className = { cn ( 'w-2 h-2 rounded-full' , stateColor [ s . state ] || 'bg-zinc-500' ) } />
715+ < div className = "flex-1" >
716+ < p className = "text-sm font-medium" > { s . name || s . id } </ p >
717+ < p className = "text-xs text-muted-foreground font-mono" >
718+ { s . ip_address || 'pending' } · { s . server_type || 'cx22' } · { s . containers_running || 0 } /{ s . max_containers || 4 } containers
719+ </ p >
720+ </ div >
721+ < span className = { cn ( 'text-xs px-1.5 py-0.5 rounded font-mono' ,
722+ s . state === 'active' ? 'bg-emerald-500/10 text-emerald-400' :
723+ s . state === 'idle' ? 'bg-zinc-500/10 text-zinc-400' :
724+ s . state === 'provisioning' ? 'bg-amber-500/10 text-amber-400' :
725+ s . state === 'destroying' ? 'bg-red-500/10 text-red-400' :
726+ 'bg-zinc-500/10 text-zinc-400'
727+ ) } >
728+ { s . state }
729+ </ span >
730+ < span className = "text-xs text-muted-foreground" > { s . created_at ? formatTimeAgo ( s . created_at ) : '' } </ span >
731+ { s . state !== 'destroyed' && s . state !== 'destroying' && (
732+ < Tooltip >
733+ < TooltipTrigger asChild >
734+ < button
735+ type = "button"
736+ onClick = { ( ) => { if ( confirm ( `Destroy server "${ s . name || s . id } "? This will terminate all running containers.` ) ) destroyMutation . mutate ( s . id ) } }
737+ className = "p-1.5 rounded hover:bg-red-500/10 transition-colors"
738+ >
739+ < Trash2 size = { 14 } className = "text-muted-foreground hover:text-red-400" />
740+ </ button >
741+ </ TooltipTrigger >
742+ < TooltipContent > Destroy server</ TooltipContent >
743+ </ Tooltip >
744+ ) }
745+ </ div >
746+ ) ) }
747+ </ div >
748+ </ div >
749+ )
750+ }
751+
617752function CredentialSection ( { title, description, type, endpoint } : { title : string ; description : string ; type : string ; endpoint : string } ) {
618753 const [ items , setItems ] = useState < any [ ] > ( [ ] )
619754 const [ loading , setLoading ] = useState ( true )
0 commit comments