CHORE(api): add ArgoCD API integration
- Add ArgoCD API endpoint - Enable ArgoCD connectivity
This commit is contained in:
21
deploy/k8s/base/argocd-secret.yaml
Normal file
21
deploy/k8s/base/argocd-secret.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: argocd-token
|
||||
namespace: portfolio
|
||||
type: Opaque
|
||||
stringData:
|
||||
token: "" # ArgoCD 토큰을 여기에 설정하거나, kubectl create secret로 생성
|
||||
---
|
||||
# 사용 방법:
|
||||
# 1. ArgoCD 토큰 생성:
|
||||
# argocd account generate-token
|
||||
#
|
||||
# 2. Secret 생성:
|
||||
# kubectl create secret generic argocd-token \
|
||||
# --from-literal=token='YOUR_TOKEN_HERE' \
|
||||
# -n portfolio
|
||||
#
|
||||
# 또는 이 파일을 수정하고:
|
||||
# kubectl apply -f argocd-secret.yaml
|
||||
|
||||
@@ -29,6 +29,14 @@ spec:
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
value: production
|
||||
- name: ARGOCD_SERVER_URL
|
||||
value: "https://argocd-server.argocd.svc.cluster.local"
|
||||
- name: ARGOCD_TOKEN
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: argocd-token
|
||||
key: token
|
||||
optional: false
|
||||
resources:
|
||||
requests:
|
||||
memory: "100Mi"
|
||||
|
||||
@@ -13,7 +13,7 @@ commonLabels:
|
||||
# 이미지 태그 설정
|
||||
images:
|
||||
- name: ghcr.io/mayne0213/portfolio
|
||||
newTag: main-sha-2a186af4b273549fa5a3b06c6eedfec2fd8ecce6
|
||||
newTag: main-sha-db21ed85671bdb8b6153d06250e41821772003dc
|
||||
|
||||
patchesStrategicMerge:
|
||||
- deployment-patch.yaml
|
||||
|
||||
105
services/nextjs/app/api/argocd/route.ts
Normal file
105
services/nextjs/app/api/argocd/route.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
// 클러스터 내부에서 실행되는 경우 클러스터 내부 서비스 URL 사용
|
||||
// 외부에서 실행되는 경우 환경 변수로 설정 가능
|
||||
const ARGOCD_SERVER_URL = process.env.ARGOCD_SERVER_URL || 'https://argocd-server.argocd.svc.cluster.local';
|
||||
const ARGOCD_TOKEN = process.env.ARGOCD_TOKEN || '';
|
||||
|
||||
interface ArgoCDApplication {
|
||||
metadata: {
|
||||
name: string;
|
||||
namespace: string;
|
||||
};
|
||||
spec: {
|
||||
source: {
|
||||
repoURL: string;
|
||||
path?: string;
|
||||
targetRevision: string;
|
||||
};
|
||||
destination: {
|
||||
server: string;
|
||||
namespace: string;
|
||||
};
|
||||
};
|
||||
status: {
|
||||
health: {
|
||||
status: string;
|
||||
};
|
||||
sync: {
|
||||
status: string;
|
||||
};
|
||||
resources?: Array<{
|
||||
kind: string;
|
||||
name: string;
|
||||
namespace: string;
|
||||
status: string;
|
||||
health?: {
|
||||
status: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/argocd - Get all ArgoCD applications
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
if (!ARGOCD_TOKEN) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ArgoCD token not configured' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// ArgoCD API v1 - Get applications
|
||||
const response = await fetch(`${ARGOCD_SERVER_URL}/api/v1/applications`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${ARGOCD_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
// Next.js에서 외부 API 호출 시 캐시 설정
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('ArgoCD API error:', response.status, errorText);
|
||||
return NextResponse.json(
|
||||
{ error: `ArgoCD API error: ${response.status}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Read-only로 필터링 (필요한 정보만 반환)
|
||||
const applications = (data.items || []).map((app: ArgoCDApplication) => ({
|
||||
name: app.metadata.name,
|
||||
namespace: app.metadata.namespace,
|
||||
repoURL: app.spec.source.repoURL,
|
||||
path: app.spec.source.path || '',
|
||||
targetRevision: app.spec.source.targetRevision,
|
||||
destination: app.spec.destination,
|
||||
health: app.status.health?.status || 'Unknown',
|
||||
sync: app.status.sync?.status || 'Unknown',
|
||||
resources: app.status.resources?.map((resource) => ({
|
||||
kind: resource.kind,
|
||||
name: resource.name,
|
||||
namespace: resource.namespace,
|
||||
status: resource.status,
|
||||
health: resource.health?.status || 'Unknown',
|
||||
})) || [],
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
applications,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching ArgoCD applications:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch ArgoCD applications' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
15
services/nextjs/app/argocd/page.tsx
Normal file
15
services/nextjs/app/argocd/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import ArgoCDView from '@/components/argocd/ArgoCDView';
|
||||
|
||||
export const metadata = {
|
||||
title: 'ArgoCD - Infrastructure Status',
|
||||
description: 'View ArgoCD applications status (Read-only)',
|
||||
};
|
||||
|
||||
export default function ArgoCDPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 pt-24 max-w-7xl">
|
||||
<ArgoCDView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
274
services/nextjs/components/argocd/ArgoCDView.tsx
Normal file
274
services/nextjs/components/argocd/ArgoCDView.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { RefreshCw, CheckCircle2, XCircle, AlertCircle, Clock } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
|
||||
interface Application {
|
||||
name: string;
|
||||
namespace: string;
|
||||
repoURL: string;
|
||||
path: string;
|
||||
targetRevision: string;
|
||||
destination: {
|
||||
server: string;
|
||||
namespace: string;
|
||||
};
|
||||
health: string;
|
||||
sync: string;
|
||||
resources: Array<{
|
||||
kind: string;
|
||||
name: string;
|
||||
namespace: string;
|
||||
status: string;
|
||||
health: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ArgoCDData {
|
||||
applications: Application[];
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const getHealthIcon = (status: string) => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'healthy':
|
||||
return <CheckCircle2 className="w-5 h-5 text-green-500" />;
|
||||
case 'degraded':
|
||||
return <AlertCircle className="w-5 h-5 text-yellow-500" />;
|
||||
case 'progressing':
|
||||
return <Clock className="w-5 h-5 text-blue-500" />;
|
||||
case 'suspended':
|
||||
return <AlertCircle className="w-5 h-5 text-gray-500" />;
|
||||
case 'missing':
|
||||
case 'unknown':
|
||||
return <XCircle className="w-5 h-5 text-red-500" />;
|
||||
default:
|
||||
return <AlertCircle className="w-5 h-5 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getSyncIcon = (status: string) => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'synced':
|
||||
return <CheckCircle2 className="w-5 h-5 text-green-500" />;
|
||||
case 'outofsync':
|
||||
return <XCircle className="w-5 h-5 text-yellow-500" />;
|
||||
case 'unknown':
|
||||
return <AlertCircle className="w-5 h-5 text-gray-500" />;
|
||||
default:
|
||||
return <AlertCircle className="w-5 h-5 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getHealthColor = (status: string) => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'healthy':
|
||||
return 'text-green-500';
|
||||
case 'degraded':
|
||||
return 'text-yellow-500';
|
||||
case 'progressing':
|
||||
return 'text-blue-500';
|
||||
case 'suspended':
|
||||
return 'text-gray-500';
|
||||
case 'missing':
|
||||
case 'unknown':
|
||||
return 'text-red-500';
|
||||
default:
|
||||
return 'text-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
const getSyncColor = (status: string) => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'synced':
|
||||
return 'text-green-500';
|
||||
case 'outofsync':
|
||||
return 'text-yellow-500';
|
||||
case 'unknown':
|
||||
return 'text-gray-500';
|
||||
default:
|
||||
return 'text-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
export default function ArgoCDView() {
|
||||
const [data, setData] = useState<ArgoCDData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchApplications = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch('/api/argocd');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch ArgoCD data');
|
||||
console.error('Error fetching ArgoCD applications:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchApplications();
|
||||
// 30초마다 자동 새로고침
|
||||
const interval = setInterval(fetchApplications, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-gray-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<XCircle className="w-12 h-12 text-red-500" />
|
||||
<p className="text-red-500">{error}</p>
|
||||
<button
|
||||
onClick={fetchApplications}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.applications.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<p className="text-gray-500">No applications found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">ArgoCD Applications</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Last updated: {new Date(data.timestamp).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchApplications}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{data.applications.map((app) => (
|
||||
<Card key={app.name} className="p-6 hover:shadow-lg transition-shadow">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-1">{app.name}</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{app.namespace}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Health:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{getHealthIcon(app.health)}
|
||||
<span className={`text-sm font-medium ${getHealthColor(app.health)}`}>
|
||||
{app.health}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Sync:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{getSyncIcon(app.sync)}
|
||||
<span className={`text-sm font-medium ${getSyncColor(app.sync)}`}>
|
||||
{app.sync}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="space-y-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
<div>
|
||||
<span className="font-medium">Repository:</span>{' '}
|
||||
<a
|
||||
href={app.repoURL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{app.repoURL.split('/').pop()}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Revision:</span> {app.targetRevision}
|
||||
</div>
|
||||
{app.path && (
|
||||
<div>
|
||||
<span className="font-medium">Path:</span> {app.path}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-medium">Destination:</span>{' '}
|
||||
{app.destination.namespace}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Resources:</span> {app.resources.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{app.resources.length > 0 && (
|
||||
<div className="pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<details className="cursor-pointer">
|
||||
<summary className="text-sm font-medium mb-2">
|
||||
Resources ({app.resources.length})
|
||||
</summary>
|
||||
<div className="mt-2 space-y-1 max-h-40 overflow-y-auto">
|
||||
{app.resources.map((resource, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between text-xs p-2 bg-gray-50 dark:bg-gray-800 rounded"
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium">{resource.kind}</span>
|
||||
<span className="text-gray-500 ml-2">{resource.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{getHealthIcon(resource.health)}
|
||||
<span className={getHealthColor(resource.health)}>
|
||||
{resource.health}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ const HEADER_MENU_ITEMS = [
|
||||
{ name: 'Experience', path: '#experience' },
|
||||
{ name: 'Projects', path: '#projects' },
|
||||
{ name: 'Contact', path: '#contact' },
|
||||
{ name: 'ArgoCD', path: '/argocd' },
|
||||
];
|
||||
|
||||
interface HeaderProfileProps {
|
||||
|
||||
Reference in New Issue
Block a user