Improve mobile car monitor layout
This commit is contained in:
@@ -15,6 +15,7 @@ Car는 차량 모뎀/게이트웨이에서 받은 상태를 수집해 DB에 저
|
|||||||
- 허용된 차량 명령 TCP 전송
|
- 허용된 차량 명령 TCP 전송
|
||||||
- monitor 화면과 AJAX 상태 갱신
|
- monitor 화면과 AJAX 상태 갱신
|
||||||
- monitor 화면 WakeLock, 언어, 테마 토글
|
- monitor 화면 WakeLock, 언어, 테마 토글
|
||||||
|
- 모바일 폭에 맞춘 사용량/로그 가로 스크롤과 시간 문구 축약
|
||||||
- 사용량/요금 표시와 보정 metadata
|
- 사용량/요금 표시와 보정 metadata
|
||||||
- 4.95초 UI timeout과 60초 사용량 정산 분리
|
- 4.95초 UI timeout과 60초 사용량 정산 분리
|
||||||
- TCP 실패 reason과 마지막 수신 지연 표시
|
- TCP 실패 reason과 마지막 수신 지연 표시
|
||||||
@@ -37,6 +38,8 @@ Car는 차량 모뎀/게이트웨이에서 받은 상태를 수집해 DB에 저
|
|||||||
|
|
||||||
상태 기록 섹션의 경과는 각 로그가 기록된 시각부터 현재까지 흐른 시간입니다. 각 행의 `age_seconds`는 서버 현재시각과 로그 timestamp의 차이로 계산해 내려주며, 화면 갱신마다 상단 조회 주기 경과처럼 계속 증가합니다.
|
상태 기록 섹션의 경과는 각 로그가 기록된 시각부터 현재까지 흐른 시간입니다. 각 행의 `age_seconds`는 서버 현재시각과 로그 timestamp의 차이로 계산해 내려주며, 화면 갱신마다 상단 조회 주기 경과처럼 계속 증가합니다.
|
||||||
|
|
||||||
|
모바일처럼 폭이 좁은 화면에서는 조회 경과와 시동 유지 시간이 한 줄로 유지되도록 초, 필요 시 분 단위를 생략해 표시합니다. 사용량 지표와 상태 기록 테이블은 카드 내부에서 좌우 스크롤되도록 분리해 전체 페이지 레이아웃이 밀리지 않게 합니다.
|
||||||
|
|
||||||
## Monitor 화면 제어
|
## Monitor 화면 제어
|
||||||
|
|
||||||
헤더 우측 컨트롤은 WakeLock, 언어, 테마 순서입니다. WakeLock은 Screen Wake Lock API를 지원하는 브라우저에서 화면 꺼짐을 방지하며, 활성 상태는 초록색 아이콘 버튼으로 표시합니다. 사용자의 선택은 `localStorage`에 저장하고, 새로고침이나 탭 복귀 후에는 브라우저 정책에 맞춰 다시 획득을 시도합니다.
|
헤더 우측 컨트롤은 WakeLock, 언어, 테마 순서입니다. WakeLock은 Screen Wake Lock API를 지원하는 브라우저에서 화면 꺼짐을 방지하며, 활성 상태는 초록색 아이콘 버튼으로 표시합니다. 사용자의 선택은 `localStorage`에 저장하고, 새로고침이나 탭 복귀 후에는 브라우저 정책에 맞춰 다시 획득을 시도합니다.
|
||||||
|
|||||||
+155
-12
@@ -773,6 +773,9 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
|
|||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
overflow: hidden; /* Fix for header background corner bleeding */
|
overflow: hidden; /* Fix for header background corner bleeding */
|
||||||
}
|
}
|
||||||
|
.glass-card > * {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Headers & Typography */
|
/* Headers & Typography */
|
||||||
.dashboard-header {
|
.dashboard-header {
|
||||||
@@ -866,7 +869,13 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
|
|||||||
@keyframes spin-slow { 100% { transform: translate(-50%, -50%) rotate(360deg); } }
|
@keyframes spin-slow { 100% { transform: translate(-50%, -50%) rotate(360deg); } }
|
||||||
|
|
||||||
/* Log Table Styling */
|
/* Log Table Styling */
|
||||||
.table-container { width: 100%; border-collapse: separate; border-spacing: 0 5px; }
|
.table-scroll {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
.table-container { width: 100%; min-width: 720px; border-collapse: separate; border-spacing: 0 5px; }
|
||||||
.table-container th {
|
.table-container th {
|
||||||
font-size: 0.7rem; color: var(--table-head-text); font-weight: 700;
|
font-size: 0.7rem; color: var(--table-head-text); font-weight: 700;
|
||||||
padding: 0 10px 10px 10px; text-align: center; border: none;
|
padding: 0 10px 10px 10px; text-align: center; border: none;
|
||||||
@@ -906,6 +915,13 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
|
|||||||
padding: 3px 10px; border-radius: 6px;
|
padding: 3px 10px; border-radius: 6px;
|
||||||
font-size: 0.75rem; font-weight: 600; min-width: 90px;
|
font-size: 0.75rem; font-weight: 600; min-width: 90px;
|
||||||
}
|
}
|
||||||
|
.duration-line {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-text {
|
.detail-text {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -980,6 +996,10 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
|
|||||||
}
|
}
|
||||||
.usage-main {
|
.usage-main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
.usage-meter {
|
.usage-meter {
|
||||||
height: 11px;
|
height: 11px;
|
||||||
@@ -1008,6 +1028,7 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, minmax(100px, 1fr));
|
grid-template-columns: repeat(6, minmax(100px, 1fr));
|
||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
|
min-width: 660px;
|
||||||
}
|
}
|
||||||
.usage-metric {
|
.usage-metric {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -1039,19 +1060,83 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
|
|||||||
.usage-metrics {
|
.usage-metrics {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 575.98px) {
|
@media (max-width: 575.98px) {
|
||||||
|
.container {
|
||||||
|
max-width: 100%;
|
||||||
|
padding-left: 0.9rem;
|
||||||
|
padding-right: 0.9rem;
|
||||||
|
}
|
||||||
|
.dashboard-header {
|
||||||
|
gap: 0.8rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.dashboard-header > .d-flex:last-child {
|
||||||
|
gap: 0.45rem !important;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.btn-icon-toggle {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
.glass-card.h-100.p-4 {
|
||||||
|
padding: 1rem !important;
|
||||||
|
}
|
||||||
|
.value-text {
|
||||||
|
font-size: 1.55rem;
|
||||||
|
}
|
||||||
|
.icon-box {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 13px;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
.usage-card {
|
.usage-card {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
.usage-metrics {
|
.usage-head {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
gap: 0.75rem;
|
||||||
gap: 0.45rem;
|
|
||||||
}
|
}
|
||||||
|
.usage-stat .d-flex {
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
}
|
||||||
|
.usage-main {
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.usage-metrics {
|
||||||
|
grid-template-columns: repeat(6, minmax(92px, 1fr));
|
||||||
|
gap: 0.45rem;
|
||||||
|
min-width: 600px;
|
||||||
|
}
|
||||||
|
.usage-metric .label-text { font-size: 0.64rem; }
|
||||||
|
.usage-metric .font-mono { font-size: 0.88rem; }
|
||||||
.usage-detail-line {
|
.usage-detail-line {
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
.table-scroll {
|
||||||
|
max-height: 52vh;
|
||||||
|
}
|
||||||
|
.table-container {
|
||||||
|
min-width: 660px;
|
||||||
|
}
|
||||||
|
.table-container th {
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
.table-container td {
|
||||||
|
padding: 9px 8px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.cmd-pill {
|
||||||
|
min-width: 78px;
|
||||||
|
padding-left: 8px;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
.detail-text {
|
||||||
|
max-width: 140px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -1110,7 +1195,7 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
|
|||||||
<div class="d-flex align-items-center gap-2 mb-2">
|
<div class="d-flex align-items-center gap-2 mb-2">
|
||||||
<span id="val-engine" class="value-text text-secondary">OFF</span>
|
<span id="val-engine" class="value-text text-secondary">OFF</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="val-state-timer" class="font-mono text-secondary mb-2" style="font-size: 0.85rem;">-</div>
|
<div id="val-state-timer" class="font-mono text-secondary mb-2 duration-line" style="font-size: 0.85rem;">-</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1133,7 +1218,7 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
|
|||||||
<div class="icon-box c-green"><i class="fas fa-signal"></i></div>
|
<div class="icon-box c-green"><i class="fas fa-signal"></i></div>
|
||||||
<div class="text-end">
|
<div class="text-end">
|
||||||
<div class="label-text mb-0" style="font-size:0.65rem;" data-i18n="age">AGE</div>
|
<div class="label-text mb-0" style="font-size:0.65rem;" data-i18n="age">AGE</div>
|
||||||
<div id="age-seconds" class="font-mono fw-bold text-muted">-</div>
|
<div id="age-seconds" class="font-mono fw-bold text-muted duration-line">-</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="label-text mt-1" data-i18n="pollTarget">Poll Target</div>
|
<div class="label-text mt-1" data-i18n="pollTarget">Poll Target</div>
|
||||||
@@ -1334,7 +1419,7 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
|
|||||||
<h6 class="mb-0 fw-bold small text-uppercase text-secondary"><i class="fas fa-list-ul me-2"></i><span data-i18n="statusLog">Status Log</span></h6>
|
<h6 class="mb-0 fw-bold small text-uppercase text-secondary"><i class="fas fa-list-ul me-2"></i><span data-i18n="statusLog">Status Log</span></h6>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3 flex-grow-1">
|
<div class="p-3 flex-grow-1">
|
||||||
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
|
<div class="table-responsive table-scroll">
|
||||||
<table class="table-container">
|
<table class="table-container">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -1514,6 +1599,8 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
|
|||||||
let usageInFlight = false;
|
let usageInFlight = false;
|
||||||
let wakeLockSentinel = null;
|
let wakeLockSentinel = null;
|
||||||
let wakeLockWanted = false;
|
let wakeLockWanted = false;
|
||||||
|
let latestDashboardData = null;
|
||||||
|
let latestDashboardMeta = null;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
initLanguage();
|
initLanguage();
|
||||||
@@ -1523,6 +1610,7 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
|
|||||||
fetchData();
|
fetchData();
|
||||||
refreshTimer = setInterval(fetchData, 1000);
|
refreshTimer = setInterval(fetchData, 1000);
|
||||||
fetchUsage();
|
fetchUsage();
|
||||||
|
window.addEventListener('resize', debounce(updateResponsiveDurations, 150));
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
@@ -1567,6 +1655,7 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
|
|||||||
langBtn.setAttribute('aria-label', t('languageTitle'));
|
langBtn.setAttribute('aria-label', t('languageTitle'));
|
||||||
}
|
}
|
||||||
updateWakeLockButton();
|
updateWakeLockButton();
|
||||||
|
updateResponsiveDurations();
|
||||||
}
|
}
|
||||||
|
|
||||||
function initTheme() {
|
function initTheme() {
|
||||||
@@ -1786,10 +1875,12 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
|
|||||||
const age = Number(meta.age_seconds ?? 0);
|
const age = Number(meta.age_seconds ?? 0);
|
||||||
const isStale = Boolean(meta.stale ?? age > 30);
|
const isStale = Boolean(meta.stale ?? age > 30);
|
||||||
const isReceiveBad = age >= 60;
|
const isReceiveBad = age >= 60;
|
||||||
|
latestDashboardData = data;
|
||||||
|
latestDashboardMeta = meta;
|
||||||
updateLiveBadge(true, isStale);
|
updateLiveBadge(true, isStale);
|
||||||
|
|
||||||
document.getElementById('last-updated').textContent = data.ts ? String(data.ts).substring(11) : '--:--:--';
|
document.getElementById('last-updated').textContent = data.ts ? String(data.ts).substring(11) : '--:--:--';
|
||||||
document.getElementById('age-seconds').textContent = formatAgeUnits(age);
|
document.getElementById('age-seconds').textContent = formatResponsiveDuration(age, 'age');
|
||||||
document.getElementById('val-poll-interval').textContent = meta.poll_interval ?? '-';
|
document.getElementById('val-poll-interval').textContent = meta.poll_interval ?? '-';
|
||||||
const freshnessEl = document.getElementById('val-freshness');
|
const freshnessEl = document.getElementById('val-freshness');
|
||||||
freshnessEl.textContent = isReceiveBad ? t('dataDelayed') : t('targetInterval');
|
freshnessEl.textContent = isReceiveBad ? t('dataDelayed') : t('targetInterval');
|
||||||
@@ -1828,15 +1919,27 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
|
|||||||
visualHeadlight.classList.remove('on');
|
visualHeadlight.classList.remove('on');
|
||||||
}
|
}
|
||||||
|
|
||||||
const duration = formatDuration(Number(meta.state_duration || 0));
|
updateEngineDuration(data, meta);
|
||||||
|
|
||||||
|
updateRemote(data);
|
||||||
|
updateDoors(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateResponsiveDurations() {
|
||||||
|
if (!latestDashboardData || !latestDashboardMeta) return;
|
||||||
|
|
||||||
|
document.getElementById('age-seconds').textContent =
|
||||||
|
formatResponsiveDuration(Number(latestDashboardMeta.age_seconds ?? 0), 'age');
|
||||||
|
updateEngineDuration(latestDashboardData, latestDashboardMeta);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEngineDuration(data, meta) {
|
||||||
|
const duration = formatResponsiveDuration(Number(meta.state_duration || 0), 'state');
|
||||||
const isEngineOnForLabel = parseInt(data.engine) === 1;
|
const isEngineOnForLabel = parseInt(data.engine) === 1;
|
||||||
const sentenceKey = isEngineOnForLabel ? 'engineStateOnSentence' : 'engineStateOffSentence';
|
const sentenceKey = isEngineOnForLabel ? 'engineStateOnSentence' : 'engineStateOffSentence';
|
||||||
const sentence = t(sentenceKey).replace('{duration}', `<span class="fw-bold">${duration}</span>`);
|
const sentence = t(sentenceKey).replace('{duration}', `<span class="fw-bold">${duration}</span>`);
|
||||||
|
|
||||||
document.getElementById('val-state-timer').innerHTML = sentence;
|
document.getElementById('val-state-timer').innerHTML = sentence;
|
||||||
|
|
||||||
updateRemote(data);
|
|
||||||
updateDoors(data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateUsage(usage) {
|
function updateUsage(usage) {
|
||||||
@@ -2011,6 +2114,38 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
|
|||||||
return '+' + Math.floor(sec / 3600) + t('hourShort');
|
return '+' + Math.floor(sec / 3600) + t('hourShort');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compactDurationMode() {
|
||||||
|
const width = window.innerWidth || document.documentElement.clientWidth || 1024;
|
||||||
|
if (width <= 380) return 'tight';
|
||||||
|
if (width <= 575) return 'compact';
|
||||||
|
return 'full';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResponsiveDuration(sec, context = 'state') {
|
||||||
|
const mode = compactDurationMode();
|
||||||
|
if (mode === 'full') {
|
||||||
|
return context === 'age' ? formatAgeUnits(sec) : formatDuration(sec);
|
||||||
|
}
|
||||||
|
|
||||||
|
sec = Math.max(0, Number(sec || 0));
|
||||||
|
const d = Math.floor(sec / 86400);
|
||||||
|
const h = Math.floor((sec % 86400) / 3600);
|
||||||
|
const m = Math.floor((sec % 3600) / 60);
|
||||||
|
const s = Math.floor(sec % 60);
|
||||||
|
|
||||||
|
if (mode === 'tight') {
|
||||||
|
if (d > 0) return d + t('dayShort');
|
||||||
|
if (h > 0) return h + t('hourShort');
|
||||||
|
if (m > 0) return m + t('minShort');
|
||||||
|
return s + t('secShort');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d > 0) return d + t('dayShort') + (h > 0 ? ' ' + h + t('hourShort') : '');
|
||||||
|
if (h > 0) return h + t('hourShort');
|
||||||
|
if (m > 0) return m + t('minShort');
|
||||||
|
return s + t('secShort');
|
||||||
|
}
|
||||||
|
|
||||||
function formatDuration(sec) {
|
function formatDuration(sec) {
|
||||||
sec = Math.max(0, Number(sec || 0));
|
sec = Math.max(0, Number(sec || 0));
|
||||||
|
|
||||||
@@ -2060,6 +2195,14 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
|
|||||||
return Math.max(0, Number(sec || 0)) + t('secShort');
|
return Math.max(0, Number(sec || 0)) + t('secShort');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function debounce(fn, wait) {
|
||||||
|
let timer = null;
|
||||||
|
return function (...args) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => fn.apply(this, args), wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
return String(value)
|
return String(value)
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
|
|||||||
Reference in New Issue
Block a user