swarm-ui: add a jobs tab, and type JobqGraph as real TypeScript
New /jobs route in swarm-ui, reusing the shared JobqGraph component against swarm-controller's own GET /api/jobq/graph (same wire shape hive-c0re's dashboard already consumes, different endpoint, no fork). Converted JobqGraph.jsx to JobqGraph.tsx with real prop/wire types (mirrors hive_jobq_wire's GraphNode/GraphDep/State by hand) instead of a hand-maintained ambient .d.ts at the swarm-ui consumer side — the .d.ts would duplicate the prop list and drift from the source the moment the component's signature changes without the declaration being touched. Both dashboard (untyped consumer, esbuild strips types) and swarm-ui (tsc --noEmit) build/typecheck clean off the one file.
This commit is contained in:
parent
61977514c6
commit
527ed8f3e2
6 changed files with 134 additions and 31 deletions
|
|
@ -28,7 +28,7 @@
|
||||||
"./hive-menu.js": "./src/hive-menu/hive-menu.js",
|
"./hive-menu.js": "./src/hive-menu/hive-menu.js",
|
||||||
"./hive-warn.js": "./src/hive-warn/hive-warn.js",
|
"./hive-warn.js": "./src/hive-warn/hive-warn.js",
|
||||||
"./side-panel.js": "./src/side-panel/hive-side-panel.js",
|
"./side-panel.js": "./src/side-panel/hive-side-panel.js",
|
||||||
"./jobq-graph.js": "./src/jobq-graph/JobqGraph.jsx",
|
"./jobq-graph.js": "./src/jobq-graph/JobqGraph.tsx",
|
||||||
"./jobq-graph.css": "./src/jobq-graph/jobq-graph.css"
|
"./jobq-graph.css": "./src/jobq-graph/jobq-graph.css"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,20 @@
|
||||||
// JobqGraph.jsx — <JobqGraph>, a Preact component rendering any
|
// JobqGraph.tsx — <JobqGraph>, a Preact component rendering any
|
||||||
// hive_jobq graph from the wire shape GET /api/jobq/graph serves (any
|
// hive_jobq graph from the wire shape GET /api/jobq/graph serves (any
|
||||||
// endpoint serving `Vec<hive_jobq_wire::GraphNode>` works — see
|
// endpoint serving `Vec<hive_jobq_wire::GraphNode>` works — see
|
||||||
// hive-jobq-wire's README). Renders an indented state tree:
|
// hive-jobq-wire's README, whose types the interfaces below mirror by
|
||||||
// `payload.label` verbatim, `payload.data` as a generic key/value list.
|
// hand). Renders an indented state tree: `payload.label` verbatim,
|
||||||
// Light DOM, shared by the dashboard and swarm-ui — both esbuild
|
// `payload.data` as a generic key/value list. Light DOM, shared by the
|
||||||
// configs run `jsx: 'automatic', jsxImportSource: 'preact'`, so this
|
// dashboard and swarm-ui — both esbuild configs run `jsx: 'automatic',
|
||||||
// file is real JSX in either build, not hand-written `h()` calls.
|
// jsxImportSource: 'preact'`, and esbuild transpiles `.tsx` (types
|
||||||
|
// stripped, not checked) the same way regardless of whether the
|
||||||
|
// consuming package runs `tsc` itself — swarm-ui does (`npm run
|
||||||
|
// typecheck`), dashboard doesn't, both build clean off the same file.
|
||||||
|
//
|
||||||
|
// Real TypeScript rather than a hand-maintained ambient `.d.ts` at each
|
||||||
|
// TS consumer: a `declare module` block duplicates the prop list and
|
||||||
|
// drifts the moment this file's signature changes without the
|
||||||
|
// declaration being touched. Typing the source once means every
|
||||||
|
// consumer's typecheck is checking the real shape, not a copy of it.
|
||||||
//
|
//
|
||||||
// `cancellable` adds a per-node cancel button calling `onCancel(id)`
|
// `cancellable` adds a per-node cancel button calling `onCancel(id)`
|
||||||
// directly (a plain prop). `onUpdate(nodes)` fires after every fetch,
|
// directly (a plain prop). `onUpdate(nodes)` fires after every fetch,
|
||||||
|
|
@ -15,14 +24,14 @@
|
||||||
// Two ways to use this: JSX (swarm-ui, or any future dashboard page
|
// Two ways to use this: JSX (swarm-ui, or any future dashboard page
|
||||||
// that renders it directly) — `<JobqGraph endpoint="..." cancellable
|
// that renders it directly) — `<JobqGraph endpoint="..." cancellable
|
||||||
// onCancel={...} onUpdate={...} />`, a normal component. Or imperative
|
// onCancel={...} onUpdate={...} />`, a normal component. Or imperative
|
||||||
// mount (dashboard/src/builds.js, which stays plain `.js` — a `.jsx`
|
// mount (dashboard/src/builds.js, which stays plain `.js` — a `.tsx`
|
||||||
// call site still needs a JSX-aware file, mounting doesn't) —
|
// call site still needs a JSX-aware file, mounting doesn't) —
|
||||||
// `mountJobqGraph(container, props)` returns a `{ refresh(),
|
// `mountJobqGraph(container, props)` returns a `{ refresh(),
|
||||||
// update(props) }` handle.
|
// update(props) }` handle.
|
||||||
//
|
//
|
||||||
// Styles live in `@hive/shared/jobq-graph.css`, `@import`ed from a
|
// Styles live in `@hive/shared/jobq-graph.css`, `@import`ed from a
|
||||||
// page/component CSS file rather than imported here. This one stays
|
// page/component CSS file rather than imported here. This one stays
|
||||||
// necessary regardless of the JSX question above: dashboard bundles
|
// necessary regardless of the JSX/TS question above: dashboard bundles
|
||||||
// every page entry (including this component, pulled in transitively)
|
// every page entry (including this component, pulled in transitively)
|
||||||
// through one esbuild call whose `.css` loader is `text` — a handful of
|
// through one esbuild call whose `.css` loader is `text` — a handful of
|
||||||
// shadow-DOM components (modal.js, hive-btn.js) need their CSS as a
|
// shadow-DOM components (modal.js, hive-btn.js) need their CSS as a
|
||||||
|
|
@ -35,7 +44,45 @@
|
||||||
import { h, render } from 'preact';
|
import { h, render } from 'preact';
|
||||||
import { useState, useEffect, useCallback } from 'preact/hooks';
|
import { useState, useEffect, useCallback } from 'preact/hooks';
|
||||||
|
|
||||||
const STATE_GLYPH = {
|
// Mirrors `hive_jobq_wire::StateSchema` verbatim (variant names, no
|
||||||
|
// `rename_all`) — see that enum's own doc comment for why it's kept in
|
||||||
|
// an exhaustive match on the Rust side; this union is this file's
|
||||||
|
// equivalent contract.
|
||||||
|
type NodeState = 'Pending' | 'Running' | 'Finishing' | 'Done' | 'Failed' | 'Cancelled' | 'Skipped';
|
||||||
|
|
||||||
|
type TerminalState = 'Done' | 'Failed' | 'Cancelled' | 'Skipped';
|
||||||
|
|
||||||
|
// Mirrors `hive_jobq_wire::GraphDep` — externally tagged on `kind`,
|
||||||
|
// values are the Rust variant names verbatim.
|
||||||
|
type GraphDep =
|
||||||
|
| { kind: 'Node'; id: number; accepts: TerminalState[] }
|
||||||
|
| { kind: 'Resource'; name: string; count: number };
|
||||||
|
|
||||||
|
interface NodePayload {
|
||||||
|
label: string;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors `hive_jobq_wire::GraphNode`. `id`/`parent` are `WireId`
|
||||||
|
// (`u64` on the wire) — `number` here, same as the rest of this
|
||||||
|
// frontend treats wire ids; large enough ids losing precision in JS
|
||||||
|
// is a pre-existing, unrelated constraint, not something this
|
||||||
|
// conversion introduces.
|
||||||
|
export interface GraphNode {
|
||||||
|
id: number;
|
||||||
|
parent?: number;
|
||||||
|
state: NodeState;
|
||||||
|
deps?: GraphDep[];
|
||||||
|
error?: string;
|
||||||
|
payload: NodePayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TreeNode extends GraphNode {
|
||||||
|
_children: TreeNode[];
|
||||||
|
_waitsOn: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATE_GLYPH: Record<NodeState, string> = {
|
||||||
Pending: '⏸',
|
Pending: '⏸',
|
||||||
Running: '▶',
|
Running: '▶',
|
||||||
Finishing: '◐',
|
Finishing: '◐',
|
||||||
|
|
@ -48,15 +95,15 @@ const STATE_GLYPH = {
|
||||||
// Declaration order doubles as render order for the filter checkboxes —
|
// Declaration order doubles as render order for the filter checkboxes —
|
||||||
// matches `hive_jobq_wire::ALL_STATES` on the wire, so the row reads in
|
// matches `hive_jobq_wire::ALL_STATES` on the wire, so the row reads in
|
||||||
// the same lifecycle order the rollup endpoint counts in.
|
// the same lifecycle order the rollup endpoint counts in.
|
||||||
const ALL_STATES = Object.keys(STATE_GLYPH);
|
const ALL_STATES = Object.keys(STATE_GLYPH) as NodeState[];
|
||||||
|
|
||||||
// Product call: "default selection filters out skipped and done."
|
// Product call: "default selection filters out skipped and done."
|
||||||
const DEFAULT_HIDDEN_STATES = new Set(['Done', 'Skipped']);
|
const DEFAULT_HIDDEN_STATES = new Set<NodeState>(['Done', 'Skipped']);
|
||||||
|
|
||||||
// Non-terminal states a cancel button makes sense on. Finishing is
|
// Non-terminal states a cancel button makes sense on. Finishing is
|
||||||
// included — "own logic done, children still running" is still a subtree
|
// included — "own logic done, children still running" is still a subtree
|
||||||
// worth stopping early.
|
// worth stopping early.
|
||||||
const CANCELLABLE_STATES = new Set(['Pending', 'Running', 'Finishing']);
|
const CANCELLABLE_STATES = new Set<NodeState>(['Pending', 'Running', 'Finishing']);
|
||||||
|
|
||||||
// Build a parent/child tree from the flat wire array. `parent` (structural
|
// Build a parent/child tree from the flat wire array. `parent` (structural
|
||||||
// grouping) defines tree shape. Sibling order follows array order, which
|
// grouping) defines tree shape. Sibling order follows array order, which
|
||||||
|
|
@ -72,9 +119,11 @@ const CANCELLABLE_STATES = new Set(['Pending', 'Running', 'Finishing']);
|
||||||
// instead of silently dropping the edge. A dep naming an id outside this
|
// instead of silently dropping the edge. A dep naming an id outside this
|
||||||
// snapshot (a filtered view) or a `Resource`-kind dep has nothing to point
|
// snapshot (a filtered view) or a `Resource`-kind dep has nothing to point
|
||||||
// at and is simply not listed.
|
// at and is simply not listed.
|
||||||
function buildTree(nodes) {
|
function buildTree(nodes: GraphNode[]): TreeNode[] {
|
||||||
const byId = new Map(nodes.map((n) => [n.id, { ...n, _children: [] }]));
|
const byId = new Map<number, TreeNode>(
|
||||||
const roots = [];
|
nodes.map((n) => [n.id, { ...n, _children: [], _waitsOn: [] }]),
|
||||||
|
);
|
||||||
|
const roots: TreeNode[] = [];
|
||||||
for (const n of byId.values()) {
|
for (const n of byId.values()) {
|
||||||
const p = n.parent != null ? byId.get(n.parent) : null;
|
const p = n.parent != null ? byId.get(n.parent) : null;
|
||||||
if (p) p._children.push(n);
|
if (p) p._children.push(n);
|
||||||
|
|
@ -82,9 +131,9 @@ function buildTree(nodes) {
|
||||||
}
|
}
|
||||||
for (const n of byId.values()) {
|
for (const n of byId.values()) {
|
||||||
n._waitsOn = (n.deps || [])
|
n._waitsOn = (n.deps || [])
|
||||||
.filter((d) => d.kind === 'Node')
|
.filter((d): d is Extract<GraphDep, { kind: 'Node' }> => d.kind === 'Node')
|
||||||
.map((d) => byId.get(d.id))
|
.map((d) => byId.get(d.id))
|
||||||
.filter(Boolean)
|
.filter((dep): dep is TreeNode => dep != null)
|
||||||
.map((dep) => dep.payload.label);
|
.map((dep) => dep.payload.label);
|
||||||
}
|
}
|
||||||
return roots;
|
return roots;
|
||||||
|
|
@ -94,10 +143,12 @@ function buildTree(nodes) {
|
||||||
// — render it as a generic key/value list when it's a plain object (the
|
// — render it as a generic key/value list when it's a plain object (the
|
||||||
// only shape a host is expected to send; anything else falls back to a
|
// only shape a host is expected to send; anything else falls back to a
|
||||||
// single stringified row rather than silently dropping it).
|
// single stringified row rather than silently dropping it).
|
||||||
function DataList({ data }) {
|
function DataList({ data }: { data: unknown }) {
|
||||||
if (data == null) return null;
|
if (data == null) return null;
|
||||||
const isPlainObject = typeof data === 'object' && !Array.isArray(data);
|
const isPlainObject = typeof data === 'object' && !Array.isArray(data);
|
||||||
const entries = isPlainObject ? Object.entries(data) : [['data', data]];
|
const entries: [string, unknown][] = isPlainObject
|
||||||
|
? Object.entries(data as Record<string, unknown>)
|
||||||
|
: [['data', data]];
|
||||||
if (!entries.length) return null;
|
if (!entries.length) return null;
|
||||||
return (
|
return (
|
||||||
<dl class="jg-data">
|
<dl class="jg-data">
|
||||||
|
|
@ -111,7 +162,15 @@ function DataList({ data }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NodeView({ n, cancellable, onCancel }) {
|
function NodeView({
|
||||||
|
n,
|
||||||
|
cancellable,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
n: TreeNode;
|
||||||
|
cancellable: boolean;
|
||||||
|
onCancel?: (id: number) => void;
|
||||||
|
}) {
|
||||||
const glyph = STATE_GLYPH[n.state] || '?';
|
const glyph = STATE_GLYPH[n.state] || '?';
|
||||||
const showCancel = cancellable && CANCELLABLE_STATES.has(n.state);
|
const showCancel = cancellable && CANCELLABLE_STATES.has(n.state);
|
||||||
return (
|
return (
|
||||||
|
|
@ -142,7 +201,13 @@ function NodeView({ n, cancellable, onCancel }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FilterBar({ selectedStates, onToggle }) {
|
function FilterBar({
|
||||||
|
selectedStates,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
selectedStates: Set<NodeState>;
|
||||||
|
onToggle: (state: NodeState) => void;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div class="jg-filter">
|
<div class="jg-filter">
|
||||||
{ALL_STATES.map((state) => {
|
{ALL_STATES.map((state) => {
|
||||||
|
|
@ -163,7 +228,7 @@ function FilterBar({ selectedStates, onToggle }) {
|
||||||
// param — omitted entirely when every state is checked, so the
|
// param — omitted entirely when every state is checked, so the
|
||||||
// unfiltered default case sends the exact same request as before this
|
// unfiltered default case sends the exact same request as before this
|
||||||
// filter existed.
|
// filter existed.
|
||||||
function fetchUrl(endpoint, selectedStates) {
|
function fetchUrl(endpoint: string | undefined, selectedStates: Set<NodeState>): string | null {
|
||||||
if (!endpoint) return null;
|
if (!endpoint) return null;
|
||||||
if (selectedStates.size >= ALL_STATES.length) return endpoint;
|
if (selectedStates.size >= ALL_STATES.length) return endpoint;
|
||||||
const url = new URL(endpoint, window.location.origin);
|
const url = new URL(endpoint, window.location.origin);
|
||||||
|
|
@ -171,18 +236,26 @@ function fetchUrl(endpoint, selectedStates) {
|
||||||
return url.pathname + url.search;
|
return url.pathname + url.search;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface JobqGraphProps {
|
||||||
|
endpoint?: string;
|
||||||
|
cancellable?: boolean;
|
||||||
|
onUpdate?: (nodes: GraphNode[]) => void;
|
||||||
|
onCancel?: (id: number) => void;
|
||||||
|
refreshToken?: number;
|
||||||
|
}
|
||||||
|
|
||||||
// `refreshToken` is not read anywhere in the body — its only job is to
|
// `refreshToken` is not read anywhere in the body — its only job is to
|
||||||
// change identity so the effect below re-runs, giving a host (or
|
// change identity so the effect below re-runs, giving a host (or
|
||||||
// `mountJobqGraph`) an explicit "refetch now" lever without an
|
// `mountJobqGraph`) an explicit "refetch now" lever without an
|
||||||
// imperative ref into this component.
|
// imperative ref into this component.
|
||||||
export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, refreshToken = 0 }) {
|
export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, refreshToken = 0 }: JobqGraphProps) {
|
||||||
const [selectedStates, setSelectedStates] = useState(
|
const [selectedStates, setSelectedStates] = useState<Set<NodeState>>(
|
||||||
() => new Set(ALL_STATES.filter((s) => !DEFAULT_HIDDEN_STATES.has(s))),
|
() => new Set(ALL_STATES.filter((s) => !DEFAULT_HIDDEN_STATES.has(s))),
|
||||||
);
|
);
|
||||||
const [nodes, setNodes] = useState(null); // null = loading, [] = empty-but-loaded
|
const [nodes, setNodes] = useState<GraphNode[] | null>(null); // null = loading, [] = empty-but-loaded
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const toggleState = useCallback((state) => {
|
const toggleState = useCallback((state: NodeState) => {
|
||||||
setSelectedStates((prev) => {
|
setSelectedStates((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(state)) next.delete(state); else next.add(state);
|
if (next.has(state)) next.delete(state); else next.add(state);
|
||||||
|
|
@ -198,7 +271,7 @@ export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, r
|
||||||
try {
|
try {
|
||||||
const r = await fetch(url);
|
const r = await fetch(url);
|
||||||
if (!r.ok) throw new Error('http ' + r.status);
|
if (!r.ok) throw new Error('http ' + r.status);
|
||||||
const data = await r.json();
|
const data = (await r.json()) as GraphNode[];
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setNodes(data);
|
setNodes(data);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -239,13 +312,13 @@ export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, r
|
||||||
// write `<JobqGraph .../>` inline). Returns a handle: `.refresh()`
|
// write `<JobqGraph .../>` inline). Returns a handle: `.refresh()`
|
||||||
// (re-fetch with the current props) and `.update(props)` (merge new
|
// (re-fetch with the current props) and `.update(props)` (merge new
|
||||||
// props — e.g. a different `endpoint` — and re-render).
|
// props — e.g. a different `endpoint` — and re-render).
|
||||||
export function mountJobqGraph(container, initialProps) {
|
export function mountJobqGraph(container: Element, initialProps: JobqGraphProps) {
|
||||||
let props = initialProps;
|
let props = initialProps;
|
||||||
let token = 0;
|
let token = 0;
|
||||||
const draw = () => render(h(JobqGraph, { ...props, refreshToken: token }), container);
|
const draw = () => render(h(JobqGraph, { ...props, refreshToken: token }), container);
|
||||||
draw();
|
draw();
|
||||||
return {
|
return {
|
||||||
refresh() { token += 1; draw(); },
|
refresh() { token += 1; draw(); },
|
||||||
update(next) { props = { ...props, ...next }; draw(); },
|
update(next: Partial<JobqGraphProps>) { props = { ...props, ...next }; draw(); },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import { useEffect, useState } from 'preact/hooks';
|
||||||
import { Route, Switch } from 'wouter-preact';
|
import { Route, Switch } from 'wouter-preact';
|
||||||
import { Shell } from './shell/Shell.js';
|
import { Shell } from './shell/Shell.js';
|
||||||
import { ComponentsPage } from './pages/ComponentsPage.js';
|
import { ComponentsPage } from './pages/ComponentsPage.js';
|
||||||
|
import { JobsPage } from './pages/JobsPage.js';
|
||||||
import { Panel } from './ui/panel/Panel.js';
|
import { Panel } from './ui/panel/Panel.js';
|
||||||
import { StatusChip } from './ui/status-chip/StatusChip.js';
|
import { StatusChip } from './ui/status-chip/StatusChip.js';
|
||||||
import { Table, type TableColumn } from './ui/table/Table.js';
|
import { Table, type TableColumn } from './ui/table/Table.js';
|
||||||
|
|
@ -70,6 +71,7 @@ export function App() {
|
||||||
<Shell>
|
<Shell>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/" component={Home} />
|
<Route path="/" component={Home} />
|
||||||
|
<Route path="/jobs" component={JobsPage} />
|
||||||
<Route path="/components" component={ComponentsPage} />
|
<Route path="/components" component={ComponentsPage} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|
|
||||||
7
frontend/packages/swarm-ui/src/pages/JobsPage.css
Normal file
7
frontend/packages/swarm-ui/src/pages/JobsPage.css
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
/* JobsPage — wraps the shared JobqGraph component pointed at the
|
||||||
|
swarm-controller's own /api/jobq/graph endpoint (same wire shape
|
||||||
|
hive-c0re's dashboard consumes, see hive-jobq-wire's README). Styles
|
||||||
|
are @hive/shared's jobq-graph.css, @import'ed here rather than from
|
||||||
|
the component file itself — see JobqGraph.jsx's own comment for why
|
||||||
|
that split exists (esbuild's loader map is global per bundle call). */
|
||||||
|
@import "@hive/shared/jobq-graph.css";
|
||||||
20
frontend/packages/swarm-ui/src/pages/JobsPage.tsx
Normal file
20
frontend/packages/swarm-ui/src/pages/JobsPage.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
// <JobsPage> — the swarm-level job graph. Reuses the shared JobqGraph
|
||||||
|
// component (originally built for the per-hive dashboard's rebuild
|
||||||
|
// queue) pointed at swarm-controller's own GET /api/jobq/graph instead
|
||||||
|
// of hive-c0re's — same wire shape (Vec<hive_jobq_wire::GraphNode>),
|
||||||
|
// different endpoint, no component fork. swarm-controller's graph has
|
||||||
|
// no real node kinds wired in yet (see swarm-controller/src/main.rs's
|
||||||
|
// SwarmNodeKind/SwarmResourceKind), so this renders an empty tree today
|
||||||
|
// — the page exists so the wiring is in place before the first real
|
||||||
|
// swarm-level job (e.g. CreateAgent) lands.
|
||||||
|
import { JobqGraph } from '@hive/shared/jobq-graph.js';
|
||||||
|
import { Panel } from '../ui/panel/Panel.js';
|
||||||
|
import './JobsPage.css';
|
||||||
|
|
||||||
|
export function JobsPage() {
|
||||||
|
return (
|
||||||
|
<Panel title="jobs">
|
||||||
|
<JobqGraph endpoint="/api/jobq/graph" />
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ import './Shell.css';
|
||||||
|
|
||||||
const NAV_ITEMS: { href: string; label: string }[] = [
|
const NAV_ITEMS: { href: string; label: string }[] = [
|
||||||
{ href: '/', label: 'overview' },
|
{ href: '/', label: 'overview' },
|
||||||
|
{ href: '/jobs', label: 'jobs' },
|
||||||
{ href: '/components', label: 'components' },
|
{ href: '/components', label: 'components' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue