diff --git a/backend/src/db.js b/backend/src/db.js index 3b209ff..a457d4d 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -705,6 +705,42 @@ async function deleteImageAssets(ids) { await query(`DELETE FROM image_assets WHERE id IN (${placeholders})`, uniqueIds) 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 }) { const createdAt = now() await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [ @@ -1595,6 +1631,7 @@ module.exports = { listRecentImageOptimizationJobs, listUnusedImageAssets, deleteImageAssets, + getImageAssetStats, createGameItem, updateGameItemLabel, deleteGameItem, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 5a73c3b..0f62a96 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -32,9 +32,11 @@ const { adminDeleteUser, listUnusedImageAssets, deleteImageAssets, + getImageAssetStats, + listRecentImageOptimizationJobs, } = require('../db') const { requireAdmin } = require('../middleware/auth') -const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage') +const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage') const router = express.Router() @@ -248,6 +250,18 @@ router.post('/image-assets/cleanup', requireAdmin, async (req, res) => { 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) { await Promise.all( items.map(async (item) => { diff --git a/docs/update.md b/docs/update.md index a75cfa4..9c86d6f 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-03-31 v1.3.4 +- 관리자 API에 이미지 자산 통계 엔드포인트를 추가해 총 자산 수, 현재 용량, 원본 대비 절감 용량/절감률, 작업 누적 상태를 조회할 수 있게 확장함. +- 관리자 오른쪽 사이드 하단에 `Image Optimization` 패널을 추가해 큐 상태, 절감 통계, 최근 최적화 작업을 바로 확인할 수 있도록 대시보드를 구성함. +- 미사용 자산 정리 API와 작업 기록 큐를 기반으로, 운영 중 이미지 스토리지 상태를 관리자 화면에서 직접 점검할 수 있는 흐름을 완성함. + ## 2026-03-31 v1.3.3 - `image_assets` 참조를 전수 점검해 아무 곳에서도 사용하지 않는 최적화 이미지 자산만 추려내는 정리 배치 로직을 추가함. - 관리자용 미사용 자산 조회/정리 API를 추가해 오래된 고아 이미지 자산을 미리 확인하거나 실제로 삭제할 수 있도록 확장함. diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index c1eee4f..f12c642 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -44,6 +44,9 @@ export const api = { listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) => request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`), 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) => request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }), promoteAdminTierListItems: (tierListId, payload) => diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 44a10e5..9192634 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -57,6 +57,9 @@ const modalRoleNextAdmin = ref(false) const modalTargetCustomItem = ref(null) const users = ref([]) +const imageStats = ref(null) +const imageQueue = ref({ concurrency: 1, activeCount: 0, pendingCount: 0 }) +const imageRecentJobs = ref([]) const error = ref('') const success = ref('') @@ -166,7 +169,7 @@ const adminOverviewStats = computed(() => { onMounted(async () => { await auth.refresh() - await Promise.all([refreshGames(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers(), refreshTemplateRequests()]) + await Promise.all([refreshGames(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers(), refreshTemplateRequests(), refreshImageDiagnostics()]) await syncFeaturedSortable() }) @@ -226,6 +229,41 @@ function resetMessages() { 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) { resetMessages() activeTab.value = tab @@ -1842,6 +1880,46 @@ async function saveFeaturedOrder() { + +
+
Image Optimization
+
+ +
+
+
+ {{ stat.label }} + {{ stat.value }} +
+
+
+
+ 큐 상태 + {{ imageQueue.activeCount }} 실행 / {{ imageQueue.pendingCount }} 대기 +
+
+ 누적 작업 + {{ imageStats?.completedCount || 0 }} 완료 · {{ imageStats?.failedCount || 0 }} 실패 +
+
+ 중복 재사용 + {{ imageStats?.reusedCount || 0 }}건 +
+
+
+
최근 최적화 작업
+
아직 기록된 최적화 작업이 없어요.
+
+
+
+ {{ job.sourceCategory || 'asset' }} + {{ job.status }} +
+
{{ formatBytes(job.originalByteSize) }} → {{ formatBytes(job.optimizedByteSize) }}
+
+
+
+
@@ -1932,6 +2010,38 @@ async function saveFeaturedOrder() { rgba(13, 13, 13, 0.94); 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 { font-size: 11px; letter-spacing: 0.12em;