feat: Phase 6 — alarm system (auto-generate, list, acknowledge)
All checks were successful
Deploy to Production / deploy (push) Successful in 1m17s

- Add Alarm model with tenant/part/machine relationships and 3 indexes
- Add alembic migration c5d6e7f8a9b0_add_alarms
- Add alarms API: list (filter by ack/severity), summary, acknowledge
- Auto-generate alarms on counter update (threshold warning, critical at 100%)
- Duplicate alarm prevention for same part+alarm_type
- Add alarms frontend page with active/acknowledged tabs, summary badges
- 9 new tests (67/67 total passing)

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
Johngreen
2026-02-10 14:39:03 +09:00
parent 035d62f0e0
commit 843f72e048
10 changed files with 889 additions and 1 deletions

View File

@@ -0,0 +1,154 @@
'use client';
import { useState, useCallback } from 'react';
import { useParams } from 'next/navigation';
import { useAlarms, useAlarmSummary } from '@/lib/hooks';
import { useToast } from '@/lib/toast-context';
import { api } from '@/lib/api';
import type { Alarm } from '@/lib/types';
type TabType = 'active' | 'acknowledged';
export default function AlarmsPage() {
const params = useParams();
const tenantId = params?.tenant as string;
const { addToast } = useToast();
const [tab, setTab] = useState<TabType>('active');
const [acknowledging, setAcknowledging] = useState<string | null>(null);
const isAcknowledged = tab === 'acknowledged';
const { alarms, isLoading, mutate } = useAlarms(tenantId, isAcknowledged);
const { summary, mutate: mutateSummary } = useAlarmSummary(tenantId);
const handleAcknowledge = useCallback(async (alarm: Alarm) => {
setAcknowledging(alarm.id);
try {
await api.post<Alarm>(`/api/${tenantId}/alarms/${alarm.id}/acknowledge`, {});
addToast('알람이 확인 처리되었습니다.', 'success');
mutate();
mutateSummary();
} catch {
addToast('알람 확인 처리에 실패했습니다.', 'error');
} finally {
setAcknowledging(null);
}
}, [tenantId, mutate, mutateSummary, addToast]);
return (
<div className="page-container">
<div className="page-header">
<h2 className="page-title">
<span className="material-symbols-outlined">notifications</span>
</h2>
{summary && (
<div className="alarm-summary-badges">
{summary.critical_count > 0 && (
<span className="alarm-badge alarm-badge-critical">
{summary.critical_count}
</span>
)}
{summary.warning_count > 0 && (
<span className="alarm-badge alarm-badge-warning">
{summary.warning_count}
</span>
)}
{summary.active_count === 0 && (
<span className="alarm-badge alarm-badge-ok">
</span>
)}
</div>
)}
</div>
<div className="tab-bar">
<button
className={`tab-item ${tab === 'active' ? 'active' : ''}`}
onClick={() => setTab('active')}
>
<span className="material-symbols-outlined">warning</span>
{summary ? `(${summary.active_count})` : ''}
</button>
<button
className={`tab-item ${tab === 'acknowledged' ? 'active' : ''}`}
onClick={() => setTab('acknowledged')}
>
<span className="material-symbols-outlined">check_circle</span>
</button>
</div>
{isLoading ? (
<div className="loading">
<span className="material-symbols-outlined spinning">progress_activity</span>
</div>
) : alarms.length === 0 ? (
<div className="empty-state">
<span className="material-symbols-outlined">
{tab === 'active' ? 'notifications_off' : 'check_circle'}
</span>
<p>{tab === 'active' ? '미확인 알람이 없습니다.' : '확인된 알람이 없습니다.'}</p>
</div>
) : (
<div className="alarm-list">
{alarms.map((alarm) => (
<div
key={alarm.id}
className={`alarm-card alarm-card-${alarm.severity}`}
>
<div className="alarm-card-icon">
<span className="material-symbols-outlined">
{alarm.severity === 'critical' ? 'error' : 'warning'}
</span>
</div>
<div className="alarm-card-body">
<div className="alarm-card-header">
<span className={`alarm-severity-badge severity-${alarm.severity}`}>
{alarm.severity === 'critical' ? '긴급' : '주의'}
</span>
<span className="alarm-card-time">
{alarm.created_at ? new Date(alarm.created_at).toLocaleString('ko-KR') : ''}
</span>
</div>
<p className="alarm-card-message">{alarm.message}</p>
<div className="alarm-card-meta">
<span>
<span className="material-symbols-outlined">precision_manufacturing</span>
{alarm.machine_name || '-'}
</span>
<span>
<span className="material-symbols-outlined">settings</span>
{alarm.part_name || '-'}
</span>
</div>
</div>
<div className="alarm-card-actions">
{!alarm.is_acknowledged ? (
<button
className="btn-primary btn-sm"
onClick={() => handleAcknowledge(alarm)}
disabled={acknowledging === alarm.id}
>
{acknowledging === alarm.id ? (
<span className="material-symbols-outlined spinning">progress_activity</span>
) : (
<span className="material-symbols-outlined">check</span>
)}
</button>
) : (
<span className="alarm-ack-info">
<span className="material-symbols-outlined">check_circle</span>
{alarm.acknowledged_at ? new Date(alarm.acknowledged_at).toLocaleString('ko-KR') : ''}
</span>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1767,6 +1767,161 @@ a {
opacity: 0.5;
}
/* ===== Phase 6: Alarms ===== */
.alarm-summary-badges {
display: flex;
gap: 8px;
}
.alarm-badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
}
.alarm-badge-critical {
background: #fce8e6;
color: var(--md-error);
}
.alarm-badge-warning {
background: #fff3e0;
color: #e65100;
}
.alarm-badge-ok {
background: #e8f5e9;
color: #2e7d32;
}
.alarm-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.alarm-card {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 16px 20px;
border-radius: 12px;
border: 1px solid var(--md-outline-variant);
background: var(--md-surface);
transition: box-shadow 0.15s;
}
.alarm-card:hover {
box-shadow: var(--md-shadow-1);
}
.alarm-card-warning {
border-left: 4px solid #e65100;
}
.alarm-card-critical {
border-left: 4px solid var(--md-error);
background: #fff8f7;
}
.alarm-card-icon {
flex-shrink: 0;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.alarm-card-warning .alarm-card-icon {
background: #fff3e0;
color: #e65100;
}
.alarm-card-critical .alarm-card-icon {
background: #fce8e6;
color: var(--md-error);
}
.alarm-card-body {
flex: 1;
min-width: 0;
}
.alarm-card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.alarm-severity-badge {
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.severity-warning {
background: #fff3e0;
color: #e65100;
}
.severity-critical {
background: #fce8e6;
color: var(--md-error);
}
.alarm-card-time {
font-size: 12px;
color: var(--md-on-surface-variant);
}
.alarm-card-message {
font-size: 14px;
font-weight: 500;
color: var(--md-on-surface);
margin-bottom: 8px;
}
.alarm-card-meta {
display: flex;
gap: 16px;
font-size: 13px;
color: var(--md-on-surface-variant);
}
.alarm-card-meta span {
display: flex;
align-items: center;
gap: 4px;
}
.alarm-card-meta .material-symbols-outlined {
font-size: 16px;
}
.alarm-card-actions {
flex-shrink: 0;
display: flex;
align-items: center;
}
.alarm-ack-info {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #2e7d32;
}
.alarm-ack-info .material-symbols-outlined {
font-size: 18px;
}
/* ===== Responsive ===== */
@media (max-width: 768px) {
.topnav-center {

View File

@@ -1,6 +1,6 @@
import useSWR from 'swr';
import { fetcher, getTenantUrl } from './api';
import type { Tenant, Machine, MachineDetail, EquipmentPart, InspectionTemplate, InspectionSession, PartReplacementLog } from './types';
import type { Tenant, Machine, MachineDetail, EquipmentPart, InspectionTemplate, InspectionSession, PartReplacementLog, Alarm, AlarmSummary } from './types';
export function useTenants() {
const { data, error, isLoading, mutate } = useSWR<{ tenants: Tenant[] }>(
@@ -125,6 +125,41 @@ export function useReplacements(tenantId?: string, partId?: string) {
};
}
export function useAlarms(tenantId?: string, isAcknowledged?: boolean) {
const params = new URLSearchParams();
if (isAcknowledged !== undefined) params.set('is_acknowledged', String(isAcknowledged));
const qs = params.toString();
const url = tenantId ? `/api/${tenantId}/alarms${qs ? `?${qs}` : ''}` : null;
const { data, error, isLoading, mutate } = useSWR<{ alarms: Alarm[] }>(
url,
fetcher,
{ refreshInterval: 10000, dedupingInterval: 2000 },
);
return {
alarms: data?.alarms || [],
error,
isLoading,
mutate,
};
}
export function useAlarmSummary(tenantId?: string) {
const url = tenantId ? `/api/${tenantId}/alarms/summary` : null;
const { data, error, isLoading, mutate } = useSWR<AlarmSummary>(
url,
fetcher,
{ refreshInterval: 10000, dedupingInterval: 2000 },
);
return {
summary: data ?? null,
error,
isLoading,
mutate,
};
}
export function useInspections(tenantId?: string, status?: string, templateId?: string) {
const params = new URLSearchParams();
if (status) params.set('status', status);

View File

@@ -143,6 +143,29 @@ export interface PartReplacementLog {
notes: string | null;
}
export interface Alarm {
id: string;
tenant_id: string;
equipment_part_id: string;
machine_id: string;
alarm_type: 'threshold' | 'critical';
severity: 'warning' | 'critical';
message: string;
lifecycle_pct_at_trigger: number;
is_acknowledged: boolean;
acknowledged_by: string | null;
acknowledged_at: string | null;
created_at: string | null;
machine_name: string | null;
part_name: string | null;
}
export interface AlarmSummary {
active_count: number;
critical_count: number;
warning_count: number;
}
export interface InspectionTemplate {
id: string;
tenant_id: string;