SPRINT-007: Gitea 연동 + 활동 로그 자동화 #2

Merged
erang merged 4 commits from feature/sprint-007 into main 2026-04-04 15:43:35 +09:00
11 changed files with 690 additions and 164 deletions
Showing only changes of commit a942f4e62d - Show all commits

View File

@@ -15,6 +15,7 @@ import { AuthModule } from './auth/auth.module';
import { AdminModule } from './admin/admin.module'; import { AdminModule } from './admin/admin.module';
import { EventsModule } from './events/events.module'; import { EventsModule } from './events/events.module';
import { CostsModule } from './costs/costs.module'; import { CostsModule } from './costs/costs.module';
import { GiteaSyncModule } from './gitea-sync/gitea-sync.module';
@Module({ @Module({
imports: [ imports: [
@@ -32,6 +33,7 @@ import { CostsModule } from './costs/costs.module';
AdminModule, AdminModule,
EventsModule, EventsModule,
CostsModule, CostsModule,
GiteaSyncModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [AppService],

View File

@@ -0,0 +1,21 @@
import { Controller, Post, Get, UseGuards } from '@nestjs/common';
import { GiteaSyncService } from './gitea-sync.service';
import { CompositeGuard } from '../auth/jwt.guard';
import { RoleGuard, Roles } from '../auth/role.guard';
@Controller('api/admin/gitea')
@UseGuards(CompositeGuard, RoleGuard)
@Roles('admin')
export class GiteaSyncController {
constructor(private readonly giteaSyncService: GiteaSyncService) {}
@Post('sync')
sync() {
return this.giteaSyncService.syncRepos();
}
@Get('status')
status() {
return { lastSyncAt: this.giteaSyncService.getLastSyncAt() };
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { GiteaSyncController } from './gitea-sync.controller';
import { GiteaSyncService } from './gitea-sync.service';
import { PrismaModule } from '../prisma/prisma.module';
import { GiteaModule } from '../gitea/gitea.module';
import { ActivityModule } from '../activity/activity.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [PrismaModule, GiteaModule, ActivityModule, AuthModule],
controllers: [GiteaSyncController],
providers: [GiteaSyncService],
exports: [GiteaSyncService],
})
export class GiteaSyncModule {}

View File

@@ -0,0 +1,79 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
import { GiteaService } from '../gitea/gitea.service';
import { ActivityService } from '../activity/activity.service';
@Injectable()
export class GiteaSyncService {
private readonly logger = new Logger(GiteaSyncService.name);
private lastSyncAt: Date | null = null;
constructor(
private readonly prisma: PrismaService,
private readonly gitea: GiteaService,
private readonly activity: ActivityService,
) {}
async syncRepos(): Promise<{ synced: number; created: number; updated: number }> {
const repos = await this.gitea.getOrgRepos();
if (!repos.length) {
this.logger.warn('Gitea returned 0 repos (unavailable or empty org)');
return { synced: 0, created: 0, updated: 0 };
}
let created = 0;
let updated = 0;
for (const repo of repos) {
const existing = await this.prisma.project.findUnique({
where: { giteaId: repo.id },
});
if (!existing) {
await this.prisma.project.create({
data: {
giteaId: repo.id,
name: repo.name,
repoUrl: repo.html_url,
description: repo.description ?? null,
status: 'active',
},
});
created++;
await this.activity.log({
action: 'gitea_sync_new_repo',
detail: `New project synced from Gitea: [${repo.name}]`,
});
} else {
await this.prisma.project.update({
where: { giteaId: repo.id },
data: {
name: repo.name,
repoUrl: repo.html_url,
description: repo.description ?? null,
},
});
updated++;
}
}
this.lastSyncAt = new Date();
if (created > 0 || updated > 0) {
await this.activity.log({
action: 'gitea_sync_complete',
detail: `Gitea sync: ${created} created, ${updated} updated`,
});
}
this.logger.log(`Gitea sync: ${repos.length} repos, ${created} new, ${updated} updated`);
return { synced: repos.length, created, updated };
}
getLastSyncAt() {
return this.lastSyncAt;
}
}

View File

@@ -22,6 +22,24 @@ export interface GiteaPR {
html_url: string; html_url: string;
user: { login: string }; user: { login: string };
created_at: string; created_at: string;
merged: boolean;
merged_at: string | null;
}
export interface GiteaCommit {
sha: string;
commit: {
message: string;
author: { name: string; date: string };
};
author?: { login: string; avatar_url: string } | null;
html_url: string;
}
export interface GiteaBranch {
name: string;
commit: { id: string; created: string };
protected: boolean;
} }
@Injectable() @Injectable()
@@ -87,4 +105,44 @@ export class GiteaService {
return []; return [];
} }
} }
async getCommits(repoName: string, limit = 20): Promise<GiteaCommit[]> {
if (!this.isAvailable()) return [];
try {
const { data } = await this.client.get<GiteaCommit[]>(
`/repos/${this.org}/${repoName}/commits`,
{ params: { limit } },
);
return data;
} catch {
this.logger.warn(`Failed to fetch commits for ${repoName}`);
return [];
}
}
async getBranches(repoName: string): Promise<GiteaBranch[]> {
if (!this.isAvailable()) return [];
try {
const { data } = await this.client.get<GiteaBranch[]>(
`/repos/${this.org}/${repoName}/branches`,
{ params: { limit: 50 } },
);
return data;
} catch {
return [];
}
}
async getPulls(repoName: string, state: 'open' | 'closed' | 'all' = 'open'): Promise<GiteaPR[]> {
if (!this.isAvailable()) return [];
try {
const { data } = await this.client.get<GiteaPR[]>(
`/repos/${this.org}/${repoName}/pulls`,
{ params: { state, limit: 30, type: 'pulls' } },
);
return data;
} catch {
return [];
}
}
} }

View File

@@ -1,5 +1,7 @@
import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common'; import { Controller, Get, Param, Query, ParseIntPipe, UseGuards, Post } from '@nestjs/common';
import { ProjectsService } from './projects.service'; import { ProjectsService } from './projects.service';
import { CompositeGuard } from '../auth/jwt.guard';
import { RoleGuard, Roles } from '../auth/role.guard';
@Controller('api/projects') @Controller('api/projects')
export class ProjectsController { export class ProjectsController {
@@ -14,4 +16,25 @@ export class ProjectsController {
getProjectById(@Param('id', ParseIntPipe) id: number) { getProjectById(@Param('id', ParseIntPipe) id: number) {
return this.projectsService.getProjectById(id); return this.projectsService.getProjectById(id);
} }
@Get(':id/commits')
getCommits(
@Param('id', ParseIntPipe) id: number,
@Query('limit') limit?: string,
) {
return this.projectsService.getProjectCommits(id, limit ? parseInt(limit, 10) : 20);
}
@Get(':id/branches')
getBranches(@Param('id', ParseIntPipe) id: number) {
return this.projectsService.getProjectBranches(id);
}
@Get(':id/pulls')
getPulls(
@Param('id', ParseIntPipe) id: number,
@Query('state') state?: 'open' | 'closed' | 'all',
) {
return this.projectsService.getProjectPulls(id, state ?? 'open');
}
} }

View File

@@ -79,4 +79,28 @@ export class ProjectsService {
return { ...project, openPRs: giteaPRs }; return { ...project, openPRs: giteaPRs };
} }
async getProjectCommits(id: number, limit = 20) {
const project = await this.getProjectMeta(id);
const repoName = project.repoUrl.split('/').pop() ?? '';
return this.gitea.getCommits(repoName, limit);
}
async getProjectBranches(id: number) {
const project = await this.getProjectMeta(id);
const repoName = project.repoUrl.split('/').pop() ?? '';
return this.gitea.getBranches(repoName);
}
async getProjectPulls(id: number, state: 'open' | 'closed' | 'all' = 'open') {
const project = await this.getProjectMeta(id);
const repoName = project.repoUrl.split('/').pop() ?? '';
return this.gitea.getPulls(repoName, state);
}
private async getProjectMeta(id: number) {
const project = await this.prisma.project.findUnique({ where: { id }, select: { id: true, repoUrl: true } });
if (!project) throw new NotFoundException(`Project ${id} not found`);
return project;
}
} }

View File

@@ -1,7 +1,8 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger, Optional } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SshService } from './ssh.service'; import { SshService } from './ssh.service';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { ActivityService } from '../activity/activity.service';
export interface SisterStatus { export interface SisterStatus {
id: number; id: number;
@@ -29,6 +30,7 @@ export class SistersService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly ssh: SshService, private readonly ssh: SshService,
private readonly config: ConfigService, private readonly config: ConfigService,
@Optional() private readonly activity?: ActivityService,
) {} ) {}
async getAllSistersStatus(): Promise<SisterStatus[]> { async getAllSistersStatus(): Promise<SisterStatus[]> {
@@ -62,7 +64,7 @@ export class SistersService {
} }
private async checkSisterStatus( private async checkSisterStatus(
sister: { id: number; name: string; ip: string; user: string; lxcId: number; lastSeen: Date | null }, sister: { id: number; name: string; ip: string; user: string; lxcId: number; lastSeen: Date | null; status: string },
sshKeyPath: string, sshKeyPath: string,
): Promise<SisterStatus> { ): Promise<SisterStatus> {
try { try {
@@ -77,11 +79,20 @@ export class SistersService {
const status: 'online' | 'offline' = isActive ? 'online' : 'offline'; const status: 'online' | 'offline' = isActive ? 'online' : 'offline';
if (isActive) { if (isActive) {
// 온라인이면 lastSeen 업데이트 // 온라인이면 lastSeen 업데이트 + 상태 변경 감지
const prevStatus = sister.status;
await this.prisma.sisterConfig.update({ await this.prisma.sisterConfig.update({
where: { id: sister.id }, where: { id: sister.id },
data: { lastSeen: new Date(), status }, data: { lastSeen: new Date(), status },
}); });
// 상태 변경 시 ActivityLog 기록
if (prevStatus !== status && this.activity) {
await this.activity.log({
sisterId: sister.id,
action: 'status_changed',
detail: `[${sister.name}] status: ${prevStatus}${status}`,
}).catch(() => {}); // 비동기 오류 무시
}
} }
return { return {

View File

@@ -1,18 +1,18 @@
'use client'; 'use client';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useCallback } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import styled from 'styled-components'; import styled from 'styled-components';
import Link from 'next/link'; import Link from 'next/link';
import { LabelMeta, PageTitle, SectionTitle, TechBar, TechBarFill } from '@/components/ui/base'; import { LabelMeta, PageTitle, SectionTitle, TechBar, TechBarFill } from '@/components/ui/base';
import { API_URL } from '@/lib/config'; import { API_URL } from '@/lib/config';
// ─── Styled ───
const Breadcrumb = styled.div` const Breadcrumb = styled.div`
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: 11px;
color: var(--text-secondary); color: var(--text-secondary);
margin-bottom: var(--space-md); margin-bottom: var(--space-md);
a { color: var(--text-secondary); text-decoration: none; &:hover { color: var(--text-primary); } } a { color: var(--text-secondary); text-decoration: none; &:hover { color: var(--text-primary); } }
`; `;
@@ -25,19 +25,188 @@ const PageTitleRow = styled.div`
flex-wrap: wrap; flex-wrap: wrap;
`; `;
const TabBar = styled.div`
display: flex;
gap: var(--space-lg);
border-bottom: 1px solid var(--border-color);
margin-bottom: var(--space-xl);
overflow-x: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
`;
const TabBtn = styled.button<{ $active: boolean }>`
font-size: 13px;
font-weight: 500;
color: ${({ $active }) => $active ? 'var(--text-primary)' : 'var(--text-secondary)'};
background: transparent;
border: none;
border-bottom: 1px solid ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
padding: var(--space-sm) 0;
cursor: pointer;
white-space: nowrap;
transition: color 0.15s, border-color 0.15s;
&:hover { color: var(--text-primary); }
`;
const CommitTable = styled.div`
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
background: var(--bg-code);
font-family: var(--font-mono);
`;
const CommitRow = styled.a`
display: grid;
grid-template-columns: 80px 1fr 120px 100px;
gap: var(--space-md);
padding: 10px 12px;
border-bottom: 1px solid #1a1a1a;
text-decoration: none;
transition: background 0.1s;
&:last-child { border-bottom: none; }
&:hover { background: #1a1a1a; }
@media (max-width: 767px) {
grid-template-columns: 70px 1fr;
gap: var(--space-sm);
}
`;
const CommitHash = styled.span`
font-size: 12px;
color: #5fafff;
font-family: var(--font-mono);
`;
const CommitMsg = styled.span`
font-size: 12px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const CommitMeta = styled.span`
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
const BranchGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: var(--space-md);
`;
const BranchCard = styled.div`
border: 1px solid var(--border-color);
padding: var(--space-md) var(--space-lg);
transition: border-color 0.15s;
&:hover { border-color: var(--border-hover); }
`;
const BranchName = styled.div`
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-primary);
margin-bottom: 4px;
`;
const BranchMeta = styled.div`
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
`;
const PRList = styled.div`
display: flex;
flex-direction: column;
gap: var(--space-sm);
`;
const PRItem = styled.a`
display: flex;
align-items: center;
gap: var(--space-md);
padding: 12px var(--space-lg);
border: 1px solid var(--border-color);
text-decoration: none;
transition: border-color 0.15s;
&:hover { border-color: var(--border-hover); }
`;
const PRState = styled.span<{ $state: string }>`
font-family: var(--font-mono);
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
padding: 2px 6px;
border: 1px solid;
white-space: nowrap;
${({ $state }) => {
switch ($state) {
case 'open': return "color: #5fff8a; border-color: #5fff8a44;";
case 'closed': return "color: var(--text-secondary); border-color: var(--border-color);";
case 'merged': return "color: #5fafff; border-color: #5fafff44;";
default: return "color: var(--text-secondary); border-color: var(--border-color);";
}
}}
`;
const PRTitle = styled.span`
font-size: 13px;
color: var(--text-primary);
flex: 1;
`;
const PRMeta = styled.span`
font-size: 11px;
color: var(--text-secondary);
font-family: var(--font-mono);
white-space: nowrap;
`;
const FilterRow = styled.div`
display: flex;
gap: var(--space-sm);
margin-bottom: var(--space-lg);
`;
const FilterBtn = styled.button<{ $active: boolean }>`
background: ${({ $active }) => $active ? 'var(--text-primary)' : 'transparent'};
border: 1px solid var(--border-color);
color: ${({ $active }) => $active ? '#000' : 'var(--text-secondary)'};
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
padding: 4px 12px;
cursor: pointer;
font-family: var(--font-mono);
transition: all 0.15s;
&:hover { border-color: var(--border-hover); color: ${({ $active }) => $active ? '#000' : 'var(--text-primary)'}; }
`;
const Empty = styled.div`
padding: var(--space-xl) 0;
color: var(--text-secondary);
font-size: 13px;
font-family: var(--font-mono);
`;
// Phase timeline 컴포넌트들
const ProjectGrid = styled.div` const ProjectGrid = styled.div`
display: grid; display: grid;
grid-template-columns: 1fr 320px; grid-template-columns: 1fr 320px;
gap: var(--space-xxl); gap: var(--space-xxl);
align-items: start; align-items: start;
@media (max-width: 1199px) { grid-template-columns: 1fr; gap: var(--space-xl); }
@media (max-width: 1199px) {
grid-template-columns: 1fr;
gap: var(--space-xl);
}
`; `;
// Phase Timeline
const PhaseTimeline = styled.div` const PhaseTimeline = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -80,39 +249,6 @@ const PhaseDesc = styled.div`
line-height: 1.5; line-height: 1.5;
`; `;
// Audit Log
const AuditLog = styled.div`
display: flex;
flex-direction: column;
gap: 1px;
background: var(--border-color);
border: 1px solid var(--border-color);
font-family: var(--font-mono);
`;
const AuditRow = styled.div<{ $header?: boolean }>`
background: var(--bg-main);
padding: var(--space-sm) var(--space-md);
display: grid;
grid-template-columns: 100px 1fr 80px;
font-size: 11px;
color: ${({ $header }) => $header ? 'var(--text-secondary)' : 'var(--text-primary)'};
font-weight: ${({ $header }) => $header ? '700' : '400'};
text-transform: ${({ $header }) => $header ? 'uppercase' : 'none'};
`;
const StatusTag = styled.span<{ $pass?: boolean }>`
padding: 2px 6px;
font-size: 10px;
border: 1px solid ${({ $pass }) => $pass ? '#00FF00' : 'var(--border-color)'};
color: ${({ $pass }) => $pass ? '#00FF00' : 'var(--text-secondary)'};
text-align: center;
font-family: var(--font-mono);
font-size: 9px;
text-transform: uppercase;
`;
// Right Panel
const NodeStack = styled.div` const NodeStack = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -126,13 +262,10 @@ const NodeMiniCard = styled.div`
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
transition: border-color 0.2s;
&:hover { border-color: var(--border-hover); }
`; `;
const NodeStatusDot = styled.div` const NodeDot = styled.div`
width: 6px; width: 6px; height: 6px;
height: 6px;
background: #00FF00; background: #00FF00;
border-radius: 50%; border-radius: 50%;
`; `;
@@ -153,43 +286,47 @@ const CheckItem = styled.li<{ $done?: boolean }>`
font-size: 13px; font-size: 13px;
color: ${({ $done }) => $done ? 'var(--text-secondary)' : 'var(--text-primary)'}; color: ${({ $done }) => $done ? 'var(--text-secondary)' : 'var(--text-primary)'};
text-decoration: ${({ $done }) => $done ? 'line-through' : 'none'}; text-decoration: ${({ $done }) => $done ? 'line-through' : 'none'};
&:last-child { border-bottom: none; } &:last-child { border-bottom: none; }
`; `;
const CheckBox = styled.div<{ $checked?: boolean }>` const CheckBox = styled.div<{ $checked?: boolean }>`
width: 14px; width: 14px; height: 14px;
height: 14px;
border: 1px solid ${({ $checked }) => $checked ? 'var(--text-primary)' : 'var(--border-color)'}; border: 1px solid ${({ $checked }) => $checked ? 'var(--text-primary)' : 'var(--border-color)'};
flex-shrink: 0; flex-shrink: 0;
background: ${({ $checked }) => $checked ? 'var(--text-primary)' : 'transparent'}; background: ${({ $checked }) => $checked ? 'var(--text-primary)' : 'transparent'};
position: relative; position: relative;
${({ $checked }) => $checked && `&::after { content: '✓'; position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 9px; color: #000; }`}
${({ $checked }) => $checked && `
&::after {
content: '✓';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
color: #000;
}
`}
`; `;
// ─── Helpers ───
function getSprintMeta(sprint: any): { label: string; isActive: boolean } { function getSprintMeta(sprint: any): { label: string; isActive: boolean } {
if (sprint.status === 'done') return { label: `COMPLETED\n${sprint.completedAt ? new Date(sprint.completedAt).toLocaleDateString() : ''}`, isActive: false }; if (sprint.status === 'done') return { label: `COMPLETED`, isActive: false };
if (sprint.status === 'in_progress') return { label: 'IN PROGRESS\nEST: TBD', isActive: true }; if (sprint.status === 'in_progress') return { label: 'IN PROGRESS', isActive: true };
return { label: 'PENDING\nTBD', isActive: false }; return { label: 'PENDING', isActive: false };
} }
function formatDate(str: string): string {
return new Date(str).toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
}
const TABS = [
{ id: 'overview', label: 'Overview' },
{ id: 'commits', label: 'Commits' },
{ id: 'branches', label: 'Branches' },
{ id: 'prs', label: 'Pull Requests' },
];
export default function ProjectDetailPage() { export default function ProjectDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const [project, setProject] = useState<any>(null); const [project, setProject] = useState<any>(null);
const [tasks, setTasks] = useState<any[]>([]); const [tasks, setTasks] = useState<any[]>([]);
const [commits, setCommits] = useState<any[]>([]);
const [branches, setBranches] = useState<any[]>([]);
const [pulls, setPulls] = useState<any[]>([]);
const [prFilter, setPrFilter] = useState<'open' | 'closed' | 'all'>('open');
const [tab, setTab] = useState('overview');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [tabLoading, setTabLoading] = useState(false);
useEffect(() => { useEffect(() => {
Promise.allSettled([ Promise.allSettled([
@@ -201,26 +338,77 @@ export default function ProjectDetailPage() {
}).finally(() => setLoading(false)); }).finally(() => setLoading(false));
}, [id]); }, [id]);
if (loading) return <div style={{ color: 'var(--text-secondary)', fontSize: '13px' }}> ...</div>; const loadCommits = useCallback(async () => {
if (!project) return <div style={{ color: 'var(--text-secondary)', fontSize: '13px' }}> </div>; setTabLoading(true);
try {
const res = await fetch(`${API_URL}/api/projects/${id}/commits?limit=30`);
if (res.ok) setCommits(await res.json());
} finally { setTabLoading(false); }
}, [id]);
const loadBranches = useCallback(async () => {
setTabLoading(true);
try {
const res = await fetch(`${API_URL}/api/projects/${id}/branches`);
if (res.ok) setBranches(await res.json());
} finally { setTabLoading(false); }
}, [id]);
const loadPulls = useCallback(async (state: 'open' | 'closed' | 'all') => {
setTabLoading(true);
try {
const res = await fetch(`${API_URL}/api/projects/${id}/pulls?state=${state}`);
if (res.ok) setPulls(await res.json());
} finally { setTabLoading(false); }
}, [id]);
useEffect(() => {
if (tab === 'commits' && commits.length === 0) loadCommits();
if (tab === 'branches' && branches.length === 0) loadBranches();
if (tab === 'prs' && pulls.length === 0) loadPulls(prFilter);
}, [tab]);
useEffect(() => {
if (tab === 'prs') loadPulls(prFilter);
}, [prFilter]);
const allTasks = tasks.flatMap((s: any) => s.tasks ?? []); const allTasks = tasks.flatMap((s: any) => s.tasks ?? []);
const doneTasks = allTasks.filter((t: any) => t.status === 'done'); const doneTasks = allTasks.filter((t: any) => t.status === 'done');
const activeSprint = tasks.find((s: any) => s.status === 'in_progress'); const activeSprint = tasks.find((s: any) => s.status === 'in_progress');
if (loading) return <Empty>LOADING...</Empty>;
if (!project) return <Empty>PROJECT NOT FOUND</Empty>;
return ( return (
<> <>
<Breadcrumb> <Breadcrumb>
<Link href="/projects">PROJECTS</Link> / P-{String(project.id).padStart(3, '0')} / SUMMARY <Link href="/projects">PROJECTS</Link> / P-{String(project.id).padStart(3, '0')} / {project.name}
</Breadcrumb> </Breadcrumb>
<PageTitleRow> <PageTitleRow>
<PageTitle>{project.name}</PageTitle> <PageTitle>
{project.name}
{project.repoUrl && (
<a href={project.repoUrl} target="_blank" rel="noopener"
style={{ fontSize: '12px', color: '#5fafff', marginLeft: 'var(--space-md)', fontWeight: 400 }}>
GITEA
</a>
)}
</PageTitle>
<LabelMeta><span>STATUS:</span>{project.status?.toUpperCase()} / {activeSprint?.name ?? 'PLANNING'}</LabelMeta> <LabelMeta><span>STATUS:</span>{project.status?.toUpperCase()} / {activeSprint?.name ?? 'PLANNING'}</LabelMeta>
</PageTitleRow> </PageTitleRow>
<TabBar>
{TABS.map((t) => (
<TabBtn key={t.id} $active={tab === t.id} onClick={() => setTab(t.id)}>
{t.label}
</TabBtn>
))}
</TabBar>
{/* OVERVIEW */}
{tab === 'overview' && (
<ProjectGrid> <ProjectGrid>
<div> <div>
{/* Phase Timeline */}
<SectionTitle> <SectionTitle>
<span>PHASE TIMELINE</span> <span>PHASE TIMELINE</span>
<LabelMeta>CURRENT: {activeSprint ? `S${activeSprint.number}` : 'N/A'}</LabelMeta> <LabelMeta>CURRENT: {activeSprint ? `S${activeSprint.number}` : 'N/A'}</LabelMeta>
@@ -230,66 +418,32 @@ export default function ProjectDetailPage() {
const { label, isActive } = getSprintMeta(sprint); const { label, isActive } = getSprintMeta(sprint);
return ( return (
<PhaseItem key={sprint.id}> <PhaseItem key={sprint.id}>
<PhaseMeta style={{ whiteSpace: 'pre-line' }}>{label}</PhaseMeta> <PhaseMeta>{label}</PhaseMeta>
<PhaseBox $active={isActive}> <PhaseBox $active={isActive}>
<PhaseName>SPRINT {String(sprint.number).padStart(2, '0')}: {sprint.name}</PhaseName> <PhaseName>SPRINT {String(sprint.number).padStart(2, '0')}: {sprint.name}</PhaseName>
<PhaseDesc> <PhaseDesc> {sprint.tasks?.length ?? 0} · {sprint.tasks?.filter((t: any) => t.status === 'done').length ?? 0}</PhaseDesc>
{sprint.tasks?.length ?? 0} · {sprint.tasks?.filter((t: any) => t.status === 'done').length ?? 0} <TechBar style={{ marginTop: 'var(--space-sm)' }}>
</PhaseDesc>
<TechBar style={{ marginTop: 'var(--space-sm)', background: 'var(--border-color)' }}>
<TechBarFill $width={sprint.progress ?? 0} /> <TechBarFill $width={sprint.progress ?? 0} />
</TechBar> </TechBar>
</PhaseBox> </PhaseBox>
</PhaseItem> </PhaseItem>
); );
})} })}
{tasks.length === 0 && ( {tasks.length === 0 && <PhaseItem><PhaseMeta>PENDING</PhaseMeta><PhaseBox><PhaseName>Sprint </PhaseName></PhaseBox></PhaseItem>}
<PhaseItem>
<PhaseMeta>PENDING{'\n'}TBD</PhaseMeta>
<PhaseBox><PhaseName>Sprint </PhaseName></PhaseBox>
</PhaseItem>
)}
</PhaseTimeline> </PhaseTimeline>
{/* Audit Log (활동 로그) */}
<SectionTitle>
<span>SECURITY AUDIT LOG</span>
<LabelMeta>LAST CHECK: LIVE</LabelMeta>
</SectionTitle>
<AuditLog>
<AuditRow $header>
<div>TIMESTAMP</div>
<div>ACTION / RESOURCE</div>
<div>RESULT</div>
</AuditRow>
{allTasks.slice(0, 5).map((task: any) => (
<AuditRow key={task.id}>
<div>{new Date(task.createdAt).toLocaleTimeString('ko-KR', { hour12: false })}</div>
<div>{task.taskId} / {task.assignee?.toUpperCase()}</div>
<StatusTag $pass={task.status === 'done'}>
{task.status === 'done' ? 'PASS' : task.status === 'failed' ? 'FAIL' : 'PEND'}
</StatusTag>
</AuditRow>
))}
</AuditLog>
</div> </div>
<div> <div>
{/* Assigned Nodes */}
<SectionTitle style={{ marginBottom: 'var(--space-md)' }}>ASSIGNED NODES</SectionTitle> <SectionTitle style={{ marginBottom: 'var(--space-md)' }}>ASSIGNED NODES</SectionTitle>
<NodeStack> <NodeStack>
{['harang', 'narang', 'darang', 'erang'].map((name) => ( {['harang', 'narang', 'darang', 'erang'].map((name) => (
<NodeMiniCard key={name}> <NodeMiniCard key={name}>
<LabelMeta> <LabelMeta>{name === 'harang' ? '하랑' : name === 'narang' ? '나랑' : name === 'darang' ? '다랑' : '이랑'} [{name === 'harang' ? 'PRIMARY' : name === 'narang' ? 'GEN' : name === 'darang' ? 'EVAL' : 'INFRA'}]</LabelMeta>
{name === 'harang' ? '하랑' : name === 'narang' ? '나랑' : name === 'darang' ? '다랑' : '이랑'}{' '} <NodeDot />
[{name === 'harang' ? 'PRIMARY' : name === 'narang' ? 'SECONDARY' : name === 'darang' ? 'EVALUATOR' : 'INFRA'}]
</LabelMeta>
<NodeStatusDot />
</NodeMiniCard> </NodeMiniCard>
))} ))}
</NodeStack> </NodeStack>
{/* Task Checklist */}
<SectionTitle>TASK CHECKLIST</SectionTitle> <SectionTitle>TASK CHECKLIST</SectionTitle>
<Checklist> <Checklist>
{allTasks.slice(0, 8).map((task: any) => ( {allTasks.slice(0, 8).map((task: any) => (
@@ -298,15 +452,92 @@ export default function ProjectDetailPage() {
<span>[{task.taskId}] {task.title}</span> <span>[{task.taskId}] {task.title}</span>
</CheckItem> </CheckItem>
))} ))}
{allTasks.length === 0 && ( {allTasks.length === 0 && <CheckItem><CheckBox /><span style={{ color: 'var(--text-secondary)' }}> </span></CheckItem>}
<CheckItem>
<CheckBox />
<span style={{ color: 'var(--text-secondary)' }}> </span>
</CheckItem>
)}
</Checklist> </Checklist>
</div> </div>
</ProjectGrid> </ProjectGrid>
)}
{/* COMMITS */}
{tab === 'commits' && (
<>
{tabLoading ? <Empty>LOADING COMMITS...</Empty> : (
<>
<CommitTable>
{commits.length === 0 ? (
<div style={{ padding: 'var(--space-xl)', color: 'var(--text-secondary)', fontSize: '12px', textAlign: 'center', fontFamily: 'var(--font-mono)' }}>NO COMMITS</div>
) : (
commits.map((c: any) => (
<CommitRow key={c.sha} href={c.html_url} target="_blank" rel="noopener">
<CommitHash>{c.sha?.slice(0, 7)}</CommitHash>
<CommitMsg>{c.commit?.message?.split('\n')[0]}</CommitMsg>
<CommitMeta style={{ display: 'var(--media-hide, initial)' }}>
{c.commit?.author?.name ?? c.author?.login ?? '-'}
</CommitMeta>
<CommitMeta>
{c.commit?.author?.date ? formatDate(c.commit.author.date) : '-'}
</CommitMeta>
</CommitRow>
))
)}
</CommitTable>
</>
)}
</>
)}
{/* BRANCHES */}
{tab === 'branches' && (
<>
{tabLoading ? <Empty>LOADING BRANCHES...</Empty> : (
<BranchGrid>
{branches.length === 0 ? (
<Empty>NO BRANCHES</Empty>
) : (
branches.map((b: any) => (
<BranchCard key={b.name}>
<BranchName>{b.name}</BranchName>
<BranchMeta>
{b.commit?.id?.slice(0, 7) ?? '-'}
{b.protected && ' · PROTECTED'}
</BranchMeta>
</BranchCard>
))
)}
</BranchGrid>
)}
</>
)}
{/* PULL REQUESTS */}
{tab === 'prs' && (
<>
<FilterRow>
{(['open', 'closed', 'all'] as const).map((s) => (
<FilterBtn key={s} $active={prFilter === s} onClick={() => setPrFilter(s)}>
{s.toUpperCase()}
</FilterBtn>
))}
</FilterRow>
{tabLoading ? <Empty>LOADING PULL REQUESTS...</Empty> : (
<PRList>
{pulls.length === 0 ? (
<Empty>NO PULL REQUESTS ({prFilter})</Empty>
) : (
pulls.map((pr: any) => (
<PRItem key={pr.id} href={pr.html_url} target="_blank" rel="noopener">
<PRState $state={pr.merged ? 'merged' : pr.state}>
{pr.merged ? 'MERGED' : pr.state?.toUpperCase()}
</PRState>
<PRTitle>#{pr.number} {pr.title}</PRTitle>
<PRMeta>{pr.user?.login ?? '-'} · {pr.created_at ? formatDate(pr.created_at) : '-'}</PRMeta>
</PRItem>
))
)}
</PRList>
)}
</>
)}
</> </>
); );
} }

View File

@@ -3,8 +3,9 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import Link from 'next/link'; import Link from 'next/link';
import { PageTitle, LabelMeta, TechBar, TechBarFill } from '@/components/ui/base'; import { PageTitle, LabelMeta, TechBar, TechBarFill, Btn, BtnPrimary } from '@/components/ui/base';
import { API_URL } from '@/lib/config'; import { API_URL } from '@/lib/config';
import { adminFetch } from '@/lib/adminFetch';
const ProjectList = styled.div` const ProjectList = styled.div`
display: flex; display: flex;
@@ -85,20 +86,50 @@ const EmptyState = styled.div`
export default function ProjectsPage() { export default function ProjectsPage() {
const [projects, setProjects] = useState<any[]>([]); const [projects, setProjects] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<string | null>(null);
useEffect(() => { const loadProjects = () => {
setLoading(true);
fetch(`${API_URL}/api/projects`) fetch(`${API_URL}/api/projects`)
.then((r) => r.json()) .then((r) => r.json())
.then(setProjects) .then(setProjects)
.catch(() => {}) .catch(() => {})
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); };
const handleSync = async () => {
setSyncing(true);
setSyncResult(null);
try {
const res = await adminFetch('/api/admin/gitea/sync', { method: 'POST' });
const d = await res.json();
setSyncResult(`sync: ${d.created ?? 0} created, ${d.updated ?? 0} updated`);
loadProjects();
} catch (e) {
setSyncResult('sync failed');
} finally {
setSyncing(false);
}
};
useEffect(() => { loadProjects(); }, []);
return ( return (
<> <>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-lg)' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-lg)' }}>
<PageTitle></PageTitle> <PageTitle></PageTitle>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-md)' }}>
{syncResult && (
<span style={{ fontFamily: 'var(--font-mono)', fontSize: '11px', color: 'var(--text-secondary)' }}>
{syncResult}
</span>
)}
<LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta> <LabelMeta>VOL: {String(projects.length).padStart(2, '0')}</LabelMeta>
<Btn onClick={handleSync} disabled={syncing}>
{syncing ? 'SYNCING...' : '↻ GITEA SYNC'}
</Btn>
</div>
</div> </div>
{loading ? ( {loading ? (

View File

@@ -41,8 +41,28 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setUser({ userId: data.id, username: data.username, role: data.role }); setUser({ userId: data.id, username: data.username, role: data.role });
} else if (res.status === 401) {
// access token 만료 → refresh 시도
const refreshToken = localStorage.getItem('hanarang_refresh_token');
if (refreshToken) {
try {
const rRes = await fetch(`${API_URL}/api/auth/refresh`, {
method: 'POST',
headers: { 'x-refresh-token': refreshToken },
});
if (rRes.ok) {
const d = await rRes.json();
localStorage.setItem('hanarang_access_token', d.accessToken);
if (d.refreshToken) localStorage.setItem('hanarang_refresh_token', d.refreshToken);
setUser({ userId: 0, username: d.username, role: d.role });
} else { } else {
localStorage.removeItem('hanarang_access_token'); localStorage.removeItem('hanarang_access_token');
localStorage.removeItem('hanarang_refresh_token');
}
} catch { /* silent */ }
} else {
localStorage.removeItem('hanarang_access_token');
}
} }
} catch { } catch {
// silent // silent
@@ -70,6 +90,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
if (d.refreshToken) { if (d.refreshToken) {
localStorage.setItem('hanarang_refresh_token', d.refreshToken); localStorage.setItem('hanarang_refresh_token', d.refreshToken);
} }
// me API로 실제 userId 가져오기
try {
const meRes = await fetch(`${API_URL}/api/auth/me`, {
headers: { Authorization: `Bearer ${d.accessToken}` },
});
if (meRes.ok) {
const me = await meRes.json();
setUser({ userId: me.id, username: me.username, role: me.role });
return;
}
} catch { /* fallback */ }
setUser({ userId: 0, username: d.username, role: d.role }); setUser({ userId: 0, username: d.username, role: d.role });
}; };