feat(frontend): UI 브랜딩 개선 + 배포 설정 정리
- 로그인 화면: kcg.svg 로고 적용 (이모지 교체) - 헤더 우측: 사용자 프로필/이름 + 로그아웃 버튼 추가 - 브라우저 탭: favicon → kcg.svg, 제목 → kcg-dashboard-demo - 프로덕션 빌드: console/debugger 자동 제거 - CORS: CorsFilter 최우선 순위 등록 (AuthFilter 이전) - deploy.yml: secrets → .env 파일로 배포 - systemd/nginx: 경로 /devdata/services/kcg/ 반영
This commit is contained in:
부모
dd57ba59d2
커밋
7cde0c57d8
@ -43,6 +43,9 @@ jobs:
|
||||
run: mvn -B clean package -DskipTests
|
||||
|
||||
- name: Deploy backend
|
||||
env:
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||
run: |
|
||||
DEPLOY_DIR=/deploy/kcg-backend
|
||||
mkdir -p $DEPLOY_DIR/backup
|
||||
@ -53,6 +56,12 @@ jobs:
|
||||
ls -t $DEPLOY_DIR/backup/*.jar | tail -n +6 | xargs -r rm
|
||||
fi
|
||||
|
||||
# Secrets → 환경변수 파일 (systemd EnvironmentFile)
|
||||
cat > $DEPLOY_DIR/.env << ENVEOF
|
||||
GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||
JWT_SECRET=${JWT_SECRET}
|
||||
ENVEOF
|
||||
|
||||
# JAR 교체 + 재시작 트리거
|
||||
cp backend/target/kcg.jar $DEPLOY_DIR/kcg.jar
|
||||
date '+%s' > $DEPLOY_DIR/.deploy-trigger
|
||||
|
||||
@ -1,18 +1,36 @@
|
||||
package gc.mda.kcg.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
import org.springframework.web.filter.CorsFilter;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
public class WebConfig {
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/api/**")
|
||||
.allowedOrigins("http://localhost:5173")
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true);
|
||||
@Value("${app.cors.allowed-origins:http://localhost:5173}")
|
||||
private List<String> allowedOrigins;
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<CorsFilter> corsFilterRegistration() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowedOrigins(allowedOrigins);
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
config.setAllowedHeaders(List.of("*"));
|
||||
config.setAllowCredentials(true);
|
||||
config.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/api/**", config);
|
||||
|
||||
FilterRegistrationBean<CorsFilter> bean = new FilterRegistrationBean<>(new CorsFilter(source));
|
||||
bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
|
||||
return bean;
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,12 +6,13 @@ After=network.target
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
WorkingDirectory=/deploy/kcg-backend
|
||||
WorkingDirectory=/devdata/services/kcg/backend
|
||||
EnvironmentFile=-/devdata/services/kcg/backend/.env
|
||||
ExecStart=/usr/lib/jvm/java-17-openjdk-17.0.18.0.8-1.el9.x86_64/bin/java \
|
||||
-Xms2g -Xmx4g \
|
||||
-Dspring.profiles.active=prod \
|
||||
-Dspring.config.additional-location=file:/deploy/kcg-backend/ \
|
||||
-jar /deploy/kcg-backend/kcg.jar
|
||||
-Dspring.config.additional-location=file:/devdata/services/kcg/backend/ \
|
||||
-jar /devdata/services/kcg/backend/kcg.jar
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
@ -8,7 +8,7 @@ server {
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
# ── Frontend SPA ──
|
||||
root /deploy/kcg;
|
||||
root /devdata/services/kcg/dist;
|
||||
# Static cache
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||
expires 1y;
|
||||
|
||||
@ -2,9 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/kcg.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>iran-airstrike-replay</title>
|
||||
<title>kcg-dashboard-demo</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
1004
frontend/public/kcg.svg
Normal file
1004
frontend/public/kcg.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | 크기: 72 KiB |
@ -146,6 +146,29 @@
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.header-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid var(--kcg-border);
|
||||
}
|
||||
|
||||
.header-user-avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.header-user-name {
|
||||
font-size: 11px;
|
||||
color: var(--kcg-text);
|
||||
max-width: 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Cheonghae Unit pulsing beacon */
|
||||
@keyframes cheonghae-pulse {
|
||||
0% { transform: scale(1); opacity: 0.8; }
|
||||
|
||||
@ -103,7 +103,7 @@ interface AuthenticatedAppProps {
|
||||
onLogout: () => Promise<void>;
|
||||
}
|
||||
|
||||
function AuthenticatedApp(_props: AuthenticatedAppProps) {
|
||||
function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
const [appMode, setAppMode] = useState<AppMode>('live');
|
||||
const [events, setEvents] = useState<GeoEvent[]>([]);
|
||||
const [sensorData, setSensorData] = useState<SensorLog[]>([]);
|
||||
@ -965,6 +965,17 @@ function AuthenticatedApp(_props: AuthenticatedAppProps) {
|
||||
<span className={`status-dot ${isLive ? 'live' : replay.state.isPlaying ? 'live' : ''}`} />
|
||||
{isLive ? t('header.live') : replay.state.isPlaying ? t('header.replaying') : t('header.paused')}
|
||||
</div>
|
||||
{user && (
|
||||
<div className="header-user">
|
||||
{user.picture && (
|
||||
<img src={user.picture} alt="" className="header-user-avatar" referrerPolicy="no-referrer" />
|
||||
)}
|
||||
<span className="header-user-name">{user.name}</span>
|
||||
<button type="button" className="header-toggle-btn" onClick={onLogout} title="Logout">
|
||||
⏻
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@ -104,7 +104,7 @@ const LoginPage = ({ onGoogleLogin, onDevLogin }: LoginPageProps) => {
|
||||
>
|
||||
{/* Title */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="text-3xl">🛡️</div>
|
||||
<img src="/kcg.svg" alt="KCG" style={{ width: 120, height: 120 }} />
|
||||
<h1
|
||||
className="text-xl font-bold"
|
||||
style={{ color: 'var(--kcg-text)' }}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, type UserConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ mode }): UserConfig => ({
|
||||
plugins: [tailwindcss(), react()],
|
||||
esbuild: mode === 'production' ? { drop: ['console', 'debugger'] } : {},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api/ais': {
|
||||
@ -117,4 +118,4 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user