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
23 changes: 18 additions & 5 deletions src/main/java/apu/saerok_admin/web/ServiceInsightController.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import apu.saerok_admin.web.view.ToastMessage;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.util.ArrayList;
import java.util.List;
import org.slf4j.Logger;
Expand All @@ -27,7 +29,10 @@ public class ServiceInsightController {

public ServiceInsightController(ServiceInsightService serviceInsightService, ObjectMapper objectMapper) {
this.serviceInsightService = serviceInsightService;
this.objectMapper = objectMapper;
// ObjectMapper 설정 강화
this.objectMapper = objectMapper.copy()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}

@GetMapping("/service-insight")
Expand All @@ -43,6 +48,8 @@ public String serviceInsight(Model 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={}",
Expand All @@ -59,7 +66,11 @@ public String serviceInsight(Model model) {
}

model.addAttribute("serviceInsight", viewModel);
model.addAttribute("chartDataJson", toJson(viewModel));
String chartDataJson = toJson(viewModel);
model.addAttribute("chartDataJson", chartDataJson);

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

return "service-insight/index";
}

Expand Down Expand Up @@ -92,10 +103,12 @@ private void attachErrorToast(Model model) {

private String toJson(ServiceInsightViewModel viewModel) {
try {
return objectMapper.writeValueAsString(viewModel);
String json = objectMapper.writeValueAsString(viewModel);
log.debug("Serialized view model to JSON successfully");
return json;
} catch (JsonProcessingException exception) {
log.warn("Failed to serialize service insight payload.", exception);
log.error("Failed to serialize service insight payload.", exception);
return "{\"metricOptions\":[],\"series\":[],\"componentLabels\":{}}";
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,42 +1,43 @@
package apu.saerok_admin.web.view;

import apu.saerok_admin.infra.stat.StatMetric.MetricUnit;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;

public record ServiceInsightViewModel(
List<MetricOption> metricOptions,
List<Series> series,
Map<String, Map<String, String>> componentLabels
@JsonProperty("metricOptions") List<MetricOption> metricOptions,
@JsonProperty("series") List<Series> series,
@JsonProperty("componentLabels") Map<String, Map<String, String>> componentLabels
) {

public record MetricOption(
String metric,
String label,
String description,
MetricUnit unit,
boolean multiSeries,
boolean defaultActive
@JsonProperty("metric") String metric,
@JsonProperty("label") String label,
@JsonProperty("description") String description,
@JsonProperty("unit") MetricUnit unit,
@JsonProperty("multiSeries") boolean multiSeries,
@JsonProperty("defaultActive") boolean defaultActive
) {
}

public record Series(
String metric,
List<Point> points,
List<ComponentSeries> components
@JsonProperty("metric") String metric,
@JsonProperty("points") List<Point> points,
@JsonProperty("components") List<ComponentSeries> components
) {
}

public record Point(
LocalDate date,
double value
@JsonProperty("date") LocalDate date,
@JsonProperty("value") double value
) {
}

public record ComponentSeries(
String key,
List<Point> points
@JsonProperty("key") String key,
@JsonProperty("points") List<Point> points
) {
}
}
}
41 changes: 41 additions & 0 deletions src/main/resources/static/css/service-insight.mobile.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/* ===== Service Insight: 모바일 전용 오버라이드 =====
- 사이드바 숨김, DnD 힌트 제거, 차트 컴팩트, FAB(모바일만) 등
*/

.service-insight__container { height: auto !important; overflow: visible !important; }
.service-insight__metrics,
.service-insight__chart { height: auto !important; min-height: 0 !important; overflow: visible !important; }

/* 사이드바는 모바일에서 숨김(바텀시트 이용) */
.service-insight__metrics { display: none !important; }

/* DnD affordance 제거 */
.service-insight .si-plot__datasets,
.service-insight .si-dropzone { display: none !important; }

/* 차트 높이 컴팩트 */
.si-plots .si-plot__surface { height: 280px !important; }

/* FAB: 모바일에서만 표출 */
#si-fab {
display: inline-flex !important;
position: fixed; right: max(16px, env(safe-area-inset-right));
bottom: calc(max(16px, env(safe-area-inset-bottom)) + 8px);
width: 56px; height: 56px; z-index: 1055;
align-items: center; justify-content: center;
border-radius: 50%; padding: 0; line-height: 1;
}

/* 바텀시트 */
.offcanvas.offcanvas-bottom#siDataPicker {
height: 75vh; border-top-left-radius: 1rem; border-top-right-radius: 1rem;
box-shadow: var(--shadow-overlay);
}
.offcanvas.offcanvas-bottom#siDataPicker .offcanvas-header { padding: 0.75rem 1rem; }
.offcanvas.offcanvas-bottom#siDataPicker .offcanvas-body { padding: 0.5rem 1rem 1rem; overflow: auto; }

/* 툴바 줄바꿈 */
.si-plots__toolbar .si-actions { display: flex; gap: .5rem; flex-wrap: wrap; }

/* 안전 여백 */
.service-insight { padding-bottom: max(72px, env(safe-area-inset-bottom)); }
111 changes: 111 additions & 0 deletions src/main/resources/static/css/service-insight.target.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/* ===== resources/static/css/service-insight.target.css ===== */
/* ===== 전역(PC/모바일 공통) ===== */

/* FAB는 PC 기본 숨김, 모바일 전용 CSS에서만 표출 */
#si-fab { display: none !important; }

/* "플롯 n 데이터 선택" – 인라인 타이틀 */
.si-targettitle {
display: inline-flex;
align-items: center;
gap: .55rem;
font-size: 1rem;
font-weight: 800;
letter-spacing: -0.2px;
}
.si-targettitle__prefix,
.si-targettitle__suffix { font-weight: 800; }

/* 숫자 Select: 클릭감 강화 */
.si-targettitle__select {
-webkit-appearance: none;
appearance: none;
cursor: pointer;
min-width: 4.5rem;
width: auto;
border-radius: 999px;
padding-inline: 1.25rem 2rem; /* 오른쪽 캐럿 공간 */
padding-block: .35rem;
line-height: 1.2;
border: 1px solid var(--border-strong, #c4cbd3);
background:
linear-gradient(to bottom, rgba(0,0,0,0.02), rgba(0,0,0,0)) padding-box,
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 16 16'%3E%3Cpath fill='%23666' d='M3.2 6l4.8 5L12.8 6z'/%3E%3C/svg%3E")
no-repeat right .6rem center/12px 12px;
box-shadow: 0 1px 0 rgba(0,0,0,.02);
transition: box-shadow .15s ease, transform .04s ease, border-color .15s ease;
font-weight: 700;
}
.si-targettitle__select:hover {
border-color: var(--accent, #0d6efd);
box-shadow: 0 0 0 .2rem rgba(13,110,253,.12);
}
.si-targettitle__select:active { transform: translateY(1px); }
.si-targettitle__select:focus {
outline: none;
border-color: var(--accent, #0d6efd);
box-shadow: 0 0 0 .24rem rgba(13,110,253,.16);
}

/* 데이터 패널 레이아웃 */
.si-data-panel { display: flex; flex-direction: column; gap: .75rem; }

/* ========== ✅ 체크 인디케이터: "오른쪽 단일"로 통일 ========== */
/* (1) 좌측 체크를 만들던 예전 커스텀 규칙들 강제 무력화 */
.si-data-panel .si-chip::before,
.si-data-panel [data-si-chip]::before,
.si-data-panel .si-chip--toggle::before { content: none !important; }

/* (2) 우측 ::after 하나만 사용 (PC/모바일 공통) */
.si-data-panel .si-chip,
.si-data-panel [data-si-chip],
.si-data-panel .si-chip--toggle {
position: relative;
padding-right: 28px; /* 오른쪽 체크 공간 확보 */
}

.si-data-panel .si-chip::after,
.si-data-panel [data-si-chip]::after,
.si-data-panel .si-chip--toggle::after {
content: "";
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 16px; height: 16px;
border-radius: 999px;
border: 1.5px solid var(--border-subtle, #cfd4d9);
background: var(--surface-primary, #fff);
pointer-events: none; /* 클릭 간섭 방지 */
transition: background-color .12s ease, border-color .12s ease, box-shadow .12s ease;
}

/* (3) 활성 조건: .is-active 또는 [data-checked="true"] 중 하나라도 true면 체크 표시 */
.si-data-panel .si-chip.is-active::after,
.si-data-panel [data-si-chip].is-active::after,
.si-data-panel .si-chip--toggle.is-active::after,
.si-data-panel .si-chip[data-checked="true"]::after,
.si-data-panel [data-si-chip][data-checked="true"]::after,
.si-data-panel .si-chip--toggle[data-checked="true"]::after {
content: "✓";
font-weight: 700;
font-size: 12px;
text-align: center;
line-height: 16px;
color: var(--accent-contrast, #fff);
background: var(--accent, #0d6efd);
border-color: var(--accent, #0d6efd);
box-shadow: 0 0 0 2px rgba(13,110,253,.15);
}

/* 키보드 포커스 접근성 */
.si-chip.si-chip--toggle:focus-visible {
outline: none;
box-shadow: 0 0 0 .2rem rgba(59,130,246,.20);
border-color: rgba(59,130,246,.35);
}

/* 모바일 폰트 톤 조정 */
@media (max-width: 991.98px) {
.si-targettitle { font-size: 1.05rem; }
}
Loading