feat: workload registry UI
This commit is contained in:
@@ -10,6 +10,8 @@ import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
|
||||
import { useLicense } from "@/lib/useLicense";
|
||||
import { TagChips } from "@/components/servers/TagChips";
|
||||
import { ServerVulnerabilities } from "@/components/vulnerabilities/ServerVulnerabilities";
|
||||
import { WorkloadList } from "@/components/workloads/WorkloadList";
|
||||
import { useAuth } from "@/components/AuthProvider";
|
||||
|
||||
function statusVariant(status: ServerStatus) {
|
||||
switch (status) {
|
||||
@@ -311,6 +313,9 @@ export default function ServerDetailPage() {
|
||||
const [showUpdatesModal, setShowUpdatesModal] = useState(false);
|
||||
const [applySuccess, setApplySuccess] = useState(false);
|
||||
const { hasFeature } = useLicense();
|
||||
// Control actions and log reads are owner|admin server-side; the UI matches
|
||||
// so a member is not offered buttons the API will refuse.
|
||||
const { isAdmin } = useAuth();
|
||||
const consoleAllowed = hasFeature("console");
|
||||
|
||||
const {
|
||||
@@ -554,6 +559,10 @@ export default function ServerDetailPage() {
|
||||
<ServerVulnerabilities serverId={server.server_id} />
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3">
|
||||
<WorkloadList serverId={server.server_id} canControl={isAdmin} />
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<Card padding={false}>
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Badge, Button, Card, Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
|
||||
import { api, workloads } from "@/lib/api";
|
||||
|
||||
/*
|
||||
* The fleet view answers "which servers run image X", which is the reason the
|
||||
* snapshot is stored at all rather than fetched on demand and discarded.
|
||||
*/
|
||||
export default function WorkloadsPage() {
|
||||
const [image, setImage] = useState("");
|
||||
const [stack, setStack] = useState("");
|
||||
const [state, setState] = useState("");
|
||||
const [applied, setApplied] = useState<{ image?: string; stack?: string; state?: string }>({});
|
||||
|
||||
const hits = useQuery({
|
||||
queryKey: ["workloads", "fleet", applied],
|
||||
queryFn: () => workloads.search(applied),
|
||||
});
|
||||
|
||||
const servers = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
|
||||
|
||||
const hostnames = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
for (const s of servers.data ?? []) m.set(s.server_id, s.hostname);
|
||||
return m;
|
||||
}, [servers.data]);
|
||||
|
||||
const inputClass =
|
||||
"w-full rounded border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary focus:border-accent focus:outline-none";
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 lg:p-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-text-primary">Workloads</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">Containers and systemd services across the fleet, as last reported by each agent.</p>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<form
|
||||
className="grid gap-3 sm:grid-cols-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setApplied({ image: image.trim(), stack: stack.trim(), state: state.trim() });
|
||||
}}
|
||||
>
|
||||
<input className={inputClass} placeholder="image (exact)" value={image} onChange={(e) => setImage(e.target.value)} />
|
||||
<input className={inputClass} placeholder="stack" value={stack} onChange={(e) => setStack(e.target.value)} />
|
||||
<input className={inputClass} placeholder="state" value={state} onChange={(e) => setState(e.target.value)} />
|
||||
<Button type="submit" variant="primary">
|
||||
Search
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card padding={false}>
|
||||
{hits.isLoading ? (
|
||||
<p className="px-6 py-5 text-sm text-text-secondary">Loading…</p>
|
||||
) : (hits.data ?? []).length === 0 ? (
|
||||
<p className="px-6 py-5 text-sm text-text-secondary">No workloads match.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Server</Th>
|
||||
<Th>Workload</Th>
|
||||
<Th>Kind</Th>
|
||||
<Th>State</Th>
|
||||
<Th>Image</Th>
|
||||
<Th>Stack</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{(hits.data ?? []).map((h) => (
|
||||
<Tr key={`${h.server_id}:${h.workload.kind}:${h.workload.id}`}>
|
||||
<Td>
|
||||
<Link href={`/servers/${h.server_id}`} className="text-accent hover:underline">
|
||||
{hostnames.get(h.server_id) ?? h.server_id}
|
||||
</Link>
|
||||
</Td>
|
||||
<Td className="font-mono text-xs">{h.workload.name}</Td>
|
||||
<Td>
|
||||
<Badge variant="neutral">{h.workload.kind}</Badge>
|
||||
</Td>
|
||||
<Td>{h.workload.state}</Td>
|
||||
<Td className="font-mono text-xs">{h.workload.image ?? "—"}</Td>
|
||||
<Td>{h.workload.stack ?? "—"}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -133,10 +133,23 @@ function ShieldIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function WorkloadIcon() {
|
||||
return (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
|
||||
{ href: "/monitors", label: "Monitors", icon: <MonitorIcon /> },
|
||||
{ href: "/vulnerabilities", label: "Vulnerabilities", icon: <ShieldIcon /> },
|
||||
{ href: "/workloads", label: "Workloads", icon: <WorkloadIcon /> },
|
||||
{ href: "/keys", label: "SSH Keys", icon: <KeyIcon /> },
|
||||
{ href: "/secrets", label: "Secrets", icon: <SecretIcon /> },
|
||||
{ href: "/workflows", label: "Workflows", icon: <WorkflowIcon /> },
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Modal } from "@/components/ui";
|
||||
import { workloads, type WorkloadKind } from "@/lib/api";
|
||||
|
||||
/*
|
||||
* A bounded snapshot, not a follow. The browser console already offers a real
|
||||
* terminal on the same server where `docker logs -f` works properly, with its
|
||||
* own scrollback and cancellation.
|
||||
*/
|
||||
export function LogDialog({
|
||||
serverId,
|
||||
kind,
|
||||
id,
|
||||
name,
|
||||
onClose,
|
||||
}: {
|
||||
serverId: string;
|
||||
kind: WorkloadKind;
|
||||
id: string;
|
||||
name: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const pre = useRef<HTMLPreElement>(null);
|
||||
|
||||
const logs = useQuery({
|
||||
queryKey: ["workload-logs", serverId, kind, id],
|
||||
queryFn: () => workloads.logs(serverId, kind, id),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// Newest output is the point of the snapshot, so it opens at the bottom.
|
||||
useEffect(() => {
|
||||
if (logs.data && pre.current) pre.current.scrollTop = pre.current.scrollHeight;
|
||||
}, [logs.data]);
|
||||
|
||||
return (
|
||||
<Modal open title={`Logs · ${name}`} onClose={onClose} wide>
|
||||
{logs.isLoading && <p className="text-sm text-text-secondary">Reading logs from the agent…</p>}
|
||||
|
||||
{logs.isError && <p className="text-sm text-danger">{(logs.error as Error).message}</p>}
|
||||
|
||||
{logs.data && (
|
||||
<>
|
||||
{/* Stated, not implied: a truncated log must never be read as
|
||||
a complete one. */}
|
||||
{logs.data.truncated && (
|
||||
<p className="mb-3 rounded border border-warning/50 px-3 py-2 text-xs text-warning">
|
||||
Output was capped at 500 lines or 256KB, whichever came first. Older lines are not shown.
|
||||
</p>
|
||||
)}
|
||||
<pre ref={pre} className="max-h-[55dvh] overflow-auto rounded border border-border bg-well p-3 font-mono text-xs text-text-primary">
|
||||
{logs.data.text || "(no output)"}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button, Card } from "@/components/ui";
|
||||
import { workloads, type Workload, type WorkloadAction, type WorkloadKind } from "@/lib/api";
|
||||
import { WorkloadRow } from "./WorkloadRow";
|
||||
import { LogDialog } from "./LogDialog";
|
||||
|
||||
function relativeAge(iso?: string): string {
|
||||
if (!iso) return "never";
|
||||
const secs = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (secs < 60) return `${Math.round(secs)}s ago`;
|
||||
if (secs < 3600) return `${Math.round(secs / 60)}m ago`;
|
||||
return `${Math.round(secs / 3600)}h ago`;
|
||||
}
|
||||
|
||||
/** Compose stacks first, grouped under the stack name; then loose containers;
|
||||
* then units. Not cosmetic: a stack is one thing to an operator even when it
|
||||
* is six containers, and a flat list turns one decision into six rows. */
|
||||
function group(list: Workload[]) {
|
||||
const stacks = new Map<string, Workload[]>();
|
||||
const loose: Workload[] = [];
|
||||
const units: Workload[] = [];
|
||||
|
||||
for (const w of list) {
|
||||
if (w.kind === "unit") units.push(w);
|
||||
else if (w.stack) stacks.set(w.stack, [...(stacks.get(w.stack) ?? []), w]);
|
||||
else loose.push(w);
|
||||
}
|
||||
|
||||
const byName = (a: Workload, b: Workload) => a.name.localeCompare(b.name);
|
||||
return {
|
||||
stacks: [...stacks.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([name, items]) => [name, items.sort(byName)] as const),
|
||||
loose: loose.sort(byName),
|
||||
units: units.sort(byName),
|
||||
};
|
||||
}
|
||||
|
||||
export function WorkloadList({ serverId, canControl }: { serverId: string; canControl: boolean }) {
|
||||
const qc = useQueryClient();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [logTarget, setLogTarget] = useState<Workload | null>(null);
|
||||
|
||||
const snapshot = useQuery({
|
||||
queryKey: ["workloads", serverId],
|
||||
queryFn: () => workloads.forServer(serverId),
|
||||
});
|
||||
|
||||
const refresh = useMutation({
|
||||
mutationFn: () => workloads.refresh(serverId),
|
||||
// The refresh returns no data — the agent reports through the normal
|
||||
// path, so the only correct move is to refetch the stored document.
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
setTimeout(() => qc.invalidateQueries({ queryKey: ["workloads", serverId] }), 1500);
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const control = useMutation({
|
||||
mutationFn: ({ w, action }: { w: Workload; action: WorkloadAction }) => workloads.control(serverId, w.kind as WorkloadKind, w.id, action),
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
qc.invalidateQueries({ queryKey: ["workloads", serverId] });
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
// Opening the panel asks for a fresh list: this page carries a Restart
|
||||
// button, and a stale row is a wrong action aimed at something already dead.
|
||||
useEffect(() => {
|
||||
refresh.mutate();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [serverId]);
|
||||
|
||||
const data = snapshot.data;
|
||||
const grouped = useMemo(() => group(data?.workloads ?? []), [data]);
|
||||
|
||||
const row = (w: Workload) => (
|
||||
<WorkloadRow
|
||||
key={`${w.kind}:${w.id}`}
|
||||
workload={w}
|
||||
canControl={canControl}
|
||||
busy={control.isPending}
|
||||
onAction={(action) => control.mutate({ w, action })}
|
||||
onLogs={() => setLogTarget(w)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card padding={false}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-6 py-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-primary">Workloads</h2>
|
||||
<p className="mt-0.5 text-xs text-text-secondary">Collected {relativeAge(data?.collected_at)}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" loading={refresh.isPending} onClick={() => refresh.mutate()}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4">
|
||||
{error && <p className="mb-3 text-sm text-danger">{error}</p>}
|
||||
|
||||
{snapshot.isLoading ? (
|
||||
<p className="text-sm text-text-secondary">Loading…</p>
|
||||
) : !data ? (
|
||||
<p className="text-sm text-text-secondary">Nothing reported yet. Agents report every 60 seconds, on Linux only.</p>
|
||||
) : (
|
||||
<div className="space-y-2 text-sm">
|
||||
{/* Docker absent is the common case on a fleet built
|
||||
around SSH keys, and is not a fault. Installed but
|
||||
not responding is a different problem, so it reads
|
||||
differently. */}
|
||||
{data.docker_error ? (
|
||||
<p className="text-warning">Docker is installed but not responding: {data.docker_error}</p>
|
||||
) : !data.docker_ok ? (
|
||||
<p className="text-text-secondary">Docker is not in use on this server.</p>
|
||||
) : null}
|
||||
|
||||
{data.systemd_error ? (
|
||||
<p className="text-warning">systemd could not be read: {data.systemd_error}</p>
|
||||
) : !data.systemd_ok ? (
|
||||
<p className="text-text-secondary">systemd is not in use on this server.</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{grouped.stacks.map(([stack, items]) => (
|
||||
<div key={stack}>
|
||||
<div className="border-y border-border bg-surface-2 px-6 py-2 text-xs font-medium uppercase tracking-[0.08em] text-text-secondary">
|
||||
stack · {stack}
|
||||
</div>
|
||||
{items.map(row)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{grouped.loose.length > 0 && (
|
||||
<div>
|
||||
<div className="border-y border-border bg-surface-2 px-6 py-2 text-xs font-medium uppercase tracking-[0.08em] text-text-secondary">containers</div>
|
||||
{grouped.loose.map(row)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{grouped.units.length > 0 && (
|
||||
<div>
|
||||
<div className="border-y border-border bg-surface-2 px-6 py-2 text-xs font-medium uppercase tracking-[0.08em] text-text-secondary">services</div>
|
||||
{grouped.units.map(row)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logTarget && (
|
||||
<LogDialog
|
||||
serverId={serverId}
|
||||
kind={logTarget.kind as WorkloadKind}
|
||||
id={logTarget.id}
|
||||
name={logTarget.name}
|
||||
onClose={() => setLogTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { Badge, Button } from "@/components/ui";
|
||||
import type { Workload, WorkloadAction } from "@/lib/api";
|
||||
|
||||
/*
|
||||
* Container and unit states are kept in their own vocabularies on purpose — a
|
||||
* failed unit and an exited container mean different things. Colour never
|
||||
* carries the state on its own: Badge already pairs a dot with the word.
|
||||
*/
|
||||
function stateVariant(w: Workload): "success" | "warning" | "danger" | "neutral" {
|
||||
if (w.kind === "container") {
|
||||
switch (w.state) {
|
||||
case "running":
|
||||
return w.health === "unhealthy" ? "danger" : "success";
|
||||
case "restarting":
|
||||
case "paused":
|
||||
case "created":
|
||||
return "warning";
|
||||
case "dead":
|
||||
case "exited":
|
||||
return "danger";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
}
|
||||
switch (w.state) {
|
||||
case "active":
|
||||
return "success";
|
||||
case "activating":
|
||||
case "reloading":
|
||||
return "warning";
|
||||
case "failed":
|
||||
return "danger";
|
||||
case "inactive":
|
||||
return "neutral";
|
||||
default:
|
||||
return "neutral";
|
||||
}
|
||||
}
|
||||
|
||||
const ACTIONS: WorkloadAction[] = ["start", "stop", "restart"];
|
||||
|
||||
export function WorkloadRow({
|
||||
workload,
|
||||
canControl,
|
||||
busy,
|
||||
onAction,
|
||||
onLogs,
|
||||
}: {
|
||||
workload: Workload;
|
||||
canControl: boolean;
|
||||
busy: boolean;
|
||||
onAction: (action: WorkloadAction) => void;
|
||||
onLogs: () => void;
|
||||
}) {
|
||||
const w = workload;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 border-b border-border px-6 py-3 last:border-b-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate font-mono text-sm text-text-primary">{w.name}</span>
|
||||
<Badge variant={stateVariant(w)}>{w.state}</Badge>
|
||||
{w.health && <Badge variant={w.health === "healthy" ? "success" : "warning"}>{w.health}</Badge>}
|
||||
{!!w.restarts && w.restarts > 0 && <Badge variant="warning">{w.restarts} restarts</Badge>}
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs text-text-secondary">
|
||||
{w.kind === "container" ? w.image || "no image" : "systemd unit"}
|
||||
{w.ports && w.ports.length > 0 && <span className="ml-2 font-mono">{w.ports.join(" ")}</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
{canControl &&
|
||||
ACTIONS.map((a) => (
|
||||
<Button
|
||||
key={a}
|
||||
size="sm"
|
||||
variant={a === "stop" ? "danger" : "secondary"}
|
||||
/* Protected rows show the action disabled with the
|
||||
reason rather than offering a button whose refusal
|
||||
the agent has already told us about. */
|
||||
disabled={w.protected || busy}
|
||||
title={w.protected ? "This workload runs the Vantage agent and cannot be controlled from here" : undefined}
|
||||
onClick={() => onAction(a)}
|
||||
>
|
||||
{a}
|
||||
</Button>
|
||||
))}
|
||||
{canControl && (
|
||||
<Button size="sm" variant="ghost" onClick={onLogs}>
|
||||
Logs
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1054,6 +1054,79 @@ export const vulnerabilities = {
|
||||
},
|
||||
};
|
||||
|
||||
export type WorkloadKind = "container" | "unit";
|
||||
export type WorkloadAction = "start" | "stop" | "restart";
|
||||
|
||||
/** One container or one systemd unit.
|
||||
*
|
||||
* `state` is deliberately not a shared vocabulary across the two kinds:
|
||||
* containers report running/exited/paused/restarting/created, units report
|
||||
* active/inactive/failed/activating. A failed unit and an exited container
|
||||
* mean different things. */
|
||||
export interface Workload {
|
||||
kind: WorkloadKind;
|
||||
id: string;
|
||||
name: string;
|
||||
state: string;
|
||||
health?: string;
|
||||
image?: string;
|
||||
stack?: string;
|
||||
ports?: string[];
|
||||
restarts?: number;
|
||||
started_at?: string;
|
||||
protected: boolean;
|
||||
}
|
||||
|
||||
export interface ServerWorkloads {
|
||||
server_id: string;
|
||||
hash?: string;
|
||||
workloads: Workload[];
|
||||
collected_at?: string;
|
||||
/** false with no error means "Docker not in use here", which is not a
|
||||
* fault. With an error it means installed but not responding. */
|
||||
docker_ok: boolean;
|
||||
docker_error?: string;
|
||||
systemd_ok: boolean;
|
||||
systemd_error?: string;
|
||||
}
|
||||
|
||||
export interface WorkloadHit {
|
||||
server_id: string;
|
||||
workload: Workload;
|
||||
}
|
||||
|
||||
export const workloads = {
|
||||
forServer(serverId: string): Promise<ServerWorkloads> {
|
||||
return request<ServerWorkloads>(`/servers/${serverId}/workloads`);
|
||||
},
|
||||
|
||||
refresh(serverId: string): Promise<{ message: string }> {
|
||||
return request<{ message: string }>(`/servers/${serverId}/workloads/refresh`, { method: "POST" });
|
||||
},
|
||||
|
||||
control(serverId: string, kind: WorkloadKind, id: string, action: WorkloadAction): Promise<{ message: string }> {
|
||||
return request<{ message: string }>(
|
||||
`/servers/${serverId}/workloads/${encodeURIComponent(id)}/action`,
|
||||
{ method: "POST", body: JSON.stringify({ kind, action }) },
|
||||
);
|
||||
},
|
||||
|
||||
logs(serverId: string, kind: WorkloadKind, id: string, tail = 500): Promise<{ text: string; truncated: boolean }> {
|
||||
return request<{ text: string; truncated: boolean }>(
|
||||
`/servers/${serverId}/workloads/${encodeURIComponent(id)}/logs?kind=${kind}&tail=${tail}`,
|
||||
);
|
||||
},
|
||||
|
||||
search(params?: { image?: string; stack?: string; state?: string }): Promise<WorkloadHit[]> {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.image) q.set("image", params.image);
|
||||
if (params?.stack) q.set("stack", params.stack);
|
||||
if (params?.state) q.set("state", params.state);
|
||||
const qs = q.toString();
|
||||
return request<WorkloadHit[]>(`/workloads${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
};
|
||||
|
||||
// `request` already prefixes /api, so these paths do not repeat it.
|
||||
export const licence = {
|
||||
get(): Promise<LicenseInfo> {
|
||||
|
||||
Reference in New Issue
Block a user