Files
hop.noname.company/apps/web/src/AdminPanel.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&#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>