fix(core,admin_basic): 정적 게시 재게시 누락 3건 수정 + 관리자 수동 복구 UI + sudo core:update 소유권 정합
- 템플릿 update 는 레이아웃 변경 여부와 무관하게 캐시 버전을 올리고(실패 복원 뒤에도), 자산 주소 방식 전환 3경로도 bump 한다 - config:cache 가 컨테이너 인스턴스를 덮어 이후 terminating 재게시가 사라지던 결함을 복원 헬퍼로 차단 - custom/ 변경 감지와 게시가 같은 열거자(재귀·크기 포함)를 쓰고, 서명은 호스트별로 저장 - 캐시 버전·서명 키를 만료시키지 않는다(기본 TTL 24h 로 매일 전체 재생성되던 문제) - 관리자 > 환경설정 > 일반 「초기 화면 정적 파일」 카드: 상태 조회 + 지금 다시 만들기(확인 모달), 대시보드 알림 버튼 연결, CLI 와 같은 statusReport 소비 - sudo core:update 경로의 root 소유 잔존(설정 디렉토리·임시 폴더·로그·업그레이드 마이그레이션 산출물) 상속·정합화, ext-static 명령 root 경고와 sudo -u 힌트 - 안내·문서·트러블슈팅 4건·audit 룰 ext-cache-version-raw-read·ja 언어팩 동기
This commit is contained in:
@@ -78,7 +78,7 @@ admin/`)이 이 템플릿의 베이스(`_admin_base`)를 extends 하고 이 템
|
||||
| 확장점 | 수 | 상세 |
|
||||
|---|---|---|
|
||||
| 제공 컴포넌트 | 125개 | [제공 컴포넌트](docs/components.md#제공-컴포넌트) |
|
||||
| 레이아웃 | 145개 | [레이아웃 목록](docs/layouts.md#레이아웃-목록) |
|
||||
| 레이아웃 | 146개 | [레이아웃 목록](docs/layouts.md#레이아웃-목록) |
|
||||
| 전용 핸들러 | 17개 | [템플릿 전용 핸들러](docs/handlers.md#템플릿-전용-핸들러) |
|
||||
| 확장 오버라이드 | 0개 | [확장 오버라이드](docs/layouts.md#확장-오버라이드) |
|
||||
<!-- @generated:extension-points-summary END -->
|
||||
@@ -122,7 +122,7 @@ admin/`)이 이 템플릿의 베이스(`_admin_base`)를 extends 하고 이 템
|
||||
| 종류 | 개수 | 위치 |
|
||||
|---|---|---|
|
||||
| PHPUnit | 0개 | — |
|
||||
| Vitest | 207개 | `vitest.config.ts` |
|
||||
| Vitest | 208개 | `vitest.config.ts` |
|
||||
| Playwright | 9개 | `tests/Playwright` |
|
||||
| 시나리오 매니페스트 | 2개 | `tests/scenarios` |
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
|
||||
### Added
|
||||
|
||||
- 환경설정 > 일반 에 「초기 화면 정적 파일」 카드가 추가되었습니다. 게시 상태(게시됨·미게시·비활성·개발 모드)와 현재 버전·파일 수·마지막 게시 시각·실행 계정·최근 실패 사유를 보여 주고, 「지금 다시 만들기」로 확인 후 즉시 다시 만들 수 있습니다. 게시가 쓰이지 않는 환경에서는 버튼이 비활성으로 표시되고 그 이유를 안내합니다.
|
||||
- 대시보드의 「초기 화면 파일 생성 실패」 알림에 「다시 만들기」 버튼이 붙어, 알림에서 바로 다시 만들 수 있습니다.
|
||||
|
||||
- 아이콘(Font Awesome), 본문 글꼴(Pretendard), 국기 아이콘, 레이아웃 코드 편집기(Monaco)를 템플릿에 함께 담았습니다. 관리자 화면 전체가 외부 CDN 없이 동작합니다.
|
||||
- 이미지 업로드 시 쓰는 압축 라이브러리도 함께 담았습니다.
|
||||
- 코드 편집기를 불러오지 못하면 안내와 함께 일반 입력창으로 전환되어, 레이아웃을 계속 편집하고 저장할 수 있습니다.
|
||||
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* @file admin-settings-static-cache.test.tsx
|
||||
* @description 환경설정 > 일반 「초기 화면 정적 파일」 카드 + 다시 만들기 확인 모달 (#651 D4·D7)
|
||||
*
|
||||
* 캐시 버전이 만료로 재생성되지 않으므로(영구 번호) 재게시 누락은 무기한 stale 이 된다 —
|
||||
* 이 카드의 [지금 다시 만들기] 가 그 안전망이다. 두 축으로 잠근다:
|
||||
*
|
||||
* (a) 스키마 — admin_settings.json 의 `staticCacheStatus` 데이터소스(일반 탭 게이트·엔드포인트·
|
||||
* transition_overlay 대기 목록·모달 등록)와 모달의 apiCall 체인
|
||||
* (refetchDataSource → toast → closeModal 순서)
|
||||
* (b) 실제 _tab_general.json 파샬 렌더 — 상태 배지 4분기·버튼 disabled 분기·입력 컨트롤 0개 계약
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { createLayoutTest } from '@core/template-engine/__tests__/utils/layoutTestUtils';
|
||||
import { ComponentRegistry } from '@core/template-engine/ComponentRegistry';
|
||||
|
||||
const layout = require('../../layouts/admin_settings.json');
|
||||
const generalPartial = JSON.parse(
|
||||
readFileSync(resolve(__dirname, '../../layouts/partials/admin_settings/_tab_general.json'), 'utf-8')
|
||||
);
|
||||
const republishModal = JSON.parse(
|
||||
readFileSync(resolve(__dirname, '../../layouts/partials/admin_settings/_modal_static_cache_republish.json'), 'utf-8')
|
||||
);
|
||||
|
||||
interface AnyJson { [k: string]: any }
|
||||
|
||||
const sources: AnyJson[] = layout.data_sources;
|
||||
const byId = (id: string) => sources.find(s => s.id === id);
|
||||
|
||||
function findNodeById(node: any, id: string): any {
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
if (node.id === id) return node;
|
||||
for (const child of node.children ?? []) {
|
||||
const found = findNodeById(child, id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findInPartial(partial: any, id: string): any {
|
||||
for (const root of partial.components ?? partial.children ?? [partial]) {
|
||||
const found = findNodeById(root, id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// (a) 스키마
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('admin_settings — staticCacheStatus 데이터소스 배선', () => {
|
||||
// @scenario permission=granted, publishable=production_enabled, outcome=success
|
||||
// @effects card_renders_status_rows
|
||||
it('일반 탭 게이트 + 상태 엔드포인트를 가진다', () => {
|
||||
const s = byId('staticCacheStatus');
|
||||
expect(s).toBeTruthy();
|
||||
expect(s!.type).toBe('api');
|
||||
expect(s!.endpoint).toBe('/api/admin/settings/static-cache');
|
||||
expect(s!.method).toBe('GET');
|
||||
expect(s!.if).toContain("'general'");
|
||||
expect(s!.if).toContain('query.tab');
|
||||
expect(s!.auto_fetch).not.toBe(false);
|
||||
expect(s!.auth_required).toBe(true);
|
||||
});
|
||||
|
||||
it('fallback 은 버튼을 비활성으로 두는 안전 기본값이다 (publishable:false)', () => {
|
||||
const s = byId('staticCacheStatus')!;
|
||||
expect(s.fallback.data.publishable).toBe(false);
|
||||
expect(s.fallback.data.published).toBe(false);
|
||||
expect(s.fallback.data.failure).toBeNull();
|
||||
});
|
||||
|
||||
it('탭 전환 오버레이가 이 소스를 기다린다', () => {
|
||||
expect(layout.transition_overlay.wait_for).toContain('staticCacheStatus');
|
||||
});
|
||||
|
||||
it('다시 만들기 확인 모달이 등록되어 있다', () => {
|
||||
const partials = (layout.modals as AnyJson[]).map(m => m.partial);
|
||||
expect(partials).toContain('partials/admin_settings/_modal_static_cache_republish.json');
|
||||
expect(republishModal.id).toBe('settings_static_cache_republish_modal');
|
||||
});
|
||||
});
|
||||
|
||||
describe('_tab_general — 초기 화면 정적 파일 카드 스키마', () => {
|
||||
it('카드가 에셋 서빙 방식 카드 바로 다음에 있다', () => {
|
||||
const ids = (generalPartial.children as AnyJson[]).map(c => c.id);
|
||||
const assetIdx = ids.indexOf('card_asset_serving');
|
||||
const cardIdx = ids.indexOf('card_static_cache');
|
||||
expect(assetIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(cardIdx).toBe(assetIdx + 1);
|
||||
});
|
||||
|
||||
// @scenario permission=granted, publishable=non_production, outcome=success
|
||||
// @effects republish_button_disabled_when_not_publishable
|
||||
it('버튼은 publishable 이 아니면 비활성이고 확인 모달을 연다', () => {
|
||||
const btn = findInPartial(generalPartial, 'static_cache_republish_button');
|
||||
expect(btn).not.toBeNull();
|
||||
expect(btn.props.type).toBe('button');
|
||||
expect(btn.props.disabled).toContain('staticCacheStatus?.data?.publishable');
|
||||
expect(btn.props.disabled).toContain('_computed.isReadOnly');
|
||||
const open = btn.actions.find((a: AnyJson) => a.handler === 'openModal');
|
||||
expect(open).toBeTruthy();
|
||||
expect(open.target).toBe('settings_static_cache_republish_modal');
|
||||
});
|
||||
|
||||
it('상태 배지는 게시됨/미게시/비활성/개발 모드 4분기를 갖는다', () => {
|
||||
const badge = findInPartial(generalPartial, 'static_cache_status_badge');
|
||||
expect(badge).not.toBeNull();
|
||||
const variants = Object.keys(badge.props.classMap.variants).sort();
|
||||
expect(variants).toEqual(['dev_mode', 'disabled', 'published', 'unpublished']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_modal_static_cache_republish — 확인 → POST → 후속 체인', () => {
|
||||
const confirmBtn = findNodeById(republishModal, 'static_cache_republish_confirm_button');
|
||||
const seq = confirmBtn?.actions?.[0];
|
||||
const apiCall = (seq?.actions as AnyJson[] | undefined)?.find(a => a.handler === 'apiCall');
|
||||
|
||||
// @scenario permission=granted, publishable=production_enabled, outcome=success
|
||||
// @effects confirm_modal_then_toast_and_refetch
|
||||
it('확인 버튼이 재게시 엔드포인트를 POST 한다 (top-level target)', () => {
|
||||
expect(confirmBtn).not.toBeNull();
|
||||
expect(seq.handler).toBe('sequence');
|
||||
expect(apiCall).toBeTruthy();
|
||||
expect(apiCall!.target).toBe('/api/admin/settings/static-cache/republish');
|
||||
expect(apiCall!.params.method).toBe('POST');
|
||||
expect(apiCall!.auth_required).toBe(true);
|
||||
});
|
||||
|
||||
// @scenario permission=granted, publishable=production_enabled, outcome=publish_failed_marker
|
||||
// @effects confirm_modal_then_toast_and_refetch
|
||||
it('성공 시 상태 재조회 → 토스트 → 모달 닫기 순서다', () => {
|
||||
const handlers = (apiCall!.onSuccess as AnyJson[]).map(a => a.handler);
|
||||
const refetchIdx = handlers.indexOf('refetchDataSource');
|
||||
const toastIdx = handlers.indexOf('toast');
|
||||
const closeIdx = handlers.indexOf('closeModal');
|
||||
expect(refetchIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(toastIdx).toBeGreaterThan(refetchIdx);
|
||||
expect(closeIdx).toBeGreaterThan(toastIdx);
|
||||
|
||||
const refetch = (apiCall!.onSuccess as AnyJson[])[refetchIdx];
|
||||
expect(refetch.params.dataSourceId).toBe('staticCacheStatus');
|
||||
|
||||
// 게시 실패도 HTTP 200 이다 — 토스트가 페이로드(republished)로 분기해야 한다
|
||||
const toast = (apiCall!.onSuccess as AnyJson[])[toastIdx];
|
||||
expect(toast.params.message).toContain('response?.data?.republished');
|
||||
expect(toast.params.type).toContain('response?.data?.republished');
|
||||
});
|
||||
|
||||
it('실패 시 에러 토스트 후 모달을 닫는다', () => {
|
||||
const handlers = (apiCall!.onError as AnyJson[]).map(a => a.handler);
|
||||
expect(handlers).toContain('toast');
|
||||
expect(handlers).toContain('closeModal');
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// (b) 실제 파샬 렌더
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const TestDiv: React.FC<any> = ({ id, className, children }) => (
|
||||
<div id={id} className={className}>{children}</div>
|
||||
);
|
||||
const TestSpan: React.FC<any> = ({ id, className, children, text }) => (
|
||||
<span id={id} className={className}>{children || text}</span>
|
||||
);
|
||||
const TestH3: React.FC<any> = ({ id, className, children, text }) => (
|
||||
<h3 id={id} className={className}>{children || text}</h3>
|
||||
);
|
||||
const TestP: React.FC<any> = ({ id, className, children, text }) => (
|
||||
<p id={id} className={className}>{children || text}</p>
|
||||
);
|
||||
const TestInput: React.FC<any> = ({ name, type }) => <input name={name} type={type} />;
|
||||
const TestSelect: React.FC<any> = ({ name }) => <select name={name} />;
|
||||
const TestTextarea: React.FC<any> = ({ name }) => <textarea name={name} />;
|
||||
const TestToggle: React.FC<any> = ({ name }) => <input type="checkbox" role="switch" name={name} />;
|
||||
const TestButton: React.FC<any> = ({ id, disabled, className, children, text }) => (
|
||||
<button id={id} type="button" disabled={!!disabled} aria-disabled={!!disabled} className={className}>
|
||||
{children || text}
|
||||
</button>
|
||||
);
|
||||
const TestFragment: React.FC<any> = ({ children }) => <>{children}</>;
|
||||
|
||||
function setupTestRegistry(): ComponentRegistry {
|
||||
const registry = ComponentRegistry.getInstance();
|
||||
(registry as any).registry = {
|
||||
Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } },
|
||||
Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } },
|
||||
H3: { component: TestH3, metadata: { name: 'H3', type: 'basic' } },
|
||||
P: { component: TestP, metadata: { name: 'P', type: 'basic' } },
|
||||
Input: { component: TestInput, metadata: { name: 'Input', type: 'basic' } },
|
||||
Select: { component: TestSelect, metadata: { name: 'Select', type: 'basic' } },
|
||||
Textarea: { component: TestTextarea, metadata: { name: 'Textarea', type: 'basic' } },
|
||||
Toggle: { component: TestToggle, metadata: { name: 'Toggle', type: 'composite' } },
|
||||
Button: { component: TestButton, metadata: { name: 'Button', type: 'basic' } },
|
||||
Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } },
|
||||
};
|
||||
return registry;
|
||||
}
|
||||
|
||||
const translations = {
|
||||
admin: {
|
||||
settings: {
|
||||
general: {
|
||||
static_cache_title: '초기 화면 정적 파일',
|
||||
static_cache_description: '설명',
|
||||
static_cache_status: '상태',
|
||||
static_cache_status_published: '게시됨',
|
||||
static_cache_status_unpublished: '미게시',
|
||||
static_cache_status_disabled: '비활성',
|
||||
static_cache_status_dev_mode: '개발 모드',
|
||||
static_cache_version: '현재 버전',
|
||||
static_cache_files: '파일 수',
|
||||
static_cache_published_at: '마지막 게시 시각',
|
||||
static_cache_never: '아직 없음',
|
||||
static_cache_process_user: '실행 계정',
|
||||
static_cache_failure: '최근 {{count}}회 연속 실패 —',
|
||||
static_cache_failure_parent_not_writable: '저장 폴더에 쓸 수 없습니다.',
|
||||
static_cache_failure_write_failed: '파일을 쓰는 중 실패했습니다.',
|
||||
static_cache_failure_lock_unavailable: '캐시 잠금을 얻지 못했습니다.',
|
||||
static_cache_hint_disabled: '꺼져 있음 힌트',
|
||||
static_cache_hint_dev_mode: '개발 모드 힌트',
|
||||
static_cache_republish_now: '지금 다시 만들기',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function renderCard(report: Record<string, unknown>) {
|
||||
const card = findInPartial(generalPartial, 'card_static_cache');
|
||||
expect(card, 'card_static_cache 노드가 _tab_general.json 에 없습니다').not.toBeNull();
|
||||
|
||||
const testUtils = createLayoutTest(
|
||||
{
|
||||
version: '1.0.0',
|
||||
layout_name: 'test_static_cache_card',
|
||||
computed: { isReadOnly: '{{false}}' },
|
||||
data_sources: [
|
||||
{
|
||||
id: 'staticCacheStatus',
|
||||
type: 'api',
|
||||
endpoint: '/api/admin/settings/static-cache',
|
||||
method: 'GET',
|
||||
auto_fetch: true,
|
||||
auth_required: true,
|
||||
},
|
||||
],
|
||||
components: [card],
|
||||
} as any,
|
||||
{ translations, locale: 'ko' }
|
||||
);
|
||||
|
||||
testUtils.mockApi('staticCacheStatus', { response: { data: report } });
|
||||
|
||||
return testUtils;
|
||||
}
|
||||
|
||||
const PUBLISHED = {
|
||||
enabled: true, publishable: true, environment: 'production', version: 1788656566, published: true,
|
||||
files: 714, published_at: '2026-09-06T01:02:49+00:00', tree_writable: true, process_user: 'www-data',
|
||||
failure: null, retained_versions: [1788656566],
|
||||
};
|
||||
|
||||
describe('환경설정 일반 — 초기 화면 정적 파일 카드 렌더', () => {
|
||||
let registry: ComponentRegistry;
|
||||
|
||||
beforeEach(() => { registry = setupTestRegistry(); });
|
||||
afterEach(() => { (registry as any).registry = {}; });
|
||||
|
||||
it('입력 컨트롤을 하나도 렌더하지 않는다 (진단 + 동작 패널 — 저장 필드 없음)', async () => {
|
||||
const testUtils = renderCard(PUBLISHED);
|
||||
await testUtils.render();
|
||||
|
||||
expect(document.querySelector('#static_cache_status_badge')).not.toBeNull();
|
||||
expect(document.querySelectorAll('input, select, textarea').length).toBe(0);
|
||||
|
||||
testUtils.cleanup();
|
||||
});
|
||||
|
||||
// @scenario permission=granted, publishable=production_enabled, outcome=success
|
||||
// @effects card_renders_status_rows
|
||||
it('게시됨: 배지 「게시됨」 + 버전·파일 수 표시 + 버튼 활성', async () => {
|
||||
const testUtils = renderCard(PUBLISHED);
|
||||
await testUtils.render();
|
||||
|
||||
expect(document.querySelector('#static_cache_status_badge')!.textContent!.trim()).toBe('게시됨');
|
||||
expect(document.querySelector('#static_cache_version_row')!.textContent).toContain('1788656566');
|
||||
expect(document.querySelector('#static_cache_files_row')!.textContent).toContain('714');
|
||||
expect(document.querySelector('#static_cache_process_user_row')!.textContent).toContain('www-data');
|
||||
expect(document.querySelector('#static_cache_failure_row')).toBeNull();
|
||||
expect(document.querySelector('#static_cache_not_publishable_hint')).toBeNull();
|
||||
|
||||
const btn = document.querySelector('#static_cache_republish_button') as HTMLButtonElement;
|
||||
expect(btn).not.toBeNull();
|
||||
expect(btn.disabled).toBe(false);
|
||||
|
||||
testUtils.cleanup();
|
||||
});
|
||||
|
||||
// @scenario permission=granted, publishable=production_enabled, outcome=publish_failed_marker
|
||||
// @effects card_renders_status_rows
|
||||
it('미게시 + 실패 마커: 배지 「미게시」 + 실패 행 노출', async () => {
|
||||
const testUtils = renderCard({
|
||||
...PUBLISHED, published: false, files: 0, published_at: null,
|
||||
failure: { version: 1, at: '2026-09-06T00:00:00+00:00', reason: 'parent_not_writable', count: 3, message: 'denied' },
|
||||
});
|
||||
await testUtils.render();
|
||||
|
||||
expect(document.querySelector('#static_cache_status_badge')!.textContent!.trim()).toBe('미게시');
|
||||
const failure = document.querySelector('#static_cache_failure_row');
|
||||
expect(failure).not.toBeNull();
|
||||
expect(failure!.textContent).toContain('3');
|
||||
expect(failure!.textContent).toContain('저장 폴더에 쓸 수 없습니다.');
|
||||
expect(document.querySelector('#static_cache_published_at_never')).not.toBeNull();
|
||||
|
||||
testUtils.cleanup();
|
||||
});
|
||||
|
||||
// @scenario permission=granted, publishable=kill_switch_off, outcome=publish_failed_marker
|
||||
// @effects republish_button_disabled_when_not_publishable
|
||||
it('kill-switch 꺼짐: 배지 「비활성」 + 버튼 비활성 + 사유 힌트', async () => {
|
||||
const testUtils = renderCard({ ...PUBLISHED, enabled: false, publishable: false, published: false });
|
||||
await testUtils.render();
|
||||
|
||||
expect(document.querySelector('#static_cache_status_badge')!.textContent!.trim()).toBe('비활성');
|
||||
const btn = document.querySelector('#static_cache_republish_button') as HTMLButtonElement;
|
||||
expect(btn.disabled).toBe(true);
|
||||
expect(btn.getAttribute('aria-disabled')).toBe('true');
|
||||
expect(document.querySelector('#static_cache_not_publishable_hint')!.textContent!.trim()).toBe('꺼져 있음 힌트');
|
||||
|
||||
testUtils.cleanup();
|
||||
});
|
||||
|
||||
// @scenario permission=granted, publishable=non_production, outcome=publish_failed_marker
|
||||
// @effects republish_button_disabled_when_not_publishable
|
||||
it('개발 모드: 배지 「개발 모드」 + 버튼 비활성', async () => {
|
||||
const testUtils = renderCard({ ...PUBLISHED, environment: 'local', publishable: false, published: false });
|
||||
await testUtils.render();
|
||||
|
||||
expect(document.querySelector('#static_cache_status_badge')!.textContent!.trim()).toBe('개발 모드');
|
||||
expect((document.querySelector('#static_cache_republish_button') as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(document.querySelector('#static_cache_not_publishable_hint')!.textContent!.trim()).toBe('개발 모드 힌트');
|
||||
|
||||
testUtils.cleanup();
|
||||
});
|
||||
|
||||
it('다국어 키가 화면에 그대로 노출되지 않는다', async () => {
|
||||
const testUtils = renderCard(PUBLISHED);
|
||||
await testUtils.render();
|
||||
|
||||
const text = document.querySelector('#card_static_cache')!.textContent ?? '';
|
||||
expect(text).not.toContain('$t:');
|
||||
expect(text).not.toContain('admin.settings');
|
||||
|
||||
testUtils.cleanup();
|
||||
});
|
||||
});
|
||||
+1
@@ -30,6 +30,7 @@ const TAB_SCOPED: Record<string, string[]> = {
|
||||
adminIdentityPolicies: ['identity'],
|
||||
policies: ['identity'],
|
||||
sitemap_status: ['seo'],
|
||||
staticCacheStatus: ['general'],
|
||||
};
|
||||
|
||||
const sources: AnyJson[] = layout.data_sources;
|
||||
|
||||
+49
-1
@@ -240,7 +240,9 @@ describe('어드민 대시보드 - 재호환 알림 (recovery_available)', () =>
|
||||
it('recover 버튼 액션이 alert.recover_endpoint 를 POST 로 호출하도록 정의되어 있다', () => {
|
||||
const recoverBtn = findById(dashboardLayout, 'alert_recover_button');
|
||||
expect(recoverBtn).not.toBeNull();
|
||||
expect(recoverBtn.if).toBe("{{alert.subtype === 'recovery_available'}}");
|
||||
// 버튼 조건은 subtype 이 아니라 **복구 엔드포인트의 존재**다 — 정적 게시 실패 알림처럼
|
||||
// 다른 subtype 의 알림도 recover_endpoint 만 싣으면 같은 배선으로 복구된다 (#651 D11)
|
||||
expect(recoverBtn.if).toBe('{{!!alert.recover_endpoint}}');
|
||||
|
||||
const action = recoverBtn.actions[0];
|
||||
expect(action.type).toBe('click');
|
||||
@@ -258,6 +260,8 @@ describe('어드민 대시보드 - 재호환 알림 (recovery_available)', () =>
|
||||
const toastAction = action.onSuccess.find((a: any) => a.handler === 'toast');
|
||||
expect(toastAction).toBeDefined();
|
||||
expect(toastAction.params.type).toBe('success');
|
||||
// 성공 문구는 알림이 실어 준 것을 우선하고, 없으면 재호환 기본 문구로 떨어진다
|
||||
expect(toastAction.params.message).toBe("{{alert.recover_success_message ?? $t('extensions.alerts.recovered_success')}}");
|
||||
|
||||
const refetch = action.onSuccess.find(
|
||||
(a: any) => a.handler === 'refetchDataSource',
|
||||
@@ -307,4 +311,48 @@ describe('어드민 대시보드 - 재호환 알림 (recovery_available)', () =>
|
||||
expect(screen.getByTestId('recover-btn')).toBeTruthy();
|
||||
expect(screen.getByTestId('dismiss-btn')).toBeTruthy();
|
||||
});
|
||||
// @scenario permission=granted, publishable=production_enabled, outcome=publish_failed_marker
|
||||
// @effects dashboard_alert_points_to_admin_republish
|
||||
it('recover_endpoint 가 없는 알림에는 버튼이 없고, 있는 알림(정적 게시 실패)에는 그 라벨로 버튼이 있다', async () => {
|
||||
testUtils = createLayoutTest(dashboardLayout, {
|
||||
translations,
|
||||
locale: 'ko',
|
||||
componentRegistry: registry,
|
||||
auth: { isAuthenticated: true, user: { id: 1, name: 'Admin' }, authType: 'admin' },
|
||||
});
|
||||
setupDefaultMocks(testUtils);
|
||||
testUtils.mockApi('dashboard_alerts', {
|
||||
response: {
|
||||
data: [
|
||||
{
|
||||
type: 'warning',
|
||||
subtype: 'static_publish_parent_not_writable',
|
||||
title: '초기 화면 파일 생성 실패',
|
||||
message: '폴더에 쓸 수 없습니다',
|
||||
time: '방금 전',
|
||||
icon: 'exclamation-triangle',
|
||||
recover_endpoint: '/api/admin/settings/static-cache/republish',
|
||||
recover_label: '다시 만들기',
|
||||
recover_success_message: '초기 화면 파일을 다시 만들었습니다.',
|
||||
},
|
||||
{
|
||||
type: 'warning',
|
||||
subtype: 'incompatible_deactivated',
|
||||
title: '복구 불가 알림',
|
||||
message: '엔드포인트 없음',
|
||||
time: '방금 전',
|
||||
icon: 'exclamation-triangle',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await testUtils.render();
|
||||
|
||||
expect(screen.getByText('초기 화면 파일 생성 실패')).toBeTruthy();
|
||||
expect(screen.getByText('복구 불가 알림')).toBeTruthy();
|
||||
// 정적 게시 실패 알림은 자기 라벨로 버튼을 갖고, 엔드포인트 없는 알림은 버튼이 없다
|
||||
expect(screen.getAllByTestId('recover-btn').length).toBe(1);
|
||||
expect(screen.getByText('다시 만들기')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> templates/_bundled/sirsoft-admin_basic · 템플릿
|
||||
|
||||
<!-- @generated:stats START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
**훅 수**: 0 · **구독 훅 수**: 0 · **라우트 수**: 29 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 145 · **핸들러 수**: 17
|
||||
**훅 수**: 0 · **구독 훅 수**: 0 · **라우트 수**: 29 · **모델 수**: 0 · **테이블 수**: 0 · **마이그레이션 수**: 0 · **레이아웃 수**: 146 · **핸들러 수**: 17
|
||||
<!-- @generated:stats END -->
|
||||
|
||||
## 문서 목차
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
| 스타일 시스템 | `tailwind` |
|
||||
| 다크 모드 전략 | `ancestor-class` |
|
||||
|
||||
> 분할 13블록 · 팔레트 79 · 스타일 컨트롤 303 · 편집 역량 86 · 중첩 컨테이너 19 · 프리뷰 샘플 71 · 페이지 상태 10 · 액션 레시피 14
|
||||
> 분할 13블록 · 팔레트 79 · 스타일 컨트롤 303 · 편집 역량 86 · 중첩 컨테이너 19 · 프리뷰 샘플 72 · 페이지 상태 10 · 액션 레시피 14
|
||||
<!-- @generated:editor-spec-summary END -->
|
||||
|
||||
<!-- @intent START -->
|
||||
@@ -39,7 +39,7 @@
|
||||
| `componentCapabilities` | 컴포넌트별 편집 역량(어떤 속성을 편집기가 다루는가) | 86 | `editor-spec/componentCapabilities.json` |
|
||||
| `nesting.draggable` | 캔버스에서 끌어 옮길 수 있는 컴포넌트 | 84 | `editor-spec/nesting.json` |
|
||||
| `nesting.containers` | 자식을 담을 수 있는 컴포넌트와 그 허용 규칙 | 19 | `editor-spec/nesting.json` |
|
||||
| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 71 | `editor-spec/sampleData.json` |
|
||||
| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 72 | `editor-spec/sampleData.json` |
|
||||
| `sampleGlobal` | `_global.*` 프리뷰 baseline 시드 | 5 | `editor-spec/sampleGlobal.json` |
|
||||
| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 10 | `editor-spec/states.json` |
|
||||
| `stateLabels` | 상태값 친화 명칭 카탈로그 | 8 | `editor-spec/stateLabels.json` |
|
||||
@@ -91,7 +91,7 @@
|
||||
<!-- @generated:editor-spec-samples START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
| 자리 | 역할 | 개수 | ID |
|
||||
|---|---|---|---|
|
||||
| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 71 | `trustedProxy` · `sitemap_status` · `sitemap_progress_ws` · `locales` · `me` · `installed_modules` · `active_plugins` · `users` · `roles` · `availableChannels` · `identityProviders` · `identityPurposes` … 외 59개 |
|
||||
| `sampleData.byDataSourceId` | 레이아웃 `data_sources` ID 로 붙는 프리뷰 응답 | 72 | `trustedProxy` · `sitemap_status` · `sitemap_progress_ws` · `staticCacheStatus` · `locales` · `me` · `installed_modules` · `active_plugins` · `users` · `roles` · `availableChannels` · `identityProviders` … 외 60개 |
|
||||
| `sampleData.byEndpointPattern` | 엔드포인트 패턴으로 붙는 프리뷰 응답 | 미선언 | - |
|
||||
| `states.groups` | 상태 변종을 적용할 범위(라우트·베이스 레이아웃) | 10 | `_admin_base` · `*/admin/users` · `*/admin/users/:id/edit` · `*/admin/settings` · `*/admin/reset-password` · `*/admin/roles/:id/edit` · `*/admin/identity/challenge` · `*/admin/templates/:type` · `*/admin/login` · `*/admin/forgot-password` |
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
## 레이아웃 목록
|
||||
|
||||
<!-- @generated:layouts START — ext:docgen 이 갱신. 이 블록 안은 직접 수정하지 않는다 -->
|
||||
레이아웃 145개 (루트: `layouts`).
|
||||
레이아웃 146개 (루트: `layouts`).
|
||||
|
||||
| 그룹 | 개수 |
|
||||
|---|---|
|
||||
@@ -13,7 +13,7 @@
|
||||
| `auth` | 1개 |
|
||||
| `errors` | 6개 |
|
||||
| `overrides` | 1개 |
|
||||
| `partials` | 110개 |
|
||||
| `partials` | 111개 |
|
||||
|
||||
| 레이아웃 | 그룹 | 종류 | extends |
|
||||
|---|---|---|---|
|
||||
@@ -125,6 +125,7 @@
|
||||
| `_modal_notification_template_form` | `partials` | partial | - |
|
||||
| `_modal_notification_template_preview` | `partials` | partial | - |
|
||||
| `_modal_password_confirm` | `partials` | partial | - |
|
||||
| `_modal_static_cache_republish` | `partials` | partial | - |
|
||||
| `_tab_advanced` | `partials` | partial | - |
|
||||
| `_tab_drivers` | `partials` | partial | - |
|
||||
| `_tab_general` | `partials` | partial | - |
|
||||
|
||||
@@ -36,6 +36,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"staticCacheStatus": {
|
||||
"data": {
|
||||
"enabled": true,
|
||||
"publishable": true,
|
||||
"environment": "production",
|
||||
"version": 1788656566,
|
||||
"published": true,
|
||||
"files": 714,
|
||||
"published_at": "2026-09-06T01:02:49+00:00",
|
||||
"tree_writable": true,
|
||||
"process_user": "www-data",
|
||||
"failure": null,
|
||||
"retained_versions": [1788171201, 1788656566]
|
||||
}
|
||||
},
|
||||
"locales": {
|
||||
"data": [
|
||||
{
|
||||
|
||||
@@ -1348,7 +1348,34 @@
|
||||
"asset_url_mode_detecting": "Detecting asset file serving mode...",
|
||||
"asset_url_mode_detect_extension": "File extensions work fine on this server.",
|
||||
"asset_url_mode_detect_extensionless": "A static-file optimization rule (e.g. nginx location ~* \\.(js|css|json|png|jpg|jpeg|gif|ico|svg|woff2?)$) is intercepting extension URLs. Switch to \"No file extensions\" and save.",
|
||||
"asset_url_mode_detect_unavailable": "Detection failed. The server did not respond, so the style could not be determined."
|
||||
"asset_url_mode_detect_unavailable": "Detection failed. The server did not respond, so the style could not be determined.",
|
||||
"static_cache_title": "Startup static files",
|
||||
"static_cache_description": "Translations, routes, component definitions and extension bundles are pre-built as static files so the first screen loads fast. They are rebuilt automatically after extension installs/updates and setting changes; if a screen shows stale content, rebuild them here right away.",
|
||||
"static_cache_status": "Status",
|
||||
"static_cache_status_published": "Published",
|
||||
"static_cache_status_unpublished": "Not published",
|
||||
"static_cache_status_disabled": "Disabled",
|
||||
"static_cache_status_dev_mode": "Development mode",
|
||||
"static_cache_version": "Current version",
|
||||
"static_cache_files": "Files",
|
||||
"static_cache_published_at": "Last published",
|
||||
"static_cache_never": "Never",
|
||||
"static_cache_process_user": "Process user",
|
||||
"static_cache_failure": "{{count}} consecutive failures recently —",
|
||||
"static_cache_failure_parent_not_writable": "The target folder is not writable. Check the folder owner and permissions on the server.",
|
||||
"static_cache_failure_write_failed": "Writing files failed. Check available disk space.",
|
||||
"static_cache_failure_lock_unavailable": "Could not acquire the cache lock. Check the cache store.",
|
||||
"static_cache_hint_disabled": "Disabled by G7_STATIC_CACHE=false — no static files are built. Every screen is served through the API.",
|
||||
"static_cache_hint_dev_mode": "Static files are built only in production mode (APP_ENV=production). Every screen is currently served through the API.",
|
||||
"static_cache_republish_now": "Rebuild now",
|
||||
"static_cache_republish": "Rebuild",
|
||||
"static_cache_republishing": "Rebuilding...",
|
||||
"static_cache_republish_confirm_title": "Rebuild startup static files",
|
||||
"static_cache_republish_confirm_body": "The static files will be rebuilt as a new version. Every visitor will download the assets again.",
|
||||
"static_cache_republish_confirm_warning": "This may take a moment. The site keeps working in the meantime.",
|
||||
"static_cache_republished": "Startup static files were rebuilt.",
|
||||
"static_cache_republish_not_publishable": "Static files are not built in this environment (not production, or disabled).",
|
||||
"static_cache_republish_failed": "Startup static files could not be rebuilt. See the failure reason below. The site keeps working normally."
|
||||
},
|
||||
"mail": {
|
||||
"smtp_settings": "SMTP Settings",
|
||||
@@ -2784,6 +2811,7 @@
|
||||
"modal_notification_template_form": "Edit Notification Template",
|
||||
"modal_notification_template_preview": "Preview Notification Template",
|
||||
"settings_cache_delete_modal": "Clear Cache",
|
||||
"settings_static_cache_republish_modal": "Rebuild startup static files",
|
||||
"settings_password_confirm_modal": "Verify Password for App Key Regeneration",
|
||||
"core_changelog_modal": "Core Changelog",
|
||||
"core_update_guide_modal": "Core Update Guide",
|
||||
|
||||
@@ -1648,6 +1648,7 @@
|
||||
"schedules": "Schedules",
|
||||
"settings": "Settings",
|
||||
"sitemap_status": "Sitemap Generation Status",
|
||||
"staticCacheStatus": "Startup static file status",
|
||||
"sitemap_progress_ws": "Sitemap Generation Progress (Live)",
|
||||
"systemInfo": "System Info",
|
||||
"templateDetail": "Template Detail",
|
||||
|
||||
@@ -1352,7 +1352,34 @@
|
||||
"asset_url_mode_detecting": "에셋 파일 서빙 방식을 감지하는 중입니다...",
|
||||
"asset_url_mode_detect_extension": "확장자를 사용할 수 있는 환경입니다.",
|
||||
"asset_url_mode_detect_extensionless": "정적 파일 최적화 규칙(nginx의 location ~* \\.(js|css|json|png|jpg|jpeg|gif|ico|svg|woff2?)$ 블록 등)이 확장자 주소를 가로채고 있습니다. '확장자 미사용'으로 바꾼 뒤 저장하세요.",
|
||||
"asset_url_mode_detect_unavailable": "감지에 실패했습니다. 서버가 응답하지 않아 방식을 판정할 수 없습니다."
|
||||
"asset_url_mode_detect_unavailable": "감지에 실패했습니다. 서버가 응답하지 않아 방식을 판정할 수 없습니다.",
|
||||
"static_cache_title": "초기 화면 정적 파일",
|
||||
"static_cache_description": "첫 화면을 빠르게 띄우기 위해 다국어·라우트·컴포넌트·확장 번들을 정적 파일로 미리 만들어 둡니다. 확장 설치·업데이트나 설정 변경 뒤 자동으로 다시 만들어지며, 화면이 옛 내용을 보이면 여기서 즉시 다시 만들 수 있습니다.",
|
||||
"static_cache_status": "상태",
|
||||
"static_cache_status_published": "게시됨",
|
||||
"static_cache_status_unpublished": "미게시",
|
||||
"static_cache_status_disabled": "비활성",
|
||||
"static_cache_status_dev_mode": "개발 모드",
|
||||
"static_cache_version": "현재 버전",
|
||||
"static_cache_files": "파일 수",
|
||||
"static_cache_published_at": "마지막 게시 시각",
|
||||
"static_cache_never": "아직 없음",
|
||||
"static_cache_process_user": "실행 계정",
|
||||
"static_cache_failure": "최근 {{count}}회 연속 실패 —",
|
||||
"static_cache_failure_parent_not_writable": "저장 폴더에 쓸 수 없습니다. 서버에서 폴더 소유자·권한을 확인하세요.",
|
||||
"static_cache_failure_write_failed": "파일을 쓰는 중 실패했습니다. 디스크 여유 공간을 확인하세요.",
|
||||
"static_cache_failure_lock_unavailable": "캐시 저장소에서 잠금을 얻지 못했습니다. 캐시 저장소 상태를 확인하세요.",
|
||||
"static_cache_hint_disabled": "G7_STATIC_CACHE=false 로 꺼져 있어 정적 파일을 만들지 않습니다. 모든 화면이 API 로 동작합니다.",
|
||||
"static_cache_hint_dev_mode": "운영 모드(APP_ENV=production)에서만 정적 파일을 만듭니다. 지금은 모든 화면이 API 로 동작합니다.",
|
||||
"static_cache_republish_now": "지금 다시 만들기",
|
||||
"static_cache_republish": "다시 만들기",
|
||||
"static_cache_republishing": "다시 만드는 중...",
|
||||
"static_cache_republish_confirm_title": "초기 화면 정적 파일 다시 만들기",
|
||||
"static_cache_republish_confirm_body": "정적 파일을 새 버전으로 다시 만듭니다. 모든 방문자가 자산을 다시 내려받습니다.",
|
||||
"static_cache_republish_confirm_warning": "완료까지 잠시 걸릴 수 있습니다. 그동안 사이트는 정상 동작합니다.",
|
||||
"static_cache_republished": "초기 화면 정적 파일을 다시 만들었습니다.",
|
||||
"static_cache_republish_not_publishable": "지금 환경에서는 정적 파일을 만들지 않습니다 (운영 모드가 아니거나 꺼져 있음).",
|
||||
"static_cache_republish_failed": "초기 화면 정적 파일을 다시 만들지 못했습니다. 아래 실패 사유를 확인하세요. 사이트는 정상 동작합니다."
|
||||
},
|
||||
"mail": {
|
||||
"smtp_settings": "SMTP 설정",
|
||||
@@ -2788,6 +2815,7 @@
|
||||
"modal_notification_template_form": "알림 템플릿 편집",
|
||||
"modal_notification_template_preview": "알림 템플릿 미리보기",
|
||||
"settings_cache_delete_modal": "캐시 삭제 확인",
|
||||
"settings_static_cache_republish_modal": "초기 화면 정적 파일 다시 만들기 확인",
|
||||
"settings_password_confirm_modal": "앱 키 재생성 비밀번호 확인",
|
||||
"core_changelog_modal": "코어 변경 이력",
|
||||
"core_update_guide_modal": "코어 업데이트 안내",
|
||||
|
||||
@@ -1649,6 +1649,7 @@
|
||||
"settings": "환경설정",
|
||||
"sitemap_status": "Sitemap 생성 상태",
|
||||
"sitemap_progress_ws": "Sitemap 생성 진행 (실시간)",
|
||||
"staticCacheStatus": "초기 화면 정적 파일 상태",
|
||||
"systemInfo": "시스템 정보",
|
||||
"templateDetail": "템플릿 상세",
|
||||
"templateInstallPreview": "템플릿 설치 미리보기",
|
||||
|
||||
@@ -429,7 +429,7 @@
|
||||
"id": "top_alert_recover_button_{{$idx}}",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"if": "{{alert.subtype === 'recovery_available'}}",
|
||||
"if": "{{!!alert.recover_endpoint}}",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "inline-flex items-center gap-1 px-3 py-1 text-xs bg-blue-600 dark:bg-blue-500 hover:bg-blue-700 dark:hover:bg-blue-600 text-white rounded"
|
||||
@@ -448,7 +448,7 @@
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "success",
|
||||
"message": "$t:extensions.alerts.recovered_success"
|
||||
"message": "{{alert.recover_success_message ?? $t('extensions.alerts.recovered_success')}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -481,6 +481,13 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!!alert.recover_label}}",
|
||||
"text": "{{alert.recover_label}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!alert.recover_label}}",
|
||||
"text": "$t:extensions.alerts.recover_action"
|
||||
}
|
||||
]
|
||||
@@ -1708,7 +1715,7 @@
|
||||
"id": "alert_recover_button_{{$idx}}",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"if": "{{alert.subtype === 'recovery_available'}}",
|
||||
"if": "{{!!alert.recover_endpoint}}",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "inline-flex items-center gap-1 px-3 py-1 text-xs bg-blue-600 dark:bg-blue-500 hover:bg-blue-700 dark:hover:bg-blue-600 text-white rounded"
|
||||
@@ -1727,7 +1734,7 @@
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "success",
|
||||
"message": "$t:extensions.alerts.recovered_success"
|
||||
"message": "{{alert.recover_success_message ?? $t('extensions.alerts.recovered_success')}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1760,6 +1767,13 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!!alert.recover_label}}",
|
||||
"text": "{{alert.recover_label}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!alert.recover_label}}",
|
||||
"text": "$t:extensions.alerts.recover_action"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
"adminIdentityPolicies",
|
||||
"policies",
|
||||
"language_packs",
|
||||
"sitemap_status"
|
||||
"sitemap_status",
|
||||
"staticCacheStatus"
|
||||
]
|
||||
},
|
||||
"data_sources": [
|
||||
@@ -127,6 +128,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "staticCacheStatus",
|
||||
"label_key": "$t:editor.data_source.staticCacheStatus",
|
||||
"type": "api",
|
||||
"endpoint": "/api/admin/settings/static-cache",
|
||||
"method": "GET",
|
||||
"if": "{{(query.tab || _global.activeSettingsTab || 'general') === 'general'}}",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"fallback": {
|
||||
"data": {
|
||||
"enabled": true,
|
||||
"publishable": false,
|
||||
"environment": null,
|
||||
"version": 0,
|
||||
"published": false,
|
||||
"files": 0,
|
||||
"published_at": null,
|
||||
"tree_writable": null,
|
||||
"process_user": null,
|
||||
"failure": null,
|
||||
"retained_versions": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "trustedProxy",
|
||||
"label_key": "$t:editor.data_source.trustedProxy",
|
||||
@@ -894,6 +920,9 @@
|
||||
{
|
||||
"partial": "partials/admin_settings/_modal_cache_delete.json"
|
||||
},
|
||||
{
|
||||
"partial": "partials/admin_settings/_modal_static_cache_republish.json"
|
||||
},
|
||||
{
|
||||
"partial": "partials/admin_settings/_modal_notification_template_form.json"
|
||||
},
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"meta": {
|
||||
"editor_label": "$t:admin.modal_label.admin_settings.settings_static_cache_republish_modal",
|
||||
"is_partial": true,
|
||||
"description": "초기 화면 정적 파일 다시 만들기 확인 모달"
|
||||
},
|
||||
"id": "settings_static_cache_republish_modal",
|
||||
"type": "composite",
|
||||
"name": "Modal",
|
||||
"props": {
|
||||
"title": "$t:admin.settings.general.static_cache_republish_confirm_title",
|
||||
"size": "sm"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "py-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "text-gray-700 dark:text-gray-300"
|
||||
},
|
||||
"text": "$t:admin.settings.general.static_cache_republish_confirm_body"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": {
|
||||
"className": "mt-2 text-label-subtle"
|
||||
},
|
||||
"text": "$t:admin.settings.general.static_cache_republish_confirm_warning"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "modal-footer-buttons"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "btn btn-secondary",
|
||||
"disabled": "{{_global.isRepublishingStaticCache}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "closeModal"
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:common.cancel"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "static_cache_republish_confirm_button",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "flex-center gap-2 px-4 py-2 bg-blue-600 dark:bg-blue-500 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
"disabled": "{{_global.isRepublishingStaticCache}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"isRepublishingStaticCache": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "apiCall",
|
||||
"auth_required": true,
|
||||
"target": "/api/admin/settings/static-cache/republish",
|
||||
"params": {
|
||||
"method": "POST"
|
||||
},
|
||||
"onSuccess": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"isRepublishingStaticCache": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "refetchDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "staticCacheStatus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "{{response?.data?.republished ? 'success' : 'warning'}}",
|
||||
"message": "{{response?.data?.republished ? $t('admin.settings.general.static_cache_republished') : (response?.data?.publishable === false ? $t('admin.settings.general.static_cache_republish_not_publishable') : $t('admin.settings.general.static_cache_republish_failed'))}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "closeModal"
|
||||
}
|
||||
],
|
||||
"onError": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"isRepublishingStaticCache": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "toast",
|
||||
"params": {
|
||||
"type": "error",
|
||||
"message": "{{error.message ?? $t('admin.settings.general.static_cache_republish_failed')}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "closeModal"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"if": "{{_global.isRepublishingStaticCache}}",
|
||||
"props": {
|
||||
"name": "spinner",
|
||||
"className": "spinner-sm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "{{_global.isRepublishingStaticCache ? '$t:admin.settings.general.static_cache_republishing' : '$t:admin.settings.general.static_cache_republish'}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+322
@@ -646,6 +646,328 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "card_static_cache",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "admin-card"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "H3",
|
||||
"props": {
|
||||
"className": "card-title"
|
||||
},
|
||||
"text": "$t:admin.settings.general.static_cache_title"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "card-description"
|
||||
},
|
||||
"text": "$t:admin.settings.general.static_cache_description"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "row-stack"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "static_cache_summary_row",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex flex-wrap items-center gap-x-6 gap-y-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "static_cache_status_row",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-center gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-label"
|
||||
},
|
||||
"text": "$t:admin.settings.general.static_cache_status"
|
||||
},
|
||||
{
|
||||
"id": "static_cache_status_badge",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium",
|
||||
"classMap": {
|
||||
"key": "{{staticCacheStatus?.data?.enabled === false ? 'disabled' : (staticCacheStatus?.data?.publishable !== true ? 'dev_mode' : (staticCacheStatus?.data?.published ? 'published' : 'unpublished'))}}",
|
||||
"variants": {
|
||||
"published": "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
|
||||
"unpublished": "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
|
||||
"disabled": "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300",
|
||||
"dev_mode": "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300"
|
||||
}
|
||||
}
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "static_cache_status_text_disabled",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{staticCacheStatus?.data?.enabled === false}}",
|
||||
"text": "$t:admin.settings.general.static_cache_status_disabled"
|
||||
},
|
||||
{
|
||||
"id": "static_cache_status_text_dev_mode",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!!staticCacheStatus?.data && staticCacheStatus?.data?.enabled !== false && staticCacheStatus?.data?.publishable !== true}}",
|
||||
"text": "$t:admin.settings.general.static_cache_status_dev_mode"
|
||||
},
|
||||
{
|
||||
"id": "static_cache_status_text_published",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{staticCacheStatus?.data?.publishable === true && !!staticCacheStatus?.data?.published}}",
|
||||
"text": "$t:admin.settings.general.static_cache_status_published"
|
||||
},
|
||||
{
|
||||
"id": "static_cache_status_text_unpublished",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{staticCacheStatus?.data?.publishable === true && !staticCacheStatus?.data?.published}}",
|
||||
"text": "$t:admin.settings.general.static_cache_status_unpublished"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "static_cache_version_row",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-center gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-label"
|
||||
},
|
||||
"text": "$t:admin.settings.general.static_cache_version"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-heading"
|
||||
},
|
||||
"text": "{{Number(staticCacheStatus?.data?.version ?? 0) > 0 ? staticCacheStatus?.data?.version : '-'}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "static_cache_files_row",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-center gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-label"
|
||||
},
|
||||
"text": "$t:admin.settings.general.static_cache_files"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-heading"
|
||||
},
|
||||
"text": "{{staticCacheStatus?.data?.published ? Number(staticCacheStatus?.data?.files ?? 0).toLocaleString() : '-'}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "static_cache_published_at_row",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-center gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-label"
|
||||
},
|
||||
"text": "$t:admin.settings.general.static_cache_published_at"
|
||||
},
|
||||
{
|
||||
"id": "static_cache_published_at_value",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!!staticCacheStatus?.data?.published_at}}",
|
||||
"props": {
|
||||
"className": "text-heading"
|
||||
},
|
||||
"text": "{{staticCacheStatus.data.published_at | datetime('YYYY-MM-DD HH:mm:ss')}}"
|
||||
},
|
||||
{
|
||||
"id": "static_cache_published_at_never",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!staticCacheStatus?.data?.published_at}}",
|
||||
"props": {
|
||||
"className": "text-heading"
|
||||
},
|
||||
"text": "$t:admin.settings.general.static_cache_never"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "static_cache_process_user_row",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-center gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-label"
|
||||
},
|
||||
"text": "$t:admin.settings.general.static_cache_process_user"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-heading"
|
||||
},
|
||||
"text": "{{staticCacheStatus?.data?.process_user ?? '-'}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "static_cache_actions",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "ml-auto"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "static_cache_republish_button",
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"disabled": "{{_computed.isReadOnly || !(staticCacheStatus?.data?.publishable ?? false)}}",
|
||||
"aria-disabled": "{{_computed.isReadOnly || !(staticCacheStatus?.data?.publishable ?? false)}}",
|
||||
"className": "px-4 py-2 bg-blue-600 dark:bg-blue-500 hover:bg-blue-700 dark:hover:bg-blue-600 disabled:bg-gray-400 dark:disabled:bg-gray-600 text-white dark:text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap"
|
||||
},
|
||||
"text": "$t:admin.settings.general.static_cache_republish_now",
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "openModal",
|
||||
"target": "settings_static_cache_republish_modal"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "static_cache_failure_row",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!!staticCacheStatus?.data?.failure}}",
|
||||
"props": {
|
||||
"className": "form-hint text-red-600 dark:text-red-400 break-words"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:admin.settings.general.static_cache_failure|count={{staticCacheStatus?.data?.failure?.count ?? 0}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": " "
|
||||
},
|
||||
{
|
||||
"id": "static_cache_failure_reason_parent_not_writable",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{staticCacheStatus?.data?.failure?.reason === 'parent_not_writable'}}",
|
||||
"text": "$t:admin.settings.general.static_cache_failure_parent_not_writable"
|
||||
},
|
||||
{
|
||||
"id": "static_cache_failure_reason_lock_unavailable",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{staticCacheStatus?.data?.failure?.reason === 'lock_unavailable'}}",
|
||||
"text": "$t:admin.settings.general.static_cache_failure_lock_unavailable"
|
||||
},
|
||||
{
|
||||
"id": "static_cache_failure_reason_write_failed",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!['parent_not_writable', 'lock_unavailable'].includes(staticCacheStatus?.data?.failure?.reason ?? '')}}",
|
||||
"text": "$t:admin.settings.general.static_cache_failure_write_failed"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "static_cache_not_publishable_hint",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!!staticCacheStatus?.data && staticCacheStatus?.data?.publishable !== true}}",
|
||||
"props": {
|
||||
"className": "form-hint"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "static_cache_hint_disabled",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{staticCacheStatus?.data?.enabled === false}}",
|
||||
"text": "$t:admin.settings.general.static_cache_hint_disabled"
|
||||
},
|
||||
{
|
||||
"id": "static_cache_hint_dev_mode",
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{staticCacheStatus?.data?.enabled !== false}}",
|
||||
"text": "$t:admin.settings.general.static_cache_hint_dev_mode"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user