Skip to content

Commit

Permalink
feat(stacks): show services and their state
Browse files Browse the repository at this point in the history
  • Loading branch information
Trugamr committed Jan 14, 2024
1 parent e0a72ef commit b16d1a2
Show file tree
Hide file tree
Showing 6 changed files with 217 additions and 23 deletions.
29 changes: 29 additions & 0 deletions app/components/service-state-indicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { match } from 'ts-pattern'
import type { ServiceState } from '~/lib/stack.server'
import { cn } from '~/lib/utils'

type StackIndicatorProps = {
state: ServiceState | 'inactive'
className?: string
}

export function ServiceStateIndicator({ state, className }: StackIndicatorProps) {
return (
<span
className={cn(
'inline-block h-2.5 w-2.5 shrink-0 rounded-full',
match(state)
.with('paused', () => 'bg-yellow-400')
.with('restarting', () => 'bg-yellow-400')
.with('removing', () => 'bg-yellow-400')
.with('running', () => 'bg-green-400')
.with('dead', () => 'bg-red-400')
.with('created', () => 'bg-yellow-400')
.with('exited', () => 'bg-red-400')
.with('inactive', () => 'bg-gray-400')
.exhaustive(),
className,
)}
/>
)
}
2 changes: 1 addition & 1 deletion app/components/stack-logs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function StackLogs({ stack, className, initialLogs }: StackLogs) {
return (
<div
className={cn(
'flex h-80 max-w-2xl flex-col-reverse overflow-y-auto rounded-md border bg-background text-sm',
'flex flex-col-reverse overflow-y-auto rounded-md border bg-background text-sm',
className,
)}
>
Expand Down
150 changes: 146 additions & 4 deletions app/lib/stack.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,22 @@ export async function destroyStack(stack: Pick<Stack, 'path'>) {
// Example: running(2) -> { value: 'running', count: 2 }
const SERVICE_STATUS_REGEX = /^(?<value>\w+)\((?<count>\d+)\)$/

const ServiceStatusSchema = z.preprocess(
export type ServiceState = z.infer<typeof ServiceStateSchema>

const ServiceStateSchema = z.enum([
'paused',
'restarting',
'removing',
'running',
'dead',
'created',
'exited',
])

const ServiceStatusesSchema = z.preprocess(
value => (typeof value === 'string' ? SERVICE_STATUS_REGEX.exec(value)?.groups : value),
z.object({
value: z.enum(['paused', 'restarting', 'removing', 'running', 'dead', 'created', 'exited']),
value: ServiceStateSchema,
count: z.coerce.number(),
}),
)
Expand All @@ -113,7 +125,7 @@ const StackSummarySchema = z
Name: z.string(),
Status: z.preprocess(
value => (typeof value === 'string' ? value.split(', ') : value),
z.array(ServiceStatusSchema),
z.array(ServiceStatusesSchema),
),
ConfigFiles: z.string(),
})
Expand Down Expand Up @@ -148,7 +160,7 @@ export async function getStacksSummaries() {
throw new Error('Failed to get stacks summaries')
}

let json: unknown = null
let json: unknown
try {
json = JSON.parse(stdout)
} catch (error) {
Expand Down Expand Up @@ -237,3 +249,133 @@ export function getStackLogsProcess(stack: Pick<ManagedStack, 'directory'>) {
cwd: stack.directory,
})
}

export type ServiceCanonicalConfig = z.infer<typeof ServiceCanonicalConfigSchema>

const ServiceCanonicalConfigSchema = z
.object({
command: z.array(z.string()).nullable(),
entrypoint: z.string().nullable(),
image: z.string(),
ports: z
.array(
z.object({
mode: z.enum(['host', 'ingress']),
target: z.number(),
published: z.string(),
protocol: z.enum(['tcp', 'udp']),
}),
)
.optional(),
})
.transform(values => {
return {
...values,
state: 'inactive',
} as const
})

const StackCanonicalConfigSchema = z.object({
name: z.string(),
services: z.record(z.string(), ServiceCanonicalConfigSchema),
})

/**
* Get compose config for a stack
*/
export async function getCanonicalStackConfig(stack: Pick<Stack, 'directory'>) {
let stdout: string
try {
const result = await execa('docker', ['compose', 'config', '--format', 'json'], {
cwd: stack.directory,
})
stdout = result.stdout
} catch (error) {
throw new Error('Failed to get stack config')
}

let json: unknown
try {
json = JSON.parse(stdout)
} catch (error) {
throw new Error('Failed to parse stack config JSON')
}

return StackCanonicalConfigSchema.parse(json)
}

const StackServiceDetailsSchema = z
.object({
Name: z.string(),
Service: z.string(),
State: ServiceStateSchema,
})
.transform(values => {
return {
name: values.Name,
service: values.Service,
state: values.State,
} as const
})

const StackServicesDetailsSchema = z.array(StackServiceDetailsSchema)

/**
* Get extended stack details
*/
export async function getStackDetails(stack: Pick<Stack, 'directory'>) {
const config = await getCanonicalStackConfig(stack)

let stdout: string
try {
const result = await execa('docker', ['compose', 'ps', '--all', '--format', 'json'], {
cwd: stack.directory,
})
stdout = result.stdout
} catch (error) {
throw new Error('Failed to get stack details')
}

let lines: unknown[]
try {
lines = stdout
.split('\n')
.filter(Boolean)
.map(line => JSON.parse(line))
} catch (error) {
throw new Error('Failed to parse stack details JSON')
}

const details = StackServicesDetailsSchema.parse(lines)

const merged: {
services: Record<
string,
Omit<ServiceCanonicalConfig, 'state'> & {
info?: {
name: string
state: ServiceState
}
}
>
} = {
services: {
...structuredClone(config.services),
},
}

// Merge details into config
for (const detail of details) {
if (detail.service in merged.services) {
const service = merged.services[detail.service]
invariant(service, 'Service should be defined')

service.info = {
name: detail.name,
state: detail.state,
}
}
}

return merged
}
4 changes: 2 additions & 2 deletions app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function Document({ children }: DocumentProps) {
<Meta />
<Links />
</head>
<body>
<body className="bg-secondary">
{children}
<ScrollRestoration />
<Scripts />
Expand All @@ -59,7 +59,7 @@ export default function App() {
return (
<Document>
<GlobalNavigationProgress />
<div className="flex min-h-screen flex-col bg-secondary">
<div className="flex min-h-screen flex-col">
<header className="flex gap-x-8 border-b bg-background p-4">
<Link to="/" className="flex items-center gap-x-1.5 text-xl font-medium">
<PackageIcon className="h-[1.2em] w-[1.2em]" />
Expand Down
51 changes: 37 additions & 14 deletions app/routes/stacks.$name.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
startStack,
stopStack,
destroyStack,
getStackDetails,
} from '~/lib/stack.server'
import { Form, useLoaderData } from '@remix-run/react'
import { Input } from '~/components/ui/input'
Expand All @@ -17,6 +18,7 @@ import { StackStatusIndicator } from '~/components/stack-status-indicator'
import { StackLogs } from '~/components/stack-logs'
import { badRequest, notFound } from '~/lib/utils'
import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert'
import { ServiceStateIndicator } from '~/components/service-state-indicator'

const StackFormSchema = z.object({
stack: z.string(),
Expand All @@ -26,15 +28,20 @@ const StackFormSchema = z.object({
export async function loader({ params }: ActionFunctionArgs) {
invariantResponse(params.name, 'Stack name is required')

// TODO: Optimize by preventing stacks summaries call
const stack = await getStackByName(params.name)
if (!stack) {
throw notFound(`Stack "${params.name}" not found`)
}

const details = await getStackDetails(stack)

// TODO: Consider deferring this to the client
const initialLogs = await getStackInitialLogs(stack)

return json({
stack,
services: details.services,
initialLogs,
})
}
Expand Down Expand Up @@ -73,10 +80,10 @@ export async function action({ request }: ActionFunctionArgs) {
}

export default function StacksNameRoute() {
const { stack, initialLogs } = useLoaderData<typeof loader>()
const { stack, services, initialLogs } = useLoaderData<typeof loader>()

return (
<section>
<div>
<div className="space-y-1">
<h3 className="flex gap-x-1 text-2xl font-medium">
<span>{stack.name}</span>
Expand All @@ -86,14 +93,7 @@ export default function StacksNameRoute() {
</h3>
<p className="space-x-1 leading-tight">
<StackStatusIndicator status={stack.status} />
<span>
{match(stack.status)
.with('active', () => 'Active')
.with('stopped', () => 'Stopped')
.with('transitioning', () => 'Transitioning')
.with('inactive', () => 'Inactive')
.exhaustive()}
</span>
<span className="capitalize">{stack.status}</span>
</p>
<p className="truncate text-sm text-muted-foreground">{stack.directory}</p>
</div>
Expand Down Expand Up @@ -144,18 +144,41 @@ export default function StacksNameRoute() {
</div>
</Form>

<div className="mt-6">
<section className="mt-6">
<h3 className="text-xl font-medium">Services</h3>
<p className="text-muted-foreground">Details about services in this stack</p>
<ul className="mt-2 flex max-w-96 flex-col gap-y-2">
{Object.entries(services).map(([name, service]) => {
const state = service.info?.state ?? 'inactive'

return (
<li key={name} className="flex flex-col rounded-md border bg-background p-3">
<h4 className="text-lg font-medium">{service.info?.name ?? name}</h4>
<p className="truncate text-sm text-muted-foreground" title={service.image}>
{service.image}
</p>
<div className="mt-1.5 text-sm">
<ServiceStateIndicator state={state} />
<span className="ml-1 capitalize">{state}</span>
</div>
</li>
)
})}
</ul>
</section>

<section className="mt-6">
<h3 className="text-xl font-medium">Logs</h3>
<p className="text-muted-foreground">Output from all services in this stack</p>
<StackLogs
// Avoid re-mounting the component when the stack status changes
// TODO: We should handle status changes in the component itself
key={`${stack.name}::${JSON.stringify(stack.statuses)}`}
className="mt-2"
className="mt-2 h-80 max-w-2xl"
stack={stack}
initialLogs={initialLogs}
/>
</div>
</section>
</section>
</div>
)
}
4 changes: 2 additions & 2 deletions app/routes/stacks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default function StacksRoute() {
const { stacks } = useLoaderData<typeof loader>()

return (
<div className="flex min-h-full">
<div className="flex h-full">
<aside className="w-60 shrink-0 border-r bg-background p-4">
<Input className="mt-2" placeholder="Search stacks" />
<ul className="mt-4 space-y-2">
Expand All @@ -43,7 +43,7 @@ export default function StacksRoute() {
})}
</ul>
</aside>
<div className="container min-w-0 grow p-6">
<div className="container min-w-0 grow overflow-y-auto p-6">
<Outlet />
</div>
</div>
Expand Down

0 comments on commit b16d1a2

Please sign in to comment.