관리자 이미지 최적화 대시보드 추가
This commit is contained in:
@@ -705,6 +705,42 @@ async function deleteImageAssets(ids) {
|
|||||||
await query(`DELETE FROM image_assets WHERE id IN (${placeholders})`, uniqueIds)
|
await query(`DELETE FROM image_assets WHERE id IN (${placeholders})`, uniqueIds)
|
||||||
return rows.map(mapImageAssetRow)
|
return rows.map(mapImageAssetRow)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getImageAssetStats() {
|
||||||
|
const [assetRows, jobRows] = await Promise.all([
|
||||||
|
query(
|
||||||
|
`SELECT COUNT(*) AS asset_count, COALESCE(SUM(byte_size), 0) AS total_byte_size, COALESCE(SUM(original_byte_size), 0) AS total_original_byte_size FROM image_assets`
|
||||||
|
),
|
||||||
|
query(
|
||||||
|
`SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'queued' THEN 1 ELSE 0 END), 0) AS queued_count,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END), 0) AS processing_count,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0) AS completed_count,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) AS failed_count,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'completed' AND reused_asset = 1 THEN 1 ELSE 0 END), 0) AS reused_count
|
||||||
|
FROM image_optimization_jobs`
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
const asset = assetRows[0] || {}
|
||||||
|
const jobs = jobRows[0] || {}
|
||||||
|
const totalByteSize = Number(asset.total_byte_size || 0)
|
||||||
|
const totalOriginalByteSize = Number(asset.total_original_byte_size || 0)
|
||||||
|
const savedByteSize = Math.max(0, totalOriginalByteSize - totalByteSize)
|
||||||
|
|
||||||
|
return {
|
||||||
|
assetCount: Number(asset.asset_count || 0),
|
||||||
|
totalByteSize,
|
||||||
|
totalOriginalByteSize,
|
||||||
|
savedByteSize,
|
||||||
|
savingsRatio: totalOriginalByteSize > 0 ? savedByteSize / totalOriginalByteSize : 0,
|
||||||
|
queuedCount: Number(jobs.queued_count || 0),
|
||||||
|
processingCount: Number(jobs.processing_count || 0),
|
||||||
|
completedCount: Number(jobs.completed_count || 0),
|
||||||
|
failedCount: Number(jobs.failed_count || 0),
|
||||||
|
reusedCount: Number(jobs.reused_count || 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
async function createGameItem({ id, gameId, src, label }) {
|
async function createGameItem({ id, gameId, src, label }) {
|
||||||
const createdAt = now()
|
const createdAt = now()
|
||||||
await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [
|
await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [
|
||||||
@@ -1595,6 +1631,7 @@ module.exports = {
|
|||||||
listRecentImageOptimizationJobs,
|
listRecentImageOptimizationJobs,
|
||||||
listUnusedImageAssets,
|
listUnusedImageAssets,
|
||||||
deleteImageAssets,
|
deleteImageAssets,
|
||||||
|
getImageAssetStats,
|
||||||
createGameItem,
|
createGameItem,
|
||||||
updateGameItemLabel,
|
updateGameItemLabel,
|
||||||
deleteGameItem,
|
deleteGameItem,
|
||||||
|
|||||||
@@ -32,9 +32,11 @@ const {
|
|||||||
adminDeleteUser,
|
adminDeleteUser,
|
||||||
listUnusedImageAssets,
|
listUnusedImageAssets,
|
||||||
deleteImageAssets,
|
deleteImageAssets,
|
||||||
|
getImageAssetStats,
|
||||||
|
listRecentImageOptimizationJobs,
|
||||||
} = require('../db')
|
} = require('../db')
|
||||||
const { requireAdmin } = require('../middleware/auth')
|
const { requireAdmin } = require('../middleware/auth')
|
||||||
const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage')
|
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
|
||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
@@ -248,6 +250,18 @@ router.post('/image-assets/cleanup', requireAdmin, async (req, res) => {
|
|||||||
res.json({ deletedCount: deleted.length, assets: deleted })
|
res.json({ deletedCount: deleted.length, assets: deleted })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.get('/image-assets/stats', requireAdmin, async (req, res) => {
|
||||||
|
const [stats, recentJobs] = await Promise.all([
|
||||||
|
getImageAssetStats(),
|
||||||
|
listRecentImageOptimizationJobs(6),
|
||||||
|
])
|
||||||
|
res.json({
|
||||||
|
stats,
|
||||||
|
queue: getImageOptimizationQueueState(),
|
||||||
|
recentJobs,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
async function removeCustomItemFiles(items) {
|
async function removeCustomItemFiles(items) {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
items.map(async (item) => {
|
items.map(async (item) => {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-03-31 v1.3.4
|
||||||
|
- 관리자 API에 이미지 자산 통계 엔드포인트를 추가해 총 자산 수, 현재 용량, 원본 대비 절감 용량/절감률, 작업 누적 상태를 조회할 수 있게 확장함.
|
||||||
|
- 관리자 오른쪽 사이드 하단에 `Image Optimization` 패널을 추가해 큐 상태, 절감 통계, 최근 최적화 작업을 바로 확인할 수 있도록 대시보드를 구성함.
|
||||||
|
- 미사용 자산 정리 API와 작업 기록 큐를 기반으로, 운영 중 이미지 스토리지 상태를 관리자 화면에서 직접 점검할 수 있는 흐름을 완성함.
|
||||||
|
|
||||||
## 2026-03-31 v1.3.3
|
## 2026-03-31 v1.3.3
|
||||||
- `image_assets` 참조를 전수 점검해 아무 곳에서도 사용하지 않는 최적화 이미지 자산만 추려내는 정리 배치 로직을 추가함.
|
- `image_assets` 참조를 전수 점검해 아무 곳에서도 사용하지 않는 최적화 이미지 자산만 추려내는 정리 배치 로직을 추가함.
|
||||||
- 관리자용 미사용 자산 조회/정리 API를 추가해 오래된 고아 이미지 자산을 미리 확인하거나 실제로 삭제할 수 있도록 확장함.
|
- 관리자용 미사용 자산 조회/정리 API를 추가해 오래된 고아 이미지 자산을 미리 확인하거나 실제로 삭제할 수 있도록 확장함.
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ export const api = {
|
|||||||
listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) =>
|
listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) =>
|
||||||
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
|
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
|
||||||
listAdminTemplateRequests: () => request('/api/admin/template-requests'),
|
listAdminTemplateRequests: () => request('/api/admin/template-requests'),
|
||||||
|
getAdminImageAssetStats: () => request('/api/admin/image-assets/stats'),
|
||||||
|
listAdminUnusedImageAssets: ({ limit = 100, minAgeHours = 24 } = {}) => request(`/api/admin/image-assets/orphans?limit=${encodeURIComponent(limit)}&minAgeHours=${encodeURIComponent(minAgeHours)}`),
|
||||||
|
cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }),
|
||||||
promoteAdminCustomItem: (itemId, payload) =>
|
promoteAdminCustomItem: (itemId, payload) =>
|
||||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
|
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
|
||||||
promoteAdminTierListItems: (tierListId, payload) =>
|
promoteAdminTierListItems: (tierListId, payload) =>
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ const modalRoleNextAdmin = ref(false)
|
|||||||
const modalTargetCustomItem = ref(null)
|
const modalTargetCustomItem = ref(null)
|
||||||
|
|
||||||
const users = ref([])
|
const users = ref([])
|
||||||
|
const imageStats = ref(null)
|
||||||
|
const imageQueue = ref({ concurrency: 1, activeCount: 0, pendingCount: 0 })
|
||||||
|
const imageRecentJobs = ref([])
|
||||||
|
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const success = ref('')
|
const success = ref('')
|
||||||
@@ -166,7 +169,7 @@ const adminOverviewStats = computed(() => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await auth.refresh()
|
await auth.refresh()
|
||||||
await Promise.all([refreshGames(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers(), refreshTemplateRequests()])
|
await Promise.all([refreshGames(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers(), refreshTemplateRequests(), refreshImageDiagnostics()])
|
||||||
await syncFeaturedSortable()
|
await syncFeaturedSortable()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -226,6 +229,41 @@ function resetMessages() {
|
|||||||
success.value = ''
|
success.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytes(value) {
|
||||||
|
const size = Number(value || 0)
|
||||||
|
if (!size) return '0 B'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB']
|
||||||
|
let current = size
|
||||||
|
let unitIndex = 0
|
||||||
|
while (current >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
current /= 1024
|
||||||
|
unitIndex += 1
|
||||||
|
}
|
||||||
|
return `${current >= 10 || unitIndex === 0 ? current.toFixed(0) : current.toFixed(1)} ${units[unitIndex]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageDiagnosticsCards = computed(() => {
|
||||||
|
const stats = imageStats.value
|
||||||
|
if (!stats) return []
|
||||||
|
return [
|
||||||
|
{ label: '총 자산', value: `${stats.assetCount || 0}` },
|
||||||
|
{ label: '현재 용량', value: formatBytes(stats.totalByteSize) },
|
||||||
|
{ label: '절감 용량', value: formatBytes(stats.savedByteSize) },
|
||||||
|
{ label: '절감률', value: `${Math.round((stats.savingsRatio || 0) * 100)}%` },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
async function refreshImageDiagnostics() {
|
||||||
|
try {
|
||||||
|
const data = await api.getAdminImageAssetStats()
|
||||||
|
imageStats.value = data.stats || null
|
||||||
|
imageQueue.value = data.queue || { concurrency: 1, activeCount: 0, pendingCount: 0 }
|
||||||
|
imageRecentJobs.value = data.recentJobs || []
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '이미지 최적화 현황을 불러오지 못했어요.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setTab(tab) {
|
function setTab(tab) {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
activeTab.value = tab
|
activeTab.value = tab
|
||||||
@@ -1842,6 +1880,46 @@ async function saveFeaturedOrder() {
|
|||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<section class="adminSidebar__panel">
|
||||||
|
<div class="adminSidebar__label">Image Optimization</div>
|
||||||
|
<div class="adminSidebar__actions">
|
||||||
|
<button class="btn btn--ghost" @click="refreshImageDiagnostics">현황 새로고침</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="imageDiagnosticsCards.length" class="adminSidebar__stats adminSidebar__stats--grid">
|
||||||
|
<article v-for="stat in imageDiagnosticsCards" :key="stat.label" class="sidebarStat">
|
||||||
|
<span class="sidebarStat__label">{{ stat.label }}</span>
|
||||||
|
<strong class="sidebarStat__value">{{ stat.value }}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div class="adminSidebar__stats">
|
||||||
|
<div class="sidebarStat">
|
||||||
|
<span class="sidebarStat__label">큐 상태</span>
|
||||||
|
<strong class="sidebarStat__value">{{ imageQueue.activeCount }} 실행 / {{ imageQueue.pendingCount }} 대기</strong>
|
||||||
|
</div>
|
||||||
|
<div class="sidebarStat">
|
||||||
|
<span class="sidebarStat__label">누적 작업</span>
|
||||||
|
<strong class="sidebarStat__value">{{ imageStats?.completedCount || 0 }} 완료 · {{ imageStats?.failedCount || 0 }} 실패</strong>
|
||||||
|
</div>
|
||||||
|
<div class="sidebarStat">
|
||||||
|
<span class="sidebarStat__label">중복 재사용</span>
|
||||||
|
<strong class="sidebarStat__value">{{ imageStats?.reusedCount || 0 }}건</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="adminSidebar__group">
|
||||||
|
<div class="section__title">최근 최적화 작업</div>
|
||||||
|
<div v-if="!imageRecentJobs.length" class="hint hint--tight">아직 기록된 최적화 작업이 없어요.</div>
|
||||||
|
<div v-else class="imageJobList">
|
||||||
|
<article v-for="job in imageRecentJobs" :key="job.id" class="imageJobRow">
|
||||||
|
<div class="imageJobRow__head">
|
||||||
|
<strong>{{ job.sourceCategory || 'asset' }}</strong>
|
||||||
|
<span class="imageJobRow__status">{{ job.status }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="hint hint--tight">{{ formatBytes(job.originalByteSize) }} → {{ formatBytes(job.optimizedByteSize) }}</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
@@ -1932,6 +2010,38 @@ async function saveFeaturedOrder() {
|
|||||||
rgba(13, 13, 13, 0.94);
|
rgba(13, 13, 13, 0.94);
|
||||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.18);
|
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.adminSidebar__stats--grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageJobList {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageJobRow {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageJobRow__head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageJobRow__status {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
.adminSidebar__label {
|
.adminSidebar__label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.12em;
|
||||||
|
|||||||
Reference in New Issue
Block a user