Align capacity units and scope administrator network edits

This commit is contained in:
2026-10-15 14:20:39 +09:00
parent 9cda9cbe84
commit 60a14e9c6c
14 changed files with 458 additions and 259 deletions

View File

@@ -227,6 +227,7 @@ onMounted(async () => {
<CapacityPanel
v-else-if="view === 'rules'"
:settings="overview.settings"
:defaults="overview.capacityDefaults"
@saved="networkSaved"
/>
<NetworkPanel

View File

@@ -1,9 +1,12 @@
<script setup>
import { ref, reactive } from 'vue';
import { Pencil } from '@lucide/vue';
import { Pencil, RotateCcw } from '@lucide/vue';
import { api, showError } from './client';
import { formatSize as size } from './displayFormat';
const props = defineProps({ settings: { type: Object, required: true } });
import { formatSize as size, SIZE_GB } from './displayFormat';
const props = defineProps({
settings: { type: Object, required: true },
defaults: { type: Object, required: true },
});
const emit = defineEmits(['saved']);
const editing = ref(false),
busy = ref(false),
@@ -14,7 +17,7 @@ const limits = [
key: 'totalStorageLimitMb',
label: '전체 파일 저장 한도',
hint: '모든 사용자의 원본 파일과 업로드 예약 합계',
max: 1099.511,
max: 1024,
},
{
key: 'accountBytes',
@@ -40,15 +43,30 @@ const limits = [
];
const bytes = (settings, key) =>
key === 'totalStorageLimitMb' ? settings[key] * 1048576 : settings[key];
const gb = (settings, key) => Number((bytes(settings, key) / 1e9).toFixed(3));
let original;
const gb = (settings, key) => (bytes(settings, key) / SIZE_GB).toFixed(3);
let original, baseline;
function fill(settings) {
baseline = { ...settings };
draft.maxConcurrentUploads = settings.maxConcurrentUploads;
for (const { key } of limits) draft[key] = gb(settings, key);
}
function edit() {
original = { ...props.settings };
draft.maxConcurrentUploads = original.maxConcurrentUploads;
for (const { key } of limits) draft[key] = gb(original, key);
fill(original);
notice.value = '';
editing.value = true;
}
function resetDefaults() {
if (busy.value || !editing.value) return;
fill(props.defaults);
notice.value = 'NAS 초기 설정값을 불러왔어요. 설정 저장을 누르면 적용됩니다.';
}
function normalize(key, input) {
if (input.value !== '' && input.validity.valid) {
draft[key] = Number(input.value).toFixed(3);
input.value = draft[key];
}
}
function cancel() {
editing.value = false;
notice.value = '변경을 취소했어요.';
@@ -64,9 +82,9 @@ async function save() {
for (const { key } of limits) {
const value = Number(draft[key]);
changes[key] =
value === gb(original, key)
? original[key]
: Math.round((value * 1e9) / (key === 'totalStorageLimitMb' ? 1048576 : 1));
value === Number(gb(baseline, key))
? baseline[key]
: Math.round((value * SIZE_GB) / (key === 'totalStorageLimitMb' ? 1048576 : 1));
}
const settings = await api('/admin/settings', 'PATCH', changes);
editing.value = false;
@@ -93,6 +111,10 @@ async function save() {
개인별 한도는 각 계정·기기 세션에 공통 적용합니다. 전체 한도는 모든 사용자가 함께 쓰는
공간입니다.
</p>
<p v-if="editing" id="capacity-input-hint" class="admin-muted">
용량은 1GB = 1,024MB 기준으로 소수점 셋째 자리까지 입력하세요. 예: 5.000GB. 입력을 마치면 세
자리로 표시합니다.
</p>
<div v-if="editing" class="admin-settings-grid">
<label
>동시에 처리할 업로드 수<input
@@ -107,18 +129,19 @@ async function save() {
>
<label v-for="limit in limits" :key="limit.key"
>{{ limit.label }} (GB)<input
v-model.number="draft[limit.key]"
:value="draft[limit.key]"
@input="draft[limit.key] = $event.target.value"
@blur="normalize(limit.key, $event.target)"
aria-describedby="capacity-input-hint"
placeholder="0.000"
inputmode="decimal"
:aria-label="limit.label + ' (GB)'"
type="number"
:min="
limit.key === 'minDiskFreeBytes'
? 0
: limit.key === 'totalStorageLimitMb'
? 1.074
: 0.001
limit.key === 'minDiskFreeBytes' ? 0 : limit.key === 'totalStorageLimitMb' ? 1 : 0.001
"
:max="limit.max || 1000"
step="any"
:max="limit.max || 1024"
step="0.001"
required
:disabled="busy"
/><small>{{ limit.hint }}</small></label
@@ -148,6 +171,9 @@ async function save() {
<div v-if="editing" class="admin-edit-actions">
<button class="button primary" :disabled="busy">설정 저장</button
><button type="button" class="button" :disabled="busy" @click="cancel">취소</button>
<button type="button" class="button" :disabled="busy" @click="resetDefaults">
<RotateCcw :size="15" />기본값으로 돌리기
</button>
</div>
<p v-if="notice" role="status" class="admin-notice">{{ notice }}</p>
</form>

View File

@@ -6,24 +6,41 @@ import {
formatAdminDate as date,
formatCountry as country,
formatSize as size,
SIZE_GB,
} from './displayFormat';
const props = defineProps({ settings: { type: Object, required: true }, suggestedIp: String });
const emit = defineEmits(['saved', 'activity', 'consumed']);
const draft = reactive({}),
const state = ref(props.settings),
draft = reactive({}),
busy = ref(false),
notice = ref(''),
editing = ref(false);
let editRevision;
const autoKeys = [
'autoWindowMinutes',
'autoLoginFailures',
'autoRateViolations',
'autoUploadRequests',
'autoUploadBytes',
'autoBlockMinutes',
];
const uploadGb = ref('0.000');
let autoOriginal;
function edit() {
fill(props.settings);
editRevision = props.settings.revision;
autoOriginal = Object.fromEntries(autoKeys.map((key) => [key, state.value[key]]));
Object.assign(draft, autoOriginal);
uploadGb.value = (draft.autoUploadBytes / SIZE_GB).toFixed(3);
editing.value = true;
notice.value = '';
}
function cancel() {
fill(props.settings);
editing.value = false;
notice.value = '변경을 취소했어요.';
notice.value = '감지 기준 수정을 취소했어요.';
}
function normalizeUpload(input) {
if (input.value !== '' && input.validity.valid) {
uploadGb.value = Number(input.value).toFixed(3);
input.value = uploadGb.value;
}
}
const selectedBlack = ref([]),
selectedWhite = ref([]),
@@ -45,45 +62,21 @@ const reasonName = (reason) =>
'file-volume': '요청 파일 용량 초과',
})[reason] || reason;
function fill(settings) {
for (const key of [
'uploadsPaused',
'blacklistEnabled',
'allowedCountries',
'allowedIps',
'blockedCountries',
'blockedIps',
'autoBlockEnabled',
'autoWindowMinutes',
'autoLoginFailures',
'autoRateViolations',
'autoUploadRequests',
'autoUploadBytes',
'autoBlockMinutes',
])
draft[key] = Array.isArray(settings[key]) ? [...settings[key]] : settings[key];
selectedBlack.value = [];
selectedWhite.value = [];
// An older parent refresh must not undo an already completed action.
if (settings.revision < state.value.revision) return;
state.value = settings;
}
watch(
() => props.settings.revision,
() => {
if (!editing.value) fill(props.settings);
},
{ immediate: true },
() => fill(props.settings),
);
const uploadGb = computed({
get: () => draft.autoUploadBytes / 1e9,
set: (number) => {
draft.autoUploadBytes = Math.round(Number(number) * 1e9);
},
});
const entries = (list) => [
...draft[list === 'black' ? 'blockedCountries' : 'allowedCountries'].map((value) => ({
...state.value[list === 'black' ? 'blockedCountries' : 'allowedCountries'].map((value) => ({
kind: 'country',
value,
id: 'country:' + value,
})),
...draft[list === 'black' ? 'blockedIps' : 'allowedIps'].map((value) => ({
...state.value[list === 'black' ? 'blockedIps' : 'allowedIps'].map((value) => ({
kind: 'ip',
value,
id: 'ip:' + value,
@@ -103,6 +96,20 @@ async function act(work) {
busy.value = false;
}
}
async function persist(changes) {
const settings = await api('/admin/settings', 'PATCH', {
revision: state.value.revision,
...changes,
});
fill(settings);
emit('saved', settings);
}
async function toggle(field) {
await act(async () => {
await persist({ [field]: !state.value[field] });
notice.value = '변경 사항을 적용했어요.';
});
}
async function loadAutomatic(more = false) {
const offset = more ? nextOffset.value : 0;
const result = await api(
@@ -117,19 +124,23 @@ loadAutomatic().catch(showError);
async function save() {
if (!editing.value) return;
await act(async () => {
const settings = await api('/admin/settings', 'PATCH', {
revision: editRevision,
if (autoKeys.some((key) => state.value[key] !== autoOriginal[key]))
throw Error('다른 화면에서 감지 기준이 변경됐어요. 수정을 취소한 뒤 다시 열어 주세요.');
const gb = Number(uploadGb.value);
await persist({
...draft,
autoUploadBytes:
gb === Number((autoOriginal.autoUploadBytes / SIZE_GB).toFixed(3))
? autoOriginal.autoUploadBytes
: Math.round(gb * SIZE_GB),
});
fill(settings);
editing.value = false;
emit('saved', settings);
notice.value = '네트워크 설정을 저장했어요.';
notice.value = '자동 감지 기준을 저장했어요.';
});
}
let trigger;
async function open(list) {
if (!editing.value) return;
if (busy.value) return;
trigger = document.activeElement;
modal.value = list;
kind.value = 'country';
@@ -141,7 +152,6 @@ watch(
() => props.suggestedIp,
async (ip) => {
if (!ip) return;
if (!editing.value) edit();
await open('black');
kind.value = 'ip';
value.value = ip;
@@ -154,7 +164,6 @@ function close() {
trigger?.focus();
}
async function add() {
if (!editing.value) return;
await act(async () => {
const entry = await api('/admin/network-rules/validate', 'POST', {
kind: kind.value,
@@ -168,22 +177,26 @@ async function add() {
: entry.kind === 'country'
? 'allowedCountries'
: 'allowedIps';
draft[field] = [...new Set([...draft[field], entry.value])];
await persist({ [field]: [...new Set([...state.value[field], entry.value])] });
close();
notice.value = '표에 추가했어요. 네트워크 설정 저장을 누르면 적용됩니다.';
notice.value = '규칙을 추가하고 적용했어요.';
});
}
function remove(list) {
if (!editing.value) return;
async function remove(list) {
const selected = list === 'black' ? selectedBlack : selectedWhite;
const countries = list === 'black' ? 'blockedCountries' : 'allowedCountries',
ips = list === 'black' ? 'blockedIps' : 'allowedIps';
draft[countries] = draft[countries].filter(
(value) => !selected.value.includes('country:' + value),
);
draft[ips] = draft[ips].filter((value) => !selected.value.includes('ip:' + value));
selected.value = [];
notice.value = '표에서 제거했어요. 네트워크 설정 저장을 누르면 적용됩니다.';
if (!selected.value.length) return;
await act(async () => {
const countries = list === 'black' ? 'blockedCountries' : 'allowedCountries',
ips = list === 'black' ? 'blockedIps' : 'allowedIps';
await persist({
[countries]: state.value[countries].filter(
(value) => !selected.value.includes('country:' + value),
),
[ips]: state.value[ips].filter((value) => !selected.value.includes('ip:' + value)),
});
selected.value = [];
notice.value = '선택한 규칙을 제거하고 적용했어요.';
});
}
async function removeAutomatic() {
await act(async () => {
@@ -225,34 +238,27 @@ function modalKey(event) {
<section class="network-panel">
<header class="admin-edit-heading">
<h2>접속 보호 설정</h2>
<button v-if="!editing" type="button" class="button small" :disabled="busy" @click="edit">
<Pencil :size="15" />수정
</button>
<span v-else class="admin-muted">수정 중</span>
</header>
<p class="admin-muted">
수정을 누르면 목록과 감지 기준을 변경할 수 있습니다. 블랙리스트와 자동 차단이 우선 적용됩니다.
토글과 규칙 추가·제거는 즉시 적용됩니다. 자동 감지 기준은 해당 영역의 수정 버튼으로
변경합니다. 블랙리스트와 자동 차단이 우선 적용됩니다.
</p>
<p v-if="notice" role="status" class="admin-notice">{{ notice }}</p>
<form @submit.prevent="save">
<div class="network-sections">
<section class="network-section" aria-labelledby="file-policy-title">
<header>
<h2 id="file-policy-title">파일 전송 금지</h2>
<button
v-if="editing"
type="button"
role="switch"
class="network-switch"
:aria-checked="draft.uploadsPaused"
:aria-checked="state.uploadsPaused"
aria-label="파일 전송 금지"
:disabled="busy"
@click="draft.uploadsPaused = !draft.uploadsPaused"
@click="toggle('uploadsPaused')"
>
<span></span>{{ draft.uploadsPaused ? '켜짐' : '꺼짐' }}
<span></span>{{ state.uploadsPaused ? '켜짐' : '꺼짐' }}
</button>
<span v-else class="admin-muted">{{
draft.uploadsPaused ? '켜짐 · 링크와 텍스트만 허용' : '꺼짐 · 파일 전송 허용'
}}</span>
</header>
<p class="admin-muted">
켜면 새 파일 전송·보관함 파일 저장을 막고 사용자 화면의 파일 선택 메뉴를 숨깁니다. 링크와
@@ -262,107 +268,134 @@ function modalKey(event) {
<section class="network-section" aria-labelledby="automatic-title">
<header>
<h2 id="automatic-title">자동 블랙리스트</h2>
<button
type="button"
v-if="editing"
role="switch"
class="network-switch"
:aria-checked="draft.autoBlockEnabled"
aria-label="자동 블랙리스트 사용"
:disabled="busy"
@click="draft.autoBlockEnabled = !draft.autoBlockEnabled"
>
<span></span>{{ draft.autoBlockEnabled ? '사용 중' : '사용 안 함' }}
</button>
<span v-else class="admin-muted">{{
draft.autoBlockEnabled ? '사용 중' : '사용 안 함'
}}</span>
<div class="network-section-controls">
<button
v-if="!editing"
type="button"
class="button small"
aria-label="자동 감지 기준 수정"
:disabled="busy"
@click="edit"
>
<Pencil :size="15" />수정
</button>
<span v-else class="admin-muted">감지 기준 수정 중</span>
<button
type="button"
role="switch"
class="network-switch"
:aria-checked="state.autoBlockEnabled"
aria-label="자동 블랙리스트 사용"
:disabled="busy"
@click="toggle('autoBlockEnabled')"
>
<span></span>{{ state.autoBlockEnabled ? '사용 중' : '사용 안 함' }}
</button>
</div>
</header>
<p class="admin-muted">
확인된 원본 공인 IP를 기준으로 반복 로그인 실패·요청 제한 위반·대량 파일 전송 시도를
감지합니다. 정상적인 파일 조각 전송과 서버 혼잡 재시도는 횟수에 포함하지 않습니다.
</p>
<div v-if="editing" class="network-auto-fields">
<label
>감지 기간 (분)<input
v-model.number="draft.autoWindowMinutes"
type="number"
min="1"
max="1440"
required
<form v-if="editing" class="network-criteria-form" @submit.prevent="save">
<div class="network-auto-fields">
<label
>감지 기간 (분)<input
v-model.number="draft.autoWindowMinutes"
type="number"
min="1"
max="1440"
required
:disabled="busy"
/></label>
<label
>로그인 실패 (회)<input
v-model.number="draft.autoLoginFailures"
type="number"
min="3"
max="1000"
required
:disabled="busy"
/></label>
<label
>요청 제한 위반 (회)<input
v-model.number="draft.autoRateViolations"
type="number"
min="3"
max="1000"
required
:disabled="busy"
/></label>
<label
>파일 전송 시도 (회)<input
v-model.number="draft.autoUploadRequests"
type="number"
min="3"
max="10000"
required
:disabled="busy"
/></label>
<label
>요청한 파일 용량 (GB)<input
:value="uploadGb"
@input="uploadGb = $event.target.value"
@blur="normalizeUpload($event.target)"
placeholder="0.000"
inputmode="decimal"
type="number"
min="0.001"
max="1024"
step="0.001"
required
:disabled="busy"
/></label>
<label
>자동 차단 시간 (분)<input
v-model.number="draft.autoBlockMinutes"
type="number"
min="1"
max="10080"
required
:disabled="busy"
/></label>
</div>
<div class="admin-edit-actions">
<button class="button primary" :disabled="busy">감지 기준 저장</button
><button
type="button"
class="button"
aria-label="감지 기준 수정 취소"
:disabled="busy"
/></label>
<label
>로그인 실패 (회)<input
v-model.number="draft.autoLoginFailures"
type="number"
min="3"
max="1000"
required
:disabled="busy"
/></label>
<label
>요청 제한 위반 (회)<input
v-model.number="draft.autoRateViolations"
type="number"
min="3"
max="1000"
required
:disabled="busy"
/></label>
<label
>파일 전송 시도 (회)<input
v-model.number="draft.autoUploadRequests"
type="number"
min="3"
max="10000"
required
:disabled="busy"
/></label>
<label
>요청한 파일 용량 (GB)<input
v-model="uploadGb"
type="number"
min="0.001"
max="1000"
step="0.001"
required
:disabled="busy"
/></label>
<label
>자동 차단 시간 (분)<input
v-model.number="draft.autoBlockMinutes"
type="number"
min="1"
max="10080"
required
:disabled="busy"
/></label>
</div>
@click="cancel"
>
취소
</button>
</div>
</form>
<dl v-else class="admin-limit-details network-auto-summary">
<div>
<dt>감지 기간</dt>
<dd>{{ draft.autoWindowMinutes }}분</dd>
<dd>{{ state.autoWindowMinutes }}분</dd>
</div>
<div>
<dt>로그인 실패</dt>
<dd>{{ draft.autoLoginFailures }}회</dd>
<dd>{{ state.autoLoginFailures }}회</dd>
</div>
<div>
<dt>요청 제한 위반</dt>
<dd>{{ draft.autoRateViolations }}회</dd>
<dd>{{ state.autoRateViolations }}회</dd>
</div>
<div>
<dt>파일 전송 시도</dt>
<dd>{{ draft.autoUploadRequests }}회</dd>
<dd>{{ state.autoUploadRequests }}회</dd>
</div>
<div>
<dt>요청한 파일 용량</dt>
<dd>{{ size(draft.autoUploadBytes) }}</dd>
<dd>{{ size(state.autoUploadBytes) }}</dd>
</div>
<div>
<dt>자동 차단 시간</dt>
<dd>{{ draft.autoBlockMinutes }}분</dd>
<dd>{{ state.autoBlockMinutes }}분</dd>
</div>
</dl>
<p class="admin-muted">
@@ -445,24 +478,20 @@ function modalKey(event) {
<h2 id="blacklist-title">블랙리스트</h2>
<button
type="button"
v-if="editing"
role="switch"
class="network-switch"
:aria-checked="draft.blacklistEnabled"
:aria-checked="state.blacklistEnabled"
aria-label="블랙리스트 사용"
:disabled="busy"
@click="draft.blacklistEnabled = !draft.blacklistEnabled"
@click="toggle('blacklistEnabled')"
>
<span></span>{{ draft.blacklistEnabled ? '사용 중' : '사용 안 함' }}
<span></span>{{ state.blacklistEnabled ? '사용 중' : '사용 안 함' }}
</button>
<span v-else class="admin-muted">{{
draft.blacklistEnabled ? '사용 중' : '사용 안 함'
}}</span>
</header>
<p class="admin-muted">
등록한 국가·IP·IP 대역의 접속을 차단합니다. 화이트리스트와 겹쳐도 차단이 우선합니다.
</p>
<div v-if="editing" class="network-actions">
<div class="network-actions">
<button type="button" class="button small" :disabled="busy" @click="open('black')">
<Plus :size="15" />블랙리스트 추가</button
><button
@@ -478,14 +507,14 @@ function modalKey(event) {
<table class="network-table">
<thead>
<tr>
<th v-if="editing">선택</th>
<th>선택</th>
<th>구분</th>
<th>국가 · IP 또는 IP 대역</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in black" :key="entry.id">
<td v-if="editing">
<td>
<input
v-model="selectedBlack"
type="checkbox"
@@ -498,9 +527,7 @@ function modalKey(event) {
<td>{{ ruleName(entry) }}</td>
</tr>
<tr v-if="!black.length">
<td :colspan="editing ? 3 : 2" class="network-empty">
등록된 차단 규칙이 없습니다.
</td>
<td colspan="3" class="network-empty">등록된 차단 규칙이 없습니다.</td>
</tr>
</tbody>
</table>
@@ -516,7 +543,7 @@ function modalKey(event) {
허용하고 나머지는 모두 차단합니다. 국가를 확인할 수 없는 접속도 허용 IP와 일치하지 않으면
차단됩니다.
</p>
<div v-if="editing" class="network-actions">
<div class="network-actions">
<button type="button" class="button small" :disabled="busy" @click="open('white')">
<Plus :size="15" />화이트리스트 추가</button
><button
@@ -532,14 +559,14 @@ function modalKey(event) {
<table class="network-table">
<thead>
<tr>
<th v-if="editing">선택</th>
<th>선택</th>
<th>구분</th>
<th>국가 · IP 또는 IP 대역</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in white" :key="entry.id">
<td v-if="editing">
<td>
<input
v-model="selectedWhite"
type="checkbox"
@@ -552,7 +579,7 @@ function modalKey(event) {
<td>{{ ruleName(entry) }}</td>
</tr>
<tr v-if="!white.length">
<td :colspan="editing ? 3 : 2" class="network-empty">
<td colspan="3" class="network-empty">
등록된 허용 규칙이 없습니다. 블랙리스트에 없는 접속은 공개됩니다.
</td>
</tr>
@@ -564,11 +591,7 @@ function modalKey(event) {
허용된 국가나 IP에서도 악용 감지 기준을 적용합니다.
</p>
</section>
<div v-if="editing" class="admin-edit-actions">
<button class="button primary" :disabled="busy">네트워크 설정 저장</button
><button type="button" class="button" :disabled="busy" @click="cancel">취소</button>
</div>
</form>
</div>
<Teleport to="body"
><div v-if="modal" class="modal-backdrop" @click.self="!busy && close()">
<section

View File

@@ -1,11 +1,14 @@
export const SIZE_KB = 1024;
export const SIZE_MB = SIZE_KB ** 2;
export const SIZE_GB = SIZE_KB ** 3;
const sizeNumber = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
export function formatSize(bytes) {
if (bytes >= 1e9) return `${sizeNumber.format(bytes / 1e9)}GB`;
if (bytes >= 1e6) return `${sizeNumber.format(bytes / 1e6)}MB`;
if (bytes >= 1000) return `${sizeNumber.format(bytes / 1000).replace(/\.0$/, '')}KB`;
if (bytes >= SIZE_GB) return `${sizeNumber.format(bytes / SIZE_GB)}GB`;
if (bytes >= SIZE_MB) return `${sizeNumber.format(bytes / SIZE_MB)}MB`;
if (bytes >= SIZE_KB) return `${sizeNumber.format(bytes / SIZE_KB).replace(/\.0$/, '')}KB`;
return bytes === 0 ? '0KB' : '1KB 미만';
}
const countryNames = new Intl.DisplayNames(['ko'], { type: 'region' });

View File

@@ -4209,7 +4209,7 @@ a.button {
color: var(--muted);
}
.network-panel > form {
.network-sections {
display: grid;
gap: 24px;
margin-top: 20px;
@@ -4346,9 +4346,6 @@ a.button {
gap: 20px;
margin-top: 24px;
}
.network-panel > form > .button {
justify-self: start;
}
@media (max-width: 639px) {
.admin-tabs {
gap: 16px;
@@ -4364,9 +4361,6 @@ a.button {
.network-search {
margin-left: 0;
}
.network-panel > form > .button {
justify-self: stretch;
}
}
.admin-audit-panel,
@@ -4473,3 +4467,15 @@ a.button {
line-height: 1.6;
margin: 0 0 20px;
}
.network-section-controls {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 10px;
}
.network-criteria-form {
display: grid;
gap: 18px;
}