- F1: cover the hook's riskiest path — a following stream that ends with an unwritten tail fragment then resumes (tail:0 + nano-since), asserting the fragment is dropped, resume params are correct, and the boundary line is deduped to one; plus MAX_LOG_LINES head-trim and buffer reset on resourceId/lineCount change. - F2: clear the error banner on a SUCCESSFUL reconnect (via a new onOpen signal on StreamLogsFn), not only when new lines arrive — an idle-but-healthy reconnect no longer leaves a stuck 'unable to stream' banner. - F4: update the stale comment in the React logs view registration (the React logs migration is now complete). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
328 lines
9.5 KiB
TypeScript
328 lines
9.5 KiB
TypeScript
import _ from 'lodash';
|
|
import { InternalAxiosRequestConfig } from 'axios';
|
|
|
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
|
import PortainerError from '@/portainer/error';
|
|
import axios, {
|
|
agentTargetHeader,
|
|
parseAxiosError,
|
|
} from '@/portainer/services/axios/axios';
|
|
import {
|
|
portainerAgentManagerOperation,
|
|
portainerAgentTargetHeader,
|
|
} from '@/portainer/services/http-request.helper';
|
|
import { dockerMaxAPIVersionInterceptor } from '@/portainer/services/dockerMaxApiVersionInterceptor';
|
|
|
|
import { withAgentTargetHeader } from '../proxy/queries/utils';
|
|
import { buildDockerProxyUrl } from '../proxy/queries/buildDockerProxyUrl';
|
|
import { buildDockerUrl } from '../queries/utils/buildDockerUrl';
|
|
|
|
import { ContainerId, ContainerLogsParams } from './types';
|
|
|
|
export async function startContainer(
|
|
environmentId: EnvironmentId,
|
|
id: ContainerId,
|
|
{ nodeName }: { nodeName?: string } = {}
|
|
) {
|
|
try {
|
|
await axios.post<void>(
|
|
buildDockerProxyUrl(environmentId, 'containers', id, 'start'),
|
|
{},
|
|
{
|
|
headers: { ...withAgentTargetHeader(nodeName) },
|
|
}
|
|
);
|
|
} catch (e) {
|
|
throw parseAxiosError(e, 'Failed starting container');
|
|
}
|
|
}
|
|
|
|
export async function stopContainer(
|
|
endpointId: EnvironmentId,
|
|
id: ContainerId,
|
|
{ nodeName }: { nodeName?: string } = {}
|
|
) {
|
|
try {
|
|
await axios.post<void>(
|
|
buildDockerProxyUrl(endpointId, 'containers', id, 'stop'),
|
|
{},
|
|
{ headers: { ...withAgentTargetHeader(nodeName) } }
|
|
);
|
|
} catch (e) {
|
|
throw parseAxiosError(e, 'Failed stopping container');
|
|
}
|
|
}
|
|
|
|
export async function recreateContainer(
|
|
endpointId: EnvironmentId,
|
|
id: ContainerId,
|
|
pullImage: boolean,
|
|
{ nodeName }: { nodeName?: string } = {}
|
|
) {
|
|
try {
|
|
await axios.post<void>(
|
|
buildDockerUrl(endpointId, 'containers', id, 'recreate'),
|
|
{
|
|
PullImage: pullImage,
|
|
},
|
|
{ headers: { ...withAgentTargetHeader(nodeName) } }
|
|
);
|
|
} catch (e) {
|
|
throw parseAxiosError(e, 'Failed recreating container');
|
|
}
|
|
}
|
|
|
|
export async function restartContainer(
|
|
endpointId: EnvironmentId,
|
|
id: ContainerId,
|
|
{ nodeName }: { nodeName?: string } = {}
|
|
) {
|
|
try {
|
|
await axios.post<void>(
|
|
buildDockerProxyUrl(endpointId, 'containers', id, 'restart'),
|
|
{},
|
|
{ headers: { ...withAgentTargetHeader(nodeName) } }
|
|
);
|
|
} catch (e) {
|
|
throw parseAxiosError(e, 'Failed restarting container');
|
|
}
|
|
}
|
|
|
|
export async function killContainer(
|
|
endpointId: EnvironmentId,
|
|
id: ContainerId,
|
|
{ nodeName }: { nodeName?: string } = {}
|
|
) {
|
|
try {
|
|
await axios.post<void>(
|
|
buildDockerProxyUrl(endpointId, 'containers', id, 'kill'),
|
|
{},
|
|
{ headers: { ...withAgentTargetHeader(nodeName) } }
|
|
);
|
|
} catch (e) {
|
|
throw parseAxiosError(e, 'Failed killing container');
|
|
}
|
|
}
|
|
|
|
export async function pauseContainer(
|
|
endpointId: EnvironmentId,
|
|
id: ContainerId,
|
|
{ nodeName }: { nodeName?: string } = {}
|
|
) {
|
|
try {
|
|
await axios.post<void>(
|
|
buildDockerProxyUrl(endpointId, 'containers', id, 'pause'),
|
|
{},
|
|
{ headers: { ...withAgentTargetHeader(nodeName) } }
|
|
);
|
|
} catch (e) {
|
|
throw parseAxiosError(e, 'Failed pausing container');
|
|
}
|
|
}
|
|
|
|
export async function resumeContainer(
|
|
endpointId: EnvironmentId,
|
|
id: ContainerId,
|
|
{ nodeName }: { nodeName?: string } = {}
|
|
) {
|
|
try {
|
|
await axios.post<void>(
|
|
buildDockerProxyUrl(endpointId, 'containers', id, 'unpause'),
|
|
{},
|
|
{ headers: { ...withAgentTargetHeader(nodeName) } }
|
|
);
|
|
} catch (e) {
|
|
throw parseAxiosError(e, 'Failed resuming container');
|
|
}
|
|
}
|
|
|
|
export async function renameContainer(
|
|
endpointId: EnvironmentId,
|
|
id: ContainerId,
|
|
name: string,
|
|
{ nodeName }: { nodeName?: string } = {}
|
|
) {
|
|
try {
|
|
await axios.post<void>(
|
|
buildDockerProxyUrl(endpointId, 'containers', id, 'rename'),
|
|
{},
|
|
{
|
|
params: { name },
|
|
headers: { ...withAgentTargetHeader(nodeName) },
|
|
}
|
|
);
|
|
} catch (e) {
|
|
throw parseAxiosError(e, 'Failed renaming container');
|
|
}
|
|
}
|
|
|
|
export async function removeContainer(
|
|
endpointId: EnvironmentId,
|
|
containerId: string,
|
|
{
|
|
nodeName,
|
|
removeVolumes,
|
|
}: { removeVolumes?: boolean; nodeName?: string } = {}
|
|
) {
|
|
try {
|
|
const { data } = await axios.delete<null | { message: string }>(
|
|
buildDockerProxyUrl(endpointId, 'containers', containerId),
|
|
{
|
|
params: { v: removeVolumes ? 1 : 0, force: true },
|
|
headers: { ...withAgentTargetHeader(nodeName) },
|
|
}
|
|
);
|
|
|
|
if (data && data.message) {
|
|
throw new PortainerError(data.message);
|
|
}
|
|
} catch (e) {
|
|
throw parseAxiosError(e, 'Unable to remove container');
|
|
}
|
|
}
|
|
|
|
export async function getContainerLogs(
|
|
environmentId: EnvironmentId,
|
|
containerId: ContainerId,
|
|
params?: ContainerLogsParams
|
|
): Promise<string> {
|
|
try {
|
|
const { data } = await axios.get<string>(
|
|
buildDockerProxyUrl(environmentId, 'containers', containerId, 'logs'),
|
|
{
|
|
params: _.pickBy(params),
|
|
}
|
|
);
|
|
|
|
return data;
|
|
} catch (e) {
|
|
throw parseAxiosError(e, 'Unable to get container logs');
|
|
}
|
|
}
|
|
|
|
export type StreamLogsParams = ContainerLogsParams & {
|
|
/** follow=1 — keep the connection open and stream new lines as they arrive */
|
|
follow?: boolean;
|
|
};
|
|
|
|
// Memoize the Docker max-API-version pinning per proxy path for the session. The
|
|
// pinning interceptor only rewrites the URL based on the environment's Docker API
|
|
// version (cached and stable for the session), so resolving it once avoids an
|
|
// extra `/version` round-trip on every reconnect. Caching the Promise also
|
|
// dedupes concurrent reconnect attempts.
|
|
const pinnedLogPathCache = new Map<string, Promise<string>>();
|
|
|
|
function resolvePinnedLogPath(path: string): Promise<string> {
|
|
let cached = pinnedLogPathCache.get(path);
|
|
if (!cached) {
|
|
cached = dockerMaxAPIVersionInterceptor({
|
|
url: path,
|
|
} as InternalAxiosRequestConfig).then((config) => config.url ?? path);
|
|
pinnedLogPathCache.set(path, cached);
|
|
}
|
|
return cached;
|
|
}
|
|
|
|
/**
|
|
* Live-tail a container's logs over HTTP.
|
|
*
|
|
* Unlike `getContainerLogs` (axios, buffers the whole body), this uses `fetch`
|
|
* so we can read the response body as a `ReadableStream` and surface the raw
|
|
* byte chunks to the caller as they arrive (`follow=1`). Bytes are handed over
|
|
* undecoded so the caller can demux Docker's binary multiplexed frames at the
|
|
* byte level (UTF-8-decoding the whole stream would corrupt frame headers). The
|
|
* backend already
|
|
* streams `follow=1` transparently through the Docker proxy — including for
|
|
* Agent/Edge environments — so no backend change is needed.
|
|
*
|
|
* Auth: the API uses an httpOnly JWT cookie (SameSite=Strict), so a same-origin
|
|
* `fetch` with `credentials: 'include'` carries it automatically — no
|
|
* `Authorization` header. CSRF middleware only guards mutating methods; logs is
|
|
* a GET, so it is unaffected. The agent-target / manager-operation headers
|
|
* (normally added by the axios `agentInterceptor`) are replicated here so the
|
|
* stream also resolves the correct node on Agent/Edge environments.
|
|
*
|
|
* The caller drives lifetime via `signal`: aborting it (unmount, container
|
|
* switch, pausing Live) cancels the in-flight fetch and ends the read loop.
|
|
*/
|
|
export async function streamContainerLogs(
|
|
environmentId: EnvironmentId,
|
|
containerId: ContainerId,
|
|
params: StreamLogsParams,
|
|
onChunk: (bytes: Uint8Array) => void,
|
|
signal: AbortSignal,
|
|
onOpen?: () => void
|
|
): Promise<void> {
|
|
const path = buildDockerProxyUrl(
|
|
environmentId,
|
|
'containers',
|
|
containerId,
|
|
'logs'
|
|
);
|
|
|
|
// The fetch path bypasses the axios request interceptors, so apply the same
|
|
// Docker max-API-version pinning axios applies to getContainerLogs. Resolve it
|
|
// once per session (memoized below) instead of hitting `/version` on every 3s
|
|
// reconnect — the pinning only depends on the environment's Docker API version,
|
|
// which is stable for the session.
|
|
const effectivePath = await resolvePinnedLogPath(path);
|
|
|
|
const query = new URLSearchParams();
|
|
// _.pickBy drops undefined/0/'' the same way the axios path does
|
|
Object.entries(_.pickBy(params)).forEach(([key, value]) => {
|
|
query.set(key, String(typeof value === 'boolean' ? Number(value) : value));
|
|
});
|
|
|
|
// axios baseURL is 'api'; mirror it here since fetch doesn't share it.
|
|
const url = `api${effectivePath}?${query.toString()}`;
|
|
|
|
const headers: Record<string, string> = {};
|
|
const target = portainerAgentTargetHeader();
|
|
if (target) {
|
|
headers[agentTargetHeader] = target;
|
|
}
|
|
if (portainerAgentManagerOperation()) {
|
|
headers['X-PortainerAgent-ManagerOperation'] = '1';
|
|
}
|
|
|
|
const response = await fetch(url, {
|
|
signal,
|
|
credentials: 'include',
|
|
headers,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new PortainerError(
|
|
`Unable to stream container logs (HTTP ${response.status})`
|
|
);
|
|
}
|
|
|
|
// Stream is open (headers received, HTTP ok). Signal it before reading so the
|
|
// caller can clear a stale error even if the container is idle and never emits.
|
|
onOpen?.();
|
|
|
|
if (!response.body) {
|
|
return;
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
|
|
try {
|
|
for (;;) {
|
|
const { value, done } = await reader.read();
|
|
if (done) {
|
|
break;
|
|
}
|
|
if (value) {
|
|
// Hand over the raw bytes undecoded; the caller demuxes Docker's binary
|
|
// frames and decodes complete lines itself. UTF-8 chars split across
|
|
// chunks are reassembled there before decoding, so no decoder flush is
|
|
// needed here. The caller flushes its own trailing partial line on end.
|
|
onChunk(value);
|
|
}
|
|
}
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
}
|