diff --git a/AGENTS.md b/AGENTS.md index ef87f5e4..0d280b80 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -261,6 +261,8 @@ | Form 내 `Button` type 없음 | `type="button"` 명시 (submit 방지) | | `options={{options}}` | `options={{options ?? []}}` (fallback) | | boolean 필드를 `RadioGroup`/`Select` 의 `name` 자동바인딩만으로 폼에 묶기 | `autoBinding: false` + `value: "{{String(_local.form?.필드 ?? 기본값)}}"` + `change` 액션 `"{{$event.target.value === 'true'}}"` 캐스팅. 자동바인딩 value 경로는 `e.target.value` 문자열을 그대로 저장해 서버 `boolean` 규칙에서 422 가 된다 (표시만 보면 정상이라 저장 시점에야 드러남) | +| `options` 지정 커스텀 `Select`(composite) 에 `defaultValue` | `value: "{{상태 ?? 기본값}}"` + `change` 액션 + 열기 지점 상태 시드 — 커스텀 Select 는 value-제어 전용이라 `defaultValue` 는 렌더되지 않고(빈 표시) 숨은 input 도 없어 값이 조용히 미전송된다 (options 없는 네이티브 렌더 경로만 defaultValue 유효) | +| 폼 밖 제출 버튼 `props.form: "X"` 만 선언 | 참조 대상 `Form` 에 `props.id: "X"` 동반 필수 — id 가 없으면 버튼이 어떤 폼에도 연결되지 않아 클릭이 무반응이 된다 (오류 없음) | Icon 은 `` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은 박스만 정하고 글리프는 부모 `font-size` 를 상속하므로 어긋난다. 기존 `w-N h-N` 을 옮길 때는 아래 등가표를 쓴다 (Chrome 실측). diff --git a/CHANGELOG.md b/CHANGELOG.md index fa086604..2f696d99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,7 +53,7 @@ - 관리자 화면 이동 시 템플릿 목록을 불필요하게 반복 검색하던 문제를 수정했습니다. - 언어팩 관리(설치·활성화·삭제·업데이트·캐시 갱신)가 실패했을 때 응답에 내부 오류 원문이 그대로 실리던 문제를 수정했습니다. 설치 전 파일 검증 실패도 마찬가지로 안내 문구만 표시되며, 상세 내용은 디버그 모드에서만 확인할 수 있습니다. - 관리자가 설정을 저장해도 백그라운드 작업(큐 워커 등)에는 이전 설정이 계속 적용되던 문제를 수정했습니다. 저장 즉시 실행 중인 프로세스에도 새 값이 반영됩니다. 설정 백업 복원과 플러그인 설정 초기화도 같은 문제가 있어 함께 수정했습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.) -- 만료된 로그인 토큰 등 수명이 지난 데이터가 정리되지 않고 계속 쌓이던 문제를 수정했습니다. 만료 토큰·비밀번호 재설정 기록·오래된 알림·실패한 작업 기록·SEO 통계·예약 작업 이력·본인인증 이력·활동 로그·알림 발송 이력이 매일 자동 정리됩니다. 기존에 쌓여 있던 보존 기간 초과 데이터는 첫 정리 때 한 번에 정리되며, 양이 많아도 나눠서 지우므로 정리 중에 사이트가 멈추지 않습니다. (#110 @Tuwasduliebst 님께서 제보해주셨습니다.) +- 만료된 로그인 토큰 등 수명이 지난 데이터가 정리되지 않고 계속 쌓이던 문제를 수정했습니다. 만료 토큰·비밀번호 재설정 기록·오래된 알림·실패한 작업 기록·SEO 통계·예약 작업 이력·본인인증 이력·활동 로그·알림 발송 이력·확장 화면 구성에 쓰인 병합 파일의 구버전이 매일 자동 정리됩니다. 기존에 쌓여 있던 보존 기간 초과 데이터는 첫 정리 때 한 번에 정리되며, 양이 많아도 나눠서 지우므로 정리 중에 사이트가 멈추지 않습니다. (#110 @Tuwasduliebst, #120 @glitter-gim 님께서 제보해주셨습니다.) - 회원 탈퇴가 마지막 단계에서 실패하면 약관 동의 이력·프로필 이미지·로그인 세션만 사라지고 계정은 남는 문제를 수정했습니다. 이제 탈퇴는 전부 성공하거나 전부 취소되며, 같은 이메일로 재가입한 회원이 같은 날 다시 탈퇴해도 실패하지 않습니다. 관리자·운영자 계정의 탈퇴 시도는 서버 오류가 아니라 안내 문구로 거절됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.) - 사이트 로고를 교체해도 이전 로고가 설정에 다시 실려, 교체할 때마다 파일과 기록이 함께 쌓이던 문제를 수정했습니다. 화면에서 뺀 로고는 저장이 완료된 뒤 실제로 삭제되며, 저장에 실패하면 파일도 그대로 남습니다. - 관리자 API 의 데이터베이스 백업 요청이 항상 서버 오류로 끝나고, 그 응답에 내부 구현 정보가 그대로 실려 나가던 문제를 수정했습니다. 이 기능은 아직 제공하지 않으므로 "제공하지 않는 기능" 임을 알리는 응답으로 답합니다. 설정 파일 백업은 종전대로 사용할 수 있습니다. @@ -73,6 +73,8 @@ - 템플릿을 업데이트하거나 템플릿 캐시를 삭제해도 공개 템플릿 설정(config.json)이 최대 1시간 동안 이전 내용으로 응답되던 문제를 수정했습니다. 비활성화·삭제한 템플릿의 설정이 같은 시간 동안 계속 조회되던 문제와, `template:cache-clear` 명령이 실제로는 대부분의 캐시를 지우지 못하면서 삭제 완료로 보고하던 문제도 함께 고쳤습니다. 모듈·플러그인의 캐시 삭제 명령도 실재하는 캐시를 지우도록 정비했습니다. (#119 @glitter-gim 님께서 제보해주셨습니다.) - 템플릿 업데이트로 컴포넌트 정의(components.json)가 바뀌어도 레이아웃 저장 검증이 최대 1시간 동안 이전 정의를 기준으로 동작하던 문제와, 템플릿 상태 변경 후에도 레이아웃 편집기의 병합 캐시 일부가 남아 편집기가 이전 내용을 받을 수 있던 문제를 수정했습니다. - 캐시 버전 조회 주소(`?v=`)를 생략하고 공개 라우트·다국어·레이아웃 API 를 직접 호출하는 경우(외부 연동·봇 등), 그 응답이 어떤 갱신 경로로도 무효화되지 않는 별도 캐시에 남던 문제를 수정했습니다. 브라우저를 통한 일반 사용에는 영향이 없습니다. +- 확장·코어 업데이트가 중단되며 남은 임시 폴더와 오래된 백업본, 언어팩 설치 임시 파일이 정리되지 않고 계속 쌓이던 문제를 수정했습니다. 사흘이 지난 임시 산출물과 30일이 지난 백업본(최신 1개는 보존)이 매일 자동 정리됩니다. +- 개발 도구의 브라우저 콘솔 로그 파일이 하나의 파일에 무한히 쌓이던 문제를 수정했습니다. 이제 날짜별 파일로 나뉘어 7일간 보관됩니다. ## [7.0.6] - 2026-08-10 diff --git a/app/Console/Commands/PruneStorageLeftoversCommand.php b/app/Console/Commands/PruneStorageLeftoversCommand.php new file mode 100644 index 00000000..2f46d720 --- /dev/null +++ b/app/Console/Commands/PruneStorageLeftoversCommand.php @@ -0,0 +1,81 @@ + + */ + private const GROUP_LABELS = [ + 'staging' => '업데이트 스테이징 잔존', + 'temp' => '코어 임시 산출물', + 'vendor_bundle_staging' => 'vendor 번들 스테이징', + 'extension_backups' => '확장 백업', + 'core_backups' => '코어 백업', + 'legacy_browser_log' => '레거시 브라우저 로그', + ]; + + /** + * Execute the console command. + * + * @param StorageLeftoverPruneService $service 잔존물 정리 서비스 + * @return int 명령 실행 결과 코드 + */ + public function handle(StorageLeftoverPruneService $service): int + { + $days = max(0, (int) $this->option('days')); + $backupDays = max(0, (int) $this->option('backup-days')); + $dryRun = (bool) $this->option('dry-run'); + + $result = $service->prune($days, $backupDays, $dryRun); + + $total = 0; + + foreach (self::GROUP_LABELS as $key => $label) { + $paths = $result[$key] ?? []; + $count = count($paths); + $total += $count; + + $this->line("{$label}: {$count}건"); + + if ($dryRun) { + foreach ($paths as $path) { + $this->line(" - {$path}"); + } + } + } + + $this->info($dryRun + ? "삭제 대상 합계 {$total}건 (dry-run — 실제 삭제 없음)" + : "합계 {$total}건이 삭제되었습니다. (임시 보존일: {$days}일, 백업 보존일: {$backupDays}일)"); + + return self::SUCCESS; + } +} diff --git a/app/Services/LanguagePackService.php b/app/Services/LanguagePackService.php index 9b87bba2..75e453ba 100644 --- a/app/Services/LanguagePackService.php +++ b/app/Services/LanguagePackService.php @@ -724,8 +724,11 @@ class LanguagePackService return $this->finalizeInstall($extractPath, 'github', $githubUrl, $autoActivate, $installedBy, $force); } catch (Throwable $e) { $this->cleanupPending($extractPath); - File::deleteDirectory($tempPath); throw $e; + } finally { + // 다운로드 임시 디렉토리는 성공/실패와 무관하게 소비가 끝난 시점에 정리한다. + // catch 에만 두면 설치가 성공할 때마다 ZIP 사본이 storage/app/temp 에 잔존한다. + File::deleteDirectory($tempPath); } } @@ -776,8 +779,11 @@ class LanguagePackService return $this->finalizeInstall($extractPath, 'url', $url, $autoActivate, $installedBy, $force); } catch (Throwable $e) { $this->cleanupPending($extractPath); - File::deleteDirectory($tempPath); throw $e; + } finally { + // 다운로드 임시 디렉토리는 성공/실패와 무관하게 소비가 끝난 시점에 정리한다. + // catch 에만 두면 설치가 성공할 때마다 ZIP 사본이 storage/app/temp 에 잔존한다. + File::deleteDirectory($tempPath); } } diff --git a/app/Services/StorageLeftoverPruneService.php b/app/Services/StorageLeftoverPruneService.php new file mode 100644 index 00000000..63fb277a --- /dev/null +++ b/app/Services/StorageLeftoverPruneService.php @@ -0,0 +1,347 @@ +> 대상군별 삭제(예정) 경로 목록 + */ + public function prune(int $days = 3, int $backupDays = 30, bool $dryRun = false): array + { + $now = time(); + $cutoff = $now - $days * 86400; + $backupCutoff = $now - $backupDays * 86400; + + return [ + 'staging' => $this->pruneUpdateStaging($cutoff, $dryRun), + 'temp' => $this->pruneCoreTemp($cutoff, $dryRun), + 'vendor_bundle_staging' => $this->pruneVendorBundleStaging($cutoff, $dryRun), + 'extension_backups' => $this->pruneExtensionBackups($backupCutoff, $dryRun), + 'core_backups' => $this->pruneCoreBackups($backupCutoff, $dryRun), + 'legacy_browser_log' => $this->pruneLegacyBrowserLog($dryRun), + ]; + } + + /** + * `{modules,plugins,templates}/_pending` 의 스테이징/교체 잔존물을 정리합니다. + * + * @param int $cutoff 이 시각(Unix time) 이전 mtime 만 삭제 + * @param bool $dryRun 삭제 없이 목록만 + * @return list 삭제(예정) 경로 목록 + */ + private function pruneUpdateStaging(int $cutoff, bool $dryRun): array + { + $pruned = []; + + foreach (['modules', 'plugins', 'templates'] as $type) { + $pendingRoot = $this->basePath($type.DIRECTORY_SEPARATOR.'_pending'); + + if (! File::isDirectory($pendingRoot)) { + continue; + } + + foreach (File::directories($pendingRoot) as $dir) { + $name = basename($dir); + + // 타임스탬프/교체 마커가 없는 디렉토리는 다운로드 대기 정상 확장 — 불가침. + if (! preg_match(self::STAGING_TIMESTAMP_PATTERN, $name) + && ! preg_match(self::SWAP_LEFTOVER_PATTERN, $name)) { + continue; + } + + if ($this->isFresh($dir, $cutoff)) { + continue; + } + + $pruned[] = $dir; + + if (! $dryRun) { + File::deleteDirectory($dir); + } + } + } + + return $pruned; + } + + /** + * `storage/app/temp` 직하위 전부(디렉토리·파일)를 보존일 기준으로 정리합니다. + * + * @param int $cutoff 이 시각(Unix time) 이전 mtime 만 삭제 + * @param bool $dryRun 삭제 없이 목록만 + * @return list 삭제(예정) 경로 목록 + */ + private function pruneCoreTemp(int $cutoff, bool $dryRun): array + { + return $this->pruneEntriesByAge( + $this->storagePath('app'.DIRECTORY_SEPARATOR.'temp'.DIRECTORY_SEPARATOR.'*'), + $cutoff, + $dryRun + ); + } + + /** + * vendor 번들 스테이징(`build-*`)을 보존일 기준으로 정리합니다. + * + * @param int $cutoff 이 시각(Unix time) 이전 mtime 만 삭제 + * @param bool $dryRun 삭제 없이 목록만 + * @return list 삭제(예정) 경로 목록 + */ + private function pruneVendorBundleStaging(int $cutoff, bool $dryRun): array + { + return $this->pruneEntriesByAge( + $this->storagePath('app'.DIRECTORY_SEPARATOR.'vendor-bundle-staging'.DIRECTORY_SEPARATOR.'build-*'), + $cutoff, + $dryRun + ); + } + + /** + * 확장 백업(`extension_backups/{type}/{identifier}_{Ymd_His}`)을 정리합니다. + * + * identifier 별 최신 1개는 나이와 무관하게 보존하고, 나머지 중 보존일 경과분만 + * 삭제합니다. type 디렉토리는 열거로 얻으므로 새 백업 유형(lang-packs 등)도 + * 하드코딩 없이 같은 규칙을 받습니다. + * + * @param int $backupCutoff 이 시각(Unix time) 이전 mtime 만 삭제 + * @param bool $dryRun 삭제 없이 목록만 + * @return list 삭제(예정) 경로 목록 + */ + private function pruneExtensionBackups(int $backupCutoff, bool $dryRun): array + { + $pruned = []; + $backupRoot = $this->storagePath('app'.DIRECTORY_SEPARATOR.'extension_backups'); + + if (! File::isDirectory($backupRoot)) { + return $pruned; + } + + foreach (File::directories($backupRoot) as $typeDir) { + /** @var array> $byIdentifier */ + $byIdentifier = []; + + foreach (File::directories($typeDir) as $dir) { + if (! preg_match(self::BACKUP_NAME_PATTERN, basename($dir), $matches)) { + continue; + } + + $byIdentifier[$matches[1]][] = ['path' => $dir, 'stamp' => $matches[2]]; + } + + foreach ($byIdentifier as $entries) { + array_push($pruned, ...$this->pruneBackupSet($entries, $backupCutoff, $dryRun)); + } + } + + return $pruned; + } + + /** + * 코어 백업(`core_backups/core_{Ymd_His}`)을 정리합니다 — 최신 1개 상시 보존. + * + * @param int $backupCutoff 이 시각(Unix time) 이전 mtime 만 삭제 + * @param bool $dryRun 삭제 없이 목록만 + * @return list 삭제(예정) 경로 목록 + */ + private function pruneCoreBackups(int $backupCutoff, bool $dryRun): array + { + $backupRoot = $this->storagePath('app'.DIRECTORY_SEPARATOR.'core_backups'); + + if (! File::isDirectory($backupRoot)) { + return []; + } + + $entries = []; + + foreach (File::directories($backupRoot) as $dir) { + if (! preg_match(self::CORE_BACKUP_NAME_PATTERN, basename($dir), $matches)) { + continue; + } + + $entries[] = ['path' => $dir, 'stamp' => $matches[1]]; + } + + return $this->pruneBackupSet($entries, $backupCutoff, $dryRun); + } + + /** + * 레거시 단일 `storage/logs/browser.log` 를 정리합니다. + * + * 브라우저 로그가 daily 드라이버로 전환되어 이 파일에 쓰는 코드는 더 이상 없다 — + * 존재 자체가 잔존물이므로 나이와 무관하게 삭제합니다. 날짜별 `browser-*.log` 는 + * daily 드라이버가 보존일로 자체 정리하므로 대상이 아닙니다. + * + * @param bool $dryRun 삭제 없이 목록만 + * @return list 삭제(예정) 경로 목록 + */ + private function pruneLegacyBrowserLog(bool $dryRun): array + { + $path = $this->storagePath('logs'.DIRECTORY_SEPARATOR.'browser.log'); + + if (! File::exists($path)) { + return []; + } + + if (! $dryRun) { + File::delete($path); + } + + return [$path]; + } + + /** + * 같은 백업 집합에서 최신 1개를 제외하고 보존일 경과분을 삭제합니다. + * + * 최신 판정은 디렉토리명 타임스탬프(`Ymd_His` — 사전순 = 시간순) 기준입니다. + * + * @param list $entries 같은 identifier 의 백업 목록 + * @param int $backupCutoff 이 시각(Unix time) 이전 mtime 만 삭제 + * @param bool $dryRun 삭제 없이 목록만 + * @return list 삭제(예정) 경로 목록 + */ + private function pruneBackupSet(array $entries, int $backupCutoff, bool $dryRun): array + { + if ($entries === []) { + return []; + } + + usort($entries, static fn (array $a, array $b): int => strcmp($b['stamp'], $a['stamp'])); + + // 최신 1개는 롤백 자산 — 나이와 무관하게 보존. + array_shift($entries); + + $pruned = []; + + foreach ($entries as $entry) { + if ($this->isFresh($entry['path'], $backupCutoff)) { + continue; + } + + $pruned[] = $entry['path']; + + if (! $dryRun) { + File::deleteDirectory($entry['path']); + } + } + + return $pruned; + } + + /** + * glob 패턴에 걸리는 항목(디렉토리·파일)을 보존일 기준으로 정리합니다. + * + * @param string $pattern glob 패턴 + * @param int $cutoff 이 시각(Unix time) 이전 mtime 만 삭제 + * @param bool $dryRun 삭제 없이 목록만 + * @return list 삭제(예정) 경로 목록 + */ + private function pruneEntriesByAge(string $pattern, int $cutoff, bool $dryRun): array + { + $pruned = []; + + foreach (File::glob($pattern) ?: [] as $entry) { + if ($this->isFresh($entry, $cutoff)) { + continue; + } + + $pruned[] = $entry; + + if (! $dryRun) { + is_dir($entry) ? File::deleteDirectory($entry) : File::delete($entry); + } + } + + return $pruned; + } + + /** + * 항목의 mtime 이 보존일 이내인지 판정합니다 (mtime 조회 실패 시 보존 — fail-safe). + * + * @param string $path 대상 경로 + * @param int $cutoff 기준 시각 (Unix time) + * @return bool 보존일 이내(삭제 금지)면 true + */ + private function isFresh(string $path, int $cutoff): bool + { + $mtime = @filemtime($path); + + return $mtime === false || $mtime > $cutoff; + } + + /** + * 프로젝트 루트 기준 경로를 만듭니다. + * + * @param string $path 루트 기준 상대 경로 + * @return string 절대 경로 + */ + private function basePath(string $path): string + { + return ($this->baseRoot ?? base_path()).DIRECTORY_SEPARATOR.$path; + } + + /** + * 스토리지 루트 기준 경로를 만듭니다. + * + * @param string $path 스토리지 루트 기준 상대 경로 + * @return string 절대 경로 + */ + private function storagePath(string $path): string + { + return ($this->storageRoot ?? storage_path()).DIRECTORY_SEPARATOR.$path; + } +} diff --git a/app/Support/DevTools/BrowserLogWriter.php b/app/Support/DevTools/BrowserLogWriter.php index ea9a7b6b..2737ce04 100644 --- a/app/Support/DevTools/BrowserLogWriter.php +++ b/app/Support/DevTools/BrowserLogWriter.php @@ -137,9 +137,12 @@ class BrowserLogWriter return Log::channel('browser'); } + // daily + 유한 보존일 — single 드라이버는 로테이션 없이 무한 append 되어 + // browser.log 가 저장소 단일 최대 파일로 자란다 (실측 989MB). return Log::build([ - 'driver' => 'single', + 'driver' => 'daily', 'path' => storage_path('logs/browser.log'), + 'days' => 7, 'level' => config('logging.level', 'debug'), ]); } diff --git a/config/schedule_security.php b/config/schedule_security.php index bbbd08ce..3faf7957 100644 --- a/config/schedule_security.php +++ b/config/schedule_security.php @@ -60,6 +60,10 @@ return [ 'identity:prune-logs' => ['options' => ['days']], 'activity-log:prune' => ['options' => ['days']], 'notification-log:prune' => ['options' => ['days']], + // 실삭제가 기본이나 사용자 파일 파기는 커맨드 자체가 설정 토글·보존기간으로 이중 확인한다. + 'attachments:prune-orphans' => ['options' => ['dry-run', 'limit', 'days', 'scheduled']], + // 잔존물 정리 — 최신 백업 1개 상시 보존 가드는 커맨드 내부 소유(옵션으로 무력화 불가). + 'storage:prune-leftovers' => ['options' => ['days', 'backup-days', 'dry-run']], // SEO 'seo:warmup' => ['options' => ['layout']], diff --git a/resources/js/core/template-engine/__tests__/__snapshots__/BindingShape.routingParity.test.ts.snap b/resources/js/core/template-engine/__tests__/__snapshots__/BindingShape.routingParity.test.ts.snap index 660ea9e2..88cf888a 100644 --- a/resources/js/core/template-engine/__tests__/__snapshots__/BindingShape.routingParity.test.ts.snap +++ b/resources/js/core/template-engine/__tests__/__snapshots__/BindingShape.routingParity.test.ts.snap @@ -86,7 +86,7 @@ exports[`BindingShape 라우팅 diff 하네스 > 단일 바인딩 판정: greedy ] `; -exports[`BindingShape 라우팅 diff 하네스 > 단일 바인딩 판정: 구 정규식과 신 정본이 갈리는 총량 1`] = `471`; +exports[`BindingShape 라우팅 diff 하네스 > 단일 바인딩 판정: 구 정규식과 신 정본이 갈리는 총량 1`] = `476`; exports[`BindingShape 라우팅 diff 하네스 > 판정이 갈리는 식의 분류별 건수 요약 1`] = ` { @@ -254,6 +254,7 @@ exports[`BindingShape 라우팅 diff 하네스 > 표현식/경로 판정: 구 "modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_list/_partial_datagrid.json", "modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_form.json", "modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_list.json", + "plugins/_bundled/sirsoft-ckeditor5/resources/layouts/admin/ckeditor5_uploads.json", "plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/gdpr_consent_log.json", "templates/_bundled/sirsoft-admin_basic/layouts/_admin_base.json", "templates/_bundled/sirsoft-admin_basic/layouts/admin_module_list.json", @@ -373,6 +374,7 @@ exports[`BindingShape 라우팅 diff 하네스 > 표현식/경로 판정: 구 "modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_product_list/_partial_product_datagrid.json", "modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_shipping_policy_list/_partial_datagrid.json", "modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_list.json", + "plugins/_bundled/sirsoft-ckeditor5/resources/layouts/admin/ckeditor5_uploads.json", "templates/_bundled/sirsoft-admin_basic/layouts/admin_user_list.json", "templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_activity_log_list/_partial_datagrid.json", "templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_notification_log_list/_partial_datagrid.json", diff --git a/resources/views/dev-dashboard.blade.php b/resources/views/dev-dashboard.blade.php index 84de65e2..c2d5df4c 100644 --- a/resources/views/dev-dashboard.blade.php +++ b/resources/views/dev-dashboard.blade.php @@ -1090,6 +1090,18 @@ if (isset($_GET['ajax_action'])) { 고아 첨부 정리 (미리보기) (attachments:prune-orphans --dry-run) + + +