feat(dashboard): add inbox and health views
This commit is contained in:
@@ -25,21 +25,27 @@ interface DashboardState {
|
||||
}
|
||||
|
||||
type UsageRow = DashboardOverview['usage']['rows'][number];
|
||||
type InboxItem = DashboardOverview['inbox'][number];
|
||||
type RiskLevel = 'ok' | 'warn' | 'critical';
|
||||
type UsageGroup = 'primary' | 'codex';
|
||||
type DashboardView = 'usage' | 'health' | 'rooms' | 'scheduled';
|
||||
type DashboardView = 'usage' | 'inbox' | 'health' | 'rooms' | 'scheduled';
|
||||
type TaskGroupKey = 'watchers' | 'scheduled' | 'paused' | 'completed';
|
||||
type TaskResultTone = 'ok' | 'fail' | 'none';
|
||||
type InboxFilter = 'all' | InboxItem['kind'];
|
||||
type HealthLevel = 'ok' | 'stale' | 'down';
|
||||
|
||||
const REFRESH_INTERVAL_MS = 15_000;
|
||||
const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale';
|
||||
const DEFAULT_VIEW: DashboardView = 'usage';
|
||||
const HEALTH_STALE_MS = 5 * 60_000;
|
||||
const HEALTH_DOWN_MS = 15 * 60_000;
|
||||
|
||||
function isDashboardView(
|
||||
value: string | null | undefined,
|
||||
): value is DashboardView {
|
||||
return (
|
||||
value === 'usage' ||
|
||||
value === 'inbox' ||
|
||||
value === 'health' ||
|
||||
value === 'rooms' ||
|
||||
value === 'scheduled'
|
||||
@@ -215,9 +221,47 @@ function taskDisplayName(task: DashboardTask, t: Messages): string {
|
||||
return task.id;
|
||||
}
|
||||
|
||||
const INBOX_FILTERS: InboxFilter[] = [
|
||||
'all',
|
||||
'ci-failure',
|
||||
'approval',
|
||||
'reviewer-request',
|
||||
'arbiter-request',
|
||||
'pending-room',
|
||||
'mention',
|
||||
];
|
||||
|
||||
function serviceAgeMs(
|
||||
service: DashboardOverview['services'][number],
|
||||
generatedAt: string,
|
||||
): number | null {
|
||||
const updated = new Date(service.updatedAt).getTime();
|
||||
const now = new Date(generatedAt).getTime();
|
||||
if (Number.isNaN(updated) || Number.isNaN(now)) return null;
|
||||
return Math.max(0, now - updated);
|
||||
}
|
||||
|
||||
function serviceHealthLevel(
|
||||
service: DashboardOverview['services'][number],
|
||||
generatedAt: string,
|
||||
): HealthLevel {
|
||||
const age = serviceAgeMs(service, generatedAt);
|
||||
if (age === null) return 'stale';
|
||||
if (age >= HEALTH_DOWN_MS) return 'down';
|
||||
if (age >= HEALTH_STALE_MS) return 'stale';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
function inboxTargetHref(item: InboxItem): string | null {
|
||||
if (item.taskId) return '#/scheduled';
|
||||
if (item.roomJid || item.groupFolder) return '#/rooms';
|
||||
return null;
|
||||
}
|
||||
|
||||
function navItems(t: Messages) {
|
||||
return [
|
||||
{ href: '#/usage', label: t.nav.usage, view: 'usage' as const },
|
||||
{ href: '#/inbox', label: t.nav.inbox, view: 'inbox' as const },
|
||||
{ href: '#/health', label: t.nav.health, view: 'health' as const },
|
||||
{ href: '#/rooms', label: t.nav.rooms, view: 'rooms' as const },
|
||||
{ href: '#/scheduled', label: t.nav.scheduled, view: 'scheduled' as const },
|
||||
@@ -496,7 +540,7 @@ function ControlRail({ data, t }: { data: DashboardState; t: Messages }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ServicePanel({
|
||||
function InboxPanel({
|
||||
overview,
|
||||
locale,
|
||||
t,
|
||||
@@ -505,42 +549,229 @@ function ServicePanel({
|
||||
locale: Locale;
|
||||
t: Messages;
|
||||
}) {
|
||||
if (overview.services.length === 0) {
|
||||
return <EmptyState>{t.service.empty}</EmptyState>;
|
||||
const [filter, setFilter] = useState<InboxFilter>('all');
|
||||
const items = overview.inbox ?? [];
|
||||
const counts = useMemo(() => {
|
||||
const next: Record<InboxFilter, number> = {
|
||||
all: items.length,
|
||||
'pending-room': 0,
|
||||
'reviewer-request': 0,
|
||||
approval: 0,
|
||||
'arbiter-request': 0,
|
||||
'ci-failure': 0,
|
||||
mention: 0,
|
||||
};
|
||||
for (const item of items) next[item.kind] += 1;
|
||||
return next;
|
||||
}, [items]);
|
||||
const filteredItems =
|
||||
filter === 'all' ? items : items.filter((item) => item.kind === filter);
|
||||
const severityCounts = items.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.severity] += 1;
|
||||
return acc;
|
||||
},
|
||||
{ error: 0, warn: 0, info: 0 },
|
||||
);
|
||||
|
||||
if (items.length === 0) {
|
||||
return <EmptyState>{t.inbox.empty}</EmptyState>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="service-grid">
|
||||
{overview.services.map((service) => (
|
||||
<section className="card service-card" key={service.serviceId}>
|
||||
<div>
|
||||
<span className="eyebrow">{service.agentType}</span>
|
||||
<h3>{service.assistantName}</h3>
|
||||
</div>
|
||||
<div className="heartbeat-line">
|
||||
<span />
|
||||
<small>
|
||||
{t.service.heartbeat} {formatDate(service.updatedAt, locale)}
|
||||
</small>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>{t.service.service}</dt>
|
||||
<dd>{service.serviceId}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t.service.rooms}</dt>
|
||||
<dd>
|
||||
{service.activeRooms}/{service.totalRooms} {t.status.active}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t.service.updated}</dt>
|
||||
<dd>{formatDate(service.updatedAt, locale)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
))}
|
||||
<div className="inbox-board">
|
||||
<section className="inbox-summary" aria-label={t.inbox.summary}>
|
||||
<div>
|
||||
<span>{t.inbox.total}</span>
|
||||
<strong>{items.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{t.inbox.severity.error}</span>
|
||||
<strong>{severityCounts.error}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{t.inbox.severity.warn}</span>
|
||||
<strong>{severityCounts.warn}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{t.inbox.severity.info}</span>
|
||||
<strong>{severityCounts.info}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="inbox-filters" aria-label={t.inbox.filters}>
|
||||
{INBOX_FILTERS.map((item) => {
|
||||
if (item !== 'all' && counts[item] === 0) return null;
|
||||
const label = item === 'all' ? t.inbox.all : t.inbox.kinds[item];
|
||||
return (
|
||||
<button
|
||||
aria-pressed={filter === item}
|
||||
className={filter === item ? 'is-active' : undefined}
|
||||
key={item}
|
||||
onClick={() => setFilter(item)}
|
||||
type="button"
|
||||
>
|
||||
{label}
|
||||
<span>{counts[item]}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="inbox-list" aria-label={t.inbox.cardsAria}>
|
||||
{filteredItems.map((item) => {
|
||||
const href = inboxTargetHref(item);
|
||||
return (
|
||||
<article
|
||||
className={`inbox-card inbox-${item.severity}`}
|
||||
key={item.id}
|
||||
>
|
||||
<div className="inbox-card-head">
|
||||
<div>
|
||||
<span className="eyebrow">{t.inbox.kinds[item.kind]}</span>
|
||||
<strong>{item.title}</strong>
|
||||
</div>
|
||||
<span className={`pill pill-${item.severity}`}>
|
||||
{t.inbox.severity[item.severity]}
|
||||
</span>
|
||||
</div>
|
||||
<p>{item.summary || t.inbox.noSummary}</p>
|
||||
<div className="inbox-meta">
|
||||
<span>
|
||||
<small>{t.inbox.occurred}</small>
|
||||
<strong>{formatDate(item.occurredAt, locale)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
<small>{t.inbox.source}</small>
|
||||
<strong>{item.source}</strong>
|
||||
</span>
|
||||
<span>
|
||||
<small>{t.inbox.target}</small>
|
||||
<strong>
|
||||
{item.taskId ??
|
||||
item.roomName ??
|
||||
item.groupFolder ??
|
||||
item.roomJid ??
|
||||
'-'}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
{href ? (
|
||||
<a className="inbox-target" href={href}>
|
||||
{item.taskId ? t.inbox.openTask : t.inbox.openRoom}
|
||||
</a>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HealthPanel({
|
||||
data,
|
||||
locale,
|
||||
t,
|
||||
}: {
|
||||
data: DashboardState;
|
||||
locale: Locale;
|
||||
t: Messages;
|
||||
}) {
|
||||
const services = data.overview.services;
|
||||
const serviceLevels = services.map((service) => ({
|
||||
service,
|
||||
level: serviceHealthLevel(service, data.overview.generatedAt),
|
||||
age: serviceAgeMs(service, data.overview.generatedAt),
|
||||
}));
|
||||
const down = serviceLevels.filter((item) => item.level === 'down').length;
|
||||
const stale = serviceLevels.filter((item) => item.level === 'stale').length;
|
||||
const queue = data.snapshots.reduce(
|
||||
(acc, snapshot) => {
|
||||
for (const entry of snapshot.entries) {
|
||||
acc.pendingTasks += entry.pendingTasks;
|
||||
if (entry.pendingMessages) acc.pendingMessageRooms += 1;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ pendingTasks: 0, pendingMessageRooms: 0 },
|
||||
);
|
||||
const ciFailures = data.overview.inbox.filter(
|
||||
(item) => item.kind === 'ci-failure',
|
||||
).length;
|
||||
const healthLevel: HealthLevel =
|
||||
down > 0 ? 'down' : stale > 0 || ciFailures > 0 ? 'stale' : 'ok';
|
||||
|
||||
return (
|
||||
<div className="health-board">
|
||||
<section className={`health-overview health-${healthLevel}`}>
|
||||
<div>
|
||||
<span className="eyebrow">{t.health.system}</span>
|
||||
<strong>{t.health.levels[healthLevel]}</strong>
|
||||
</div>
|
||||
<p>{t.health.summary}</p>
|
||||
</section>
|
||||
|
||||
<section className="health-signals" aria-label={t.health.signals}>
|
||||
<div>
|
||||
<span>{t.health.services}</span>
|
||||
<strong>
|
||||
{services.length - stale - down}/{services.length}
|
||||
</strong>
|
||||
<small>{t.health.fresh}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>{t.health.stale}</span>
|
||||
<strong>{stale + down}</strong>
|
||||
<small>
|
||||
{down} {t.health.levels.down}
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>{t.health.queue}</span>
|
||||
<strong>{queue.pendingTasks}</strong>
|
||||
<small>
|
||||
{queue.pendingMessageRooms} {t.control.pendingRooms}
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>{t.health.ciFailures}</span>
|
||||
<strong>{ciFailures}</strong>
|
||||
<small>
|
||||
{data.overview.tasks.watchers.paused} {t.status.paused}
|
||||
</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{services.length === 0 ? (
|
||||
<EmptyState>{t.service.empty}</EmptyState>
|
||||
) : (
|
||||
<div className="health-service-list">
|
||||
{serviceLevels.map(({ service, level, age }) => (
|
||||
<article className="health-service" key={service.serviceId}>
|
||||
<div>
|
||||
<span className="eyebrow">{service.agentType}</span>
|
||||
<strong>{service.assistantName}</strong>
|
||||
<small>{service.serviceId}</small>
|
||||
</div>
|
||||
<span className={`pill pill-${level}`}>
|
||||
{t.health.levels[level]}
|
||||
</span>
|
||||
<div>
|
||||
<small>{t.service.updated}</small>
|
||||
<strong>{formatDate(service.updatedAt, locale)}</strong>
|
||||
<em>{formatDuration(age, t)}</em>
|
||||
</div>
|
||||
<div>
|
||||
<small>{t.service.rooms}</small>
|
||||
<strong>
|
||||
{service.activeRooms}/{service.totalRooms}
|
||||
</strong>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1082,13 +1313,23 @@ function App() {
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{activeView === 'inbox' ? (
|
||||
<section className="panel view-panel" id="inbox">
|
||||
<div className="panel-title">
|
||||
<h2>{t.panels.inbox}</h2>
|
||||
<span>{t.panels.inboxQueue}</span>
|
||||
</div>
|
||||
<InboxPanel locale={locale} overview={data.overview} t={t} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{activeView === 'health' ? (
|
||||
<section className="panel view-panel" id="health">
|
||||
<div className="panel-title">
|
||||
<h2>{t.panels.health}</h2>
|
||||
<span>{t.panels.heartbeat}</span>
|
||||
<span>{t.panels.healthSignals}</span>
|
||||
</div>
|
||||
<ServicePanel locale={locale} overview={data.overview} t={t} />
|
||||
<HealthPanel data={data} locale={locale} t={t} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -35,6 +35,28 @@ export interface DashboardOverview {
|
||||
}>;
|
||||
fetchedAt: string | null;
|
||||
};
|
||||
inbox: Array<{
|
||||
id: string;
|
||||
kind:
|
||||
| 'pending-room'
|
||||
| 'reviewer-request'
|
||||
| 'approval'
|
||||
| 'arbiter-request'
|
||||
| 'ci-failure'
|
||||
| 'mention';
|
||||
severity: 'info' | 'warn' | 'error';
|
||||
title: string;
|
||||
summary: string;
|
||||
occurredAt: string;
|
||||
createdAt: string;
|
||||
source: 'status-snapshot' | 'paired-task' | 'scheduled-task';
|
||||
roomJid?: string;
|
||||
roomName?: string;
|
||||
groupFolder?: string;
|
||||
serviceId?: string;
|
||||
taskId?: string;
|
||||
taskStatus?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface StatusSnapshot {
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface Messages {
|
||||
operations: string;
|
||||
updated: string;
|
||||
health: string;
|
||||
inbox: string;
|
||||
usage: string;
|
||||
rooms: string;
|
||||
scheduled: string;
|
||||
@@ -45,6 +46,9 @@ export interface Messages {
|
||||
panels: {
|
||||
health: string;
|
||||
heartbeat: string;
|
||||
healthSignals: string;
|
||||
inbox: string;
|
||||
inboxQueue: string;
|
||||
usage: string;
|
||||
usageWindow: string;
|
||||
rooms: string;
|
||||
@@ -59,6 +63,48 @@ export interface Messages {
|
||||
rooms: string;
|
||||
updated: string;
|
||||
};
|
||||
health: {
|
||||
system: string;
|
||||
summary: string;
|
||||
signals: string;
|
||||
services: string;
|
||||
fresh: string;
|
||||
stale: string;
|
||||
queue: string;
|
||||
ciFailures: string;
|
||||
levels: {
|
||||
ok: string;
|
||||
stale: string;
|
||||
down: string;
|
||||
};
|
||||
};
|
||||
inbox: {
|
||||
empty: string;
|
||||
cardsAria: string;
|
||||
summary: string;
|
||||
total: string;
|
||||
filters: string;
|
||||
all: string;
|
||||
noSummary: string;
|
||||
occurred: string;
|
||||
source: string;
|
||||
target: string;
|
||||
openTask: string;
|
||||
openRoom: string;
|
||||
kinds: {
|
||||
'pending-room': string;
|
||||
'reviewer-request': string;
|
||||
approval: string;
|
||||
'arbiter-request': string;
|
||||
'ci-failure': string;
|
||||
mention: string;
|
||||
};
|
||||
severity: {
|
||||
info: string;
|
||||
warn: string;
|
||||
error: string;
|
||||
};
|
||||
};
|
||||
rooms: {
|
||||
empty: string;
|
||||
cardsAria: string;
|
||||
@@ -169,6 +215,7 @@ export const messages = {
|
||||
operations: '운영',
|
||||
updated: '갱신',
|
||||
health: '상태',
|
||||
inbox: '인입',
|
||||
usage: '사용량',
|
||||
rooms: '룸',
|
||||
scheduled: '예약',
|
||||
@@ -193,6 +240,9 @@ export const messages = {
|
||||
panels: {
|
||||
health: '상태',
|
||||
heartbeat: '하트비트',
|
||||
healthSignals: '운영 신호',
|
||||
inbox: '인입',
|
||||
inboxQueue: '대기 항목',
|
||||
usage: '사용량',
|
||||
usageWindow: '5시간 / 7일',
|
||||
rooms: '룸',
|
||||
@@ -207,6 +257,48 @@ export const messages = {
|
||||
rooms: '룸',
|
||||
updated: '갱신',
|
||||
},
|
||||
health: {
|
||||
system: '시스템',
|
||||
summary: '헬스는 heartbeat, 큐, CI 실패를 합산합니다.',
|
||||
signals: '헬스 신호',
|
||||
services: '서비스',
|
||||
fresh: '정상 heartbeat',
|
||||
stale: '지연',
|
||||
queue: '큐',
|
||||
ciFailures: 'CI 실패',
|
||||
levels: {
|
||||
ok: '정상',
|
||||
stale: '주의',
|
||||
down: '중단',
|
||||
},
|
||||
},
|
||||
inbox: {
|
||||
empty: '인입 없음.',
|
||||
cardsAria: '인입 항목',
|
||||
summary: '인입 요약',
|
||||
total: '전체',
|
||||
filters: '인입 필터',
|
||||
all: '전체',
|
||||
noSummary: '요약 없음',
|
||||
occurred: '발생',
|
||||
source: '소스',
|
||||
target: '대상',
|
||||
openTask: '예약 보기',
|
||||
openRoom: '룸 보기',
|
||||
kinds: {
|
||||
'pending-room': '대기 룸',
|
||||
'reviewer-request': '리뷰 요청',
|
||||
approval: '승인',
|
||||
'arbiter-request': '중재 요청',
|
||||
'ci-failure': 'CI 실패',
|
||||
mention: '멘션',
|
||||
},
|
||||
severity: {
|
||||
info: '정보',
|
||||
warn: '주의',
|
||||
error: '위험',
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
empty: '룸 없음.',
|
||||
cardsAria: '룸 상태 카드',
|
||||
@@ -301,6 +393,7 @@ export const messages = {
|
||||
operations: 'Ops',
|
||||
updated: 'Updated',
|
||||
health: 'Health',
|
||||
inbox: 'Inbox',
|
||||
usage: 'Usage',
|
||||
rooms: 'Rooms',
|
||||
scheduled: 'Scheduled',
|
||||
@@ -325,6 +418,9 @@ export const messages = {
|
||||
panels: {
|
||||
health: 'Health',
|
||||
heartbeat: 'Heartbeat',
|
||||
healthSignals: 'Signals',
|
||||
inbox: 'Inbox',
|
||||
inboxQueue: 'Work intake',
|
||||
usage: 'Usage',
|
||||
usageWindow: '5h / 7d',
|
||||
rooms: 'Rooms',
|
||||
@@ -339,6 +435,48 @@ export const messages = {
|
||||
rooms: 'rooms',
|
||||
updated: 'updated',
|
||||
},
|
||||
health: {
|
||||
system: 'System',
|
||||
summary: 'Health combines heartbeat, queue, and CI failures.',
|
||||
signals: 'Health signals',
|
||||
services: 'Services',
|
||||
fresh: 'fresh heartbeat',
|
||||
stale: 'stale',
|
||||
queue: 'Queue',
|
||||
ciFailures: 'CI failures',
|
||||
levels: {
|
||||
ok: 'OK',
|
||||
stale: 'Watch',
|
||||
down: 'Down',
|
||||
},
|
||||
},
|
||||
inbox: {
|
||||
empty: 'No inbound work.',
|
||||
cardsAria: 'Inbox items',
|
||||
summary: 'Inbox summary',
|
||||
total: 'Total',
|
||||
filters: 'Inbox filters',
|
||||
all: 'All',
|
||||
noSummary: 'No summary',
|
||||
occurred: 'Occurred',
|
||||
source: 'Source',
|
||||
target: 'Target',
|
||||
openTask: 'Open scheduled',
|
||||
openRoom: 'Open room',
|
||||
kinds: {
|
||||
'pending-room': 'Pending room',
|
||||
'reviewer-request': 'Review request',
|
||||
approval: 'Approval',
|
||||
'arbiter-request': 'Arbiter request',
|
||||
'ci-failure': 'CI failure',
|
||||
mention: 'Mention',
|
||||
},
|
||||
severity: {
|
||||
info: 'Info',
|
||||
warn: 'Warn',
|
||||
error: 'Risk',
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
empty: 'No rooms yet.',
|
||||
cardsAria: 'Room status cards',
|
||||
@@ -433,6 +571,7 @@ export const messages = {
|
||||
operations: '运营',
|
||||
updated: '更新',
|
||||
health: '健康',
|
||||
inbox: '收件',
|
||||
usage: '用量',
|
||||
rooms: '房间',
|
||||
scheduled: '计划',
|
||||
@@ -457,6 +596,9 @@ export const messages = {
|
||||
panels: {
|
||||
health: '健康',
|
||||
heartbeat: '心跳',
|
||||
healthSignals: '运行信号',
|
||||
inbox: '收件',
|
||||
inboxQueue: '待处理',
|
||||
usage: '用量',
|
||||
usageWindow: '5小时 / 7天',
|
||||
rooms: '房间',
|
||||
@@ -471,6 +613,48 @@ export const messages = {
|
||||
rooms: '房间',
|
||||
updated: '更新',
|
||||
},
|
||||
health: {
|
||||
system: '系统',
|
||||
summary: '健康状态汇总心跳、队列和 CI 失败。',
|
||||
signals: '健康信号',
|
||||
services: '服务',
|
||||
fresh: '心跳正常',
|
||||
stale: '延迟',
|
||||
queue: '队列',
|
||||
ciFailures: 'CI 失败',
|
||||
levels: {
|
||||
ok: '正常',
|
||||
stale: '关注',
|
||||
down: '中断',
|
||||
},
|
||||
},
|
||||
inbox: {
|
||||
empty: '暂无收件。',
|
||||
cardsAria: '收件项',
|
||||
summary: '收件摘要',
|
||||
total: '全部',
|
||||
filters: '收件筛选',
|
||||
all: '全部',
|
||||
noSummary: '无摘要',
|
||||
occurred: '发生',
|
||||
source: '来源',
|
||||
target: '目标',
|
||||
openTask: '查看计划',
|
||||
openRoom: '查看房间',
|
||||
kinds: {
|
||||
'pending-room': '待处理房间',
|
||||
'reviewer-request': '评审请求',
|
||||
approval: '审批',
|
||||
'arbiter-request': '仲裁请求',
|
||||
'ci-failure': 'CI 失败',
|
||||
mention: '提及',
|
||||
},
|
||||
severity: {
|
||||
info: '信息',
|
||||
warn: '关注',
|
||||
error: '风险',
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
empty: '暂无房间。',
|
||||
cardsAria: '房间状态卡片',
|
||||
@@ -565,6 +749,7 @@ export const messages = {
|
||||
operations: '運用',
|
||||
updated: '更新',
|
||||
health: '状態',
|
||||
inbox: '受信',
|
||||
usage: '使用量',
|
||||
rooms: 'ルーム',
|
||||
scheduled: '予定',
|
||||
@@ -589,6 +774,9 @@ export const messages = {
|
||||
panels: {
|
||||
health: '状態',
|
||||
heartbeat: 'ハートビート',
|
||||
healthSignals: '運用シグナル',
|
||||
inbox: '受信',
|
||||
inboxQueue: '対応待ち',
|
||||
usage: '使用量',
|
||||
usageWindow: '5時間 / 7日',
|
||||
rooms: 'ルーム',
|
||||
@@ -603,6 +791,48 @@ export const messages = {
|
||||
rooms: 'ルーム',
|
||||
updated: '更新',
|
||||
},
|
||||
health: {
|
||||
system: 'システム',
|
||||
summary: '状態はハートビート、キュー、CI失敗を集計します。',
|
||||
signals: '状態シグナル',
|
||||
services: 'サービス',
|
||||
fresh: '正常ハートビート',
|
||||
stale: '遅延',
|
||||
queue: 'キュー',
|
||||
ciFailures: 'CI失敗',
|
||||
levels: {
|
||||
ok: '正常',
|
||||
stale: '注意',
|
||||
down: '停止',
|
||||
},
|
||||
},
|
||||
inbox: {
|
||||
empty: '受信なし。',
|
||||
cardsAria: '受信項目',
|
||||
summary: '受信サマリー',
|
||||
total: '全体',
|
||||
filters: '受信フィルター',
|
||||
all: '全体',
|
||||
noSummary: '概要なし',
|
||||
occurred: '発生',
|
||||
source: 'ソース',
|
||||
target: '対象',
|
||||
openTask: '予定を見る',
|
||||
openRoom: 'ルームを見る',
|
||||
kinds: {
|
||||
'pending-room': '待機ルーム',
|
||||
'reviewer-request': 'レビュー依頼',
|
||||
approval: '承認',
|
||||
'arbiter-request': '仲裁依頼',
|
||||
'ci-failure': 'CI失敗',
|
||||
mention: 'メンション',
|
||||
},
|
||||
severity: {
|
||||
info: '情報',
|
||||
warn: '注意',
|
||||
error: '危険',
|
||||
},
|
||||
},
|
||||
rooms: {
|
||||
empty: 'ルームなし。',
|
||||
cardsAria: 'ルーム状態カード',
|
||||
|
||||
@@ -523,63 +523,6 @@ dd,
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
.service-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.service-card {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 20px;
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 250, 240, 0.72);
|
||||
}
|
||||
|
||||
.service-card h3 {
|
||||
margin: 6px 0 0;
|
||||
font-size: 26px;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.heartbeat-line {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.heartbeat-line span {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 0 7px rgba(63, 127, 81, 0.13);
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dl div {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--muted);
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(43, 55, 38, 0.1);
|
||||
@@ -837,6 +780,235 @@ progress::-moz-progress-bar {
|
||||
background: rgba(255, 250, 240, 0.45);
|
||||
}
|
||||
|
||||
.pill-info {
|
||||
color: #625d52;
|
||||
background: rgba(109, 115, 95, 0.16);
|
||||
}
|
||||
|
||||
.pill-error,
|
||||
.pill-down {
|
||||
color: #7c2518;
|
||||
background: rgba(183, 71, 52, 0.18);
|
||||
}
|
||||
|
||||
.pill-stale {
|
||||
color: #6e4a05;
|
||||
background: rgba(181, 138, 34, 0.2);
|
||||
}
|
||||
|
||||
.inbox-board,
|
||||
.health-board {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.inbox-summary,
|
||||
.health-signals {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inbox-summary div,
|
||||
.health-signals div,
|
||||
.health-overview,
|
||||
.inbox-card,
|
||||
.health-service {
|
||||
border: 1px solid rgba(43, 55, 38, 0.1);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 250, 240, 0.64);
|
||||
}
|
||||
|
||||
.inbox-summary div,
|
||||
.health-signals div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 11px 12px;
|
||||
}
|
||||
|
||||
.inbox-summary span,
|
||||
.health-signals span,
|
||||
.inbox-meta small,
|
||||
.health-service small,
|
||||
.health-service em {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.inbox-summary strong,
|
||||
.health-signals strong {
|
||||
font-size: clamp(18px, 2vw, 28px);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.inbox-filters {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.inbox-filters button {
|
||||
display: inline-flex;
|
||||
min-height: 38px;
|
||||
flex: none;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
color: var(--bg-ink);
|
||||
background: rgba(43, 55, 38, 0.08);
|
||||
box-shadow: none;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.inbox-filters button.is-active,
|
||||
.inbox-filters button[aria-pressed='true'] {
|
||||
color: #fffaf0;
|
||||
background: linear-gradient(135deg, #2b3726, #1c211c);
|
||||
}
|
||||
|
||||
.inbox-filters span {
|
||||
color: inherit;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.inbox-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.inbox-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 13px;
|
||||
}
|
||||
|
||||
.inbox-error {
|
||||
border-color: rgba(183, 71, 52, 0.26);
|
||||
background: rgba(255, 244, 238, 0.72);
|
||||
}
|
||||
|
||||
.inbox-warn {
|
||||
border-color: rgba(181, 138, 34, 0.24);
|
||||
}
|
||||
|
||||
.inbox-card-head,
|
||||
.health-service {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.inbox-card-head strong,
|
||||
.inbox-card p,
|
||||
.inbox-meta strong,
|
||||
.health-service strong,
|
||||
.health-service small {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.inbox-card p {
|
||||
margin: 0;
|
||||
color: var(--bg-ink);
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.inbox-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.inbox-meta span {
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
border-radius: 12px;
|
||||
background: rgba(43, 55, 38, 0.045);
|
||||
}
|
||||
|
||||
.inbox-meta small,
|
||||
.inbox-meta strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.inbox-target {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
min-height: 34px;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
color: var(--accent-strong);
|
||||
background: rgba(191, 95, 44, 0.11);
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.health-overview {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.42fr) minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.health-overview strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: clamp(24px, 3vw, 40px);
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
.health-overview p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.health-down {
|
||||
border-color: rgba(183, 71, 52, 0.32);
|
||||
}
|
||||
|
||||
.health-stale {
|
||||
border-color: rgba(181, 138, 34, 0.28);
|
||||
}
|
||||
|
||||
.health-service-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.health-service {
|
||||
grid-template-columns: minmax(0, 1.2fr) auto minmax(0, 0.9fr) minmax(
|
||||
64px,
|
||||
0.35fr
|
||||
);
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.health-service > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.health-service strong,
|
||||
.health-service small,
|
||||
.health-service em {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.health-service em {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.task-board {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -1370,6 +1542,37 @@ progress::-moz-progress-bar {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.inbox-summary,
|
||||
.health-signals {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.inbox-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.inbox-card {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.inbox-meta {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.health-overview {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.health-service {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.health-service > div:nth-of-type(2),
|
||||
.health-service > div:nth-of-type(3) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.task-group {
|
||||
padding: 10px;
|
||||
}
|
||||
@@ -1440,6 +1643,11 @@ progress::-moz-progress-bar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.inbox-meta,
|
||||
.health-signals {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.task-time-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user