Disk Cloner GUI (PyQt6) je open-source Python aplikacija koja omogućava jednostavno, ali moćno upravljanje kloniranjem i backupom diskova kroz moderni grafički interfejs. Kreirana u PyQt6 okruženju, ova alatka je posebno namenjena sistem administratorima i tehničarima koji žele da naprave potpunu kopiju diska, kreiraju kompresovani image sistemske particije, ili vrate backup na novi uređaj — sve iz jedne praktične GUI aplikacije.
🔧 Ključne funkcionalnosti
Program kombinuje snagu Linux komandnih alata (kao što su dd, gzip, lsblk, parted, resize2fs, xfs_growfs, itd.) sa jednostavnošću PyQt6 grafičkog interfejsa.
Glavne mogućnosti uključuju:
1. Automatsko otkrivanje diskova
Disk Cloner koristi komandu lsblk u JSON formatu kako bi automatski detektovao sve dostupne uređaje i prikazao ih u listi. Svaki disk se prikazuje sa detaljima poput veličine, modela, mount tačke i tipa fajl sistema.
2. Podešavanje uloga diskova
Korisnik može jednostavno da izabere koji će uređaj biti:
- Source (matični disk) – izvorni disk sa koga se pravi kopija,
- External (eksterni HDD) – lokacija za čuvanje kompresovane slike diska,
- Target (ciljni disk) – uređaj na koji se vraća (restoruje) image.
3. Kreiranje kompresovanog image fajla
Aplikacija omogućava stvaranje gzip-kompresovanog image fajla izvornog diska pomoću dd alata.
Podržava i korišćenje pv alata (ako je instaliran) za vizuelni prikaz napretka u realnom vremenu.
Image se automatski snima u odabrani folder na eksternom disku, sa imenom po izboru korisnika (npr. backup.img.gz).
4. Vraćanje image fajla na ciljni disk
Opcija “Restore image” omogućava dekompresiju image fajla (gunzip) i direktno upisivanje njegovog sadržaja na ciljni disk uz pomoć dd.
Ova operacija potpuno zamenjuje sadržaj ciljnog diska, pa program s pravom upozorava korisnika da su operacije destruktivne.
5. Proširenje root particije nakon kloniranja
Nakon vraćanja image fajla, aplikacija može automatski ili ručno pokrenuti resize particije kako bi iskoristila ceo dostupan prostor na disku.
Podržani su:
growpart(za automatsko proširenje particije)partedkao alternativaresize2fs(za EXT fajl sisteme)xfs_growfs(za XFS fajl sisteme)
6. Prikaz logova i napretka u GUI-ju
Sve komande i poruke iz komandne linije se prikazuju u realnom vremenu u log prozoru, zajedno sa vizuelnim indikatorom napretka.
To korisniku omogućava potpunu transparentnost i praćenje svake operacije.
🧠 Napredne opcije
Pored osnovnih funkcionalnosti, program nudi i nekoliko dodatnih opcija koje olakšavaju svakodnevni rad:
- Odabir foldera za snimanje image fajla putem dijaloga.
- Odabir postojećeg
.img.gzfajla za restore operaciju. - Unos root lozinke (opcionalno) za automatsko pokretanje komandi sa
sudoprivilegijama. - Automatsko resize-ovanje particije po završetku restore procesa.
⚙️ Tehnički detalji i preduvjeti
Program koristi standardne Linux alate, pa pre njegovog pokretanja treba osigurati da su instalirani sledeći paketi:
sudo, dd, gzip, gunzip, lsblk, parted, growpart, resize2fs, xfs_growfs, pv
Pokreće se jednostavno iz terminala:
python3 disk_cloner_qt6.py
Preporučuje se pokretanje sa administratorskim privilegijama ili unošenje root lozinke u samom GUI-ju.
⚠️ Bezbednosna napomena
Sve operacije koje Disk Cloner obavlja — kloniranje, upisivanje image fajlova i resize particija — su destruktivne i mogu trajno obrisati podatke sa ciljnog diska.
Pre svake kritične akcije, aplikacija prikazuje jasno upozorenje i traži potvrdu korisnika.
💡 Tehnologije i implementacija
- Programski jezik: Python 3
- GUI framework: PyQt6
- Upravljanje procesima:
subprocessiQThreadza asinhrono izvršavanje komandi - Interaktivnost: Signali i slotovi za ažuriranje logova u realnom vremenu
- Struktura: Modularna, sa klasama
CommandExecutoriDiskClonerkoje razdvajaju logiku izvršavanja i interfejsa.
🧰 Prednosti upotrebe
✅ Intuitivan grafički interfejs
✅ Nema potrebe za ručnim unosom komplikovanih dd komandi
✅ Automatsko prepoznavanje diskova i fajl sistema
✅ Podrška za kompresiju i automatsko resize-ovanje
✅ Potpuna kontrola i transparentnost kroz GUI log
Disk Cloner GUI (PyQt6) je izuzetno koristan alat za sve koji rade sa sistemskim diskovima, serverima ili žele da prave brze sigurnosne kopije Linux sistema.
Spoj jednostavnosti i snage komandne linije čini ga odličnim rešenjem i za profesionalce i za entuzijaste koji žele potpunu kontrolu nad procesom kloniranja bez potrebe za terminalom.
Autor: Aleksandar Maričić
Licenca: Open Source (Python / PyQt6)
Platforma: Linux (testirano na Debian/Ubuntu baziranim distribucijama)
Program je u fazi testiranja!!!
programski kod za diskclonerqt6.py
#diskclonerqt6.py
#!/usr/bin/env python3
"""
Disk Cloner GUI (PyQt6)
Funkcionalnosti:
- Otkriva dostupne diskove (lsblk)
- Omogućava izbor: matični (source), eksterni HDD (image storage), ciljni disk (target)
- Napravi kompresovani image sa source -> eksterni HDD (gzip)
- Vrati image sa eksternog HDD -> ciljni disk
- Opcija za proširenje root particije na ciljnog disku (growpart/parted + resize2fs/xfs_growfs)
- Prikazuje log/progress u GUI
NAPOMENA: Operacije su DESTRUKTIVNE. Aplikacija SAMO generiše i pokreće sistemske komande
koje zahtevaju root privilegije. Pokreni program sa odgovarajućim privilegijama ili će
aplikacija pokušati da koristi sudo (biće zatražena lozinka u konzoli).
Preduvjeti na sistemu (instaliraj ako nedostaju):
- sudo, dd, gzip, gunzip
- lsblk, parted, growpart (opciono), resize2fs (za ext) ili xfs_growfs (za XFS)
- pv (opciono, za lepši napredak)
Upotreba: pokreni iz terminala: python3 disk_cloner_qt6.py
Disk Cloner GUI (PyQt6) - sa opcijama za folder, fajl i lozinku
Dodate opcije:
- odabir foldera za snimanje slike
- odabir .img.gz fajla za kloniranje
- unos root lozinke za sudo
"""
#!/usr/bin/env python3
import sys
import os
import shutil
import subprocess
import threading
import time
import json
from functools import partial
from pathlib import Path
from PyQt6.QtWidgets import (
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QListWidget,
QPushButton, QFileDialog, QTextEdit, QMessageBox, QLineEdit, QCheckBox, QProgressBar
)
from PyQt6.QtCore import Qt, pyqtSignal, QThread
class CommandExecutor(QThread):
log_signal = pyqtSignal(str)
finished_signal = pyqtSignal(bool)
def __init__(self, cmd):
super().__init__()
self.cmd = cmd
def run(self):
self.log_signal.emit(f"Pokrećem: {self.cmd}\n")
try:
proc = subprocess.Popen(self.cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
for line in proc.stdout:
self.log_signal.emit(line)
proc.wait()
success = (proc.returncode == 0)
except Exception as e:
self.log_signal.emit(f"Greska: {e}\n")
success = False
self.finished_signal.emit(success)
class DiskCloner(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("Disk Cloner — Qt6")
self.resize(900, 700)
self.layout = QVBoxLayout()
self.setLayout(self.layout)
# Postavljanje stila
QApplication.setStyle("Fusion")
self.disks = []
self.roles = {"source": None, "external": None, "target": None}
self._setup_ui()
self.refresh_disks()
def _setup_ui(self):
# Disk lista
top_layout = QHBoxLayout()
self.layout.addLayout(top_layout)
self.lst_disks = QListWidget()
self.lst_disks.setSelectionMode(QListWidget.SelectionMode.SingleSelection)
top_layout.addWidget(self._group_box_widget("Detected disks (lsblk)", self.lst_disks))
controls_layout = QVBoxLayout()
top_layout.addLayout(controls_layout)
btn_refresh = QPushButton("Refresh disks")
btn_refresh.clicked.connect(self.refresh_disks)
controls_layout.addWidget(btn_refresh)
# Role buttons
self.btn_set_source = QPushButton("Set as Source (matični)")
self.btn_set_ext = QPushButton("Set as External (image storage)")
self.btn_set_target = QPushButton("Set as Target (ciljni)")
controls_layout.addWidget(self.btn_set_source)
controls_layout.addWidget(self.btn_set_ext)
controls_layout.addWidget(self.btn_set_target)
self.btn_set_source.clicked.connect(partial(self.set_role_from_selection, "source"))
self.btn_set_ext.clicked.connect(partial(self.set_role_from_selection, "external"))
self.btn_set_target.clicked.connect(partial(self.set_role_from_selection, "target"))
# Labeli za postavljene diskove
self.lbl_source = QLabel("Source: -")
self.lbl_external = QLabel("External: -")
self.lbl_target = QLabel("Target: -")
controls_layout.addWidget(self.lbl_source)
controls_layout.addWidget(self.lbl_external)
controls_layout.addWidget(self.lbl_target)
# Ulaz za ime fajla
controls_layout.addSpacing(10)
controls_layout.addWidget(QLabel("Image filename (on external):"))
self.image_name = QLineEdit("elementary-os-backup.img.gz")
controls_layout.addWidget(self.image_name)
self.chk_resize_after = QCheckBox("Resize root partition on target after restore")
self.chk_resize_after.setChecked(True)
controls_layout.addWidget(self.chk_resize_after)
# Dugo za kreiranje i restore
controls_layout.addSpacing(10)
self.btn_create_image = QPushButton("Create compressed image on External")
self.btn_restore_image = QPushButton("Restore image from External to Target")
controls_layout.addWidget(self.btn_create_image)
controls_layout.addWidget(self.btn_restore_image)
self.btn_create_image.clicked.connect(self.create_image)
self.btn_restore_image.clicked.connect(self.restore_image)
# Resize dugme
controls_layout.addSpacing(10)
self.btn_resize_now = QPushButton("Resize target root partition now")
controls_layout.addWidget(self.btn_resize_now)
self.btn_resize_now.clicked.connect(self.resize_target_root)
# Dodatne opcije
self._add_extra_options()
controls_layout.addStretch()
# Log i progress
self.log = QTextEdit()
self.log.setReadOnly(True)
self.layout.addWidget(self._group_box_widget("Log/Output", self.log))
self.progress = QProgressBar()
self.progress.setRange(0, 0)
self.progress.hide()
self.layout.addWidget(self.progress)
def _group_box_widget(self, title, widget):
container = QWidget()
v = QVBoxLayout()
container.setLayout(v)
v.addWidget(QLabel(title))
v.addWidget(widget)
return container
def _add_extra_options(self):
# Folder za snimanje
folder_layout = QHBoxLayout()
self.btn_browse_folder = QPushButton("Odaberi folder za snimanje")
self.lbl_folder_path = QLabel("-")
folder_layout.addWidget(self.btn_browse_folder)
folder_layout.addWidget(self.lbl_folder_path)
self.btn_browse_folder.clicked.connect(self.browse_folder)
self.layout.addLayout(folder_layout)
# Fajl za kloniranje
file_layout = QHBoxLayout()
self.btn_browse_img = QPushButton("Odaberi .img.gz fajl za kloniranje")
self.lbl_img_file = QLabel("-")
file_layout.addWidget(self.btn_browse_img)
file_layout.addWidget(self.lbl_img_file)
self.btn_browse_img.clicked.connect(self.browse_img_file)
self.layout.addLayout(file_layout)
# Lozinka za sudo
pw_layout = QHBoxLayout()
self.lbl_password = QLabel("Root password:")
self.txt_password = QLineEdit()
self.txt_password.setEchoMode(QLineEdit.EchoMode.Password)
pw_layout.addWidget(self.lbl_password)
pw_layout.addWidget(self.txt_password)
self.layout.addLayout(pw_layout)
def browse_folder(self):
folder = QFileDialog.getExistingDirectory(self, "Odaberi folder za snimanje")
if folder:
self.lbl_folder_path.setText(folder)
def browse_img_file(self):
filename, _ = QFileDialog.getOpenFileName(self, "Odaberi .img.gz fajl", filter="Gzip Files (*.img.gz)")
if filename:
self.lbl_img_file.setText(filename)
def append_log(self, text: str):
# Dodavanje teksta u log sa automatskim scroll-ovanjem
self.log.append(text)
self.log.verticalScrollBar().setValue(self.log.verticalScrollBar().maximum())
def run_command(self, cmd):
# Pokretanje komande u posebnoj niti sa signalima
self.thread = CommandExecutor(cmd)
self.thread.log_signal.connect(self.append_log)
self.thread.finished_signal.connect(self.on_operation_finished)
self.progress.show()
self.thread.start()
def on_operation_finished(self, success: bool):
self.progress.hide()
if success:
self.append_log("== Operacija završena uspešno ==\n")
else:
self.append_log("== Operacija završena sa greškama ==\n")
def refresh_disks(self):
self.lst_disks.clear()
self.disks = []
try:
out = subprocess.check_output(['lsblk', '-o', 'NAME,SIZE,MODEL,MOUNTPOINT,TYPE,FSTYPE', '-J'], text=True)
js = json.loads(out)
for dev in js.get('blockdevices', []):
if dev.get('type') != 'disk':
continue
name = dev.get('name')
size = dev.get('size')
model = dev.get('model') or ''
mount = dev.get('mountpoint') or ''
fstype = dev.get('fstype') or ''
display = f"/dev/{name} — {size} — {model} — mount: {mount} — fstype: {fstype}"
self.disks.append({'name': name, 'size': size, 'model': model, 'mount': mount, 'fstype': fstype})
self.lst_disks.addItem(display)
self.append_log("Disk lista osvežena.")
except Exception as e:
self.append_log(f"Ne mogu da pozovem lsblk: {e}")
def set_role_from_selection(self, role):
item = self.lst_disks.currentItem()
if not item:
QMessageBox.warning(self, "Nije izabrano", "Izaberi disk iz liste prvo.")
return
idx = self.lst_disks.currentRow()
disk = self.disks[idx]
devpath = f"/dev/{disk['name']}"
self.roles[role] = devpath
self.update_role_labels()
self.append_log(f"Postavljen {role} = {devpath}")
def update_role_labels(self):
self.lbl_source.setText(f"Source: {self.roles['source'] or '-'}")
self.lbl_external.setText(f"External: {self.roles['external'] or '-'}")
self.lbl_target.setText(f"Target: {self.roles['target'] or '-'}")
def confirm_danger(self, text):
msg = QMessageBox(self)
msg.setWindowTitle("Upozorenje — opasna operacija")
msg.setText(text)
msg.setInformativeText("Ove operacije mogu trajno obrisati podatke. Nastaviti?")
msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
msg.setDefaultButton(QMessageBox.StandardButton.No)
ret = msg.exec()
return ret == QMessageBox.StandardButton.Yes
def ensure_roles_set(self, need_source=True, need_external=True, need_target=False):
if need_source and not self.roles['source']:
QMessageBox.warning(self, "Source nije postavljen", "Postavi source disk (matični) iz liste.")
return False
if need_external and not self.roles['external']:
QMessageBox.warning(self, "External nije postavljen", "Postavi eksterni disk (image storage) iz liste.")
return False
if need_target and not self.roles['target']:
QMessageBox.warning(self, "Target nije postavljen", "Postavi ciljni disk iz liste.")
return False
return True
def create_image(self):
if not self.ensure_roles_set(need_source=True, need_external=True):
return
src = self.roles['source']
ext = self.roles['external']
folder = self.lbl_folder_path.text().strip()
if not folder or not os.path.isdir(folder):
QMessageBox.warning(self, "Folder", "Odaberi validan folder za snimanje.")
return
imgname = self.image_name.text().strip()
if not imgname:
QMessageBox.warning(self, "Ime fajla", "Unesi ime image fajla.")
return
dest = os.path.join(folder, imgname)
# Provera
if os.path.exists(dest):
if not QMessageBox.question(self, "Datoteka postoji", f"{dest} već postoji. Overwrite?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) == QMessageBox.StandardButton.Yes:
return
if not self.confirm_danger(f"Napraviti image diska {src} na {dest} ?"):
return
# Koristi root password ako je unet
password = self.txt_password.text()
sudo_prefix = ""
if password:
sudo_prefix = f"echo '{password}' | sudo -S "
elif os.geteuid() != 0:
sudo_prefix = "sudo "
# Komanda za dd + gzip (sa pv ako je dostupan)
pv = shutil.which('pv')
gzip_bin = shutil.which('gzip') or 'gzip'
if pv:
cmd = f"{sudo_prefix}bash -lc 'dd if={src} bs=64K conv=noerror,sync | {pv} | {gzip_bin} -c > \"{dest}\"'"
else:
cmd = f"{sudo_prefix}bash -lc 'dd if={src} bs=64K conv=noerror,sync | {gzip_bin} -c > \"{dest}\"'"
self.append_log(f"Kreira se image: {dest}\nOcekivano vreme: zavisi od velicine i brzine diskova.")
self.run_command(cmd)
def restore_image(self):
if not self.ensure_roles_set(need_source=False, need_external=True, need_target=True):
return
ext = self.roles['external']
target = self.roles['target']
srcfile = self.lbl_img_file.text().strip()
if not srcfile or not os.path.exists(srcfile):
QMessageBox.warning(self, "Fajl ne postoji", f"Fajl {srcfile} ne postoji na eksternom disku.")
return
if not self.confirm_danger(f"Restore {srcfile} -> {target} ? Svi podaci na {target} bice izbrisani."):
return
password = self.txt_password.text()
sudo_prefix = ""
if password:
sudo_prefix = f"echo '{password}' | sudo -S "
elif os.geteuid() != 0:
sudo_prefix = "sudo "
# Komanda za gunzip + dd
gzip_path = shutil.which('gzip') or 'gzip'
cmd = f"{sudo_prefix}bash -lc '{gzip_path} -d -c \"{srcfile}\" | dd of={target} bs=64K conv=noerror,sync'"
self.append_log(f"Restorujem image {srcfile} na {target}")
self.run_command(cmd)
# Ako je opcija za resize nakon restoriranja
if self.chk_resize_after.isChecked():
def delayed_resize():
time.sleep(3)
self.append_log("Automatsko pokretanje resize operacije nakon restor-a (moze potrajati)...")
self.resize_target_root()
t = threading.Timer(2.0, delayed_resize)
t.start()
def resize_target_root(self):
if not self.roles['target']:
QMessageBox.warning(self, "Target nije postavljen", "Postavi ciljni disk iz liste.")
return
target = self.roles['target']
try:
out = subprocess.check_output(['lsblk', '-o', 'NAME,MOUNTPOINT,FSTYPE', '-J', target], text=True)
js = json.loads(out)
blockdevices = js.get('blockdevices', [])
if not blockdevices:
self.append_log('Nema particija na target ili target nije prepoznat.')
return
parts = blockdevices[0].get('children', [])
if not parts:
self.append_log('Nema particija na target ili target nije prepoznat.')
return
# Traži particiju mounted kao /
root_part = None
for p in parts:
if p.get('mountpoint') == '/':
root_part = p
break
if not root_part:
# ili najveća
parts_sorted = sorted(parts, key=lambda x: self._size_str_to_bytes(x.get('size', '0')), reverse=True)
root_part = parts_sorted[0]
part_name = root_part['name']
part_path = f"/dev/{part_name}"
fstype = root_part.get('fstype') or ''
except Exception as e:
self.append_log(f"Greska pri detekciji particija target: {e}")
return
if not self.confirm_danger(f"Prosiriti particiju {part_path} da iskoristi ceo slobodan prostor na disku {target}? \nOperacija moze potrajati."):
return
password = self.txt_password.text()
sudo_prefix = ""
if password:
sudo_prefix = f"echo '{password}' | sudo -S "
elif os.geteuid() != 0:
sudo_prefix = "sudo "
# Pokušaj growpart
growpart_path = shutil.which('growpart')
if growpart_path:
import re
m = re.match(r".*/([a-zA-Z]+)(\d+)$", part_path)
if m:
diskbase = m.group(1)
partnum = m.group(2)
diskdev = part_path.replace(partnum, '')
cmd_grow = f"{sudo_prefix}bash -lc '{growpart_path} {diskdev} {partnum} && sleep 1'"
# resize fs
if fstype.startswith('ext'):
cmd_resizefs = f"{sudo_prefix}bash -lc 'resize2fs {part_path}'"
elif fstype == 'xfs':
mountpoint = f"/mnt/_tmp_resize_{part_name}"
os.makedirs(mountpoint, exist_ok=True)
cmd_resizefs = f"{sudo_prefix}bash -lc 'mount {part_path} {mountpoint} || true; xfs_growfs {mountpoint}; umount {mountpoint} || true'"
else:
cmd_resizefs = None
if cmd_resizefs:
cmd = cmd_grow + " && " + cmd_resizefs
else:
cmd = cmd_grow
self.append_log(f"Koristim growpart -> {cmd}")
self.run_command(cmd)
return
# Alternativa: parted resize
import re
m = re.match(r".*/([a-zA-Z]+)(\d+)$", part_path)
if m:
diskdev = part_path.replace(m.group(2), '')
partnum = m.group(2)
cmd_parts = []
cmd_parts.append(f"{sudo_prefix}bash -lc 'parted -s {diskdev} resizepart {partnum} 100%' || true'")
if fstype.startswith('ext'):
cmd_parts.append(f"{sudo_prefix}bash -lc 'resize2fs {part_path}' || true'")
elif fstype == 'xfs':
mountpoint = f"/mnt/_tmp_resize_{part_name}"
os.makedirs(mountpoint, exist_ok=True)
cmd_parts.append(f"{sudo_prefix}bash -lc 'mount {part_path} {mountpoint} || true; xfs_growfs {mountpoint} || true; umount {mountpoint} || true'")
cmd = ' && '.join(cmd_parts)
self.append_log(f"Pokrecem resize secvencre: {cmd}")
self.run_command(cmd)
else:
self.append_log('Ne mogu da odredim partition number za resize.')
def _size_str_to_bytes(self, s: str) -> int:
if not s:
return 0
s = s.strip().upper()
units = {'K': 1024, 'M': 1024**2, 'G': 1024**3, 'T': 1024**4}
try:
if s[-1] in units:
return int(float(s[:-1]) * units[s[-1]])
return int(s)
except Exception:
return 0
if __name__ == '__main__':
app = QApplication(sys.argv)
win = DiskCloner()
win.show()
sys.exit(app.exec())
#MIT_License.txt MIT License Copyright (c) [2025] [Aleksandar Maričić] Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
