refine dashboard settings and inbox UX
This commit is contained in:
@@ -85,22 +85,6 @@ const baseProps: ServicePanelProps = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
inbox: [
|
|
||||||
{
|
|
||||||
createdAt: '2026-04-28T04:10:00.000Z',
|
|
||||||
groupFolder: 'eyejokerdb',
|
|
||||||
groupKey: 'ci',
|
|
||||||
id: 'ci-1',
|
|
||||||
kind: 'ci-failure',
|
|
||||||
lastOccurredAt: '2026-04-28T04:12:00.000Z',
|
|
||||||
occurrences: 2,
|
|
||||||
occurredAt: '2026-04-28T04:10:00.000Z',
|
|
||||||
severity: 'error',
|
|
||||||
source: 'status-snapshot',
|
|
||||||
summary: 'CI failed',
|
|
||||||
title: 'CI failed',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
operations: {
|
operations: {
|
||||||
serviceRestarts: [
|
serviceRestarts: [
|
||||||
{
|
{
|
||||||
@@ -113,6 +97,13 @@ const baseProps: ServicePanelProps = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
tasks: {
|
||||||
|
active: 0,
|
||||||
|
completed: 0,
|
||||||
|
paused: 2,
|
||||||
|
total: 2,
|
||||||
|
watchers: { active: 0, completed: 0, paused: 2 },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
serviceActionKey: null,
|
serviceActionKey: null,
|
||||||
|
|||||||
@@ -67,11 +67,7 @@ export function ServicePanel({
|
|||||||
},
|
},
|
||||||
{ pendingTasks: 0, pendingMessageRooms: 0 },
|
{ pendingTasks: 0, pendingMessageRooms: 0 },
|
||||||
);
|
);
|
||||||
const ciFailures = overview.inbox.reduce(
|
const ciFailures = overview.tasks.watchers.paused;
|
||||||
(count, item) =>
|
|
||||||
item.kind === 'ci-failure' ? count + item.occurrences : count,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const healthLevel: HealthLevel =
|
const healthLevel: HealthLevel =
|
||||||
down > 0 ? 'down' : stale > 0 || ciFailures > 0 ? 'stale' : 'ok';
|
down > 0 ? 'down' : stale > 0 || ciFailures > 0 ? 'stale' : 'ok';
|
||||||
const affectedServices = serviceLevels.filter((item) => item.level !== 'ok');
|
const affectedServices = serviceLevels.filter((item) => item.level !== 'ok');
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ describe('SettingsPanel', () => {
|
|||||||
const html = renderToStaticMarkup(createElement(SettingsPanel, baseProps));
|
const html = renderToStaticMarkup(createElement(SettingsPanel, baseProps));
|
||||||
|
|
||||||
expect(html).toContain('settings-panel');
|
expect(html).toContain('settings-panel');
|
||||||
expect(html).toContain('settings-hero');
|
expect(html).not.toContain('settings-hero');
|
||||||
|
expect(html).toContain('settings-sidebar');
|
||||||
expect(html).toContain('settings-nav');
|
expect(html).toContain('settings-nav');
|
||||||
expect(html).toContain(t.settings.nicknameLabel);
|
expect(html).toContain(t.settings.nicknameLabel);
|
||||||
expect(html).toContain('value="Night Owl"');
|
expect(html).toContain('value="Night Owl"');
|
||||||
@@ -41,7 +42,9 @@ describe('SettingsPanel', () => {
|
|||||||
expect(html).toContain('aria-controls="settings-codex"');
|
expect(html).toContain('aria-controls="settings-codex"');
|
||||||
expect(html).not.toContain('href="#settings-codex"');
|
expect(html).not.toContain('href="#settings-codex"');
|
||||||
expect(html).toContain('/goal');
|
expect(html).toContain('/goal');
|
||||||
expect(html).toContain('변경 적용');
|
expect(html).toContain('settings-apply-card');
|
||||||
|
expect(html).not.toContain('settings-apply-bar');
|
||||||
|
expect(html).toContain('저장 후 재시작');
|
||||||
expect(html).toContain('불러오는 중');
|
expect(html).toContain('불러오는 중');
|
||||||
expect(html).toContain('Claude');
|
expect(html).toContain('Claude');
|
||||||
expect(html).toContain('계정');
|
expect(html).toContain('계정');
|
||||||
|
|||||||
@@ -62,9 +62,11 @@ export function SettingsPanel({
|
|||||||
}: SettingsPanelProps) {
|
}: SettingsPanelProps) {
|
||||||
return (
|
return (
|
||||||
<div className="settings-panel">
|
<div className="settings-panel">
|
||||||
<SettingsHero />
|
|
||||||
<div className="settings-layout">
|
<div className="settings-layout">
|
||||||
<SettingsNav />
|
<aside className="settings-sidebar" aria-label="설정 탐색과 적용">
|
||||||
|
<SettingsNav />
|
||||||
|
<SettingsApplyCard onRestartStack={onRestartStack} />
|
||||||
|
</aside>
|
||||||
<main className="settings-content" aria-label="설정 항목">
|
<main className="settings-content" aria-label="설정 항목">
|
||||||
<section className="settings-section" id="settings-general">
|
<section className="settings-section" id="settings-general">
|
||||||
<SettingsSectionHeading
|
<SettingsSectionHeading
|
||||||
@@ -109,8 +111,6 @@ export function SettingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<SettingsApplyBar onRestartStack={onRestartStack} />
|
|
||||||
|
|
||||||
<ModelSettings />
|
<ModelSettings />
|
||||||
|
|
||||||
<MoaSettingsPanel />
|
<MoaSettingsPanel />
|
||||||
@@ -134,26 +134,6 @@ export function SettingsPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsHero() {
|
|
||||||
return (
|
|
||||||
<header className="settings-hero">
|
|
||||||
<div>
|
|
||||||
<span className="settings-kicker">Operations console</span>
|
|
||||||
<h2>설정</h2>
|
|
||||||
<p>
|
|
||||||
모델, MoA, Codex 실험 기능, 계정을 한 화면에서 조정합니다. 런타임에
|
|
||||||
반영되는 항목은 저장 후 한 번만 재시작하면 됩니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="settings-hero-badges" aria-label="주요 설정">
|
|
||||||
<span>Codex /goal</span>
|
|
||||||
<span>MoA</span>
|
|
||||||
<span>Accounts</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SettingsNav() {
|
function SettingsNav() {
|
||||||
const scrollToSection = (targetId: string) => {
|
const scrollToSection = (targetId: string) => {
|
||||||
document
|
document
|
||||||
@@ -197,16 +177,13 @@ function SettingsSectionHeading({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsApplyBar({ onRestartStack }: { onRestartStack: () => void }) {
|
function SettingsApplyCard({ onRestartStack }: { onRestartStack: () => void }) {
|
||||||
return (
|
return (
|
||||||
<section className="settings-apply-bar" aria-label="변경 적용">
|
<section className="settings-apply-card" aria-label="변경 적용">
|
||||||
<div>
|
<div>
|
||||||
<span className="settings-kicker">Apply changes</span>
|
<span className="settings-kicker">Apply</span>
|
||||||
<strong>변경 적용</strong>
|
<strong>저장 후 재시작</strong>
|
||||||
<small>
|
<small>모델, MoA, Codex, 계정 변경은 스택 재시작 후 반영됩니다.</small>
|
||||||
모델, MoA, Codex 실험 기능, 계정 변경은 저장 후 스택 재시작으로
|
|
||||||
적용됩니다.
|
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="settings-restart"
|
className="settings-restart"
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ export const messages = {
|
|||||||
heartbeat: '하트비트',
|
heartbeat: '하트비트',
|
||||||
healthSignals: '운영 신호',
|
healthSignals: '운영 신호',
|
||||||
inbox: 'Inbox',
|
inbox: 'Inbox',
|
||||||
inboxQueue: 'Queue',
|
inboxQueue: '처리할 액션',
|
||||||
usage: '사용량',
|
usage: '사용량',
|
||||||
usageWindow: '남음 / 속도',
|
usageWindow: '남음 / 속도',
|
||||||
rooms: '룸',
|
rooms: '룸',
|
||||||
@@ -392,7 +392,7 @@ export const messages = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
inbox: {
|
inbox: {
|
||||||
empty: 'Inbox 없음.',
|
empty: '처리할 Inbox 액션 없음.',
|
||||||
cardsAria: 'Inbox 항목',
|
cardsAria: 'Inbox 항목',
|
||||||
summary: 'Inbox 요약',
|
summary: 'Inbox 요약',
|
||||||
total: '전체',
|
total: '전체',
|
||||||
@@ -609,7 +609,7 @@ export const messages = {
|
|||||||
heartbeat: 'Heartbeat',
|
heartbeat: 'Heartbeat',
|
||||||
healthSignals: 'Signals',
|
healthSignals: 'Signals',
|
||||||
inbox: 'Inbox',
|
inbox: 'Inbox',
|
||||||
inboxQueue: 'Queue',
|
inboxQueue: 'Action queue',
|
||||||
usage: 'Usage',
|
usage: 'Usage',
|
||||||
usageWindow: 'Remaining / speed',
|
usageWindow: 'Remaining / speed',
|
||||||
rooms: 'Rooms',
|
rooms: 'Rooms',
|
||||||
@@ -650,7 +650,7 @@ export const messages = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
inbox: {
|
inbox: {
|
||||||
empty: 'No queued work.',
|
empty: 'No Inbox actions.',
|
||||||
cardsAria: 'Inbox items',
|
cardsAria: 'Inbox items',
|
||||||
summary: 'Inbox summary',
|
summary: 'Inbox summary',
|
||||||
total: 'Total',
|
total: 'Total',
|
||||||
@@ -867,7 +867,7 @@ export const messages = {
|
|||||||
heartbeat: '心跳',
|
heartbeat: '心跳',
|
||||||
healthSignals: '运行信号',
|
healthSignals: '运行信号',
|
||||||
inbox: '收件',
|
inbox: '收件',
|
||||||
inboxQueue: '待处理',
|
inboxQueue: '待处理操作',
|
||||||
usage: '用量',
|
usage: '用量',
|
||||||
usageWindow: '剩余 / 速度',
|
usageWindow: '剩余 / 速度',
|
||||||
rooms: '房间',
|
rooms: '房间',
|
||||||
@@ -908,7 +908,7 @@ export const messages = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
inbox: {
|
inbox: {
|
||||||
empty: '暂无收件。',
|
empty: '暂无待处理收件操作。',
|
||||||
cardsAria: '收件项',
|
cardsAria: '收件项',
|
||||||
summary: '收件摘要',
|
summary: '收件摘要',
|
||||||
total: '全部',
|
total: '全部',
|
||||||
@@ -1127,7 +1127,7 @@ export const messages = {
|
|||||||
heartbeat: 'ハートビート',
|
heartbeat: 'ハートビート',
|
||||||
healthSignals: '運用シグナル',
|
healthSignals: '運用シグナル',
|
||||||
inbox: '受信',
|
inbox: '受信',
|
||||||
inboxQueue: '対応待ち',
|
inboxQueue: '対応アクション',
|
||||||
usage: '使用量',
|
usage: '使用量',
|
||||||
usageWindow: '残量 / 速度',
|
usageWindow: '残量 / 速度',
|
||||||
rooms: 'ルーム',
|
rooms: 'ルーム',
|
||||||
@@ -1168,7 +1168,7 @@ export const messages = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
inbox: {
|
inbox: {
|
||||||
empty: '受信なし。',
|
empty: '対応する受信アクションはありません。',
|
||||||
cardsAria: '受信項目',
|
cardsAria: '受信項目',
|
||||||
summary: '受信サマリー',
|
summary: '受信サマリー',
|
||||||
total: '全体',
|
total: '全体',
|
||||||
|
|||||||
@@ -628,65 +628,10 @@ button:disabled {
|
|||||||
|
|
||||||
.settings-panel {
|
.settings-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 18px;
|
|
||||||
max-width: 1160px;
|
max-width: 1160px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-hero {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
gap: 18px;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 20px;
|
|
||||||
border: 1px solid rgba(214, 130, 88, 0.18);
|
|
||||||
border-radius: 18px;
|
|
||||||
background:
|
|
||||||
radial-gradient(
|
|
||||||
circle at 12% 0%,
|
|
||||||
rgba(214, 130, 88, 0.2),
|
|
||||||
transparent 28rem
|
|
||||||
),
|
|
||||||
linear-gradient(135deg, rgba(255, 255, 255, 0.055), transparent 52%),
|
|
||||||
var(--panel);
|
|
||||||
box-shadow: var(--card-shadow-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-hero::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
right: -70px;
|
|
||||||
top: -90px;
|
|
||||||
width: 260px;
|
|
||||||
height: 260px;
|
|
||||||
border: 1px solid rgba(214, 130, 88, 0.2);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(214, 130, 88, 0.04);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-hero > div {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-hero h2 {
|
|
||||||
margin: 4px 0 6px;
|
|
||||||
font-size: clamp(26px, 4vw, 42px);
|
|
||||||
line-height: 0.95;
|
|
||||||
letter-spacing: -0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-hero p {
|
|
||||||
max-width: 62ch;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-kicker {
|
.settings-kicker {
|
||||||
color: var(--accent-strong);
|
color: var(--accent-strong);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
@@ -695,24 +640,6 @@ button:disabled {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-hero-badges {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: flex-end;
|
|
||||||
min-width: 220px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-hero-badges span {
|
|
||||||
padding: 6px 9px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(255, 255, 255, 0.045);
|
|
||||||
color: var(--bg-ink);
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-layout {
|
.settings-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 220px minmax(0, 1fr);
|
grid-template-columns: 220px minmax(0, 1fr);
|
||||||
@@ -720,9 +647,14 @@ button:disabled {
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-nav {
|
.settings-sidebar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 72px;
|
top: 72px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
@@ -891,39 +823,34 @@ button:disabled {
|
|||||||
background: rgba(255, 255, 255, 0.018);
|
background: rgba(255, 255, 255, 0.018);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-apply-bar {
|
.settings-apply-card {
|
||||||
position: sticky;
|
display: grid;
|
||||||
top: 72px;
|
|
||||||
z-index: 5;
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: center;
|
padding: 13px;
|
||||||
justify-content: space-between;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border: 1px solid rgba(214, 130, 88, 0.32);
|
border: 1px solid rgba(214, 130, 88, 0.32);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
background:
|
background:
|
||||||
linear-gradient(135deg, rgba(214, 130, 88, 0.2), transparent 56%),
|
linear-gradient(145deg, rgba(214, 130, 88, 0.16), transparent 62%),
|
||||||
rgba(16, 20, 18, 0.96);
|
rgba(19, 24, 22, 0.88);
|
||||||
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.28);
|
box-shadow: var(--card-shadow);
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-apply-bar > div {
|
.settings-apply-card > div {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-apply-bar strong {
|
.settings-apply-card strong {
|
||||||
color: var(--bg-ink);
|
color: var(--bg-ink);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-apply-bar small {
|
.settings-apply-card small {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-row-inline {
|
.settings-row-inline {
|
||||||
@@ -1272,23 +1199,25 @@ button:disabled {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-nav {
|
.settings-sidebar {
|
||||||
position: static;
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav {
|
||||||
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-apply-card {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.settings-hero,
|
.settings-apply-card {
|
||||||
.settings-apply-bar {
|
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex-direction: column;
|
grid-template-columns: 1fr;
|
||||||
}
|
|
||||||
|
|
||||||
.settings-hero-badges {
|
|
||||||
justify-content: flex-start;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-form-grid,
|
.settings-form-grid,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { createServer, type ViteDevServer } from 'vite';
|
|||||||
interface MockApiState {
|
interface MockApiState {
|
||||||
codexFeatures: { goals: boolean };
|
codexFeatures: { goals: boolean };
|
||||||
codexFeatureUpdates: number;
|
codexFeatureUpdates: number;
|
||||||
|
ciWatcherFailures: number;
|
||||||
restartRequests: number;
|
restartRequests: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +111,30 @@ async function main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await runScenario(
|
||||||
|
'inbox stays focused on actionable work',
|
||||||
|
browser,
|
||||||
|
baseUrl,
|
||||||
|
async (page, state) => {
|
||||||
|
state.ciWatcherFailures = 2;
|
||||||
|
|
||||||
|
await page.goto(new URL('/#/inbox', baseUrl).toString(), {
|
||||||
|
waitUntil: 'networkidle',
|
||||||
|
});
|
||||||
|
|
||||||
|
await assertVisible(page.locator('#inbox .empty-state'));
|
||||||
|
assert.equal(await page.getByText(/CI 실패|CI failure/).count(), 0);
|
||||||
|
assert.equal(
|
||||||
|
await page.getByRole('button', { name: 'Dismiss' }).count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// This scenario protects the Inbox information architecture. The
|
||||||
|
// accessibility scan stays scoped to Settings, where this UX suite
|
||||||
|
// currently has stable interactive coverage.
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
console.log('dashboard:ux passed');
|
console.log('dashboard:ux passed');
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
@@ -156,6 +181,7 @@ function createMockApiState(): MockApiState {
|
|||||||
return {
|
return {
|
||||||
codexFeatures: { goals: false },
|
codexFeatures: { goals: false },
|
||||||
codexFeatureUpdates: 0,
|
codexFeatureUpdates: 0,
|
||||||
|
ciWatcherFailures: 0,
|
||||||
restartRequests: 0,
|
restartRequests: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -165,9 +191,10 @@ async function openSettings(page: Page, baseUrl: string) {
|
|||||||
waitUntil: 'networkidle',
|
waitUntil: 'networkidle',
|
||||||
});
|
});
|
||||||
await assertVisible(page.locator('.settings-panel'));
|
await assertVisible(page.locator('.settings-panel'));
|
||||||
await assertVisible(page.locator('.settings-hero'));
|
assert.equal(await page.locator('.settings-hero').count(), 0);
|
||||||
|
await assertVisible(page.locator('.settings-sidebar'));
|
||||||
await assertVisible(page.locator('.settings-nav'));
|
await assertVisible(page.locator('.settings-nav'));
|
||||||
await assertVisible(page.locator('.settings-apply-bar'));
|
await assertVisible(page.locator('.settings-apply-card'));
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page.getByRole('button', { name: '스택 재시작' }).count(),
|
await page.getByRole('button', { name: '스택 재시작' }).count(),
|
||||||
1,
|
1,
|
||||||
@@ -209,9 +236,9 @@ async function handleMockApi(route: Route, state: MockApiState) {
|
|||||||
tasks: {
|
tasks: {
|
||||||
total: 0,
|
total: 0,
|
||||||
active: 0,
|
active: 0,
|
||||||
paused: 0,
|
paused: state.ciWatcherFailures,
|
||||||
completed: 0,
|
completed: 0,
|
||||||
watchers: { active: 0, paused: 0, completed: 0 },
|
watchers: { active: 0, paused: state.ciWatcherFailures, completed: 0 },
|
||||||
},
|
},
|
||||||
usage: { rows: [], fetchedAt: null },
|
usage: { rows: [], fetchedAt: null },
|
||||||
operations: { serviceRestarts: [] },
|
operations: { serviceRestarts: [] },
|
||||||
|
|||||||
@@ -761,7 +761,7 @@ describe('web dashboard room activity data', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('web dashboard inbox data', () => {
|
describe('web dashboard inbox data', () => {
|
||||||
it('builds typed inbox items from pending rooms, paired tasks, and CI failures', () => {
|
it('builds typed inbox items from pending rooms and paired tasks only', () => {
|
||||||
const snapshots: StatusSnapshot[] = [
|
const snapshots: StatusSnapshot[] = [
|
||||||
{
|
{
|
||||||
serviceId: 'codex-main',
|
serviceId: 'codex-main',
|
||||||
@@ -840,7 +840,6 @@ describe('web dashboard inbox data', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(overview.inbox.map((item) => item.kind)).toEqual([
|
expect(overview.inbox.map((item) => item.kind)).toEqual([
|
||||||
'ci-failure',
|
|
||||||
'approval',
|
'approval',
|
||||||
'reviewer-request',
|
'reviewer-request',
|
||||||
'pending-room',
|
'pending-room',
|
||||||
@@ -866,18 +865,17 @@ describe('web dashboard inbox data', () => {
|
|||||||
occurredAt: '2026-04-26T05:03:00.000Z',
|
occurredAt: '2026-04-26T05:03:00.000Z',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const ciFailure = overview.inbox.find((item) => item.kind === 'ci-failure');
|
expect(overview.inbox.some((item) => item.kind === 'ci-failure')).toBe(
|
||||||
expect(ciFailure).toMatchObject({
|
false,
|
||||||
id: 'ci:ci-2',
|
);
|
||||||
severity: 'error',
|
expect(overview.inbox.some((item) => item.taskId === 'ci-1')).toBe(false);
|
||||||
occurrences: 2,
|
expect(overview.inbox.some((item) => item.taskId === 'ci-2')).toBe(false);
|
||||||
source: 'scheduled-task',
|
|
||||||
taskId: 'ci-2',
|
|
||||||
lastOccurredAt: '2026-04-26T05:07:00.000Z',
|
|
||||||
});
|
|
||||||
expect(ciFailure?.summary).toContain('BOT_TOKEN=<redacted>');
|
|
||||||
expect(ciFailure?.summary).not.toContain('plain-secret-value');
|
|
||||||
expect(overview.inbox.some((item) => item.taskId === 'cron-1')).toBe(false);
|
expect(overview.inbox.some((item) => item.taskId === 'cron-1')).toBe(false);
|
||||||
expect(overview.inbox.some((item) => item.taskId === 'done-1')).toBe(false);
|
expect(overview.inbox.some((item) => item.taskId === 'done-1')).toBe(false);
|
||||||
|
expect(overview.tasks.watchers).toEqual({
|
||||||
|
active: 1,
|
||||||
|
paused: 1,
|
||||||
|
completed: 0,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -183,10 +183,6 @@ function buildPromptPreview(prompt: string): string {
|
|||||||
return truncateText(redactSensitiveText(prompt).replace(/\s+/g, ' ').trim());
|
return truncateText(redactSensitiveText(prompt).replace(/\s+/g, ' ').trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildInboxPreview(value: string): string {
|
|
||||||
return truncateText(redactSensitiveText(value).replace(/\s+/g, ' ').trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRoomBody(value: string): string {
|
function buildRoomBody(value: string): string {
|
||||||
return redactSensitiveText(value).trim();
|
return redactSensitiveText(value).trim();
|
||||||
}
|
}
|
||||||
@@ -254,14 +250,6 @@ function normalizeStructuredVisibleContent(value: string): {
|
|||||||
return splitLegacyImageTags(value);
|
return splitLegacyImageTags(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stableHash(value: string): string {
|
|
||||||
let hash = 0;
|
|
||||||
for (let index = 0; index < value.length; index += 1) {
|
|
||||||
hash = (hash * 31 + value.charCodeAt(index)) | 0;
|
|
||||||
}
|
|
||||||
return Math.abs(hash).toString(36);
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectUsageRows(snapshots: StatusSnapshot[]): UsageRowSnapshot[] {
|
function collectUsageRows(snapshots: StatusSnapshot[]): UsageRowSnapshot[] {
|
||||||
const rows: UsageRowSnapshot[] = [];
|
const rows: UsageRowSnapshot[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -282,21 +270,6 @@ function collectUsageRows(snapshots: StatusSnapshot[]): UsageRowSnapshot[] {
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
function failedTaskResult(task: ScheduledTask): string | null {
|
|
||||||
if (!task.last_result) return null;
|
|
||||||
const normalized = task.last_result.toLowerCase();
|
|
||||||
if (
|
|
||||||
normalized.includes('fail') ||
|
|
||||||
normalized.includes('error') ||
|
|
||||||
normalized.includes('timeout') ||
|
|
||||||
normalized.includes('cancel') ||
|
|
||||||
normalized.includes('reject')
|
|
||||||
) {
|
|
||||||
return buildInboxPreview(task.last_result);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pairedTaskInboxKind(
|
function pairedTaskInboxKind(
|
||||||
status: PairedTask['status'],
|
status: PairedTask['status'],
|
||||||
): InboxItemKind | null {
|
): InboxItemKind | null {
|
||||||
@@ -383,31 +356,6 @@ function collectInboxItems(args: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const task of args.tasks) {
|
|
||||||
if (!isWatchCiTask(task)) continue;
|
|
||||||
const result = failedTaskResult(task);
|
|
||||||
if (!result) continue;
|
|
||||||
|
|
||||||
items.push({
|
|
||||||
id: `ci:${task.id}`,
|
|
||||||
groupKey: `ci:${stableHash(result.toLowerCase())}`,
|
|
||||||
kind: 'ci-failure',
|
|
||||||
severity: task.status === 'paused' ? 'error' : 'warn',
|
|
||||||
title: 'CI watcher failed',
|
|
||||||
summary: result,
|
|
||||||
occurredAt: task.last_run ?? task.created_at,
|
|
||||||
lastOccurredAt: task.last_run ?? task.created_at,
|
|
||||||
createdAt: args.createdAt,
|
|
||||||
occurrences: 1,
|
|
||||||
source: 'scheduled-task',
|
|
||||||
roomJid: task.chat_jid,
|
|
||||||
groupFolder: task.group_folder,
|
|
||||||
serviceId: task.agent_type ?? undefined,
|
|
||||||
taskId: task.id,
|
|
||||||
taskStatus: task.status,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const severityRank: Record<InboxItemSeverity, number> = {
|
const severityRank: Record<InboxItemSeverity, number> = {
|
||||||
error: 0,
|
error: 0,
|
||||||
warn: 1,
|
warn: 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user