atualiza versão para 0.2.2; corrige busca de volumes em containers remotos e aprimora agrupamento por Docker Compose na interface
This commit is contained in:
parent
fd72520f99
commit
c6c0db01f1
12
README.md
12
README.md
|
|
@ -9,7 +9,7 @@
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/VERSION-0.2.1-blue?style=flat-square" />
|
<img src="https://img.shields.io/badge/VERSION-0.2.2-blue?style=flat-square" />
|
||||||
<img src="https://img.shields.io/badge/NODE.JS-%3E%3D20-339933?style=flat-square&logo=node.js&logoColor=white" />
|
<img src="https://img.shields.io/badge/NODE.JS-%3E%3D20-339933?style=flat-square&logo=node.js&logoColor=white" />
|
||||||
<img src="https://img.shields.io/badge/DOCKER-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
|
<img src="https://img.shields.io/badge/DOCKER-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
|
||||||
<img src="https://img.shields.io/badge/READY-yes-brightgreen?style=flat-square" />
|
<img src="https://img.shields.io/badge/READY-yes-brightgreen?style=flat-square" />
|
||||||
|
|
@ -18,12 +18,20 @@
|
||||||
|
|
||||||
> ⚠️ **AVISO CRÍTICO:** Aplicação em estágio inicial de desenvolvimento. Não use em produção — há risco de perda de dados.
|
> ⚠️ **AVISO CRÍTICO:** Aplicação em estágio inicial de desenvolvimento. Não use em produção — há risco de perda de dados.
|
||||||
|
|
||||||
Versão atual: **0.2.1**
|
Versão atual: **0.2.2**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Changelog
|
## 📋 Changelog
|
||||||
|
|
||||||
|
### [0.2.2] — 2026-05-12
|
||||||
|
|
||||||
|
#### Corrigido
|
||||||
|
- **Volumes de container remoto:** ao clicar em um container para selecionar volumes no modal de criação de perfil, a busca de mounts agora utiliza a conexão correta (TCP remoto) quando a origem selecionada é uma conexão Docker remota (porta 2375), corrigindo o erro `ENOENT /var/run/docker.sock` que ocorria ao usar origens remotas.
|
||||||
|
- **Agrupamento por Docker Compose na lista de containers:** containers do modal de criação de profile agora são agrupados visualmente por projeto Compose, com cabeçalho identificando o stack. Containers sem Compose aparecem em grupo separado "Containers avulsos".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### [0.2.1] — 2026-05-12
|
### [0.2.1] — 2026-05-12
|
||||||
|
|
||||||
#### Adicionado
|
#### Adicionado
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "dockerbackup-app",
|
"name": "dockerbackup-app",
|
||||||
"version": "0.2.1",
|
"version": "0.2.2",
|
||||||
"description": "Aplicacao web para backup e restauracao de volumes Docker",
|
"description": "Aplicacao web para backup e restauracao de volumes Docker",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1048,6 +1048,20 @@ function isMountBlocked(destination) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderContainerOption(container, selected) {
|
||||||
|
const hasVolumeSelection = Boolean(state.volumeSelections[container.id]?.length);
|
||||||
|
return `
|
||||||
|
<label class="container-option">
|
||||||
|
<input type="checkbox" name="containerIds" value="${escapeHtml(container.id)}" ${selected.has(container.id) ? 'checked' : ''} />
|
||||||
|
<span>
|
||||||
|
<strong>${escapeHtml(container.name)}</strong>
|
||||||
|
<small>${escapeHtml(container.image)} · ${escapeHtml(container.status)}${hasVolumeSelection ? ` · ${state.volumeSelections[container.id].length} volume(s) selecionado(s)` : ''}</small>
|
||||||
|
</span>
|
||||||
|
<em class="state ${escapeHtml(container.state)}">${escapeHtml(container.state)}</em>
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderContainers() {
|
function renderContainers() {
|
||||||
elements.containerCount.textContent = String(state.containers.length);
|
elements.containerCount.textContent = String(state.containers.length);
|
||||||
|
|
||||||
|
|
@ -1059,19 +1073,48 @@ function renderContainers() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const selected = new Set(getSelectedContainerIds());
|
const selected = new Set(getSelectedContainerIds());
|
||||||
elements.containerOptions.innerHTML = eligible.map((container) => {
|
|
||||||
const hasVolumeSelection = Boolean(state.volumeSelections[container.id]?.length);
|
// Separate compose containers from standalone
|
||||||
return `
|
const composeGroups = new Map(); // project -> container[]
|
||||||
<label class="container-option">
|
const standalone = [];
|
||||||
<input type="checkbox" name="containerIds" value="${escapeHtml(container.id)}" ${selected.has(container.id) ? 'checked' : ''} />
|
|
||||||
<span>
|
for (const container of eligible) {
|
||||||
<strong>${escapeHtml(container.name)}</strong>
|
if (container.composeProject) {
|
||||||
<small>${escapeHtml(container.image)} · ${escapeHtml(container.status)}${hasVolumeSelection ? ` · ${state.volumeSelections[container.id].length} volume(s) selecionado(s)` : ''}</small>
|
if (!composeGroups.has(container.composeProject)) composeGroups.set(container.composeProject, []);
|
||||||
</span>
|
composeGroups.get(container.composeProject).push(container);
|
||||||
<em class="state ${escapeHtml(container.state)}">${escapeHtml(container.state)}</em>
|
} else {
|
||||||
</label>
|
standalone.push(container);
|
||||||
`;
|
}
|
||||||
}).join('');
|
}
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
// Compose groups first, sorted by project name
|
||||||
|
for (const [project, containers] of [...composeGroups.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
||||||
|
parts.push(`<div class="compose-group">
|
||||||
|
<div class="compose-group-header">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="13" height="13"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
|
||||||
|
<span>${escapeHtml(project)}</span>
|
||||||
|
</div>
|
||||||
|
${containers.map((c) => renderContainerOption(c, selected)).join('')}
|
||||||
|
</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standalone containers
|
||||||
|
if (standalone.length) {
|
||||||
|
const standaloneHeader = composeGroups.size > 0
|
||||||
|
? `<div class="compose-group">
|
||||||
|
<div class="compose-group-header compose-group-header--standalone">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="13" height="13"><rect x="3" y="3" width="18" height="18" rx="2"/></svg>
|
||||||
|
<span>Containers avulsos</span>
|
||||||
|
</div>
|
||||||
|
${standalone.map((c) => renderContainerOption(c, selected)).join('')}
|
||||||
|
</div>`
|
||||||
|
: standalone.map((c) => renderContainerOption(c, selected)).join('');
|
||||||
|
parts.push(standaloneHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.containerOptions.innerHTML = parts.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function backupButtons(profile) {
|
function backupButtons(profile) {
|
||||||
|
|
@ -1104,10 +1147,13 @@ function getProfileScopeLabel(scope) {
|
||||||
return scope === 'container' ? 'container inteiro' : 'somente volumes';
|
return scope === 'container' ? 'container inteiro' : 'somente volumes';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function askVolumeSelection(containerId, containerName, currentSelections) {
|
async function askVolumeSelection(containerId, containerName, currentSelections, sourceId) {
|
||||||
let mounts;
|
let mounts;
|
||||||
try {
|
try {
|
||||||
mounts = await api(`/api/containers/${encodeURIComponent(containerId)}/mounts`);
|
const mountsUrl = sourceId
|
||||||
|
? `/api/containers/${encodeURIComponent(containerId)}/mounts?sourceId=${encodeURIComponent(sourceId)}`
|
||||||
|
: `/api/containers/${encodeURIComponent(containerId)}/mounts`;
|
||||||
|
mounts = await api(mountsUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(`Falha ao buscar volumes de ${containerName}: ${error.message}`, true);
|
showToast(`Falha ao buscar volumes de ${containerName}: ${error.message}`, true);
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -1224,7 +1270,8 @@ async function handleContainerCheck(event) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentSelections = state.volumeSelections[containerId] || null;
|
const currentSelections = state.volumeSelections[containerId] || null;
|
||||||
const selected = await askVolumeSelection(containerId, containerName, currentSelections);
|
const sourceId = elements.profileSourceSelect?.value || null;
|
||||||
|
const selected = await askVolumeSelection(containerId, containerName, currentSelections, sourceId);
|
||||||
|
|
||||||
if (selected === null) {
|
if (selected === null) {
|
||||||
input.checked = false;
|
input.checked = false;
|
||||||
|
|
|
||||||
|
|
@ -639,6 +639,29 @@ button, input, select {
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compose-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-group-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 6px 3px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent, #4a6cf7);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-group-header--standalone {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.container-option {
|
.container-option {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr auto;
|
grid-template-columns: auto 1fr auto;
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,8 @@ class DockerService {
|
||||||
image: container.Image,
|
image: container.Image,
|
||||||
state: container.State,
|
state: container.State,
|
||||||
status: container.Status,
|
status: container.Status,
|
||||||
|
composeProject: (container.Labels || {})['com.docker.compose.project'] || null,
|
||||||
|
composeService: (container.Labels || {})['com.docker.compose.service'] || null,
|
||||||
}))
|
}))
|
||||||
.sort((left, right) => left.name.localeCompare(right.name));
|
.sort((left, right) => left.name.localeCompare(right.name));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,12 @@ async function main() {
|
||||||
|
|
||||||
app.get('/api/containers/:containerId/mounts', authMiddleware, async (request, response) => {
|
app.get('/api/containers/:containerId/mounts', authMiddleware, async (request, response) => {
|
||||||
try {
|
try {
|
||||||
const inspect = await dockerService.inspectContainer(request.params.containerId);
|
let ds = dockerService;
|
||||||
|
if (request.query.sourceId) {
|
||||||
|
const source = await store.getSource(String(request.query.sourceId));
|
||||||
|
if (source) ds = createDockerServiceForSource(source);
|
||||||
|
}
|
||||||
|
const inspect = await ds.inspectContainer(request.params.containerId);
|
||||||
const mounts = (inspect.Mounts || [])
|
const mounts = (inspect.Mounts || [])
|
||||||
.filter((m) => m.Type === 'bind' || m.Type === 'volume')
|
.filter((m) => m.Type === 'bind' || m.Type === 'volume')
|
||||||
.map((m) => ({
|
.map((m) => ({
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue