G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현 G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건) G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST) G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직 G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트 G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트 G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트 G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트 G-10: core/push_notify.py + routers/push.py + PushSubscription 모델 G-11: approvals 다중승인 (위임/서명/기한초과/마감연장) G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh 하네스: guardia-orchestrator 확장기능 Phase 반영 봇명령어: /sr /status /license /bulk 슬래시 명령어 추가 설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
213 lines
6.7 KiB
JavaScript
213 lines
6.7 KiB
JavaScript
/**
|
|
* GUARDiA ITSM — Service Worker (F-4 Mobile PWA)
|
|
*
|
|
* 전략:
|
|
* - 정적 자산 : Cache-First (빠른 로딩)
|
|
* - API 요청 : Network-First (최신 데이터 우선, 오프라인 시 캐시)
|
|
* - 오프라인 : /static/offline.html 폴백
|
|
*
|
|
* 캐시 버전이 바뀌면 이전 캐시를 자동 정리합니다.
|
|
*/
|
|
|
|
const CACHE_VERSION = 'guardia-v1';
|
|
const STATIC_CACHE = `${CACHE_VERSION}-static`;
|
|
const API_CACHE = `${CACHE_VERSION}-api`;
|
|
const OFFLINE_URL = '/static/offline.html';
|
|
|
|
// 설치 시 사전 캐싱할 정적 자산
|
|
const PRECACHE_URLS = [
|
|
'/',
|
|
'/static/offline.html',
|
|
'/static/manifest.json',
|
|
'/static/style.css',
|
|
'/static/app.js',
|
|
'/static/login.html',
|
|
'/static/login.css',
|
|
'/static/login.js',
|
|
'/static/index.html',
|
|
'/static/customer.html',
|
|
];
|
|
|
|
// API 캐시 대상 접두사
|
|
const API_CACHE_PREFIXES = [
|
|
'/api/dashboard',
|
|
'/api/sr',
|
|
'/api/incidents',
|
|
'/api/metrics/summary',
|
|
'/api/metrics/health',
|
|
];
|
|
|
|
// 캐시하지 않을 API 경로 (실시간·민감 데이터)
|
|
const NO_CACHE_PATTERNS = [
|
|
'/api/auth',
|
|
'/api/audit',
|
|
'/api/pam',
|
|
'/api/otp',
|
|
'/ws',
|
|
];
|
|
|
|
// ── 설치 (Install) ─────────────────────────────────────────────────────────
|
|
self.addEventListener('install', event => {
|
|
event.waitUntil(
|
|
caches.open(STATIC_CACHE)
|
|
.then(cache => cache.addAll(PRECACHE_URLS).catch(() => {
|
|
// 일부 파일이 없어도 SW 설치 계속 진행
|
|
return Promise.resolve();
|
|
}))
|
|
.then(() => self.skipWaiting())
|
|
);
|
|
});
|
|
|
|
// ── 활성화 (Activate) — 이전 캐시 정리 ─────────────────────────────────────
|
|
self.addEventListener('activate', event => {
|
|
event.waitUntil(
|
|
caches.keys().then(keys =>
|
|
Promise.all(
|
|
keys
|
|
.filter(k => k.startsWith('guardia-') && k !== STATIC_CACHE && k !== API_CACHE)
|
|
.map(k => caches.delete(k))
|
|
)
|
|
).then(() => self.clients.claim())
|
|
);
|
|
});
|
|
|
|
// ── Fetch 인터셉트 ─────────────────────────────────────────────────────────
|
|
self.addEventListener('fetch', event => {
|
|
const { request } = event;
|
|
const url = new URL(request.url);
|
|
|
|
// 다른 오리진 요청은 처리하지 않음 (Ollama 등 내부 서비스 포함)
|
|
if (url.origin !== self.location.origin) return;
|
|
|
|
// WebSocket / POST/PUT/DELETE → 캐시 안 함
|
|
if (request.method !== 'GET') return;
|
|
|
|
// 보안 민감 경로 → 캐시 안 함
|
|
if (NO_CACHE_PATTERNS.some(p => url.pathname.startsWith(p))) return;
|
|
|
|
// API 요청 → Network-First
|
|
if (url.pathname.startsWith('/api/')) {
|
|
event.respondWith(networkFirst(request, API_CACHE));
|
|
return;
|
|
}
|
|
|
|
// 정적 자산 → Cache-First
|
|
event.respondWith(cacheFirst(request, STATIC_CACHE));
|
|
});
|
|
|
|
// ── 전략 함수 ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Cache-First: 캐시 → 네트워크 → 오프라인 폴백
|
|
*/
|
|
async function cacheFirst(request, cacheName) {
|
|
const cached = await caches.match(request);
|
|
if (cached) return cached;
|
|
|
|
try {
|
|
const response = await fetch(request);
|
|
if (response.ok) {
|
|
const cache = await caches.open(cacheName);
|
|
cache.put(request, response.clone());
|
|
}
|
|
return response;
|
|
} catch {
|
|
const offline = await caches.match(OFFLINE_URL);
|
|
return offline || new Response('오프라인 상태입니다.', {
|
|
status: 503,
|
|
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Network-First: 네트워크 → 캐시 → 오프라인 폴백
|
|
* API 응답은 5분간 캐시 유지
|
|
*/
|
|
async function networkFirst(request, cacheName) {
|
|
try {
|
|
const response = await fetch(request);
|
|
if (response.ok) {
|
|
const cache = await caches.open(cacheName);
|
|
// API 응답에 캐시 타임스탬프 추가
|
|
const cloned = response.clone();
|
|
cache.put(request, cloned);
|
|
}
|
|
return response;
|
|
} catch {
|
|
const cached = await caches.match(request);
|
|
if (cached) return cached;
|
|
|
|
// HTML 요청 시 오프라인 페이지
|
|
if (request.headers.get('Accept')?.includes('text/html')) {
|
|
const offline = await caches.match(OFFLINE_URL);
|
|
if (offline) return offline;
|
|
}
|
|
|
|
return new Response(
|
|
JSON.stringify({ error: '오프라인 상태입니다.', offline: true }),
|
|
{
|
|
status: 503,
|
|
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── 백그라운드 동기화 ──────────────────────────────────────────────────────
|
|
self.addEventListener('sync', event => {
|
|
if (event.tag === 'guardia-sync-sr') {
|
|
event.waitUntil(syncPendingSR());
|
|
}
|
|
});
|
|
|
|
async function syncPendingSR() {
|
|
// IndexedDB에서 오프라인 중 생성된 SR 대기열 처리 (향후 구현)
|
|
// 현재는 로그만 기록
|
|
console.log('[GUARDiA SW] 오프라인 SR 동기화 시작');
|
|
}
|
|
|
|
// ── 푸시 알림 ──────────────────────────────────────────────────────────────
|
|
self.addEventListener('push', event => {
|
|
if (!event.data) return;
|
|
|
|
let data;
|
|
try {
|
|
data = event.data.json();
|
|
} catch {
|
|
data = { title: 'GUARDiA 알림', body: event.data.text() };
|
|
}
|
|
|
|
const options = {
|
|
body: data.body || '새 알림이 있습니다.',
|
|
icon: '/static/icons/icon-192.png',
|
|
badge: '/static/icons/icon-72.png',
|
|
tag: data.tag || 'guardia-notification',
|
|
data: data.data || {},
|
|
actions: data.actions || [
|
|
{ action: 'view', title: '확인' },
|
|
{ action: 'dismiss', title: '닫기' },
|
|
],
|
|
requireInteraction: data.severity === 'CRITICAL',
|
|
};
|
|
|
|
event.waitUntil(
|
|
self.registration.showNotification(data.title || 'GUARDiA ITSM', options)
|
|
);
|
|
});
|
|
|
|
self.addEventListener('notificationclick', event => {
|
|
event.notification.close();
|
|
if (event.action === 'dismiss') return;
|
|
|
|
const url = event.notification.data?.url || '/';
|
|
event.waitUntil(
|
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => {
|
|
for (const client of clientList) {
|
|
if (client.url === url && 'focus' in client) return client.focus();
|
|
}
|
|
return clients.openWindow(url);
|
|
})
|
|
);
|
|
});
|