以前、LXDコンテナの操作を助けるツールとして簡易的なWeb-UI「EasyLXD」を作成しました。また、別途、LXDコンテナにGPUをパススルーするためのスクリプトを用意していました。今回、これらの機能を統合してみました。
GPUパススルー機能
コンテナ名をクリックして、GPUパススルー項目の「None〜」となっているところをクリックします。

GPUが表示されるのでその名前の部分をクリックします。

確認が表示されます。

無事追加されたら、今度は取り外すかの画面が表示されるので「Close」で閉じます。
次のようにGPUが追加されていることが確認できます。

取り外す時は「gpu0」などのところをクリックして進めていきます。
なお、場合によってはLXDコンテナの再起動が必要になります。その場合はこの画面で「Restart」をクリックして再起動すれば良いでしょう。
ホストに直接インストール
3329番ポートで公開します。ホストにインストールするためLAN内に公開されてしまいますので、環境に応じて対策をしてから使ってください。
sudo mkdir -p /opt/script/easylxd
cd /opt/script/easylxd
sudo nano install-exsylxd2.sh
sudo bash install-exsylxd2.sh
#!/bin/bash
set -euo pipefail
INSTALL_DIR="/opt/lxd-data/easy-lxd"
PORT=3329
echo "=== Easy LXD UI Installer v2 ==="
echo " Features: Instance management, Snapshots, GPU Passthrough, Favicon"
echo ""
# --- Node.js の確認とインストール ---
NODE_PATH=""
if command -v node &>/dev/null; then
NODE_PATH=$(command -v node)
echo "Node: $(node -v) ($NODE_PATH)"
else
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
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)"
# --- pciutils の確認 (GPU パススルー用) ---
if ! command -v lspci &>/dev/null; then
echo "pciutils (lspci) が見つかりません。インストールします..."
apt-get install -y pciutils
fi
# --- 既存サービス停止 ---
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"
# --- favicon.svg ---
cat > "$INSTALL_DIR/public/favicon.svg" << 'FAVICON'
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#7baafc"/>
<stop offset="100%" stop-color="#3b6fd4"/>
</linearGradient>
</defs>
<rect x="3" y="10" width="26" height="18" rx="3" fill="url(#g)" opacity=".9"/>
<rect x="3" y="7" width="26" height="6" rx="3" fill="#5b8af5"/>
<rect x="8" y="2" width="16" height="8" rx="2" fill="#3dd68c" opacity=".85"/>
<rect x="10" y="14" width="5" height="5" rx="1" fill="#fff" opacity=".25"/>
<rect x="17" y="14" width="5" height="5" rx="1" fill="#fff" opacity=".25"/>
<rect x="10" y="21" width="5" height="4" rx="1" fill="#fff" opacity=".15"/>
<rect x="17" y="21" width="5" height="4" rx="1" fill="#fff" opacity=".15"/>
</svg>
FAVICON
echo "favicon.svg を作成しました"
# --- 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 === '/favicon.svg') {
const favicon = fs.readFileSync(path.join(__dirname, 'public', 'favicon.svg'), 'utf-8');
res.writeHead(200, { 'Content-Type': 'image/svg+xml' });
return res.end(favicon);
}
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 });
}
}
if (pathname === '/api/gpus' && req.method === 'GET') {
try {
const { stdout } = await run('lspci', ['-Dnn']);
const lines = stdout.split('\n').filter(l => /VGA compatible controller|3D controller|Display controller/i.test(l));
const gpus = lines.map(line => {
const match = line.match(/^([0-9a-f]{4}:[0-9a-f]{2}:[0-9a-f]{2}\.\d)\s+(.*)/);
if (!match) return null;
const ids = line.match(/\[([0-9a-f]{4}:[0-9a-f]{4})\]/);
return {
pci: match[1],
desc: match[2],
vendorId: ids ? ids[1].split(':')[0] : '',
deviceId: ids ? ids[1].split(':')[1] : ''
};
}).filter(Boolean);
return json(res, 200, { gpus });
} catch (e) {
return json(res, 500, { error: e.message });
}
}
const gpuAddMatch = pathname.match(/^\/api\/instances\/([^/]+)\/gpu\/add$/);
if (gpuAddMatch && req.method === 'POST') {
const [, name] = gpuAddMatch;
try {
const body = await parseBody(req);
if (!body.pci) {
return json(res, 400, { error: 'pci address is required' });
}
let existingDevices = {};
try {
const inst = await getInstance(name);
existingDevices = inst.devices || {};
} catch (e) {}
let devName = 'gpu0';
let n = 0;
while (true) {
const tryName = `gpu${n}`;
if (!existingDevices[tryName]) { devName = tryName; break; }
n++;
}
await lxc('config', 'device', 'add', name, devName, 'gpu', 'gputype=physical', `pci=${body.pci}`);
return json(res, 200, { ok: true, message: `GPU ${body.pci} added as ${devName}`, deviceName: devName });
} catch (e) {
return json(res, 500, { error: e.message });
}
}
const gpuRemoveMatch = pathname.match(/^\/api\/instances\/([^/]+)\/gpu\/remove$/);
if (gpuRemoveMatch && req.method === 'POST') {
const [, name] = gpuRemoveMatch;
try {
const body = await parseBody(req);
if (!body.deviceName) {
return json(res, 400, { error: 'deviceName is required' });
}
await lxc('config', 'device', 'remove', name, body.deviceName);
return json(res, 200, { ok: true, message: `GPU device ${body.deviceName} removed` });
} 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
echo "server.js を作成しました"
# --- 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">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<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}
.gpu-item{display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:6px;transition:all .15s}
.gpu-item:hover{border-color:var(--accent)}
.gpu-item .gpu-desc{flex:1;font-size:13px}
.gpu-item .gpu-pci{color:var(--text2);font-size:11px;font-family:'SF Mono',Consolas,monospace}
.gpu-item .gpu-current{border-color:var(--green);background:rgba(61,214,140,.08)}
.gpu-item .gpu-current .gpu-label{color:var(--green);font-size:11px;font-weight:600}
.gpu-item .gpu-remove{color:var(--red);cursor:pointer;font-size:12px}
.gpu-item .gpu-remove:hover{text-decoration:underline}
.gpu-item.clickable{cursor:pointer}
.gpu-item.clickable:hover{border-color:var(--accent);background:rgba(91,138,245,.08)}
.gpustat-link{cursor:pointer;transition:color .15s;color:var(--text2)}
.gpustat-link:hover{color:var(--accent)}
.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()">×</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()">×</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()">×</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()">×</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>
<div class="modal-overlay" id="gpuModal">
<div class="modal" style="width:560px">
<div class="modal-header"><h2>GPU Passthrough</h2><button class="modal-close" onclick="closeModals()">×</button></div>
<div class="modal-body">
<div class="form-group"><label>Instance</label><input type="text" id="gpuInstance" readonly></div>
<div class="form-group"><label>Current GPU Devices</label><div id="gpuCurrentList"></div></div>
<div class="form-group"><label>Available Host GPUs</label><div id="gpuAvailableList"></div></div>
</div>
<div class="modal-footer"><button class="btn btn-ghost" onclick="closeModals()">Close</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><div class="overview-item gpustat-link" onclick="showGpuModal('${esc(inst.name)}')">${gpus.length?gpus.map(g=>`<span class="overview-label">${esc(g.name)}</span><span class="overview-value">${esc(g.gputype)} / ${esc(g.pci)}</span>`).join(''):'<span class="overview-empty">None (click to configure)</span>'}</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')}
async function showGpuModal(instName){
document.getElementById('gpuInstance').value=instName;
document.getElementById('gpuCurrentList').innerHTML='<div class="overview-empty">Loading...</div>';
document.getElementById('gpuAvailableList').innerHTML='<div class="overview-empty">Loading...</div>';
document.getElementById('gpuModal').classList.add('active');
try {
const inst = instances.find(i => i.name === instName);
const devices = (inst && inst.devices) || {};
const currentGpus = Object.entries(devices).filter(([,d]) => d.type === 'gpu');
if (currentGpus.length) {
document.getElementById('gpuCurrentList').innerHTML = currentGpus.map(([name,dev]) =>
`<div class="gpu-item gpu-current"><span class="gpu-label">${esc(name)}</span><span class="gpu-desc">${esc(dev.gputype||'physical')} / ${esc(dev.pci||'all')}</span><span class="gpu-remove" onclick="confirmRemoveGpu('${esc(instName)}','${esc(name)}')">Remove</span></div>`
).join('');
} else {
document.getElementById('gpuCurrentList').innerHTML = '<div class="overview-empty">No GPU attached</div>';
}
} catch(e) {
document.getElementById('gpuCurrentList').innerHTML = `<div class="overview-empty">Error: ${esc(e.message)}</div>`;
}
try {
const res = await api('/api/gpus');
const gpus = res.gpus || [];
if (gpus.length) {
document.getElementById('gpuAvailableList').innerHTML = gpus.map(g =>
`<div class="gpu-item clickable" onclick="confirmAddGpu('${esc(instName)}','${esc(g.pci)}','${esc(g.desc)}')"><span class="gpu-desc">${esc(g.desc)}</span><span class="gpu-pci">${esc(g.pci)}</span></div>`
).join('');
} else {
document.getElementById('gpuAvailableList').innerHTML = '<div class="overview-empty">No GPUs detected on host</div>';
}
} catch(e) {
document.getElementById('gpuAvailableList').innerHTML = `<div class="overview-empty">Error: ${esc(e.message)}</div>`;
}
}
function confirmAddGpu(instName,pci,desc){
document.getElementById('confirmTitle').textContent='Add GPU Passthrough';
document.getElementById('confirmMsg').innerHTML=`Add GPU <span class="confirm-name">${esc(desc)}</span> (${esc(pci)}) to <span class="confirm-name">${esc(instName)}</span>?<br>GPU passthrough will be active immediately (no restart needed).`;
document.getElementById('confirmBtn').onclick=async()=>{closeModals();const w=toastWorking(`Adding GPU to ${instName}...`);try{const res=await api(`/api/instances/${instName}/gpu/add`,{method:'POST',body:{pci}});if(res.error)throw new Error(res.error);w.done(res.message||'GPU added');refresh();setTimeout(()=>showGpuModal(instName),600)}catch(e){w.fail(`Error: ${e.message}`)}};
closeModals();
document.getElementById('confirmModal').classList.add('active');
}
function confirmRemoveGpu(instName,deviceName){
document.getElementById('confirmTitle').textContent='Remove GPU Passthrough';
document.getElementById('confirmMsg').innerHTML=`Remove GPU device <span class="confirm-name">${esc(deviceName)}</span> from <span class="confirm-name">${esc(instName)}</span>?`;
document.getElementById('confirmBtn').onclick=async()=>{closeModals();const w=toastWorking(`Removing GPU from ${instName}...`);try{const res=await api(`/api/instances/${instName}/gpu/remove`,{method:'POST',body:{deviceName}});if(res.error)throw new Error(res.error);w.done(res.message||'GPU removed');refresh();setTimeout(()=>showGpuModal(instName),600)}catch(e){w.fail(`Error: ${e.message}`)}};
closeModals();
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
echo "index.html を作成しました"
# --- 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}"
echo ""
echo " 機能:"
echo " - インスタンス管理 (作成/起動/停止/削除/クローン)"
echo " - スナップショット (作成/復元/削除)"
echo " - GPU パススルー (追加/削除、再起動不要)"
echo " - ファビコン (コンテナボックスアイコン)"
アンインストール方法
アンインストール用のスクリプトも用意しています。
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"

