docker: logs follow a held connection, and networks can be removed
Two gaps the Docker tab had against Dockhand and against its own Services tab next door. Logs: one `docker logs -f` per viewer over a WebSocket, the shape the Services tab's journal tail already uses, instead of asking again every two seconds. stdout and stderr both, with stderr marked so the page can colour what went wrong. Docker replays the backlog down two pipes in no particular order, so the opening 300 ms is held and sorted by the stamp Docker wrote; after that the log is live and in order. A reconnect resumes from the last line rather than from the top — `--since` is inclusive and two lines can share a nanosecond, so the client drops what it has already shown by line, not by "later than". A container that has stopped ends the stream at once, so reconnects back off to 15 s. Networks: remove and prune, which images and volumes have had all along. The overview now says which containers are attached to each network, so the ✕ appears only on one nothing uses, and never on bridge, host or none. `network rm` prints the real reason and then an aggregate "exit status 1", so the message shown is the first line, not the last. Prune is not exercised against `core`: several networks here belong to stacks that are merely down, and pruning would give them new subnets on their next deploy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b8c654b61b
commit
09dc846730
+71
-23
@@ -2,7 +2,7 @@
|
||||
// logs that follow, and inspect — then images, volumes and networks. Live CPU and memory
|
||||
// come from the same container sensors everything else uses.
|
||||
import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api, DockerContainer, DockerEvent, DockerHealth, DockerInfo, ImageCheck, MachineUpdates, Stack, StackDiff, UpdateJob, UpdateJobRow } from './api'
|
||||
import { api, DockerContainer, DockerEvent, DockerHealth, DockerInfo, DockerLogLine, ImageCheck, MachineUpdates, Stack, StackDiff, UpdateJob, UpdateJobRow, wsURLPath } from './api'
|
||||
import TaskOutput from './TaskOutput'
|
||||
import StackNew, { DupFrom } from './StackNew'
|
||||
import { Sensor, fetchHistories, fmtValue, useSensorStream } from './sensors'
|
||||
@@ -16,36 +16,77 @@ type Tab = 'containers' | 'stacks' | 'updates' | 'images' | 'volumes' | 'network
|
||||
const dot = (c: DockerContainer) => (c.state !== 'running' ? (c.state === 'exited' || c.state === 'dead' ? 'bad' : 'warn') : c.health === 'unhealthy' ? 'bad' : c.health === 'starting' ? 'warn' : 'ok')
|
||||
const ports = (p: string) => [...new Set(p.split(', ').map((x) => x.replace(/^\[?::\]?:/, '').replace(/^0\.0\.0\.0:/, '').replace(/^[\d.]+:/, '')).filter((x) => x.includes('->')))].join(' ')
|
||||
|
||||
// A container's output, followed over a held connection the way the Services tab follows a
|
||||
// journal. A reconnect resumes from the last line's timestamp, so a container that restarts
|
||||
// — or a phone that slept — doesn't replay what is already on screen.
|
||||
function LogView({ id }: { id: string }) {
|
||||
const [lines, setLines] = useState<{ t: string; m: string }[]>([])
|
||||
const [lines, setLines] = useState<DockerLogLine[]>([])
|
||||
const [err, setErr] = useState('')
|
||||
const [live, setLive] = useState(false)
|
||||
const [follow, setFollow] = useState(true)
|
||||
const [paused, setPaused] = useState(false)
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const last = useRef('')
|
||||
const pausedRef = useRef(false)
|
||||
pausedRef.current = paused
|
||||
const pending = useRef<DockerLogLine[]>([])
|
||||
// What a resume has already shown: `--since` is inclusive, and two lines can share a
|
||||
// timestamp (a container writing to both streams at once), so the boundary is a set of
|
||||
// lines rather than a moment. Bounded — only the lines around a reconnect can repeat.
|
||||
const seen = useRef<{ set: Set<string>; q: string[] }>({ set: new Set(), q: [] })
|
||||
useEffect(() => {
|
||||
let stop = false; setLines([]); last.current = ''
|
||||
const tick = async () => {
|
||||
if (stop) return
|
||||
if (document.visibilityState === 'visible') {
|
||||
try {
|
||||
const r = await api.dockerLogs(id, last.current)
|
||||
const fresh = last.current ? r.lines.filter((l) => l.t > last.current) : r.lines
|
||||
if (fresh.length) { last.current = fresh[fresh.length - 1].t || last.current; setLines((old) => [...old, ...fresh].slice(-2000)) }
|
||||
setErr('')
|
||||
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||
setLines([]); setErr(''); setLive(false); last.current = ''; pending.current = []
|
||||
seen.current = { set: new Set(), q: [] }
|
||||
let closed = false
|
||||
let sock: WebSocket | null = null
|
||||
let retry: number | undefined
|
||||
let attempt = 0
|
||||
const connect = () => {
|
||||
if (closed) return
|
||||
const q = last.current ? `since=${encodeURIComponent(last.current)}` : 'n=300'
|
||||
// fixed when the log opens: a reconnect must not follow the console to another machine
|
||||
const s = new WebSocket(wsURLPath(`/api/docker/containers/${encodeURIComponent(id)}/logs/tail?${q}`))
|
||||
sock = s
|
||||
s.onopen = () => { attempt = 0; setLive(true); setErr('') }
|
||||
s.onmessage = (ev) => {
|
||||
const l = JSON.parse(ev.data) as DockerLogLine
|
||||
if (l.t) {
|
||||
const k = l.t + '|' + (l.err ? 'e' : 'o') + '|' + l.m
|
||||
const { set, q } = seen.current
|
||||
if (set.has(k)) return
|
||||
set.add(k); q.push(k)
|
||||
if (q.length > 1000) for (const old of q.splice(0, 500)) set.delete(old)
|
||||
if (l.t > last.current) last.current = l.t
|
||||
}
|
||||
if (pausedRef.current) { pending.current.push(l); return }
|
||||
setLines((prev) => (prev.length > 2000 ? [...prev.slice(-1500), l] : [...prev, l]))
|
||||
}
|
||||
window.setTimeout(tick, 2000)
|
||||
// a container that has stopped — or was never there — ends the stream at once, so
|
||||
// back off rather than asking Docker the same question twice a second forever
|
||||
s.onclose = () => { if (closed) return; setLive(false); retry = window.setTimeout(connect, Math.min(15000, 1000 * 2 ** attempt++)) }
|
||||
s.onerror = () => { setErr('log stream disconnected'); s.close() }
|
||||
}
|
||||
tick()
|
||||
return () => { stop = true }
|
||||
connect()
|
||||
return () => { closed = true; clearTimeout(retry); sock?.close() }
|
||||
}, [id])
|
||||
useEffect(() => { if (follow && box.current) box.current.scrollTop = box.current.scrollHeight }, [lines, follow])
|
||||
// Resume: flush what arrived while paused.
|
||||
useEffect(() => {
|
||||
if (paused || pending.current.length === 0) return
|
||||
const p = pending.current; pending.current = []
|
||||
setLines((prev) => [...prev, ...p].slice(-2000))
|
||||
}, [paused])
|
||||
useEffect(() => { if (follow && !paused && box.current) box.current.scrollTop = box.current.scrollHeight }, [lines, follow, paused])
|
||||
return (
|
||||
<div className="logs">
|
||||
<div className="logs-bar"><span className="muted">{lines.length} lines</span><span className="spacer" /><button className={'chip' + (follow ? ' on' : '')} onClick={() => setFollow(!follow)}>follow</button><button className="chip" onClick={() => setLines([])}>Clear</button></div>
|
||||
{err && <div className="error">{err}</div>}
|
||||
<div className="logs-bar">
|
||||
<span className={'dot ' + (live ? 'ok' : 'bad')} /> <span className="muted">{live ? 'following' : 'reconnecting…'} · {lines.length} lines{err && ` · ${err}`}</span>
|
||||
<span className="spacer" />
|
||||
<button className={'chip' + (follow ? ' on' : '')} onClick={() => setFollow(!follow)}>follow</button>
|
||||
<button className="chip" onClick={() => setPaused(!paused)}>{paused ? `Resume${pending.current.length ? ` (${pending.current.length})` : ''}` : 'Pause'}</button>
|
||||
<button className="chip" onClick={() => setLines([])}>Clear</button>
|
||||
</div>
|
||||
<div className="log-lines" ref={box} onScroll={(e) => { const el = e.currentTarget; if (el.scrollHeight - el.scrollTop - el.clientHeight > 40) setFollow(false) }}>
|
||||
{lines.map((l, i) => <div className="log" key={i}><span className="lt">{l.t ? new Date(l.t).toLocaleTimeString() : ''}</span><span className="lm">{l.m}</span></div>)}
|
||||
{lines.map((l, i) => <div className={'log' + (l.err ? ' err' : '')} key={i}><span className="lt">{l.t ? new Date(l.t).toLocaleTimeString() : ''}</span><span className="lm">{l.m}</span></div>)}
|
||||
{lines.length === 0 && !err && <div className="muted small" style={{ padding: 8 }}>No output yet.</div>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -381,7 +422,7 @@ export default function Docker() {
|
||||
const shownTab: Tab = selStack ? 'stacks' : tab
|
||||
const up = d.containers.filter((c) => c.state === 'running').length
|
||||
const sid = (c: DockerContainer, k: string) => sensors[`container.${c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')}.${k}`]
|
||||
const cols: Record<Exclude<Tab, 'containers' | 'stacks' | 'updates'>, [string, string][]> = { images: [['Repository', 'Repository'], ['Tag', 'Tag'], ['Size', 'Size'], ['CreatedSince', 'Created']], volumes: [['Name', 'Name'], ['Driver', 'Driver'], ['Mountpoint', 'Mountpoint']], networks: [['Name', 'Name'], ['Driver', 'Driver'], ['Scope', 'Scope']] }
|
||||
const imageCols: [string, string][] = [['Repository', 'Repository'], ['Tag', 'Tag'], ['Size', 'Size'], ['CreatedSince', 'Created']]
|
||||
return (
|
||||
<div className={'svc docker' + (((cur || sel) && shownTab === 'containers') || selStack ? ' has-sel' : '')}>
|
||||
<div className="svc-list">
|
||||
@@ -395,6 +436,7 @@ export default function Docker() {
|
||||
<Btn className="btn" title="remove dangling images: layers no tag points at any more" confirm="Remove dangling images? Only layers that no tag points at any more — nothing a container or a tag uses." onClick={async () => { try { toast((await api.pruneImages()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune</Btn>
|
||||
</form>}
|
||||
{shownTab === 'volumes' && (() => { const n = d.volumes.filter((v) => v.anonymous && !((v.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">Deleting a volume deletes its data. Only one nothing uses can be deleted.</span><span className="spacer" />{n > 0 && <Btn className="btn danger" title="the nameless volumes an image's VOLUME line leaves behind each time its container is recreated" confirm={`Delete ${n} unused anonymous volume${n === 1 ? '' : 's'} and what is in them? Named volumes are not touched.`} onClick={async () => { try { toast((await api.pruneVolumes()).result); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune anonymous · {n}</Btn>}</div> })()}
|
||||
{shownTab === 'networks' && (() => { const n = d.networks.filter((x) => !x.predefined && !((x.used_by as string[] | undefined) ?? []).length).length; return <div className="svc-actions"><span className="muted small">A network holds no data. Only one nothing is attached to can be removed.</span><span className="spacer" />{n > 0 && <Btn className="btn" title="remove every network no container is attached to" confirm={`Remove ${n} network${n === 1 ? '' : 's'} nothing is attached to? A stack that is only down counts as unattached — its network comes back on the next deploy, with a new subnet.`} onClick={async () => { try { toast((await api.pruneNetworks()).result || 'nothing to remove'); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>Prune · {n}</Btn>}</div> })()}
|
||||
{shownTab === 'images' && pullTask && <TaskOutput id={pullTask} onDone={() => load()} />}
|
||||
{shownTab === 'containers' && <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="filter containers, images, stacks" />}
|
||||
</div>
|
||||
@@ -431,9 +473,15 @@ export default function Docker() {
|
||||
return <tr key={name}><td className="mono small" title={String(row.Mountpoint ?? '')}>{anon ? <>{name.slice(0, 12)}… <span className="muted">anonymous</span></> : name}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge">in use</span> : <button className="icon-btn" title="delete this volume and its data" onClick={remove}>✕</button>}</td></tr> })}
|
||||
{d.volumes.length === 0 && <tr><td colSpan={3} className="muted small">No volumes.</td></tr>}</tbody></table>
|
||||
)}
|
||||
{shownTab !== 'containers' && shownTab !== 'stacks' && shownTab !== 'volumes' && shownTab !== 'updates' && (
|
||||
<table className="dtable"><thead><tr>{cols[shownTab as Exclude<Tab, 'containers' | 'stacks' | 'volumes' | 'updates'>].map(([, h]) => <th key={h}>{h}</th>)}{shownTab === 'images' && <th />}</tr></thead>
|
||||
<tbody>{d[shownTab as Exclude<Tab, 'containers' | 'stacks' | 'volumes' | 'updates'>].map((row, i) => <tr key={i}>{cols[shownTab as Exclude<Tab, 'containers' | 'stacks' | 'volumes' | 'updates'>].map(([k]) => <td key={k} className="mono small">{String(row[k] ?? '')}</td>)}{shownTab === 'images' && <td className="row-end">{row.in_use ? <span className="badge" title="a container uses this image">in use</span> : <Btn className="icon-btn" title="remove this image" confirm={`Remove the image ${row.Repository}:${row.Tag}? Docker refuses if a container still uses it.`} onClick={async () => { const ref = row.Tag && row.Tag !== '<none>' ? `${row.Repository}:${row.Tag}` : String(row.ID); try { await api.removeImage(ref); toast(`${ref} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕</Btn>}</td>}</tr>)}</tbody></table>
|
||||
{shownTab === 'networks' && (
|
||||
<table className="dtable"><thead><tr><th>Network</th><th>Driver</th><th>Used by</th><th /></tr></thead>
|
||||
<tbody>{d.networks.map((row, i) => { const name = String(row.Name), users = (row.used_by as string[] | undefined) ?? [], own = !!row.predefined
|
||||
return <tr key={i}><td className="mono small">{name}{own && <span className="muted"> · Docker's own</span>}</td><td className="mono small">{String(row.Driver ?? '')}</td><td className="small">{users.length ? users.join(', ') : <span className="muted">nothing</span>}</td><td className="row-end">{users.length ? <span className="badge" title="a container is attached to this network">in use</span> : own ? null : <Btn className="icon-btn" title="remove this network" confirm={`Remove the network ${name}? Nothing is attached to it. A stack that wants it back gets it recreated on its next deploy, with a new subnet.`} onClick={async () => { try { await api.removeNetwork(name); toast(`${name} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕</Btn>}</td></tr> })}
|
||||
{d.networks.length === 0 && <tr><td colSpan={4} className="muted small">No networks.</td></tr>}</tbody></table>
|
||||
)}
|
||||
{shownTab === 'images' && (
|
||||
<table className="dtable"><thead><tr>{imageCols.map(([, h]) => <th key={h}>{h}</th>)}<th /></tr></thead>
|
||||
<tbody>{d.images.map((row, i) => <tr key={i}>{imageCols.map(([k]) => <td key={k} className="mono small">{String(row[k] ?? '')}</td>)}<td className="row-end">{row.in_use ? <span className="badge" title="a container uses this image">in use</span> : <Btn className="icon-btn" title="remove this image" confirm={`Remove the image ${row.Repository}:${row.Tag}? Docker refuses if a container still uses it.`} onClick={async () => { const ref = row.Tag && row.Tag !== '<none>' ? `${row.Repository}:${row.Tag}` : String(row.ID); try { await api.removeImage(ref); toast(`${ref} removed`); load() } catch (er) { toast(er instanceof Error ? er.message : 'failed', 'err') } }}>✕</Btn>}</td></tr>)}</tbody></table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+4
-1
@@ -117,6 +117,7 @@ export interface DockerHealthProbe { Start: string; End: string; ExitCode: numbe
|
||||
export interface DockerHealth { configured: boolean; state: string; status?: string; failing_streak?: number; log?: DockerHealthProbe[] }
|
||||
export interface DockerEvent { t: number; name: string; action: string; image?: string; exit_code?: string; signal?: string; health?: string }
|
||||
|
||||
export interface DockerLogLine { t: string; m: string; err?: boolean }
|
||||
export interface DockerInfo { available: boolean; message?: string; tool?: string; containers: DockerContainer[]; images: Record<string, unknown>[]; volumes: Record<string, unknown>[]; networks: Record<string, unknown>[] }
|
||||
export interface Stack { name: string; containers: string[]; running: number; file: string; host_file: string; files: string[]; host_files: string[] | null; images: string[] | null; via: string; remote: string; held_by: string; editable: boolean; note?: string; has_env: boolean }
|
||||
export interface StackHome { dir: string; inner: string; via: string; remote: string; held_by: string; stacks: number; default: boolean }
|
||||
@@ -254,7 +255,7 @@ export const api = {
|
||||
createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true),
|
||||
docker: () => req<DockerInfo>('GET', '/api/docker'),
|
||||
dockerAction: (id: string, action: 'start' | 'stop' | 'restart' | 'remove') => req<void>('POST', `/api/docker/containers/${encodeURIComponent(id)}/${action}`),
|
||||
dockerLogs: (id: string, since = '') => req<{ lines: { t: string; m: string }[] }>('GET', `/api/docker/containers/${encodeURIComponent(id)}/logs${since ? `?since=${encodeURIComponent(since)}` : '?n=300'}`),
|
||||
dockerLogs: (id: string, since = '') => req<{ lines: DockerLogLine[] }>('GET', `/api/docker/containers/${encodeURIComponent(id)}/logs${since ? `?since=${encodeURIComponent(since)}` : '?n=300'}`),
|
||||
dockerInspect: (id: string) => req<Record<string, unknown>>('GET', `/api/docker/containers/${encodeURIComponent(id)}/inspect`),
|
||||
machineUpdates: (refresh = false) => req<MachineUpdates>('GET', `/api/docker/updates${refresh ? '?refresh=1' : ''}`),
|
||||
updateJobs: () => req<{ jobs: UpdateJobRow[]; file: string }>('GET', '/api/docker/schedules'),
|
||||
@@ -274,6 +275,8 @@ export const api = {
|
||||
pruneImages: (all = false) => req<{ result: string }>('POST', '/api/docker/images/prune', { all }),
|
||||
removeVolume: (name: string) => req<void>('POST', '/api/docker/volumes/remove', { name, confirm: name }),
|
||||
pruneVolumes: () => req<{ result: string }>('POST', '/api/docker/volumes/prune'),
|
||||
removeNetwork: (name: string) => req<void>('POST', '/api/docker/networks/remove', { name }),
|
||||
pruneNetworks: () => req<{ result: string }>('POST', '/api/docker/networks/prune'),
|
||||
stackDiff: (name: string) => req<{ files: StackDiff[] }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/diff`),
|
||||
stackUpdates: (name: string, refresh = false) => req<{ images: ImageCheck[]; checked: number }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/updates${refresh ? '?refresh=1' : ''}`),
|
||||
stackFile: (name: string, file: number | 'env' = 0) => req<{ stack: Stack; text: string; sum: string }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`),
|
||||
|
||||
Reference in New Issue
Block a user