refactor: Decouple roles.

This commit is contained in:
2026-02-23 01:49:24 -08:00
parent 72d35f2641
commit eeaa5045d0
55 changed files with 2144 additions and 1276 deletions

View File

@@ -27,15 +27,13 @@ const TEMPLATES: Record<string, Record<string, unknown>> = {
path: { alias: "" },
},
},
worker: {
job: {
run: {
runner: "command",
argv: [""],
cwd: "",
},
manage: {
systemd: {},
},
schedule: "0 2 * * *",
},
empty: {},
}
@@ -145,7 +143,7 @@ export function AddComponent({ onAdd, existingNames }: AddComponentProps) {
>
<option value="service">Service (FastAPI + systemd + Caddy)</option>
<option value="tool">Tool (PATH install)</option>
<option value="worker">Worker (systemd, no HTTP)</option>
<option value="job">Job (scheduled task)</option>
<option value="empty">Empty</option>
</select>
</Field>

View File

@@ -38,9 +38,7 @@ export function ComponentCard({ component, health }: ComponentCardProps) {
</div>
<div className="flex gap-1 mb-2">
{component.roles.map((role) => (
<RoleBadge key={role} role={role} />
))}
<RoleBadge role={component.category} />
</div>
{component.description && (

View File

@@ -25,9 +25,7 @@ export function ComponentEditor({ component, onSave, onDelete }: ComponentEditor
<span className="text-sm text-[var(--muted)]">{component.description}</span>
</div>
<div className="flex items-center gap-1.5">
{component.roles.map((r) => (
<RoleBadge key={r} role={r} />
))}
<RoleBadge role={component.category} />
</div>
</button>

View File

@@ -63,7 +63,7 @@ export function ComponentFields({ component, onSave, onDelete }: ComponentFields
try {
const config: Record<string, unknown> = { ...m }
delete config.id
delete config.roles
delete config.category
config.description = description || undefined
// Merge plain env + secret references back together

View File

@@ -1,16 +1,8 @@
import type { ComponentSummary, HealthStatus } from "@/types"
import { CATEGORY_LABELS } from "@/lib/labels"
import { ComponentCard } from "./ComponentCard"
const ROLE_ORDER = ["service", "tool", "worker", "job", "frontend", "remote", "containerized"]
const ROLE_LABELS: Record<string, string> = {
service: "Services",
tool: "Tools",
worker: "Workers",
job: "Jobs",
frontend: "Frontends",
remote: "Remote",
containerized: "Containers",
}
const CATEGORY_ORDER = ["service", "job", "tool", "frontend", "component"]
interface ComponentGridProps {
components: ComponentSummary[]
@@ -20,24 +12,24 @@ interface ComponentGridProps {
export function ComponentGrid({ components, statuses }: ComponentGridProps) {
const statusMap = new Map(statuses.map((s) => [s.id, s]))
// Group by primary role
// Group by category
const groups = new Map<string, ComponentSummary[]>()
for (const comp of components) {
const primary = comp.roles[0] ?? "tool"
const list = groups.get(primary) ?? []
const cat = comp.category
const list = groups.get(cat) ?? []
list.push(comp)
groups.set(primary, list)
groups.set(cat, list)
}
return (
<div className="space-y-8">
{ROLE_ORDER.map((role) => {
const items = groups.get(role)
{CATEGORY_ORDER.map((cat) => {
const items = groups.get(cat)
if (!items?.length) return null
return (
<section key={role}>
<section key={cat}>
<h2 className="text-lg font-semibold mb-3 text-[var(--muted)]">
{ROLE_LABELS[role] ?? role}
{CATEGORY_LABELS[cat] ?? cat}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{items.map((comp) => (

View File

@@ -12,7 +12,7 @@ interface ComponentTableProps {
statuses: HealthStatus[]
}
type SortKey = "id" | "role" | "runner" | "schedule" | "port" | "status"
type SortKey = "id" | "category" | "runner" | "schedule" | "port" | "status"
type SortDir = "asc" | "desc"
function statusRank(s: HealthStatus | undefined, installed: boolean | null): number {
@@ -30,13 +30,13 @@ function statusRank(s: HealthStatus | undefined, installed: boolean | null): num
export function ComponentTable({ components, statuses }: ComponentTableProps) {
const statusMap = useMemo(() => new Map(statuses.map((s) => [s.id, s])), [statuses])
const allRoles = useMemo(() => {
const allCategories = useMemo(() => {
const set = new Set<string>()
for (const c of components) for (const r of c.roles) set.add(r)
for (const c of components) set.add(c.category)
return Array.from(set).sort()
}, [components])
const [roleFilter, setRoleFilter] = useState<string | null>(null)
const [categoryFilter, setCategoryFilter] = useState<string | null>(null)
const [search, setSearch] = useState("")
const [sortKey, setSortKey] = useState<SortKey>("id")
const [sortDir, setSortDir] = useState<SortDir>("asc")
@@ -52,8 +52,8 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
const filtered = useMemo(() => {
let list = components
if (roleFilter) {
list = list.filter((c) => c.roles.includes(roleFilter))
if (categoryFilter) {
list = list.filter((c) => c.category === categoryFilter)
}
if (search) {
const q = search.toLowerCase()
@@ -64,7 +64,7 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
)
}
return list
}, [components, roleFilter, search])
}, [components, categoryFilter, search])
const sorted = useMemo(() => {
const dir = sortDir === "asc" ? 1 : -1
@@ -72,8 +72,8 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
switch (sortKey) {
case "id":
return dir * a.id.localeCompare(b.id)
case "role":
return dir * (a.roles[0] ?? "").localeCompare(b.roles[0] ?? "")
case "category":
return dir * a.category.localeCompare(b.category)
case "runner":
return dir * (a.runner ?? "").localeCompare(b.runner ?? "")
case "schedule":
@@ -100,26 +100,26 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
/>
<div className="flex items-center gap-1.5">
<button
onClick={() => setRoleFilter(null)}
onClick={() => setCategoryFilter(null)}
className={`text-xs px-2 py-1 rounded transition-colors ${
roleFilter === null
categoryFilter === null
? "bg-[var(--primary)] text-white"
: "bg-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
}`}
>
All
</button>
{allRoles.map((role) => (
{allCategories.map((cat) => (
<button
key={role}
onClick={() => setRoleFilter(roleFilter === role ? null : role)}
key={cat}
onClick={() => setCategoryFilter(categoryFilter === cat ? null : cat)}
className={`text-xs px-2 py-1 rounded transition-colors ${
roleFilter === role
categoryFilter === cat
? "bg-[var(--primary)] text-white"
: "bg-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
}`}
>
{role}
{cat}
</button>
))}
</div>
@@ -131,7 +131,7 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
<thead>
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left">
<SortHeader label="Name" sortKey="id" current={sortKey} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Roles" sortKey="role" current={sortKey} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Category" sortKey="category" current={sortKey} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Runner" sortKey="runner" current={sortKey} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Schedule" sortKey="schedule" current={sortKey} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Port" sortKey="port" current={sortKey} dir={sortDir} onSort={toggleSort} />
@@ -233,11 +233,7 @@ function ComponentRow({
)}
</td>
<td className="px-3 py-2.5">
<div className="flex gap-1 flex-wrap">
{component.roles.map((role) => (
<RoleBadge key={role} role={role} />
))}
</div>
<RoleBadge role={component.category} />
</td>
<td className="px-3 py-2.5 text-[var(--muted)]">
{component.runner ? runnerLabel(component.runner) : "—"}

View File

@@ -0,0 +1,105 @@
import { useMemo } from "react"
import { Link } from "react-router-dom"
import { RefreshCw } from "lucide-react"
import type { ComponentSummary } from "@/types"
import { useServiceAction } from "@/services/api/hooks"
import { SectionHeader } from "./SectionHeader"
import { SortHeader, useSort } from "./SortHeader"
type JobSortKey = "id" | "timer"
interface JobSectionProps {
jobs: ComponentSummary[]
}
export function JobSection({ jobs }: JobSectionProps) {
const { sortKey, sortDir, toggleSort } = useSort<JobSortKey>("id")
const sorted = useMemo(() => {
const dir = sortDir === "asc" ? 1 : -1
return [...jobs].sort((a, b) => {
switch (sortKey) {
case "id":
return dir * a.id.localeCompare(b.id)
case "timer": {
const aTimer = a.systemd?.timer ? 1 : 0
const bTimer = b.systemd?.timer ? 1 : 0
return dir * (aTimer - bTimer)
}
default:
return 0
}
})
}, [jobs, sortKey, sortDir])
return (
<section>
<SectionHeader category="job" />
<div className="border border-[var(--border)] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left">
<SortHeader label="Name" sortKey="id" current={sortKey} dir={sortDir} onSort={toggleSort} />
<th className="px-3 py-2 font-medium text-[var(--muted)]">Schedule</th>
<SortHeader label="Timer" sortKey="timer" current={sortKey} dir={sortDir} onSort={toggleSort} />
<th className="px-3 py-2 font-medium text-[var(--muted)]">Actions</th>
</tr>
</thead>
<tbody>
{sorted.map((job) => (
<JobRow key={job.id} job={job} />
))}
</tbody>
</table>
</div>
</section>
)
}
function JobRow({ job }: { job: ComponentSummary }) {
const { mutate, isPending } = useServiceAction()
const hasTimer = job.systemd?.timer ?? false
return (
<tr className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--card)]/50 transition-colors">
<td className="px-3 py-2.5">
<Link
to={`/component/${job.id}`}
className="font-medium hover:text-[var(--primary)] transition-colors"
>
{job.id}
</Link>
{job.description && (
<p className="text-xs text-[var(--muted)] mt-0.5 truncate max-w-xs">
{job.description}
</p>
)}
</td>
<td className="px-3 py-2.5 font-mono text-[var(--muted)]">
{job.schedule ?? "—"}
</td>
<td className="px-3 py-2.5">
{hasTimer ? (
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-full bg-purple-900/40 text-purple-400 border border-purple-800/50">
<span className="w-1.5 h-1.5 rounded-full bg-purple-400" />
active
</span>
) : (
<span className="text-[var(--muted)]"></span>
)}
</td>
<td className="px-3 py-2.5">
{job.managed && (
<button
onClick={() => mutate({ name: job.id, action: "restart" })}
disabled={isPending}
className="p-1 rounded hover:bg-blue-800/30 text-blue-400 transition-colors disabled:opacity-40"
title="Restart"
>
<RefreshCw size={14} />
</button>
)}
</td>
</tr>
)
}

View File

@@ -1,14 +1,12 @@
import { cn } from "@/lib/utils"
import { ROLE_DESCRIPTIONS } from "@/lib/labels"
import { CATEGORY_DESCRIPTIONS } from "@/lib/labels"
const roleColors: Record<string, string> = {
const categoryColors: Record<string, string> = {
service: "bg-green-700 text-white",
tool: "bg-blue-700 text-white",
worker: "bg-blue-500 text-white",
job: "bg-purple-700 text-white",
tool: "bg-blue-700 text-white",
frontend: "bg-yellow-600 text-black",
remote: "bg-gray-600 text-gray-200",
containerized: "bg-orange-600 text-black",
component: "bg-gray-600 text-gray-200",
}
export function RoleBadge({ role }: { role: string }) {
@@ -16,9 +14,9 @@ export function RoleBadge({ role }: { role: string }) {
<span
className={cn(
"inline-block text-[0.65rem] font-semibold uppercase px-1.5 py-0.5 rounded",
roleColors[role] ?? "bg-gray-600 text-gray-200",
categoryColors[role] ?? "bg-gray-600 text-gray-200",
)}
title={ROLE_DESCRIPTIONS[role]}
title={CATEGORY_DESCRIPTIONS[role]}
>
{role}
</span>

View File

@@ -0,0 +1,11 @@
import { SECTION_HEADERS } from "@/lib/labels"
export function SectionHeader({ category }: { category: string }) {
const info = SECTION_HEADERS[category]
return (
<div className="mb-3">
<h2 className="text-lg font-semibold">{info?.title ?? category}</h2>
<p className="text-sm text-[var(--muted)]">{info?.subtitle}</p>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { useMemo } from "react"
import type { ComponentSummary, HealthStatus } from "@/types"
import { ComponentCard } from "./ComponentCard"
import { SectionHeader } from "./SectionHeader"
interface ServiceSectionProps {
services: ComponentSummary[]
statuses: HealthStatus[]
}
export function ServiceSection({ services, statuses }: ServiceSectionProps) {
const statusMap = useMemo(() => new Map(statuses.map((s) => [s.id, s])), [statuses])
return (
<section>
<SectionHeader category="service" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{services.map((svc) => (
<ComponentCard key={svc.id} component={svc} health={statusMap.get(svc.id)} />
))}
</div>
</section>
)
}

View File

@@ -0,0 +1,48 @@
import { useState } from "react"
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"
export type SortDir = "asc" | "desc"
export function SortHeader<K extends string>({
label,
sortKey,
current,
dir,
onSort,
}: {
label: string
sortKey: K
current: K
dir: SortDir
onSort: (key: K) => void
}) {
const active = current === sortKey
const Icon = active ? (dir === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown
return (
<th className="px-3 py-2 font-medium text-[var(--muted)]">
<button
onClick={() => onSort(sortKey)}
className="flex items-center gap-1 hover:text-[var(--foreground)] transition-colors"
>
{label}
<Icon size={12} className={active ? "text-[var(--foreground)]" : ""} />
</button>
</th>
)
}
export function useSort<K extends string>(defaultKey: K, defaultDir: SortDir = "asc") {
const [sortKey, setSortKey] = useState<K>(defaultKey)
const [sortDir, setSortDir] = useState<SortDir>(defaultDir)
const toggleSort = (key: K) => {
if (sortKey === key) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"))
} else {
setSortKey(key)
setSortDir("asc")
}
}
return { sortKey, sortDir, toggleSort } as const
}

View File

@@ -0,0 +1,120 @@
import { useMemo } from "react"
import { Link } from "react-router-dom"
import { Download, Trash2 } from "lucide-react"
import type { ComponentSummary } from "@/types"
import { useToolAction } from "@/services/api/hooks"
import { SectionHeader } from "./SectionHeader"
import { SortHeader, useSort } from "./SortHeader"
type ToolSortKey = "id" | "status"
function statusRank(installed: boolean | null): number {
if (installed === false) return 0
if (installed === true) return 1
return 2
}
interface ToolSectionProps {
tools: ComponentSummary[]
}
export function ToolSection({ tools }: ToolSectionProps) {
const { sortKey, sortDir, toggleSort } = useSort<ToolSortKey>("id")
const sorted = useMemo(() => {
const dir = sortDir === "asc" ? 1 : -1
return [...tools].sort((a, b) => {
switch (sortKey) {
case "id":
return dir * a.id.localeCompare(b.id)
case "status":
return dir * (statusRank(a.installed) - statusRank(b.installed))
default:
return 0
}
})
}, [tools, sortKey, sortDir])
return (
<section>
<SectionHeader category="tool" />
<div className="border border-[var(--border)] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left">
<SortHeader label="Name" sortKey="id" current={sortKey} dir={sortDir} onSort={toggleSort} />
<th className="px-3 py-2 font-medium text-[var(--muted)]">Description</th>
<SortHeader label="Status" sortKey="status" current={sortKey} dir={sortDir} onSort={toggleSort} />
<th className="px-3 py-2 font-medium text-[var(--muted)]">Actions</th>
</tr>
</thead>
<tbody>
{sorted.map((tool) => (
<ToolRow key={tool.id} tool={tool} />
))}
</tbody>
</table>
</div>
</section>
)
}
function ToolRow({ tool }: { tool: ComponentSummary }) {
const { mutate, isPending } = useToolAction()
return (
<tr className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--card)]/50 transition-colors">
<td className="px-3 py-2.5">
<Link
to={`/component/${tool.id}`}
className="font-medium hover:text-[var(--primary)] transition-colors"
>
{tool.id}
</Link>
</td>
<td className="px-3 py-2.5 text-[var(--muted)] truncate max-w-xs">
{tool.description ?? "—"}
</td>
<td className="px-3 py-2.5">
{tool.installed !== null ? (
tool.installed ? (
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-full bg-green-900/40 text-green-400 border border-green-800/50">
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
installed
</span>
) : (
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-full bg-zinc-800/40 text-[var(--muted)] border border-[var(--border)]">
<span className="w-1.5 h-1.5 rounded-full bg-zinc-500" />
not installed
</span>
)
) : (
<span className="text-[var(--muted)]"></span>
)}
</td>
<td className="px-3 py-2.5">
{tool.installed !== null && (
tool.installed ? (
<button
onClick={() => mutate({ name: tool.id, action: "uninstall" })}
disabled={isPending}
className="p-1 rounded hover:bg-red-800/30 text-red-400 transition-colors disabled:opacity-40"
title="Uninstall from PATH"
>
<Trash2 size={14} />
</button>
) : (
<button
onClick={() => mutate({ name: tool.id, action: "install" })}
disabled={isPending}
className="p-1 rounded hover:bg-green-800/30 text-green-400 transition-colors disabled:opacity-40"
title="Install to PATH"
>
<Download size={14} />
</button>
)
)}
</td>
</tr>
)
}