413 lines
15 KiB
Vue
413 lines
15 KiB
Vue
<script setup>
|
|
import { ref, onMounted, nextTick } from 'vue';
|
|
import {
|
|
ShieldCheck,
|
|
RefreshCw,
|
|
Trash2,
|
|
ArrowLeft,
|
|
Pause,
|
|
Play,
|
|
Folder,
|
|
Globe,
|
|
HardDrive,
|
|
X,
|
|
} from '@lucide/vue';
|
|
import { api, showError } from './client';
|
|
import { formatSize as size, formatAdminDate as date } from './displayFormat';
|
|
const emit = defineEmits(['devices']);
|
|
const overview = ref(null),
|
|
files = ref([]),
|
|
cursor = ref(null),
|
|
events = ref([]),
|
|
view = ref('files'),
|
|
busy = ref(false),
|
|
loading = ref(true),
|
|
notice = ref(''),
|
|
confirmation = ref(null),
|
|
dialog = ref(null);
|
|
const maxConcurrent = ref(4),
|
|
capacity = ref(20),
|
|
countries = ref(''),
|
|
ips = ref('');
|
|
const countryNames = new Intl.DisplayNames(['ko'], { type: 'region' });
|
|
function region(location) {
|
|
if (!location) return '확인 불가';
|
|
return (
|
|
[
|
|
location.countryCode ? countryNames.of(location.countryCode) : null,
|
|
location.city || location.region,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' · ') || '확인 불가'
|
|
);
|
|
}
|
|
function form(settings) {
|
|
maxConcurrent.value = settings.maxConcurrentUploads;
|
|
capacity.value = Number(((settings.totalStorageLimitMb * 1048576) / 1e9).toFixed(1));
|
|
countries.value = settings.blockedCountries.join(', ');
|
|
ips.value = settings.blockedIps.join('\n');
|
|
}
|
|
async function refresh(resetForm = true) {
|
|
const [summary, list, audit] = await Promise.all([
|
|
api('/admin/summary'),
|
|
api('/admin/files'),
|
|
api('/admin/audit'),
|
|
]);
|
|
overview.value = summary;
|
|
files.value = list.items;
|
|
cursor.value = list.nextCursor;
|
|
events.value = audit.events;
|
|
if (resetForm) form(summary.settings);
|
|
}
|
|
async function act(fn) {
|
|
if (busy.value) return;
|
|
busy.value = true;
|
|
notice.value = '';
|
|
try {
|
|
await fn();
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
busy.value = false;
|
|
}
|
|
}
|
|
async function reload() {
|
|
await act(async () => {
|
|
await refresh();
|
|
notice.value = '최신 관리 정보를 불러왔어요.';
|
|
});
|
|
}
|
|
async function pauseUploads() {
|
|
await act(async () => {
|
|
const settings = await api('/admin/settings', 'PATCH', {
|
|
revision: overview.value.settings.revision,
|
|
uploadsPaused: !overview.value.settings.uploadsPaused,
|
|
});
|
|
overview.value.settings = settings;
|
|
notice.value = settings.uploadsPaused
|
|
? '신규 업로드를 중지했어요. 기존 전송은 계속됩니다.'
|
|
: '신규 업로드를 다시 허용했어요.';
|
|
await refresh(false);
|
|
});
|
|
}
|
|
const split = (value) =>
|
|
value
|
|
.split(/[,\n]/)
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
function addBlockedIp(ip) {
|
|
ips.value = [...new Set([...split(ips.value), ip])].join('\n');
|
|
view.value = 'rules';
|
|
notice.value = 'IP를 입력했어요. 설정 저장을 누르면 차단됩니다.';
|
|
}
|
|
async function save() {
|
|
await act(async () => {
|
|
const settings = await api('/admin/settings', 'PATCH', {
|
|
revision: overview.value.settings.revision,
|
|
maxConcurrentUploads: Number(maxConcurrent.value),
|
|
totalStorageLimitMb:
|
|
Number(capacity.value) ===
|
|
Number(((overview.value.settings.totalStorageLimitMb * 1048576) / 1e9).toFixed(1))
|
|
? overview.value.settings.totalStorageLimitMb
|
|
: Math.round((Number(capacity.value) * 1e9) / 1048576),
|
|
blockedCountries: split(countries.value),
|
|
blockedIps: split(ips.value),
|
|
});
|
|
overview.value.settings = settings;
|
|
form(settings);
|
|
notice.value = '관리 설정을 저장했어요.';
|
|
await refresh(false);
|
|
});
|
|
}
|
|
async function more() {
|
|
await act(async () => {
|
|
const result = await api('/admin/files?cursor=' + encodeURIComponent(cursor.value));
|
|
files.value.push(...result.items);
|
|
cursor.value = result.nextCursor;
|
|
});
|
|
}
|
|
let trigger;
|
|
async function confirm(file) {
|
|
trigger = document.activeElement;
|
|
confirmation.value = file;
|
|
await nextTick();
|
|
dialog.value?.querySelector('button')?.focus();
|
|
}
|
|
function closeConfirmation() {
|
|
confirmation.value = null;
|
|
trigger?.focus();
|
|
}
|
|
function dialogKey(event) {
|
|
if (event.key === 'Escape' && !busy.value) {
|
|
event.preventDefault();
|
|
closeConfirmation();
|
|
return;
|
|
}
|
|
if (event.key !== 'Tab') return;
|
|
const controls = [...dialog.value.querySelectorAll('button:not(:disabled)')],
|
|
first = controls[0],
|
|
last = controls.at(-1);
|
|
if (event.shiftKey && document.activeElement === first) {
|
|
event.preventDefault();
|
|
last?.focus();
|
|
} else if (!event.shiftKey && document.activeElement === last) {
|
|
event.preventDefault();
|
|
first?.focus();
|
|
}
|
|
}
|
|
async function remove() {
|
|
const file = confirmation.value;
|
|
await act(async () => {
|
|
await api('/admin/files/' + file.id, 'DELETE');
|
|
closeConfirmation();
|
|
notice.value = '원본과 연결된 항목을 삭제했어요.';
|
|
await refresh(false);
|
|
});
|
|
}
|
|
const actionName = (action) =>
|
|
({ 'settings-change': '관리 설정 변경', 'file-delete': '원본 파일 삭제' })[action] || action;
|
|
onMounted(async () => {
|
|
try {
|
|
await refresh();
|
|
} catch (error) {
|
|
showError(error);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<section class="admin-panel">
|
|
<header class="admin-heading">
|
|
<div>
|
|
<p class="admin-eyebrow"><ShieldCheck :size="17" />관리자</p>
|
|
<h1>서비스 관리</h1>
|
|
</div>
|
|
<div class="admin-toolbar">
|
|
<button class="button small" :disabled="busy || loading" @click="reload">
|
|
<RefreshCw :size="16" />새로고침</button
|
|
><button class="text-button" @click="emit('devices')">
|
|
<ArrowLeft :size="16" />메인으로
|
|
</button>
|
|
</div>
|
|
</header>
|
|
<p v-if="loading" role="status" class="admin-muted">관리 정보를 불러오고 있어요.</p>
|
|
<template v-if="overview">
|
|
<div class="admin-stats">
|
|
<article>
|
|
<Folder :size="21" /><span>저장 파일</span><strong>{{ overview.storedFiles }}개</strong
|
|
><small
|
|
>{{ size(overview.storedBytes) }} /
|
|
{{ size(overview.settings.totalStorageLimitMb * 1048576) }}</small
|
|
>
|
|
</article>
|
|
<article>
|
|
<HardDrive :size="21" /><span>디스크 여유</span
|
|
><strong>{{ size(overview.diskFreeBytes) }}</strong
|
|
><small>업로드 예약 {{ size(overview.reservedBytes) }}</small>
|
|
</article>
|
|
<article>
|
|
<RefreshCw :size="21" /><span>업로드 처리</span
|
|
><strong
|
|
>{{ overview.activeRequests }} / {{ overview.settings.maxConcurrentUploads }}</strong
|
|
><small>파일 저장 작업 {{ overview.activeFileWrites }}개</small>
|
|
</article>
|
|
<article>
|
|
<ShieldCheck :size="21" /><span>활성 계정</span
|
|
><strong>{{ overview.activeAccounts }}명</strong
|
|
><small
|
|
>차단 국가 {{ overview.settings.blockedCountries.length }} · IP 규칙
|
|
{{ overview.settings.blockedIps.length }}</small
|
|
>
|
|
</article>
|
|
</div>
|
|
<div class="admin-upload-control">
|
|
<div>
|
|
<strong>{{
|
|
overview.settings.uploadsPaused ? '신규 업로드 중지됨' : '신규 업로드 허용 중'
|
|
}}</strong>
|
|
<p>이미 시작한 전송과 기존 파일 다운로드는 유지됩니다.</p>
|
|
</div>
|
|
<button class="button" :disabled="busy" @click="pauseUploads">
|
|
<Play v-if="overview.settings.uploadsPaused" :size="16" /><Pause v-else :size="16" />{{
|
|
overview.settings.uploadsPaused ? '업로드 재개' : '신규 업로드 중지'
|
|
}}
|
|
</button>
|
|
</div>
|
|
<section v-if="overview.requests.length" class="admin-activity">
|
|
<strong>진행 중인 업로드 요청</strong>
|
|
<div v-for="request in overview.requests" :key="request.ip + ':' + request.startedAt">
|
|
<code>{{ request.ip || 'IP 확인 불가' }}</code
|
|
><span
|
|
>{{ request.countryCode ? countryNames.of(request.countryCode) : '지역 확인 불가' }} ·
|
|
{{ date(request.startedAt) }}</span
|
|
><button
|
|
class="text-button"
|
|
:disabled="busy || !request.ip"
|
|
@click="addBlockedIp(request.ip)"
|
|
>
|
|
차단 규칙에 추가
|
|
</button>
|
|
</div>
|
|
</section>
|
|
<p v-if="notice" class="admin-notice" role="status">{{ notice }}</p>
|
|
<nav class="admin-tabs" aria-label="관리 항목">
|
|
<button :class="{ selected: view === 'files' }" @click="view = 'files'">저장 파일</button
|
|
><button :class="{ selected: view === 'rules' }" @click="view = 'rules'">
|
|
접속 · 용량 설정</button
|
|
><button :class="{ selected: view === 'audit' }" @click="view = 'audit'">관리 기록</button>
|
|
</nav>
|
|
<section v-if="view === 'files'" class="admin-files">
|
|
<p v-if="!files.length" class="admin-muted">저장된 파일이 없습니다.</p>
|
|
<article v-for="file in files" :key="file.id" class="admin-file">
|
|
<div class="admin-file-heading">
|
|
<strong>{{ file.name }}</strong
|
|
><span>{{ size(file.size) }}</span>
|
|
</div>
|
|
<div class="admin-file-info">
|
|
<span
|
|
>[{{
|
|
file.uploader?.name ||
|
|
(file.uploader?.kind === 'user' ? '로그인 계정' : '비로그인 기기')
|
|
}}]</span
|
|
><code>{{ file.path.split('/')[0] }}</code>
|
|
</div>
|
|
<div class="admin-file-info">
|
|
<Globe :size="15" /><span>접속 지역 · {{ region(file.lastLocation) }}</span
|
|
><code>{{ file.lastLocation?.ip || 'IP 확인 불가' }}</code>
|
|
</div>
|
|
<footer>
|
|
<small>[보관 만료] {{ date(file.expiresAt) }}{{ file.expiresAt ? ' 까지' : '' }}</small
|
|
><button class="text-button admin-delete" :disabled="busy" @click="confirm(file)">
|
|
<Trash2 :size="16" />원본 삭제
|
|
</button>
|
|
</footer>
|
|
</article>
|
|
<button v-if="cursor" class="button small" :disabled="busy" @click="more">더 보기</button>
|
|
</section>
|
|
<form v-else-if="view === 'rules'" class="admin-settings" @submit.prevent="save">
|
|
<div class="admin-settings-grid">
|
|
<label
|
|
>동시에 처리할 업로드 수<input
|
|
v-model="maxConcurrent"
|
|
type="number"
|
|
min="1"
|
|
max="16"
|
|
required
|
|
:disabled="busy" /></label
|
|
><label
|
|
>전체 파일 저장 한도 (GB)<input
|
|
v-model="capacity"
|
|
type="number"
|
|
min="1.1"
|
|
max="1099.5"
|
|
step="any"
|
|
required
|
|
:disabled="busy"
|
|
/></label>
|
|
</div>
|
|
<p class="admin-muted">
|
|
전체 한도는 모든 사용자가 올린 원본 파일과 업로드 예약을 합친 용량입니다. 개인마다 이
|
|
용량을 배정하는 것은 아닙니다.
|
|
</p>
|
|
<dl class="admin-limit-details">
|
|
<div>
|
|
<dt>로그인 보관함</dt>
|
|
<dd>계정당 {{ size(overview.limits.accountBytes) }} · 임시 저장과 받은 파일 합산</dd>
|
|
</div>
|
|
<div>
|
|
<dt>기기 전송 업로드</dt>
|
|
<dd>
|
|
기기 세션당 {{ size(overview.limits.deviceBytes) }} · 로그인 여부와 관계없이 적용
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>기기 전송 업로드 전체</dt>
|
|
<dd>모든 기기 세션 합계 {{ size(overview.limits.devicesTotalBytes) }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>파일 한 개</dt>
|
|
<dd>최대 {{ size(overview.limits.perFileBytes) }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>NAS 최소 여유 공간</dt>
|
|
<dd>업로드 예약을 반영해 {{ size(overview.limits.minDiskFreeBytes) }} 이상 확보</dd>
|
|
</div>
|
|
</dl>
|
|
<p class="admin-muted">
|
|
전체·기기 업로드 한도나 NAS 여유 공간이 부족하면 새 업로드를 거절합니다. 로그인 보관함이
|
|
가득 차면 수신·다운로드는 진행하고 받은 항목에 저장하지 않습니다. 한도를 줄여도 기존
|
|
파일은 삭제하지 않습니다.
|
|
</p>
|
|
<label
|
|
>차단할 국가 코드<input
|
|
v-model="countries"
|
|
placeholder="예: CN, RU"
|
|
:disabled="busy"
|
|
/><small>CN 중국 · RU 러시아 · US 미국 · JP 일본 · KR 대한민국</small></label
|
|
>
|
|
<label
|
|
>차단할 IP 또는 IP 대역<textarea
|
|
v-model="ips"
|
|
rows="4"
|
|
placeholder="203.0.113.10 198.51.100.0/24"
|
|
:disabled="busy"
|
|
></textarea
|
|
><small>쉼표나 줄바꿈으로 구분합니다. IPv4·IPv6·CIDR을 지원합니다.</small></label
|
|
>
|
|
<p class="admin-muted">
|
|
기본 접속 정보 기준으로 차단합니다. 지역 정보가 없는 접속은 국가 규칙으로 차단되지
|
|
않습니다. 현재 관리자 접속을 차단하는 규칙은 저장할 수 없습니다.
|
|
</p>
|
|
<button class="button primary" :disabled="busy">설정 저장</button>
|
|
</form>
|
|
<section v-else class="admin-audit">
|
|
<p v-if="!events.length" class="admin-muted">관리 기록이 없습니다.</p>
|
|
<article v-for="event in events" :key="event.id">
|
|
<strong>{{ actionName(event.action) }}</strong
|
|
><span>{{ date(event.createdAt) }}</span
|
|
><small v-if="event.action === 'file-delete'">{{ event.details.name }}</small
|
|
><small v-else
|
|
>업로드 {{ event.details.uploadsPaused ? '중지' : '허용' }} · 동시
|
|
{{ event.details.maxConcurrentUploads }}개 · 국가
|
|
{{ event.details.blockedCountries?.join(', ') || '없음' }} · IP
|
|
{{ event.details.blockedIps?.length || 0 }}개</small
|
|
>
|
|
</article>
|
|
</section>
|
|
</template>
|
|
<div v-if="confirmation" class="modal-backdrop" @click.self="!busy && closeConfirmation()">
|
|
<section
|
|
ref="dialog"
|
|
class="modal admin-confirm"
|
|
role="alertdialog"
|
|
aria-modal="true"
|
|
aria-labelledby="admin-confirm-title"
|
|
aria-describedby="admin-confirm-description"
|
|
@keydown="dialogKey"
|
|
>
|
|
<header class="modal-title">
|
|
<h2 id="admin-confirm-title">원본 파일을 삭제할까요?</h2>
|
|
<button
|
|
class="icon-button"
|
|
:disabled="busy"
|
|
aria-label="삭제 확인 닫기"
|
|
@click="closeConfirmation"
|
|
>
|
|
<X :size="20" />
|
|
</button>
|
|
</header>
|
|
<strong class="admin-confirm-file">{{ confirmation.name }}</strong>
|
|
<p id="admin-confirm-description">
|
|
원본과 모든 사용자의 연결된 항목이 삭제됩니다. 복원할 수 없습니다.
|
|
</p>
|
|
<div class="admin-confirm-actions">
|
|
<button class="button" :disabled="busy" @click="closeConfirmation">취소</button
|
|
><button class="button primary" :disabled="busy" @click="remove">원본 삭제 확인</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</section>
|
|
</template>
|