refactor: Update component structure to use behavior and stack attributes, enhancing clarity and functionality across services, tools, and frontends
This commit is contained in:
24
app/src/components/BehaviorBadge.tsx
Normal file
24
app/src/components/BehaviorBadge.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { BEHAVIOR_DESCRIPTIONS, behaviorLabel } from "@/lib/labels"
|
||||
|
||||
const behaviorColors: Record<string, string> = {
|
||||
daemon: "bg-green-700 text-white",
|
||||
tool: "bg-blue-700 text-white",
|
||||
frontend: "bg-yellow-600 text-black",
|
||||
}
|
||||
|
||||
export function BehaviorBadge({ behavior }: { behavior: string | null }) {
|
||||
if (!behavior) return null
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block text-[0.65rem] font-semibold uppercase px-1.5 py-0.5 rounded",
|
||||
behaviorColors[behavior] ?? "bg-gray-600 text-gray-200",
|
||||
)}
|
||||
title={BEHAVIOR_DESCRIPTIONS[behavior]}
|
||||
>
|
||||
{behaviorLabel(behavior)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import type { ComponentSummary, HealthStatus } from "@/types"
|
||||
import { useServiceAction } from "@/services/api/hooks"
|
||||
import { runnerLabel } from "@/lib/labels"
|
||||
import { HealthBadge } from "./HealthBadge"
|
||||
import { RoleBadge } from "./RoleBadge"
|
||||
import { BehaviorBadge } from "./BehaviorBadge"
|
||||
import { StackBadge } from "./StackBadge"
|
||||
|
||||
interface ComponentCardProps {
|
||||
component: ComponentSummary
|
||||
@@ -37,8 +38,9 @@ export function ComponentCard({ component, health }: ComponentCardProps) {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 mb-2">
|
||||
<RoleBadge role={component.category} />
|
||||
<div className="flex gap-1.5 mb-2">
|
||||
<BehaviorBadge behavior={component.behavior} />
|
||||
<StackBadge stack={component.stack} />
|
||||
</div>
|
||||
|
||||
{component.description && (
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useState } from "react"
|
||||
import { ChevronDown, ChevronRight } from "lucide-react"
|
||||
import type { ComponentDetail } from "@/types"
|
||||
import { ComponentFields } from "./ComponentFields"
|
||||
import { RoleBadge } from "./RoleBadge"
|
||||
import { BehaviorBadge } from "./BehaviorBadge"
|
||||
import { StackBadge } from "./StackBadge"
|
||||
|
||||
interface ComponentEditorProps {
|
||||
component: ComponentDetail
|
||||
@@ -25,7 +26,8 @@ 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">
|
||||
<RoleBadge role={component.category} />
|
||||
<BehaviorBadge behavior={component.behavior} />
|
||||
<StackBadge stack={component.stack} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export function ComponentFields({ component, onSave, onDelete }: ComponentFields
|
||||
try {
|
||||
const config: Record<string, unknown> = { ...m }
|
||||
delete config.id
|
||||
delete config.category
|
||||
delete config.behavior
|
||||
config.description = description || undefined
|
||||
|
||||
// Merge plain env + secret references back together
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { ComponentSummary, HealthStatus } from "@/types"
|
||||
import { CATEGORY_LABELS } from "@/lib/labels"
|
||||
import { BEHAVIOR_LABELS } from "@/lib/labels"
|
||||
import { ComponentCard } from "./ComponentCard"
|
||||
|
||||
const CATEGORY_ORDER = ["service", "job", "tool", "frontend", "component"]
|
||||
const BEHAVIOR_ORDER = ["daemon", "tool", "frontend"]
|
||||
|
||||
interface ComponentGridProps {
|
||||
components: ComponentSummary[]
|
||||
@@ -12,24 +12,24 @@ interface ComponentGridProps {
|
||||
export function ComponentGrid({ components, statuses }: ComponentGridProps) {
|
||||
const statusMap = new Map(statuses.map((s) => [s.id, s]))
|
||||
|
||||
// Group by category
|
||||
// Group by behavior
|
||||
const groups = new Map<string, ComponentSummary[]>()
|
||||
for (const comp of components) {
|
||||
const cat = comp.category
|
||||
const list = groups.get(cat) ?? []
|
||||
const key = comp.behavior ?? "other"
|
||||
const list = groups.get(key) ?? []
|
||||
list.push(comp)
|
||||
groups.set(cat, list)
|
||||
groups.set(key, list)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{CATEGORY_ORDER.map((cat) => {
|
||||
const items = groups.get(cat)
|
||||
{BEHAVIOR_ORDER.map((key) => {
|
||||
const items = groups.get(key)
|
||||
if (!items?.length) return null
|
||||
return (
|
||||
<section key={cat}>
|
||||
<section key={key}>
|
||||
<h2 className="text-lg font-semibold mb-3 text-[var(--muted)]">
|
||||
{CATEGORY_LABELS[cat] ?? cat}
|
||||
{BEHAVIOR_LABELS[key] ?? key}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{items.map((comp) => (
|
||||
|
||||
@@ -3,26 +3,24 @@ import { Link } from "react-router-dom"
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, Download, Play, RefreshCw, Square, Trash2 } from "lucide-react"
|
||||
import type { ComponentSummary, HealthStatus } from "@/types"
|
||||
import { useServiceAction, useToolAction } from "@/services/api/hooks"
|
||||
import { runnerLabel } from "@/lib/labels"
|
||||
import { HealthBadge } from "./HealthBadge"
|
||||
import { RoleBadge } from "./RoleBadge"
|
||||
import { BehaviorBadge } from "./BehaviorBadge"
|
||||
import { StackBadge } from "./StackBadge"
|
||||
|
||||
interface ComponentTableProps {
|
||||
components: ComponentSummary[]
|
||||
statuses: HealthStatus[]
|
||||
}
|
||||
|
||||
type SortKey = "id" | "category" | "runner" | "schedule" | "port" | "status"
|
||||
type SortKey = "id" | "stack" | "behavior" | "status"
|
||||
type SortDir = "asc" | "desc"
|
||||
|
||||
function statusRank(s: HealthStatus | undefined, installed: boolean | null): number {
|
||||
// Health takes priority for services
|
||||
if (s) {
|
||||
if (s.status === "down") return 0
|
||||
if (s.status === "up") return 3
|
||||
return 2
|
||||
}
|
||||
// Installed state for tools
|
||||
if (installed === false) return 1
|
||||
if (installed === true) return 3
|
||||
return 2
|
||||
@@ -30,13 +28,7 @@ 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 allCategories = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
for (const c of components) set.add(c.category)
|
||||
return Array.from(set).sort()
|
||||
}, [components])
|
||||
|
||||
const [categoryFilter, setCategoryFilter] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState("")
|
||||
const [sortKey, setSortKey] = useState<SortKey>("id")
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc")
|
||||
@@ -51,20 +43,14 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
|
||||
}
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = components
|
||||
if (categoryFilter) {
|
||||
list = list.filter((c) => c.category === categoryFilter)
|
||||
}
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
list = list.filter(
|
||||
(c) =>
|
||||
c.id.toLowerCase().includes(q) ||
|
||||
(c.description?.toLowerCase().includes(q) ?? false),
|
||||
)
|
||||
}
|
||||
return list
|
||||
}, [components, categoryFilter, search])
|
||||
if (!search) return components
|
||||
const q = search.toLowerCase()
|
||||
return components.filter(
|
||||
(c) =>
|
||||
c.id.toLowerCase().includes(q) ||
|
||||
(c.description?.toLowerCase().includes(q) ?? false),
|
||||
)
|
||||
}, [components, search])
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const dir = sortDir === "asc" ? 1 : -1
|
||||
@@ -72,14 +58,10 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
|
||||
switch (sortKey) {
|
||||
case "id":
|
||||
return dir * a.id.localeCompare(b.id)
|
||||
case "category":
|
||||
return dir * a.category.localeCompare(b.category)
|
||||
case "runner":
|
||||
return dir * (a.runner ?? "").localeCompare(b.runner ?? "")
|
||||
case "schedule":
|
||||
return dir * (a.schedule ?? "").localeCompare(b.schedule ?? "")
|
||||
case "port":
|
||||
return dir * ((a.port ?? 0) - (b.port ?? 0))
|
||||
case "stack":
|
||||
return dir * (a.stack ?? "").localeCompare(b.stack ?? "")
|
||||
case "behavior":
|
||||
return dir * (a.behavior ?? "").localeCompare(b.behavior ?? "")
|
||||
case "status":
|
||||
return dir * (statusRank(statusMap.get(a.id), a.installed) - statusRank(statusMap.get(b.id), b.installed))
|
||||
default:
|
||||
@@ -90,51 +72,22 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 mb-4 flex-wrap">
|
||||
<div className="mb-4">
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Filter components..."
|
||||
className="bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)] w-56"
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => setCategoryFilter(null)}
|
||||
className={`text-xs px-2 py-1 rounded transition-colors ${
|
||||
categoryFilter === null
|
||||
? "bg-[var(--primary)] text-white"
|
||||
: "bg-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{allCategories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setCategoryFilter(categoryFilter === cat ? null : cat)}
|
||||
className={`text-xs px-2 py-1 rounded transition-colors ${
|
||||
categoryFilter === cat
|
||||
? "bg-[var(--primary)] text-white"
|
||||
: "bg-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<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} />
|
||||
<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} />
|
||||
<SortHeader label="Stack" sortKey="stack" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||
<SortHeader label="Behavior" sortKey="behavior" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||
<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>
|
||||
@@ -149,7 +102,7 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
|
||||
))}
|
||||
{sorted.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-3 py-6 text-center text-[var(--muted)]">
|
||||
<td colSpan={5} className="px-3 py-6 text-center text-[var(--muted)]">
|
||||
No components match.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -222,7 +175,6 @@ function ComponentRow({
|
||||
<Link
|
||||
to={`/component/${component.id}`}
|
||||
className="font-medium hover:text-[var(--primary)] transition-colors"
|
||||
title={component.systemd?.unit_path ?? undefined}
|
||||
>
|
||||
{component.id}
|
||||
</Link>
|
||||
@@ -233,16 +185,10 @@ function ComponentRow({
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<RoleBadge role={component.category} />
|
||||
<StackBadge stack={component.stack} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--muted)]">
|
||||
{component.runner ? runnerLabel(component.runner) : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono text-[var(--muted)]">
|
||||
{component.schedule ?? "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono text-[var(--muted)]">
|
||||
{component.port ?? "—"}
|
||||
<td className="px-3 py-2.5">
|
||||
<BehaviorBadge behavior={component.behavior} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
{health ? (
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CATEGORY_DESCRIPTIONS } from "@/lib/labels"
|
||||
|
||||
const categoryColors: Record<string, string> = {
|
||||
service: "bg-green-700 text-white",
|
||||
job: "bg-purple-700 text-white",
|
||||
tool: "bg-blue-700 text-white",
|
||||
frontend: "bg-yellow-600 text-black",
|
||||
component: "bg-gray-600 text-gray-200",
|
||||
}
|
||||
|
||||
export function RoleBadge({ role }: { role: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block text-[0.65rem] font-semibold uppercase px-1.5 py-0.5 rounded",
|
||||
categoryColors[role] ?? "bg-gray-600 text-gray-200",
|
||||
)}
|
||||
title={CATEGORY_DESCRIPTIONS[role]}
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
107
app/src/components/ScheduledSection.tsx
Normal file
107
app/src/components/ScheduledSection.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Link } from "react-router-dom"
|
||||
import { Play, RefreshCw, Square } from "lucide-react"
|
||||
import type { ComponentSummary, HealthStatus } from "@/types"
|
||||
import { useServiceAction } from "@/services/api/hooks"
|
||||
import { SectionHeader } from "./SectionHeader"
|
||||
import { StackBadge } from "./StackBadge"
|
||||
|
||||
interface ScheduledSectionProps {
|
||||
jobs: ComponentSummary[]
|
||||
statuses: HealthStatus[]
|
||||
}
|
||||
|
||||
export function ScheduledSection({ jobs, statuses }: ScheduledSectionProps) {
|
||||
const statusMap = new Map(statuses.map((s) => [s.id, s]))
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader section="scheduled" />
|
||||
<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">
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Name</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Schedule</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Stack</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Status</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((job) => (
|
||||
<ScheduledRow key={job.id} job={job} health={statusMap.get(job.id)} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ScheduledRow({ job, health }: { job: ComponentSummary; health?: HealthStatus }) {
|
||||
const { mutate, isPending } = useServiceAction()
|
||||
const isDown = health?.status === "down"
|
||||
|
||||
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-xs">{job.schedule}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<StackBadge stack={job.stack} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
{health ? (
|
||||
<span className={`text-xs ${health.status === "up" ? "text-green-400" : "text-red-400"}`}>
|
||||
{health.status}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[var(--muted)]">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex items-center gap-1">
|
||||
{isDown && (
|
||||
<button
|
||||
onClick={() => mutate({ name: job.id, action: "start" })}
|
||||
disabled={isPending}
|
||||
className="p-1 rounded hover:bg-green-800/30 text-green-400 transition-colors disabled:opacity-40"
|
||||
title="Start"
|
||||
>
|
||||
<Play size={14} />
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
{!isDown && (
|
||||
<button
|
||||
onClick={() => mutate({ name: job.id, action: "stop" })}
|
||||
disabled={isPending}
|
||||
className="p-1 rounded hover:bg-red-800/30 text-red-400 transition-colors disabled:opacity-40"
|
||||
title="Stop"
|
||||
>
|
||||
<Square size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { SECTION_HEADERS } from "@/lib/labels"
|
||||
|
||||
export function SectionHeader({ category }: { category: string }) {
|
||||
const info = SECTION_HEADERS[category]
|
||||
export function SectionHeader({ section }: { section: string }) {
|
||||
const info = SECTION_HEADERS[section]
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<h2 className="text-lg font-semibold">{info?.title ?? category}</h2>
|
||||
<h2 className="text-lg font-semibold">{info?.title ?? section}</h2>
|
||||
<p className="text-sm text-[var(--muted)]">{info?.subtitle}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ export function ServiceSection({ services, statuses }: ServiceSectionProps) {
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader category="service" />
|
||||
<SectionHeader section="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)} />
|
||||
|
||||
11
app/src/components/StackBadge.tsx
Normal file
11
app/src/components/StackBadge.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { stackLabel } from "@/lib/labels"
|
||||
|
||||
export function StackBadge({ stack }: { stack: string | null }) {
|
||||
if (!stack) return null
|
||||
|
||||
return (
|
||||
<span className="text-[0.65rem] font-mono px-1.5 py-0.5 rounded bg-[var(--border)] text-[var(--muted)]">
|
||||
{stackLabel(stack)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -6,30 +6,44 @@ export const RUNNER_LABELS: Record<string, string> = {
|
||||
remote: "Remote",
|
||||
}
|
||||
|
||||
export const CATEGORY_LABELS: Record<string, string> = {
|
||||
service: "Services",
|
||||
job: "Jobs",
|
||||
tool: "Tools",
|
||||
frontend: "Frontends",
|
||||
component: "Components",
|
||||
export const BEHAVIOR_LABELS: Record<string, string> = {
|
||||
daemon: "Daemon",
|
||||
tool: "Tool",
|
||||
frontend: "Frontend",
|
||||
}
|
||||
|
||||
export const CATEGORY_DESCRIPTIONS: Record<string, string> = {
|
||||
service: "Long-running daemon",
|
||||
job: "Scheduled task",
|
||||
tool: "CLI utility installed to PATH",
|
||||
frontend: "Built static assets",
|
||||
component: "Software component",
|
||||
export const BEHAVIOR_DESCRIPTIONS: Record<string, string> = {
|
||||
daemon: "Long-running process that exposes ports",
|
||||
tool: "CLI utility or scheduled task",
|
||||
frontend: "Built web application",
|
||||
}
|
||||
|
||||
export const STACK_LABELS: Record<string, string> = {
|
||||
"python-fastapi": "Python / FastAPI",
|
||||
"python-cli": "Python / CLI",
|
||||
"react-vite": "React / Vite",
|
||||
rust: "Rust",
|
||||
go: "Go",
|
||||
bash: "Bash",
|
||||
container: "Container",
|
||||
command: "Command",
|
||||
remote: "Remote",
|
||||
}
|
||||
|
||||
export const SECTION_HEADERS: Record<string, { title: string; subtitle: string }> = {
|
||||
service: { title: "Services", subtitle: "Long-running daemons managed by systemd" },
|
||||
job: { title: "Jobs", subtitle: "Scheduled tasks with cron timers" },
|
||||
tool: { title: "Tools", subtitle: "CLI utilities installed to PATH" },
|
||||
frontend: { title: "Frontends", subtitle: "Built web applications" },
|
||||
component: { title: "Other", subtitle: "Software catalog entries" },
|
||||
service: { title: "Services", subtitle: "Long-running processes" },
|
||||
scheduled: { title: "Scheduled", subtitle: "Systemd timers" },
|
||||
component: { title: "Components", subtitle: "Software catalog" },
|
||||
}
|
||||
|
||||
export function runnerLabel(runner: string): string {
|
||||
return RUNNER_LABELS[runner] ?? runner
|
||||
}
|
||||
|
||||
export function behaviorLabel(behavior: string): string {
|
||||
return BEHAVIOR_LABELS[behavior] ?? behavior
|
||||
}
|
||||
|
||||
export function stackLabel(stack: string): string {
|
||||
return STACK_LABELS[stack] ?? stack
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import { runnerLabel } from "@/lib/labels"
|
||||
import { ComponentFields } from "@/components/ComponentFields"
|
||||
import { HealthBadge } from "@/components/HealthBadge"
|
||||
import { LogViewer } from "@/components/LogViewer"
|
||||
import { RoleBadge } from "@/components/RoleBadge"
|
||||
import { BehaviorBadge } from "@/components/BehaviorBadge"
|
||||
import { StackBadge } from "@/components/StackBadge"
|
||||
|
||||
export function ComponentDetailPage() {
|
||||
useEventStream()
|
||||
@@ -20,7 +21,7 @@ export function ComponentDetailPage() {
|
||||
const { mutate, isPending } = useServiceAction()
|
||||
const health = statusResp?.statuses.find((s) => s.id === name)
|
||||
const isDown = health?.status === "down"
|
||||
const isTool = component?.category === "tool"
|
||||
const isTool = component?.behavior === "tool"
|
||||
const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "")
|
||||
const isGateway = name === "castle-gateway"
|
||||
const { data: caddyfile } = useCaddyfile(isGateway)
|
||||
@@ -28,8 +29,8 @@ export function ComponentDetailPage() {
|
||||
const { data: unitData } = useSystemdUnit(name ?? "", showUnit && !!component?.systemd)
|
||||
const [message, setMessage] = useState<{ type: "ok" | "error"; text: string } | null>(null)
|
||||
|
||||
const configSection = component?.category === "service" ? "services"
|
||||
: component?.category === "job" ? "jobs"
|
||||
const configSection = component?.behavior === "daemon" ? "services"
|
||||
: component?.schedule ? "jobs"
|
||||
: "components"
|
||||
|
||||
const handleSave = async (compName: string, config: Record<string, unknown>) => {
|
||||
@@ -121,7 +122,8 @@ export function ComponentDetailPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<RoleBadge role={component.category} />
|
||||
<BehaviorBadge behavior={component.behavior} />
|
||||
<StackBadge stack={component.stack} />
|
||||
{component.source && (
|
||||
<span className="text-sm text-[var(--muted)] font-mono">{component.source}</span>
|
||||
)}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useMemo } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { useComponents, useStatus, useGateway, useNodes, useMeshStatus, useEventStream } from "@/services/api/hooks"
|
||||
import { GatewayPanel } from "@/components/GatewayPanel"
|
||||
import { MeshPanel } from "@/components/MeshPanel"
|
||||
import { NodeBar } from "@/components/NodeBar"
|
||||
import { ServiceSection } from "@/components/ServiceSection"
|
||||
import { JobSection } from "@/components/JobSection"
|
||||
import { ToolSection } from "@/components/ToolSection"
|
||||
import { ScheduledSection } from "@/components/ScheduledSection"
|
||||
import { ComponentTable } from "@/components/ComponentTable"
|
||||
import { SectionHeader } from "@/components/SectionHeader"
|
||||
|
||||
|
||||
export function Dashboard() {
|
||||
useEventStream()
|
||||
const { data: components, isLoading } = useComponents()
|
||||
@@ -17,16 +17,10 @@ export function Dashboard() {
|
||||
const { data: nodes } = useNodes()
|
||||
const { data: mesh } = useMeshStatus()
|
||||
|
||||
const { services, jobs, tools, frontends, other } = useMemo(() => {
|
||||
const s = { services: [] as typeof components, jobs: [] as typeof components, tools: [] as typeof components, frontends: [] as typeof components, other: [] as typeof components }
|
||||
for (const c of components ?? []) {
|
||||
if (c.category === "service") s.services!.push(c)
|
||||
else if (c.category === "job") s.jobs!.push(c)
|
||||
else if (c.category === "tool") s.tools!.push(c)
|
||||
else if (c.category === "frontend") s.frontends!.push(c)
|
||||
else s.other!.push(c)
|
||||
}
|
||||
return { services: s.services!, jobs: s.jobs!, tools: s.tools!, frontends: s.frontends!, other: s.other! }
|
||||
const { services, scheduled } = useMemo(() => {
|
||||
const svc = (components ?? []).filter((c) => c.managed && !c.schedule)
|
||||
const sch = (components ?? []).filter((c) => c.managed && c.schedule)
|
||||
return { services: svc, scheduled: sch }
|
||||
}, [components])
|
||||
|
||||
const statuses = statusResp?.statuses ?? []
|
||||
@@ -59,62 +53,13 @@ export function Dashboard() {
|
||||
{services.length > 0 && (
|
||||
<ServiceSection services={services} statuses={statuses} />
|
||||
)}
|
||||
{jobs.length > 0 && (
|
||||
<JobSection jobs={jobs} />
|
||||
{scheduled.length > 0 && (
|
||||
<ScheduledSection jobs={scheduled} statuses={statuses} />
|
||||
)}
|
||||
{tools.length > 0 && (
|
||||
<ToolSection tools={tools} />
|
||||
)}
|
||||
{frontends.length > 0 && (
|
||||
{(components ?? []).length > 0 && (
|
||||
<section>
|
||||
<SectionHeader category="frontend" />
|
||||
<div className="border border-[var(--border)] rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{frontends.map((fe) => (
|
||||
<tr key={fe.id} 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/${fe.id}`}
|
||||
className="font-medium hover:text-[var(--primary)] transition-colors"
|
||||
>
|
||||
{fe.id}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--muted)]">
|
||||
{fe.description ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{other.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader category="component" />
|
||||
<div className="border border-[var(--border)] rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{other.map((c) => (
|
||||
<tr key={c.id} 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/${c.id}`}
|
||||
className="font-medium hover:text-[var(--primary)] transition-colors"
|
||||
>
|
||||
{c.id}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--muted)]">
|
||||
{c.description ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<SectionHeader section="component" />
|
||||
<ComponentTable components={components ?? []} statuses={statuses} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useParams, Link } from "react-router-dom"
|
||||
import { ArrowLeft, Server } from "lucide-react"
|
||||
import { useNode } from "@/services/api/hooks"
|
||||
import { RoleBadge } from "@/components/RoleBadge"
|
||||
import { BehaviorBadge } from "@/components/BehaviorBadge"
|
||||
import { StackBadge } from "@/components/StackBadge"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function NodeDetailPage() {
|
||||
@@ -62,7 +63,8 @@ export function NodeDetailPage() {
|
||||
<thead>
|
||||
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left">
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Component</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Category</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Behavior</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Stack</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Runner</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Port</th>
|
||||
</tr>
|
||||
@@ -85,7 +87,10 @@ export function NodeDetailPage() {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<RoleBadge role={comp.category} />
|
||||
<BehaviorBadge behavior={comp.behavior} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<StackBadge stack={comp.stack} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--muted)]">
|
||||
{comp.runner ?? "—"}
|
||||
|
||||
@@ -7,7 +7,8 @@ export interface SystemdInfo {
|
||||
export interface ComponentSummary {
|
||||
id: string
|
||||
description: string | null
|
||||
category: string
|
||||
behavior: string | null
|
||||
stack: string | null
|
||||
runner: string | null
|
||||
port: number | null
|
||||
health_path: string | null
|
||||
|
||||
Reference in New Issue
Block a user