Compare commits

...

2 Commits

Author SHA1 Message Date
c8bb675ba4 feat: add data explorer to sidebar navigation and fix gitignore
All checks were successful
Deploy to Production / deploy (push) Successful in 1m30s
- Add "데이터 탐색" menu item to sidebar with Search icon
- Add "수집 데이터 조회" link button on data management page
- Fix sidebar active state to correctly distinguish /admin/data
  from /admin/data/explorer
- Add page title mapping for data explorer in header
- Fix .gitignore: add negation for frontend/src/app/admin/data/
  so admin data pages are tracked without needing git add -f
- Fix dashboard loading state (return null → skeleton with layout)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 23:54:09 +09:00
2858c87b1b fix: use named volume for production PostgreSQL to prevent data loss
Two issues caused DB reset on every deploy:

1. docker-compose.prod.yml used bind mount (./data/postgres) with
   PostgreSQL 18's incompatible /var/lib/postgresql/data path.

2. The Gitea CI runner shares Docker socket with the host, but
   ./data/postgres resolves to a temp path inside the runner container.
   Each deploy creates a fresh workspace, so the bind mount always
   points to an empty directory on the host.

Fix: Use a named Docker volume (same as docker-compose.yml dev config).
Named volumes are managed by Docker daemon directly, survive container
recreation, and don't depend on working directory resolution.

Also fix deploy.yml: remove unnecessary mkdir for data dirs, write
backup to /tmp instead of relative path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 23:47:46 +09:00
7 changed files with 54 additions and 16 deletions

View File

@ -39,14 +39,8 @@ jobs:
- name: Backup database before deploy
run: |
mkdir -p ./data/backups
docker exec galaxis-po-db pg_dump -U ${{ secrets.DB_USER }} ${{ secrets.DB_NAME }} \
> ./data/backups/$(date +%Y%m%d_%H%M%S).sql 2>/dev/null || true
- name: Ensure data directories exist
run: |
mkdir -p ./data/postgres
mkdir -p ./data/backups
> /tmp/galaxis-po-backup-$(date +%Y%m%d_%H%M%S).sql 2>/dev/null || true
- name: Deploy with Docker Compose
run: |

1
.gitignore vendored
View File

@ -55,6 +55,7 @@ Thumbs.db
*.db
*.sqlite3
data/
!frontend/src/app/admin/data/
# Test
.coverage

View File

@ -1,5 +1,11 @@
# Production Docker Compose
# Usage: docker compose -f docker-compose.prod.yml up -d
#
# DB data is stored in a named volume (galaxis-po_postgres_data).
# This survives container recreation and avoids path resolution issues
# when deploying via CI runners with shared Docker sockets.
# To back up: docker exec galaxis-po-db pg_dump -U $DB_USER $DB_NAME > backup.sql
# Volume is only removed with: docker volume rm galaxis-po_postgres_data
services:
postgres:
@ -10,7 +16,7 @@ services:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
volumes:
- ./data/postgres:/var/lib/postgresql/data
- postgres_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 5s
@ -63,3 +69,7 @@ services:
networks:
galaxy-net:
driver: bridge
volumes:
postgres_data:
driver: local

View File

@ -2,6 +2,7 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { DashboardLayout } from '@/components/layout/dashboard-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@ -135,7 +136,12 @@ export default function DataManagementPage() {
return (
<DashboardLayout>
<h1 className="text-2xl font-bold text-foreground mb-6"> </h1>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-foreground"> </h1>
<Button asChild variant="outline">
<Link href="/admin/data/explorer"> </Link>
</Button>
</div>
{error && (
<div className="bg-destructive/10 border border-destructive text-destructive px-4 py-3 rounded mb-4">

View File

@ -84,7 +84,26 @@ export default function DashboardPage() {
}, [router]);
if (loading) {
return null; // DashboardLayout handles skeleton
return (
<DashboardLayout>
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardContent className="pt-6">
<div className="h-4 w-20 bg-muted animate-pulse rounded mb-2" />
<div className="h-8 w-32 bg-muted animate-pulse rounded" />
</CardContent>
</Card>
))}
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card><CardContent className="pt-6"><div className="h-64 bg-muted animate-pulse rounded" /></CardContent></Card>
<Card><CardContent className="pt-6"><div className="h-64 bg-muted animate-pulse rounded" /></CardContent></Card>
</div>
</div>
</DashboardLayout>
);
}
const totalValue = portfolios.reduce((sum, p) => sum + (p.total_value ?? 0), 0);

View File

@ -10,7 +10,8 @@ const pageTitles: Record<string, string> = {
'/portfolio': '포트폴리오',
'/strategy': '전략',
'/backtest': '백테스트',
'/admin/data': '데이터 관리',
'/admin/data': '데이터 수집',
'/admin/data/explorer': '데이터 탐색',
};
function getPageTitle(pathname: string): string {
@ -19,12 +20,16 @@ function getPageTitle(pathname: string): string {
return pageTitles[pathname];
}
// Check for partial matches (for nested routes)
// Check for partial matches (for nested routes), prefer longest match
let bestMatch = '';
let bestTitle = '';
for (const [path, title] of Object.entries(pageTitles)) {
if (path !== '/' && pathname.startsWith(path)) {
return title;
if (path !== '/' && pathname.startsWith(path) && path.length > bestMatch.length) {
bestMatch = path;
bestTitle = title;
}
}
if (bestTitle) return bestTitle;
return '대시보드';
}

View File

@ -9,6 +9,7 @@ import {
TrendingUp,
FlaskConical,
Database,
Search,
ChevronLeft,
ChevronRight,
} from 'lucide-react';
@ -27,7 +28,8 @@ const navItems = [
{ href: '/portfolio', label: '포트폴리오', icon: Briefcase },
{ href: '/strategy', label: '전략', icon: TrendingUp },
{ href: '/backtest', label: '백테스트', icon: FlaskConical },
{ href: '/admin/data', label: '데이터 관리', icon: Database },
{ href: '/admin/data', label: '데이터 수집', icon: Database },
{ href: '/admin/data/explorer', label: '데이터 탐색', icon: Search },
];
interface NewSidebarProps {
@ -84,7 +86,8 @@ export function NewSidebar({ collapsed = false, onCollapsedChange }: NewSidebarP
{navItems.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/' && pathname.startsWith(item.href));
(item.href !== '/' && pathname.startsWith(item.href) &&
!navItems.some((other) => other.href !== item.href && other.href.startsWith(item.href) && pathname.startsWith(other.href)));
const Icon = item.icon;
const linkContent = (