LXDコンテナの情報や管理を手軽に行える「EasyLXD」

LXDの操作をWebで行えるLXD-UIは多機能で便利なのですが、ちょっとだけ使用したい場合などでは、初回の接続が面倒ですよね。そこで、機能を最小限に絞った簡易的なWeb-UI「EasyLXD」を作成してみました。

EasyLXDの画面や機能

コンテナのIPアドレスやマウント状況、GPUパススルー状況を確認出来るほか、開始や停止などの操作、スナップショットの作成や復元が行えます。

新規作成では、Updateまで実行するか、Tailscaleのインストールまで行うか、Dockerのインストールまで行うか、ホストのディレクトリ(このブログでよく紹介している/opt/lxd-data)をマウントするか、までの設定が行えます。

ホストに直接インストール

3329番ポートで公開します。ホストにインストールするためLAN内に公開されてしまいますので、環境に応じて対策をしてから使ってください。
(GPUパススルー機能を追加した新しいバージョンを作りました)

sudo mkdir -p /opt/script/easylxd
cd /opt/script/easylxd
sudo nano install-exsylxd1.sh
sudo bash install-exsylxd1.sh
#!/bin/bash
set -euo pipefail

INSTALL_DIR="/opt/lxd-data/easy-lxd"
PORT=3329

echo "=== Easy LXD UI Installer ==="

# --- Node.js の確認とインストール ---
NODE_PATH=""

if command -v node &>/dev/null; then
  NODE_PATH=$(command -v node)
  echo "Node: $(node -v) ($NODE_PATH)"
else
  # sudo 環境で nvm の node を探す
  for candidate in \
    /usr/local/bin/node \
    /usr/bin/node \
    "$HOME/.nvm/versions/node"/*/bin/node \
    /root/.nvm/versions/node/*/bin/node; do
    if [ -x "$candidate" ]; then
      NODE_PATH="$candidate"
      echo "Node (found at): $NODE_PATH"
      break
    fi
  done
fi

if [ -z "$NODE_PATH" ]; then
  echo "Node.js が見つかりません。NodeSource LTS をインストールします..."
  if ! command -v curl &>/dev/null; then
    apt-get install -y curl
  fi
  curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
  apt-get install -y nodejs
  NODE_PATH=$(command -v node)
  echo "Node.js $(node -v) をインストールしました"
fi

# nvm 経由の node は systemd から参照できないためシンボリックリンクを作成
if [[ "$NODE_PATH" == *"/.nvm/"* ]]; then
  echo "nvm 環境を検出。/usr/local/bin/node にシンボリックリンクを作成します..."
  ln -sf "$NODE_PATH" /usr/local/bin/node
  NODE_PATH=/usr/local/bin/node
  echo "node -> $NODE_PATH"
fi

# 最終確認
if [ ! -x "$NODE_PATH" ]; then
  echo "ERROR: node のインストールに失敗しました" >&2
  exit 1
fi

# --- lxc の確認 ---
if ! command -v lxc &>/dev/null; then
  echo "ERROR: lxc is not installed" >&2
  exit 1
fi

echo "LXC:  $(lxc version 2>/dev/null || lxc --version)"

# --- 既存サービス停止 ---
if pgrep -f "node.*easy-lxd/server.js" &>/dev/null; then
  echo "Stopping existing Easy LXD UI..."
  pkill -f "node.*easy-lxd/server.js" 2>/dev/null || true
  sleep 1
fi

# --- ディレクトリ作成 ---
mkdir -p "$INSTALL_DIR/public"

# --- server.js ---
cat > "$INSTALL_DIR/server.js" << 'SERVEREOF'
const http = require('http');
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');

const PORT = 3329;

function run(cmd, args = [], timeout = 120000) {
  return new Promise((resolve, reject) => {
    const child = spawn(cmd, args, { timeout, stdio: ['ignore', 'pipe', 'ignore'] });
    let stdout = '';
    child.stdout.on('data', d => stdout += d);
    const timer = setTimeout(() => { child.kill('SIGKILL'); reject(new Error('Command timed out')); }, timeout);
    child.on('close', code => {
      clearTimeout(timer);
      if (code !== 0) reject(new Error(`Command failed with exit code ${code}`));
      else resolve({ stdout: stdout.trim() });
    });
    child.on('error', e => { clearTimeout(timer); reject(e); });
  });
}

function lxc(...args) {
  return run('lxc', args);
}

function parseBody(req) {
  return new Promise((resolve, reject) => {
    let data = '';
    req.on('data', chunk => data += chunk);
    req.on('end', () => {
      try { resolve(data ? JSON.parse(data) : {}); }
      catch (e) { reject(e); }
    });
  });
}

function json(res, code, obj) {
  res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
  res.end(JSON.stringify(obj));
}

async function getInstances() {
  const { stdout } = await lxc('list', '--format', 'json');
  const raw = JSON.parse(stdout);
  return raw.map(c => ({
    name: c.name,
    status: c.status,
    ephemeral: c.ephemeral,
    type: c.type,
    architecture: c.architecture,
    created_at: c.created_at,
    profiles: c.profiles,
    devices: c.devices || {},
    state: c.state ? {
      status: c.state.status,
      pid: c.state.pid,
      memory: c.state.memory,
      disk: c.state.disk,
      cpu: c.state.cpu,
      network: c.state.network
    } : null,
    snapshots: c.snapshots || []
  }));
}

async function getInstance(name) {
  const instances = await getInstances();
  const inst = instances.find(i => i.name === name);
  if (!inst) throw new Error('Instance not found');
  return inst;
}

async function getSnapshots(container) {
  const inst = await getInstance(container);
  return (inst.snapshots || []).map(s => ({
    name: s.name,
    created_at: s.created_at,
    size: s.size
  }));
}

async function lxcExec(name, script, timeout = 300000) {
  return run('lxc', ['exec', name, '--', 'bash', '-euo', 'pipefail', '-c', script], timeout);
}

function waitRunning(name, timeout = 30) {
  return new Promise((resolve, reject) => {
    let elapsed = 0;
    const iv = setInterval(async () => {
      try {
        const { stdout } = await lxc('info', name);
        const match = stdout.match(/Status:\s*(\w+)/);
        if (match && match[1] === 'RUNNING') {
          clearInterval(iv);
          resolve();
        }
      } catch (e) {}
      elapsed++;
      if (elapsed >= timeout) {
        clearInterval(iv);
        resolve();
      }
    }, 1000);
  });
}

const UBUNTU_VERSIONS = ['26.04', '25.10', '25.04', '24.04', '22.04', '20.04', '18.04'];
let cachedImages = null;

function getImages() {
  if (cachedImages) return cachedImages;
  return UBUNTU_VERSIONS.map(ver => ({ alias: `ubuntu:${ver}`, description: `Ubuntu ${ver} LTS` }));
}

async function refreshImages() {
  const { stdout } = await run('lxc', ['image', 'list', 'ubuntu:', '--format', 'json']);
  const raw = JSON.parse(stdout);
  const seen = new Set();
  const images = [];
  for (const img of raw) {
    if (img.architecture !== 'x86_64') continue;
    const aliases = img.aliases || [];
    for (const alias of aliases) {
      const name = alias.name || '';
      if (name.includes('/') || seen.has(name)) continue;
      const fullAlias = `ubuntu:${name}`;
      seen.add(name);
      const desc = (img.properties && img.properties.description || '').replace(/\s*\(release\)/, '').trim();
      images.push({ alias: fullAlias, description: desc || `Ubuntu ${name}` });
    }
  }
  images.sort((a, b) => {
    const va = a.alias.replace(/[^0-9.]/g, '');
    const vb = b.alias.replace(/[^0-9.]/g, '');
    return vb.localeCompare(va, undefined, { numeric: true });
  });
  cachedImages = images;
  return images;
}

async function createInstance(opts) {
  const { name, image, update: doUpdate, tailscale, docker, mount } = opts;
  const isUbuntu = image.startsWith('ubuntu:');

  await lxc('launch', image, name);
  await waitRunning(name);

  if (isUbuntu && doUpdate) {
    await lxcExec(name, 'apt-get update && apt-get upgrade -y', 600000);
  }
  if (isUbuntu && tailscale) {
    await lxcExec(name, 'curl -fsSL https://tailscale.com/install.sh | sh', 300000);
  }
  if (isUbuntu && docker) {
    await lxc('stop', name);
    await lxc('config', 'set', name, 'security.nesting', 'true');
    await lxc('start', name);
    await waitRunning(name);
    await lxcExec(name, 'curl -fsSL https://get.docker.com | sh', 600000);
    await lxcExec(name, `mkdir -p /opt/docker && if getent group docker >/dev/null 2>&1; then chown -R root:docker /opt/docker && chmod -R 775 /opt/docker; else chown -R root:root /opt/docker && chmod -R 755 /opt/docker; fi`);
  }
  if (isUbuntu && mount) {
    await lxc('stop', name);
    await lxc('config', 'device', 'add', name, 'opt-lxd-data', 'disk', 'source=/opt/lxd-data', 'path=/opt/lxd-data');
    await lxc('config', 'set', name, 'raw.idmap', 'both 1000 1000');
    await lxc('start', name);
  }

  const features = [];
  if (isUbuntu && doUpdate) features.push('update');
  if (isUbuntu && tailscale) features.push('tailscale');
  if (isUbuntu && docker) features.push('docker');
  if (isUbuntu && mount) features.push('mount');
  return `Instance ${name} created (${image}${features.length ? ' + ' + features.join(' + ') : ''})`;
}

const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, `http://localhost:${PORT}`);
  const pathname = url.pathname;

  if (pathname === '/' || pathname === '/index.html') {
    const html = fs.readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf-8');
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    return res.end(html);
  }

  if (pathname === '/api/instances' && req.method === 'GET') {
    try {
      const instances = await getInstances();
      return json(res, 200, instances);
    } catch (e) {
      return json(res, 500, { error: e.message });
    }
  }

  const instMatch = pathname.match(/^\/api\/instances\/([^/]+)\/(start|stop|restart|delete)$/);
  if (instMatch) {
    const [, name, action] = instMatch;
    try {
      if (action === 'delete') {
        await lxc('stop', name, '--force');
        await lxc('delete', name);
      } else if (action === 'stop') {
        await lxc('stop', name);
      } else {
        await lxc(action, name);
      }
      return json(res, 200, { ok: true, message: `${action} completed for ${name}` });
    } catch (e) {
      return json(res, 500, { error: e.message });
    }
  }

  if (pathname === '/api/instances/create' && req.method === 'POST') {
    try {
      const body = await parseBody(req);
      if (!body.name || !body.image) {
        return json(res, 400, { error: 'name and image are required' });
      }
      const result = await createInstance({
        name: body.name,
        image: body.image,
        update: !!body.update,
        tailscale: !!body.tailscale,
        docker: !!body.docker,
        mount: !!body.mount,
      });
      return json(res, 200, { ok: true, message: result });
    } catch (e) {
      return json(res, 500, { error: e.message });
    }
  }

  const cloneMatch = pathname.match(/^\/api\/instances\/([^/]+)\/clone$/);
  if (cloneMatch && req.method === 'POST') {
    const [, srcName] = cloneMatch;
    try {
      const body = await parseBody(req);
      if (!body.newName) {
        return json(res, 400, { error: 'newName is required' });
      }
      await lxc('stop', srcName);
      await lxc('copy', srcName, body.newName);
      await lxc('config', 'set', body.newName, 'raw.idmap', 'both 1000 1000');
      await lxc('start', body.newName);
      return json(res, 200, { ok: true, message: `Cloned ${srcName} to ${body.newName}` });
    } catch (e) {
      return json(res, 500, { error: e.message });
    }
  }

  const snapListMatch = pathname.match(/^\/api\/instances\/([^/]+)\/snapshots$/);
  if (snapListMatch && req.method === 'GET') {
    const [, name] = snapListMatch;
    try {
      const snapshots = await getSnapshots(name);
      return json(res, 200, snapshots);
    } catch (e) {
      return json(res, 500, { error: e.message });
    }
  }

  const snapCreateMatch = pathname.match(/^\/api\/instances\/([^/]+)\/snapshots$/);
  if (snapCreateMatch && req.method === 'POST') {
    const [, name] = snapCreateMatch;
    try {
      const body = await parseBody(req);
      const snap = `snap-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}${body.comment ? '-' + body.comment.replace(/\s+/g, '-') : ''}`;
      await lxc('stop', name);
      await lxc('snapshot', name, snap);
      await lxc('start', name);
      return json(res, 200, { ok: true, message: `Snapshot ${snap} created`, name: snap });
    } catch (e) {
      return json(res, 500, { error: e.message });
    }
  }

  const snapRestoreMatch = pathname.match(/^\/api\/instances\/([^/]+)\/snapshots\/([^/]+)\/restore$/);
  if (snapRestoreMatch && req.method === 'POST') {
    const [, name, snapName] = snapRestoreMatch;
    try {
      await lxc('stop', name, '--force');
      await lxc('restore', name, snapName);
      await lxc('start', name);
      return json(res, 200, { ok: true, message: `Restored ${name} from ${snapName}` });
    } catch (e) {
      return json(res, 500, { error: e.message });
    }
  }

  const snapDeleteMatch = pathname.match(/^\/api\/instances\/([^/]+)\/snapshots\/([^/]+)$/);
  if (snapDeleteMatch && req.method === 'DELETE') {
    const [, name, snapName] = snapDeleteMatch;
    try {
      await lxc('delete', `${name}/${snapName}`);
      return json(res, 200, { ok: true, message: `Deleted snapshot ${snapName}` });
    } catch (e) {
      return json(res, 500, { error: e.message });
    }
  }

  if (pathname === '/api/images' && req.method === 'GET') {
    try {
      const images = getImages();
      return json(res, 200, { images, cached: !!cachedImages });
    } catch (e) {
      return json(res, 500, { error: e.message });
    }
  }

  if (pathname === '/api/images/refresh' && req.method === 'POST') {
    try {
      const images = await refreshImages();
      return json(res, 200, { images });
    } catch (e) {
      return json(res, 500, { error: e.message });
    }
  }

  json(res, 404, { error: 'Not found' });
});

server.listen(PORT, '0.0.0.0', () => {
  console.log(`Easy LXD UI running on http://0.0.0.0:${PORT}`);
});
SERVEREOF

# --- index.html ---
cat > "$INSTALL_DIR/public/index.html" << 'HTMLEOF'
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Easy LXD</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{
  --bg:#0f1117;--surface:#1a1d27;--surface2:#242837;--border:#2d3148;
  --text:#e1e4ed;--text2:#8b90a5;--accent:#5b8af5;--accent-hover:#4a77e0;
  --green:#3dd68c;--red:#f55b6a;--yellow:#f5c95b;--orange:#f59b5b;
  --radius:8px;--shadow:0 2px 8px rgba(0,0,0,.3);
}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
a{color:var(--accent);text-decoration:none}
.header{background:var(--surface);border-bottom:1px solid var(--border);padding:16px 24px;display:flex;align-items:center;gap:16px;position:sticky;top:0;z-index:100}
.header h1{font-size:20px;font-weight:600}
.header .subtitle{color:var(--text2);font-size:13px}
.header .actions{margin-left:auto;display:flex;gap:8px}
.container{max-width:1200px;margin:0 auto;padding:24px}
.btn{display:inline-flex;align-items:center;gap:6px;padding:8px 16px;border:none;border-radius:var(--radius);font-size:13px;font-weight:500;cursor:pointer;transition:all .15s}
.btn:disabled{opacity:.5;cursor:not-allowed}
.btn-primary{background:var(--accent);color:#fff}
.btn-primary:hover:not(:disabled){background:var(--accent-hover)}
.btn-danger{background:var(--red);color:#fff}
.btn-danger:hover:not(:disabled){opacity:.85}
.btn-ghost{background:transparent;color:var(--text2);border:1px solid var(--border)}
.btn-ghost:hover:not(:disabled){background:var(--surface2);color:var(--text)}
.btn-sm{padding:5px 10px;font-size:12px}
.btn-icon{padding:6px 8px}
.table-wrap{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden}
table{width:100%;border-collapse:collapse}
th{background:var(--surface2);text-align:left;padding:12px 16px;font-size:12px;font-weight:600;color:var(--text2);text-transform:uppercase;letter-spacing:.5px;border-bottom:1px solid var(--border)}
td{padding:12px 16px;border-bottom:1px solid var(--border);font-size:14px;vertical-align:middle}
tr:last-child td{border-bottom:none}
tr:hover td{background:rgba(91,138,245,.04)}
.badge{display:inline-block;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.3px}
.badge-running{background:rgba(61,214,140,.15);color:var(--green)}
.badge-stopped{background:rgba(245,91,106,.15);color:var(--red)}
.badge-other{background:rgba(245,201,91,.15);color:var(--yellow)}
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:200;justify-content:center;align-items:center}
.modal-overlay.active{display:flex}
.modal{background:var(--surface);border:1px solid var(--border);border-radius:12px;width:520px;max-width:90vw;max-height:85vh;overflow-y:auto;box-shadow:var(--shadow)}
.modal-header{padding:20px 24px 0;display:flex;justify-content:space-between;align-items:center}
.modal-header h2{font-size:18px}
.modal-close{background:none;border:none;color:var(--text2);font-size:22px;cursor:pointer;padding:4px}
.modal-close:hover{color:var(--text)}
.modal-body{padding:20px 24px}
.modal-footer{padding:16px 24px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:8px}
.form-group{margin-bottom:16px}
.form-group label{display:block;font-size:13px;font-weight:500;color:var(--text2);margin-bottom:6px}
.form-group input,.form-group select{width:100%;padding:10px 12px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);font-size:14px}
.form-group input:focus,.form-group select:focus{outline:none;border-color:var(--accent)}
.spinner{display:inline-block;width:16px;height:16px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .6s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.toast{position:fixed;bottom:24px;right:24px;padding:12px 20px;border-radius:var(--radius);font-size:13px;font-weight:500;z-index:300;animation:slideUp .3s ease;max-width:400px;display:flex;align-items:center;gap:10px}
.toast-success{background:var(--green);color:#000}
.toast-error{background:var(--red);color:#fff}
.toast-working{background:var(--surface2);color:var(--text);border:1px solid var(--accent)}
.toast-working .spinner{border-top-color:var(--accent)}
.toast-working.pulse{animation:pulse 1.5s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
@keyframes slideUp{from{transform:translateY(20px);opacity:0}to{transform:translateY(0);opacity:1}}
@media(max-width:768px){
  .header{flex-wrap:wrap;gap:8px}
  .header .actions{width:100%}
  td{padding:8px 12px;font-size:13px}
  .actions-cell{display:flex;flex-wrap:wrap;gap:4px}
}
.snap-section{margin-top:8px}
.snap-section summary{cursor:pointer;color:var(--text2);font-size:12px;padding:4px 0}
.snap-list{margin-top:6px;display:flex;flex-direction:column;gap:4px}
.snap-item{display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--surface2);border-radius:6px;font-size:12px}
.snap-item .snap-name{flex:1;color:var(--text)}
.snap-item .snap-date{color:var(--text2);font-size:11px}
.inst-name{font-weight:600;font-family:'SF Mono',Consolas,monospace;font-size:13px}
.inst-name.clickable{cursor:pointer;transition:color .15s}
.inst-name.clickable:hover{color:var(--accent)}
.overview-row td{padding:0 16px 12px !important;background:rgba(91,138,245,.03) !important}
.overview-panel{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;padding:12px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius)}
.overview-title{font-size:11px;font-weight:600;color:var(--text2);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid var(--border)}
.overview-item{display:flex;align-items:baseline;gap:8px;padding:3px 0;font-size:13px}
.overview-label{color:var(--accent);font-weight:500;min-width:80px;font-family:'SF Mono',Consolas,monospace;font-size:12px}
.overview-value{color:var(--text);font-family:'SF Mono',Consolas,monospace;font-size:12px}
.overview-scope{color:var(--text2);font-size:11px}
.overview-empty{color:var(--text2);font-style:italic;font-size:12px}
@media(max-width:900px){.overview-panel{grid-template-columns:1fr}}
.confirm-msg{font-size:14px;line-height:1.6;margin-bottom:8px}
.confirm-name{font-weight:700;color:var(--accent)}
.checkbox-label{display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);cursor:pointer;font-size:13px;margin-bottom:6px;transition:all .15s}
.checkbox-label:hover{border-color:var(--accent)}
.checkbox-label:has(input:checked){border-color:var(--accent);background:rgba(91,138,245,.08)}
.checkbox-label input[type=checkbox]{accent-color:var(--accent);width:16px;height:16px}
.checkbox-label.disabled{opacity:.4;cursor:not-allowed;pointer-events:none}
.checkbox-note{font-size:11px;color:var(--text2);margin-top:4px}
.image-row{display:flex;gap:8px;align-items:stretch}
.image-row select{flex:1}
.image-row .btn{white-space:nowrap}
</style>
</head>
<body>
<div class="header">
  <div>
    <h1>Easy LXD</h1>
    <div class="subtitle">LXD Instance Manager</div>
  </div>
  <div class="actions">
    <button class="btn btn-ghost btn-sm" onclick="refresh()">Refresh</button>
    <button class="btn btn-primary" onclick="showCreate()">+ New Instance</button>
  </div>
</div>
<div class="container">
  <div class="table-wrap">
    <table>
      <thead><tr><th>Name</th><th>Status</th><th>Created</th><th>Profiles</th><th>Actions</th></tr></thead>
      <tbody id="instances"></tbody>
    </table>
  </div>
</div>
<div class="modal-overlay" id="createModal">
  <div class="modal">
    <div class="modal-header"><h2>Create Instance</h2><button class="modal-close" onclick="closeModals()">&times;</button></div>
    <div class="modal-body">
      <div class="form-group"><label>Instance Name</label><input type="text" id="newName" placeholder="e.g. my-server" pattern="[a-zA-Z0-9_-]+"></div>
      <div class="form-group"><label>Image</label><div class="image-row"><select id="newImage"><option value="">Loading images...</option></select><button class="btn btn-ghost btn-sm" id="refreshImagesBtn" onclick="doRefreshImages()" title="Check for new images">↻ Check</button></div><input type="text" id="newImageCustom" placeholder="e.g. ubuntu:24.04, debian:12, alpine/3.18" style="display:none;margin-top:8px"></div>
      <div class="form-group" id="optsGroup" style="display:none"><label>Options</label><label class="checkbox-label"><input type="checkbox" id="optUpdate" checked> Update (apt upgrade)</label><label class="checkbox-label"><input type="checkbox" id="optTailscale" checked> Tailscale</label><label class="checkbox-label"><input type="checkbox" id="optDocker"> Docker</label><label class="checkbox-label"><input type="checkbox" id="optMount" checked> /opt/lxd-data mount</label></div>
    </div>
    <div class="modal-footer"><button class="btn btn-ghost" onclick="closeModals()">Cancel</button><button class="btn btn-primary" id="createBtn" onclick="doCreate()" disabled>Create</button></div>
  </div>
</div>
<div class="modal-overlay" id="cloneModal">
  <div class="modal">
    <div class="modal-header"><h2>Clone Instance</h2><button class="modal-close" onclick="closeModals()">&times;</button></div>
    <div class="modal-body"><div class="form-group"><label>Source</label><input type="text" id="cloneSource" readonly></div><div class="form-group"><label>New Instance Name</label><input type="text" id="cloneNewName" placeholder="e.g. my-server-copy"></div></div>
    <div class="modal-footer"><button class="btn btn-ghost" onclick="closeModals()">Cancel</button><button class="btn btn-primary" id="cloneBtn" onclick="doClone()">Clone</button></div>
  </div>
</div>
<div class="modal-overlay" id="snapshotModal">
  <div class="modal">
    <div class="modal-header"><h2>Create Snapshot</h2><button class="modal-close" onclick="closeModals()">&times;</button></div>
    <div class="modal-body"><div class="form-group"><label>Instance</label><input type="text" id="snapInstance" readonly></div><div class="form-group"><label>Comment (optional)</label><input type="text" id="snapComment" placeholder="e.g. before-update"></div></div>
    <div class="modal-footer"><button class="btn btn-ghost" onclick="closeModals()">Cancel</button><button class="btn btn-primary" id="snapBtn" onclick="doSnapshot()">Create</button></div>
  </div>
</div>
<div class="modal-overlay" id="confirmModal">
  <div class="modal" style="width:400px">
    <div class="modal-header"><h2 id="confirmTitle">Confirm</h2><button class="modal-close" onclick="closeModals()">&times;</button></div>
    <div class="modal-body"><p class="confirm-msg" id="confirmMsg"></p></div>
    <div class="modal-footer"><button class="btn btn-ghost" onclick="closeModals()">Cancel</button><button class="btn btn-danger" id="confirmBtn">Confirm</button></div>
  </div>
</div>
<script>
let instances=[];
let imageList=[];
async function api(path,opts={}){const res=await fetch(path,{headers:{'Content-Type':'application/json'},...opts,body:opts.body?JSON.stringify(opts.body):undefined});return res.json()}
function toast(msg,type='success'){const el=document.createElement('div');el.className=`toast toast-${type}`;el.textContent=msg;document.body.appendChild(el);setTimeout(()=>el.remove(),3000)}
function toastWorking(msg){const el=document.createElement('div');el.className='toast toast-working pulse';el.innerHTML=`<div class="spinner"></div><span>${esc(msg)}</span>`;document.body.appendChild(el);return{done(okMsg){el.className='toast toast-success';el.innerHTML=`<span>${esc(okMsg)}</span>`;setTimeout(()=>el.remove(),3000)},fail(errMsg){el.className='toast toast-error';el.innerHTML=`<span>${esc(errMsg)}</span>`;setTimeout(()=>el.remove(),4000)},remove(){el.remove()}}}
function formatDate(d){if(!d)return'-';const dt=new Date(d);return dt.toLocaleString('ja-JP',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
function statusBadge(status){const cls=status==='Running'?'running':status==='Stopped'?'stopped':'other';return`<span class="badge badge-${cls}">${status}</span>`}
async function refresh(){const tbody=document.getElementById('instances');tbody.innerHTML='<tr><td colspan="5" style="text-align:center;color:var(--text2);padding:32px"><div class="spinner"></div></td></tr>';try{instances=await api('/api/instances');render()}catch(e){tbody.innerHTML=`<tr><td colspan="5" style="color:var(--red);padding:24px">Error: ${e.message}</td></tr>`}}
async function loadImages(){try{const res=await api('/api/images');imageList=res.images||res;renderImageSelect()}catch(e){document.getElementById('newImage').innerHTML='<option value="__custom__">Custom image...</option>'}}
function renderImageSelect(){const sel=document.getElementById('newImage');const prev=sel.value;sel.innerHTML=imageList.map(img=>`<option value="${esc(img.alias)}">${esc(img.alias)} - ${esc(img.description)}</option>`).join('')+'<option value="__custom__">Custom image...</option>';if(prev&&[...sel.options].some(o=>o.value===prev))sel.value=prev}
async function doRefreshImages(){const btn=document.getElementById('refreshImagesBtn');btn.disabled=true;btn.textContent='Checking...';const w=toastWorking('Checking for new images...');try{const res=await api('/api/images/refresh',{method:'POST'});imageList=res.images||res;renderImageSelect();w.done(`Found ${imageList.length} images`)}catch(e){w.fail(`Error: ${e.message}`)}finally{btn.disabled=false;btn.textContent='↻ Check'}}
function render(){const tbody=document.getElementById('instances');if(!instances.length){tbody.innerHTML='<tr><td colspan="5" style="text-align:center;color:var(--text2);padding:40px">No instances</td></tr>';return}tbody.innerHTML=instances.map(inst=>`<tr><td><span class="inst-name clickable" onclick="toggleOverview('${esc(inst.name)}')">${esc(inst.name)}</span></td><td>${statusBadge(inst.status)}</td><td style="color:var(--text2);font-size:13px">${formatDate(inst.created_at)}</td><td style="color:var(--text2);font-size:13px">${(inst.profiles||[]).join(', ')}</td><td><div class="actions-cell">${inst.status==='Stopped'?`<button class="btn btn-ghost btn-sm" onclick="action('${inst.name}','start')">Start</button>`:`<button class="btn btn-ghost btn-sm" onclick="action('${inst.name}','stop')">Stop</button>`}<button class="btn btn-ghost btn-sm" onclick="action('${inst.name}','restart')">Restart</button><button class="btn btn-ghost btn-sm" onclick="showClone('${inst.name}')">Clone</button><button class="btn btn-ghost btn-sm" onclick="showSnapshot('${inst.name}')">Snap</button><button class="btn btn-danger btn-sm" onclick="confirmAction('${inst.name}','delete')">Delete</button></div></td></tr><tr id="ov-${esc(inst.name)}" class="overview-row" style="display:none"><td colspan="5">${renderOverview(inst)}</td></tr><tr class="snap-row"><td colspan="5" style="padding:0 16px 8px">${renderSnapshots(inst)}</td></tr>`).join('')}
function renderSnapshots(inst){const snaps=inst.snapshots||[];if(!snaps.length)return'';return`<details class="snap-section"><summary>Snapshots (${snaps.length})</summary><div class="snap-list">${snaps.map(s=>`<div class="snap-item"><span class="snap-name">${esc(s.name)}</span><span class="snap-date">${formatDate(s.created_at)}</span><button class="btn btn-ghost btn-sm btn-icon" onclick="confirmRestore('${inst.name}','${esc(s.name)}')" title="Restore">↺</button><button class="btn btn-danger btn-sm btn-icon" onclick="confirmDeleteSnap('${inst.name}','${esc(s.name)}')" title="Delete">×</button></div>`).join('')}</div></details>`}
function toggleOverview(name){const row=document.getElementById('ov-'+name);if(!row)return;const isOpen=row.style.display!=='none';document.querySelectorAll('.overview-row').forEach(r=>r.style.display='none');if(!isOpen)row.style.display=''}
function renderOverview(inst){const net=(inst.state&&inst.state.network)||{};const devices=inst.devices||{};const ips=[];for(const[iface,info]of Object.entries(net)){if(iface==='lo')continue;for(const addr of(info.addresses||[])){if(addr.family==='inet')ips.push({iface,addr:addr.address,scope:addr.scope})}}const mounts=[];for(const[name,dev]of Object.entries(devices)){if(dev.type==='disk')mounts.push({name,source:dev.source||'-',path:dev.path||'-'})}const gpus=[];for(const[name,dev]of Object.entries(devices)){if(dev.type==='gpu')gpus.push({name,pci:dev.pci||'-',gputype:dev.gputype||'physical'})}return`<div class="overview-panel"><div class="overview-section"><div class="overview-title">IP Addresses</div>${ips.length?ips.map(i=>`<div class="overview-item"><span class="overview-label">${esc(i.iface)}</span><span class="overview-value">${esc(i.addr)}</span><span class="overview-scope">${i.scope}</span></div>`).join(''):'<div class="overview-item overview-empty">No IP (stopped)</div>'}</div><div class="overview-section"><div class="overview-title">Mount Devices</div>${mounts.length?mounts.map(m=>`<div class="overview-item"><span class="overview-label">${esc(m.name)}</span><span class="overview-value">${esc(m.source)} → ${esc(m.path)}</span></div>`).join(''):'<div class="overview-item overview-empty">None</div>'}</div><div class="overview-section"><div class="overview-title">GPU Passthrough</div>${gpus.length?gpus.map(g=>`<div class="overview-item"><span class="overview-label">${esc(g.name)}</span><span class="overview-value">${esc(g.gputype)} / ${esc(g.pci)}</span></div>`).join(''):'<div class="overview-item overview-empty">None</div>'}</div></div>`}
function esc(s){const el=document.createElement('span');el.textContent=s;return el.innerHTML}
async function action(name,act){const w=toastWorking(`${act} ${name}...`);try{await api(`/api/instances/${name}/${act}`,{method:'POST'});w.done(`${act} completed`);setTimeout(refresh,500)}catch(e){w.fail(`Error: ${e.message}`)}}
function updateCreateBtn(){const name=document.getElementById('newName').value.trim();const image=getSelectedImage();document.getElementById('createBtn').disabled=!name||!image}
function updateOptsVisibility(){const image=getSelectedImage();const isUbuntu=image.startsWith('ubuntu:');document.getElementById('optsGroup').style.display=isUbuntu?'':'none'}
function getSelectedImage(){const sel=document.getElementById('newImage');if(sel.value==='__custom__')return document.getElementById('newImageCustom').value.trim();return sel.value}
async function showCreate(){document.getElementById('newName').value='';document.getElementById('createBtn').disabled=true;document.getElementById('newImageCustom').style.display='none';if(!imageList.length)await loadImages();document.getElementById('newImage').value=imageList.length?imageList[0].alias:'__custom__';document.getElementById('newImageCustom').value='';updateOptsVisibility();updateCreateBtn();document.getElementById('createModal').classList.add('active')}
document.getElementById('newName').addEventListener('input',updateCreateBtn);
document.getElementById('newImage').addEventListener('change',()=>{const isCustom=document.getElementById('newImage').value==='__custom__';document.getElementById('newImageCustom').style.display=isCustom?'':'none';updateOptsVisibility();updateCreateBtn()});
document.getElementById('newImageCustom').addEventListener('input',()=>{updateOptsVisibility();updateCreateBtn()});
async function doCreate(){const name=document.getElementById('newName').value.trim();const image=getSelectedImage();if(!name||!image)return;const isUbuntu=image.startsWith('ubuntu:');const body={name,image};if(isUbuntu){body.update=document.getElementById('optUpdate').checked;body.tailscale=document.getElementById('optTailscale').checked;body.docker=document.getElementById('optDocker').checked;body.mount=document.getElementById('optMount').checked}closeModals();const w=toastWorking(`Creating instance "${name}" (${image})...`);try{const res=await api('/api/instances/create',{method:'POST',body});if(res.error)throw new Error(res.error);w.done(res.message||'Instance created');refresh()}catch(e){w.fail(`Error: ${e.message}`)}}
function showClone(name){document.getElementById('cloneSource').value=name;document.getElementById('cloneNewName').value='';document.getElementById('cloneModal').classList.add('active')}
async function doClone(){const src=document.getElementById('cloneSource').value;const newName=document.getElementById('cloneNewName').value.trim();if(!newName)return;closeModals();const w=toastWorking(`Cloning "${src}" to "${newName}"...`);try{const res=await api(`/api/instances/${src}/clone`,{method:'POST',body:{newName}});if(res.error)throw new Error(res.error);w.done(res.message||'Cloned');refresh()}catch(e){w.fail(`Error: ${e.message}`)}}
function showSnapshot(name){document.getElementById('snapInstance').value=name;document.getElementById('snapComment').value='';document.getElementById('snapshotModal').classList.add('active')}
async function doSnapshot(){const name=document.getElementById('snapInstance').value;const comment=document.getElementById('snapComment').value.trim();closeModals();const w=toastWorking(`Creating snapshot for "${name}"...`);try{const res=await api(`/api/instances/${name}/snapshots`,{method:'POST',body:{comment}});if(res.error)throw new Error(res.error);w.done(res.message||'Snapshot created');refresh()}catch(e){w.fail(`Error: ${e.message}`)}}
function confirmRestore(inst,snap){document.getElementById('confirmTitle').textContent='Restore Snapshot';document.getElementById('confirmMsg').innerHTML=`Restore <span class="confirm-name">${esc(inst)}</span> from snapshot <span class="confirm-name">${esc(snap)}</span>?<br>Current state will be lost.`;document.getElementById('confirmBtn').onclick=async()=>{closeModals();const w=toastWorking(`Restoring ${inst} from ${snap}...`);try{await api(`/api/instances/${inst}/snapshots/${snap}/restore`,{method:'POST'});w.done('Restored');refresh()}catch(e){w.fail(`Error: ${e.message}`)}};document.getElementById('confirmModal').classList.add('active')}
function confirmDeleteSnap(inst,snap){document.getElementById('confirmTitle').textContent='Delete Snapshot';document.getElementById('confirmMsg').innerHTML=`Delete snapshot <span class="confirm-name">${esc(snap)}</span> from <span class="confirm-name">${esc(inst)}</span>?`;document.getElementById('confirmBtn').onclick=async()=>{closeModals();const w=toastWorking(`Deleting snapshot ${snap}...`);try{await api(`/api/instances/${inst}/snapshots/${snap}`,{method:'DELETE'});w.done('Deleted');refresh()}catch(e){w.fail(`Error: ${e.message}`)}};document.getElementById('confirmModal').classList.add('active')}
function confirmAction(name,act){document.getElementById('confirmTitle').textContent='Delete Instance';document.getElementById('confirmMsg').innerHTML=`Delete instance <span class="confirm-name">${esc(name)}</span>?<br>This cannot be undone.`;document.getElementById('confirmBtn').onclick=async()=>{closeModals();const w=toastWorking(`Deleting ${name}...`);try{await api(`/api/instances/${name}/delete`,{method:'POST'});w.done('Deleted');refresh()}catch(e){w.fail(`Error: ${e.message}`)}};document.getElementById('confirmModal').classList.add('active')}
function closeModals(){document.querySelectorAll('.modal-overlay').forEach(m=>m.classList.remove('active'))}
document.querySelectorAll('.modal-overlay').forEach(m=>{m.addEventListener('click',e=>{if(e.target===m)closeModals()})});
refresh();loadImages();
</script>
</body>
</html>
HTMLEOF

# --- systemd サービス ---
SERVICE_FILE="/etc/systemd/system/easy-lxd.service"
if [ -w /etc/systemd/system ] 2>/dev/null || [ "$(id -u)" -eq 0 ]; then
  cat > "$SERVICE_FILE" << SVCEOF
[Unit]
Description=Easy LXD UI
After=network.target lxd.service

[Service]
Type=simple
ExecStart=${NODE_PATH} ${INSTALL_DIR}/server.js
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
SVCEOF
  systemctl daemon-reload
  systemctl enable easy-lxd
  systemctl restart easy-lxd
  echo "Systemd サービスをインストールし起動しました"
  echo "  状態確認: systemctl status easy-lxd"
  echo "  ログ確認: journalctl -u easy-lxd -f"
else
  echo ""
  echo "NOTE: root 権限がないため systemd サービスを作成できません。"
  echo "手動起動:"
  echo "  setsid ${NODE_PATH} ${INSTALL_DIR}/server.js </dev/null > /tmp/easy-lxd.log 2>&1 &"
  echo ""
fi

echo ""
echo "=== インストール完了 ==="
echo "  Node:  ${NODE_PATH} ($(${NODE_PATH} -v))"
echo "  URL:   http://$(hostname -I | awk '{print $1}'):${PORT}"
echo "  Dir:   ${INSTALL_DIR}"

アンインストール方法

アンインストール用のスクリプトも用意しています。このアンインストールスクリプトでは次のことを行います。

  1. root チェック
  2. 削除対象を表示して [y/N] 確認
  3. systemd サービスを stop → disable → ファイル削除 → daemon-reload
  4. pgrep で残存プロセスがあれば pkill で終了
  5. /opt/lxd-data/easy-lxdrm -rf
  6. nvm シンボリックリンク(/usr/local/bin/node が nvm パスを指していた場合のみ)を削除

Node.js 本体は触らず、最後に apt で入れた場合の削除コマンドを案内する形にしています。

sudo mkdir -p /opt/script/easylxd
cd /opt/script/easylxd
sudo nano uninstall-exsylxd1.sh
sudo bash uninstall-exsylxd1.sh
#!/bin/bash
set -euo pipefail

INSTALL_DIR="/opt/lxd-data/easy-lxd"
SERVICE_NAME="easy-lxd"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
NVM_SYMLINK="/usr/local/bin/node"

echo "=== Easy LXD UI アンインストーラー ==="

# --- root 確認 ---
if [ "$(id -u)" -ne 0 ]; then
  echo "ERROR: root で実行してください (sudo bash $0)" >&2
  exit 1
fi

# --- 確認プロンプト ---
echo ""
echo "以下を削除します:"
echo "  - systemd サービス: ${SERVICE_FILE}"
echo "  - インストールディレクトリ: ${INSTALL_DIR}"
if [ -L "$NVM_SYMLINK" ] && [[ "$(readlink "$NVM_SYMLINK")" == *"/.nvm/"* ]]; then
  echo "  - nvm シンボリックリンク: ${NVM_SYMLINK}"
fi
echo ""
read -r -p "続けますか? [y/N]: " confirm
case "$confirm" in
  [yY]|[yY][eE][sS]) ;;
  *) echo "キャンセルしました"; exit 0 ;;
esac

# --- systemd サービス停止・削除 ---
if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
  echo "サービスを停止中..."
  systemctl stop "$SERVICE_NAME"
fi

if systemctl is-enabled --quiet "$SERVICE_NAME" 2>/dev/null; then
  echo "自動起動を無効化中..."
  systemctl disable "$SERVICE_NAME"
fi

if [ -f "$SERVICE_FILE" ]; then
  echo "サービスファイルを削除中: ${SERVICE_FILE}"
  rm -f "$SERVICE_FILE"
  systemctl daemon-reload
fi

# --- プロセスが残っていれば強制終了 ---
if pgrep -f "node.*easy-lxd/server.js" &>/dev/null; then
  echo "残存プロセスを終了中..."
  pkill -f "node.*easy-lxd/server.js" 2>/dev/null || true
  sleep 1
fi

# --- インストールディレクトリ削除 ---
if [ -d "$INSTALL_DIR" ]; then
  echo "ディレクトリを削除中: ${INSTALL_DIR}"
  rm -rf "$INSTALL_DIR"
else
  echo "ディレクトリが見つかりません (スキップ): ${INSTALL_DIR}"
fi

# --- nvm シンボリックリンク削除 ---
if [ -L "$NVM_SYMLINK" ] && [[ "$(readlink "$NVM_SYMLINK")" == *"/.nvm/"* ]]; then
  echo "nvm シンボリックリンクを削除中: ${NVM_SYMLINK}"
  rm -f "$NVM_SYMLINK"
fi

echo ""
echo "=== アンインストール完了 ==="
echo "Node.js 本体はアンインストールしていません。"
echo "apt で入れた場合は必要に応じて: sudo apt remove nodejs"

カスタマイズ項目

デフォルトの表示イメージリスト

デフォルトの表示イメージリストです。 ↻ Check ボタンを押せば情報をチェックし実際にあるものだけが表示されるようになりますが、そもそものデフォルト表示リストを変更したい場合は下記場所を編集します。

const UBUNTU_VERSIONS = ['26.04', '25.10', '25.04', '24.04', '22.04', '20.04', '18.04'];

コンテナを止めずにスナップショットを作成

下記部分でスナップショットの動作を指定しています。これを見ると分かるように安全性を重視して、一度コンテナを停止させてスナップショットを取るようにしています。

await lxc('stop', name);
await lxc('snapshot', name, snap);
await lxc('start', name);

ただ、LXDはランニングコンテナのライブスナップショットが取れるので(--stateful オプション)、もし止めずに取りたいなら、下記のように指定します。

await lxc('snapshot', name, snap);  // stop不要

コンテナを止めずにクローンする

コンテナのクローンも安全性を考え一度コンテナを停止してクローンを実行するようにしています。

await lxc('stop', srcName);
await lxc('copy', srcName, body.newName);

こちらもlxc copy はランニングコンテナからでもコピーできます(整合性は落ちますが)。止めたくない場合は --stateless フラグで対応可能です。

タイトルとURLをコピーしました