rsyncの設定をGUIで行えるツールを作ってみました。複数のペアを登録し、スケジュール実行出来るので、日々のバックアップなどに活用出来ると思います。

目次
LXDコンテナにインストール
#!/bin/bash
set -e
INSTALL_DIR="/opt/rsyncgui"
PORT=3326
SERVICE_NAME="rsyncgui"
echo "=== rsyncGUI Installer ==="
# Detect package manager
if command -v apt-get &>/dev/null; then
PKG="apt"
elif command -v yum &>/dev/null; then
PKG="yum"
elif command -v dnf &>/dev/null; then
PKG="dnf"
elif command -v apk &>/dev/null; then
PKG="apk"
else
echo "Unsupported package manager. Install python3, rsync, sshpass manually."
exit 1
fi
echo "[1/6] Installing dependencies..."
case $PKG in
apt)
apt-get update -qq
apt-get install -y -qq python3 rsync sshpass openssh-client
;;
yum)
yum install -y -q python3 rsync sshpass openssh-clients
;;
dnf)
dnf install -y -q python3 rsync sshpass openssh-clients
;;
apk)
apk add --no-cache python3 rsync sshpass openssh-client
;;
esac
echo "[2/6] Creating install directory..."
mkdir -p "$INSTALL_DIR/public"
echo "[3/6] Writing server..."
cat > "$INSTALL_DIR/server.py" << 'PYEOF'
#!/usr/bin/env python3
import http.server
import json
import os
import subprocess
import threading
import time
import signal
import sys
import tempfile
from urllib.parse import urlparse, parse_qs
PORT = 3326
PUBLIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "public")
CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
CRON_TAG = "# rsyncgui-managed"
MIME = {
".html": "text/html",
".css": "text/css",
".js": "application/javascript",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
}
processes = {}
def load_config():
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, "r") as f:
return json.load(f)
return {"pairs": [], "schedules": []}
def save_config(cfg):
with open(CONFIG_FILE, "w") as f:
json.dump(cfg, f, indent=2, ensure_ascii=False)
def build_rsync_cmd(job):
args = []
mode = job.get("mode", "sync")
if mode == "copy":
args.append("-r")
elif mode == "sync":
args.append("-avz")
options = job.get("options", {})
for key, val in options.items():
if val is True:
args.append(f"--{key}")
elif val is not False and val != "" and val is not None:
args.append(f"--{key}={val}")
if job.get("dryRun") or job.get("dry_run"):
args.append("--dry-run")
ssh_cfg = job.get("ssh")
if ssh_cfg:
ssh_parts = ["ssh"]
if ssh_cfg.get("port") and ssh_cfg["port"] != "22":
ssh_parts.append(f"-p {ssh_cfg['port']}")
if ssh_cfg.get("key"):
ssh_parts.append(f"-i {ssh_cfg['key']}")
ssh_parts.append("-o StrictHostKeyChecking=no")
args.append("-e")
args.append(" ".join(ssh_parts))
exclude = job.get("exclude", "")
for line in exclude.split("\n"):
line = line.strip()
if line:
args.append(f"--exclude={line}")
source = job.get("source", "")
target = job.get("target", "")
if ssh_cfg and ssh_cfg.get("host"):
user_prefix = f"{ssh_cfg['user']}@" if ssh_cfg.get("user") else ""
if ":" not in target:
target = f"{user_prefix}{ssh_cfg['host']}:{target}"
args.append(source)
args.append(target)
use_sshpass = ssh_cfg and ssh_cfg.get("password") and ssh_cfg.get("host")
password = ssh_cfg.get("password", "") if use_sshpass else ""
return args, use_sshpass, password
def get_crontab():
try:
result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
if result.returncode == 0:
return result.stdout
except Exception:
pass
return ""
def set_crontab(content):
proc = subprocess.run(["crontab", "-"], input=content, text=True, capture_output=True)
return proc.returncode == 0
def schedule_to_cron_time(schedule):
time_str = schedule.get("time", "02:00")
hour, minute = time_str.split(":")
days = schedule.get("days", [])
if not days:
return None
day_str = ",".join(str(d) for d in days)
return f"{minute} {hour} * * {day_str}"
def pair_to_cron_cmd(pair):
job = {
"source": pair.get("source", ""),
"target": pair.get("target", ""),
"mode": pair.get("mode", "sync"),
"options": pair.get("options", {}),
"ssh": pair.get("ssh") if pair.get("sshEnabled") else None,
"exclude": pair.get("exclude", ""),
"dryRun": pair.get("dryRun", False),
}
args, use_sshpass, password = build_rsync_cmd(job)
if use_sshpass:
return f"sshpass -f /tmp/rsyncgui_cron_pw_{pair.get('id', '?')} rsync " + " ".join(args)
return "rsync " + " ".join(args)
def sync_crontab(pairs):
existing = get_crontab()
lines = [l for l in existing.splitlines() if CRON_TAG not in l]
for pair in pairs:
sched = pair.get("schedule", {})
if not sched.get("enabled"):
continue
pattern_idx = sched.get("pattern", 0)
patterns = [
{"time": "02:00", "days": [1,2,3,4,5,6,0]},
{"time": "12:00", "days": [1,3,5]},
{"time": "23:00", "days": [6,0]},
]
if pattern_idx >= len(patterns):
continue
pat = patterns[pattern_idx]
cron_time = schedule_to_cron_time(pat)
if not cron_time:
continue
cmd = pair_to_cron_cmd(pair)
cron_line = f"{cron_time} {cmd} {CRON_TAG} id={pair.get('id', '?')}"
lines.append(cron_line)
new_crontab = "\n".join(lines) + "\n" if lines else ""
return set_crontab(new_crontab)
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, format, *args):
pass
def _cors(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
def _json_response(self, data, code=200):
body = json.dumps(data).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self._cors()
self.send_header("Content-Length", len(body))
self.end_headers()
self.wfile.write(body)
def _file_response(self, filepath):
ext = os.path.splitext(filepath)[1]
mime = MIME.get(ext, "application/octet-stream")
try:
with open(filepath, "rb") as f:
data = f.read()
self.send_response(200)
self.send_header("Content-Type", f"{mime}; charset=utf-8")
self._cors()
self.send_header("Content-Length", len(data))
self.end_headers()
self.wfile.write(data)
except FileNotFoundError:
self.send_response(404)
self.end_headers()
self.wfile.write(b"Not found")
def do_OPTIONS(self):
self.send_response(204)
self._cors()
self.end_headers()
def do_GET(self):
parsed = urlparse(self.path)
path = parsed.path
params = parse_qs(parsed.query)
if path == "/api/browse":
browse_path = params.get("path", ["/"])[0]
self._browse(browse_path)
elif path.startswith("/api/status/"):
job_id = path.split("/")[-1]
self._status(job_id)
elif path == "/api/config":
self._get_config()
elif path == "/api/cron":
self._get_cron()
else:
if path == "/":
path = "/index.html"
filepath = os.path.join(PUBLIC, path.lstrip("/"))
self._file_response(filepath)
def do_POST(self):
parsed = urlparse(self.path)
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
if parsed.path == "/api/rsync":
self._run_rsync(body)
elif parsed.path == "/api/config":
self._save_config(body)
elif parsed.path == "/api/schedule/save":
self._save_schedule(body)
else:
self.send_response(404)
self.end_headers()
def _browse(self, browse_path):
browse_path = browse_path or "/"
try:
entries = os.scandir(browse_path)
items = []
for e in entries:
if e.name.startswith("."):
continue
if e.is_dir():
items.append({
"name": e.name,
"type": "dir",
"path": os.path.join(browse_path, e.name),
})
items.sort(key=lambda x: x["name"])
parent = os.path.dirname(browse_path.rstrip("/"))
if not parent:
parent = "/"
self._json_response({"current": browse_path, "parent": parent, "items": items})
except Exception as ex:
self._json_response({"error": str(ex)}, 500)
def _get_config(self):
cfg = load_config()
self._json_response(cfg)
def _save_config(self, body):
try:
cfg = json.loads(body)
save_config(cfg)
self._json_response({"ok": True})
except Exception as ex:
self._json_response({"error": str(ex)}, 500)
def _save_schedule(self, body):
try:
data = json.loads(body)
pairs = data.get("pairs", [])
sync_crontab(pairs)
cfg = load_config()
cfg["pairs"] = pairs
save_config(cfg)
self._json_response({"ok": True})
except Exception as ex:
self._json_response({"error": str(ex)}, 500)
def _get_cron(self):
content = get_crontab()
lines = [l for l in content.splitlines() if CRON_TAG in l]
self._json_response({"entries": lines})
def _run_rsync(self, body):
try:
job = json.loads(body)
args, use_sshpass, password = build_rsync_cmd(job)
job_id = f"job_{int(time.time() * 1000)}"
cmd = "rsync " + " ".join(args)
if use_sshpass:
cmd = f"sshpass -f /tmp/rsyncgui_pw_{job_id} rsync " + " ".join(args)
self._json_response({"id": job_id, "command": cmd})
def run():
pw_file = None
try:
if use_sshpass:
pw_file = tempfile.NamedTemporaryFile(mode='w', delete=False, prefix='rsyncgui_pw_')
pw_file.write(password)
pw_file.close()
os.chmod(pw_file.name, 0o600)
cmd_list = ["sshpass", "-f", pw_file.name, "rsync"] + args
else:
cmd_list = ["rsync"] + args
proc = subprocess.Popen(
cmd_list,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
processes[job_id] = {"proc": proc, "log": [], "done": False, "exitCode": None}
for line in proc.stdout:
processes[job_id]["log"].append({"type": "stdout", "text": line})
for line in proc.stderr:
processes[job_id]["log"].append({"type": "stderr", "text": line})
proc.wait()
processes[job_id]["done"] = True
processes[job_id]["exitCode"] = proc.returncode
except Exception as e:
processes[job_id]["log"].append({"type": "error", "text": str(e)})
processes[job_id]["done"] = True
processes[job_id]["exitCode"] = -1
finally:
if pw_file and os.path.exists(pw_file.name):
os.unlink(pw_file.name)
threading.Thread(target=run, daemon=True).start()
except Exception as ex:
self._json_response({"error": str(ex)}, 500)
def _status(self, job_id):
p = processes.get(job_id)
if not p:
self._json_response({"error": "not found"}, 404)
return
log = p["log"][:]
p["log"] = []
self._json_response({
"done": p["done"],
"exitCode": p["exitCode"],
"log": log,
})
def main():
server = http.server.HTTPServer(("0.0.0.0", PORT), Handler)
print(f"rsyncGUI running at http://localhost:{PORT}")
def shutdown(sig, frame):
print("\nShutting down...")
server.shutdown()
sys.exit(0)
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
server.serve_forever()
if __name__ == "__main__":
main()
PYEOF
echo "[4/6] Writing frontend..."
cat > "$INSTALL_DIR/public/index.html" << 'HTMLEOF'
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>rsyncGUI</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #232733;
--border: #2e3345;
--text: #e1e4ed;
--text2: #8b90a0;
--accent: #5b8af5;
--accent2: #4a6fd8;
--green: #3ecf8e;
--red: #f44;
--orange: #f5a623;
--radius: 8px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
header h1 {
font-size: 18px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
header h1 span { color: var(--accent); }
.header-actions { display: flex; gap: 8px; }
.btn {
padding: 7px 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface2);
color: var(--text);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.btn:hover { background: var(--border); }
.btn-accent {
background: var(--accent);
border-color: var(--accent);
color: #fff;
font-weight: 600;
}
.btn-accent:hover { background: var(--accent2); }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.btn-danger { border-color: var(--red); color: var(--red); }
.btn-danger:hover { background: rgba(244,68,68,0.1); }
.btn-icon { padding: 6px 8px; font-size: 16px; line-height: 1; }
main { padding: 20px 24px; }
.pairs-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.pairs-header h2 { font-size: 15px; font-weight: 600; }
.pair-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
margin-bottom: 12px;
overflow: hidden;
transition: border-color 0.15s;
}
.pair-card:hover { border-color: #3a3f55; }
.pair-card.active { border-color: var(--accent); }
.pair-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: var(--surface2);
border-bottom: 1px solid var(--border);
cursor: pointer;
user-select: none;
}
.pair-header:hover { background: #282c3a; }
.pair-title {
font-size: 13px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
.pair-num {
background: var(--accent);
color: #fff;
width: 22px;
height: 22px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
}
.pair-status {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
}
.pair-status.idle { background: var(--surface); color: var(--text2); }
.pair-status.running { background: rgba(91,138,245,0.15); color: var(--accent); }
.pair-status.done { background: rgba(62,207,142,0.15); color: var(--green); }
.pair-status.error { background: rgba(244,68,68,0.15); color: var(--red); }
.pair-body { padding: 16px; }
.pair-body.collapsed { display: none; }
.path-row {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.path-group {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.path-group label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text2);
}
.path-input {
display: flex;
gap: 0;
}
.path-input input {
flex: 1;
padding: 8px 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius) 0 0 var(--radius);
color: var(--text);
font-size: 13px;
font-family: 'SF Mono', 'Fira Code', monospace;
outline: none;
}
.path-input input:focus { border-color: var(--accent); }
.path-input button {
padding: 8px 10px;
background: var(--surface2);
border: 1px solid var(--border);
border-left: none;
border-radius: 0 var(--radius) var(--radius) 0;
color: var(--text2);
cursor: pointer;
font-size: 14px;
}
.path-input button:hover { background: var(--border); color: var(--text); }
.arrow-center {
display: flex;
align-items: flex-end;
padding-bottom: 8px;
color: var(--text2);
font-size: 20px;
}
.mode-row {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.mode-group {
display: flex;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.mode-btn {
padding: 8px 20px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: none;
background: transparent;
color: var(--text2);
transition: all 0.15s;
}
.mode-btn.active {
background: var(--accent);
color: #fff;
}
.mode-btn:hover:not(.active) { background: var(--surface2); color: var(--text); }
.options-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 4px;
margin-bottom: 16px;
}
.option-item {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 8px;
border-radius: 6px;
cursor: pointer;
transition: background 0.1s;
}
.option-item:hover { background: var(--surface2); }
.option-item input[type="checkbox"] {
accent-color: var(--accent);
width: 15px;
height: 15px;
}
.option-item label {
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
}
.option-item .opt-name { font-weight: 600; }
.option-item .opt-desc { color: var(--text2); font-size: 11px; }
.ssh-toggle {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.toggle {
position: relative;
width: 36px;
height: 20px;
background: var(--border);
border-radius: 10px;
cursor: pointer;
transition: background 0.2s;
}
.toggle.active { background: var(--accent); }
.toggle::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
background: #fff;
border-radius: 50%;
transition: transform 0.2s;
}
.toggle.active::after { transform: translateX(16px); }
.ssh-toggle span { font-size: 12px; font-weight: 600; }
.ssh-config {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
margin-bottom: 16px;
padding: 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.ssh-config.hidden { display: none; }
.ssh-field { display: flex; flex-direction: column; gap: 4px; }
.ssh-field label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: var(--text2);
}
.ssh-field input {
padding: 7px 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
outline: none;
}
.ssh-field input:focus { border-color: var(--accent); }
.exclude-row {
margin-bottom: 16px;
}
.exclude-row label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: var(--text2);
display: block;
margin-bottom: 4px;
}
.exclude-row textarea {
width: 100%;
height: 50px;
padding: 8px 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 12px;
font-family: 'SF Mono', 'Fira Code', monospace;
resize: vertical;
outline: none;
}
.exclude-row textarea:focus { border-color: var(--accent); }
.pair-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.pair-actions-left { display: flex; gap: 8px; }
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.modal-overlay.hidden { display: none; }
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
width: 600px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.modal-header h3 { font-size: 15px; font-weight: 600; }
.modal-close {
background: none;
border: none;
color: var(--text2);
font-size: 20px;
cursor: pointer;
}
.modal-close:hover { color: var(--text); }
.modal-body { flex: 1; overflow-y: auto; padding: 12px 20px; }
.modal-footer {
padding: 12px 20px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 8px;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 0;
font-size: 13px;
flex-wrap: wrap;
}
.breadcrumb-item {
color: var(--accent);
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
}
.breadcrumb-item:hover { background: rgba(91,138,245,0.1); }
.breadcrumb-sep { color: var(--text2); }
.folder-list { list-style: none; }
.folder-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: background 0.1s;
}
.folder-item:hover { background: var(--surface2); }
.folder-item .icon { font-size: 16px; }
.folder-item .name { flex: 1; }
.folder-item .path { color: var(--text2); font-size: 11px; font-family: monospace; }
.folder-item.selected { background: rgba(91,138,245,0.15); border: 1px solid var(--accent); }
.log-panel {
margin-top: 24px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
.log-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: var(--surface2);
border-bottom: 1px solid var(--border);
}
.log-header h3 { font-size: 13px; font-weight: 600; }
.log-body {
padding: 12px 16px;
max-height: 300px;
overflow-y: auto;
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
color: var(--text2);
}
.log-body .stdout { color: var(--text); }
.log-body .stderr { color: var(--orange); }
.log-body .error { color: var(--red); }
.notification {
position: fixed;
top: 16px;
right: 16px;
padding: 12px 20px;
border-radius: var(--radius);
font-size: 13px;
font-weight: 600;
z-index: 2000;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.notification.success { background: rgba(62,207,142,0.15); color: var(--green); border: 1px solid var(--green); }
.notification.error { background: rgba(244,68,68,0.15); color: var(--red); border: 1px solid var(--red); }
.empty-state {
text-align: center;
padding: 40px;
color: var(--text2);
}
.empty-state .icon { font-size: 48px; margin-bottom: 12px; }
.empty-state p { font-size: 14px; }
.schedule-section {
margin-bottom: 16px;
padding: 12px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.schedule-section.hidden { display: none; }
.schedule-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
}
.schedule-row:last-child { margin-bottom: 0; }
.schedule-row label {
font-size: 12px;
font-weight: 600;
min-width: 80px;
}
.schedule-row input[type="time"],
.schedule-row select {
padding: 5px 8px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 12px;
outline: none;
}
.schedule-row input[type="time"]:focus,
.schedule-row select:focus { border-color: var(--accent); }
.schedule-row .days {
display: flex;
gap: 4px;
}
.schedule-row .day-btn {
width: 28px;
height: 24px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
color: var(--text2);
font-size: 10px;
font-weight: 600;
cursor: pointer;
}
.schedule-row .day-btn.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.schedule-desc {
font-size: 11px;
color: var(--text2);
margin-top: 8px;
padding: 6px 8px;
background: var(--surface);
border-radius: 4px;
}
.schedule-toggle {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.schedule-select {
padding: 5px 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 12px;
outline: none;
}
.schedule-select:focus { border-color: var(--accent); }
.schedule-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
background: rgba(62,207,142,0.15);
color: var(--green);
}
.schedule-badge.off {
background: var(--surface);
color: var(--text2);
}
</style>
</head>
<body>
<header>
<h1><span>⚡</span> rsyncGUI</h1>
<div class="header-actions">
<button class="btn btn-sm" onclick="addPair()">+ ペア追加</button>
<button class="btn btn-sm" onclick="exportConfig()">📥 エクスポート</button>
<button class="btn btn-sm" onclick="document.getElementById('importFile').click()">📤 インポート</button>
<input type="file" id="importFile" accept=".json" style="display:none" onchange="importConfig(event)">
<button class="btn btn-sm" onclick="saveSchedule()" style="border-color:var(--green);color:var(--green)">⏰ スケジュール登録</button>
<button class="btn btn-sm btn-accent" onclick="runAll()">▶ 全実行</button>
</div>
</header>
<main>
<div id="pairs"></div>
<div id="logPanel" class="log-panel" style="display:none">
<div class="log-header">
<h3>📋 実行ログ</h3>
<button class="btn btn-sm" onclick="clearLog()">クリア</button>
</div>
<div class="log-body" id="logBody"></div>
</div>
</main>
<div class="modal-overlay hidden" id="folderModal">
<div class="modal">
<div class="modal-header">
<h3>📁 フォルダを選択</h3>
<button class="modal-close" onclick="closeModal()">×</button>
</div>
<div class="modal-body">
<div class="breadcrumb" id="breadcrumb"></div>
<ul class="folder-list" id="folderList"></ul>
</div>
<div class="modal-footer">
<button class="btn" onclick="closeModal()">キャンセル</button>
<button class="btn btn-accent" id="selectBtn" onclick="selectFolder()">選択</button>
</div>
</div>
</div>
<script>
const RSYNC_OPTIONS = [
{ key: 'archive', short: 'a', desc: 'アーカイブモード(権限・タイムスタンプ保持)' },
{ key: 'verbose', short: 'v', desc: '詳細出力' },
{ key: 'compress', short: 'z', desc: '転送時圧縮' },
{ key: 'recursive', short: 'r', desc: 'サブディレクトリ再帰' },
{ key: 'times', short: 't', desc: 'タイムスタンプ維持' },
{ key: 'perms', short: 'p', desc: 'パーミッション維持' },
{ key: 'owner', short: 'o', desc: 'オーナー情報維持' },
{ key: 'group', short: 'g', desc: 'グループ情報維持' },
{ key: 'links', short: 'l', desc: 'シンボリックリンクを複製' },
{ key: 'hard-links', short: 'H', desc: 'ハードリンクを複製' },
{ key: 'delete', short: null, desc: 'ターゲット側の不要ファイル削除' },
{ key: 'partial', short: 'P', desc: '中断ファイルを保持' },
{ key: 'progress', short: null, desc: '進捗表示' },
{ key: 'human-readable', short: 'h', desc: '人間が読めるサイズ表示' },
{ key: 'checksum', short: 'c', desc: 'チェックサムで比較' },
{ key: 'itemize-changes', short: 'i', desc: '変更詳細表示' },
{ key: 'stats', short: null, desc: '統計情報表示' },
{ key: 'ignore-existing', short: null, desc: '既存ファイルをスキップ' },
{ key: 'update', short: 'u', desc: '新しいファイルのみ転送' },
{ key: 'backup', short: 'b', desc: '上書き前にバックアップ' },
];
let pairs = [];
let nextId = 1;
let currentPairId = null;
let currentInputId = null;
let currentPath = '/';
let selectedFolder = null;
let pollingTimers = {};
const SCHEDULES = [
{ name: '設定1', time: '02:00', days: [1,2,3,4,5,6,0], desc: '毎日 02:00' },
{ name: '設定2', time: '12:00', days: [1,3,5], desc: '月・水・金 12:00' },
{ name: '設定3', time: '23:00', days: [6,0], desc: '土・日 23:00' },
];
const DAY_NAMES = ['日','月','火','水','木','金','土'];
function createDefaultPair() {
return {
id: nextId++,
source: '',
target: '',
mode: 'sync',
sshEnabled: false,
ssh: { host: '', port: '22', user: '', password: '', key: '' },
options: { archive: true, verbose: true, compress: true, progress: true },
exclude: '',
dryRun: false,
status: 'idle',
collapsed: false,
schedule: { enabled: false, pattern: 0 },
};
}
function ensurePair(p) {
if (!p.ssh) p.ssh = { host: '', port: '22', user: '', password: '', key: '' };
if (!p.options) p.options = { archive: true, verbose: true, compress: true, progress: true };
if (!p.schedule) p.schedule = { enabled: false, pattern: 0 };
return p;
}
function addPair() {
pairs.push(createDefaultPair());
render();
}
function removePair(id) {
pairs = pairs.filter(p => p.id !== id);
render();
}
function toggleCollapse(id) {
const p = pairs.find(x => x.id === id);
if (p) p.collapsed = !p.collapsed;
render();
}
function setMode(id, mode) {
const p = pairs.find(x => x.id === id);
if (p) { p.mode = mode; render(); }
}
function toggleOption(id, key) {
const p = pairs.find(x => x.id === id);
if (p) p.options[key] = !p.options[key];
}
function toggleSSH(id) {
const p = pairs.find(x => x.id === id);
if (p) { p.sshEnabled = !p.sshEnabled; render(); }
}
function toggleSchedule(id) {
const p = pairs.find(x => x.id === id);
if (p) {
p.schedule.enabled = !p.schedule.enabled;
render();
}
}
function setSchedulePattern(id, idx) {
const p = pairs.find(x => x.id === id);
if (p) { p.schedule.pattern = parseInt(idx); render(); }
}
function setScheduleTime(id, time) {
const p = pairs.find(x => x.id === id);
if (p) SCHEDULES[p.schedule.pattern].time = time;
}
function toggleScheduleDay(id, day) {
const p = pairs.find(x => x.id === id);
if (!p) return;
const s = SCHEDULES[p.schedule.pattern];
const idx = s.days.indexOf(day);
if (idx >= 0) s.days.splice(idx, 1);
else s.days.push(day);
s.days.sort();
render();
}
function getScheduleDesc(p) {
if (!p.schedule.enabled) return 'スケジュール: OFF';
const s = SCHEDULES[p.schedule.pattern];
if (!s.days.length) return s.name + ': 未設定';
const dayStr = s.days.map(d => DAY_NAMES[d]).join('・');
return s.name + ': ' + dayStr + ' ' + s.time;
}
function updateField(id, field, value) {
const p = pairs.find(x => x.id === id);
if (!p) return;
if (field.startsWith('ssh.')) {
p.ssh[field.slice(4)] = value;
} else {
p[field] = value;
}
}
function openFolderChooser(pairId, inputType) {
currentPairId = pairId;
currentInputId = inputType;
const p = pairs.find(x => x.id === pairId);
currentPath = (inputType === 'source' ? p.source : p.target) || '/';
if (!currentPath.endsWith('/')) currentPath += '/';
selectedFolder = null;
document.getElementById('folderModal').classList.remove('hidden');
browseTo(currentPath);
}
function closeModal() {
document.getElementById('folderModal').classList.add('hidden');
}
function selectFolder() {
if (!selectedFolder) return;
const p = pairs.find(x => x.id === currentPairId);
if (!p) return;
if (currentInputId === 'source') p.source = selectedFolder;
else p.target = selectedFolder;
closeModal();
render();
}
async function browseTo(dirPath) {
currentPath = dirPath;
const res = await fetch('/api/browse?path=' + encodeURIComponent(dirPath));
const data = await res.json();
if (data.error) return;
const bc = document.getElementById('breadcrumb');
const parts = data.current.split('/').filter(Boolean);
let html = '<span class="breadcrumb-item" onclick="browseTo(\'/\')">/</span>';
let accumulated = '';
for (const part of parts) {
accumulated += '/' + part;
const p = accumulated;
html += '<span class="breadcrumb-sep">/</span>';
html += '<span class="breadcrumb-item" onclick="browseTo(\'' + p.replace(/'/g, "\\'") + '\')">' + part + '</span>';
}
bc.innerHTML = html;
const list = document.getElementById('folderList');
let items = '';
if (data.parent && data.parent !== data.current) {
items += '<li class="folder-item" onclick="browseTo(\'' + data.parent.replace(/'/g, "\\'") + '\')">';
items += '<span class="icon">⬆</span><span class="name">..</span></li>';
}
for (const item of data.items) {
const sel = selectedFolder === item.path ? ' selected' : '';
items += '<li class="folder-item' + sel + '" onclick="selectItem(this, \'' + item.path.replace(/'/g, "\\'") + '\')">';
items += '<span class="icon">📁</span><span class="name">' + item.name + '</span>';
items += '<span class="path">' + item.path + '</span></li>';
}
if (!data.items.length && !data.parent) {
items = '<li class="folder-item"><span class="name">(フォルダなし)</span></li>';
}
list.innerHTML = items;
}
function selectItem(el, path) {
selectedFolder = path;
document.querySelectorAll('.folder-item').forEach(x => x.classList.remove('selected'));
el.classList.add('selected');
}
async function runPair(id) {
const p = pairs.find(x => x.id === id);
if (!p || !p.source || !p.target) {
notify('ソースとターゲットを指定してください', 'error');
return;
}
p.status = 'running';
render();
try {
const body = {
source: p.source,
target: p.target,
mode: p.mode,
options: p.options,
ssh: p.sshEnabled ? p.ssh : null,
dryRun: p.dryRun,
exclude: p.exclude,
};
const res = await fetch('/api/rsync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
showLog('▶ ' + data.command);
pollStatus(id, data.id);
} catch (e) {
p.status = 'error';
render();
notify('エラー: ' + e.message, 'error');
}
}
function pollStatus(pairId, jobId) {
const timer = setInterval(async () => {
try {
const res = await fetch('/api/status/' + jobId);
const data = await res.json();
for (const line of data.log) {
showLog(line.text, line.type);
}
data.log.length = 0;
if (data.done) {
clearInterval(timer);
const p = pairs.find(x => x.id === pairId);
if (p) p.status = data.exitCode === 0 ? 'done' : 'error';
render();
notify(data.exitCode === 0 ? '完了' : 'エラー終了', data.exitCode === 0 ? 'success' : 'error');
}
} catch (e) {
clearInterval(timer);
}
}, 500);
pollingTimers[pairId] = timer;
}
function runAll() {
for (const p of pairs) {
if (p.source && p.target) runPair(p.id);
}
}
function showLog(text, type) {
const panel = document.getElementById('logPanel');
const body = document.getElementById('logBody');
panel.style.display = 'block';
const span = document.createElement('span');
span.className = type || '';
span.textContent = text;
body.appendChild(span);
body.appendChild(document.createElement('br'));
body.scrollTop = body.scrollHeight;
}
function clearLog() {
document.getElementById('logBody').innerHTML = '';
document.getElementById('logPanel').style.display = 'none';
}
function notify(msg, type) {
const el = document.createElement('div');
el.className = 'notification ' + type;
el.textContent = msg;
document.body.appendChild(el);
setTimeout(() => el.remove(), 3000);
}
function render() {
const container = document.getElementById('pairs');
if (!pairs.length) {
container.innerHTML = '<div class="empty-state"><div class="icon">📂</div><p>ペアを追加してrsyncを開始しましょう</p></div>';
return;
}
let html = '';
for (const p of pairs) {
html += renderPair(p);
}
container.innerHTML = html;
}
function renderPair(p) {
p = ensurePair(p);
const statusLabel = { idle: '待機中', running: '実行中', done: '完了', error: 'エラー' }[p.status];
const modeBtn = (m, label) => `<button class="mode-btn${p.mode === m ? ' active' : ''}" onclick="setMode(${p.id},'${m}')">${label}</button>`;
const optItem = (o) => `<label class="option-item"><input type="checkbox" ${p.options[o.key] ? 'checked' : ''} onchange="toggleOption(${p.id},'${o.key}')"><label class="opt-name">${o.short ? '-' + o.short : ''}</label><label class="opt-desc">${o.desc}</label></label>`;
return `
<div class="pair-card ${p.status === 'running' ? 'active' : ''}">
<div class="pair-header" onclick="toggleCollapse(${p.id})">
<div class="pair-title">
<span class="pair-num">${p.id}</span>
${p.source ? '<span style="color:var(--text2);font-size:12px">' + shorten(p.source) + '</span>' : '<span style="color:var(--text2);font-size:12px">未設定</span>'}
<span style="color:var(--text2)">→</span>
${p.target ? '<span style="color:var(--text2);font-size:12px">' + shorten(p.target) + '</span>' : '<span style="color:var(--text2);font-size:12px">未設定</span>'}
</div>
<div style="display:flex;align-items:center;gap:8px">
${p.schedule.enabled ? '<span class="schedule-badge">' + getScheduleDesc(p) + '</span>' : ''}
<span class="pair-status ${p.status}">${statusLabel}</span>
</div>
</div>
<div class="pair-body${p.collapsed ? ' collapsed' : ''}">
<div class="path-row">
<div class="path-group">
<label>ソースフォルダ</label>
<div class="path-input">
<input type="text" value="${esc(p.source)}" placeholder="/path/to/source" onchange="updateField(${p.id},'source',this.value)">
<button onclick="openFolderChooser(${p.id},'source')">📁</button>
</div>
</div>
<div class="arrow-center">→</div>
<div class="path-group">
<label>ターゲットフォルダ</label>
<div class="path-input">
<input type="text" value="${esc(p.target)}" placeholder="/path/to/target" onchange="updateField(${p.id},'target',this.value)">
<button onclick="openFolderChooser(${p.id},'target')">📁</button>
</div>
</div>
</div>
<div class="mode-row">
<div class="mode-group">
${modeBtn('copy', '📋 Copy')}
${modeBtn('sync', '🔄 Sync')}
</div>
<div class="ssh-toggle">
<div class="toggle${p.sshEnabled ? ' active' : ''}" onclick="toggleSSH(${p.id})"></div>
<span>SSH リモート接続</span>
</div>
</div>
<div class="ssh-config${p.sshEnabled ? '' : ' hidden'}">
<div class="ssh-field">
<label>ホスト名 / IP</label>
<input type="text" value="${esc(p.ssh.host)}" placeholder="192.168.1.100" onchange="updateField(${p.id},'ssh.host',this.value)">
</div>
<div class="ssh-field">
<label>ポート</label>
<input type="text" value="${esc(p.ssh.port)}" placeholder="22" onchange="updateField(${p.id},'ssh.port',this.value)">
</div>
<div class="ssh-field">
<label>ユーザー名</label>
<input type="text" value="${esc(p.ssh.user)}" placeholder="root" onchange="updateField(${p.id},'ssh.user',this.value)">
</div>
<div class="ssh-field">
<label>パスワード</label>
<input type="password" value="${esc(p.ssh.password)}" placeholder="(パスワード認証の場合)" onchange="updateField(${p.id},'ssh.password',this.value)">
</div>
<div class="ssh-field">
<label>SSH秘密鍵パス</label>
<input type="text" value="${esc(p.ssh.key)}" placeholder="~/.ssh/id_rsa" onchange="updateField(${p.id},'ssh.key',this.value)">
</div>
</div>
<div class="options-grid">
${RSYNC_OPTIONS.map(o => optItem(o)).join('')}
</div>
<div class="exclude-row">
<label>除外パターン(1行1パターン)</label>
<textarea placeholder="*.log .git/ node_modules/" onchange="updateField(${p.id},'exclude',this.value)">${esc(p.exclude)}</textarea>
</div>
<div class="schedule-section">
<div class="schedule-toggle">
<div class="toggle${p.schedule.enabled ? ' active' : ''}" onclick="toggleSchedule(${p.id})"></div>
<span style="font-size:12px;font-weight:600">スケジュール実行</span>
${p.schedule.enabled ? '<span class="schedule-badge">' + getScheduleDesc(p) + '</span>' : '<span class="schedule-badge off">OFF</span>'}
</div>
<div class="${p.schedule.enabled ? '' : 'hidden'}" style="margin-top:12px">
<div class="schedule-row">
<label>パターン</label>
<select class="schedule-select" onchange="setSchedulePattern(${p.id},this.value)">
${SCHEDULES.map((s,i) => '<option value="' + i + '"' + (p.schedule.pattern === i ? ' selected' : '') + '>' + s.name + '</option>').join('')}
</select>
</div>
<div class="schedule-row">
<label>時刻</label>
<input type="time" value="${SCHEDULES[p.schedule.pattern].time}" onchange="setScheduleTime(${p.id},this.value)">
</div>
<div class="schedule-row">
<label>曜日</label>
<div class="days">
${DAY_NAMES.map((d,i) => '<div class="day-btn' + (SCHEDULES[p.schedule.pattern].days.includes(i) ? ' active' : '') + '" onclick="toggleScheduleDay(${p.id},' + i + ')">' + d + '</div>').join('')}
</div>
</div>
<div class="schedule-desc">${getScheduleDesc(p)}</div>
</div>
</div>
<div class="pair-actions">
<div class="pair-actions-left">
<button class="btn btn-accent" onclick="runPairForce(${p.id})" ${p.status === 'running' ? 'disabled' : ''}>▶ 実行</button>
<button class="btn" onclick="runPairDry(${p.id})">🔍 ドライラン</button>
</div>
<button class="btn btn-danger btn-sm" onclick="removePair(${p.id})">🗑 削除</button>
</div>
</div>
</div>`;
}
function runPairDry(id) {
const p = pairs.find(x => x.id === id);
if (p) { p.dryRun = true; runPair(id); }
}
function runPairForce(id) {
const p = pairs.find(x => x.id === id);
if (p) { p.dryRun = false; render(); runPair(id); }
}
function shorten(s) {
if (!s) return '';
const parts = s.split('/').filter(Boolean);
if (parts.length <= 2) return s;
return '…/' + parts.slice(-2).join('/');
}
function esc(s) {
return (s || '').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
}
function exportConfig() {
const data = {
pairs: pairs.map(p => {
const clean = { ...p };
delete clean.status;
delete clean.collapsed;
return clean;
}),
schedules: SCHEDULES,
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'rsyncgui-config-' + new Date().toISOString().slice(0,10) + '.json';
a.click();
URL.revokeObjectURL(url);
notify('エクスポート完了', 'success');
}
function importConfig(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
try {
const data = JSON.parse(e.target.result);
if (data.pairs && Array.isArray(data.pairs)) {
pairs = data.pairs.map(ensurePair);
nextId = Math.max(...pairs.map(p => p.id), 0) + 1;
if (data.schedules) {
data.schedules.forEach((s, i) => {
if (SCHEDULES[i]) {
SCHEDULES[i].time = s.time;
SCHEDULES[i].days = s.days;
}
});
}
render();
notify('インポート完了: ' + pairs.length + 'ペア読み込み', 'success');
} else {
notify('無効な設定ファイルです', 'error');
}
} catch (err) {
notify('パースエラー: ' + err.message, 'error');
}
};
reader.readAsText(file);
event.target.value = '';
}
async function saveSchedule() {
try {
const res = await fetch('/api/schedule/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pairs: pairs }),
});
const data = await res.json();
if (data.ok) {
notify('スケジュールをcrontabに登録しました', 'success');
} else {
notify('登録エラー: ' + (data.error || '不明'), 'error');
}
} catch (e) {
notify('通信エラー: ' + e.message, 'error');
}
}
async function loadConfig() {
try {
const res = await fetch('/api/config');
const cfg = await res.json();
if (cfg.pairs && cfg.pairs.length) {
pairs = cfg.pairs.map(ensurePair);
nextId = Math.max(...pairs.map(p => p.id), 0) + 1;
} else {
addPair();
}
} catch (e) {
addPair();
}
render();
}
loadConfig();
</script>
</body>
</html>
HTMLEOF
echo "[5/6] Creating systemd service..."
cat > /etc/systemd/system/${SERVICE_NAME}.service << SVCEOF
[Unit]
Description=rsyncGUI - Web-based rsync interface
After=network.target
[Service]
Type=simple
WorkingDirectory=${INSTALL_DIR}
ExecStart=/usr/bin/python3 ${INSTALL_DIR}/server.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
SVCEOF
echo "[6/6] Starting service..."
systemctl daemon-reload
systemctl enable ${SERVICE_NAME}
systemctl restart ${SERVICE_NAME}
echo ""
echo "=== Done! ==="
echo "rsyncGUI is running at http://localhost:${PORT}"
echo ""
echo "Commands:"
echo " systemctl status ${SERVICE_NAME}"
echo " systemctl restart ${SERVICE_NAME}"
echo " journalctl -u ${SERVICE_NAME} -f"
インストール基本情報
bash /opt/script/install-rsyncgui1.sh
必要なパッケージ
- Python 3
- rsync
- sshpass(SSHパスワード認証時)
- openssh-client
起動・停止
# サービス開始
systemctl start rsyncgui
# 停止
systemctl stop rsyncgui
# 再起動
systemctl restart rsyncgui
# 状態確認
systemctl status rsyncgui
# ログ確認
journalctl -u rsyncgui -f
アクセス
ブラウザで http://localhost:3326 にアクセス。
画面構成
| 項目 | 説明 |
|---|---|
| + ペア追加 | ソース→ターゲットのペアを追加 |
| 📥 エクスポート | 全設定をJSONファイルとしてダウンロード |
| 📤 インポート | JSONファイルから設定を復元 |
| ⏰ スケジュール登録 | crontabにスケジュールを登録 |
| ▶ 全実行 | 全ペアを一括実行 |
ペア設定
フォルダ指定
- ソースフォルダ: コピー元のローカルパス
- ターゲットフォルダ: コピー先のパス
- 📁ボタンでフォルダ選択ダイアログが開く
- パスは直接入力も可能
モード
| モード | 説明 |
|---|---|
| 📋 Copy | ファイルをコピー(-r) |
| 🔄 Sync | 同期(-avz、デフォルト推奨) |
SSH設定
「SSH リモート接続」トグルをONでSSH設定が表示される。
| 項目 | 説明 |
|---|---|
| ホスト名 / IP | リモートホスト名またはIPアドレス |
| ポート | SSHポート(デフォルト: 22) |
| ユーザー名 | リモートユーザー |
| パスワード | パスワード認証の場合(sshpass使用) |
| SSH秘密鍵パス | 鍵認証の場合の鍵ファイルパス |
rsyncオプション
| オプション | 短縮形 | 説明 |
|---|---|---|
| archive | -a | アーカイブモード(権限・タイムスタンプ保持) |
| verbose | -v | 詳細出力 |
| compress | -z | 転送時圧縮 |
| recursive | -r | サブディレクトリ再帰 |
| times | -t | タイムスタンプ維持 |
| perms | -p | パーミッション維持 |
| owner | -o | オーナー情報維持 |
| group | -g | グループ情報維持 |
| links | -l | シンボリックリンクを複製 |
| hard-links | -H | ハードリンクを複製 |
| delete | ターゲット側の不要ファイル削除 | |
| partial | -P | 中断ファイルを保持 |
| progress | 進捗表示 | |
| human-readable | -h | 人間が読めるサイズ表示 |
| checksum | -c | チェックサムで比較 |
| itemize-changes | -i | 変更詳細表示 |
| stats | 統計情報表示 | |
| ignore-existing | 既存ファイルをスキップ | |
| update | -u | 新しいファイルのみ転送 |
| backup | -b | 上書き前にバックアップ |
除外パターン
除外したいファイル・フォルダを1行1パターンで指定。例:
*.log
.git/
node_modules/
実行ボタン
| ボタン | 説明 |
|---|---|
| ▶ 実行 | 本番実行(dry-runなし) |
| 🔍 ドライラン | 実行計画のみ確認(実際の転送なし) |
スケジュール設定
3パターンのスケジュールから選択可能。各ペアに個別に設定する。
| パターン | デフォルト設定 |
|---|---|
| 設定1 | 毎日 02:00 |
| 設定2 | 月・水・金 12:00 |
| 設定3 | 土・日 23:00 |
- 時刻と曜日はカスタマイズ可能
- 「⏰ スケジュール登録」ボタンでcrontabに反映
- スケジュールOFFにして登録ボタンを押すと、crontabから削除される
エクスポート・インポート
- エクスポート: 全ペア設定をJSONファイルに保存
- インポート: JSONファイルから設定を復元
対象: ソース・ターゲット・SSH設定・rsyncオプション・除外パターン・スケジュール設定
ファイル構成
/opt/rsyncgui/
├── server.py # Python HTTPサーバー
├── public/
│ └── index.html # Web UI
├── config.json # 設定保存ファイル
└── start.sh # 手動起動スクリプト
/etc/systemd/system/
└── rsyncgui.service # systemdサービス定義
トラブルシューティング
SSH接続エラー
- ターミナルで
ssh user@hostが動作することを確認 - パスワード認証の場合、sshpassがインストールされていることを確認
- ポート番号が正しいことを確認
サービスが起動しない
# ログ確認
journalctl -u rsyncgui -n 50
# 手動起動してエラー確認
cd /opt/rsyncgui && python3 server.py
設定が反映されない
- ページを再読み込み(F5)
- 設定はブラウザに保持されない場合があるため、定期的にエクスポートすることを推奨

