Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions src/main/java/apu/saerok_admin/infra/stat/AdminStatClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import apu.saerok_admin.infra.SaerokApiProps;
import apu.saerok_admin.infra.stat.dto.StatSeriesResponse;
import java.net.URI;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Collection;
import java.util.Objects;
import org.springframework.stereotype.Component;
Expand All @@ -23,13 +25,15 @@ public AdminStatClient(RestClient saerokRestClient, SaerokApiProps saerokApiProp
this.missingPrefixSegments = saerokApiProps.missingPrefixSegments().toArray(new String[0]);
}

public StatSeriesResponse fetchSeries(Collection<StatMetric> metrics) {
private static final DateTimeFormatter ISO_DATE = DateTimeFormatter.ISO_DATE;

public StatSeriesResponse fetchSeries(Collection<StatMetric> metrics, LocalDate startDate, LocalDate endDate) {
if (metrics == null || metrics.isEmpty()) {
throw new IllegalArgumentException("metrics must not be empty");
}

StatSeriesResponse response = saerokRestClient.get()
.uri(uriBuilder -> buildSeriesUri(uriBuilder, metrics))
.uri(uriBuilder -> buildSeriesUri(uriBuilder, metrics, startDate, endDate))
.retrieve()
.body(StatSeriesResponse.class);

Expand All @@ -40,7 +44,7 @@ public StatSeriesResponse fetchSeries(Collection<StatMetric> metrics) {
return response;
}

private URI buildSeriesUri(UriBuilder builder, Collection<StatMetric> metrics) {
private URI buildSeriesUri(UriBuilder builder, Collection<StatMetric> metrics, LocalDate startDate, LocalDate endDate) {
if (missingPrefixSegments.length > 0) {
builder.pathSegment(missingPrefixSegments);
}
Expand All @@ -52,6 +56,16 @@ private URI buildSeriesUri(UriBuilder builder, Collection<StatMetric> metrics) {
.map(Enum::name)
.forEach(metric -> builder.queryParam("metric", metric));

if (startDate != null || endDate != null) {
builder.queryParam("period", buildPeriodQuery(startDate, endDate));
}

return builder.build();
}

private String buildPeriodQuery(LocalDate startDate, LocalDate endDate) {
String startToken = startDate != null ? ISO_DATE.format(startDate) : "";
String endToken = endDate != null ? ISO_DATE.format(endDate) : "";
return startToken + "," + endToken;
}
}
156 changes: 136 additions & 20 deletions src/main/java/apu/saerok_admin/web/ServiceInsightController.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package apu.saerok_admin.web;

import apu.saerok_admin.web.serviceinsight.ServiceInsightAjaxResponse;
import apu.saerok_admin.web.serviceinsight.ServiceInsightQuery;
import apu.saerok_admin.web.serviceinsight.ServiceInsightRangePreset;
import apu.saerok_admin.web.serviceinsight.ServiceInsightService;
import apu.saerok_admin.web.view.Breadcrumb;
import apu.saerok_admin.web.view.ServiceInsightViewModel;
Expand All @@ -8,13 +11,19 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestClientResponseException;

Expand All @@ -23,6 +32,8 @@ public class ServiceInsightController {

private static final Logger log = LoggerFactory.getLogger(ServiceInsightController.class);
private static final String ERROR_TOAST_ID = "toastServiceInsightError";
private static final ZoneId DEFAULT_ZONE = ZoneId.of("Asia/Seoul");
private static final ServiceInsightRangePreset DEFAULT_PRESET = ServiceInsightRangePreset.LAST_14_DAYS;

private final ServiceInsightService serviceInsightService;
private final ObjectMapper objectMapper;
Expand All @@ -35,8 +46,15 @@ public ServiceInsightController(ServiceInsightService serviceInsightService, Obj
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}

@GetMapping("/service-insight")
public String serviceInsight(Model model) {
@GetMapping(value = "/service-insight", produces = MediaType.TEXT_HTML_VALUE)
public String serviceInsight(
Model model,
@RequestParam(value = "range", required = false) String rangeParam,
@RequestParam(value = "startDate", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam(value = "endDate", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate
) {
model.addAttribute("pageTitle", "서비스 인사이트");
model.addAttribute("activeMenu", "serviceInsight");
model.addAttribute("breadcrumbs", List.of(
Expand All @@ -45,41 +63,133 @@ public String serviceInsight(Model model) {
));
ensureToastMessages(model);

ServiceInsightViewModel viewModel;
try {
viewModel = serviceInsightService.loadViewModel();
log.info("Successfully loaded service insight view model with {} metrics",
viewModel.metricOptions().size());
} catch (RestClientResponseException exception) {
log.warn(
"Failed to load service insight stats. status={}, body={}",
exception.getStatusCode(),
exception.getResponseBodyAsString(),
exception
);
viewModel = serviceInsightService.defaultViewModel();
attachErrorToast(model);
} catch (RestClientException | IllegalStateException exception) {
log.warn("Failed to load service insight stats.", exception);
viewModel = serviceInsightService.defaultViewModel();
PageData pageData = loadPageData(rangeParam, startDate, endDate);
ServiceInsightViewModel viewModel = pageData.viewModel();
RangeSelection rangeSelection = pageData.rangeSelection();
if (pageData.hadError()) {
attachErrorToast(model);
}

model.addAttribute("serviceInsight", viewModel);
String chartDataJson = toJson(viewModel);
model.addAttribute("chartDataJson", chartDataJson);
model.addAttribute("rangeQuickOptions", ServiceInsightRangePreset.quickSelections());
model.addAttribute("selectedRange", rangeSelection.preset().paramValue());
model.addAttribute("customRangeActive", rangeSelection.preset() == ServiceInsightRangePreset.CUSTOM);
model.addAttribute("selectedStartDate", rangeSelection.query().startDate());
model.addAttribute("selectedEndDate", rangeSelection.query().endDate());

log.debug("Chart data JSON length: {}", chartDataJson.length());

return "service-insight/index";
}

@GetMapping(value = "/service-insight", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ServiceInsightAjaxResponse> serviceInsightData(
@RequestParam(value = "range", required = false) String rangeParam,
@RequestParam(value = "startDate", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam(value = "endDate", required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate
) {
PageData pageData = loadPageData(rangeParam, startDate, endDate);
RangeSelection rangeSelection = pageData.rangeSelection();

ServiceInsightAjaxResponse response = new ServiceInsightAjaxResponse(
pageData.viewModel(),
rangeSelection.preset().paramValue(),
rangeSelection.preset() == ServiceInsightRangePreset.CUSTOM,
rangeSelection.query().startDate(),
rangeSelection.query().endDate(),
pageData.hadError()
);

return ResponseEntity.ok(response);
}

private void ensureToastMessages(Model model) {
if (!model.containsAttribute("toastMessages")) {
model.addAttribute("toastMessages", List.of());
}
}

private PageData loadPageData(String rangeParam, LocalDate startDate, LocalDate endDate) {
RangeSelection rangeSelection = resolveRange(rangeParam, startDate, endDate);

try {
ServiceInsightViewModel viewModel = serviceInsightService.loadViewModel(rangeSelection.query());
log.info("Successfully loaded service insight view model with {} metrics (range: {} - {}, preset: {})",
viewModel.metricOptions().size(),
rangeSelection.query().startDate(),
rangeSelection.query().endDate(),
rangeSelection.preset().name());
return new PageData(rangeSelection, viewModel, false);
} catch (RestClientResponseException exception) {
log.warn(
"Failed to load service insight stats. status={}, body={}",
exception.getStatusCode(),
exception.getResponseBodyAsString(),
exception
);
} catch (RestClientException | IllegalStateException exception) {
log.warn("Failed to load service insight stats.", exception);
}

ServiceInsightViewModel fallback = serviceInsightService.defaultViewModel();
return new PageData(rangeSelection, fallback, true);
}

private RangeSelection resolveRange(String rangeParam, LocalDate startDate, LocalDate endDate) {
LocalDate today = LocalDate.now(DEFAULT_ZONE);

ServiceInsightRangePreset requestedPreset = ServiceInsightRangePreset.fromParameter(rangeParam)
.orElse(DEFAULT_PRESET);

if (startDate != null || endDate != null) {
requestedPreset = ServiceInsightRangePreset.CUSTOM;
}

if (requestedPreset == ServiceInsightRangePreset.ALL) {
return new RangeSelection(ServiceInsightRangePreset.ALL, ServiceInsightQuery.all());
}

if (requestedPreset == ServiceInsightRangePreset.CUSTOM) {
if (startDate == null || endDate == null) {
log.debug("Incomplete custom range supplied (start={}, end={}), falling back to default preset {}",
startDate,
endDate,
DEFAULT_PRESET.name());
return buildPresetSelection(DEFAULT_PRESET, today);
}

LocalDate effectiveStart = startDate;
LocalDate effectiveEnd = endDate;

if (effectiveEnd.isBefore(effectiveStart)) {
effectiveStart = endDate;
effectiveEnd = startDate;
}

if (effectiveEnd.isAfter(today)) {
effectiveEnd = today;
}

if (effectiveStart.isAfter(effectiveEnd)) {
effectiveStart = effectiveEnd;
}

return new RangeSelection(ServiceInsightRangePreset.CUSTOM, new ServiceInsightQuery(effectiveStart, effectiveEnd));
}

return buildPresetSelection(requestedPreset, today);
}

private RangeSelection buildPresetSelection(ServiceInsightRangePreset preset, LocalDate today) {
return preset.toWindow(today)
.map(window -> new RangeSelection(preset, new ServiceInsightQuery(window.startDate(), window.endDate())))
.orElseGet(() -> new RangeSelection(ServiceInsightRangePreset.ALL, ServiceInsightQuery.all()));
}

private void attachErrorToast(Model model) {
ToastMessage errorToast = new ToastMessage(
ERROR_TOAST_ID,
Expand Down Expand Up @@ -111,4 +221,10 @@ private String toJson(ServiceInsightViewModel viewModel) {
return "{\"metricOptions\":[],\"series\":[],\"componentLabels\":{}}";
}
}
}

private record RangeSelection(ServiceInsightRangePreset preset, ServiceInsightQuery query) {
}

private record PageData(RangeSelection rangeSelection, ServiceInsightViewModel viewModel, boolean hadError) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package apu.saerok_admin.web.serviceinsight;

import apu.saerok_admin.web.view.ServiceInsightViewModel;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.LocalDate;

public record ServiceInsightAjaxResponse(
@JsonProperty("viewModel") ServiceInsightViewModel viewModel,
@JsonProperty("selectedRange") String selectedRange,
@JsonProperty("customRangeActive") boolean customRangeActive,
@JsonProperty("startDate") LocalDate startDate,
@JsonProperty("endDate") LocalDate endDate,
@JsonProperty("error") boolean error
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package apu.saerok_admin.web.serviceinsight;

import java.time.LocalDate;

public record ServiceInsightQuery(LocalDate startDate, LocalDate endDate) {

public static ServiceInsightQuery all() {
return new ServiceInsightQuery(null, null);
}

public boolean hasRange() {
return startDate != null && endDate != null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package apu.saerok_admin.web.serviceinsight;

import java.time.LocalDate;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Optional;

public enum ServiceInsightRangePreset {

LAST_7_DAYS("recent-7", "최근 1주", 7),
LAST_14_DAYS("recent-14", "최근 2주", 14),
LAST_30_DAYS("recent-30", "최근 1달", 30),
ALL("all", "전체", null),
CUSTOM("custom", "사용자 지정", null);

private final String paramValue;
private final String displayLabel;
private final Integer days;

ServiceInsightRangePreset(String paramValue, String displayLabel, Integer days) {
this.paramValue = paramValue;
this.displayLabel = displayLabel;
this.days = days;
}

public String paramValue() {
return paramValue;
}

public String displayLabel() {
return displayLabel;
}

public boolean isCustom() {
return this == CUSTOM;
}

public boolean isAll() {
return this == ALL;
}

public Optional<RangeWindow> toWindow(LocalDate today) {
if (days == null) {
return Optional.empty();
}
if (today == null) {
return Optional.empty();
}
if (days <= 0) {
return Optional.empty();
}
LocalDate endDate = today;
LocalDate startDate = today.minusDays(days - 1L);
return Optional.of(new RangeWindow(startDate, endDate));
}

public static Optional<ServiceInsightRangePreset> fromParameter(String parameter) {
if (parameter == null || parameter.isBlank()) {
return Optional.empty();
}
String normalized = parameter.trim().toLowerCase(Locale.ROOT);
return Arrays.stream(values())
.filter(preset -> preset.paramValue.equalsIgnoreCase(normalized) || preset.name().equalsIgnoreCase(normalized))
.findFirst();
}

public static List<ServiceInsightRangePreset> quickSelections() {
return List.of(LAST_7_DAYS, LAST_14_DAYS, LAST_30_DAYS, ALL);
}

public record RangeWindow(LocalDate startDate, LocalDate endDate) {
}
}
Loading