EasyLXDのUIを日本語化しました。またスナップショットやクローンは、停止してから実行するのが安全ではありますが、それは運用の仕方なので、稼働中でも取れるように変更しています。
そのうちTailscale ServeでHTTPS化するところまでやる予定ですが、ひとまず完成で。
使い方は前と同じ。

ホストに直接インストール
3329番ポートで公開します。ホストにインストールするためLAN内に公開されてしまいますので、環境に応じて対策をしてから使ってください。
インストール先は、LXDコンテナから見える必要はないので、過去のバージョンと異なり/opt/easy-lxdに変更しています。それに伴い、アンインストールスクリプトもパスの部分だけ変更しています。
sudo mkdir -p /opt/script/easylxd
cd /opt/script/easylxd
sudo nano install-exsylxd6.sh
sudo bash install-exsylxd6.sh
#!/bin/bash
set -euo pipefail
INSTALL_DIR="/opt/easy-lxd"
PORT=3329
echo "=== Easy LXD UI Installer v6 ==="
echo " Features: Instance management, Snapshots (--stateful), Clone (--stateless), GPU Passthrough (LXD API), Favicon, Japanese UI, Snapshot Restore (devices+config)"
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): $NODE_PATH"; break; fi
done
fi
if [ -z "$NODE_PATH" ]; then
echo "Node.js が見つかりません。インストールします..."
command -v curl &>/dev/null || apt-get install -y curl
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
apt-get install -y nodejs
NODE_PATH=$(command -v node)
fi
if [[ "$NODE_PATH" == *"/.nvm/"* ]]; then
ln -sf "$NODE_PATH" /usr/local/bin/node
NODE_PATH=/usr/local/bin/node
fi
[ ! -x "$NODE_PATH" ] && { echo "ERROR: node のインストールに失敗"; exit 1; }
# --- lxc ---
command -v lxc &>/dev/null || { echo "ERROR: lxc is not installed"; exit 1; }
echo "LXC: $(lxc version 2>/dev/null || lxc --version)"
# --- pciutils ---
if ! command -v lspci &>/dev/null; then
echo "pciutils をインストールします..."
apt-get install -y pciutils
fi
# --- 既存停止 ---
pgrep -f "node.*easy-lxd/server.js" &>/dev/null && { pkill -f "node.*easy-lxd/server.js" 2>/dev/null; sleep 1; }
# --- ディレクトリ ---
mkdir -p "$INSTALL_DIR/public"
# --- favicon.svg ---
cat > "$INSTALL_DIR/public/favicon.svg" << 'EOF'
<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>
EOF
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); code !== 0 ? reject(new Error(`Command failed with exit code ${code}`)) : resolve({ stdout: stdout.trim() }); });
child.on('error', e => { clearTimeout(timer); reject(e); });
});
}
function lxc(...args) { return run('lxc', args); }
async function lxdApi(method, apiPath, body = null, etag = null) {
const args = ['query', '--wait', '-X', method];
if (etag) args.push('-H', `If-Match: ${etag}`);
if (body) args.push('--data', JSON.stringify(body));
args.push(apiPath);
const result = await run('lxc', args);
return result.stdout ? JSON.parse(result.stdout) : {};
}
async function lxdGetInstance(name) {
const result = await run('lxc', ['query', '--wait', '--raw', `/1.0/instances/${name}?recursion=1`]);
const data = JSON.parse(result.stdout);
return data.metadata || data;
}
async function lxdUpdateInstance(name, updateFields) {
const inst = await lxdGetInstance(name);
const payload = {
devices: { ...(inst.devices || {}) },
config: { ...(inst.config || {}) },
profiles: inst.profiles || ['default'],
description: inst.description || '',
ephemeral: inst.ephemeral || false,
stateful: inst.stateful || false
};
Object.assign(payload, updateFields);
return run('lxc', ['query', '--wait', '-X', 'PUT', '--data', JSON.stringify(payload), `/1.0/instances/${name}`]);
}
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');
return JSON.parse(stdout).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 => {
let elapsed = 0;
const iv = setInterval(async () => {
try { const { stdout } = await lxc('info', name); if (/Status:\s*RUNNING/.test(stdout)) { 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;
for (const alias of (img.aliases || [])) {
const name = alias.name || '';
if (name.includes('/') || seen.has(name)) continue;
seen.add(name);
const desc = ((img.properties && img.properties.description) || '').replace(/\s*\(release\)/, '').trim();
images.push({ alias: `ubuntu:${name}`, 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') {
return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }).end(fs.readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf-8'));
}
if (pathname === '/favicon.svg') {
return res.writeHead(200, { 'Content-Type': 'image/svg+xml' }).end(fs.readFileSync(path.join(__dirname, 'public', 'favicon.svg'), 'utf-8'));
}
if (pathname === '/api/instances' && req.method === 'GET') {
try { return json(res, 200, await getInstances()); } 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' });
return json(res, 200, { ok: true, message: await createInstance({ name: body.name, image: body.image, update: !!body.update, tailscale: !!body.tailscale, docker: !!body.docker, mount: !!body.mount }) });
} 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('copy', srcName, body.newName, '--stateless');
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') {
try { return json(res, 200, await getSnapshots(snapListMatch[1])); } catch (e) { return json(res, 500, { error: e.message }); }
}
if (snapListMatch && req.method === 'POST') {
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('snapshot', snapListMatch[1], snap, '--stateful');
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') {
try {
const [, instName, snapName] = snapRestoreMatch;
const snapRaw = await lxdApi('GET', `/1.0/instances/${instName}/snapshots/${snapName}`);
const snapMeta = snapRaw.metadata || snapRaw;
const snapDevices = snapMeta.devices || {};
const snapConfig = snapMeta.config || {};
await lxc('stop', instName, '--force');
await lxc('restore', instName, snapName);
const inst = await lxdGetInstance(instName);
const currentDevices = inst.devices || {};
const currentConfig = inst.config || {};
const needsUpdate = JSON.stringify(snapDevices) !== JSON.stringify(currentDevices)
|| JSON.stringify(snapConfig) !== JSON.stringify(currentConfig);
if (needsUpdate) {
await lxdUpdateInstance(instName, { devices: snapDevices, config: snapConfig });
}
await lxc('start', instName);
return json(res, 200, { ok: true, message: `Restored ${instName} from ${snapName}` });
} catch (e) { return json(res, 500, { error: e.message }); }
}
const snapDeleteMatch = pathname.match(/^\/api\/instances\/([^/]+)\/snapshots\/([^/]+)$/);
if (snapDeleteMatch && req.method === 'DELETE') {
try { await lxc('delete', `${snapDeleteMatch[1]}/${snapDeleteMatch[2]}`); return json(res, 200, { ok: true, message: `Deleted snapshot ${snapDeleteMatch[2]}` }); }
catch (e) { return json(res, 500, { error: e.message }); }
}
if (pathname === '/api/images' && req.method === 'GET') {
try { return json(res, 200, { images: getImages(), cached: !!cachedImages }); } catch (e) { return json(res, 500, { error: e.message }); }
}
if (pathname === '/api/images/refresh' && req.method === 'POST') {
try { return json(res, 200, { images: await refreshImages() }); } catch (e) { return json(res, 500, { error: e.message }); }
}
if (pathname === '/api/gpus' && req.method === 'GET') {
try {
const resources = await lxdApi('GET', '/1.0/resources?recursion=1');
const gpus = (resources.gpu || []).map(gpu => ({ pci: gpu.pci_address || '', desc: `${gpu.vendor || ''} ${gpu.product || ''}`.trim(), vendor: gpu.vendor || '', product: gpu.product || '', driver: gpu.driver || '', driverVersion: gpu.driver_version || '', numaNode: gpu.numa_node })).filter(g => g.pci);
if (gpus.length === 0) {
const { stdout } = await run('lspci', ['-Dnn']);
stdout.split('\n').filter(l => /VGA compatible controller|3D controller|Display controller/i.test(l)).forEach(line => {
const match = line.match(/^([0-9a-f]{4}:[0-9a-f]{2}:[0-9a-f]{2}\.\d)\s+(.*)/);
if (match) gpus.push({ pci: match[1], desc: match[2], vendor: '', product: '', driver: '', driverVersion: '' });
});
}
return json(res, 200, { gpus });
} catch (e) {
try {
const { stdout } = await run('lspci', ['-Dnn']);
const gpus = stdout.split('\n').filter(l => /VGA compatible controller|3D controller|Display controller/i.test(l)).map(line => {
const match = line.match(/^([0-9a-f]{4}:[0-9a-f]{2}:[0-9a-f]{2}\.\d)\s+(.*)/);
return match ? { pci: match[1], desc: match[2], vendor: '', product: '', driver: '', driverVersion: '' } : null;
}).filter(Boolean);
return json(res, 200, { gpus });
} catch (e2) { return json(res, 500, { error: e2.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' });
const inst = await lxdGetInstance(name);
const devices = inst.devices || {};
let devName = 'gpu0'; let n = 0;
while (devices[`gpu${n}`]) n++; devName = `gpu${n}`;
await lxdUpdateInstance(name, { devices: { ...devices, [devName]: { type: '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' });
const inst = await lxdGetInstance(name);
const devices = inst.devices || {};
if (!devices[body.deviceName]) return json(res, 400, { error: `Device ${body.deviceName} not found` });
const newDevices = { ...devices }; delete newDevices[body.deviceName];
await lxdUpdateInstance(name, { devices: newDevices });
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:14px}.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:14px;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:13px}.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:13px;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:15px;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:12px;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:14px;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:15px}
.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:14px;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:13px;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:13px}
.snap-item .snap-name{flex:1;color:var(--text)}.snap-item .snap-date{color:var(--text2);font-size:12px}
.inst-name{font-weight:600;font-family:'SF Mono',Consolas,monospace;font-size:14px}
.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:12px;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:14px}
.overview-label{color:var(--accent);font-weight:500;min-width:80px;font-family:'SF Mono',Consolas,monospace;font-size:13px}
.overview-value{color:var(--text);font-family:'SF Mono',Consolas,monospace;font-size:13px}.overview-scope{color:var(--text2);font-size:12px}
.overview-empty{color:var(--text2);font-style:italic;font-size:13px}
@media(max-width:900px){.overview-panel{grid-template-columns:1fr}}
.confirm-msg{font-size:15px;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:14px;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:12px;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:14px}
.gpu-item .gpu-pci{color:var(--text2);font-size:12px;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:12px;font-weight:600}
.gpu-item .gpu-remove{color:var(--red);cursor:pointer;font-size:13px}.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 インスタンス管理</div></div><div class="actions"><button class="btn btn-ghost btn-sm" onclick="refresh()">更新</button><button class="btn btn-primary" onclick="showCreate()">+ 新規インスタンス</button></div></div>
<div class="container"><div class="table-wrap"><table><thead><tr><th>名前</th><th>状態</th><th>作成日</th><th>プロファイル</th><th>操作</th></tr></thead><tbody id="instances"></tbody></table></div></div>
<div class="modal-overlay" id="createModal"><div class="modal"><div class="modal-header"><h2>インスタンス作成</h2><button class="modal-close" onclick="closeModals()">×</button></div><div class="modal-body"><div class="form-group"><label>インスタンス名</label><input type="text" id="newName" placeholder="例: my-server" pattern="[a-zA-Z0-9_-]+"></div><div class="form-group"><label>イメージ</label><div class="image-row"><select id="newImage"><option value="">読み込み中...</option></select><button class="btn btn-ghost btn-sm" id="refreshImagesBtn" onclick="doRefreshImages()" title="新しいイメージを確認">↻ 確認</button></div><input type="text" id="newImageCustom" placeholder="例: 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>オプション</label><label class="checkbox-label"><input type="checkbox" id="optUpdate" checked> アップデート (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 マウント</label></div></div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModals()">キャンセル</button><button class="btn btn-primary" id="createBtn" onclick="doCreate()" disabled>作成</button></div></div></div>
<div class="modal-overlay" id="cloneModal"><div class="modal"><div class="modal-header"><h2>インスタンスのクローン</h2><button class="modal-close" onclick="closeModals()">×</button></div><div class="modal-body"><div class="form-group"><label>ソース</label><input type="text" id="cloneSource" readonly></div><div class="form-group"><label>新しいインスタンス名</label><input type="text" id="cloneNewName" placeholder="例: my-server-copy"></div></div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModals()">キャンセル</button><button class="btn btn-primary" id="cloneBtn" onclick="doClone()">クローン</button></div></div></div>
<div class="modal-overlay" id="snapshotModal"><div class="modal"><div class="modal-header"><h2>スナップショット作成</h2><button class="modal-close" onclick="closeModals()">×</button></div><div class="modal-body"><div class="form-group"><label>インスタンス</label><input type="text" id="snapInstance" readonly></div><div class="form-group"><label>コメント(任意)</label><input type="text" id="snapComment" placeholder="例: before-update"></div></div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModals()">キャンセル</button><button class="btn btn-primary" id="snapBtn" onclick="doSnapshot()">作成</button></div></div></div>
<div class="modal-overlay" id="confirmModal"><div class="modal" style="width:400px"><div class="modal-header"><h2 id="confirmTitle">確認</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()">キャンセル</button><button class="btn btn-danger" id="confirmBtn">確認</button></div></div></div>
<div class="modal-overlay" id="gpuModal"><div class="modal" style="width:560px"><div class="modal-header"><h2>GPU パススルー</h2><button class="modal-close" onclick="closeModals()">×</button></div><div class="modal-body"><div class="form-group"><label>インスタンス</label><input type="text" id="gpuInstance" readonly></div><div class="form-group"><label>現在の GPU デバイス</label><div id="gpuCurrentList"></div></div><div class="form-group"><label>ホストの利用可能な GPU</label><div id="gpuAvailableList"></div></div></div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModals()">閉じる</button></div></div></div>
<script>
let instances=[],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'-';return new Date(d).toLocaleString('ja-JP',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
function statusBadge(s){const cls=s==='Running'?'running':s==='Stopped'?'stopped':'other';const labels={Running:'実行中',Stopped:'停止中'};return`<span class="badge badge-${cls}">${labels[s]||s}</span>`}
async function refresh(){const t=document.getElementById('instances');t.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){t.innerHTML=`<tr><td colspan="5" style="color:var(--red);padding:24px">エラー: ${e.message}</td></tr>`}}
async function loadImages(){try{const r=await api('/api/images');imageList=r.images||r;renderImageSelect()}catch(e){document.getElementById('newImage').innerHTML='<option value="__custom__">カスタム...</option>'}}
function renderImageSelect(){const s=document.getElementById('newImage'),p=s.value;s.innerHTML=imageList.map(i=>`<option value="${esc(i.alias)}">${esc(i.alias)} - ${esc(i.description)}</option>`).join('')+'<option value="__custom__">カスタム...</option>';if(p&&[...s.options].some(o=>o.value===p))s.value=p}
async function doRefreshImages(){const b=document.getElementById('refreshImagesBtn');b.disabled=true;b.textContent='確認中...';const w=toastWorking('新しいイメージを確認中...');try{const r=await api('/api/images/refresh',{method:'POST'});imageList=r.images||r;renderImageSelect();w.done(`${imageList.length} 個のイメージが見つかりました`)}catch(e){w.fail(`エラー: ${e.message}`)}finally{b.disabled=false;b.textContent='↻ 確認'}}
function render(){const t=document.getElementById('instances');if(!instances.length){t.innerHTML='<tr><td colspan="5" style="text-align:center;color:var(--text2);padding:40px">インスタンスがありません</td></tr>';return}t.innerHTML=instances.map(i=>`<tr><td><span class="inst-name clickable" onclick="toggleOverview('${esc(i.name)}')">${esc(i.name)}</span></td><td>${statusBadge(i.status)}</td><td style="color:var(--text2);font-size:14px">${formatDate(i.created_at)}</td><td style="color:var(--text2);font-size:14px">${(i.profiles||[]).join(', ')}</td><td><div class="actions-cell">${i.status==='Stopped'?`<button class="btn btn-ghost btn-sm" onclick="action('${i.name}','start')">起動</button>`:`<button class="btn btn-ghost btn-sm" onclick="action('${i.name}','stop')">停止</button>`}<button class="btn btn-ghost btn-sm" onclick="action('${i.name}','restart')">再起動</button><button class="btn btn-ghost btn-sm" onclick="showClone('${i.name}')">クローン</button><button class="btn btn-ghost btn-sm" onclick="showSnapshot('${i.name}')">スナップ</button><button class="btn btn-danger btn-sm" onclick="confirmAction('${i.name}','delete')">削除</button></div></td></tr><tr id="ov-${esc(i.name)}" class="overview-row" style="display:none"><td colspan="5">${renderOverview(i)}</td></tr><tr class="snap-row"><td colspan="5" style="padding:0 16px 8px">${renderSnapshots(i)}</td></tr>`).join('')}
function renderSnapshots(i){const s=i.snapshots||[];if(!s.length)return'';return`<details class="snap-section"><summary>スナップショット (${s.length})</summary><div class="snap-list">${s.map(p=>`<div class="snap-item"><span class="snap-name">${esc(p.name)}</span><span class="snap-date">${formatDate(p.created_at)}</span><button class="btn btn-ghost btn-sm btn-icon" onclick="confirmRestore('${i.name}','${esc(p.name)}')" title="復元">↺</button><button class="btn btn-danger btn-sm btn-icon" onclick="confirmDeleteSnap('${i.name}','${esc(p.name)}')" title="削除">×</button></div>`).join('')}</div></details>`}
function toggleOverview(n){const r=document.getElementById('ov-'+n);if(!r)return;const o=r.style.display!=='none';document.querySelectorAll('.overview-row').forEach(r=>r.style.display='none');if(!o)r.style.display=''}
function renderOverview(i){const net=(i.state&&i.state.network)||{},devices=i.devices||{},ips=[],mounts=[],gpus=[];for(const[f,info]of Object.entries(net)){if(f==='lo')continue;for(const a of(info.addresses||[])){if(a.family==='inet')ips.push({iface:f,addr:a.address,scope:a.scope})}}for(const[n,d]of Object.entries(devices)){if(d.type==='disk')mounts.push({name:n,source:d.source||'-',path:d.path||'-'});if(d.type==='gpu')gpus.push({name:n,pci:d.pci||'-',gputype:d.gputype||'physical'})}return`<div class="overview-panel"><div class="overview-section"><div class="overview-title">IP アドレス</div>${ips.length?ips.map(p=>`<div class="overview-item"><span class="overview-label">${esc(p.iface)}</span><span class="overview-value">${esc(p.addr)}</span><span class="overview-scope">${p.scope}</span></div>`).join(''):'<div class="overview-item overview-empty">IP なし(停止中)</div>'}</div><div class="overview-section"><div class="overview-title">マウントデバイス</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">なし</div>'}</div><div class="overview-section"><div class="overview-title">GPU パススルー</div><div class="overview-item gpustat-link" onclick="showGpuModal('${esc(i.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">なし(クリックで設定)</span>'}</div></div></div>`}
function esc(s){const e=document.createElement('span');e.textContent=s;return e.innerHTML}
async function action(n,a){const labels={start:'起動',stop:'停止',restart:'再起動'};const w=toastWorking(`${labels[a]||a} ${n}...`);try{await api(`/api/instances/${n}/${a}`,{method:'POST'});w.done(`${labels[a]||a}完了`);setTimeout(refresh,500)}catch(e){w.fail(`エラー: ${e.message}`)}}
function updateCreateBtn(){const n=document.getElementById('newName').value.trim(),i=getSelectedImage();document.getElementById('createBtn').disabled=!n||!i}
function updateOptsVisibility(){const i=getSelectedImage();document.getElementById('optsGroup').style.display=i.startsWith('ubuntu:')?'':'none'}
function getSelectedImage(){const s=document.getElementById('newImage');return s.value==='__custom__'?document.getElementById('newImageCustom').value.trim():s.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 c=document.getElementById('newImage').value==='__custom__';document.getElementById('newImageCustom').style.display=c?'':'none';updateOptsVisibility();updateCreateBtn()});
document.getElementById('newImageCustom').addEventListener('input',()=>{updateOptsVisibility();updateCreateBtn()});
async function doCreate(){const n=document.getElementById('newName').value.trim(),i=getSelectedImage();if(!n||!i)return;const u=i.startsWith('ubuntu:'),b={name:n,image:i};if(u){b.update=document.getElementById('optUpdate').checked;b.tailscale=document.getElementById('optTailscale').checked;b.docker=document.getElementById('optDocker').checked;b.mount=document.getElementById('optMount').checked}closeModals();const w=toastWorking(`インスタンス "${n}" を作成中 (${i})...`);try{const r=await api('/api/instances/create',{method:'POST',body:b});if(r.error)throw new Error(r.error);w.done(r.message||'インスタンスを作成しました');refresh()}catch(e){w.fail(`エラー: ${e.message}`)}}
function showClone(n){document.getElementById('cloneSource').value=n;document.getElementById('cloneNewName').value='';document.getElementById('cloneModal').classList.add('active')}
async function doClone(){const s=document.getElementById('cloneSource').value,n=document.getElementById('cloneNewName').value.trim();if(!n)return;closeModals();const w=toastWorking(`"${s}" を "${n}" にクローン中...`);try{const r=await api(`/api/instances/${s}/clone`,{method:'POST',body:{newName:n}});if(r.error)throw new Error(r.error);w.done(r.message||'クローンしました');refresh()}catch(e){w.fail(`エラー: ${e.message}`)}}
function showSnapshot(n){document.getElementById('snapInstance').value=n;document.getElementById('snapComment').value='';document.getElementById('snapshotModal').classList.add('active')}
async function doSnapshot(){const n=document.getElementById('snapInstance').value,c=document.getElementById('snapComment').value.trim();closeModals();const w=toastWorking(`"${n}" のスナップショットを作成中...`);try{const r=await api(`/api/instances/${n}/snapshots`,{method:'POST',body:{comment:c}});if(r.error)throw new Error(r.error);w.done(r.message||'スナップショットを作成しました');refresh()}catch(e){w.fail(`エラー: ${e.message}`)}}
function confirmRestore(i,s){document.getElementById('confirmTitle').textContent='スナップショットの復元';document.getElementById('confirmMsg').innerHTML=`<span class="confirm-name">${esc(i)}</span> をスナップショット <span class="confirm-name">${esc(s)}</span> から復元しますか?<br>現在の状態は失われます。`;document.getElementById('confirmBtn').onclick=async()=>{closeModals();const w=toastWorking(`${i} を ${s} から復元中...`);try{await api(`/api/instances/${i}/snapshots/${s}/restore`,{method:'POST'});w.done('復元しました');refresh()}catch(e){w.fail(`エラー: ${e.message}`)}};document.getElementById('confirmModal').classList.add('active')}
function confirmDeleteSnap(i,s){document.getElementById('confirmTitle').textContent='スナップショットの削除';document.getElementById('confirmMsg').innerHTML=`<span class="confirm-name">${esc(i)}</span> のスナップショット <span class="confirm-name">${esc(s)}</span> を削除しますか?`;document.getElementById('confirmBtn').onclick=async()=>{closeModals();const w=toastWorking(`スナップショット ${s} を削除中...`);try{await api(`/api/instances/${i}/snapshots/${s}`,{method:'DELETE'});w.done('削除しました');refresh()}catch(e){w.fail(`エラー: ${e.message}`)}};document.getElementById('confirmModal').classList.add('active')}
function confirmAction(n,a){document.getElementById('confirmTitle').textContent='インスタンスの削除';document.getElementById('confirmMsg').innerHTML=`インスタンス <span class="confirm-name">${esc(n)}</span> を削除しますか?<br>この操作は取り消せません。`;document.getElementById('confirmBtn').onclick=async()=>{closeModals();const w=toastWorking(`${n} を削除中...`);try{await api(`/api/instances/${n}/delete`,{method:'POST'});w.done('削除しました');refresh()}catch(e){w.fail(`エラー: ${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">読み込み中...</div>';document.getElementById('gpuAvailableList').innerHTML='<div class="overview-empty">読み込み中...</div>';document.getElementById('gpuModal').classList.add('active');try{const i=instances.find(x=>x.name===instName),d=(i&&i.devices)||{},gpus=Object.entries(d).filter(([,v])=>v.type==='gpu');document.getElementById('gpuCurrentList').innerHTML=gpus.length?gpus.map(([n,v])=>`<div class="gpu-item gpu-current"><span class="gpu-label">${esc(n)}</span><span class="gpu-desc">${esc(v.gputype||'physical')} / ${esc(v.pci||'all')}</span><span class="gpu-remove" onclick="confirmRemoveGpu('${esc(instName)}','${esc(n)}')">取り外す</span></div>`).join(''):'<div class="overview-empty">GPU がアタッチされていません</div>'}catch(e){document.getElementById('gpuCurrentList').innerHTML=`<div class="overview-empty">エラー: ${esc(e.message)}</div>`}try{const r=await api('/api/gpus'),gpus=r.gpus||[];document.getElementById('gpuAvailableList').innerHTML=gpus.length?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(''):'<div class="overview-empty">ホストに GPU が検出されません</div>'}catch(e){document.getElementById('gpuAvailableList').innerHTML=`<div class="overview-empty">エラー: ${esc(e.message)}</div>`}}
function confirmAddGpu(instName,pci,desc){document.getElementById('confirmTitle').textContent='GPU パススルーの追加';document.getElementById('confirmMsg').innerHTML=`GPU <span class="confirm-name">${esc(desc)}</span> (${esc(pci)}) を <span class="confirm-name">${esc(instName)}</span> に追加しますか?<br>GPU パススルーは即座に有効になります(再起動不要)。`;document.getElementById('confirmBtn').onclick=async()=>{closeModals();const w=toastWorking(`GPU を ${instName} に追加中...`);try{const r=await api(`/api/instances/${instName}/gpu/add`,{method:'POST',body:{pci}});if(r.error)throw new Error(r.error);w.done(r.message||'GPU を追加しました');refresh();setTimeout(()=>showGpuModal(instName),600)}catch(e){w.fail(`エラー: ${e.message}`)}};closeModals();document.getElementById('confirmModal').classList.add('active')}
function confirmRemoveGpu(instName,deviceName){document.getElementById('confirmTitle').textContent='GPU パススルーの取り外し';document.getElementById('confirmMsg').innerHTML=`GPU デバイス <span class="confirm-name">${esc(deviceName)}</span> を <span class="confirm-name">${esc(instName)}</span> から取り外しますか?`;document.getElementById('confirmBtn').onclick=async()=>{closeModals();const w=toastWorking(`GPU を ${instName} から取り外し中...`);try{const r=await api(`/api/instances/${instName}/gpu/remove`,{method:'POST',body:{deviceName}});if(r.error)throw new Error(r.error);w.done(r.message||'GPU を取り外しました');refresh();setTimeout(()=>showGpuModal(instName),600)}catch(e){w.fail(`エラー: ${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 サービスをインストールし起動しました"
else
echo ""
echo "NOTE: root 権限がないため systemd サービスを作成できません。"
echo "手動起動: setsid ${NODE_PATH} ${INSTALL_DIR}/server.js </dev/null > /tmp/easy-lxd.log 2>&1 &"
fi
echo ""
echo "=== インストール完了 ==="
echo " Node: ${NODE_PATH} ($(${NODE_PATH} -v))"
echo " URL: http://$(hostname -I | awk '{print $1}'):${PORT}"
echo " Dir: ${INSTALL_DIR}"
echo ""
アンインストール
sudo mkdir -p /opt/script/easylxd
cd /opt/script/easylxd
sudo nano uninstall-exsylxd2.sh
sudo bash uninstall-exsylxd2.sh
#!/bin/bash
set -euo pipefail
INSTALL_DIR="/opt/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"


