diff --git a/src/main/java/apu/saerok_admin/infra/stat/AdminStatClient.java b/src/main/java/apu/saerok_admin/infra/stat/AdminStatClient.java index cde2d0f..5d43716 100644 --- a/src/main/java/apu/saerok_admin/infra/stat/AdminStatClient.java +++ b/src/main/java/apu/saerok_admin/infra/stat/AdminStatClient.java @@ -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; @@ -23,13 +25,15 @@ public AdminStatClient(RestClient saerokRestClient, SaerokApiProps saerokApiProp this.missingPrefixSegments = saerokApiProps.missingPrefixSegments().toArray(new String[0]); } - public StatSeriesResponse fetchSeries(Collection metrics) { + private static final DateTimeFormatter ISO_DATE = DateTimeFormatter.ISO_DATE; + + public StatSeriesResponse fetchSeries(Collection 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); @@ -40,7 +44,7 @@ public StatSeriesResponse fetchSeries(Collection metrics) { return response; } - private URI buildSeriesUri(UriBuilder builder, Collection metrics) { + private URI buildSeriesUri(UriBuilder builder, Collection metrics, LocalDate startDate, LocalDate endDate) { if (missingPrefixSegments.length > 0) { builder.pathSegment(missingPrefixSegments); } @@ -52,6 +56,16 @@ private URI buildSeriesUri(UriBuilder builder, Collection 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; + } } diff --git a/src/main/java/apu/saerok_admin/web/ServiceInsightController.java b/src/main/java/apu/saerok_admin/web/ServiceInsightController.java index 5e7ba39..357fa67 100644 --- a/src/main/java/apu/saerok_admin/web/ServiceInsightController.java +++ b/src/main/java/apu/saerok_admin/web/ServiceInsightController.java @@ -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; @@ -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; @@ -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; @@ -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( @@ -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 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, @@ -111,4 +221,10 @@ private String toJson(ServiceInsightViewModel viewModel) { return "{\"metricOptions\":[],\"series\":[],\"componentLabels\":{}}"; } } -} \ No newline at end of file + + private record RangeSelection(ServiceInsightRangePreset preset, ServiceInsightQuery query) { + } + + private record PageData(RangeSelection rangeSelection, ServiceInsightViewModel viewModel, boolean hadError) { + } +} diff --git a/src/main/java/apu/saerok_admin/web/serviceinsight/ServiceInsightAjaxResponse.java b/src/main/java/apu/saerok_admin/web/serviceinsight/ServiceInsightAjaxResponse.java new file mode 100644 index 0000000..5076eb2 --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/serviceinsight/ServiceInsightAjaxResponse.java @@ -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 +) { +} diff --git a/src/main/java/apu/saerok_admin/web/serviceinsight/ServiceInsightQuery.java b/src/main/java/apu/saerok_admin/web/serviceinsight/ServiceInsightQuery.java new file mode 100644 index 0000000..747310d --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/serviceinsight/ServiceInsightQuery.java @@ -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; + } +} diff --git a/src/main/java/apu/saerok_admin/web/serviceinsight/ServiceInsightRangePreset.java b/src/main/java/apu/saerok_admin/web/serviceinsight/ServiceInsightRangePreset.java new file mode 100644 index 0000000..ff2e234 --- /dev/null +++ b/src/main/java/apu/saerok_admin/web/serviceinsight/ServiceInsightRangePreset.java @@ -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 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 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 quickSelections() { + return List.of(LAST_7_DAYS, LAST_14_DAYS, LAST_30_DAYS, ALL); + } + + public record RangeWindow(LocalDate startDate, LocalDate endDate) { + } +} diff --git a/src/main/java/apu/saerok_admin/web/serviceinsight/ServiceInsightService.java b/src/main/java/apu/saerok_admin/web/serviceinsight/ServiceInsightService.java index 1f1a3af..693ef64 100644 --- a/src/main/java/apu/saerok_admin/web/serviceinsight/ServiceInsightService.java +++ b/src/main/java/apu/saerok_admin/web/serviceinsight/ServiceInsightService.java @@ -8,6 +8,7 @@ import apu.saerok_admin.web.view.ServiceInsightViewModel.MetricOption; import apu.saerok_admin.web.view.ServiceInsightViewModel.Point; import apu.saerok_admin.web.view.ServiceInsightViewModel.Series; +import java.time.LocalDate; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -27,7 +28,13 @@ public ServiceInsightService(AdminStatClient adminStatClient) { } public ServiceInsightViewModel loadViewModel() { - StatSeriesResponse response = adminStatClient.fetchSeries(List.of(StatMetric.values())); + return loadViewModel(ServiceInsightQuery.all()); + } + + public ServiceInsightViewModel loadViewModel(ServiceInsightQuery query) { + LocalDate startDate = query != null ? query.startDate() : null; + LocalDate endDate = query != null ? query.endDate() : null; + StatSeriesResponse response = adminStatClient.fetchSeries(List.of(StatMetric.values()), startDate, endDate); return buildViewModel(response); } diff --git a/src/main/resources/static/css/service-insight.css b/src/main/resources/static/css/service-insight.css index 56134dc..fa342cd 100644 --- a/src/main/resources/static/css/service-insight.css +++ b/src/main/resources/static/css/service-insight.css @@ -22,6 +22,92 @@ /* 플롯 영역 여백 + 포커스 그림자 */ .service-insight .service-insight__chart { margin-left: 16px; } +.service-insight .si-plots__toolbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} +.service-insight .si-range { + display: flex; + flex-direction: column; + gap: .5rem; + margin-right: auto; +} +.service-insight .si-range__bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: .5rem; +} +.service-insight .si-range__label { + font-size: .82rem; + font-weight: 600; + color: #334155; + letter-spacing: .02em; + text-transform: uppercase; +} +.service-insight .si-range__quick { + display: flex; + flex-wrap: wrap; + gap: .35rem; +} +.service-insight .si-range__btn { + border-radius: 999px; + padding-inline: .85rem; + font-weight: 500; + transition: color .18s ease, background-color .18s ease, box-shadow .18s ease; +} +.service-insight .si-range__custom-toggle { + border-radius: 999px; + display: inline-flex; + align-items: center; + gap: .35rem; + transition: color .18s ease, background-color .18s ease, box-shadow .18s ease; +} +.service-insight .si-range__btn:hover, +.service-insight .si-range__btn:focus-visible, +.service-insight .si-range__custom-toggle:hover, +.service-insight .si-range__custom-toggle:focus-visible { + box-shadow: 0 4px 12px rgba(59, 130, 246, .18); +} +.service-insight .si-range__form { + display: none; + align-items: center; + gap: .75rem; + flex-wrap: wrap; + padding: .65rem .75rem; + border-radius: .75rem; + border: 1px solid rgba(148, 163, 184, .45); + background: rgba(248, 250, 252, .9); +} +.service-insight .si-range__form.is-open { + display: flex; +} +.service-insight .si-range__fields { + display: flex; + align-items: center; + gap: .5rem; + flex-wrap: wrap; +} +.service-insight .si-range__fields .form-control { + min-width: 9rem; +} +.service-insight .si-range__dash { + font-weight: 600; + color: #64748b; +} +.service-insight .si-range__actions { + display: flex; + align-items: center; + gap: .5rem; +} +.service-insight .si-range__apply { + border-radius: 999px; + padding-inline: 1.1rem; + font-weight: 600; +} .service-insight .si-plot-card { transition: box-shadow .22s ease, border-color .22s ease, transform .12s ease; will-change: box-shadow, transform; } .service-insight .si-plot-card.si-plot--active{ box-shadow: diff --git a/src/main/resources/static/css/service-insight.mobile.css b/src/main/resources/static/css/service-insight.mobile.css index 0db8571..e4b4be6 100644 --- a/src/main/resources/static/css/service-insight.mobile.css +++ b/src/main/resources/static/css/service-insight.mobile.css @@ -35,6 +35,21 @@ .offcanvas.offcanvas-bottom#siDataPicker .offcanvas-body { padding: 0.5rem 1rem 1rem; overflow: auto; } /* 툴바 줄바꿈 */ +.si-plots__toolbar { flex-direction: column; align-items: stretch; gap: 1rem; } +.si-range { width: 100%; gap: .75rem; } +.si-range__bar { width: 100%; align-items: stretch; gap: .6rem; } +.si-range__label { font-size: .78rem; letter-spacing: .06em; } +.si-range__quick { flex: 1 1 auto; width: 100%; overflow-x: auto; padding-bottom: .15rem; gap: .5rem; } +.si-range__quick::-webkit-scrollbar { height: 6px; } +.si-range__quick::-webkit-scrollbar-thumb { background: rgba(148,163,184,.55); border-radius: 999px; } +.si-range__btn, +.si-range__custom-toggle { flex: 0 0 auto; } +.si-range__form { width: 100%; flex-direction: column; align-items: stretch; gap: .65rem; } +.si-range__fields { width: 100%; flex-direction: column; align-items: stretch; gap: .65rem; } +.si-range__fields .form-control { width: 100%; min-width: 0; } +.si-range__dash { display: none; } +.si-range__actions { width: 100%; justify-content: stretch; } +.si-range__apply { width: 100%; padding-block: .65rem; } .si-plots__toolbar .si-actions { display: flex; gap: .5rem; flex-wrap: wrap; } /* 안전 여백 */ diff --git a/src/main/resources/static/js/service-insight.js b/src/main/resources/static/js/service-insight.js index f7496cc..771d1e2 100644 --- a/src/main/resources/static/js/service-insight.js +++ b/src/main/resources/static/js/service-insight.js @@ -4,16 +4,58 @@ const dataElement = document.getElementById('serviceInsightData'); if (!dataElement) return; + const dataEndpoint = dataElement.dataset?.endpoint || window.location.pathname; + let payload = {}; try { payload = JSON.parse(dataElement.textContent || '{}'); } catch (err) { console.warn('Failed to parse service insight payload.', err); } - const metricOptions = Array.isArray(payload.metricOptions) ? payload.metricOptions : []; - const seriesList = Array.isArray(payload.series) ? payload.series : []; - const componentLabels = (payload.componentLabels && typeof payload.componentLabels === 'object') ? payload.componentLabels : {}; + let metricOptions = []; + let seriesList = []; + let componentLabels = {}; + let optionMap = new Map(); + let seriesMap = new Map(); + + function applyViewModelPayload(source) { + payload = (source && typeof source === 'object') ? source : {}; + metricOptions = Array.isArray(payload.metricOptions) ? payload.metricOptions : []; + seriesList = Array.isArray(payload.series) ? payload.series : []; + componentLabels = (payload.componentLabels && typeof payload.componentLabels === 'object') ? payload.componentLabels : {}; + optionMap = new Map(metricOptions.map(o => [o.metric, o])); + seriesMap = new Map(seriesList.map(s => [s.metric, s])); + try { + dataElement.textContent = JSON.stringify(payload); + } catch (err) { + console.warn('Failed to cache service insight payload JSON.', err); + } + } + + applyViewModelPayload(payload); + + const rangeQuickContainer = document.querySelector('.si-range__quick'); + const rangeForm = document.querySelector('.si-range__form'); + const rangeStartInput = rangeForm?.querySelector('input[name="startDate"]') || null; + const rangeEndInput = rangeForm?.querySelector('input[name="endDate"]') || null; + const rangeApplyButton = rangeForm?.querySelector('button[type="submit"]') || null; + const rangeCustomToggle = document.getElementById('si-range-custom-toggle'); - const optionMap = new Map(metricOptions.map(o => [o.metric, o])); - const seriesMap = new Map(seriesList.map(s => [s.metric, s])); + function setCustomRangeOpen(open) { + const next = !!open; + if (rangeForm) { + rangeForm.classList.toggle('is-open', next); + } + if (rangeCustomToggle) { + rangeCustomToggle.setAttribute('aria-expanded', next ? 'true' : 'false'); + rangeCustomToggle.classList.toggle('btn-primary', next); + rangeCustomToggle.classList.toggle('btn-outline-secondary', !next); + } + if (rangeApplyButton) { + rangeApplyButton.classList.toggle('btn-primary', next); + rangeApplyButton.classList.toggle('btn-outline-secondary', !next); + } + } + + setCustomRangeOpen(rangeForm ? rangeForm.classList.contains('is-open') : false); // 서버 enum 키 const METRICS = { @@ -238,6 +280,26 @@ // plots: id -> { id, index, el, chart, datasets:Set, groups: Map>, active:boolean } const plots = new Map(); + function refreshAllPlots() { + for (const [plotId, plot] of plots) { + const metrics = [...plot.groups.keys()]; + plot.chart.data.datasets = []; + plot.datasets.clear(); + plot.groups.clear(); + + if (metrics.length === 0) { + renderPlotGroupChips(plotId); + updateEmptyState(plotId); + continue; + } + + metrics.forEach(metric => addMetricGroupToPlot(plotId, metric)); + } + + resizeAllCharts(); + syncAsideActiveStates(); + } + function ensureAtLeastOnePlot() { if (plots.size===0) return createPlot(); return [...plots.keys()][0]; } function getActivePlotId(){ for (const [id,p] of plots){ if (p.active) return id; } return null; } function setActivePlot(id){ @@ -767,6 +829,155 @@ }); } + function updateRangeControlsState(meta) { + if (!meta) return; + + const selectedRange = typeof meta.selectedRange === 'string' ? meta.selectedRange : ''; + const customActive = !!meta.customRangeActive; + const startValue = meta.startDate != null ? String(meta.startDate) : ''; + const endValue = meta.endDate != null ? String(meta.endDate) : ''; + + document.querySelectorAll('.si-range__quick .si-range__btn').forEach(btn => { + const value = btn.getAttribute('data-range') || ''; + const isActive = !customActive && selectedRange && value === selectedRange; + btn.classList.toggle('btn-primary', isActive); + btn.classList.toggle('btn-outline-primary', !isActive); + btn.setAttribute('aria-pressed', isActive ? 'true' : 'false'); + }); + + if (rangeStartInput) rangeStartInput.value = startValue; + if (rangeEndInput) rangeEndInput.value = endValue; + + setCustomRangeOpen(customActive); + } + + function updateBrowserUrl(meta) { + if (!window.history || !window.history.replaceState) return; + const url = new URL(window.location.href); + url.searchParams.delete('range'); + url.searchParams.delete('startDate'); + url.searchParams.delete('endDate'); + + if (meta && typeof meta.selectedRange === 'string' && meta.selectedRange) { + url.searchParams.set('range', meta.selectedRange); + } + + if (meta && meta.customRangeActive) { + if (meta.startDate) url.searchParams.set('startDate', meta.startDate); + if (meta.endDate) url.searchParams.set('endDate', meta.endDate); + } + + const query = url.searchParams.toString(); + const next = url.pathname + (query ? `?${query}` : ''); + window.history.replaceState(null, document.title, next); + } + + async function requestAndApplyRange(target, fallbackHref) { + let url; + try { + url = target instanceof URL ? target : new URL(String(target), window.location.origin || window.location.href); + } catch (error) { + console.warn('Invalid service insight range URL.', error); + if (fallbackHref) window.location.href = fallbackHref; + return; + } + + try { + const response = await fetch(url.toString(), { + method: 'GET', + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin' + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + if (!data || typeof data !== 'object' || !data.viewModel) { + throw new Error('Invalid response payload'); + } + + applyViewModelPayload(data.viewModel); + refreshAllPlots(); + updateRangeControlsState(data); + updateBrowserUrl(data); + + if (data.error) { + console.warn('Service insight stats responded with a fallback dataset.'); + } + } catch (error) { + console.warn('Failed to refresh service insight data without reloading.', error); + if (fallbackHref) { + window.location.href = fallbackHref; + } + } + } + + function setupRangeControls() { + if (rangeQuickContainer) { + rangeQuickContainer.querySelectorAll('.si-range__btn').forEach(btn => { + btn.addEventListener('click', (event) => { + if (event.defaultPrevented) return; + if (event.button !== 0) return; + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + + const href = btn.getAttribute('href'); + if (!href) return; + + event.preventDefault(); + setCustomRangeOpen(false); + const absoluteHref = btn.href || href; + requestAndApplyRange(absoluteHref, absoluteHref); + }); + }); + } + + if (rangeCustomToggle && rangeForm) { + rangeCustomToggle.addEventListener('click', (event) => { + event.preventDefault(); + const currentlyOpen = rangeForm.classList.contains('is-open'); + const next = !currentlyOpen; + setCustomRangeOpen(next); + if (next && rangeStartInput) { + try { rangeStartInput.focus(); } catch (_) {} + } + }); + } + + if (rangeForm) { + rangeForm.addEventListener('submit', (event) => { + if (event.defaultPrevented) return; + event.preventDefault(); + + const base = rangeForm.getAttribute('action') || dataEndpoint || window.location.pathname; + let url; + try { + url = new URL(base, window.location.origin || window.location.href); + } catch (error) { + console.warn('Invalid service insight form action URL.', error); + if (typeof rangeForm.submit === 'function') { + rangeForm.submit(); + } + return; + } + + url.searchParams.set('range', 'custom'); + + const startValue = rangeStartInput?.value ? rangeStartInput.value.trim() : ''; + const endValue = rangeEndInput?.value ? rangeEndInput.value.trim() : ''; + + if (startValue) url.searchParams.set('startDate', startValue); + else url.searchParams.delete('startDate'); + + if (endValue) url.searchParams.set('endDate', endValue); + else url.searchParams.delete('endDate'); + + requestAndApplyRange(url, url.toString()); + }); + } + } + // 컨트롤 if (addPlotBtn) addPlotBtn.addEventListener('click', ()=> createPlot()); if (clearAllBtn) clearAllBtn.addEventListener('click', ()=>{ @@ -785,6 +996,7 @@ // 초기 렌더 renderGroups(); createPlot(); + setupRangeControls(); // 툴팁 초기화 window.addEventListener('load', () => initTooltipsIn(document)); diff --git a/src/main/resources/templates/service-insight/index.html b/src/main/resources/templates/service-insight/index.html index 8d6c022..b260721 100644 --- a/src/main/resources/templates/service-insight/index.html +++ b/src/main/resources/templates/service-insight/index.html @@ -42,6 +42,64 @@
+
+
+ 기간 + + +
+
+ +
+ + + + + +
+
+ +
+
+