Восстановление данных с M.2 NVMe SSD. Скрипт ddrescue-loop v0.2

в 19:23, , рубрики: ddrescue, ddrescue-loop, linux, NVMe, ssd, usb

Речь пойдет о способе извлечения данных с неисправного SSD для случаев когда после попытки чтения любого сбойного сектора - SSD совсем перестает отдавать данные и помогает только отключение включение питания.

Представляю доработанную версию скрипта ddrescue-loop с поддержкой управления USB реле и uhubctl.

Для прерывания питания SSD задействовал простое и дешевое решение USB Relay Module LCUS-1 CH340 которые доступны на Aliexpress. И подключение через док станцию AgeStar 31CBNV1C на основе USB-NVMe моста JMicron JMS583.

Рассмотрим процесс восстановления на примере случая с неисправными M.2 NVMe SSD производства Kimtigo на контроллере Maxio MAP1202.

ddrescue-loop v0.2.1

ddrescue-loop v0.2.1

ddrescue-loop-v0.2.1.gz

#!/bin/sh
#ddrescue-loop script writen by gumanzoy <gumanzoy@gmail.com>

# Compatible only with Linux, not with other *nix!
# Depends on udev /dev and sysfs /sys kernel interfaces

# For SATA requires AHCI compatible motherboard
# For all Intel and modern AMD platforms (AM4 and newer), check the UEFI Setup
# SATA settings to ensure Port Hot Plug is enabled

# For USB requires lsusb from usbutils package
# And optional uhubctl for power off/on cycle
# Or hardware USB Relay Module LCUS-1 CH340

# [RU] forum thread. Обсуждение
# https://forum.ixbt.com/topic.cgi?id=11:47589-31

# /* This program is free software. It comes without any warranty, to
# * the extent permitted by applicable law. You can redistribute it
# * and/or modify it under the terms of the Do What The Fuck You Want
# * To Public License, Version 2, as published by Sam Hocevar. See
# * http://www.wtfpl.net/ for more details. */

VERSION=0.2.1

showhelp () {
echo "ddrescue-loop v""$VERSION"" перезапускает процесс ddrescue в случае его завершения"
echo "Внимание следует соблюдать очередность аргументов"
echo "Указывать ключи в произвольном порядке нельзя!"
echo "Числовые значения аргументов обязательно через пробел"
echo -n "n"
echo "# ----- SATA ----- SATA ----- SATA ----- SATA ----- SATA -----"
echo "# Остановить/запустить диск на SATA порту:"
echo "-ata <n> -stop""		""остановить диск на SATA порту <n>"
echo "-ata <n> -scan""		""сканировать SATA порт <n>"
echo -n "n"
echo "# Запустить восстановление c SATA:"
echo "ddrescue-loop -ata <n> [-loop <n>] [-pwc] [-wait <n>] [-act <n>] outfile mapfile [ddrescue options]"
echo -n "n"
echo "# Укажите номер SATA порта к которому подключен диск источник:"
echo -n "-ata <n>""		""Номер SATA порта <n> цифра (смотрите вывод dmesg)"
echo -n "n""			""#: "; ls /sys/class/ata_port
echo -n "n"
echo "# Функция циклической остановки/перезапуска диска на SATA порту:"
echo "-loop <n>""		""<n> предельное число попыток"
echo -n "n"
echo "# Таймер ожидания остановки/перезапуска диска:"
echo "-wait <n>""		""Время в секундах <n> [10]"
echo -n "n"
echo "# Переопределить таймаут ожидания исполнения ATA команд:"
echo "-act <n>""		""Время в секундах <n> [30]"
echo -n "n"
echo "# ------ USB ------ USB ------ USB ------ USB ------ USB -----"
echo "# Отключить/включить питание USB устройства <ID>, методом <hub/rle>:"
echo "-usb <ID> -pwc hub""	""Использовать uhubctl --search <ID>"
echo "-usb <ID> -pwc rle""	""Использовать USB реле LCUS-1 CH340 RLETTY=""$RLETTY"
echo -n "n"
echo "# Запустить восстановление c USB:"
echo "ddrescue-loop -usb <ID> [-loop <n>] [-pwc <hub/rle>] [-wait <n>] outfile mapfile [ddrescue options]"
echo -n "n"
echo "# Укажите Hex идентификаторы VID:PID USB устройства источника:"
echo "-usb <ID>""		""<VID:PID> через двоеточие (смотрите вывод lsusb)"
echo -n "n"
echo "# Функция циклического перезапуска ddrescue:"
echo "-loop <n>""		""<n> предельное число попыток"
echo -n "n"
echo "# Основные:"
echo "outfile""			""Устройство приемник данных / файл образа"
echo "mapfile""			""ddrescue map/log файл (обязательно)"
echo -n "n"
echo "# В конце после mapfile можно указать опции запуска ddrescue через пробел"
echo "# Поддержка зависит от версии. Полный список опций в мануале. Важные:"
echo "-P [<n>]""		""Предпросмотр данных [число строк] по умолчанию 3"
echo "-b 4096""			""<bytes> размер сектора (физического блока) [default 512]"
echo "-c <n>""			""Размер кластера <n> секторов за раз [default 128]"
echo "-O"" #Рекомендую!		""После каждой ошибки заново открывать файл устройства"
echo "-J"" #Опционален		""При ошибке перечитать последний не сбойный сектор"
echo "-r <n> #ИЛИ -r -1""	""<n> число повторных проходов до перехода к trim"
echo "-m <domain.mapfile>""	""Ограничить область чтения доменом <file> ddru_ntfsbitmap"
}

get_ata_host () {
until SCSIHOST=`readlink -f /sys/class/ata_port/ata"$1"/device/host?/scsi_host/host?/` 
&& test -d "$SCSIHOST"; do sleep 1; done
}

get_ata_target () {
until SYSFSTGT=`readlink -f /sys/class/ata_port/ata"$1"/device/host?/target?:?:?/?:?:?:?/` 
&& test -d "$SYSFSTGT"; do sleep 1; done
}

get_ata_dev () {
until INDEV=`readlink -f /dev/disk/by-path/pci-*-ata-"$1"` 
&& test -b "$INDEV"; do sleep 1; done
}

device_delete () {
while test -f "$SYSFSTGT"/delete; do echo 1 > "$SYSFSTGT"/delete; sleep 1; done
}

get_usb_dev_by_path () {
INDEV="/dev/"`basename "$1"`
SYSFSTGT="$1""/device/"
}

get_usb_dev_by_id () {
IDVID=`echo -n "$1" | cut -d ":" -f1`
IDPID=`echo -n "$1" | cut -d ":" -f2`

until get_usb_dev_by_path `udevadm trigger -v -n -s block 
-p ID_VENDOR_ID="$IDVID" -p ID_MODEL_ID="$IDPID"` 
&& test -b "$INDEV"; do sleep 1; done
}

power_cycle () {
if [ -n "$USBID" ] && [ "$PWRCTL" = hub ]; then
uhubctl --search "$USBID" --action cycle --delay "$LOOPWAIT"
elif [ "$PWRCTL" = rle ]; then /bin/echo -en "xA0x01x01xA2" > "$RLETTY" && 
sleep "$LOOPWAIT" && /bin/echo -en "xA0x01x00xA1" > "$RLETTY"
fi
}

if [ "$1" = "-h" -o "$1" = "--help" ]; then showhelp
exit; fi

if [ "`whoami`" != "root" ]; then
echo Exit. This script should be run as root !
exit 1; fi

if [ -z "$RLETTY" ] && test -c /dev/ttyUSB0; then RLETTY="/dev/ttyUSB0"
elif [ -n "$RLETTY" ] && ! test -c "$RLETTY"; then
echo "RLETTY=""$RLETTY"" control device not found"; exit 1; fi

if [ -n "$1" ] && [ "$1" = "-ata" ]; then
if [ -n "$2" ] && test -d /sys/class/ata_port/ata"$2"; then
SATAP="$2"; get_ata_host "$SATAP"; shift; shift
else echo -n "Please enter correct port number: "; ls /sys/class/ata_port; exit 1; fi
fi

if [ -n "$1" ] && [ "$1" = "-stop" ] && [ -n "$SATAP" ]; then
get_ata_target "$SATAP"; device_delete; exit; fi

if [ -n "$1" ] && [ "$1" = "-scan" ] && [ -n "$SATAP" ]; then
echo '0 0 0' > "$SCSIHOST"/scan; exit; fi

if [ -n "$1" ] && [ "$1" = "-usb" ] && [ -z "$SATAP" ]; then
if [ -n "$2" ] && lsusb -d "$2"; then
USBID="$2"; get_usb_dev_by_id "$USBID"; shift; shift
else echo "Please enter correct USB Device ID:"
lsusb | cut -d ":" -f2,3 | grep -vi hub
exit 1; fi
fi

if [ -n "$1" ] && [ "$1" = "-loop" ]; then
if [ -n "$2" ] && [ "$2" -gt 0 ]; then
DDLOOP="$2"; shift; shift; fi
else DDLOOP=0
fi

if [ -n "$1" ] && [ "$1" = "-pwc" ]; then
if [ -n "$USBID" ] && [ -n "$2" ] && [ "$2" = "hub" -o "$2" = "rle" ]; then
PWRCTL="$2"; echo "PWRCTL=""$2"; shift; shift
elif [ -n "$RLETTY" ]; then
PWRCTL="rle"; echo "PWRCTL=rle"; shift; fi
fi

if [ -n "$1" ] && [ "$1" = "-wait" ]; then
if [ -n "$2" ] && [ "$2" -gt 0 ]; then
LOOPWAIT="$2"; shift; shift; fi
else LOOPWAIT=10
fi

if [ -n "$1" ] && [ "$1" = "-act" ]; then
if [ -n "$2" ] && [ "$2" -gt 0 ]; then
ATACMDT="$2"; shift; shift; fi
fi

if [ -n "$RLETTY" ] && [ "$PWRCTL" = rle ]; then
stty -F "$RLETTY" 9600 -echo && echo "RLETTY=""$RLETTY"; fi

if [ "$DDLOOP" = 0 ]; then
if [ -n "$USBID" ] && [ "$PWRCTL" = hub ]; then power_cycle; exit
elif [ -n "$RLETTY" ] && [ "$PWRCTL" = rle ]; then power_cycle; exit; fi
fi

if [ -z "$SATAP" ] && [ -z "$USBID" ]; then showhelp
exit; fi

OUTFILE="$1"; shift
MAPFILE="$1"; shift
DDOPTS="$@"

DONE=X
LOOPCOUNT=0

until [ "$DONE" = 0 ]; do

if [ -n "$SATAP" ]; then get_ata_target "$SATAP"; get_ata_dev "$SATAP"
elif [ "$LOOPCOUNT" -gt 0 ] && [ -n "$USBID" ]; then get_usb_dev_by_id "$USBID"
fi

if [ -n "$ATACMDT" ]; then echo "$ATACMDT" > "$SYSFSTGT"/timeout
fi

echo ddrescue "-fd" "$INDEV" "$OUTFILE" "$MAPFILE" "$DDOPTS"
ddrescue "-fd" "$INDEV" "$OUTFILE" "$MAPFILE" $DDOPTS
DONE="$?"

if [ "$DONE" != 0 ] && [ "$DDLOOP" -gt 0 ]; then

  device_delete &
  sleep "$LOOPWAIT"

  if [ -n "$PWRCTL" ]; then power_cycle
  elif [ -n "$SATAP" ]; then while test -d "$SYSFSTGT"; do
  sleep "$LOOPWAIT"; done; fi

  if [ -n "$SATAP" ]; then sleep "$LOOPWAIT"
  echo '0 0 0' > "$SCSIHOST"/scan; fi

  DDLOOP=$(($DDLOOP-1))
  LOOPCOUNT=$(($LOOPCOUNT+1))

  echo "n33[1mDDLOOP #""$LOOPCOUNT"; tput sgr0
  date; echo -n "n"

  sleep "$LOOPWAIT"

else DONE=0
fi
done

This program is free software. It comes without any warranty, to the extent permitted by applicable law. You can redistribute it and/or modify it under the terms of the Do What The Fuck You Want To Public License, Version 2

This program is free software. It comes without any warranty, to the extent permitted by applicable law. You can redistribute it and/or modify it under the terms of the Do What The Fuck You Want To Public License, Version 2

Как пользоваться. Параметры запуска

Использование с SATA устройствами разобрано в моей первой статье. Если не читали, то рекомендую сначала ознакомится с ней.

Так как я не слишком искушен в sh скриптинге, и вообще не программист - с разбором параметров особо не мудрствовал. Поэтому есть некоторые важные ограничения!

Следует соблюдать очередность аргументов. Указывать ключи в произвольном порядке нельзя!
Числовые значения аргументов обязательно через пробел.

ddrescue-loop -usb <ID> [-loop N] [-pwc <hub/rle>] [-wait N] outfile mapfile [ddrescue options]

Укажите Hex идентификаторы VID:PID USB устройства источника:
-usb <ID> VID:PID через двоеточие (смотрите вывод lsusb)

Функция циклической остановки/перезапуска ddrescue:
-loop N Предельное число попыток N целое число. Указывать обязательно.

Функция прерывания питания устройства:
-pwc hub Использовать uhubctl --search <ID>
-pwc rle Использовать USB реле LCUS-1 CH340

Таймер ожидания остановки/перезапуска диска:
-wait N Время в секундах. 10 по умолчанию.

В конце после mapfile можно указать опции запуска ddrescue. Их обрабатывает уже сама ddrescue, можно указывать все как обычно.

Демонстрация работы. Записи вывода терминала

https://asciinema.org/a/628786Запись вывода терминала ddrescue-loop

https://asciinema.org/a/628786
Запись вывода терминала ddrescue-loop
https://asciinema.org/a/628787Запись вывода терминала dmesg -Wt

https://asciinema.org/a/628787
Запись вывода терминала dmesg -Wt

При ошибке чтения сектора и после сообщений в dmesg
uas_eh_device_reset_handler start
uas_eh_device_reset_handler success
Device offlined - not ready after error recovery
I/O error, dev sdd, sector 8985600

Устройство перестает отдавать данные, процесс ddrescue завершается
Can't reopen input file: No such device or address
Скрипт подает команду на включение реле, затем перезапускает ddrescue и чтение продолжается.

Как это работает

Так как имена устройств sda sdb sdc не постоянны и буква меняется в зависимости от очередности подключения к системе. Применил вот такое решение: скрипт принимает на вход ID VID:PID USB устройства источника. Их отображает lsusb. В скрипте код получает адрес блочного устройства /dev/sdX И делает это каждый раз после отключения питания диска перед перезапуском процесса ddrescue

USB Relay Module LCUS-1 CH340

USB Relay Module LCUS-1 CH340

USB Relay Module LCUS-1 CH340
Вариант с USB Type-A
aliexpress.com/item/4001216792789.html
aliexpress.com/item/1005001993993906.html
Вариант с USB Type-C
aliexpress.com/item/1005004323626598.html
aliexpress.com/item/1005004347242232.html

Использование USB реле. Подразумевается подключение питания на USB/SATA диск через контакты реле COM и NC Чтобы в выключенном состоянии питание проходило. А при подаче команды на включение реле - питание отключалось. Скрипт управляет реле с помощью команд:

echo -en "xA0x01x01xA2" > "$RLETTY"
sleep "$LOOPWAIT"
echo -en "xA0x01x00xA1" > "$RLETTY"

По умолчанию RLETTY=/dev/ttyUSB0 можно переопределить передав переменную окружения перед запуском скрипта:
RLETTY=/dev/ttyUSB1 ddrescue-loop -pwc rle

Также добавил поддержку uhubctl. Теоретически можно использовать вместо реле, но у меня нет подходящего USB хаба. Возможно будет полезно для восстановления с флешек.
ddrescue-loop -usb <ID> -pwc hub

uhubctl --search "$USBID" --action cycle --delay "$LOOPWAIT"

Подключение реле к док станции

В док станции AgeStar 31CBNV1C есть переключатель для отключения питания. Я подключил реле вместо него.

Док станция AgeStar 31CBNV1C

Док станция AgeStar 31CBNV1C

У переключателя пять контактов. Крайние для крепления к плате. Припаиваться нужно ко второму и третьему слева. На фото отметил красным.

Выпаял переключатель совсем, впаял два провода, залудил обратные концы и убрал в термоусадку. Собрал док станцию обратно в корпус. Вот что получилось.

Док станция + реле

Док станция + реле

Непосредственно процесс восстановления

Данный раздел пишу в том числе в расчете на тех кто далек от Linux. Не уверен что у меня получится понятно объяснить, но попробую. По большей части все примечания такого рода убрал под спойлеры.

Использую ПК с GNU/Linux Debian 11 (еще не обновился до Debian 12 по причине лени).
Док станцию подключаю к USB3 порту. А к SATA подключено несколько 3.5" жестких дисков.

Сохраняю образ в файл, на диск с файловой системой Ext4. Файл создается разреженный (sparse file) таким образом место расходуется только под фактический объем скопированного, а не под весь объем неисправного SSD. При этом файл монтирую в /dev/loopN это позволяет работать с ним так же как с физическим диском.

Для создания/подключения/отключения образов удобно использовать графический интерфейс gnome-disk-utility

gnome-disk-utility используется не только в среде Gnome. Зависимостей относительно не много.

Создавать образ нужно такого же или большего объема. Для того чтобы не ошибиться можно скопировать и указать размер диска в байтах (отображается в gnome-disks в правой панели вверху при выборе соответствующего диска).

В меню «гамбургер» пункт New Disk Image..., в открывшемся окне выбрать размер в байтах, вставить скопированное число в поле и удалить запятые. Указать имя и путь для сохранения. Нажать Attach new image...

Созданный образ подключится в свободный /dev/loopN в режиме чтение/запись. При выборе и подключении существующего файла образа он по умолчанию подключается в режиме только чтение. Не забывайте снимать галочку Set up read-only device

Если таблица разделов на SSD читается. То прежде чем запускать копирование можно построить файл домена с помощью утилиты ddru_ntfsbitmap (из состава ddrutility). Это позволяет для разделов с файловой системой NTFS ограничить объем копирования только занятым пространством.

К сожалению в случае с SSD это не позволяет ускорить процесс, а только экономит место под образ. Так как нули из не занятых блоков копируются на полной скорости и без ошибок. Сбойные же сектора располагаются там где были какие то файлы и именно на обработку диском ошибок тратится основная часть времени.

Создание файла домена с помощью ddru_ntfsbitmap

В приведенных ниже командах в /dev/sdX вместо X подставить соответствующую устройству букву (можно посмотреть в том же gnome-disk-utility).

Нужно создавать файл домена в привязке ко всему диску, а не к отдельному разделу. Поэтому нужно указывать в командах именно устройство /dev/sdX а не раздел /dev/sdXN

Для ddru_ntfsbitmap нужно вычислить и указать значение input offset (partition offset) ключ --inputoffset или коротко -i

Сначала нужно запустить sudo fdisk -l /dev/sdX
Найти в таблице нужный раздел NTFS, и скопировать значение из столбца Start
Затем запустить команду, куда вместо START подставить значение

sudo ddru_ntfsbitmap -i $((START*512)) -m mftdomain.map /dev/sdX domain.map

Будут созданы искомые файлы domain.map и mftdomain.map

А также еще несколько файлов, имена которых начинаются на __ (двойное нижнее подчеркивание). Они не нужны, их можно удалить.

Запуск процесса копирования

Скрипт ddrescue-loop для удобства запуска можно скопировать в /usr/local/bin/

Скопировать и выдать права на исполнение

sudo zcat ddrescue-loop-v0.2.1.gz > /usr/local/bin/ddrescue-loop
sudo chmod +x /usr/local/bin/ddrescue-loop

Сначала в отдельном терминале запустить dmesg -Wt чтобы видеть что происходит с диском.

Запускать ddrescue-loop нужно с правами root. Также и dmesg, но только в дистрибутивах где по умолчанию включен kernel.dmesg_restrict=1 (Debian входит в их число). Для краткости команду sudo добавлять не буду, но она подразумевается.

ddrescue-loop -usb 152d:0583 -loop 9999 -pwc rle /dev/loopN mapfile.log -b 4096 -c 32 -O -J

Разберем приведенные параметры:

  • -usb 152d:0583 это ID VID:PID док станции (а точнее контроллера JMicron JMS583 USB-NVMe)
    Посмотреть список подключенных устройств можно запустив ddrescue-loop -usb

  • -loop 9999 -pwc rle предельное число попыток перезапуска в цикле и использовать USB реле.

  • /dev/loopN Где N заменить на соответствующую цифру. Предполагается что файл приемник создан и смонтирован (посмотреть можно в gnome-disk-utility)
    Вместо этого можете просто указать куда сохранить файл образ, тогда ddrescue создаст его сама.

  • mapfile.log имя ddrescue map/log файла указываем обязательно.

  • -b 4096 обязательно указываем реальный размер сектора (физического блока)
    По умолчанию 512 и это не соответствует современным накопителям как SSD так и HDD

  • -c 32 ограничиваем размер кластера (сколько секторов ddrescue будет пытаться читать за раз в обычном режиме до перехода к trimming). По умолчанию 128, а так как мы увеличили размер сектора то это уже чересчур.

  • -O обязательно указываем. Чтобы ddrescue после каждой ошибки пыталась заново открыть файл устройства. Это необходимо для того чтобы в случае невозможности дальнейшего чтения - процесс ddrescue завершался с ошибкой и скрипт задействовал метод остановки/перезапуска диска.

  • -J тоже указываем, это дополнительная проверка - при ошибке перечитывать последний не сбойный сектор.

Если создавали файл домена то первый проход запускаем с добавлением в конец строки запуска -m mftdomain.map
Затем когда вычитали весь MFT то читаем все остальные задействованные файловой системой сектора -m domain.map

Тонкая подстройка в процессе

Если останавливали ddrescue по Ctrl+C для изменения параметров то перед перезапуском можно задействовать реле или uhubctl (если используем хаб вместо реле)
ddrescue-loop -pwc rle или ddrescue-loop -usb <ID> -pwc hub

На начальном этапе можно (но не обязательно) стараться вычитывать в первую очередь крупные беспроблемные участки. Для этого можно перепрыгивать скопления бэдов, добавляя в конце опцию -i Например -i 30G если чтение в прямом направлении. И можно читать задом наперед, для этого указывать -R и -s Например -R -s 40G

ddrescue-loop -usb 152d:0583 -loop 9999 -pwc rle -wait 4 -act 23 /dev/loopN mapfile.log -b 4096 -c 32 -O -J -m domain.map

Здесь добавлены -wait 4 то есть уменьшен таймер ожидания с 10 до 4
-act 23 таймаут ожидания исполнения ATA команд уменьшен с 30 до 23

Эти параметры подбирал экспериментально для описываемого случая для того чтобы постараться уменьшить время ожидания пере-подключения при ошибках SSD.

Значение -act подбирал с оглядкой на кол-во сообщений uas_eh_device_reset_handler start uas_eh_device_reset_handler success в dmesg после каждого сбойного сектора. При 30 ядро успевало делать две попытки reset'a. При 22 уже три reset'a - а это выходит дольше. Оптимальным оказалось значение 23.

Когда основные стадии процесса вычитки завершены, то в режиме scraping для ускорения можно увеличить размер блока, при этом качество пострадает.
Например указать -b 16Ki -c 1 или -b 32Ki -c 1 вместо -b 4096 -c 32

Когда процесс близится к завершению

В данном случае вычитка одного SSD включая trimming заняла около 14 суток. Но scraping еще не завершен.

Промежуточный / на 98.51% финальный результат

Промежуточный / на 98.51% финальный результат

Файловые системы с разделов получившегося образа можно монтировать средствами ядра Linux сразу из /dev/loopNpP, где P - номер раздела. Это можно делать с помощью gnome-disk-utility. При этом обязательно только в режиме чтения. Надежнее отключить образ и пере-подключить в режиме только чтение.

Но для улучшения результатов, поисков фрагментов MFT и файлов по сигнатурам лучше использовать специализированный софт. Из свободных TestDisk/PhotoRec.

Я уже давно пользуюсь Linux версией DMDE. К сожалению исходники закрыты, это платное ПО. Однако в бесплатной версии (только для личного не коммерческого использования) ограничено только кол-во одновременно восстанавливаемых файлов, но не их размер. Это отлично подходит для оценки возможности восстановления нужных файлов.

Использование такого ПО и разбор разных нюансов с этим связанных точно не поместится в данную статью. Да и мои знания об устройстве файловых систем совсем поверхностные.

Заключение

Восстановление в данном случае считаю успешным. Пользовательские файлы извлечены и читаются. Справедливости ради нужно заметить что не все конечно, но дальше пытаться выцарапывать вряд ли имеет смысл. Так как остались только проблемные области общим объемом 1.54GB и вполне возможно 90% из них не читаемые.

Надеюсь мой опыт и скрипт кому нибудь еще пригодятся. Думаю что потребности в восстановлении данных с SSD меньше не становится. Не забывайте напоминать пользователям о бэкапах.

Благодарю за внимание! И удачных экспериментов!

Автор: Иван Пономарев

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js