mirror of https://github.com/bvn13/ADaStor.git
working on disk space watcher
parent
46fea03041
commit
9c1b70a24e
|
@ -17,10 +17,13 @@ public class Config {
|
||||||
private String storagePath;
|
private String storagePath;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
// #{new Integer.parseInt('${api.orders.pingFrequency}')}
|
|
||||||
@Value("${adastor.storage.space.free}")
|
@Value("${adastor.storage.space.free}")
|
||||||
private Long freeSpace;
|
private Long freeSpace;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Value("${adastor.storage.space.critical}")
|
||||||
|
private Long criticalSpace;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@Value("${adastor.max-size}")
|
@Value("${adastor.max-size}")
|
||||||
private Long maxSize;
|
private Long maxSize;
|
||||||
|
@ -47,6 +50,12 @@ public class Config {
|
||||||
if (maxDaysStoring == null || maxDaysStoring.equals(0L)) {
|
if (maxDaysStoring == null || maxDaysStoring.equals(0L)) {
|
||||||
throw new IllegalArgumentException("Max days storing is not specified!");
|
throw new IllegalArgumentException("Max days storing is not specified!");
|
||||||
}
|
}
|
||||||
|
if (criticalSpace == null) {
|
||||||
|
throw new IllegalArgumentException("Critical space is not specified!");
|
||||||
|
}
|
||||||
|
if (criticalSpace.compareTo(freeSpace) > 0) {
|
||||||
|
throw new IllegalArgumentException("Critical space must be less than free space!");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getStoragePath() {
|
public String getStoragePath() {
|
||||||
|
|
|
@ -4,13 +4,10 @@ import lombok.AllArgsConstructor;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import ru.bvn13.adastor.config.Config;
|
|
||||||
|
|
||||||
import javax.persistence.Column;
|
import javax.persistence.Column;
|
||||||
import javax.persistence.Entity;
|
import javax.persistence.Entity;
|
||||||
import javax.persistence.Id;
|
import javax.persistence.Id;
|
||||||
import javax.persistence.Transient;
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -21,6 +18,7 @@ import java.time.LocalDateTime;
|
||||||
@Setter
|
@Setter
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
|
// Stored portion of data :)
|
||||||
public class Stortion {
|
public class Stortion {
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
|
@ -35,4 +33,7 @@ public class Stortion {
|
||||||
@Column
|
@Column
|
||||||
private String path;
|
private String path;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private String hash;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
package ru.bvn13.adastor.exceptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author boykovn at 13.03.2019
|
||||||
|
*/
|
||||||
|
public class AdastorException extends Exception {
|
||||||
|
|
||||||
|
public AdastorException() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public AdastorException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AdastorException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AdastorException(Throwable cause) {
|
||||||
|
super(cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
public AdastorException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
|
||||||
|
super(message, cause, enableSuppression, writableStackTrace);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
package ru.bvn13.adastor.exceptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author boykovn at 13.03.2019
|
||||||
|
*/
|
||||||
|
public class InternalServerError extends AdastorException {
|
||||||
|
|
||||||
|
public InternalServerError() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public InternalServerError(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public InternalServerError(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
public InternalServerError(Throwable cause) {
|
||||||
|
super(cause);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,16 @@
|
||||||
|
package ru.bvn13.adastor.exceptions;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import ru.bvn13.adastor.entities.dtos.StortionDto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author boykovn at 13.03.2019
|
||||||
|
*/
|
||||||
|
public class StortionExistByHash extends AdastorException {
|
||||||
|
@Getter
|
||||||
|
private StortionDto stortion;
|
||||||
|
|
||||||
|
public StortionExistByHash(StortionDto stortionDto) {
|
||||||
|
this.stortion = stortionDto;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
package ru.bvn13.adastor.exceptions;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author boykovn at 13.03.2019
|
||||||
|
*/
|
||||||
|
public class UploadNotAvailable extends AdastorException {
|
||||||
|
|
||||||
|
public UploadNotAvailable() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public UploadNotAvailable(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,41 +0,0 @@
|
||||||
package ru.bvn13.adastor.tasks;
|
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import ru.bvn13.adastor.config.Config;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @author boykovn at 12.03.2019
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
public class DiskFreeSpaceCheck {
|
|
||||||
|
|
||||||
private Config config;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
public void setConfig(Config config) {
|
|
||||||
this.config = config;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Scheduled(fixedDelay = 30000)
|
|
||||||
public void checkFreeDiskSpace() {
|
|
||||||
double space = getSpaceLeft();
|
|
||||||
if (space <= config.getFreeSpace()) {
|
|
||||||
removeOldStortions();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public double getSpaceLeft() {
|
|
||||||
File path = new File(config.getStoragePath());
|
|
||||||
double space = (double) path.getFreeSpace() / 1024 / 1024;
|
|
||||||
return space;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void removeOldStortions() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,73 @@
|
||||||
|
package ru.bvn13.adastor.tasks;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import ru.bvn13.adastor.config.Config;
|
||||||
|
import ru.bvn13.adastor.web.services.StortionService;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author boykovn at 12.03.2019
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class DiskFreeSpaceChecker {
|
||||||
|
|
||||||
|
private Config config;
|
||||||
|
private StortionService stortionService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public void setConfig(Config config) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public void setStortionService(StortionService stortionService) {
|
||||||
|
this.stortionService = stortionService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(fixedDelay = 30000)
|
||||||
|
public void checkFreeDiskSpace() {
|
||||||
|
double spaceLeft = getSpaceLeft();
|
||||||
|
if (spaceLeft <= config.getFreeSpace()) {
|
||||||
|
removeOldStortions(spaceLeft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private double getSpaceLeft() {
|
||||||
|
File path = new File(config.getStoragePath());
|
||||||
|
double space = (double) path.getFreeSpace() / 1024 / 1024;
|
||||||
|
return space;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void removeOldStortions(final double currentSpaceLeft) {
|
||||||
|
final double mustFreeSpace = config.getFreeSpace();
|
||||||
|
final AtomicReference<Double> spaceLeft = new AtomicReference<>(currentSpaceLeft);
|
||||||
|
|
||||||
|
final ExecutorService es = Executors.newFixedThreadPool(10);
|
||||||
|
|
||||||
|
stortionService.findAllSortedByRetention().forEach(stortionDto -> {
|
||||||
|
double space = spaceLeft.accumulateAndGet((double) stortionDto.getSize(), (a, b) -> a + b);
|
||||||
|
if (space >= mustFreeSpace) {
|
||||||
|
es.submit(() -> {
|
||||||
|
File file = new File(String.format("%s%s", config.getStoragePath(), stortionDto.getPath()));
|
||||||
|
if (file.exists()) {
|
||||||
|
file.delete();
|
||||||
|
}
|
||||||
|
stortionService.removeStortionByUUID(stortionDto.getUuid());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
es.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean checkUploadAvailable(long dataLength) {
|
||||||
|
return !(getSpaceLeft() - dataLength <= config.getCriticalSpace());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -4,9 +4,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.ResponseBody;
|
import org.springframework.web.bind.annotation.ResponseBody;
|
||||||
import ru.bvn13.adastor.entities.Stortion;
|
|
||||||
import ru.bvn13.adastor.entities.dtos.StortionDto;
|
import ru.bvn13.adastor.entities.dtos.StortionDto;
|
||||||
import ru.bvn13.adastor.web.repositories.StortionRepository;
|
|
||||||
import ru.bvn13.adastor.web.services.StortionService;
|
import ru.bvn13.adastor.web.services.StortionService;
|
||||||
|
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
|
@ -5,9 +5,14 @@ import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.ResponseBody;
|
import org.springframework.web.bind.annotation.ResponseBody;
|
||||||
import ru.bvn13.adastor.entities.dtos.StortionDto;
|
import ru.bvn13.adastor.entities.dtos.StortionDto;
|
||||||
|
import ru.bvn13.adastor.exceptions.AdastorException;
|
||||||
|
import ru.bvn13.adastor.exceptions.InternalServerError;
|
||||||
|
import ru.bvn13.adastor.exceptions.StortionExistByHash;
|
||||||
|
import ru.bvn13.adastor.exceptions.UploadNotAvailable;
|
||||||
import ru.bvn13.adastor.web.services.StortionService;
|
import ru.bvn13.adastor.web.services.StortionService;
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -25,8 +30,20 @@ public class UploadController {
|
||||||
|
|
||||||
@PostMapping(value="/a", produces = {"application/json"})
|
@PostMapping(value="/a", produces = {"application/json"})
|
||||||
public @ResponseBody
|
public @ResponseBody
|
||||||
StortionDto uploadData(HttpServletRequest request) throws IOException {
|
StortionDto uploadData(HttpServletRequest request, HttpServletResponse response) throws IOException, AdastorException {
|
||||||
return stortionService.createStortion(request.getInputStream());
|
try {
|
||||||
|
return stortionService.createStortion(request.getContentLengthLong(), request.getInputStream());
|
||||||
|
} catch (InternalServerError internalServerError) {
|
||||||
|
internalServerError.printStackTrace();
|
||||||
|
response.sendError(500, "Internal server error, Sorry");
|
||||||
|
return null;
|
||||||
|
} catch (StortionExistByHash stortionExistByHash) {
|
||||||
|
stortionExistByHash.printStackTrace();
|
||||||
|
return stortionExistByHash.getStortion();
|
||||||
|
} catch (UploadNotAvailable uploadNotAvailable) {
|
||||||
|
response.sendError(406, uploadNotAvailable.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,10 +4,15 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
import ru.bvn13.adastor.entities.Stortion;
|
import ru.bvn13.adastor.entities.Stortion;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author boykovn at 11.03.2019
|
* @author boykovn at 11.03.2019
|
||||||
*/
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
public interface StortionRepository extends JpaRepository<Stortion, String>, CustomStortionRepository {
|
public interface StortionRepository extends JpaRepository<Stortion, String>, CustomStortionRepository {
|
||||||
|
|
||||||
|
Iterable<Stortion> findAllByHash(String hash);
|
||||||
|
Optional<Stortion> findFirstByHash(String hash);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,10 +6,19 @@ import org.springframework.stereotype.Service;
|
||||||
import ru.bvn13.adastor.config.Config;
|
import ru.bvn13.adastor.config.Config;
|
||||||
import ru.bvn13.adastor.entities.Stortion;
|
import ru.bvn13.adastor.entities.Stortion;
|
||||||
import ru.bvn13.adastor.entities.dtos.StortionDto;
|
import ru.bvn13.adastor.entities.dtos.StortionDto;
|
||||||
|
import ru.bvn13.adastor.exceptions.AdastorException;
|
||||||
|
import ru.bvn13.adastor.exceptions.InternalServerError;
|
||||||
|
import ru.bvn13.adastor.exceptions.StortionExistByHash;
|
||||||
|
import ru.bvn13.adastor.exceptions.UploadNotAvailable;
|
||||||
|
import ru.bvn13.adastor.tasks.DiskFreeSpaceChecker;
|
||||||
import ru.bvn13.adastor.web.repositories.StortionRepository;
|
import ru.bvn13.adastor.web.repositories.StortionRepository;
|
||||||
|
|
||||||
import java.io.*;
|
import java.io.*;
|
||||||
|
import java.security.DigestInputStream;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Formatter;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
@ -23,6 +32,7 @@ public class StortionService {
|
||||||
private StortionRepository stortionRepository;
|
private StortionRepository stortionRepository;
|
||||||
private Config config;
|
private Config config;
|
||||||
private ModelMapper modelMapper;
|
private ModelMapper modelMapper;
|
||||||
|
private DiskFreeSpaceChecker diskFreeSpaceChecker;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public void setStortionRepository(StortionRepository stortionRepository) {
|
public void setStortionRepository(StortionRepository stortionRepository) {
|
||||||
|
@ -39,6 +49,11 @@ public class StortionService {
|
||||||
this.modelMapper = modelMapper;
|
this.modelMapper = modelMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public void setDiskFreeSpaceChecker(DiskFreeSpaceChecker diskFreeSpaceChecker) {
|
||||||
|
this.diskFreeSpaceChecker = diskFreeSpaceChecker;
|
||||||
|
}
|
||||||
|
|
||||||
public Optional<Stortion> findStortion(String uuid) {
|
public Optional<Stortion> findStortion(String uuid) {
|
||||||
return stortionRepository.findById(uuid);
|
return stortionRepository.findById(uuid);
|
||||||
}
|
}
|
||||||
|
@ -54,26 +69,55 @@ public class StortionService {
|
||||||
return targetStream;
|
return targetStream;
|
||||||
}
|
}
|
||||||
|
|
||||||
public StortionDto createStortion(InputStream is) throws IOException {
|
public StortionDto createStortion(long dataLength, InputStream is) throws IOException, AdastorException {
|
||||||
|
if (!diskFreeSpaceChecker.checkUploadAvailable(dataLength)) {
|
||||||
|
throw new UploadNotAvailable("No space left on device!");
|
||||||
|
}
|
||||||
|
|
||||||
String uuid = UUID.randomUUID().toString();
|
String uuid = UUID.randomUUID().toString();
|
||||||
String path = String.format("/%s", uuid);
|
String path = String.format("/%s", uuid);
|
||||||
String fullPath = String.format("%s/%s", config.getStoragePath(), uuid);
|
String fullPath = String.format("%s/%s", config.getStoragePath(), uuid);
|
||||||
|
|
||||||
|
long bytesCount;
|
||||||
|
String hash;
|
||||||
|
try(DigestInputStream dis = new DigestInputStream(new BufferedInputStream(is), MessageDigest.getInstance("SHA-1")); FileOutputStream fos = new FileOutputStream(fullPath)) {
|
||||||
|
bytesCount = is.transferTo(fos);
|
||||||
|
hash = formatMessageDigestToHex(dis);
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new InternalServerError("SHA-1 not found, Sorry.", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<StortionDto> similarByHash = findAnyByHash(hash);
|
||||||
|
if (similarByHash.isPresent()) {
|
||||||
|
throw new StortionExistByHash(similarByHash.get());
|
||||||
|
}
|
||||||
|
|
||||||
Stortion stortion = new Stortion();
|
Stortion stortion = new Stortion();
|
||||||
stortion.setUuid(uuid);
|
stortion.setUuid(uuid);
|
||||||
stortion.setStoreDate(LocalDateTime.now());
|
stortion.setStoreDate(LocalDateTime.now());
|
||||||
stortion.setPath(path);
|
stortion.setPath(path);
|
||||||
|
|
||||||
long bytesCount = 0;
|
|
||||||
try(FileOutputStream fos = new FileOutputStream(fullPath)) {
|
|
||||||
bytesCount = is.transferTo(fos);
|
|
||||||
}
|
|
||||||
|
|
||||||
stortion.setSize(bytesCount);
|
stortion.setSize(bytesCount);
|
||||||
|
|
||||||
stortionRepository.save(stortion);
|
stortionRepository.save(stortion);
|
||||||
return convertToDto(stortion);
|
return convertToDto(stortion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String formatMessageDigestToHex(DigestInputStream dis) {
|
||||||
|
final MessageDigest md = dis.getMessageDigest();
|
||||||
|
final byte[] digest = md.digest();
|
||||||
|
|
||||||
|
// Format as HEX
|
||||||
|
try (Formatter formatter = new Formatter()) {
|
||||||
|
for (final byte b : digest) {
|
||||||
|
formatter.format("%02x", b);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String sha1 = formatter.toString();
|
||||||
|
return sha1;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
private StortionDto convertToDto(Stortion stortion) {
|
private StortionDto convertToDto(Stortion stortion) {
|
||||||
StortionDto stortionDto = modelMapper.map(stortion, StortionDto.class);
|
StortionDto stortionDto = modelMapper.map(stortion, StortionDto.class);
|
||||||
stortionDto.setRetention(computeRetention(stortion));
|
stortionDto.setRetention(computeRetention(stortion));
|
||||||
|
@ -95,4 +139,16 @@ public class StortionService {
|
||||||
return Math.round(retention);
|
return Math.round(retention);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void removeStortionByUUID(String uuid) {
|
||||||
|
stortionRepository.deleteById(uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Iterable<Stortion> findAllByHash(String hash) {
|
||||||
|
return stortionRepository.findAllByHash(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<StortionDto> findAnyByHash(String hash) {
|
||||||
|
return stortionRepository.findFirstByHash(hash).map(this::convertToDto);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
adastor.storage.path=./storage
|
adastor.storage.path=./storage
|
||||||
#in bytes
|
#in bytes
|
||||||
adastor.storage.space.free=200000000
|
adastor.storage.space.free=200000000
|
||||||
|
adastor.storage.space.critical=10000000
|
||||||
#max stortion size in bytes
|
#max stortion size in bytes
|
||||||
adastor.max-size=100000000
|
adastor.max-size=100000000
|
||||||
#min days storing
|
#min days storing
|
||||||
|
|
Loading…
Reference in New Issue