Compare commits

...

8 Commits

Author SHA1 Message Date
kjh2064 5fa2fd5709 feat(frontend): add internal WBS workspace preview page
New WbsWorkspacePage component under internal /internal/wbs route for
component preview and workspace management. Frontend rebuild generated
new bundle hashes (index-VG0yv2WA.js, index-BE8ymjzb.css) integrated
into Host wwwroot.

AGENTS.md: Necessity-driven (internal UI preview); Simplicity (no external API).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-07 13:32:40 +09:00
kjh2064 67274cbdb6 Merge pull request '배포: 날짜·일련번호 기반 프런트엔드 버전 계약 적용' (#15) from fix/deploy-build-frontend-artifact into main
deploy / deploy (push) Successful in 2m15s
deploy / notify (push) Successful in 1s
Reviewed-on: #15
2026-08-06 16:28:38 +09:00
kjh2064 9e4346efa9 DEPLOY: generate date sequence semantic frontend version 2026-08-06 16:23:47 +09:00
kjh2064 07b6fc6bb3 Merge pull request 'DEPLOY: rebuild frontend before publishing host artifact' (#14) from fix/deploy-build-frontend-artifact into main
ci / static (push) Successful in 10s
ci / frontend (push) Has been cancelled
ci / publish (push) Has been cancelled
ci / backend (push) Has been cancelled
Build & Test with Secrets / frontend (push) Has been cancelled
Build & Test with Secrets / security-scan (push) Has been cancelled
Build & Test with Secrets / notification (push) Has been cancelled
Build & Test with Secrets / build (push) Has been cancelled
deploy / deploy (push) Successful in 1m53s
deploy / notify (push) Successful in 0s
Reviewed-on: #14
2026-08-06 16:13:37 +09:00
kjh2064 366978ce0f DEPLOY: rebuild frontend before publishing host artifact
ci / static (push) Has been cancelled
ci / publish (push) Has been cancelled
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
Build & Test with Secrets / build (pull_request) Has been cancelled
Build & Test with Secrets / frontend (pull_request) Has been cancelled
Build & Test with Secrets / security-scan (pull_request) Has been cancelled
Build & Test with Secrets / notification (pull_request) Has been cancelled
ci / static (pull_request) Successful in 7s
ci / publish (pull_request) Has been cancelled
ci / frontend (pull_request) Has been cancelled
ci / backend (pull_request) Has been cancelled
2026-08-06 16:13:01 +09:00
kjh2064 c0b49959d4 Merge pull request 'DEPLOY: one-time sudo delegation for kartsell restart' (#13) from fix/deploy-kartsell-nopasswd into main
ci / static (push) Has been cancelled
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / publish (push) Has been cancelled
Build & Test with Secrets / build (push) Has been cancelled
Build & Test with Secrets / frontend (push) Has been cancelled
Build & Test with Secrets / security-scan (push) Has been cancelled
Build & Test with Secrets / notification (push) Has been cancelled
deploy / deploy (push) Successful in 2m35s
deploy / notify (push) Successful in 1s
Reviewed-on: #13
2026-08-06 16:05:28 +09:00
kjh2064 1c685e2285 DEPLOY: delegate kartsell restart without interactive sudo
ci / static (push) Successful in 11s
ci / publish (push) Has been cancelled
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / static (pull_request) Successful in 9s
Build & Test with Secrets / build (pull_request) Failing after 2s
Build & Test with Secrets / security-scan (pull_request) Has been cancelled
Build & Test with Secrets / notification (pull_request) Has been cancelled
Build & Test with Secrets / frontend (pull_request) Has been cancelled
ci / backend (pull_request) Has been cancelled
ci / frontend (pull_request) Has been cancelled
ci / publish (pull_request) Has been cancelled
2026-08-06 15:59:16 +09:00
kjh2064 aee4a4d624 Merge pull request 'Deploy: fail closed on migration and restart errors' (#12) from fix/deploy-fail-closed into main
Build & Test with Secrets / build (push) Has been cancelled
Build & Test with Secrets / frontend (push) Has been cancelled
Build & Test with Secrets / security-scan (push) Has been cancelled
Build & Test with Secrets / notification (push) Has been cancelled
ci / static (push) Successful in 13s
ci / publish (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / backend (push) Has been cancelled
deploy / deploy (push) Failing after 1m41s
deploy / notify (push) Successful in 0s
Reviewed-on: #12
2026-08-06 15:32:26 +09:00
6 changed files with 3553 additions and 0 deletions
+32
View File
@@ -22,6 +22,33 @@ jobs:
with:
dotnet-version: '10.0.x'
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: frontend/pnpm-lock.yaml
- name: Build frontend into Host static assets
run: |
pnpm install --frozen-lockfile
VERSION_DATE="$(TZ=Asia/Seoul date +%Y.%m.%d)"
RELEASE_COUNT="$(git ls-remote --tags origin "refs/tags/v${VERSION_DATE}.*" | wc -l | tr -d ' ')"
VERSION_SEQUENCE="$((RELEASE_COUNT + 1))"
APP_VERSION="${VERSION_DATE}.${VERSION_SEQUENCE}.${GITHUB_SHA::10}"
echo "VITE_APP_VERSION=${APP_VERSION}" >> "$GITHUB_ENV"
echo "release_version=${APP_VERSION}"
VITE_APP_VERSION="${APP_VERSION}" pnpm build
grep -R -q 'app-version' dist
grep -R -q 'UI contract 4.0' dist
grep -R -q "${APP_VERSION}" dist
find ../src/KArtSell.Host/wwwroot -mindepth 1 -delete
cp -R dist/. ../src/KArtSell.Host/wwwroot/
working-directory: frontend
- run: dotnet restore KArtSell.sln
- run: dotnet build KArtSell.sln --no-restore -c Release
@@ -58,6 +85,11 @@ jobs:
echo ""
ssh -i /tmp/deploy_key.pem -o StrictHostKeyChecking=no kjh2064@178.104.200.7 \
"set -euo pipefail; \
sudo -n -l | grep -Fq '/usr/bin/systemctl restart kartsell' || { \
echo 'Deployment blocked: one-time sudoers delegation is missing for kartsell.' >&2; \
echo 'Expected: kjh2064 ALL=(root) NOPASSWD: /usr/bin/systemctl restart kartsell' >&2; \
exit 77; \
}; \
export KARTSELL_POSTGRES='${{ secrets.KARTSELL_POSTGRES }}'; \
mkdir -p /app/kartsell/current; \
unzip -oq /tmp/kartsell-release.zip -d /app/kartsell/current; \
@@ -0,0 +1,22 @@
# 배포 frontend artifact 계약
## Source
- 운영 배포 Run 3357 로그: `dotnet publish` 전 frontend build 단계 없음
- 운영 bundle에 `app-version``UI contract 4.0` marker 없음
- `frontend`의 재현 가능한 `pnpm-lock.yaml` 및 기존 CI frontend job
## Decision
배포 workflow는 Host publish 전에 다음 규칙으로 버전을 계산하고 frontend를 재생성한다.
```text
YYYY.MM.DD.<당일 release 순번>.<commit SHA 10자리>
```
당일 순번은 `vYYYY.MM.DD.*` release tag 개수에 1을 더해 계산한다. 예: `2026.08.06.1.acaa731b3f`. 생성된 `frontend/dist`를 Host `wwwroot`에 복사하고, `app-version`, `UI contract 4.0`, 계산된 전체 버전 marker가 없으면 배포를 중단한다.
## Evidence / Unknown
- Source 변경과 운영 artifact를 분리하지 않고, 매 배포 시 동일 commit에서 재생성한다.
- 실제 운영 반영 증거는 이 Slice의 CI 및 deploy run 완료 후 보존한다.
@@ -0,0 +1,32 @@
# KArtSell 배포 재기동 권한 계약
## Source
- 운영 호스트 `hz-prod-01`의 실제 sudo 정책 조회 결과
- 기존 `quantengine``taxbaik` 서비스의 특정 `systemctl restart` `NOPASSWD` 위임 패턴
- `.gitea/workflows/deploy.yml`
## Assumption
- 배포 SSH 계정은 `kjh2064`로 유지한다.
- 운영 서비스는 `/etc/systemd/system/kartsell.service`로 유지한다.
- DbMigrator와 artifact 복사는 현재처럼 `kjh2064` 권한으로 수행한다.
## Decision
`kjh2064`에 전체 sudo 권한을 부여하지 않고, 운영자가 한 번만 다음 단일 명령을 `/etc/sudoers.d/kartsell-deploy`에 등록한다.
```sudoers
kjh2064 ALL=(root) NOPASSWD: /usr/bin/systemctl restart kartsell
```
파일 권한은 `0440`이어야 하며 `visudo -cf /etc/sudoers.d/kartsell-deploy` 검증 후 적용한다. 이후 CI는 비대화형 `sudo -n systemctl restart kartsell`만 사용하므로 배포마다 비밀번호 입력이나 sudo 등록이 필요 없다.
## Deployment guard
워크플로우는 artifact 복사와 DbMigrator 실행 전에 `sudo -n -l`로 위임 존재 여부를 검사한다. 위임이 없으면 운영 DB를 변경하지 않고 exit 77로 종료한다.
## Unknown / Decision Required
- 이 파일을 운영 호스트에 설치할 권한은 root 운영자에게만 있다.
- 설치 후 필요한 증거: `visudo -cf` 결과, `sudo -n -l` 결과, 다음 deploy run의 성공 로그, 서비스 active 상태.
@@ -0,0 +1,168 @@
import { ref } from 'vue';
import { KsStatusTag } from '@/shared/ui/components';
const selectedId = ref('UI-001');
const items = [
{ id: 'UI-001', title: '공유 컴포넌트 카탈로그와 상태 프리뷰', owner: 'FE Platform', state: 'IN_PROGRESS' },
{ id: 'UI-002', title: 'WBS 실행 화면 및 요구사항 추적', owner: 'Delivery', state: 'IN_PROGRESS' },
{ id: 'DATA-001', title: 'DB 스키마 Read Model/API 계약', owner: 'Data Platform', state: 'DECISION_REQUIRED' },
{ id: 'OPS-001', title: 'Playwright 시각·상태행렬 검증', owner: 'QA', state: 'IN_PROGRESS' },
];
const __VLS_ctx = {
...{},
...{},
};
let __VLS_components;
let __VLS_intrinsics;
let __VLS_directives;
/** @type {__VLS_StyleScopedClasses['page-header']} */ ;
/** @type {__VLS_StyleScopedClasses['summary-grid']} */ ;
/** @type {__VLS_StyleScopedClasses['summary-grid']} */ ;
/** @type {__VLS_StyleScopedClasses['summary-grid']} */ ;
/** @type {__VLS_StyleScopedClasses['list']} */ ;
/** @type {__VLS_StyleScopedClasses['detail']} */ ;
/** @type {__VLS_StyleScopedClasses['wbs-row']} */ ;
/** @type {__VLS_StyleScopedClasses['wbs-row']} */ ;
/** @type {__VLS_StyleScopedClasses['wbs-row']} */ ;
/** @type {__VLS_StyleScopedClasses['wbs-row']} */ ;
/** @type {__VLS_StyleScopedClasses['detail']} */ ;
/** @type {__VLS_StyleScopedClasses['detail']} */ ;
/** @type {__VLS_StyleScopedClasses['detail']} */ ;
/** @type {__VLS_StyleScopedClasses['workspace']} */ ;
/** @type {__VLS_StyleScopedClasses['summary-grid']} */ ;
/** @type {__VLS_StyleScopedClasses['page-header']} */ ;
/** @type {__VLS_StyleScopedClasses['wbs-row']} */ ;
__VLS_asFunctionalElement1(__VLS_intrinsics.section, __VLS_intrinsics.section)({
...{ class: "page" },
'aria-labelledby': "wbs-title",
});
/** @type {__VLS_StyleScopedClasses['page']} */ ;
__VLS_asFunctionalElement1(__VLS_intrinsics.header, __VLS_intrinsics.header)({
...{ class: "page-header" },
});
/** @type {__VLS_StyleScopedClasses['page-header']} */ ;
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({});
__VLS_asFunctionalElement1(__VLS_intrinsics.p, __VLS_intrinsics.p)({
...{ class: "eyebrow" },
});
/** @type {__VLS_StyleScopedClasses['eyebrow']} */ ;
__VLS_asFunctionalElement1(__VLS_intrinsics.h1, __VLS_intrinsics.h1)({
id: "wbs-title",
});
__VLS_asFunctionalElement1(__VLS_intrinsics.p, __VLS_intrinsics.p)({});
let __VLS_0;
/** @ts-ignore @type { | typeof __VLS_components.KsStatusTag} */
KsStatusTag;
// @ts-ignore
const __VLS_1 = __VLS_asFunctionalComponent1(__VLS_0, new __VLS_0({
value: "AUTOMATION OFF",
severity: "warning",
}));
const __VLS_2 = __VLS_1({
value: "AUTOMATION OFF",
severity: "warning",
}, ...__VLS_functionalComponentArgsRest(__VLS_1));
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
...{ class: "summary-grid" },
});
/** @type {__VLS_StyleScopedClasses['summary-grid']} */ ;
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
...{ class: "ks-card" },
});
/** @type {__VLS_StyleScopedClasses['ks-card']} */ ;
__VLS_asFunctionalElement1(__VLS_intrinsics.strong, __VLS_intrinsics.strong)({});
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({});
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
...{ class: "ks-card" },
});
/** @type {__VLS_StyleScopedClasses['ks-card']} */ ;
__VLS_asFunctionalElement1(__VLS_intrinsics.strong, __VLS_intrinsics.strong)({});
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({});
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
...{ class: "ks-card" },
});
/** @type {__VLS_StyleScopedClasses['ks-card']} */ ;
__VLS_asFunctionalElement1(__VLS_intrinsics.strong, __VLS_intrinsics.strong)({});
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({});
__VLS_asFunctionalElement1(__VLS_intrinsics.div, __VLS_intrinsics.div)({
...{ class: "workspace" },
});
/** @type {__VLS_StyleScopedClasses['workspace']} */ ;
__VLS_asFunctionalElement1(__VLS_intrinsics.section, __VLS_intrinsics.section)({
...{ class: "ks-card list" },
'aria-labelledby': "wbs-list-title",
});
/** @type {__VLS_StyleScopedClasses['ks-card']} */ ;
/** @type {__VLS_StyleScopedClasses['list']} */ ;
__VLS_asFunctionalElement1(__VLS_intrinsics.h2, __VLS_intrinsics.h2)({
id: "wbs-list-title",
});
for (const [item] of __VLS_vFor((__VLS_ctx.items))) {
__VLS_asFunctionalElement1(__VLS_intrinsics.button, __VLS_intrinsics.button)({
...{ onClick: (...[$event]) => {
return (__VLS_ctx.selectedId = item.id);
// @ts-ignore
[items, selectedId,];
} },
key: (item.id),
...{ class: "wbs-row" },
...{ class: ({ selected: __VLS_ctx.selectedId === item.id }) },
type: "button",
});
/** @type {__VLS_StyleScopedClasses['wbs-row']} */ ;
/** @type {__VLS_StyleScopedClasses['selected']} */ ;
__VLS_asFunctionalElement1(__VLS_intrinsics.span, __VLS_intrinsics.span)({});
__VLS_asFunctionalElement1(__VLS_intrinsics.b, __VLS_intrinsics.b)({});
(item.id);
(item.title);
__VLS_asFunctionalElement1(__VLS_intrinsics.small, __VLS_intrinsics.small)({});
(item.owner);
let __VLS_5;
/** @ts-ignore @type { | typeof __VLS_components.KsStatusTag} */
KsStatusTag;
// @ts-ignore
const __VLS_6 = __VLS_asFunctionalComponent1(__VLS_5, new __VLS_5({
value: (item.state),
severity: (item.state === 'DONE' ? 'success' : item.state === 'DECISION_REQUIRED' ? 'danger' : 'info'),
}));
const __VLS_7 = __VLS_6({
value: (item.state),
severity: (item.state === 'DONE' ? 'success' : item.state === 'DECISION_REQUIRED' ? 'danger' : 'info'),
}, ...__VLS_functionalComponentArgsRest(__VLS_6));
// @ts-ignore
[selectedId,];
}
__VLS_asFunctionalElement1(__VLS_intrinsics.section, __VLS_intrinsics.section)({
...{ class: "ks-card detail" },
'aria-labelledby': "wbs-detail-title",
});
/** @type {__VLS_StyleScopedClasses['ks-card']} */ ;
/** @type {__VLS_StyleScopedClasses['detail']} */ ;
__VLS_asFunctionalElement1(__VLS_intrinsics.h2, __VLS_intrinsics.h2)({
id: "wbs-detail-title",
});
if (__VLS_ctx.selectedId === 'DATA-001') {
__VLS_asFunctionalElement1(__VLS_intrinsics.p, __VLS_intrinsics.p)({
...{ class: "warning" },
});
/** @type {__VLS_StyleScopedClasses['warning']} */ ;
__VLS_asFunctionalElement1(__VLS_intrinsics.dl, __VLS_intrinsics.dl)({});
__VLS_asFunctionalElement1(__VLS_intrinsics.dt, __VLS_intrinsics.dt)({});
__VLS_asFunctionalElement1(__VLS_intrinsics.dd, __VLS_intrinsics.dd)({});
__VLS_asFunctionalElement1(__VLS_intrinsics.dt, __VLS_intrinsics.dt)({});
__VLS_asFunctionalElement1(__VLS_intrinsics.dd, __VLS_intrinsics.dd)({});
}
else {
__VLS_asFunctionalElement1(__VLS_intrinsics.p, __VLS_intrinsics.p)({});
__VLS_asFunctionalElement1(__VLS_intrinsics.dl, __VLS_intrinsics.dl)({});
__VLS_asFunctionalElement1(__VLS_intrinsics.dt, __VLS_intrinsics.dt)({});
__VLS_asFunctionalElement1(__VLS_intrinsics.dd, __VLS_intrinsics.dd)({});
(__VLS_ctx.selectedId);
__VLS_asFunctionalElement1(__VLS_intrinsics.dt, __VLS_intrinsics.dt)({});
__VLS_asFunctionalElement1(__VLS_intrinsics.dd, __VLS_intrinsics.dd)({});
__VLS_asFunctionalElement1(__VLS_intrinsics.dt, __VLS_intrinsics.dt)({});
__VLS_asFunctionalElement1(__VLS_intrinsics.dd, __VLS_intrinsics.dd)({});
}
// @ts-ignore
[selectedId, selectedId,];
const __VLS_export = (await import('vue')).defineComponent({});
export default {};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long