feat(docker): add Services dashboards, layers, processes, view filters and log editor tabs (#2246)

Цей коміт міститься в:
Deng Liu authored and GitHub committed 2026-11-09 15:24:52 +08:00
1 джерело a206ac0ca5
коміт c5124b809d
52 files changed
+3679 -137

No files matched your search

+277
Переглянути файл
@@ -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;
}
+14
Переглянути файл
@@ -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
+142 -36
Переглянути файл
@@ -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() },
],
};
+71 -2
Переглянути файл
@@ -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();
+442
Переглянути файл
@@ -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>
);
}
+160 -50
Переглянути файл
@@ -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";
}
}
+51 -5
Переглянути файл
@@ -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)) {
+262 -17
Переглянути файл
@@ -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");
});
});
+41 -2
Переглянути файл
@@ -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 };