дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
feat(docker): add Services dashboards, layers, processes, view filters and log editor tabs (#2246)
Цей коміт міститься в:
1 джерело
a206ac0ca5
коміт
c5124b809d
52 files changed
+3679
-137
No files matched your search
@@ -228,3 +228,280 @@
|
||||
border: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* Dashboards, Layers & Processes (Phase 1) */
|
||||
.dashboard {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-bottom: 12px;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
.dashboardActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sectionBanner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 5px 12px;
|
||||
font-size: var(--fs-small);
|
||||
font-weight: 600;
|
||||
color: var(--fg-secondary);
|
||||
background: var(--bg-elev);
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.propList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.propRow {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
padding: 5px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.propName {
|
||||
color: var(--fg-secondary);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.propVal {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--fg);
|
||||
}
|
||||
.inlineRow {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-code);
|
||||
}
|
||||
.codeInline {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--bg-elev);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-code);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.mutedMeta {
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.statusBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-small);
|
||||
font-size: var(--fs-small);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.badgeOk {
|
||||
color: var(--success);
|
||||
background: color-mix(in srgb, var(--success) 16%, transparent);
|
||||
}
|
||||
.badgeWarn {
|
||||
color: var(--warning);
|
||||
background: color-mix(in srgb, var(--warning) 16%, transparent);
|
||||
}
|
||||
.badgeDanger {
|
||||
color: var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 16%, transparent);
|
||||
}
|
||||
.badgeMuted {
|
||||
color: var(--fg-secondary);
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
.statusDot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
.dotOk {
|
||||
background: var(--success);
|
||||
}
|
||||
.dotMuted {
|
||||
background: var(--fg-subtle);
|
||||
}
|
||||
.metricBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-width: 320px;
|
||||
}
|
||||
.lineProgress {
|
||||
height: 5px;
|
||||
width: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-elev);
|
||||
overflow: hidden;
|
||||
}
|
||||
.lineProgressFill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.progressNormal {
|
||||
background: var(--accent);
|
||||
}
|
||||
.progressWarning {
|
||||
background: var(--warning);
|
||||
}
|
||||
.progressDanger {
|
||||
background: var(--danger);
|
||||
}
|
||||
.chipGroup {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.chipGroupInline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.entityChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-small);
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--bg-elev);
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: var(--fs-small);
|
||||
cursor: pointer;
|
||||
}
|
||||
.entityChip:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
span.entityChip {
|
||||
cursor: default;
|
||||
}
|
||||
button.entityChip:not(:disabled):hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.portChipLink {
|
||||
color: var(--link);
|
||||
}
|
||||
.chipMeta {
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.emptySection {
|
||||
padding: 8px 12px;
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.kvTable {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.kvRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 240px) 1fr;
|
||||
gap: 10px;
|
||||
padding: 4px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-code);
|
||||
}
|
||||
.kvKey {
|
||||
color: var(--fg-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.kvVal {
|
||||
color: var(--fg);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.dataTable {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.dataRow {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(120px, 1fr) minmax(140px, 1.3fr) 56px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.dataHeader {
|
||||
color: var(--fg-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.subToolbar {
|
||||
height: 28px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 8px 0 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.subToolbarTitle {
|
||||
color: var(--fg-secondary);
|
||||
font-size: var(--fs-small);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tableScroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
.gridTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.gridTable th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
text-align: left;
|
||||
padding: 5px 10px;
|
||||
color: var(--fg-secondary);
|
||||
background: var(--bg-elev);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gridTable td {
|
||||
padding: 4px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
.layerCmd {
|
||||
max-width: 520px;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-code);
|
||||
}
|
||||
.layerSizeCell {
|
||||
min-width: 96px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
useServicesTree,
|
||||
} from "./tree";
|
||||
import styles from "./Services.module.css";
|
||||
import { viewFilterMenu } from "./ops";
|
||||
import { selectionActions, selectionMenu, selectedServices } from "./selection";
|
||||
|
||||
export const SERVICES_VIEW_FOCUS = "servicesViewFocus";
|
||||
@@ -88,6 +89,7 @@ export function ServicesTreeView() {
|
||||
const selectedKeys = useServicesTree((s) => s.selectedKeys);
|
||||
const expanded = useServicesTree((s) => s.expanded);
|
||||
const addRef = useRef<HTMLButtonElement>(null);
|
||||
const filterRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const roots = useMemo(() => orderedRoots(rootsByProvider, providers), [rootsByProvider, providers]);
|
||||
const index = useMemo(() => indexTree(rootsByProvider), [rootsByProvider]);
|
||||
@@ -181,6 +183,18 @@ export function ServicesTreeView() {
|
||||
disabled={!roots.length}
|
||||
onClick={() => setExpanded(new Set())}
|
||||
/>
|
||||
<IconButton
|
||||
ref={filterRef}
|
||||
icon="filter"
|
||||
size="small"
|
||||
iconSize={16}
|
||||
title="View Options"
|
||||
disabled={!roots.length}
|
||||
onClick={() => {
|
||||
const r = filterRef.current?.getBoundingClientRect();
|
||||
showContextMenu({ x: r?.left ?? 140, y: (r?.bottom ?? 100) + 2 }, viewFilterMenu());
|
||||
}}
|
||||
/>
|
||||
{actions.length > 0 && <span className={styles.sep} />}
|
||||
{actions.map((a) => (
|
||||
<IconButton
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -10,6 +10,7 @@ import { Channel } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
DockerAvailability,
|
||||
DockerConnection,
|
||||
DockerContainer,
|
||||
DockerEndpoint,
|
||||
DockerLogLine,
|
||||
DockerObject,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
import { confirm } from "@/workbench/messages";
|
||||
import { openConnectionDialog, openRegistryDialog } from "./dialogs";
|
||||
import {
|
||||
DEFAULT_DOCKER_FILTERS,
|
||||
DOCKER_KEY,
|
||||
childrenOf,
|
||||
connectionKey,
|
||||
@@ -40,9 +42,20 @@ import {
|
||||
registryKey,
|
||||
rootNodes,
|
||||
treeKey,
|
||||
type DockerFilters,
|
||||
type DockerTarget,
|
||||
type ServiceNode,
|
||||
} from "./model";
|
||||
import {
|
||||
ComposeProjectDashboard,
|
||||
ContainerDashboard,
|
||||
ContainerProcessesView,
|
||||
ImageDashboard,
|
||||
ImageLayersView,
|
||||
NetworkDashboard,
|
||||
VolumeDashboard,
|
||||
} from "./dashboard";
|
||||
import { openContainerLogInEditor } from "./logEditor";
|
||||
import {
|
||||
canRemove,
|
||||
canRestart,
|
||||
@@ -68,6 +81,7 @@ import {
|
||||
useServices,
|
||||
watch,
|
||||
type ConnectionState,
|
||||
type DetailTab,
|
||||
} from "./store";
|
||||
import { useServicesTree } from "./tree";
|
||||
import styles from "./Services.module.css";
|
||||
@@ -101,11 +115,12 @@ export function dockerItems(
|
||||
connections: readonly DockerConnection[],
|
||||
registries: readonly DockerRegistry[],
|
||||
conns: Record<string, ConnectionState>,
|
||||
filters: DockerFilters = DEFAULT_DOCKER_FILTERS,
|
||||
): ServiceTreeItem[] {
|
||||
const items: ServiceTreeItem[] = connections.map((c) => {
|
||||
const state = conns[c.id] ?? { snapshot: null, loading: false, error: null };
|
||||
const snap = state.snapshot;
|
||||
const children = childrenOf(snap);
|
||||
const children = childrenOf(snap, filters);
|
||||
const convert = (n: ServiceNode): ServiceTreeItem => {
|
||||
const kids = children(n);
|
||||
return {
|
||||
@@ -121,13 +136,14 @@ export function dockerItems(
|
||||
return {
|
||||
key: connectionKey(c.id),
|
||||
label: c.name,
|
||||
icon: "file-docker",
|
||||
icon: snap?.availability.kind === "ready" ? "docker-connected" : "file-docker",
|
||||
detail: connectionDetail(state),
|
||||
dim: snap?.availability.kind !== "ready",
|
||||
children: children(root)?.map(convert) ?? [],
|
||||
};
|
||||
});
|
||||
for (const r of registries) items.push({ key: registryKey(r.id), label: r.name, icon: "package", detail: r.address });
|
||||
for (const r of registries)
|
||||
items.push({ key: registryKey(r.id), label: r.name, icon: "docker-registry", detail: r.address });
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -205,7 +221,7 @@ function actions(item: ServiceTreeItem): ServiceItemAction[] {
|
||||
disabled: !container || !isRunning(container),
|
||||
run: () => execShell(t),
|
||||
},
|
||||
{ id: "pull", title: "Pull Image…", icon: "git-pull", run: () => void pullPrompt(t.conn) },
|
||||
{ id: "pull", title: "Pull Image…", icon: "docker-pull", run: () => void pullPrompt(t.conn) },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -275,14 +291,23 @@ export function DockerTaskBanner() {
|
||||
}
|
||||
|
||||
/** Follows a container's log while shown. */
|
||||
function LogView({ conn, id, running }: { conn: string; id: string; running: boolean }) {
|
||||
function LogView({ conn, container, running }: { conn: string; container: DockerContainer; running: boolean }) {
|
||||
const tx = useTx();
|
||||
const id = container.id;
|
||||
return (
|
||||
<div className={styles.detailBody}>
|
||||
<LogPane
|
||||
sourceKey={`${conn}:${id}:${running}`}
|
||||
live={running}
|
||||
toggles={[{ id: "timestamps", label: "Timestamps" }]}
|
||||
actions={
|
||||
<IconButton
|
||||
icon="external-link"
|
||||
size="small"
|
||||
title="Open Log in Editor"
|
||||
onClick={() => openContainerLogInEditor(conn, container)}
|
||||
/>
|
||||
}
|
||||
ariaLabel={tx("Container log")}
|
||||
start={(onLine, onError, on) => {
|
||||
const request = nextRequestId();
|
||||
@@ -300,21 +325,43 @@ function LogView({ conn, id, running }: { conn: string; id: string; running: boo
|
||||
);
|
||||
}
|
||||
|
||||
function InspectView({ conn, object, id }: { conn: string; object: DockerObject; id: string }) {
|
||||
const tx = useTx();
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
function useObjectInspect(conn: string, object: DockerObject | null, id: string | null) {
|
||||
const [state, setState] = useState<{ key: string; text: string | null; error: string | null }>({
|
||||
key: "",
|
||||
text: null,
|
||||
error: null,
|
||||
});
|
||||
const currentKey = object && id ? `${conn}:${object}:${id}` : "";
|
||||
useEffect(() => {
|
||||
if (!object || !id) return;
|
||||
const reqKey = `${conn}:${object}:${id}`;
|
||||
let alive = true;
|
||||
call(commands.dockerInspect(endpointOf(conn), object, id))
|
||||
.then((t) => alive && setText(t))
|
||||
.catch((e) => alive && setError(errorMessage(e)));
|
||||
.then((t) => alive && setState({ key: reqKey, text: t, error: null }))
|
||||
.catch((e) => alive && setState({ key: reqKey, text: null, error: errorMessage(e) }));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [conn, object, id]);
|
||||
return state.key === currentKey ? { text: state.text, error: state.error } : { text: null, error: null };
|
||||
}
|
||||
|
||||
function InspectView({ text, error }: { text: string | null; error: string | null }) {
|
||||
const tx = useTx();
|
||||
return (
|
||||
<div className={styles.detailBody}>
|
||||
<div className={styles.subToolbar}>
|
||||
<span className={styles.subToolbarTitle}>
|
||||
<T>Inspect JSON</T>
|
||||
</span>
|
||||
<IconButton
|
||||
icon="copy"
|
||||
size="small"
|
||||
title="Copy"
|
||||
disabled={!text}
|
||||
onClick={() => text && void navigator.clipboard?.writeText(text)}
|
||||
/>
|
||||
</div>
|
||||
<pre
|
||||
tabIndex={0}
|
||||
onKeyDown={copySelectionOnKeyDown}
|
||||
@@ -362,10 +409,26 @@ function DockerDetail({ item }: { item: ServiceTreeItem }) {
|
||||
const state = useServices((s) => (parsed ? connectionState(parsed.conn, s) : null));
|
||||
const target = resolveTarget(item.key);
|
||||
const registry = settings?.registries.find((r) => registryKey(r.id) === item.key);
|
||||
const node = target?.node ?? null;
|
||||
const object: DockerObject | null =
|
||||
node && (node.kind === "container" || node.kind === "image" || node.kind === "volume" || node.kind === "network")
|
||||
? node.kind
|
||||
: null;
|
||||
const objectId =
|
||||
node?.kind === "container"
|
||||
? node.container.id
|
||||
: node?.kind === "image"
|
||||
? node.image.id
|
||||
: node?.kind === "volume"
|
||||
? node.volume.name
|
||||
: node?.kind === "network"
|
||||
? node.network.id
|
||||
: null;
|
||||
const inspect = useObjectInspect(parsed?.conn ?? "default", object, objectId);
|
||||
|
||||
if (registry) return <RegistryDetail registry={registry} />;
|
||||
if (!target || !parsed || !state) return <EmptyState title="Select service to view details" />;
|
||||
if (!target || !parsed || !state || !node) return <EmptyState title="Select service to view details" />;
|
||||
const conn = connectionOf(parsed.conn);
|
||||
const node = target.node;
|
||||
const snapshot = state.snapshot;
|
||||
if (!snapshot)
|
||||
return (
|
||||
@@ -379,7 +442,14 @@ function DockerDetail({ item }: { item: ServiceTreeItem }) {
|
||||
);
|
||||
if (snapshot.availability.kind !== "ready")
|
||||
return <Unavailable availability={snapshot.availability} conn={parsed.conn} />;
|
||||
if (node.kind === "docker" || node.kind === "group" || node.kind === "compose") {
|
||||
if (node.kind === "compose") {
|
||||
return (
|
||||
<div className={styles.detailPane}>
|
||||
<ComposeProjectDashboard conn={parsed.conn} project={node.project} containers={node.containers} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (node.kind === "docker" || node.kind === "group") {
|
||||
const a = snapshot.availability;
|
||||
return (
|
||||
<div className={styles.detailPane}>
|
||||
@@ -388,9 +458,7 @@ function DockerDetail({ item }: { item: ServiceTreeItem }) {
|
||||
<span className={styles.bannerText}>{snapshot.errors.join("; ")}</span>
|
||||
</div>
|
||||
)}
|
||||
<EmptyState
|
||||
title={node.kind === "compose" ? txTemplate(`Compose project '${node.project}'`) : (conn?.name ?? "Docker")}
|
||||
>
|
||||
<EmptyState title={conn?.name ?? "Docker"}>
|
||||
<div>
|
||||
{txTemplate(`Engine ${a.serverVersion} · CLI ${a.clientVersion}`)}
|
||||
{a.context ? txTemplate(` · context ${a.context}`) : ""}
|
||||
@@ -403,32 +471,66 @@ function DockerDetail({ item }: { item: ServiceTreeItem }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const object: DockerObject = node.kind;
|
||||
const id =
|
||||
|
||||
const tabItems: Array<{ id: DetailTab; label: string }> =
|
||||
node.kind === "container"
|
||||
? node.container.id
|
||||
? [
|
||||
{ id: "dashboard", label: "Dashboard" },
|
||||
{ id: "log", label: "Log" },
|
||||
{ id: "processes", label: "Processes" },
|
||||
{ id: "inspect", label: "Inspect" },
|
||||
]
|
||||
: node.kind === "image"
|
||||
? node.image.id
|
||||
: node.kind === "volume"
|
||||
? node.volume.name
|
||||
: node.network.id;
|
||||
const showLog = node.kind === "container" && tab === "log";
|
||||
? [
|
||||
{ id: "dashboard", label: "Dashboard" },
|
||||
{ id: "layers", label: "Layers" },
|
||||
{ id: "inspect", label: "Inspect" },
|
||||
]
|
||||
: [
|
||||
{ id: "dashboard", label: "Dashboard" },
|
||||
{ id: "inspect", label: "Inspect" },
|
||||
];
|
||||
|
||||
const activeId: DetailTab = tabItems.some((t) => t.id === tab) ? tab : "dashboard";
|
||||
const id = objectId!;
|
||||
|
||||
return (
|
||||
<div className={styles.detailPane}>
|
||||
<Tabs
|
||||
variant="compact"
|
||||
activeId={showLog ? "log" : "inspect"}
|
||||
onSelect={(t) => useServices.setState({ detailTab: t as "log" | "inspect" })}
|
||||
activeId={activeId}
|
||||
onSelect={(t) => useServices.setState({ detailTab: t as DetailTab })}
|
||||
aria-label="Details"
|
||||
items={[
|
||||
...(node.kind === "container" ? [{ id: "log", label: "Log" }] : []),
|
||||
{ id: "inspect", label: "Inspect" },
|
||||
]}
|
||||
items={tabItems}
|
||||
/>
|
||||
{showLog ? (
|
||||
<LogView key={id} conn={parsed.conn} id={id} running={isRunning(node.container)} />
|
||||
{activeId === "log" && node.kind === "container" ? (
|
||||
<LogView key={id} conn={parsed.conn} container={node.container} running={isRunning(node.container)} />
|
||||
) : activeId === "processes" && node.kind === "container" ? (
|
||||
<ContainerProcessesView key={id} conn={parsed.conn} id={id} running={isRunning(node.container)} />
|
||||
) : activeId === "layers" && node.kind === "image" ? (
|
||||
<ImageLayersView key={id} conn={parsed.conn} id={id} />
|
||||
) : activeId === "inspect" ? (
|
||||
<InspectView key={id} text={inspect.text} error={inspect.error} />
|
||||
) : node.kind === "container" ? (
|
||||
<ContainerDashboard
|
||||
key={id}
|
||||
conn={parsed.conn}
|
||||
container={node.container}
|
||||
snapshot={snapshot}
|
||||
inspectText={inspect.text}
|
||||
/>
|
||||
) : node.kind === "image" ? (
|
||||
<ImageDashboard key={id} conn={parsed.conn} image={node.image} snapshot={snapshot} inspectText={inspect.text} />
|
||||
) : node.kind === "volume" ? (
|
||||
<VolumeDashboard key={id} conn={parsed.conn} volume={node.volume} snapshot={snapshot} />
|
||||
) : (
|
||||
<InspectView key={id} conn={parsed.conn} object={object} id={id} />
|
||||
<NetworkDashboard
|
||||
key={id}
|
||||
conn={parsed.conn}
|
||||
network={node.network}
|
||||
snapshot={snapshot}
|
||||
inspectText={inspect.text}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -437,7 +539,11 @@ function DockerDetail({ item }: { item: ServiceTreeItem }) {
|
||||
function useRoots(): ServiceTreeItem[] {
|
||||
const settings = useServices((s) => s.settings);
|
||||
const conns = useServices((s) => s.conns);
|
||||
return useMemo(() => dockerItems(settings?.connections ?? [], settings?.registries ?? [], conns), [settings, conns]);
|
||||
const filters = useServices((s) => s.filters);
|
||||
return useMemo(
|
||||
() => dockerItems(settings?.connections ?? [], settings?.registries ?? [], conns, filters),
|
||||
[settings, conns, filters],
|
||||
);
|
||||
}
|
||||
|
||||
export const dockerProvider: ServiceProviderContribution = {
|
||||
@@ -456,7 +562,7 @@ export const dockerProvider: ServiceProviderContribution = {
|
||||
detail: DockerDetail,
|
||||
banner: DockerTaskBanner,
|
||||
addMenu: () => [
|
||||
{ label: "Docker Registry…", icon: "package", run: () => openRegistryDialog() },
|
||||
{ label: "Docker Registry…", icon: "docker-registry", run: () => openRegistryDialog() },
|
||||
{ label: "Docker Connection…", icon: "file-docker", run: () => openConnectionDialog() },
|
||||
],
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import { registerActions } from "@/platform/actions";
|
||||
import { registerEditorContribution } from "@/editor/api";
|
||||
import {
|
||||
SERVICES_WINDOW,
|
||||
registerEditorKind,
|
||||
registerOverlay,
|
||||
registerServiceProvider,
|
||||
registerServiceView,
|
||||
@@ -22,6 +23,7 @@ import { dockerGutter } from "./dockerGutter";
|
||||
import { registerDockerRunTypes } from "./runTypes";
|
||||
import { ServicesDialogHost } from "./dialogs";
|
||||
import { dockerProvider, selectedTarget } from "./docker";
|
||||
import { DOCKER_LOG_EDITOR_KIND, DockerLogEditor, dockerLogEditorTitle, openContainerLogInEditor } from "./logEditor";
|
||||
import {
|
||||
canRemove,
|
||||
canStart,
|
||||
@@ -37,7 +39,7 @@ import {
|
||||
} from "./ops";
|
||||
import { SERVICES_VIEW_FOCUS, ServicesTreeView, showAddServiceMenu } from "./ServicesTree";
|
||||
import { ServicesHeaderActions, ServicesHost, ServicesTabs } from "./ServicesHost";
|
||||
import { connections, refresh } from "./store";
|
||||
import { connections, refresh, setDockerFilters, useServices } from "./store";
|
||||
import { SERVICES_TREE_VIEW } from "./tree";
|
||||
import { isRunning } from "./model";
|
||||
import type { PruneTarget } from "@/platform/ipc";
|
||||
@@ -68,6 +70,13 @@ export function activate(): () => void {
|
||||
component: ServicesTreeView,
|
||||
}),
|
||||
registerServiceProvider(dockerProvider),
|
||||
registerEditorKind({
|
||||
kind: DOCKER_LOG_EDITOR_KIND,
|
||||
component: DockerLogEditor,
|
||||
title: dockerLogEditorTitle,
|
||||
icon: () => "file-log",
|
||||
keepAlive: true,
|
||||
}),
|
||||
registerOverlay({ id: "services.dialogs", component: ServicesDialogHost }),
|
||||
registerEditorContribution({ id: "services.dockerGutter", order: 56, extensions: dockerGutter }),
|
||||
registerDockerRunTypes(),
|
||||
@@ -135,6 +144,17 @@ export function activate(): () => void {
|
||||
when: PROJECT,
|
||||
run: () => (canRemove(selectedTarget()) ? remove(selectedTarget()) : undefined),
|
||||
},
|
||||
{
|
||||
id: "docker.showDashboard",
|
||||
title: "Show Docker Dashboard",
|
||||
category: "Docker",
|
||||
icon: "layout",
|
||||
when: PROJECT,
|
||||
run: () => {
|
||||
showDetail("dashboard");
|
||||
showServiceView(SERVICES_TREE_VIEW, true);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "docker.showLog",
|
||||
title: "Show Container Log",
|
||||
@@ -145,6 +165,39 @@ export function activate(): () => void {
|
||||
showServiceView(SERVICES_TREE_VIEW, true);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "docker.openLogInEditor",
|
||||
title: "Open Container Log in Editor",
|
||||
category: "Docker",
|
||||
icon: "file-log",
|
||||
when: PROJECT,
|
||||
run: () => {
|
||||
const t = selectedTarget();
|
||||
if (t?.node.kind === "container") openContainerLogInEditor(t.conn, t.node.container);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "docker.showProcesses",
|
||||
title: "Show Container Processes",
|
||||
category: "Docker",
|
||||
icon: "list",
|
||||
when: PROJECT,
|
||||
run: () => {
|
||||
showDetail("processes");
|
||||
showServiceView(SERVICES_TREE_VIEW, true);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "docker.showLayers",
|
||||
title: "Show Image Layers",
|
||||
category: "Docker",
|
||||
icon: "list",
|
||||
when: PROJECT,
|
||||
run: () => {
|
||||
showDetail("layers");
|
||||
showServiceView(SERVICES_TREE_VIEW, true);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "docker.inspect",
|
||||
title: "Inspect Docker Object",
|
||||
@@ -156,6 +209,22 @@ export function activate(): () => void {
|
||||
showServiceView(SERVICES_TREE_VIEW, true);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "docker.toggleStoppedContainers",
|
||||
title: "Toggle Stopped Containers",
|
||||
category: "Docker",
|
||||
icon: "filter",
|
||||
when: PROJECT,
|
||||
run: () => setDockerFilters({ showStoppedContainers: !useServices.getState().filters.showStoppedContainers }),
|
||||
},
|
||||
{
|
||||
id: "docker.toggleUntaggedImages",
|
||||
title: "Toggle Untagged Images",
|
||||
category: "Docker",
|
||||
icon: "filter",
|
||||
when: PROJECT,
|
||||
run: () => setDockerFilters({ showUntaggedImages: !useServices.getState().filters.showUntaggedImages }),
|
||||
},
|
||||
{
|
||||
id: "docker.execShell",
|
||||
title: "Create Terminal in Container",
|
||||
@@ -172,7 +241,7 @@ export function activate(): () => void {
|
||||
id: "docker.pullImage",
|
||||
title: "Pull Docker Image…",
|
||||
category: "Docker",
|
||||
icon: "git-pull",
|
||||
icon: "docker-pull",
|
||||
when: PROJECT,
|
||||
run: () => {
|
||||
const conn = currentConnection();
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* Container log in a regular CodeMirror 6 Editor Tab (`DOCKER_LOG_EDITOR_KIND`),
|
||||
* with log syntax highlighting, line numbers, Timestamps & Soft-wrap toggles,
|
||||
* Find (`⌘F`) and font zoom (`⌘=` / `⌘-` / `⌘0`), matching `PluginLogEditor`.
|
||||
*/
|
||||
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
import { Compartment, EditorState, Prec, type StateEffect } from "@codemirror/state";
|
||||
import { EditorView, highlightSpecialChars, keymap } from "@codemirror/view";
|
||||
import { defaultKeymap } from "@codemirror/commands";
|
||||
import { LanguageSupport, syntaxHighlighting } from "@codemirror/language";
|
||||
import { caretRowHighlight } from "@/editor/core/cm/caretRow";
|
||||
import { findExtension, findResultLines, findScope, type FindOptions } from "@/editor/core/cm/find";
|
||||
import { openideaHighlightStyle } from "@/editor/core/cm/highlight";
|
||||
import { overviewLane } from "@/editor/core/cm/lanes";
|
||||
import { logLanguage } from "@/editor/core/cm/log";
|
||||
import { caretExtension, fontZoom, setFontZoom, softWrapActive, stepFontZoom } from "@/editor/core/cm/prefs";
|
||||
import { fontExtension, heavyExtension, lineNumbersExtension, prefsOf } from "@/editor/core/cm/setup";
|
||||
import { openideaTheme } from "@/editor/core/cm/theme";
|
||||
import { FindPanel, searchAgain, seedFind, type FindTarget } from "@/editor/core/view/FindBar";
|
||||
import { editorPrefs } from "@/platform/editorSettings";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { call, commands, isCancelled, nextRequestId, type DockerContainer, type DockerLogLine } from "@/platform/ipc";
|
||||
import { bindingsFor, normalizeBinding, registerKeyClaim, strokeOf } from "@/platform/keymap";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { EmptyState, IconButton, Toggle, selectableText } from "@/ui";
|
||||
import logStyles from "@/ui/LogPane.module.css";
|
||||
import { editorService, type EditorInput } from "@/workbench/contributions";
|
||||
import { isRunning } from "./model";
|
||||
import { connectionState, endpointOf, errorMessage, useServices } from "./store";
|
||||
|
||||
export const DOCKER_LOG_EDITOR_KIND = "dockerLog";
|
||||
|
||||
export interface DockerLogEditorData {
|
||||
conn: string;
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
}
|
||||
|
||||
export function dockerLogEditorTitle(input: EditorInput): string {
|
||||
const data = input.data as Partial<DockerLogEditorData> | undefined;
|
||||
return data?.containerName ? `${data.containerName} (Log)` : input.key;
|
||||
}
|
||||
|
||||
export function openContainerLogInEditor(conn: string, container: DockerContainer): void {
|
||||
const data: DockerLogEditorData = {
|
||||
conn,
|
||||
containerId: container.id,
|
||||
containerName: container.name,
|
||||
};
|
||||
void editorService().openInput({
|
||||
kind: DOCKER_LOG_EDITOR_KIND,
|
||||
key: `${DOCKER_LOG_EDITOR_KIND}:${conn}:${container.id}`,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
const ANSI = /\x1b\[[0-9;?]*[A-Za-z]/g; // eslint-disable-line no-control-regex
|
||||
|
||||
function boundedLog(text: string): string {
|
||||
let result = text.slice(-(1 << 20));
|
||||
if (/^[\uDC00-\uDFFF]/.test(result)) result = result.slice(1);
|
||||
const lines = result.split("\n");
|
||||
if (lines.length > 5000) result = lines.slice(-5000).join("\n");
|
||||
return result;
|
||||
}
|
||||
|
||||
function isActionStroke(stroke: string | null, actionId: string): boolean {
|
||||
return !!stroke && bindingsFor(actionId).some((b) => normalizeBinding(b) === stroke);
|
||||
}
|
||||
|
||||
const wheelZoom = EditorView.domEventHandlers({
|
||||
wheel(e, view) {
|
||||
if (!e.metaKey && !e.ctrlKey) return false;
|
||||
e.preventDefault();
|
||||
stepFontZoom(view, e.deltaY < 0 ? 1 : -1);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
export function DockerLogEditor({ input }: { input: EditorInput }) {
|
||||
const data = input.data as DockerLogEditorData | undefined;
|
||||
if (!data || !data.containerId) {
|
||||
return <EmptyState title={tx("Invalid log stream configuration")} />;
|
||||
}
|
||||
return <DockerLogEditorBody input={input} data={data} />;
|
||||
}
|
||||
|
||||
function DockerLogEditorBody({ input, data }: { input: EditorInput; data: DockerLogEditorData }) {
|
||||
const initialWrap = useMemo(() => {
|
||||
const s = useProject.getState().settings?.editor;
|
||||
return softWrapActive(s?.softWrap ?? true, prefsOf(s), "stream.log");
|
||||
}, []);
|
||||
const [timestamps, setTimestamps] = useState(false);
|
||||
const [wrap, setWrap] = useState<boolean>(initialWrap);
|
||||
const [find, setFind] = useState<{ seq: number } | null>(null);
|
||||
const [viewGen, setViewGen] = useState(0);
|
||||
|
||||
const running = useServices((s) => {
|
||||
const c = connectionState(data.conn, s).snapshot?.containers.find((x) => x.id === data.containerId);
|
||||
return c ? isRunning(c) : true;
|
||||
});
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const wrapSlotRef = useRef(new Compartment());
|
||||
const stickRef = useRef(true);
|
||||
const receivedRef = useRef(false);
|
||||
const findOpenRef = useRef(false);
|
||||
const findWatchersRef = useRef(new Set<(u: { docChanged: boolean; selectionSet: boolean }) => void>());
|
||||
const findEscapeRef = useRef<() => boolean>(() => false);
|
||||
const findId = useId();
|
||||
const title = `${data.containerName} (Log)`;
|
||||
|
||||
const findTarget = useMemo<FindTarget>(
|
||||
() => ({
|
||||
id: `dockerLog:${findId}:${input.key}`,
|
||||
view: () => viewRef.current,
|
||||
watch(fn) {
|
||||
findWatchersRef.current.add(fn);
|
||||
return () => void findWatchersRef.current.delete(fn);
|
||||
},
|
||||
isOpen: () => findOpenRef.current,
|
||||
results(options: FindOptions) {
|
||||
const view = viewRef.current;
|
||||
if (!view) return null;
|
||||
const state = view.state;
|
||||
const scope = state.field(findScope, false) ?? { from: 0, to: state.doc.length };
|
||||
return {
|
||||
title: tx`Occurrences of '${options.search}'`,
|
||||
query: {
|
||||
pattern: options.search,
|
||||
isRegex: options.regexp,
|
||||
caseSensitive: options.caseSensitive,
|
||||
wholeWord: options.wholeWord,
|
||||
context: options.context,
|
||||
include: [],
|
||||
exclude: [],
|
||||
scope: { kind: "files", paths: [title] },
|
||||
maxMatches: 0,
|
||||
},
|
||||
read: () => ({
|
||||
path: title,
|
||||
relPath: title,
|
||||
matches: findResultLines(
|
||||
viewRef.current?.state ?? state,
|
||||
options,
|
||||
state.field(findScope, false)
|
||||
? ((viewRef.current?.state ?? state).field(findScope, false) ?? scope)
|
||||
: { from: 0, to: (viewRef.current?.state ?? state).doc.length },
|
||||
),
|
||||
}),
|
||||
};
|
||||
},
|
||||
}),
|
||||
[findId, input.key, title],
|
||||
);
|
||||
|
||||
const openFind = () => {
|
||||
if (!viewRef.current) return;
|
||||
findOpenRef.current = true;
|
||||
seedFind(findTarget, false);
|
||||
setFind((f) => ({ seq: (f?.seq ?? 0) + 1 }));
|
||||
};
|
||||
|
||||
const closeFind = () => {
|
||||
findOpenRef.current = false;
|
||||
setFind(null);
|
||||
viewRef.current?.focus();
|
||||
};
|
||||
|
||||
findEscapeRef.current = () => {
|
||||
if (!findOpenRef.current) return false;
|
||||
closeFind();
|
||||
return true;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
const fontSlot = new Compartment();
|
||||
const caretSlot = new Compartment();
|
||||
const lineNumbersSlot = new Compartment();
|
||||
const heavySlot = new Compartment();
|
||||
const wrapSlot = wrapSlotRef.current;
|
||||
const editorSettings = () => useProject.getState().settings?.editor;
|
||||
|
||||
const view = new EditorView({
|
||||
parent: host,
|
||||
state: EditorState.create({
|
||||
doc: tx("Waiting for output…"),
|
||||
extensions: [
|
||||
fontSlot.of(fontExtension(editorSettings())),
|
||||
caretSlot.of(caretExtension(editorPrefs())),
|
||||
lineNumbersSlot.of(lineNumbersExtension(editorSettings())),
|
||||
wrapSlot.of(wrap ? EditorView.lineWrapping : []),
|
||||
heavySlot.of(heavyExtension(0, editorSettings())),
|
||||
fontZoom,
|
||||
wheelZoom,
|
||||
caretRowHighlight,
|
||||
openideaTheme,
|
||||
highlightSpecialChars(),
|
||||
syntaxHighlighting(openideaHighlightStyle),
|
||||
new LanguageSupport(logLanguage),
|
||||
findExtension(),
|
||||
overviewLane,
|
||||
Prec.high(keymap.of([{ key: "Escape", run: () => findEscapeRef.current() }])),
|
||||
keymap.of(defaultKeymap),
|
||||
EditorState.readOnly.of(true),
|
||||
EditorView.editable.of(false),
|
||||
EditorView.contentAttributes.of({
|
||||
"aria-label": title,
|
||||
class: selectableText,
|
||||
tabindex: "0",
|
||||
}),
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged || u.selectionSet) {
|
||||
findWatchersRef.current.forEach((fn) => fn(u));
|
||||
}
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
viewRef.current = view;
|
||||
setViewGen((g) => g + 1);
|
||||
|
||||
const onScroll = () => {
|
||||
const el = view.scrollDOM;
|
||||
stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 32;
|
||||
};
|
||||
view.scrollDOM.addEventListener("scroll", onScroll, { passive: true });
|
||||
|
||||
let prevAppearance = editorPrefs().appearance;
|
||||
let prevEditor = editorSettings();
|
||||
const offSettings = useProject.subscribe(() => {
|
||||
const effects: StateEffect<unknown>[] = [];
|
||||
const nextPrefs = editorPrefs();
|
||||
const nextEditor = editorSettings();
|
||||
if (JSON.stringify(nextPrefs.appearance) !== JSON.stringify(prevAppearance)) {
|
||||
prevAppearance = nextPrefs.appearance;
|
||||
effects.push(caretSlot.reconfigure(caretExtension(nextPrefs)));
|
||||
}
|
||||
if (nextEditor !== prevEditor) {
|
||||
prevEditor = nextEditor;
|
||||
effects.push(
|
||||
fontSlot.reconfigure(fontExtension(nextEditor)),
|
||||
lineNumbersSlot.reconfigure(lineNumbersExtension(nextEditor)),
|
||||
heavySlot.reconfigure(heavyExtension(view.state.doc.length, nextEditor)),
|
||||
);
|
||||
}
|
||||
if (effects.length) view.dispatch({ effects });
|
||||
});
|
||||
|
||||
return () => {
|
||||
offSettings();
|
||||
view.scrollDOM.removeEventListener("scroll", onScroll);
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [title]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
view.dispatch({
|
||||
effects: wrapSlotRef.current.reconfigure(wrap ? EditorView.lineWrapping : []),
|
||||
});
|
||||
}, [wrap]);
|
||||
|
||||
useEffect(() => {
|
||||
return registerKeyClaim((e, stroke) => {
|
||||
if (!rootRef.current?.contains(e.target as Node)) return false;
|
||||
const view = viewRef.current;
|
||||
if (!view) return false;
|
||||
if (isActionStroke(stroke, "find.inFile") || stroke === "Meta+F" || stroke === "Ctrl+F") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openFind();
|
||||
return true;
|
||||
}
|
||||
if (isActionStroke(stroke, "find.next") || isActionStroke(stroke, "find.previous")) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!searchAgain(findTarget, isActionStroke(stroke, "find.next"))) openFind();
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
isActionStroke(stroke, "editor.increaseFontSize") ||
|
||||
stroke === "Meta+=" ||
|
||||
stroke === "Meta+Shift+=" ||
|
||||
stroke === "Ctrl+=" ||
|
||||
stroke === "Ctrl+Shift+=" ||
|
||||
stroke === "Meta+NumpadAdd" ||
|
||||
stroke === "Ctrl+NumpadAdd"
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
stepFontZoom(view, 1);
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
isActionStroke(stroke, "editor.decreaseFontSize") ||
|
||||
stroke === "Meta+-" ||
|
||||
stroke === "Meta+Shift+-" ||
|
||||
stroke === "Ctrl+-" ||
|
||||
stroke === "Ctrl+Shift+-" ||
|
||||
stroke === "Meta+NumpadSubtract" ||
|
||||
stroke === "Ctrl+NumpadSubtract"
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
stepFontZoom(view, -1);
|
||||
return true;
|
||||
}
|
||||
if (isActionStroke(stroke, "editor.resetFontSize") || stroke === "Meta+0" || stroke === "Ctrl+0") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
view.dispatch({ effects: setFontZoom.of(0) });
|
||||
return true;
|
||||
}
|
||||
if (stroke === "Escape" && findOpenRef.current) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeFind();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [findTarget]);
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented) return;
|
||||
const stroke = strokeOf(e.nativeEvent);
|
||||
const view = viewRef.current;
|
||||
if (!view || !stroke) return;
|
||||
if (isActionStroke(stroke, "find.inFile") || stroke === "Meta+F" || stroke === "Ctrl+F") {
|
||||
openFind();
|
||||
} else if (isActionStroke(stroke, "find.next") || isActionStroke(stroke, "find.previous")) {
|
||||
if (!searchAgain(findTarget, isActionStroke(stroke, "find.next"))) openFind();
|
||||
} else if (
|
||||
isActionStroke(stroke, "editor.increaseFontSize") ||
|
||||
stroke === "Meta+=" ||
|
||||
stroke === "Meta+Shift+=" ||
|
||||
stroke === "Ctrl+="
|
||||
) {
|
||||
stepFontZoom(view, 1);
|
||||
} else if (
|
||||
isActionStroke(stroke, "editor.decreaseFontSize") ||
|
||||
stroke === "Meta+-" ||
|
||||
stroke === "Meta+Shift+-" ||
|
||||
stroke === "Ctrl+-"
|
||||
) {
|
||||
stepFontZoom(view, -1);
|
||||
} else if (isActionStroke(stroke, "editor.resetFontSize") || stroke === "Meta+0" || stroke === "Ctrl+0") {
|
||||
view.dispatch({ effects: setFontZoom.of(0) });
|
||||
} else if (e.key === "Escape" && findOpenRef.current) {
|
||||
closeFind();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
receivedRef.current = false;
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: tx("Waiting for output…") } });
|
||||
stickRef.current = true;
|
||||
let stopped = false;
|
||||
let buffer: string[] = [];
|
||||
let frame = 0;
|
||||
|
||||
const append = (chunk: string) => {
|
||||
if (stopped || !viewRef.current) return;
|
||||
const v = viewRef.current;
|
||||
const previous = receivedRef.current ? v.state.doc.toString() : "";
|
||||
receivedRef.current = true;
|
||||
const joined = previous ? `${previous}\n${chunk}` : chunk;
|
||||
const result = boundedLog(joined);
|
||||
const stick = stickRef.current;
|
||||
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: result } });
|
||||
if (stick && !findOpenRef.current) v.scrollDOM.scrollTop = v.scrollDOM.scrollHeight;
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
frame = 0;
|
||||
if (!buffer.length) return;
|
||||
const lines = buffer;
|
||||
buffer = [];
|
||||
append(lines.join("\n"));
|
||||
};
|
||||
|
||||
const request = nextRequestId();
|
||||
const channel = new Channel<DockerLogLine>();
|
||||
channel.onmessage = (line) => {
|
||||
if (stopped) return;
|
||||
buffer.push(line.text.replace(ANSI, ""));
|
||||
frame ||= requestAnimationFrame(flush);
|
||||
};
|
||||
call(
|
||||
commands.dockerLogs(request, endpointOf(data.conn), data.containerId, 1000, running, timestamps, channel),
|
||||
).catch((e) => {
|
||||
if (!stopped && !isCancelled(e)) {
|
||||
append(`Error: ${errorMessage(e)}`);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
void commands.cancelRequest(request);
|
||||
};
|
||||
}, [data.conn, data.containerId, running, timestamps]);
|
||||
|
||||
const clearLogs = () => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
receivedRef.current = true;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: "" },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={logStyles.pane} onKeyDown={onKeyDown}>
|
||||
<div className={logStyles.toolbar}>
|
||||
<Toggle checked={timestamps} onChange={setTimestamps} label="Timestamps" aria-label="Timestamps" />
|
||||
<Toggle checked={wrap} onChange={setWrap} label="Soft-wrap" aria-label="Soft-wrap" />
|
||||
<span className={logStyles.flex} />
|
||||
<IconButton icon="search" size="small" title="Find in Logs" onClick={openFind} />
|
||||
<IconButton icon="trash" size="small" title="Clear" onClick={clearLogs} />
|
||||
</div>
|
||||
{find && <FindPanel target={findTarget} version={viewGen} replace={false} seq={find.seq} onClose={closeFind} />}
|
||||
<div ref={hostRef} className={logStyles.output} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,17 @@
|
||||
/** The Services tree (Docker → Containers / Images / Volumes / Networks), built from a snapshot. */
|
||||
import type { DockerContainer, DockerImage, DockerNetwork, DockerSnapshot, DockerVolume } from "@/platform/ipc";
|
||||
|
||||
export type GroupKind = "containers" | "images" | "volumes" | "networks";
|
||||
export type GroupKind = "devContainers" | "containers" | "images" | "networks" | "volumes";
|
||||
|
||||
export interface DockerFilters {
|
||||
showStoppedContainers: boolean;
|
||||
showUntaggedImages: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_DOCKER_FILTERS: DockerFilters = {
|
||||
showStoppedContainers: true,
|
||||
showUntaggedImages: true,
|
||||
};
|
||||
|
||||
export type ServiceNode =
|
||||
| { kind: "docker"; key: string }
|
||||
@@ -14,10 +24,11 @@ export type ServiceNode =
|
||||
|
||||
export const DOCKER_KEY = "docker";
|
||||
const GROUPS: Array<[GroupKind, string]> = [
|
||||
["devContainers", "Dev Containers"],
|
||||
["containers", "Containers"],
|
||||
["images", "Images"],
|
||||
["volumes", "Volumes"],
|
||||
["networks", "Networks"],
|
||||
["volumes", "Volumes"],
|
||||
];
|
||||
|
||||
export const shortId = (id: string) => id.replace(/^sha256:/, "").slice(0, 12);
|
||||
@@ -26,6 +37,18 @@ export function isRunning(c: DockerContainer): boolean {
|
||||
return c.state === "running" || c.state === "restarting" || c.state === "paused";
|
||||
}
|
||||
|
||||
export function isDevContainer(c: DockerContainer): boolean {
|
||||
const labels = c.labels;
|
||||
if (!labels) return false;
|
||||
return (
|
||||
"devcontainer.local_folder" in labels || "com.intellij.devcontainer.id" in labels || "vsch.local.folder" in labels
|
||||
);
|
||||
}
|
||||
|
||||
export function isUntaggedImage(i: DockerImage): boolean {
|
||||
return (!i.repository || i.repository === "<none>") && (!i.tag || i.tag === "<none>");
|
||||
}
|
||||
|
||||
export function imageRef(i: DockerImage): string {
|
||||
if (!i.repository || i.repository === "<none>") return shortId(i.id);
|
||||
return !i.tag || i.tag === "<none>" ? i.repository : `${i.repository}:${i.tag}`;
|
||||
@@ -35,23 +58,49 @@ export function rootNodes(): ServiceNode[] {
|
||||
return [{ kind: "docker", key: DOCKER_KEY }];
|
||||
}
|
||||
|
||||
function composeProjects(snap: DockerSnapshot, filters: DockerFilters): ServiceNode[] {
|
||||
const projects = new Map<string, DockerContainer[]>();
|
||||
for (const c of snap.containers) {
|
||||
if (c.composeProject) projects.set(c.composeProject, [...(projects.get(c.composeProject) ?? []), c]);
|
||||
}
|
||||
return [...projects.entries()]
|
||||
.filter(([, containers]) => filters.showStoppedContainers || containers.some(isRunning))
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([project, containers]) => ({
|
||||
kind: "compose" as const,
|
||||
key: `compose:${project}`,
|
||||
project,
|
||||
containers: [...containers].sort((a, b) =>
|
||||
(a.composeService ?? a.name).localeCompare(b.composeService ?? b.name),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Children lookup over one snapshot (stable node objects per snapshot). */
|
||||
export function childrenOf(snap: DockerSnapshot | null): (n: ServiceNode) => ServiceNode[] | undefined {
|
||||
export function childrenOf(
|
||||
snap: DockerSnapshot | null,
|
||||
filters: DockerFilters = DEFAULT_DOCKER_FILTERS,
|
||||
): (n: ServiceNode) => ServiceNode[] | undefined {
|
||||
const cache = new Map<string, ServiceNode[] | undefined>();
|
||||
const compute = (n: ServiceNode): ServiceNode[] | undefined => {
|
||||
if (!snap || snap.availability.kind !== "ready") return undefined;
|
||||
switch (n.kind) {
|
||||
case "docker":
|
||||
return GROUPS.map(([group]) => ({
|
||||
kind: "group",
|
||||
key: `group:${group}`,
|
||||
group,
|
||||
count: groupCount(snap, group),
|
||||
}));
|
||||
return [
|
||||
...composeProjects(snap, filters),
|
||||
...GROUPS.map(([group]) => ({
|
||||
kind: "group" as const,
|
||||
key: `group:${group}`,
|
||||
group,
|
||||
count: groupCount(snap, group, filters),
|
||||
})),
|
||||
];
|
||||
case "group":
|
||||
return groupChildren(snap, n.group);
|
||||
case "compose":
|
||||
return n.containers.map(containerNode);
|
||||
return groupChildren(snap, n.group, filters);
|
||||
case "compose": {
|
||||
const list = filters.showStoppedContainers ? n.containers : n.containers.filter(isRunning);
|
||||
return list.map(containerNode);
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
@@ -62,14 +111,21 @@ export function childrenOf(snap: DockerSnapshot | null): (n: ServiceNode) => Ser
|
||||
};
|
||||
}
|
||||
|
||||
function groupCount(snap: DockerSnapshot, g: GroupKind): number {
|
||||
return g === "containers"
|
||||
? snap.containers.length
|
||||
: g === "images"
|
||||
? snap.images.length
|
||||
: g === "volumes"
|
||||
? snap.volumes.length
|
||||
: snap.networks.length;
|
||||
function groupCount(snap: DockerSnapshot, g: GroupKind, filters: DockerFilters = DEFAULT_DOCKER_FILTERS): number {
|
||||
if (g === "devContainers") {
|
||||
return snap.containers.filter(
|
||||
(c) => !c.composeProject && isDevContainer(c) && (filters.showStoppedContainers || isRunning(c)),
|
||||
).length;
|
||||
}
|
||||
if (g === "containers") {
|
||||
return snap.containers.filter(
|
||||
(c) => !c.composeProject && !isDevContainer(c) && (filters.showStoppedContainers || isRunning(c)),
|
||||
).length;
|
||||
}
|
||||
if (g === "images") {
|
||||
return filters.showUntaggedImages ? snap.images.length : snap.images.filter((i) => !isUntaggedImage(i)).length;
|
||||
}
|
||||
return g === "volumes" ? snap.volumes.length : snap.networks.length;
|
||||
}
|
||||
|
||||
const containerNode = (c: DockerContainer): ServiceNode => ({
|
||||
@@ -78,30 +134,25 @@ const containerNode = (c: DockerContainer): ServiceNode => ({
|
||||
container: c,
|
||||
});
|
||||
|
||||
/** Compose projects (by label) first, then standalone containers, as IDEA groups them. */
|
||||
export function groupChildren(snap: DockerSnapshot, g: GroupKind): ServiceNode[] {
|
||||
/** Children of one group node (`Dev Containers`, `Containers`, `Images`, `Networks`, `Volumes`). */
|
||||
export function groupChildren(
|
||||
snap: DockerSnapshot,
|
||||
g: GroupKind,
|
||||
filters: DockerFilters = DEFAULT_DOCKER_FILTERS,
|
||||
): ServiceNode[] {
|
||||
switch (g) {
|
||||
case "containers": {
|
||||
const projects = new Map<string, DockerContainer[]>();
|
||||
const loose: DockerContainer[] = [];
|
||||
for (const c of snap.containers) {
|
||||
if (c.composeProject) projects.set(c.composeProject, [...(projects.get(c.composeProject) ?? []), c]);
|
||||
else loose.push(c);
|
||||
}
|
||||
const compose: ServiceNode[] = [...projects.entries()]
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([project, containers]) => ({
|
||||
kind: "compose",
|
||||
key: `compose:${project}`,
|
||||
project,
|
||||
containers: [...containers].sort((a, b) =>
|
||||
(a.composeService ?? a.name).localeCompare(b.composeService ?? b.name),
|
||||
),
|
||||
}));
|
||||
return [...compose, ...loose.map(containerNode)];
|
||||
case "devContainers":
|
||||
return snap.containers
|
||||
.filter((c) => !c.composeProject && isDevContainer(c) && (filters.showStoppedContainers || isRunning(c)))
|
||||
.map(containerNode);
|
||||
case "containers":
|
||||
return snap.containers
|
||||
.filter((c) => !c.composeProject && !isDevContainer(c) && (filters.showStoppedContainers || isRunning(c)))
|
||||
.map(containerNode);
|
||||
case "images": {
|
||||
const imgs = filters.showUntaggedImages ? snap.images : snap.images.filter((i) => !isUntaggedImage(i));
|
||||
return imgs.map((image) => ({ kind: "image", key: `image:${image.id}`, image }));
|
||||
}
|
||||
case "images":
|
||||
return snap.images.map((image) => ({ kind: "image", key: `image:${image.id}`, image }));
|
||||
case "volumes":
|
||||
return snap.volumes.map((volume) => ({ kind: "volume", key: `volume:${volume.name}`, volume }));
|
||||
case "networks":
|
||||
@@ -109,6 +160,42 @@ export function groupChildren(snap: DockerSnapshot, g: GroupKind): ServiceNode[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether `container.image` refers to `image`. */
|
||||
export function containerMatchesImage(c: DockerContainer, image: DockerImage): boolean {
|
||||
const ref = imageRef(image);
|
||||
if (c.image === ref || c.image === image.id || c.image === shortId(image.id)) return true;
|
||||
if (image.tag === "latest" && c.image === image.repository) return true;
|
||||
const cleanId = image.id.replace(/^sha256:/, "");
|
||||
const cleanTarget = c.image.replace(/^sha256:/, "");
|
||||
return cleanTarget.length >= 12 && cleanId.startsWith(cleanTarget);
|
||||
}
|
||||
|
||||
/** Containers in `snap` created from `image`. */
|
||||
export function containersUsingImage(snap: DockerSnapshot, image: DockerImage): DockerContainer[] {
|
||||
return snap.containers.filter((c) => containerMatchesImage(c, image));
|
||||
}
|
||||
|
||||
/** All `repo:tag` references in `snap` that share `image.id`. */
|
||||
export function imageTagsForId(snap: DockerSnapshot, image: DockerImage): string[] {
|
||||
const tags = snap.images.filter((i) => i.id === image.id && !isUntaggedImage(i)).map(imageRef);
|
||||
return [...new Set(tags)];
|
||||
}
|
||||
|
||||
/** The `DockerImage` in `snap` that `container` runs, if present locally. */
|
||||
export function findImageForContainer(snap: DockerSnapshot, container: DockerContainer): DockerImage | null {
|
||||
return snap.images.find((i) => containerMatchesImage(container, i)) ?? null;
|
||||
}
|
||||
|
||||
/** Containers in `snap` mounting `volume`. */
|
||||
export function containersUsingVolume(snap: DockerSnapshot, volume: DockerVolume): DockerContainer[] {
|
||||
return snap.containers.filter((c) => c.mounts?.includes(volume.name));
|
||||
}
|
||||
|
||||
/** Containers in `snap` attached to `network`. */
|
||||
export function containersInNetwork(snap: DockerSnapshot, network: DockerNetwork): DockerContainer[] {
|
||||
return snap.containers.filter((c) => c.networks?.includes(network.name));
|
||||
}
|
||||
|
||||
export function nodeLabel(n: ServiceNode): string {
|
||||
switch (n.kind) {
|
||||
case "docker":
|
||||
@@ -116,7 +203,7 @@ export function nodeLabel(n: ServiceNode): string {
|
||||
case "group":
|
||||
return GROUPS.find(([g]) => g === n.group)![1];
|
||||
case "compose":
|
||||
return n.project;
|
||||
return `Docker-compose: ${n.project}`;
|
||||
case "container":
|
||||
return n.container.composeService && n.container.composeProject ? n.container.composeService : n.container.name;
|
||||
case "image":
|
||||
@@ -149,22 +236,45 @@ export function nodeDetail(n: ServiceNode): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function groupIcon(g: GroupKind): string {
|
||||
switch (g) {
|
||||
case "devContainers":
|
||||
return "docker-devcontainer";
|
||||
case "containers":
|
||||
return "docker-containers";
|
||||
case "images":
|
||||
return "docker-images";
|
||||
case "networks":
|
||||
return "docker-networks";
|
||||
case "volumes":
|
||||
return "docker-volumes";
|
||||
}
|
||||
}
|
||||
|
||||
export function nodeIcon(n: ServiceNode): string {
|
||||
switch (n.kind) {
|
||||
case "docker":
|
||||
return "file-docker";
|
||||
return "docker-connected";
|
||||
case "group":
|
||||
return "folder";
|
||||
return groupIcon(n.group);
|
||||
case "compose":
|
||||
return "file-yaml";
|
||||
return n.containers.some(isRunning) ? "docker-compose-running" : "docker-compose-stopped";
|
||||
case "container":
|
||||
return n.container.state === "paused" ? "pause" : isRunning(n.container) ? "run" : "module";
|
||||
if (isDevContainer(n.container)) return "docker-devcontainer";
|
||||
if (n.container.composeProject) {
|
||||
return isRunning(n.container) ? "docker-compose-service-running" : "docker-compose-service-stopped";
|
||||
}
|
||||
return n.container.state === "paused"
|
||||
? "pause"
|
||||
: isRunning(n.container)
|
||||
? "docker-container-running"
|
||||
: "docker-container-stopped";
|
||||
case "image":
|
||||
return "package";
|
||||
return "docker-image";
|
||||
case "volume":
|
||||
return "database";
|
||||
return "docker-volumes";
|
||||
case "network":
|
||||
return "hierarchy";
|
||||
return "docker-networks";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { executeAction } from "@/platform/actions";
|
||||
import type { MenuItem } from "@/ui";
|
||||
import { confirm, confirmChecked } from "@/workbench/messages";
|
||||
import { promptText } from "./dialogs";
|
||||
import { openContainerLogInEditor } from "./logEditor";
|
||||
import { isRunning, nodeContainers, nodeLabel, shortId, type DockerTarget, type ServiceNode } from "./model";
|
||||
import {
|
||||
composeOp,
|
||||
@@ -14,7 +15,9 @@ import {
|
||||
pullImage,
|
||||
refresh,
|
||||
removeObjects,
|
||||
setDockerFilters,
|
||||
useServices,
|
||||
type DetailTab,
|
||||
} from "./store";
|
||||
|
||||
export function canStart(t: DockerTarget | null): boolean {
|
||||
@@ -185,10 +188,26 @@ export function pruneMenu(conn: string): MenuItem[] {
|
||||
|
||||
const copy = (text: string) => void navigator.clipboard?.writeText(text);
|
||||
|
||||
export function showDetail(tab: "log" | "inspect") {
|
||||
export function showDetail(tab: DetailTab) {
|
||||
useServices.setState({ detailTab: tab });
|
||||
}
|
||||
|
||||
export function viewFilterMenu(): MenuItem[] {
|
||||
const { filters } = useServices.getState();
|
||||
return [
|
||||
{
|
||||
label: "Stopped Containers",
|
||||
checked: filters.showStoppedContainers,
|
||||
run: () => setDockerFilters({ showStoppedContainers: !useServices.getState().filters.showStoppedContainers }),
|
||||
},
|
||||
{
|
||||
label: "Untagged Images",
|
||||
checked: filters.showUntaggedImages,
|
||||
run: () => setDockerFilters({ showUntaggedImages: !useServices.getState().filters.showUntaggedImages }),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Context menu of a node of a connection's subtree. */
|
||||
export function nodeMenu(t: DockerTarget): MenuItem[] {
|
||||
const { conn, node: n } = t;
|
||||
@@ -212,7 +231,14 @@ export function nodeMenu(t: DockerTarget): MenuItem[] {
|
||||
run: () => void containerOp(conn, [n.container.id], "pause"),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{ label: "Dashboard", icon: "layout", run: () => showDetail("dashboard") },
|
||||
{ label: "Show Log", icon: "file-log", run: () => showDetail("log") },
|
||||
{
|
||||
label: "Open Log in Editor",
|
||||
icon: "external-link",
|
||||
run: () => openContainerLogInEditor(conn, n.container),
|
||||
},
|
||||
{ label: "Show Processes", icon: "list", disabled: !running, run: () => showDetail("processes") },
|
||||
{ label: "Inspect", icon: "info", run: () => showDetail("inspect") },
|
||||
{ label: "Create Terminal", icon: "terminal", disabled: !running, run: () => execShell(t) },
|
||||
{ kind: "separator" },
|
||||
@@ -221,12 +247,16 @@ export function nodeMenu(t: DockerTarget): MenuItem[] {
|
||||
);
|
||||
}
|
||||
if (n.kind === "image" || n.kind === "volume" || n.kind === "network") {
|
||||
items.push({ label: "Dashboard", icon: "layout", run: () => showDetail("dashboard") });
|
||||
if (n.kind === "image") {
|
||||
items.push({ label: "Show Layers", icon: "list", run: () => showDetail("layers") });
|
||||
}
|
||||
items.push({ label: "Inspect", icon: "info", run: () => showDetail("inspect") });
|
||||
if (n.kind === "image")
|
||||
items.push(
|
||||
{
|
||||
label: "Pull Again",
|
||||
icon: "git-pull",
|
||||
icon: "docker-pull",
|
||||
disabled: n.image.repository === "<none>",
|
||||
run: () => void pullImage(conn, nodeLabel(n)),
|
||||
},
|
||||
@@ -234,10 +264,25 @@ export function nodeMenu(t: DockerTarget): MenuItem[] {
|
||||
);
|
||||
}
|
||||
if (n.kind === "group" && n.group === "images")
|
||||
items.push({ label: "Pull Image…", icon: "git-pull", run: () => void pullPrompt(conn) });
|
||||
items.push({ label: "Pull Image…", icon: "docker-pull", run: () => void pullPrompt(conn) });
|
||||
if (n.kind === "group") {
|
||||
if (n.group === "containers" || n.group === "devContainers") {
|
||||
const { filters } = useServices.getState();
|
||||
items.push({
|
||||
label: "Stopped Containers",
|
||||
checked: filters.showStoppedContainers,
|
||||
run: () => setDockerFilters({ showStoppedContainers: !useServices.getState().filters.showStoppedContainers }),
|
||||
});
|
||||
} else if (n.group === "images") {
|
||||
const { filters } = useServices.getState();
|
||||
items.push({
|
||||
label: "Untagged Images",
|
||||
checked: filters.showUntaggedImages,
|
||||
run: () => setDockerFilters({ showUntaggedImages: !useServices.getState().filters.showUntaggedImages }),
|
||||
});
|
||||
}
|
||||
const target: PruneTarget =
|
||||
n.group === "containers"
|
||||
n.group === "containers" || n.group === "devContainers"
|
||||
? "containers"
|
||||
: n.group === "images"
|
||||
? "images"
|
||||
@@ -252,7 +297,8 @@ export function nodeMenu(t: DockerTarget): MenuItem[] {
|
||||
if (n.kind === "docker")
|
||||
items.push(
|
||||
{ label: "Refresh", icon: "refresh", run: () => void refresh(conn) },
|
||||
{ label: "Pull Image…", icon: "git-pull", run: () => void pullPrompt(conn) },
|
||||
{ label: "Pull Image…", icon: "docker-pull", run: () => void pullPrompt(conn) },
|
||||
{ label: "View Options", icon: "filter", submenu: viewFilterMenu() },
|
||||
{ label: "Prune", submenu: pruneMenu(conn) },
|
||||
);
|
||||
if (canRemove(t)) {
|
||||
|
||||
@@ -12,16 +12,36 @@ import {
|
||||
} from "./runTypes";
|
||||
import {
|
||||
childrenOf,
|
||||
containersInNetwork,
|
||||
containersUsingImage,
|
||||
containersUsingVolume,
|
||||
findImageForContainer,
|
||||
findNode,
|
||||
imageTagsForId,
|
||||
isUntaggedImage,
|
||||
nodeDetail,
|
||||
nodeIcon,
|
||||
nodeLabel,
|
||||
parseTreeKey,
|
||||
rootNodes,
|
||||
treeKey,
|
||||
type ServiceNode,
|
||||
} from "./model";
|
||||
import {
|
||||
bytesToUnits,
|
||||
formatCreatedDetailed,
|
||||
parseContainerInspect,
|
||||
parseImageInspect,
|
||||
parseNetworkInspect,
|
||||
parsePercent,
|
||||
parsePortMappings,
|
||||
parseUsagePair,
|
||||
splitLayerInstruction,
|
||||
unitsToBytes,
|
||||
} from "./dashboard";
|
||||
import { endpointFrom } from "./dialogs";
|
||||
import { dockerItems } from "./docker";
|
||||
import { setDockerFilters, useServices } from "./store";
|
||||
import { addServiceMenu, expandableKeys, indexTree, setProviderRoots, useServicesTree } from "./tree";
|
||||
|
||||
const container = (name: string, state: string, project?: string, service?: string): DockerContainer => ({
|
||||
@@ -54,18 +74,36 @@ const snapshot: DockerSnapshot = {
|
||||
};
|
||||
|
||||
describe("services tree", () => {
|
||||
it("groups containers by compose project before standalone ones", () => {
|
||||
it("groups compose projects at the top, followed by Dev Containers, Containers, Images, Networks and Volumes", () => {
|
||||
const children = childrenOf(snapshot);
|
||||
const [docker] = rootNodes();
|
||||
const groups = children(docker!)!;
|
||||
expect(groups.map(nodeLabel)).toEqual(["Containers", "Images", "Volumes", "Networks"]);
|
||||
expect(nodeDetail(groups[0]!)).toBe("3");
|
||||
const containers = children(groups[0]!)!;
|
||||
expect(containers.map((n) => n.kind)).toEqual(["compose", "container"]);
|
||||
const shop = containers[0] as Extract<ServiceNode, { kind: "compose" }>;
|
||||
expect(groups.map(nodeLabel)).toEqual([
|
||||
"Docker-compose: shop",
|
||||
"Dev Containers",
|
||||
"Containers",
|
||||
"Images",
|
||||
"Networks",
|
||||
"Volumes",
|
||||
]);
|
||||
expect(groups.map(nodeIcon)).toEqual([
|
||||
"docker-compose-running",
|
||||
"docker-devcontainer",
|
||||
"docker-containers",
|
||||
"docker-images",
|
||||
"docker-networks",
|
||||
"docker-volumes",
|
||||
]);
|
||||
const shop = groups[0] as Extract<ServiceNode, { kind: "compose" }>;
|
||||
expect(nodeDetail(shop)).toBe("1/2 running");
|
||||
expect(children(shop)!.map(nodeLabel)).toEqual(["api", "db"]);
|
||||
expect(nodeLabel(children(groups[1]!)![0]!)).toBe("postgres:16");
|
||||
expect(children(shop)!.map(nodeIcon)).toEqual(["docker-compose-service-stopped", "docker-compose-service-running"]);
|
||||
expect(nodeDetail(groups[2]!)).toBe("1");
|
||||
const containers = children(groups[2]!)!;
|
||||
expect(containers.map((n) => n.kind)).toEqual(["container"]);
|
||||
expect(nodeIcon(containers[0]!)).toBe("docker-container-stopped");
|
||||
expect(nodeLabel(children(groups[3]!)![0]!)).toBe("postgres:16");
|
||||
expect(nodeIcon(children(groups[3]!)![0]!)).toBe("docker-image");
|
||||
});
|
||||
|
||||
it("finds nodes again in a new snapshot", () => {
|
||||
@@ -88,20 +126,19 @@ describe("services tree host", () => {
|
||||
|
||||
it("puts every connection and registry at the root, with connection-scoped keys", () => {
|
||||
const items = dockerItems(conns, [{ id: "r1", name: "GHCR", address: "ghcr.io", username: "me" }], state);
|
||||
expect(items.map((i) => [i.key, i.label, i.detail])).toEqual([
|
||||
["docker:default", "Docker", undefined],
|
||||
["docker:c2", "Build box", undefined],
|
||||
["docker-registry:r1", "GHCR", "ghcr.io"],
|
||||
expect(items.map((i) => [i.key, i.label, i.detail, i.icon])).toEqual([
|
||||
["docker:default", "Docker", undefined, "docker-connected"],
|
||||
["docker:c2", "Build box", undefined, "file-docker"],
|
||||
["docker-registry:r1", "GHCR", "ghcr.io", "docker-registry"],
|
||||
]);
|
||||
expect(items[1]!.dim).toBe(true);
|
||||
expect(items[1]!.children).toEqual([]);
|
||||
const containers = items[0]!.children![0]!;
|
||||
const compose = items[0]!.children![0]!;
|
||||
expect(compose.key).toBe("docker:default:compose:shop");
|
||||
const containers = items[0]!.children![2]!;
|
||||
expect(containers.key).toBe("docker:default:group:containers");
|
||||
expect(containers.children!.map((c) => c.key)).toEqual([
|
||||
"docker:default:compose:shop",
|
||||
"docker:default:container:web-id",
|
||||
]);
|
||||
expect(containers.children![1]!.dim).toBe(true);
|
||||
expect(containers.children!.map((c) => c.key)).toEqual(["docker:default:container:web-id"]);
|
||||
expect(containers.children![0]!.dim).toBe(true);
|
||||
const index = indexTree({ docker: items });
|
||||
expect(index.get("docker:default:container:shop-db-1-id")?.item.label).toBe("db");
|
||||
expect(expandableKeys(items)).toContain("docker:default:compose:shop");
|
||||
@@ -272,3 +309,211 @@ describe("docker run targets", () => {
|
||||
expect(shellQuote('a "b"', true)).toBe('"a ""b"""');
|
||||
});
|
||||
});
|
||||
|
||||
describe("phase 1: filters, cross-references and dashboard parsers", () => {
|
||||
const richSnapshot: DockerSnapshot = {
|
||||
...snapshot,
|
||||
containers: [
|
||||
{
|
||||
...container("web", "exited"),
|
||||
image: "nginx:1.27",
|
||||
ports: "0.0.0.0:8080->80/tcp",
|
||||
mounts: [],
|
||||
networks: ["bridge"],
|
||||
},
|
||||
{
|
||||
...container("shop-db-1", "running", "shop", "db"),
|
||||
image: "postgres:16",
|
||||
ports: "127.0.0.1:5432->5432/tcp",
|
||||
mounts: ["shop_pgdata"],
|
||||
networks: ["shop_default"],
|
||||
},
|
||||
{
|
||||
...container("shop-api-1", "exited", "shop", "api"),
|
||||
image: "postgres:16",
|
||||
mounts: [],
|
||||
networks: ["shop_default"],
|
||||
},
|
||||
],
|
||||
images: [
|
||||
{ id: "sha256:1111111111112222", repository: "postgres", tag: "16", size: "431MB", createdSince: "7 weeks ago" },
|
||||
{
|
||||
id: "sha256:1111111111112222",
|
||||
repository: "postgres",
|
||||
tag: "latest",
|
||||
size: "431MB",
|
||||
createdSince: "7 weeks ago",
|
||||
},
|
||||
{
|
||||
id: "sha256:aaaaaaaaaaaa0000",
|
||||
repository: "<none>",
|
||||
tag: "<none>",
|
||||
size: "120MB",
|
||||
createdSince: "4 weeks ago",
|
||||
},
|
||||
],
|
||||
volumes: [
|
||||
{
|
||||
name: "shop_pgdata",
|
||||
driver: "local",
|
||||
mountpoint: "/var/lib/docker/volumes/shop_pgdata/_data",
|
||||
composeProject: "shop",
|
||||
},
|
||||
],
|
||||
networks: [{ id: "net-1", name: "shop_default", driver: "bridge", scope: "local", composeProject: "shop" }],
|
||||
};
|
||||
|
||||
it("filters stopped containers and untagged images when toggled off", () => {
|
||||
const filtered = childrenOf(richSnapshot, { showStoppedContainers: false, showUntaggedImages: false });
|
||||
const [docker] = rootNodes();
|
||||
const groups = filtered(docker!)!;
|
||||
expect(groups.map(nodeLabel)).toEqual([
|
||||
"Docker-compose: shop",
|
||||
"Dev Containers",
|
||||
"Containers",
|
||||
"Images",
|
||||
"Networks",
|
||||
"Volumes",
|
||||
]);
|
||||
const shopChildren = filtered(groups[0]!)!;
|
||||
expect(shopChildren.map(nodeLabel)).toEqual(["db"]);
|
||||
expect(nodeDetail(groups[2]!)).toBeUndefined();
|
||||
expect(nodeDetail(groups[3]!)).toBe("2");
|
||||
const imageNodes = filtered(groups[3]!)!;
|
||||
expect(imageNodes.map(nodeLabel)).toEqual(["postgres:16", "postgres:latest"]);
|
||||
expect(isUntaggedImage(richSnapshot.images[2]!)).toBe(true);
|
||||
expect(isUntaggedImage(richSnapshot.images[0]!)).toBe(false);
|
||||
|
||||
setDockerFilters({ showStoppedContainers: false });
|
||||
expect(useServices.getState().filters.showStoppedContainers).toBe(false);
|
||||
setDockerFilters({ showStoppedContainers: true, showUntaggedImages: true });
|
||||
});
|
||||
|
||||
it("cross-references images, volumes and networks with containers", () => {
|
||||
const pg = richSnapshot.images[0]!;
|
||||
expect(containersUsingImage(richSnapshot, pg).map((c) => c.name)).toEqual(["shop-db-1", "shop-api-1"]);
|
||||
expect(imageTagsForId(richSnapshot, pg)).toEqual(["postgres:16", "postgres:latest"]);
|
||||
expect(findImageForContainer(richSnapshot, richSnapshot.containers[1]!)?.id).toBe(pg.id);
|
||||
expect(containersUsingVolume(richSnapshot, richSnapshot.volumes[0]!).map((c) => c.name)).toEqual(["shop-db-1"]);
|
||||
expect(containersInNetwork(richSnapshot, richSnapshot.networks[0]!).map((c) => c.name)).toEqual([
|
||||
"shop-db-1",
|
||||
"shop-api-1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses units, usage pairs, timestamps, ports, layers and inspect payloads", () => {
|
||||
expect(unitsToBytes("50MiB")).toBe(50 * 1024 * 1024);
|
||||
expect(unitsToBytes("431MB")).toBe(431 * 1000 * 1000);
|
||||
expect(bytesToUnits(50 * 1024 * 1024)).toBe("50.0 MiB");
|
||||
expect(parsePercent("12.5%")).toBe(12.5);
|
||||
const pair = parseUsagePair("512MiB / 2GiB");
|
||||
expect(pair.percent).toBeCloseTo(25, 1);
|
||||
|
||||
const formatted = formatCreatedDetailed("2026-10-01T15:00:00Z", Date.parse("2026-10-03T17:00:00Z"), "UTC");
|
||||
expect(formatted).toContain("2d 2h ago");
|
||||
expect(formatted).toContain("2026-10-01 15:00:00");
|
||||
const localFormatted = formatCreatedDetailed("2026-10-01T15:00:00Z", Date.parse("2026-10-03T17:00:00Z"));
|
||||
expect(localFormatted).toContain("2d 2h ago");
|
||||
|
||||
const ports = parsePortMappings("0.0.0.0:8080->80/tcp, 5432/tcp");
|
||||
expect(ports).toEqual([
|
||||
{
|
||||
hostIp: "0.0.0.0",
|
||||
hostPort: "8080",
|
||||
containerPort: "80",
|
||||
protocol: "tcp",
|
||||
url: "http://localhost:8080",
|
||||
label: "0.0.0.0:8080 -> 80/tcp",
|
||||
},
|
||||
{
|
||||
hostIp: "",
|
||||
hostPort: null,
|
||||
containerPort: "5432",
|
||||
protocol: "tcp",
|
||||
url: null,
|
||||
label: "5432/tcp",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(splitLayerInstruction('/bin/sh -c #(nop) CMD ["postgres"]')).toEqual({
|
||||
instruction: "CMD",
|
||||
args: '["postgres"]',
|
||||
});
|
||||
expect(splitLayerInstruction("RUN /bin/sh -c apt-get update # buildkit")).toEqual({
|
||||
instruction: "RUN",
|
||||
args: "/bin/sh -c apt-get update",
|
||||
});
|
||||
|
||||
const cInspect = parseContainerInspect(
|
||||
JSON.stringify([
|
||||
{
|
||||
Created: "2026-10-01T15:00:00Z",
|
||||
Image: "sha256:1111",
|
||||
State: { Pid: 4321, ExitCode: 0, StartedAt: "2026-10-01T15:00:01Z", Health: { Status: "healthy" } },
|
||||
Config: {
|
||||
User: "postgres",
|
||||
WorkingDir: "/var/lib/postgresql",
|
||||
Entrypoint: ["docker-entrypoint.sh"],
|
||||
Cmd: ["postgres"],
|
||||
Env: ["POSTGRES_DB=shop", "PGDATA=/var/lib/postgresql/data"],
|
||||
Labels: { "com.docker.compose.project": "shop" },
|
||||
},
|
||||
HostConfig: { RestartPolicy: { Name: "unless-stopped", MaximumRetryCount: 0 } },
|
||||
Mounts: [
|
||||
{
|
||||
Type: "volume",
|
||||
Name: "shop_pgdata",
|
||||
Source: "/var/lib/docker/volumes/shop_pgdata/_data",
|
||||
Destination: "/var/lib/postgresql/data",
|
||||
Mode: "z",
|
||||
RW: true,
|
||||
},
|
||||
],
|
||||
NetworkSettings: {
|
||||
Ports: { "5432/tcp": [{ HostIp: "127.0.0.1", HostPort: "5432" }] },
|
||||
Networks: { shop_default: { IPAddress: "172.18.0.2", Gateway: "172.18.0.1" } },
|
||||
},
|
||||
},
|
||||
]),
|
||||
)!;
|
||||
expect(cInspect.pid).toBe(4321);
|
||||
expect(cInspect.healthStatus).toBe("healthy");
|
||||
expect(cInspect.restartPolicy).toBe("unless-stopped");
|
||||
expect(cInspect.env).toEqual([
|
||||
["POSTGRES_DB", "shop"],
|
||||
["PGDATA", "/var/lib/postgresql/data"],
|
||||
]);
|
||||
expect(cInspect.mounts[0]!.name).toBe("shop_pgdata");
|
||||
expect(cInspect.ports[0]!.url).toBe("http://127.0.0.1:5432");
|
||||
expect(cInspect.networks[0]!.ipAddress).toBe("172.18.0.2");
|
||||
|
||||
const iInspect = parseImageInspect(
|
||||
JSON.stringify({
|
||||
Created: "2026-09-01T08:00:00Z",
|
||||
Size: 451936256,
|
||||
Architecture: "arm64",
|
||||
Os: "linux",
|
||||
RepoTags: ["postgres:16"],
|
||||
Config: {
|
||||
ExposedPorts: { "5432/tcp": {} },
|
||||
Entrypoint: ["docker-entrypoint.sh"],
|
||||
Cmd: ["postgres"],
|
||||
Env: ["PATH=/usr/local/bin"],
|
||||
},
|
||||
}),
|
||||
)!;
|
||||
expect(iInspect.os).toBe("linux");
|
||||
expect(iInspect.architecture).toBe("arm64");
|
||||
expect(iInspect.exposedPorts).toEqual(["5432/tcp"]);
|
||||
|
||||
const nInspect = parseNetworkInspect(
|
||||
JSON.stringify({
|
||||
Created: "2026-10-01T09:00:00Z",
|
||||
IPAM: { Config: [{ Subnet: "172.18.0.0/16", Gateway: "172.18.0.1" }] },
|
||||
Containers: { c1: { Name: "shop-db-1", IPv4Address: "172.18.0.2/16" } },
|
||||
}),
|
||||
)!;
|
||||
expect(nInspect.ipamConfigs[0]!.subnet).toBe("172.18.0.0/16");
|
||||
expect(nInspect.containers[0]!.name).toBe("shop-db-1");
|
||||
});
|
||||
});
|
||||
@@ -22,8 +22,31 @@ import type {
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { call, commands, isCancelled, isTauri, nextRequestId } from "@/platform/ipc";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { DEFAULT_DOCKER_FILTERS, type DockerFilters } from "./model";
|
||||
|
||||
export type DetailTab = "log" | "inspect";
|
||||
export type DetailTab = "dashboard" | "log" | "processes" | "layers" | "inspect";
|
||||
|
||||
const FILTERS_STORAGE_KEY = "openidea.docker.filters";
|
||||
|
||||
function loadStoredFilters(): DockerFilters {
|
||||
try {
|
||||
const raw = typeof localStorage !== "undefined" ? localStorage.getItem(FILTERS_STORAGE_KEY) : null;
|
||||
if (!raw) return DEFAULT_DOCKER_FILTERS;
|
||||
const parsed = JSON.parse(raw) as Partial<DockerFilters>;
|
||||
return {
|
||||
showStoppedContainers:
|
||||
typeof parsed.showStoppedContainers === "boolean"
|
||||
? parsed.showStoppedContainers
|
||||
: DEFAULT_DOCKER_FILTERS.showStoppedContainers,
|
||||
showUntaggedImages:
|
||||
typeof parsed.showUntaggedImages === "boolean"
|
||||
? parsed.showUntaggedImages
|
||||
: DEFAULT_DOCKER_FILTERS.showUntaggedImages,
|
||||
};
|
||||
} catch {
|
||||
return DEFAULT_DOCKER_FILTERS;
|
||||
}
|
||||
}
|
||||
|
||||
/** `DockerSettings` with its defaults filled in. */
|
||||
export interface Settings {
|
||||
@@ -43,6 +66,7 @@ interface DockerState {
|
||||
/** By connection id. */
|
||||
conns: Record<string, ConnectionState>;
|
||||
detailTab: DetailTab;
|
||||
filters: DockerFilters;
|
||||
/** Long operation in progress (pull, prune, compose…): its title and last output line. */
|
||||
task: { title: string; line: string; request: RequestId } | null;
|
||||
}
|
||||
@@ -50,10 +74,25 @@ interface DockerState {
|
||||
export const useServices = create<DockerState>(() => ({
|
||||
settings: null,
|
||||
conns: {},
|
||||
detailTab: "log",
|
||||
detailTab: "dashboard",
|
||||
filters: loadStoredFilters(),
|
||||
task: null,
|
||||
}));
|
||||
|
||||
export function setDockerFilters(patch: Partial<DockerFilters>): void {
|
||||
useServices.setState((s) => {
|
||||
const filters = { ...s.filters, ...patch };
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem(FILTERS_STORAGE_KEY, JSON.stringify(filters));
|
||||
}
|
||||
} catch {
|
||||
// ignore storage quota errors
|
||||
}
|
||||
return { filters };
|
||||
});
|
||||
}
|
||||
|
||||
export const errorMessage = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
|
||||
const IDLE: ConnectionState = { snapshot: null, loading: false, error: null };
|
||||
|
||||
Reference in new issue
Заблокувати користувача