Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Summary } from "@/components/Summary"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { api, groupsOf, useNodes, type Node } from "@/lib/api"
import { loadConfig } from "@/lib/config"

type Me = { authed: boolean; github: boolean; site_name: string; public_page: boolean }

Expand Down Expand Up @@ -92,6 +93,7 @@ export default function App() {
const [dark, toggleTheme] = useTheme()
const [me, setMe] = useState<Me | null>(null)
const [meError, setMeError] = useState("")
const [config, setConfig] = useState<Record<string, unknown> | null>(null)
const { nodes, error, closed } = useNodes()
const [open, go] = useNodeRoute()
// The list's group tab, held here so it survives a visit to a node's page.
Expand All @@ -105,6 +107,9 @@ export default function App() {

useEffect(() => {
loadMe()
// Requested alongside /me, not after it. It never rejects: any failure
// yields the defaults.
void loadConfig().then(setConfig)
// Warmed here rather than left to Suspense, which requests the chunk only
// once a render reaches the detail view, itself waiting on /me. Without this
// the split trades its first paint for a full-page skeleton over the first
Expand Down Expand Up @@ -183,14 +188,20 @@ export default function App() {
节点不存在或未公开。<button className="underline" onClick={() => go(null)}>返回列表</button>
</p>
)
) : !nodes ? (
) : !nodes || !config ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-72" />
))}
</div>
) : (
<NodeList nodes={sorted} group={group} onGroup={setGroup} onOpen={go} />
<NodeList
nodes={sorted}
group={group}
summary={config.show_summary === true}
onGroup={setGroup}
onOpen={go}
/>
)}
</main>
</div>
Expand All @@ -199,10 +210,12 @@ export default function App() {

// Group tabs appear only once the operator has grouped something, so a hub
// without groups keeps the page it always had. The summary follows the tab.
function NodeList({ nodes, group, onGroup, onOpen }: {
function NodeList({ nodes, group, summary, onGroup, onOpen }: {
nodes: Node[]
/** null is every node, "" the ungrouped. */
group: string | null
/** The operator's `show_summary` setting. */
summary: boolean
onGroup: (group: string | null) => void
onOpen: (id: number) => void
}) {
Expand Down Expand Up @@ -242,7 +255,7 @@ function NodeList({ nodes, group, onGroup, onOpen }: {
))}
</div>
)}
<Summary nodes={shown} group={current} />
{summary && <Summary nodes={shown} group={current} />}
{nodes.length === 0 ? (
<p className="py-16 text-center text-sm text-muted-foreground">还没有节点</p>
) : (
Expand Down
16 changes: 9 additions & 7 deletions src/components/NodeCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,19 +115,21 @@ export function Country({ node }: { node: Node }) {
}

/**
* The distribution's logo in its brand colour. Mixed toward white on the dark
* theme, where AlmaLinux's black and CentOS's navy would otherwise vanish.
* The distribution's logo in its brand colour, standing in for the name, which
* shows on hover. Mixed toward white on the dark theme, where AlmaLinux's black
* and CentOS's navy would otherwise vanish.
*/
function OsIcon({ os }: { os: string }) {
const name = os.toLowerCase()
const icon = DISTROS.find(([key]) => name.includes(key))?.[1] ?? siLinux
return (
<svg
viewBox="0 0 24 24"
aria-hidden
role="img"
style={{ "--brand": `#${icon.hex}` } as CSSProperties}
className="size-3 shrink-0 fill-(--brand) dark:fill-[color-mix(in_oklab,var(--brand)_60%,white)]"
>
<title>{osName(os)}</title>
<path d={icon.path} />
</svg>
)
Expand Down Expand Up @@ -175,15 +177,15 @@ export function NodeCard({ node, onOpen }: { node: Node; onOpen: () => void }) {
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-1.5">
<h3 className="truncate font-medium" title={node.name}>{node.name}</h3>
<Country node={node} />
<h3 className="truncate font-medium" title={node.name}>{node.name}</h3>
</div>
<p className="mt-1 flex min-w-0 items-center gap-1 text-xs text-muted-foreground">
{node.os && <OsIcon os={node.os} />}
<span className="truncate">
{node.os ? osName(node.os) : "等待首次上报"}
{node.virt && node.virt !== "none" ? ` · ${node.virt}` : ""}
{node.arch ? ` · ${node.arch}` : ""}
{node.os
? [node.virt !== "none" && node.virt, node.arch].filter(Boolean).join(" · ")
: "等待首次上报"}
</span>
</p>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/NodeDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -333,8 +333,8 @@ export function NodeDetail({ node }: { node: Node }) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<h2 className="truncate text-lg font-medium">{node.name}</h2>
<Country node={node} />
<h2 className="truncate text-lg font-medium">{node.name}</h2>
<Status node={node} />
{node.agent_version && (
<Badge variant="outline" className="font-normal">
Expand Down
18 changes: 18 additions & 0 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import manifest from "../../theme.json"
import { api } from "@/lib/api"

/**
* The operator's settings over the defaults declared in theme.json, which the
* hub does not store. A saved value of another type -- left by an older version
* of this theme -- counts as unsaved. Any failure, a hub predating settings (404)
* included, renders the defaults rather than an error.
*/
export function loadConfig(): Promise<Record<string, unknown>> {
// ponytail: checks the type only; a select or a ranged number would need the
// option and min/max checks the panel applies.
const pick = (saved: Record<string, unknown>) =>
Object.fromEntries(
manifest.config.map((f) => [f.key, typeof saved[f.key] === typeof f.default ? saved[f.key] : f.default]),
)
return api<Record<string, unknown>>(`/themes/${manifest.short}/config`).then(pick).catch(() => pick({}))
}
11 changes: 10 additions & 1 deletion theme.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,14 @@
"description": "内置状态面板,黑白配色",
"version": "1.1.0",
"author": "stqfdyr",
"url": "https://github.com/monitor-probe/monitor-theme-default"
"url": "https://github.com/monitor-probe/monitor-theme-default",
"config": [
{
"key": "show_summary",
"type": "boolean",
"label": "显示汇总",
"default": true,
"help": "节点列表上方的节点数、最忙节点、流量和网速四格"
}
]
}
Loading