feat: Phase 6 — alarm system (auto-generate, list, acknowledge)
All checks were successful
Deploy to Production / deploy (push) Successful in 1m17s
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:
154
dashboard/app/[tenant]/alarms/page.tsx
Normal file
154
dashboard/app/[tenant]/alarms/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user