Got most of the create menu working
Some checks failed
Build / build (push) Failing after 15s

This commit is contained in:
2025-06-07 17:32:55 +02:00
parent 07765b8367
commit e703da995e
17 changed files with 286 additions and 20 deletions

View File

@@ -5,15 +5,50 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
/**
* Controller for handling web requests related to assets.
* Provides endpoints for viewing and creating assets.
*/
@RequiredArgsConstructor
@Controller
public class WebController {
/// The name of the model attribute that holds the global asset descriptors.
private static final String DESCRIPTORS = "descriptors";
/// The name of the model attribute that holds the asset descriptor for the current view.
private static final String DESCRIPTOR = "descriptor";
private final AssetService assetService;
/**
* Handles the root URL and returns the index page with asset descriptors and asset count.
*/
@GetMapping("/")
public String index(Model model) {
model.addAttribute(DESCRIPTORS, assetService.getAssetDescriptors());
model.addAttribute("asset_count", assetService.countAssets());
return "index";
}
/**
* Shows a view where the user can create the type of asset to create.
*/
@GetMapping("/create")
public String create(Model model) {
model.addAttribute(DESCRIPTORS, assetService.getAssetDescriptors());
return "create_select";
}
/**
* Shows a view where the user can create a specific type of asset.
*
* @param type The type of asset to create.
*/
@GetMapping("/create/{type}")
public String createType(Model model, @PathVariable String type) {
model.addAttribute(DESCRIPTORS, assetService.getAssetDescriptorTree(type));
model.addAttribute(DESCRIPTOR, assetService.getAssetDescriptor(type));
return "create_asset";
}
}

View File

@@ -1,6 +1,7 @@
package be.seeseepuff.pcinv.meta;
import lombok.Builder;
import lombok.Getter;
import java.util.Arrays;
import java.util.Collection;
@@ -9,8 +10,9 @@ import java.util.Objects;
/**
* Describes the properties of an asset
*/
@Getter
@Builder
public class AssetProperties {
public class AssetDescriptor {
/// The type of property, e.g.: ram, asset, etc...
private final String type;
@@ -30,10 +32,10 @@ public class AssetProperties {
* @param assetType The class of the asset to load properties for.
* @return An AssetProperties instance containing the loaded properties.
*/
public static AssetProperties loadFrom(Class<?> assetType) {
public static AssetDescriptor loadFrom(Class<?> assetType) {
var assetInfo = assetType.getAnnotation(AssetInfo.class);
Objects.requireNonNull(assetInfo, "Asset class must be annotated with @AssetInfo");
return AssetProperties.builder()
return AssetDescriptor.builder()
.type(assetInfo.type())
.displayName(assetInfo.displayName())
.visible(assetInfo.isVisible())
@@ -66,4 +68,14 @@ public class AssetProperties {
builder.append(indent).append('}');
return builder.toString();
}
/**
* Returns a string representation of the asset property in the format "type-propertyName".
*
* @param property The asset property to format.
* @return A string representation of the asset property.
*/
public String asString(AssetProperty property) {
return String.format("%s-%s", type, property.getName());
}
}

View File

@@ -2,8 +2,9 @@ package be.seeseepuff.pcinv.meta;
import lombok.Getter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.TreeSet;
/**
* Holds the descriptors for all possible asset types.
@@ -11,7 +12,7 @@ import java.util.Collection;
@Getter
public class AssetDescriptors {
/// A collection of all types of assets.
private final Collection<AssetProperties> assets = new ArrayList<>();
private final Collection<AssetDescriptor> assets = new TreeSet<>(Comparator.comparing(AssetDescriptor::getDisplayName));
/**
* Loads the asset properties from a given asset type.
@@ -19,7 +20,7 @@ public class AssetDescriptors {
* @param assetType The type of the asset to load properties for.
*/
public void loadFrom(Class<?> assetType) {
var property = AssetProperties.loadFrom(assetType);
var property = AssetDescriptor.loadFrom(assetType);
assets.add(property);
}
@@ -27,7 +28,7 @@ public class AssetDescriptors {
public String toString() {
var builder = new StringBuilder();
builder.append("AssetDescriptors [\n");
assets.forEach(assetProperties -> builder.append(assetProperties.toString(" ")).append('\n'));
assets.forEach(assetDescriptor -> builder.append(assetDescriptor.toString(" ")).append('\n'));
builder.append("]");
return builder.toString();
}

View File

@@ -0,0 +1,15 @@
package be.seeseepuff.pcinv.meta;
/**
* An interface that should be implemented by all asset enums.
*/
public interface AssetEnum {
/// Get the internal value of the enum.
String getValue();
/// Get the display name of the enum.
String getDisplayName();
/// Get the default value of the enum that should be selected when the user creates a device.
boolean isDefaultValue();
}

View File

@@ -0,0 +1,18 @@
package be.seeseepuff.pcinv.meta;
import lombok.Builder;
import lombok.Getter;
/**
* Represents an option for an asset property enum.
*/
@Getter
@Builder
public class AssetOption {
/// The internal value of the option.
private final String value;
/// The display name of the option.
private final String displayName;
/// Whether this option is the default value for the property.
private final boolean isDefaultValue;
}

View File

@@ -2,14 +2,19 @@ package be.seeseepuff.pcinv.meta;
import be.seeseepuff.pcinv.models.AssetCondition;
import jakarta.annotation.Nullable;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.Singular;
import java.lang.reflect.Field;
import java.util.List;
/**
* Represents a property of an asset, such as its name or type.
* This class is used to define the metadata for assets in the system.
*/
@Getter
@Builder
public class AssetProperty {
/// The name of the property, e.g., "brand", "model", etc.
@@ -18,12 +23,37 @@ public class AssetProperty {
private final String displayName;
/// The type of the property, which can be a string or an integer.
private final Type type;
/// Whether the property is required for the asset.
private final boolean required;
/// A set of options for the property, used for enum types.
@Singular
private final List<AssetOption> options;
/// Whether the capacity should be displayed in SI units (e.g., GB, MB).
private final boolean capacityAsSI;
/// Whether the capacity should be displayed in IEC units (e.g., GiB, MiB).
private final boolean capacityAsIEC;
/**
* Enum representing the possible types of asset properties.
*/
@AllArgsConstructor
public enum Type {
STRING, INTEGER, CONDITION
STRING(false),
INTEGER(false),
CAPACITY(false),
CONDITION(true),
;
/// Set to `true` if the type is an enum, `false` otherwise.
public final boolean isEnum;
/**
* Returns the name of the type, or "enum" if it is an enum type.
*
* @return The name of the type or "enum" if it is an enum.
*/
public String nameOrEnum() {
return isEnum ? "enum" : name();
}
}
/**
@@ -38,11 +68,34 @@ public class AssetProperty {
if (annotation == null) {
return null;
}
return AssetProperty.builder()
var type = determineType(property);
var builder = AssetProperty.builder()
.name(property.getName())
.displayName(annotation.value())
.type(determineType(property))
.build();
.type(type)
.required(annotation.required());
if (type.isEnum) {
var enumConstants = property.getType().getEnumConstants();
for (var enumConstant : enumConstants) {
if (!(enumConstant instanceof AssetEnum assetEnum)) {
throw new IllegalArgumentException("Property " + enumConstant.getClass().getName() + " does not implement AssetEnum");
}
var option = AssetOption.builder()
.value(assetEnum.getValue())
.displayName(assetEnum.getDisplayName())
.isDefaultValue(assetEnum.isDefaultValue())
.build();
builder.option(option);
}
}
if (type == Type.CAPACITY) {
var capacityAnnotation = property.getAnnotation(Capacity.class);
builder.capacityAsSI(capacityAnnotation.si());
builder.capacityAsIEC(capacityAnnotation.iec());
}
return builder.build();
}
/**
@@ -55,6 +108,8 @@ public class AssetProperty {
private static Type determineType(Field property) {
if (property.getType() == String.class) {
return Type.STRING;
} else if (property.isAnnotationPresent(Capacity.class)) {
return Type.CAPACITY;
} else if (property.getType() == Integer.class || property.getType() == int.class || property.getType() == Long.class || property.getType() == long.class) {
return Type.INTEGER;
} else if (property.getType() == AssetCondition.class) {
@@ -66,6 +121,10 @@ public class AssetProperty {
@Override
public String toString() {
return String.format("%s:%s (%s)", name, type.name(), displayName);
var enumOptions = "";
if (type.isEnum) {
enumOptions = " [" + String.join(", ", options.stream().map(AssetOption::getValue).toList()) + "]";
}
return String.format("%s:%s (%s)%s", name, type.name(), displayName, enumOptions);
}
}

View File

@@ -0,0 +1,20 @@
package be.seeseepuff.pcinv.meta;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* An annotation to mark a property descriptor as representing some sort of capacity.
* It is used to render the capacity in the UI as bytes, kilobytes, megabytes, etc.
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Capacity {
/// Set to `true` if the capacity should include SI units (e.g., 1 KB = 1000 bytes).
boolean si() default false;
/// Set to `true` if the capacity should be displayed in IEC units (e.g., 1 KiB = 1024 bytes).
boolean iec() default true;
}

View File

@@ -17,4 +17,11 @@ public @interface Property {
* @return the display name of the property
*/
String value();
/**
* Whether the property is required for the asset.
*
* @return true if the property is required, false otherwise
*/
boolean required() default false;
}

View File

@@ -1,18 +1,32 @@
package be.seeseepuff.pcinv.models;
import be.seeseepuff.pcinv.meta.AssetEnum;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* Represents the condition of an asset in the inventory system.
*/
public enum AssetCondition
@Getter
@RequiredArgsConstructor
public enum AssetCondition implements AssetEnum
{
/// The asset is in perfect working order.
HEALTHY,
HEALTHY("healthy", "Healthy"),
/// The condition of the asset is unknown. E.g.: it is untested.
UNKNOWN,
UNKNOWN("unknown", "Not known"),
/// The asset generally works, but has some known issues.
PARTIAL,
PARTIAL("partial", "Partially working"),
/// The asset is in need of repair, but is not completely broken.
REPAIR,
REPAIR("repair", "Requires repairs"),
/// The asset is completely broken and cannot be used.
BORKED,
BORKED("borked", "Borked"),
;
private final String value;
private final String displayName;
@Override
public boolean isDefaultValue() {
return this == UNKNOWN;
}
}

View File

@@ -27,7 +27,7 @@ public class GenericAsset
private long id;
/// The QR code attached to the asset, used for identification.
@Property("QR")
@Property(value = "QR", required = true)
private long qr;
/// The brand of the asset.

View File

@@ -1,6 +1,7 @@
package be.seeseepuff.pcinv.models;
import be.seeseepuff.pcinv.meta.AssetInfo;
import be.seeseepuff.pcinv.meta.Capacity;
import be.seeseepuff.pcinv.meta.Property;
import jakarta.persistence.*;
import lombok.Getter;
@@ -29,6 +30,7 @@ public class HddAsset implements Asset
/// The capacity of the drive in bytes.
@Property("Capacity")
@Capacity(si = true, iec = true)
private long capacity;
/// The drive's interface type, such as SATA, IDE, ISA-16, ...

View File

@@ -1,6 +1,7 @@
package be.seeseepuff.pcinv.models;
import be.seeseepuff.pcinv.meta.AssetInfo;
import be.seeseepuff.pcinv.meta.Capacity;
import be.seeseepuff.pcinv.meta.Property;
import jakarta.persistence.*;
import lombok.Getter;
@@ -29,6 +30,7 @@ public class RamAsset implements Asset
/// The capacity of the RAM in bytes.
@Property("Capacity")
@Capacity
private long capacity;
/// The type of memory. E.g.: DDR2, SDRAM, ISA-8, etc...

View File

@@ -1,5 +1,6 @@
package be.seeseepuff.pcinv.services;
import be.seeseepuff.pcinv.meta.AssetDescriptor;
import be.seeseepuff.pcinv.meta.AssetDescriptors;
import be.seeseepuff.pcinv.models.GenericAsset;
import be.seeseepuff.pcinv.repositories.AssetRepository;
@@ -8,6 +9,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Collection;
import java.util.List;
/**
* Service for managing assets in the repository.
@@ -28,6 +30,11 @@ public class AssetService {
return assetRepository.count();
}
/**
* Retrieves the global asset descriptors for all asset types.
*
* @return the AssetProperties for the specified type
*/
public AssetDescriptors getAssetDescriptors() {
var descriptors = new AssetDescriptors();
descriptors.loadFrom(GenericAsset.class);
@@ -36,4 +43,30 @@ public class AssetService {
}
return descriptors;
}
/**
* Retrieves the asset properties for a specific type.
*
* @param type the type of asset to retrieve properties for
* @return the AssetProperties for the specified type
*/
public AssetDescriptor getAssetDescriptor(String type) {
return getAssetDescriptors().getAssets().stream()
.filter(asset -> asset.getType().equals(type))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unknown asset type: " + type));
}
/**
* Retrieves a tree of asset descriptors for the specified type.
*
* @param type the type of asset to retrieve descriptors for
* @return a list of AssetDescriptors for the specified type
*/
public List<AssetDescriptor> getAssetDescriptorTree(String type) {
if (type.equals("asset")) {
return List.of(getAssetDescriptor("asset"));
}
return List.of(getAssetDescriptor("asset"), getAssetDescriptor(type));
}
}

View File

@@ -0,0 +1,40 @@
<body th:replace="fragments :: base(title='Select type to create', content=~{::content})">
<div th:fragment="content">
Create a <span th:text="${descriptor.displayName}"></span>
<form th:action="'/asset/'+${descriptor.getType()}" method="post">
<div th:each="d : ${descriptors}">
<h2 th:text="${d.displayName}"></h2>
<table border="1">
<tr th:each="p : ${d.getProperties()}">
<td><label th:text="${p.displayName}" th:for="${d.asString(p)}"></label></td>
<td th:switch="${p.type.nameOrEnum()}">
<input th:case="STRING" type="text" th:id="${d.asString(p)}" th:name="${d.asString(p)}" th:placeholder="${p.displayName}" th:required="${p.required}"/>
<input th:case="INTEGER" type="number" th:id="${d.asString(p)}" th:name="${d.asString(p)}" th:required="${p.required}"/>
<select th:case="enum" th:id="${d.asString(p)}" th:name="${d.asString(p)}">
<option th:each="o : ${p.options}" th:value="${o.value}" th:text="${o.displayName}" th:selected="${o.defaultValue}">Good</option>
</select>
<span th:case="CAPACITY">
<input type="number" th:id="${d.asString(p)+'-value'}" th:name="${d.asString(p)+'-value'}" th:required="${p.required}"/>
<select th:id="${d.asString(p)}+'-unit'" th:name="${d.asString(p)}+'-unit'">
<option value="1">Bytes</option>
<option th:if="${p.capacityAsSI}" value="1000">kB</option>
<option th:if="${p.capacityAsIEC}" value="1024">KiB</option>
<option th:if="${p.capacityAsSI}" value="1000000">MB</option>
<option th:if="${p.capacityAsIEC}" value="1048576">MiB</option>
<option th:if="${p.capacityAsSI}" value="1000000000">GB</option>
<option th:if="${p.capacityAsIEC}" value="1073741824">GiB</option>
<option th:if="${p.capacityAsSI}" value="1000000000000">TB</option>
<option th:if="${p.capacityAsIEC}" value="1099511627776">TiB</option>
</select>
</span>
<b th:case="*">Bad input type for <span th:text="${d.type}+'-'+${p.type}"></span></b>
</td>
</tr>
</table>
</div>
<p>
<input type="submit" value="Create">
</p>
</form>
</div>
</body>

View File

@@ -0,0 +1,8 @@
<body th:replace="fragments :: base(title='Select type to create', content=~{::content})">
<div th:fragment="content">
Create a new device
<ul>
<li th:each="d : ${descriptors.getAssets()}" th:if="${d.visible}"><a th:href="'/create/'+${d.getType()}" th:text="${d.displayName}"></a></li>
</ul>
</div>
</body>

View File

@@ -1,4 +1,3 @@
<html lang="en" xmlns:th="http://www.thymeleaf.org" th:fragment="base(title, content)">
<head>
<meta charset="UTF-8">

View File

@@ -1,5 +1,6 @@
<div th:replace="fragments :: base(title='Home', content=~{::content})">
<div th:fragment="content">
<a href="/create">Create a new device</a>
<p>This system holds <span th:text="${asset_count}">5</span> assets.</p>
</div>
</div>