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:
HeuJung
2026-09-06 15:22:52 +09:00
parent d263f74cae
commit de18159986
105 changed files with 4055 additions and 208 deletions
@@ -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 없이 동작합니다.
- 이미지 업로드 시 쓰는 압축 라이브러리도 함께 담았습니다.
- 코드 편집기를 불러오지 못하면 안내와 함께 일반 입력창으로 전환되어, 레이아웃을 계속 편집하고 저장할 수 있습니다.
@@ -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();
});
});
@@ -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;
@@ -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"
},
@@ -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'}}"
}
]
}
]
}
]
}
@@ -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"
}
]
}
]
}
]
}
]
}