Compare commits
18 Commits
main
...
09cdb088af
| Author | SHA1 | Date | |
|---|---|---|---|
| 09cdb088af | |||
| ea11f92070 | |||
| f69ee95443 | |||
| 591173d1cf | |||
| 7e32632186 | |||
| 522d39d3df | |||
| 8bd6b1f400 | |||
| 53c1f45e02 | |||
| 1ea11a6a3c | |||
| ca61a89739 | |||
| 9155bf5479 | |||
| 92091bfe88 | |||
| 247b9dbee7 | |||
| c3963fc26c | |||
| 6e8a7b81fb | |||
| 3c6723bcde | |||
| 76e727fb99 | |||
| 52cc779bca |
@@ -2,7 +2,7 @@
|
||||
HEADSCALE_API_KEY=your_api_key_here
|
||||
|
||||
# Server configuration
|
||||
SERVER_URL=http://localhost:8080
|
||||
SERVER_URL=http://localhost:8070
|
||||
LISTEN_ADDR=0.0.0.0:8080
|
||||
|
||||
# Database (SQLite by default)
|
||||
|
||||
99
.gitignore
vendored
99
.gitignore
vendored
@@ -61,4 +61,101 @@ tmp/
|
||||
temp/
|
||||
|
||||
# Docker Compose override files
|
||||
docker-compose.override.yml
|
||||
docker-compose.override.yml
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
.venv/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
Pipfile.lock
|
||||
|
||||
# PEP 582
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
301
CLIENT_CONNECTION_TEST.md
Normal file
301
CLIENT_CONNECTION_TEST.md
Normal file
@@ -0,0 +1,301 @@
|
||||
# 🔗 Tailscale 클라이언트 연결 및 테스트 가이드
|
||||
|
||||
## 📋 테스트 개요
|
||||
- **목적**: Headscale 서버에 Tailscale 클라이언트 연결 및 VPN 기능 검증
|
||||
- **환경**: Ubuntu 24.04 LTS, Tailscale 1.86.2
|
||||
- **서버**: Headscale (http://localhost:8070)
|
||||
|
||||
## 🛠️ 사전 준비사항
|
||||
- Headscale 서버가 정상 작동 중 (8070 포트)
|
||||
- 사용자 및 Pre-auth 키 생성 완료
|
||||
- 테스트할 클라이언트 장치 준비
|
||||
|
||||
## 📊 기본 정보 확인
|
||||
|
||||
### Headscale 서버 상태
|
||||
```bash
|
||||
# API 헬스 체크
|
||||
curl -s http://localhost:8070/health
|
||||
# 응답: {"status":"pass"}
|
||||
|
||||
# 컨테이너 상태 확인
|
||||
docker-compose ps
|
||||
# STATUS: Up (healthy 또는 running)
|
||||
```
|
||||
|
||||
### 사용자 및 키 정보
|
||||
```bash
|
||||
# 사용자 목록
|
||||
docker-compose exec headscale headscale users list
|
||||
# 결과: myuser (ID: 1)
|
||||
|
||||
# Pre-auth 키 확인
|
||||
echo "Pre-auth Key: fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21"
|
||||
```
|
||||
|
||||
## 🚀 Tailscale 클라이언트 설치
|
||||
|
||||
### Ubuntu/Debian 설치
|
||||
```bash
|
||||
# 공식 설치 스크립트 사용
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
|
||||
# 설치 확인
|
||||
tailscale version
|
||||
# 결과: 1.86.2
|
||||
```
|
||||
|
||||
### 설치 후 서비스 상태 확인
|
||||
```bash
|
||||
# Tailscale 데몬 상태 확인
|
||||
sudo systemctl status tailscaled
|
||||
# Active: active (running)
|
||||
|
||||
# Tailscale 명령어 확인
|
||||
which tailscale
|
||||
# /usr/bin/tailscale
|
||||
```
|
||||
|
||||
## 🔗 Headscale 서버 연결
|
||||
|
||||
### 연결 명령어 실행
|
||||
```bash
|
||||
# Pre-auth 키를 사용한 자동 연결
|
||||
tailscale up --login-server=http://localhost:8070 --authkey=fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21
|
||||
```
|
||||
|
||||
### 연결 성공 확인
|
||||
```bash
|
||||
# 연결 상태 확인
|
||||
tailscale status
|
||||
```
|
||||
|
||||
**성공적인 출력 예시:**
|
||||
```
|
||||
100.64.0.1 0bin-ubuntu-vm myuser linux -
|
||||
```
|
||||
|
||||
## 📡 네트워크 인터페이스 확인
|
||||
|
||||
### Tailscale 인터페이스 생성 확인
|
||||
```bash
|
||||
# tailscale0 인터페이스 확인
|
||||
ip addr show tailscale0
|
||||
```
|
||||
|
||||
**출력 결과:**
|
||||
```
|
||||
214: tailscale0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 1280 qdisc pfifo_fast state UNKNOWN group default qlen 500
|
||||
link/none
|
||||
inet 100.64.0.1/32 scope global tailscale0
|
||||
valid_lft forever preferred_lft forever
|
||||
inet6 fd7a:115c:a1e0::1/128 scope global
|
||||
valid_lft forever preferred_lft forever
|
||||
inet6 fe80::a49:8d96:4244:2fcf/64 scope link stable-privacy
|
||||
valid_lft forever preferred_lft forever
|
||||
```
|
||||
|
||||
### IP 주소 할당 확인
|
||||
- **IPv4**: `100.64.0.1/32`
|
||||
- **IPv6**: `fd7a:115c:a1e0::1/128`
|
||||
- **링크로컬**: `fe80::a49:8d96:4244:2fcf/64`
|
||||
|
||||
## 🌐 Headscale 서버에서 노드 확인
|
||||
|
||||
### 연결된 노드 목록 확인
|
||||
```bash
|
||||
docker-compose exec headscale headscale nodes list
|
||||
```
|
||||
|
||||
**출력 결과:**
|
||||
```
|
||||
ID | Hostname | Name | MachineKey | NodeKey | User | IP addresses | Ephemeral | Last seen | Expiration | Connected | Expired
|
||||
1 | 0bin-Ubuntu-VM | 0bin-ubuntu-vm| [rzOhs] | [SbpbT] | myuser | 100.64.0.1, fd7a:115c:a1e0::1| false | 2025-09-09 05:42:25 | N/A | online | no
|
||||
```
|
||||
|
||||
### 노드 세부 정보
|
||||
- **ID**: 1
|
||||
- **호스트명**: 0bin-Ubuntu-VM
|
||||
- **노드명**: 0bin-ubuntu-vm
|
||||
- **사용자**: myuser
|
||||
- **IP 주소**: 100.64.0.1 (IPv4), fd7a:115c:a1e0::1 (IPv6)
|
||||
- **상태**: online
|
||||
- **임시 노드**: false
|
||||
- **만료**: 없음
|
||||
|
||||
## 🧪 연결 테스트
|
||||
|
||||
### 1. 자기 자신 핑 테스트
|
||||
```bash
|
||||
# IPv4 핑 테스트
|
||||
ping -c 3 100.64.0.1
|
||||
```
|
||||
|
||||
**성공 결과:**
|
||||
```
|
||||
PING 100.64.0.1 (100.64.0.1) 56(84) bytes of data.
|
||||
64 bytes from 100.64.0.1: icmp_seq=1 ttl=64 time=0.032 ms
|
||||
64 bytes from 100.64.0.1: icmp_seq=2 ttl=64 time=0.044 ms
|
||||
64 bytes from 100.64.0.1: icmp_seq=3 ttl=64 time=0.050 ms
|
||||
|
||||
--- 100.64.0.1 ping statistics ---
|
||||
3 packets transmitted, 3 received, 0% packet loss, time 2080ms
|
||||
rtt min/avg/max/mdev = 0.032/0.042/0.050/0.007 ms
|
||||
```
|
||||
|
||||
### 2. IPv6 핑 테스트
|
||||
```bash
|
||||
# IPv6 핑 테스트
|
||||
ping6 -c 3 fd7a:115c:a1e0::1
|
||||
```
|
||||
|
||||
### 3. DNS 확인 (Magic DNS)
|
||||
```bash
|
||||
# Magic DNS 테스트 (설정된 경우)
|
||||
nslookup 0bin-ubuntu-vm.headscale.local
|
||||
```
|
||||
|
||||
## 📋 추가 클라이언트 연결 방법
|
||||
|
||||
### 다른 장치에서 연결하기
|
||||
|
||||
#### Windows
|
||||
```cmd
|
||||
# PowerShell 또는 Command Prompt에서
|
||||
tailscale up --login-server=http://YOUR_SERVER_IP:8070 --authkey=fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21
|
||||
```
|
||||
|
||||
#### macOS
|
||||
```bash
|
||||
# Terminal에서
|
||||
sudo tailscale up --login-server=http://YOUR_SERVER_IP:8070 --authkey=fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21
|
||||
```
|
||||
|
||||
#### 다른 Linux 장치
|
||||
```bash
|
||||
# 동일한 명령어 사용
|
||||
tailscale up --login-server=http://YOUR_SERVER_IP:8070 --authkey=fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21
|
||||
```
|
||||
|
||||
### 새로운 Pre-auth 키 생성 (필요시)
|
||||
```bash
|
||||
# 새로운 24시간 유효 키 생성
|
||||
docker-compose exec headscale headscale preauthkeys create --user 1 --reusable --expiration 24h
|
||||
```
|
||||
|
||||
## 🔍 모니터링 및 관리
|
||||
|
||||
### 실시간 연결 상태 모니터링
|
||||
```bash
|
||||
# 실시간 로그 확인
|
||||
docker-compose logs -f headscale
|
||||
|
||||
# Tailscale 상태 지속 확인
|
||||
watch -n 5 'tailscale status'
|
||||
```
|
||||
|
||||
### 네트워크 트래픽 모니터링
|
||||
```bash
|
||||
# tailscale0 인터페이스 트래픽 확인
|
||||
iftop -i tailscale0
|
||||
|
||||
# 또는 간단한 통계
|
||||
ip -s link show tailscale0
|
||||
```
|
||||
|
||||
## 🚨 문제 해결
|
||||
|
||||
### 연결 실패 시 체크리스트
|
||||
|
||||
#### 1. Headscale 서버 상태 확인
|
||||
```bash
|
||||
curl -f http://localhost:8070/health || echo "Headscale not responding"
|
||||
```
|
||||
|
||||
#### 2. 방화벽 설정 확인
|
||||
```bash
|
||||
# 8070 포트 오픈 확인
|
||||
sudo ufw status | grep 8070
|
||||
|
||||
# 필요시 포트 개방
|
||||
sudo ufw allow 8070
|
||||
```
|
||||
|
||||
#### 3. Pre-auth 키 유효성 확인
|
||||
```bash
|
||||
# 키 목록 확인
|
||||
docker-compose exec headscale headscale preauthkeys list
|
||||
```
|
||||
|
||||
#### 4. Tailscale 서비스 재시작
|
||||
```bash
|
||||
sudo systemctl restart tailscaled
|
||||
```
|
||||
|
||||
### 연결 해제 및 재연결
|
||||
```bash
|
||||
# 연결 해제
|
||||
tailscale down
|
||||
|
||||
# 재연결
|
||||
tailscale up --login-server=http://localhost:8070 --authkey=fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21
|
||||
```
|
||||
|
||||
## 📊 성능 테스트
|
||||
|
||||
### 대역폭 테스트 (2개 이상 클라이언트 연결 시)
|
||||
```bash
|
||||
# iperf3 설치
|
||||
sudo apt install iperf3
|
||||
|
||||
# 서버 모드 (첫 번째 클라이언트)
|
||||
iperf3 -s
|
||||
|
||||
# 클라이언트 모드 (두 번째 클라이언트)
|
||||
iperf3 -c 100.64.0.1
|
||||
```
|
||||
|
||||
### 지연시간 테스트
|
||||
```bash
|
||||
# 지속적인 핑 테스트
|
||||
ping -i 0.1 100.64.0.1
|
||||
```
|
||||
|
||||
## 🎯 테스트 결과 요약
|
||||
|
||||
### ✅ 성공적으로 확인된 기능
|
||||
1. **클라이언트 설치**: Tailscale 1.86.2 설치 완료
|
||||
2. **서버 연결**: Pre-auth 키를 통한 자동 인증 성공
|
||||
3. **IP 할당**: IPv4(100.64.0.1), IPv6(fd7a:115c:a1e0::1) 정상 할당
|
||||
4. **네트워크 통신**: 핑 테스트 성공 (0% 패킷 손실)
|
||||
5. **인터페이스 생성**: tailscale0 인터페이스 정상 생성
|
||||
6. **서버 인식**: Headscale에서 노드 정상 인식
|
||||
|
||||
### 📈 네트워크 성능
|
||||
- **핑 지연시간**: 평균 0.042ms (로컬)
|
||||
- **패킷 손실**: 0%
|
||||
- **MTU**: 1280 bytes
|
||||
- **상태**: UNKNOWN (정상 동작)
|
||||
|
||||
### 🔒 보안 확인사항
|
||||
- **암호화**: WireGuard 프로토콜 사용
|
||||
- **인증**: Pre-auth 키 기반 자동 인증
|
||||
- **키 관리**: 24시간 만료, 재사용 가능 설정
|
||||
|
||||
## 🚀 결론
|
||||
|
||||
Headscale 서버와 Tailscale 클라이언트 간의 연결이 완벽하게 성공했습니다.
|
||||
|
||||
**주요 성과:**
|
||||
- ✅ VPN 터널 구성 완료
|
||||
- ✅ IP 주소 자동 할당 성공
|
||||
- ✅ 실시간 통신 확인
|
||||
- ✅ Headscale 관리 인터페이스 정상 동작
|
||||
- ✅ Headplane 웹 UI 외부 접속 성공
|
||||
|
||||
### 🌐 완전한 관리 환경 구축
|
||||
- **Headscale API**: http://localhost:8070 (명령줄 관리)
|
||||
- **Headplane UI**: http://192.168.0.151:3000/admin/ (웹 관리)
|
||||
- **로그인 API Key**: `8qRr1IB.tV95CmA0fLaCiGGIgBfeoN9daHceFkzI`
|
||||
|
||||
이제 **Tailscale을 완전히 대체**할 수 있는 자체 호스팅 VPN 솔루션이 구축되었습니다!
|
||||
193
CLIENT_SETUP_GUIDE.md
Normal file
193
CLIENT_SETUP_GUIDE.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# 팜큐(FARMQ) Headscale 클라이언트 설치 가이드
|
||||
|
||||
## 🏥 개요
|
||||
|
||||
팜큐 네트워크에 PC를 연결하기 위한 간편한 설치 가이드입니다.
|
||||
|
||||
## 📋 Pre-auth Key 정보
|
||||
|
||||
### ✅ Pre-auth Key 특징:
|
||||
- **1회 등록**: 한 번 사용하면 해당 머신이 영구적으로 네트워크에 등록됩니다
|
||||
- **자동 재연결**: 재부팅 후에도 자동으로 연결됩니다
|
||||
- **재사용 가능**: 동일한 key로 여러 머신을 등록할 수 있습니다 (설정에 따라)
|
||||
|
||||
### 🔑 현재 유효한 Key:
|
||||
- **myuser**: `fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21` (재사용 가능)
|
||||
- **pharmacy1**: `5c15b41ea8b135dbed42455ad1a9a0cf0352b100defd241c` (7일 유효, 재사용 가능)
|
||||
|
||||
## 🚀 자동 설치 (권장)
|
||||
|
||||
### Linux/Ubuntu 시스템
|
||||
|
||||
1. **스크립트 다운로드**:
|
||||
```bash
|
||||
wget https://head.0bin.in/register-client.sh
|
||||
chmod +x register-client.sh
|
||||
```
|
||||
|
||||
2. **스크립트 실행**:
|
||||
```bash
|
||||
sudo ./register-client.sh
|
||||
```
|
||||
|
||||
### 수동으로 Pre-auth Key 업데이트
|
||||
|
||||
스크립트의 Pre-auth Key를 업데이트하려면:
|
||||
|
||||
```bash
|
||||
# 스크립트 편집
|
||||
nano register-client.sh
|
||||
|
||||
# PREAUTH_KEY 값을 새로운 key로 변경
|
||||
PREAUTH_KEY="새로운키값"
|
||||
```
|
||||
|
||||
## 🔧 수동 설치
|
||||
|
||||
### 1. Tailscale 설치
|
||||
|
||||
#### Ubuntu/Debian:
|
||||
```bash
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
```
|
||||
|
||||
#### CentOS/RHEL:
|
||||
```bash
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
```
|
||||
|
||||
#### macOS:
|
||||
```bash
|
||||
# Homebrew 사용
|
||||
brew install --cask tailscale
|
||||
|
||||
# 또는 직접 다운로드
|
||||
# https://tailscale.com/download/mac
|
||||
```
|
||||
|
||||
#### Windows:
|
||||
1. https://tailscale.com/download/windows 에서 다운로드
|
||||
2. 설치 후 아래 명령어를 관리자 권한 PowerShell에서 실행
|
||||
|
||||
### 2. Headscale 서버에 등록
|
||||
|
||||
#### Linux/macOS:
|
||||
```bash
|
||||
sudo tailscale up \
|
||||
--login-server="https://head.0bin.in" \
|
||||
--authkey="fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21" \
|
||||
--accept-routes \
|
||||
--accept-dns=false
|
||||
```
|
||||
|
||||
#### Windows (PowerShell 관리자 권한):
|
||||
```powershell
|
||||
tailscale up `
|
||||
--login-server="https://head.0bin.in" `
|
||||
--authkey="fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21" `
|
||||
--accept-routes `
|
||||
--accept-dns=false
|
||||
```
|
||||
|
||||
## 📊 연결 확인
|
||||
|
||||
### 연결 상태 확인:
|
||||
```bash
|
||||
tailscale status
|
||||
```
|
||||
|
||||
### IP 주소 확인:
|
||||
```bash
|
||||
tailscale ip -4
|
||||
```
|
||||
|
||||
### 네트워크 테스트:
|
||||
```bash
|
||||
# 다른 팜큐 머신으로 핑 테스트
|
||||
ping 100.64.0.1
|
||||
```
|
||||
|
||||
## 🔑 관리자용 - Pre-auth Key 생성
|
||||
|
||||
### 새로운 약국용 Key 생성:
|
||||
|
||||
1. **스크립트 사용** (권장):
|
||||
```bash
|
||||
./create-preauth-key.sh pharmacy2 30d
|
||||
```
|
||||
|
||||
2. **수동 생성**:
|
||||
```bash
|
||||
# 사용자 생성 (필요시)
|
||||
docker exec headscale headscale users create pharmacy2
|
||||
|
||||
# Pre-auth key 생성
|
||||
docker exec headscale headscale preauthkeys create \
|
||||
-u 2 --expiration 30d --reusable
|
||||
```
|
||||
|
||||
### Key 관리 명령어:
|
||||
|
||||
```bash
|
||||
# 사용자 목록 확인
|
||||
docker exec headscale headscale users list
|
||||
|
||||
# Pre-auth key 목록 확인 (사용자 ID 필요)
|
||||
docker exec headscale headscale preauthkeys list -u 1
|
||||
|
||||
# 만료된 key 삭제
|
||||
docker exec headscale headscale preauthkeys expire -k <key_id>
|
||||
```
|
||||
|
||||
## 🛠️ 문제해결
|
||||
|
||||
### 연결 안됨:
|
||||
1. **방화벽 확인**: 8080, 443 포트가 열려있는지 확인
|
||||
2. **DNS 확인**: `https://head.0bin.in` 접근 가능한지 확인
|
||||
3. **Key 유효성**: Pre-auth key가 만료되지 않았는지 확인
|
||||
|
||||
### 기존 연결 해제:
|
||||
```bash
|
||||
sudo tailscale logout
|
||||
```
|
||||
|
||||
### 완전 재설정:
|
||||
```bash
|
||||
sudo tailscale logout
|
||||
sudo tailscale up --login-server="https://head.0bin.in" --authkey="새로운키"
|
||||
```
|
||||
|
||||
## 📱 모바일 설정
|
||||
|
||||
### Android/iOS:
|
||||
1. Tailscale 앱 설치
|
||||
2. "Use a different server" 선택
|
||||
3. 서버 URL: `https://head.0bin.in`
|
||||
4. Pre-auth key 입력 (위 key 중 하나 사용)
|
||||
|
||||
## 🔐 보안 참고사항
|
||||
|
||||
- Pre-auth key는 민감한 정보입니다. 공유 시 주의하세요
|
||||
- Key가 만료되기 전에 새로운 key를 생성하세요
|
||||
- 불필요한 key는 정기적으로 만료시키세요
|
||||
- 각 약국별로 별도의 사용자와 key를 사용하는 것을 권장합니다
|
||||
|
||||
## 📞 지원
|
||||
|
||||
문제가 발생하면 다음 정보를 포함하여 문의하세요:
|
||||
- 운영체제 정보
|
||||
- `tailscale status` 출력
|
||||
- 에러 메시지
|
||||
- 사용한 Pre-auth key (마지막 8자리만)
|
||||
|
||||
---
|
||||
|
||||
## 📋 요약
|
||||
|
||||
1. **간편 설치**: `register-client.sh` 스크립트 실행
|
||||
2. **수동 설치**: Tailscale 설치 → `tailscale up` 명령어 실행
|
||||
3. **연결 확인**: `tailscale status` 및 `tailscale ip` 확인
|
||||
4. **문제 시**: 재부팅 또는 logout 후 재연결
|
||||
|
||||
**서버**: https://head.0bin.in
|
||||
**기본 Key**: `fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21`
|
||||
349
FARMQ_HEADPLANE_CUSTOMIZATION_PLAN.md
Normal file
349
FARMQ_HEADPLANE_CUSTOMIZATION_PLAN.md
Normal file
@@ -0,0 +1,349 @@
|
||||
# 🏥 팜큐(FARMQ) Headplane 커스터마이징 기획서
|
||||
|
||||
## 📋 프로젝트 개요
|
||||
|
||||
### 회사 현황
|
||||
- **회사명**: 팜큐(FARMQ)
|
||||
- **사업 규모**: 약국 100개소
|
||||
- **인프라**: 각 약국마다 Proxmox 호스트 PC 납품
|
||||
- **관리 대상**: 약국별 VM 환경 원격 관리
|
||||
- **네트워크**: WireGuard 기반 Headscale + Headplane 전환 완료
|
||||
|
||||
### 현재 환경
|
||||
- ✅ **Headscale 서버**: http://192.168.0.151:8070 (정상 운영)
|
||||
- ✅ **Headplane UI**: http://192.168.0.151:3000/admin/ (정상 운영)
|
||||
- ✅ **VPN 네트워크**: 100.64.0.0/10 대역 할당
|
||||
- ✅ **기본 기능**: Machine/User 관리 가능
|
||||
|
||||
### 개선 목표
|
||||
현재 기본 Headplane UI를 **팜큐 전용 약국 관리 시스템**으로 확장
|
||||
|
||||
## 🎯 요구사항 분석
|
||||
|
||||
### 1. 데이터 확장 요구사항
|
||||
|
||||
#### 현재 User 테이블 구조
|
||||
```
|
||||
User | Role | Created At | Last Seen
|
||||
myuser | Unmanaged | 2025.9.9 | Connected
|
||||
```
|
||||
|
||||
#### 확장 요구사항
|
||||
```
|
||||
User | 약국명 | 사업자번호 | Role | Created At | Last Seen
|
||||
myuser | 서울약국 | 123-45-67890 | Unmanaged | 2025.9.9 | Connected
|
||||
```
|
||||
|
||||
#### Machine 정보 확장 요구사항
|
||||
- **현재**: Machine 이름만 관리
|
||||
- **확장 후**:
|
||||
- 약국명 연동
|
||||
- PC 사양 정보 (CPU, RAM, Storage)
|
||||
- Proxmox 호스트 정보
|
||||
- 실시간 하드웨어 모니터링
|
||||
|
||||
### 2. 모니터링 요구사항
|
||||
- **Proxmox API 연동**: CPU 온도, 사용률, VM 상태
|
||||
- **실시간 대시보드**: 약국별 시스템 현황
|
||||
- **알림 시스템**: 장애 발생 시 실시간 알림
|
||||
|
||||
## 🏗️ 아키텍처 설계
|
||||
|
||||
### 시스템 구조
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Headplane UI │───►│ Custom Backend │───►│ Proxmox Hosts │
|
||||
│ (Frontend) │ │ (API Server) │ │ (100개 약국) │
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────┐ │
|
||||
└─────────────►│ Headscale DB │◄────────────┘
|
||||
│ (Extended) │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
### 데이터베이스 확장 설계
|
||||
|
||||
#### 새로운 테이블: `pharmacy_info`
|
||||
```sql
|
||||
CREATE TABLE pharmacy_info (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id VARCHAR(255) UNIQUE REFERENCES users(name),
|
||||
pharmacy_name VARCHAR(255) NOT NULL,
|
||||
business_number VARCHAR(20),
|
||||
address TEXT,
|
||||
phone VARCHAR(20),
|
||||
manager_name VARCHAR(100),
|
||||
proxmox_host VARCHAR(255),
|
||||
proxmox_api_token TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
#### 새로운 테이블: `machine_specs`
|
||||
```sql
|
||||
CREATE TABLE machine_specs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
machine_id BIGINT REFERENCES machines(id),
|
||||
pharmacy_id INTEGER REFERENCES pharmacy_info(id),
|
||||
cpu_model VARCHAR(255),
|
||||
cpu_cores INTEGER,
|
||||
ram_gb INTEGER,
|
||||
storage_gb INTEGER,
|
||||
gpu_model VARCHAR(255),
|
||||
last_updated TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
#### 새로운 테이블: `monitoring_data`
|
||||
```sql
|
||||
CREATE TABLE monitoring_data (
|
||||
id SERIAL PRIMARY KEY,
|
||||
machine_id BIGINT REFERENCES machines(id),
|
||||
cpu_usage DECIMAL(5,2),
|
||||
memory_usage DECIMAL(5,2),
|
||||
disk_usage DECIMAL(5,2),
|
||||
cpu_temperature INTEGER,
|
||||
network_rx_bytes BIGINT,
|
||||
network_tx_bytes BIGINT,
|
||||
vm_count INTEGER,
|
||||
vm_running INTEGER,
|
||||
collected_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
## 🛠️ 구현 방안
|
||||
|
||||
### 방안 1: Headplane 포크 + 직접 수정 (권장)
|
||||
**장점**: 완전한 커스터마이징 가능
|
||||
**단점**: 업스트림 업데이트 반영 어려움
|
||||
**개발 기간**: 2-3주
|
||||
|
||||
#### 구현 단계:
|
||||
1. **포크 및 개발환경 구성**
|
||||
2. **데이터베이스 스키마 확장**
|
||||
3. **API 엔드포인트 추가**
|
||||
4. **UI 컴포넌트 커스터마이징**
|
||||
5. **Proxmox API 연동 모듈 개발**
|
||||
6. **모니터링 대시보드 구현**
|
||||
|
||||
### 방안 2: 별도 관리 시스템 + Headplane 연동
|
||||
**장점**: 기존 Headplane 유지, 독립적 개발
|
||||
**단점**: 시스템 분리로 복잡도 증가
|
||||
**개발 기간**: 3-4주
|
||||
|
||||
### 방안 3: Headplane 플러그인 시스템 구축
|
||||
**장점**: 모듈러 구조, 확장성 좋음
|
||||
**단점**: 플러그인 시스템 자체 개발 필요
|
||||
**개발 기간**: 4-5주
|
||||
|
||||
## 📋 세부 기능 명세
|
||||
|
||||
### 1. 약국 정보 관리
|
||||
#### 1-1. 약국 등록/수정 화면
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 약국 정보 등록 │
|
||||
├─────────────────────────────────────┤
|
||||
│ 약국명: [서울중앙약국 ] │
|
||||
│ 사업자번호: [123-45-67890 ] │
|
||||
│ 주소: [서울시 강남구... ] │
|
||||
│ 전화번호: [02-1234-5678 ] │
|
||||
│ 담당자: [홍길동 ] │
|
||||
│ Proxmox 호스트: [192.168.1.100 ] │
|
||||
│ API 토큰: [********************] │
|
||||
│ │
|
||||
│ [저장] [취소] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 1-2. 사용자 테이블 확장
|
||||
```
|
||||
User | 약국명 | 사업자번호 | 전화번호 | Role | Last Seen
|
||||
pharmacy1| 서울중앙약국 | 123-45-67890 | 02-1234-5678 | Active | 5분 전
|
||||
pharmacy2| 부산해운약국 | 987-65-43210 | 051-9876-5432| Active | 1시간 전
|
||||
```
|
||||
|
||||
### 2. 머신 정보 확장
|
||||
#### 2-1. 머신 상세 정보 화면
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 머신 상세 정보: pharmacy1-main │
|
||||
├─────────────────────────────────────┤
|
||||
│ 약국명: 서울중앙약국 │
|
||||
│ IP 주소: 100.64.0.15 │
|
||||
│ 마지막 접속: 2분 전 │
|
||||
│ │
|
||||
│ 하드웨어 정보: │
|
||||
│ CPU: Intel i7-12700 (12코어) │
|
||||
│ RAM: 32GB │
|
||||
│ Storage: 1TB NVMe SSD │
|
||||
│ │
|
||||
│ 실시간 모니터링: │
|
||||
│ CPU 사용률: ████████░░ 80% │
|
||||
│ CPU 온도: 65°C │
|
||||
│ 메모리 사용률: ██████░░░░ 60% │
|
||||
│ 디스크 사용률: ███░░░░░░░ 30% │
|
||||
│ │
|
||||
│ VM 상태: │
|
||||
│ 총 VM: 5개 | 실행중: 4개 | 정지: 1개│
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3. 통합 대시보드
|
||||
#### 3-1. 메인 대시보드 레이아웃
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ 팜큐 약국 관리 시스템 [사용자: admin] │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ 📊 전체 현황 │
|
||||
│ ┌──────────┬──────────┬──────────┬──────────────────────┐ │
|
||||
│ │총 약국 수 │온라인 │오프라인 │평균 CPU 온도 │ │
|
||||
│ │ 100 │ 95 │ 5 │ 62°C │ │
|
||||
│ └──────────┴──────────┴──────────┴──────────────────────┘ │
|
||||
│ │
|
||||
│ 🚨 알림 │
|
||||
│ • 부산해운약국: CPU 온도 85°C (경고) │
|
||||
│ • 대구중앙약국: 디스크 사용률 95% (위험) │
|
||||
│ │
|
||||
│ 📈 약국별 상태 │
|
||||
│ ┌─────────────┬────────┬────────┬────────┬──────────────┐ │
|
||||
│ │약국명 │상태 │CPU온도 │메모리 │마지막 접속 │ │
|
||||
│ ├─────────────┼────────┼────────┼────────┼──────────────┤ │
|
||||
│ │서울중앙약국 │🟢 온라인│ 65°C │ 80% │ 2분 전 │ │
|
||||
│ │부산해운약국 │🟡 경고 │ 85°C │ 60% │ 5분 전 │ │
|
||||
│ │대구중앙약국 │🔴 위험 │ 70°C │ 95% │ 10분 전 │ │
|
||||
│ └─────────────┴────────┴────────┴────────┴──────────────┘ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4. Proxmox API 연동
|
||||
#### 4-1. 데이터 수집 프로세스
|
||||
```python
|
||||
# proxmox_monitor.py
|
||||
class ProxmoxMonitor:
|
||||
def collect_host_info(self, proxmox_host, api_token):
|
||||
"""Proxmox 호스트 정보 수집"""
|
||||
# CPU 온도, 사용률
|
||||
# 메모리 사용률
|
||||
# 디스크 사용률
|
||||
# VM 상태 및 개수
|
||||
# 네트워크 트래픽
|
||||
|
||||
def collect_vm_info(self, proxmox_host, vm_id):
|
||||
"""개별 VM 정보 수집"""
|
||||
# VM 상태 (running, stopped)
|
||||
# 리소스 할당량
|
||||
# 실제 리소스 사용량
|
||||
```
|
||||
|
||||
## 🔧 기술 스택
|
||||
|
||||
### Frontend (Headplane 기반)
|
||||
- **React 19.1.0** + TypeScript
|
||||
- **Tailwind CSS** (스타일링)
|
||||
- **React Router** (라우팅)
|
||||
- **Chart.js** (모니터링 차트)
|
||||
- **Socket.io Client** (실시간 업데이트)
|
||||
|
||||
### Backend (확장 API 서버)
|
||||
- **Node.js** + Express.js 또는 **Python FastAPI**
|
||||
- **PostgreSQL** (확장된 데이터베이스)
|
||||
- **Socket.io** (실시간 통신)
|
||||
- **Proxmox API Client**
|
||||
- **Cron Jobs** (주기적 데이터 수집)
|
||||
|
||||
### 모니터링 스택
|
||||
- **Proxmox API** (호스트 정보)
|
||||
- **Glances API** (시스템 메트릭)
|
||||
- **InfluxDB** (시계열 데이터 저장)
|
||||
- **Redis** (캐싱 및 실시간 데이터)
|
||||
|
||||
## 📅 개발 로드맵
|
||||
|
||||
### Phase 1: 기반 구축 (1주)
|
||||
- [ ] Headplane 포크 및 개발환경 구성
|
||||
- [ ] 데이터베이스 스키마 설계 및 확장
|
||||
- [ ] 기본 약국 정보 CRUD API 구현
|
||||
- [ ] UI 기본 레이아웃 수정
|
||||
|
||||
### Phase 2: 핵심 기능 (2주)
|
||||
- [ ] 사용자 테이블에 약국 정보 연동
|
||||
- [ ] 머신 정보 확장 및 UI 개선
|
||||
- [ ] Proxmox API 연동 모듈 개발
|
||||
- [ ] 기본 모니터링 대시보드 구현
|
||||
|
||||
### Phase 3: 고도화 (1주)
|
||||
- [ ] 실시간 모니터링 구현
|
||||
- [ ] 알림 시스템 구축
|
||||
- [ ] 통합 대시보드 완성
|
||||
- [ ] 성능 최적화 및 테스트
|
||||
|
||||
### Phase 4: 배포 및 운영 (1주)
|
||||
- [ ] 프로덕션 환경 배포
|
||||
- [ ] 100개 약국 데이터 마이그레이션
|
||||
- [ ] 운영 매뉴얼 작성
|
||||
- [ ] 사용자 교육 및 피드백 수집
|
||||
|
||||
## 💰 예상 비용 및 리소스
|
||||
|
||||
### 개발 리소스
|
||||
- **개발 기간**: 4-5주
|
||||
- **개발자**: 풀스택 개발자 1명
|
||||
- **디자이너**: UI/UX 디자이너 0.5명
|
||||
|
||||
### 인프라 비용
|
||||
- **확장 서버**: 추가 서버 인스턴스 (모니터링 데이터 처리)
|
||||
- **데이터베이스**: PostgreSQL + InfluxDB
|
||||
- **스토리지**: 시계열 데이터 저장용
|
||||
|
||||
## 🚀 시작하기
|
||||
|
||||
### 1단계: 요구사항 확정
|
||||
- [ ] 약국 정보 필드 최종 확정
|
||||
- [ ] 모니터링 지표 우선순위 결정
|
||||
- [ ] UI/UX 디자인 컨셉 결정
|
||||
|
||||
### 2단계: 개발환경 구성
|
||||
- [ ] Headplane 포크 및 클론
|
||||
- [ ] 로컬 개발환경 세팅
|
||||
- [ ] 테스트용 Proxmox 호스트 준비
|
||||
|
||||
### 3단계: 프로토타입 개발
|
||||
- [ ] 약국 정보 등록 화면 구현
|
||||
- [ ] 기본 모니터링 기능 구현
|
||||
- [ ] 초기 버전 데모
|
||||
|
||||
## 📊 성공 지표
|
||||
|
||||
### 기능적 지표
|
||||
- [ ] 100개 약국 정보 완전 등록
|
||||
- [ ] 실시간 모니터링 정확도 95% 이상
|
||||
- [ ] 알림 반응 시간 1분 이내
|
||||
- [ ] 시스템 가용성 99.5% 이상
|
||||
|
||||
### 사용성 지표
|
||||
- [ ] 관리자 업무 효율 50% 향상
|
||||
- [ ] 장애 발견 시간 80% 단축
|
||||
- [ ] 사용자 만족도 4.5/5.0 이상
|
||||
|
||||
## 📝 체크리스트
|
||||
|
||||
### 즉시 검토 필요사항
|
||||
- [ ] 현재 Headscale DB 구조 분석
|
||||
- [ ] Proxmox API 접근 권한 확인
|
||||
- [ ] 약국별 네트워크 접근성 테스트
|
||||
- [ ] 추가 하드웨어 리소스 요구사항 검토
|
||||
|
||||
### 장기적 고려사항
|
||||
- [ ] 확장성: 약국 수 증가 대비
|
||||
- [ ] 보안: 약국 데이터 보호
|
||||
- [ ] 백업: 중요 데이터 백업 전략
|
||||
- [ ] 업데이트: 원활한 시스템 업데이트 방안
|
||||
|
||||
---
|
||||
**📅 작성일**: 2025-09-09
|
||||
**👤 작성자**: Claude Code Assistant
|
||||
**🏢 대상**: 팜큐(FARMQ) - 약국 IT 인프라 관리 시스템
|
||||
320
HEADPLANE_KOREAN_LOCALIZATION_PLAN.md
Normal file
320
HEADPLANE_KOREAN_LOCALIZATION_PLAN.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# 📋 Headplane UI 한글화 계획서
|
||||
|
||||
## 🔍 현재 상황 분석
|
||||
|
||||
### Headplane 국제화 현황
|
||||
- ❌ i18n 라이브러리 미사용 (react-i18next, next-intl 등 없음)
|
||||
- ❌ 모든 UI 텍스트가 컴포넌트에 하드코딩
|
||||
- ❌ 언어 설정 옵션 없음
|
||||
- ❌ GitHub에 다국어 지원 요청이나 논의 없음
|
||||
|
||||
### 기술 스택
|
||||
- ✅ React 19.1.0 + TypeScript
|
||||
- ✅ Vite 빌드 시스템
|
||||
- ✅ Docker 멀티스테이지 빌드
|
||||
- ✅ pnpm 패키지 매니저
|
||||
|
||||
### 현재 접속 정보
|
||||
- **로컬**: http://localhost:3000/admin/
|
||||
- **외부**: http://192.168.0.151:3000/admin/
|
||||
- **API Key**: `8qRr1IB.tV95CmA0fLaCiGGIgBfeoN9daHceFkzI`
|
||||
|
||||
## 🎯 한글화 목표
|
||||
|
||||
### 우선순위 1: 핵심 UI 요소
|
||||
- [ ] 상단 헤더 "Headplane" → "헤드플레인"
|
||||
- [ ] 네비게이션 메뉴 (Machines → 장치 관리, Users → 사용자 관리, Settings → 설정 등)
|
||||
- [ ] 로그인 페이지 텍스트 (API Key → API 키, Sign In → 로그인)
|
||||
- [ ] 메인 대시보드 라벨들
|
||||
|
||||
### 우선순위 2: 상세 페이지
|
||||
- [ ] 테이블 헤더 (Name → 이름, IP Address → IP 주소, Status → 상태 등)
|
||||
- [ ] 버튼 텍스트 (Create → 생성, Delete → 삭제, Edit → 편집 등)
|
||||
- [ ] 폼 라벨 및 플레이스홀더
|
||||
|
||||
### 우선순위 3: 메시지 및 알림
|
||||
- [ ] 에러 메시지
|
||||
- [ ] 성공/확인 메시지
|
||||
- [ ] 도움말 텍스트
|
||||
|
||||
## 🛠️ 구현 방안별 비교
|
||||
|
||||
### 방안 1: 환경 변수 기반 (권장 - 단기)
|
||||
**난이도**: ⭐⭐
|
||||
**소요 시간**: 2-3시간
|
||||
**장점**:
|
||||
- 빠른 구현 가능
|
||||
- 기존 코드 최소 변경
|
||||
- Docker 환경변수로 쉽게 제어
|
||||
- 현재 환경에서 즉시 적용 가능
|
||||
|
||||
**단점**:
|
||||
- 제한적인 유연성
|
||||
- 텍스트별 개별 환경변수 필요
|
||||
|
||||
**구현 방법**:
|
||||
```typescript
|
||||
// app/config/texts.ts 생성
|
||||
export const UI_TEXTS = {
|
||||
app_title: process.env.HEADPLANE_TITLE || 'Headplane',
|
||||
nav: {
|
||||
machines: process.env.HEADPLANE_NAV_MACHINES || 'Machines',
|
||||
users: process.env.HEADPLANE_NAV_USERS || 'Users',
|
||||
settings: process.env.HEADPLANE_NAV_SETTINGS || 'Settings'
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 방안 2: react-i18next 도입 (권장 - 장기)
|
||||
**난이도**: ⭐⭐⭐⭐
|
||||
**소요 시간**: 1-2일
|
||||
**장점**:
|
||||
- 완전한 국제화 지원
|
||||
- 언어 전환 기능
|
||||
- 표준적인 접근 방식
|
||||
- 향후 확장성 좋음
|
||||
|
||||
**단점**:
|
||||
- 모든 컴포넌트 수정 필요
|
||||
- 라이브러리 의존성 추가
|
||||
- 상당한 코드 변경 필요
|
||||
|
||||
**구현 구조**:
|
||||
```
|
||||
public/locales/
|
||||
├── en/
|
||||
│ └── translation.json
|
||||
└── ko/
|
||||
└── translation.json
|
||||
```
|
||||
|
||||
### 방안 3: 브라우저 확장프로그램 (즉시 적용)
|
||||
**난이도**: ⭐
|
||||
**소요 시간**: 30분
|
||||
**장점**:
|
||||
- 즉시 적용 가능
|
||||
- 코드 수정 불필요
|
||||
- 테스트 목적으로 최적
|
||||
|
||||
**단점**:
|
||||
- 개인 환경에서만 동작
|
||||
- 동적 콘텐츠 한계
|
||||
- 일시적 해결책
|
||||
|
||||
### 방안 4: 포크 후 하드코딩 수정
|
||||
**난이도**: ⭐⭐⭐
|
||||
**소요 시간**: 4-6시간
|
||||
**장점**:
|
||||
- 완전한 제어
|
||||
- 즉시 적용 가능
|
||||
|
||||
**단점**:
|
||||
- 업스트림 업데이트 어려움
|
||||
- 유지보수 부담
|
||||
|
||||
## 📅 단계별 실행 계획
|
||||
|
||||
### Phase 1: 즉시 적용 (오늘)
|
||||
#### 1-1. 브라우저 확장프로그램 제작
|
||||
- [ ] Tampermonkey 스크립트 작성
|
||||
- [ ] 핵심 UI 요소 번역 매핑
|
||||
- [ ] 테스트 및 검증
|
||||
|
||||
#### 1-2. 브라우저 확장 스크립트 예시
|
||||
```javascript
|
||||
// ==UserScript==
|
||||
// @name Headplane 한글화
|
||||
// @match http://192.168.0.151:3000/*
|
||||
// @match http://localhost:3000/*
|
||||
// ==/UserScript==
|
||||
|
||||
const translations = {
|
||||
'Headplane': '헤드플레인',
|
||||
'Machines': '장치 관리',
|
||||
'Users': '사용자 관리',
|
||||
'Settings': '설정',
|
||||
'API Key': 'API 키',
|
||||
'Sign In': '로그인',
|
||||
'Welcome to Headplane': '헤드플레인에 오신 것을 환영합니다',
|
||||
'Enter an API key to authenticate': 'API 키를 입력하여 인증해주세요'
|
||||
};
|
||||
|
||||
function translatePage() {
|
||||
document.querySelectorAll('*').forEach(element => {
|
||||
if (element.children.length === 0 && element.textContent.trim()) {
|
||||
const text = element.textContent.trim();
|
||||
if (translations[text]) {
|
||||
element.textContent = translations[text];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 페이지 로드 시 번역 적용
|
||||
translatePage();
|
||||
|
||||
// 동적 콘텐츠 변경 감지 및 번역 적용
|
||||
new MutationObserver(translatePage).observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
```
|
||||
|
||||
### Phase 2: 임시 해결책 (1-2일 내)
|
||||
#### 2-1. 환경 변수 기반 커스터마이징
|
||||
- [ ] 텍스트 상수 파일 생성 (`app/config/texts.ts`)
|
||||
- [ ] 주요 컴포넌트에 텍스트 상수 적용
|
||||
- [ ] Docker Compose 환경변수 설정
|
||||
- [ ] 커스텀 Docker 이미지 빌드
|
||||
- [ ] 컨테이너 재배포 및 테스트
|
||||
|
||||
#### 2-2. Docker 환경변수 설정 예시
|
||||
```yaml
|
||||
# docker-compose.yml에 추가
|
||||
services:
|
||||
headplane:
|
||||
environment:
|
||||
- TZ=Asia/Seoul
|
||||
- HEADSCALE_URL=http://headscale:8080
|
||||
- HEADSCALE_API_KEY=${HEADSCALE_API_KEY}
|
||||
# 한글화 환경변수
|
||||
- HEADPLANE_TITLE=헤드플레인
|
||||
- HEADPLANE_NAV_MACHINES=장치 관리
|
||||
- HEADPLANE_NAV_USERS=사용자 관리
|
||||
- HEADPLANE_NAV_SETTINGS=설정
|
||||
- HEADPLANE_LOGIN_TITLE=로그인
|
||||
- HEADPLANE_API_KEY_LABEL=API 키
|
||||
```
|
||||
|
||||
### Phase 3: 완전한 해결책 (1주일 내)
|
||||
#### 3-1. react-i18next 라이브러리 도입
|
||||
- [ ] GitHub에서 tale/headplane 포크
|
||||
- [ ] 로컬 개발 환경 구성
|
||||
- [ ] react-i18next 라이브러리 설치
|
||||
- [ ] i18n 설정 및 번역 파일 구조 생성
|
||||
- [ ] 주요 컴포넌트 국제화 적용
|
||||
- [ ] 언어 전환 UI 추가
|
||||
- [ ] 커스텀 Docker 이미지 빌드 및 배포
|
||||
|
||||
#### 3-2. 번역 파일 구조 예시
|
||||
```json
|
||||
// public/locales/ko/translation.json
|
||||
{
|
||||
"app": {
|
||||
"title": "헤드플레인",
|
||||
"description": "헤드스케일 관리 웹 인터페이스"
|
||||
},
|
||||
"navigation": {
|
||||
"machines": "장치 관리",
|
||||
"users": "사용자 관리",
|
||||
"settings": "설정",
|
||||
"accessControl": "접근 제어",
|
||||
"dns": "DNS"
|
||||
},
|
||||
"login": {
|
||||
"title": "헤드플레인에 오신 것을 환영합니다",
|
||||
"description": "API 키를 입력하여 헤드플레인에 인증해주세요.",
|
||||
"apiKeyLabel": "API 키",
|
||||
"apiKeyPlaceholder": "API 키를 입력해주세요",
|
||||
"signInButton": "로그인",
|
||||
"helpText": "터미널에서 'headscale apikeys create' 명령을 실행하여 API 키를 생성할 수 있습니다."
|
||||
},
|
||||
"machines": {
|
||||
"title": "장치 관리",
|
||||
"description": "테일넷에 연결된 장치들을 관리합니다.",
|
||||
"tableHeaders": {
|
||||
"name": "이름",
|
||||
"addresses": "주소",
|
||||
"version": "버전",
|
||||
"lastSeen": "마지막 접속"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"title": "사용자 관리",
|
||||
"description": "헤드스케일 사용자들을 관리합니다."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 권장 실행 방안
|
||||
|
||||
### 즉시 실행: 방안 3 (브라우저 확장프로그램)
|
||||
현재 사용 중인 환경에서 바로 한글 UI를 확인할 수 있도록 **Tampermonkey 스크립트**부터 시작
|
||||
|
||||
### 단기 목표: 방안 1 (환경 변수 기반)
|
||||
Docker 환경변수를 통한 텍스트 커스터마이징으로 **안정적인 한글화** 구현
|
||||
|
||||
### 장기 목표: 방안 2 (react-i18next 도입)
|
||||
완전한 국제화 지원을 위한 **표준적인 다국어 지원** 구현
|
||||
|
||||
## 📊 예상 번역 범위
|
||||
|
||||
### 핵심 번역 대상 (총 약 50-80개 텍스트)
|
||||
|
||||
#### 공통 UI 요소
|
||||
- Headplane → 헤드플레인
|
||||
- API Key → API 키
|
||||
- Sign In → 로그인
|
||||
- Settings → 설정
|
||||
- Profile → 프로필
|
||||
- Logout → 로그아웃
|
||||
|
||||
#### 네비게이션 메뉴
|
||||
- Machines → 장치 관리
|
||||
- Users → 사용자 관리
|
||||
- Access Control → 접근 제어
|
||||
- DNS → DNS 설정
|
||||
|
||||
#### 테이블 및 폼 요소
|
||||
- Name → 이름
|
||||
- IP Address → IP 주소
|
||||
- Status → 상태
|
||||
- Version → 버전
|
||||
- Last Seen → 마지막 접속
|
||||
- Create → 생성
|
||||
- Delete → 삭제
|
||||
- Edit → 편집
|
||||
|
||||
## 🚀 시작하기
|
||||
|
||||
### 1단계: 브라우저 확장프로그램 설치
|
||||
1. Chrome/Edge에서 Tampermonkey 확장프로그램 설치
|
||||
2. 위의 스크립트 코드를 새 스크립트로 생성
|
||||
3. http://192.168.0.151:3000/admin/ 접속하여 한글화 확인
|
||||
|
||||
### 2단계: 환경변수 기반 구현 준비
|
||||
1. tale/headplane 저장소 포크
|
||||
2. 로컬 개발환경 구성
|
||||
3. 텍스트 상수 파일 생성 및 적용
|
||||
|
||||
## 📝 진행 상황 체크리스트
|
||||
|
||||
### Phase 1: 브라우저 확장프로그램
|
||||
- [ ] Tampermonkey 설치 및 스크립트 작성
|
||||
- [ ] 로그인 페이지 한글화 테스트
|
||||
- [ ] 메인 대시보드 한글화 테스트
|
||||
- [ ] 번역 품질 검증
|
||||
|
||||
### Phase 2: 환경변수 기반
|
||||
- [ ] 프로젝트 포크 및 클론
|
||||
- [ ] 개발 환경 구성
|
||||
- [ ] 텍스트 상수 파일 구조 설계
|
||||
- [ ] 주요 컴포넌트 수정
|
||||
- [ ] Docker 이미지 빌드 테스트
|
||||
- [ ] 프로덕션 배포
|
||||
|
||||
### Phase 3: react-i18next 도입
|
||||
- [ ] i18n 라이브러리 설치 및 설정
|
||||
- [ ] 번역 파일 구조 생성
|
||||
- [ ] 컴포넌트별 국제화 적용
|
||||
- [ ] 언어 전환 UI 구현
|
||||
- [ ] 전체 테스트 및 검증
|
||||
|
||||
## 🔗 관련 링크
|
||||
|
||||
- **Headplane GitHub**: https://github.com/tale/headplane
|
||||
- **React i18next 문서**: https://react.i18next.com/
|
||||
- **현재 Headplane UI**: http://192.168.0.151:3000/admin/
|
||||
|
||||
## 📅 생성일: 2025-09-09
|
||||
## 👤 작성자: Claude Code Assistant
|
||||
370
INSTALLATION_GUIDE.md
Normal file
370
INSTALLATION_GUIDE.md
Normal file
@@ -0,0 +1,370 @@
|
||||
# 🚀 Headscale + Headplane 완전 설치 가이드
|
||||
|
||||
## 📋 프로젝트 개요
|
||||
- **목표**: Tailscale을 완전히 대체하는 자체 호스팅 VPN 솔루션 구축
|
||||
- **기술 스택**: Docker, Docker Compose, Headscale, Headplane
|
||||
- **환경**: Ubuntu 24.04 LTS, Docker 27.2.0
|
||||
|
||||
## 🛠️ 사전 요구사항
|
||||
- Docker 및 Docker Compose 설치
|
||||
- 8070, 3000 포트 사용 가능
|
||||
- root 권한 또는 sudo 권한
|
||||
|
||||
## 📁 프로젝트 구조
|
||||
```
|
||||
headscale-setup/
|
||||
├── docker-compose.yml # Docker Compose 설정
|
||||
├── .env # 환경변수 (API 키 포함)
|
||||
├── .env.example # 환경변수 템플릿
|
||||
├── config/
|
||||
│ └── config.yaml # Headscale 최신 설정
|
||||
├── headplane-config/
|
||||
│ └── config.yaml # Headplane 설정
|
||||
├── data/ # SQLite 데이터베이스 (자동 생성)
|
||||
├── run/ # 런타임 파일 (자동 생성)
|
||||
└── start.sh # 자동 설치 스크립트
|
||||
```
|
||||
|
||||
## 🔧 상세 설치 과정
|
||||
|
||||
### 1단계: 환경 준비
|
||||
```bash
|
||||
# 작업 디렉토리 생성
|
||||
mkdir -p headscale-setup
|
||||
cd headscale-setup
|
||||
|
||||
# 필요한 하위 디렉토리 생성
|
||||
mkdir -p config data run headplane-config
|
||||
```
|
||||
|
||||
### 2단계: Docker Compose 설정
|
||||
|
||||
#### docker-compose.yml 작성
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
headscale:
|
||||
image: headscale/headscale:latest
|
||||
container_name: headscale
|
||||
restart: unless-stopped
|
||||
command: serve
|
||||
environment:
|
||||
- TZ=Asia/Seoul
|
||||
volumes:
|
||||
- ./config:/etc/headscale
|
||||
- ./data:/var/lib/headscale
|
||||
- ./run:/var/run/headscale
|
||||
ports:
|
||||
- "8070:8080" # 외부:내부 (포트 충돌 방지)
|
||||
- "9090:9090" # 메트릭스
|
||||
networks:
|
||||
- headscale-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "nc -z localhost 8080 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
headplane:
|
||||
image: ghcr.io/tale/headplane:latest
|
||||
container_name: headplane
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TZ=Asia/Seoul
|
||||
- HEADSCALE_URL=http://headscale:8080
|
||||
- HEADSCALE_API_KEY=${HEADSCALE_API_KEY}
|
||||
volumes:
|
||||
- ./headplane-config:/etc/headplane
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- headscale
|
||||
networks:
|
||||
- headscale-net
|
||||
|
||||
networks:
|
||||
headscale-net:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
### 3단계: Headscale 설정 파일
|
||||
|
||||
#### config/config.yaml (최신 형식)
|
||||
```yaml
|
||||
---
|
||||
server_url: http://localhost:8070
|
||||
listen_addr: 0.0.0.0:8080
|
||||
metrics_listen_addr: 0.0.0.0:9090
|
||||
|
||||
private_key_path: /var/lib/headscale/private.key
|
||||
noise:
|
||||
private_key_path: /var/lib/headscale/noise_private.key
|
||||
|
||||
# 최신 형식: prefixes 사용
|
||||
prefixes:
|
||||
v4: 100.64.0.0/10
|
||||
v6: fd7a:115c:a1e0::/48
|
||||
|
||||
derp:
|
||||
server:
|
||||
enabled: false
|
||||
urls:
|
||||
- https://controlplane.tailscale.com/derpmap/default
|
||||
|
||||
disable_check_updates: false
|
||||
ephemeral_node_inactivity_timeout: 30m
|
||||
|
||||
database:
|
||||
type: sqlite3
|
||||
sqlite:
|
||||
path: /var/lib/headscale/db.sqlite
|
||||
|
||||
# 최신 DNS 설정 형식
|
||||
dns:
|
||||
override_local_dns: true
|
||||
nameservers:
|
||||
global:
|
||||
- 1.1.1.1
|
||||
- 8.8.8.8
|
||||
search_domains: []
|
||||
magic_dns: true
|
||||
base_domain: headscale.local
|
||||
|
||||
# 최신 정책 설정
|
||||
policy:
|
||||
path: ""
|
||||
|
||||
log:
|
||||
format: text
|
||||
level: info
|
||||
|
||||
unix_socket: /var/run/headscale/headscale.sock
|
||||
unix_socket_permission: "0770"
|
||||
|
||||
logtail:
|
||||
enabled: false
|
||||
|
||||
randomize_client_port: false
|
||||
|
||||
# 간소화된 OIDC 설정
|
||||
oidc:
|
||||
only_start_if_oidc_is_available: false
|
||||
issuer: ""
|
||||
client_id: ""
|
||||
client_secret: ""
|
||||
scope: ["openid", "profile", "email"]
|
||||
extra_params: {}
|
||||
allowed_domains: []
|
||||
allowed_users: []
|
||||
```
|
||||
|
||||
### 4단계: Headplane 설정 파일
|
||||
|
||||
#### headplane-config/config.yaml
|
||||
```yaml
|
||||
headscale:
|
||||
url: http://headscale:8080
|
||||
api_key: YOUR_API_KEY_HERE # 자동 생성됨
|
||||
config_strict: false
|
||||
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 3000
|
||||
cookie_secret: headscale-ui-secret-32-chars-key # 정확히 32자
|
||||
cookie_secure: false
|
||||
|
||||
settings:
|
||||
title: "Headscale 관리 패널"
|
||||
favicon_url: ""
|
||||
custom_css: ""
|
||||
```
|
||||
|
||||
**중요 설정사항:**
|
||||
- `cookie_secret`: 정확히 32자여야 함 (설정 검증 오류 방지)
|
||||
- `config_strict: false`: 설정 검증 완화
|
||||
- `api_key`: 설치 시 자동 생성되어 교체됨
|
||||
- 설정 파일은 환경변수보다 우선순위가 높음
|
||||
|
||||
### 5단계: 환경변수 설정
|
||||
|
||||
#### .env.example
|
||||
```bash
|
||||
# Headscale API Key (설치 후 자동 생성됨)
|
||||
HEADSCALE_API_KEY=your_api_key_here
|
||||
|
||||
# 서버 설정
|
||||
SERVER_URL=http://localhost:8070
|
||||
LISTEN_ADDR=0.0.0.0:8080
|
||||
|
||||
# 데이터베이스 (SQLite 기본)
|
||||
DB_TYPE=sqlite3
|
||||
DB_PATH=/var/lib/headscale/db.sqlite
|
||||
|
||||
# Magic DNS
|
||||
MAGIC_DNS=true
|
||||
BASE_DOMAIN=headscale.local
|
||||
|
||||
# 네트워크 설정
|
||||
IP_PREFIXES=100.64.0.0/10
|
||||
|
||||
# 시간대
|
||||
TZ=Asia/Seoul
|
||||
```
|
||||
|
||||
### 5단계: 설치 실행
|
||||
|
||||
#### 환경변수 파일 복사
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
#### 자동 설치 스크립트 실행
|
||||
```bash
|
||||
chmod +x start.sh
|
||||
./start.sh
|
||||
```
|
||||
|
||||
#### 또는 수동 설치
|
||||
```bash
|
||||
# 1. Headscale 시작
|
||||
docker-compose up -d headscale
|
||||
|
||||
# 2. API 키 생성 (약 30초 대기 후)
|
||||
sleep 30
|
||||
API_KEY=$(docker-compose exec -T headscale headscale apikeys create)
|
||||
echo "Generated API Key: $API_KEY"
|
||||
|
||||
# 3. .env 파일에 API 키 입력
|
||||
sed -i "s/HEADSCALE_API_KEY=your_api_key_here/HEADSCALE_API_KEY=$API_KEY/" .env
|
||||
|
||||
# 4. Headplane 시작
|
||||
docker-compose up -d headplane
|
||||
```
|
||||
|
||||
## 🎯 중요한 설정 변경사항
|
||||
|
||||
### 포트 충돌 해결
|
||||
- **기존**: 8080:8080 (충돌 발생)
|
||||
- **변경**: 8070:8080 (외부 8070 포트 사용)
|
||||
|
||||
### 최신 Headscale 설정 형식 적용
|
||||
- `ip_prefixes` → `prefixes` (v4/v6 분리)
|
||||
- `dns_config` → `dns` (구조 변경)
|
||||
- `acl_policy_path` → `policy.path`
|
||||
- OIDC `strip_email_domain` 제거
|
||||
|
||||
### Docker 헬스체크 개선
|
||||
- `curl` → `nc` (netcat 사용)
|
||||
- Headplane 의존성 조건 완화
|
||||
|
||||
## 🔍 설치 확인 및 검증
|
||||
|
||||
### 1. 컨테이너 상태 확인
|
||||
```bash
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
### 2. Headscale API 테스트
|
||||
```bash
|
||||
curl -s http://localhost:8070/health
|
||||
# 응답: {"status":"pass"}
|
||||
```
|
||||
|
||||
### 3. 로그 확인
|
||||
```bash
|
||||
docker-compose logs headscale
|
||||
docker-compose logs headplane
|
||||
```
|
||||
|
||||
### 4. 사용자 생성
|
||||
```bash
|
||||
docker-compose exec headscale headscale users create myuser
|
||||
```
|
||||
|
||||
### 5. 사용자 목록 확인
|
||||
```bash
|
||||
docker-compose exec headscale headscale users list
|
||||
```
|
||||
|
||||
### 6. Pre-auth 키 생성
|
||||
```bash
|
||||
docker-compose exec headscale headscale preauthkeys create --user 1 --reusable --expiration 24h
|
||||
```
|
||||
|
||||
## 🚨 문제 해결
|
||||
|
||||
### 포트 충돌 문제
|
||||
```bash
|
||||
# 8080 포트 사용 중인 프로세스 확인
|
||||
lsof -i :8080
|
||||
|
||||
# 포트를 8070으로 변경하여 해결
|
||||
```
|
||||
|
||||
### Headplane 설정 파일 문제
|
||||
```bash
|
||||
# cookie_secret 길이 오류 시 (정확히 32자 필요)
|
||||
echo "headscale-ui-secret-32-chars-key" | wc -c # 32자 확인
|
||||
|
||||
# 설정 파일 재검증
|
||||
docker-compose logs headplane --tail 10
|
||||
|
||||
# 컨테이너 재시작으로 설정 재로드
|
||||
docker-compose restart headplane
|
||||
```
|
||||
|
||||
### 헬스체크 실패
|
||||
```bash
|
||||
# wget 대신 netcat 사용
|
||||
# CMD-SHELL을 사용하여 호환성 개선
|
||||
```
|
||||
|
||||
## 📊 최종 설치 결과
|
||||
|
||||
### 접속 정보
|
||||
- **Headscale API**: http://localhost:8070
|
||||
- **Headplane UI**: http://localhost:3000/admin/ (로그인 페이지)
|
||||
- **외부 접속**: http://192.168.0.151:3000/admin/ (네트워크 설정에 따라)
|
||||
- **메트릭스**: http://localhost:9090
|
||||
|
||||
### 생성된 정보
|
||||
- **사용자**: myuser (ID: 1)
|
||||
- **API 키**: 8qRr1IB.tV95CmA0fLaCiGGIgBfeoN9daHceFkzI (자동 생성됨)
|
||||
- **Pre-auth 키**: fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21 (24시간 유효, 재사용 가능)
|
||||
|
||||
### 🔑 Headplane 로그인
|
||||
1. 브라우저에서 http://localhost:3000/admin/ 또는 http://192.168.0.151:3000/admin/ 접속
|
||||
2. **API Key** 필드에 입력: `8qRr1IB.tV95CmA0fLaCiGGIgBfeoN9daHceFkzI`
|
||||
3. **Sign In** 버튼 클릭
|
||||
|
||||
### 네트워크 설정
|
||||
- **IPv4**: 100.64.0.0/10
|
||||
- **IPv6**: fd7a:115c:a1e0::/48
|
||||
- **Magic DNS**: headscale.local
|
||||
|
||||
## 🔄 Git 관리
|
||||
|
||||
### 브랜치 전략
|
||||
```bash
|
||||
# 기능 브랜치 생성
|
||||
git checkout -b feature/working-headscale-setup
|
||||
|
||||
# 변경사항 커밋
|
||||
git add .
|
||||
git commit -m "🎉 Working Headscale Setup Complete"
|
||||
|
||||
# 원격 저장소 푸시
|
||||
git push -u origin feature/working-headscale-setup
|
||||
```
|
||||
|
||||
## 📈 다음 단계
|
||||
1. Tailscale 클라이언트 연결 테스트
|
||||
2. HTTPS/TLS 인증서 구성
|
||||
3. Headplane 한글화 작업
|
||||
4. ACL 보안 규칙 설정
|
||||
5. 백업 및 모니터링 구성
|
||||
|
||||
## 🎉 결론
|
||||
Headscale과 Headplane을 사용한 완전한 자체 호스팅 VPN 솔루션이 성공적으로 구축되었습니다. 이제 Tailscale을 완전히 대체할 수 있는 환경이 준비되었습니다.
|
||||
458
PROXMOX_WEBSOCKET_INTEGRATION_PLAN.md
Normal file
458
PROXMOX_WEBSOCKET_INTEGRATION_PLAN.md
Normal file
@@ -0,0 +1,458 @@
|
||||
# Proxmox VNC WebSocket 직접 연결 해결 방안
|
||||
|
||||
## 🎯 목표
|
||||
|
||||
Flask 웹 애플리케이션에서 Proxmox VNC WebSocket에 **직접 연결**하여 브라우저 내에서 seamless한 VNC 경험 제공
|
||||
|
||||
## 🔍 현재 문제 상황
|
||||
|
||||
### 1. 문제 증상
|
||||
- Flask에서 VNC 버튼 클릭 시 `"Unsupported"... is not valid JSON` 오류 발생
|
||||
- noVNC 클라이언트가 Proxmox WebSocket 서버에 연결 실패
|
||||
- HTTP 501 응답 (nginx에서 WebSocket 요청 거부)
|
||||
|
||||
### 2. 근본 원인 분석
|
||||
```
|
||||
[브라우저] --> [Flask:5002] --> [Proxmox:443] --> [실제 WebSocket:5900]
|
||||
❌ CORS 이슈 ❌ nginx 차단 ✅ VM VNC 서버
|
||||
```
|
||||
|
||||
**주요 차단 요인:**
|
||||
- Proxmox nginx가 외부 WebSocket 연결 차단
|
||||
- CORS (Cross-Origin Resource Sharing) 정책
|
||||
- SSL/TLS 인증서 불일치
|
||||
- WebSocket Upgrade 헤더 처리 문제
|
||||
|
||||
## 💡 해결 방안 (3가지 접근법)
|
||||
|
||||
## 방안 1: Flask WebSocket 프록시 서버 (권장)
|
||||
|
||||
### 개념도
|
||||
```
|
||||
[브라우저] <--WebSocket--> [Flask Proxy] <--WebSocket--> [Proxmox VNC]
|
||||
ws://localhost:5002/ws/vnc/vm123 wss://pve7:443/api2/...
|
||||
```
|
||||
|
||||
### 구현 방법
|
||||
|
||||
#### 1.1 Flask-SocketIO 기반 프록시
|
||||
```python
|
||||
# requirements.txt에 추가
|
||||
flask-socketio==5.3.6
|
||||
python-socketio==5.8.0
|
||||
websockets==11.0.3
|
||||
|
||||
# app.py 수정
|
||||
from flask_socketio import SocketIO, emit
|
||||
import websockets
|
||||
import asyncio
|
||||
import ssl
|
||||
|
||||
socketio = SocketIO(app, cors_allowed_origins="*")
|
||||
|
||||
@socketio.on('vnc_connect')
|
||||
def handle_vnc_connect(data):
|
||||
"""VNC WebSocket 프록시 연결"""
|
||||
vm_id = data['vm_id']
|
||||
node = data['node']
|
||||
|
||||
# Proxmox VNC 티켓 생성
|
||||
client = ProxmoxClient(PROXMOX_HOST, PROXMOX_USERNAME, PROXMOX_PASSWORD)
|
||||
vnc_data = client.get_vnc_ticket(node, vm_id)
|
||||
|
||||
if vnc_data:
|
||||
# 백그라운드에서 프록시 실행
|
||||
socketio.start_background_task(proxy_vnc_connection,
|
||||
request.sid,
|
||||
vnc_data['websocket_url'])
|
||||
|
||||
def proxy_vnc_connection(session_id, proxmox_ws_url):
|
||||
"""Proxmox VNC WebSocket과 브라우저 간 프록시"""
|
||||
asyncio.run(proxy_websocket(session_id, proxmox_ws_url))
|
||||
|
||||
async def proxy_websocket(session_id, proxmox_ws_url):
|
||||
"""비동기 WebSocket 프록시"""
|
||||
try:
|
||||
# SSL 컨텍스트 설정 (인증서 검증 무시)
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
|
||||
# Proxmox WebSocket 연결
|
||||
async with websockets.connect(
|
||||
proxmox_ws_url,
|
||||
ssl=ssl_context,
|
||||
extra_headers={'Origin': f'https://{PROXMOX_HOST}'}
|
||||
) as proxmox_ws:
|
||||
|
||||
@socketio.on('vnc_data', namespace=f'/vnc/{session_id}')
|
||||
def forward_to_proxmox(data):
|
||||
asyncio.create_task(proxmox_ws.send(data))
|
||||
|
||||
# Proxmox → 브라우저
|
||||
async for message from proxmox_ws:
|
||||
socketio.emit('vnc_data', message,
|
||||
room=session_id,
|
||||
namespace=f'/vnc/{session_id}')
|
||||
|
||||
except Exception as e:
|
||||
socketio.emit('vnc_error', {'error': str(e)}, room=session_id)
|
||||
```
|
||||
|
||||
#### 1.2 noVNC 클라이언트 수정
|
||||
```html
|
||||
<!-- templates/vnc_console.html -->
|
||||
<script src="/static/js/socket.io.min.js"></script>
|
||||
<script>
|
||||
// Flask-SocketIO 연결
|
||||
const socket = io();
|
||||
|
||||
// VNC 연결 설정
|
||||
socket.emit('vnc_connect', {
|
||||
vm_id: {{ vmid }},
|
||||
node: '{{ node }}'
|
||||
});
|
||||
|
||||
// noVNC WebSocket URL을 Flask 프록시로 변경
|
||||
const websocketUrl = `ws://localhost:5002/socket.io/`;
|
||||
|
||||
// RFB 연결
|
||||
const rfb = new RFB(canvas, websocketUrl, {
|
||||
wsProtocols: ['base64']
|
||||
});
|
||||
|
||||
// 데이터 중계 설정
|
||||
socket.on('vnc_data', function(data) {
|
||||
// Proxmox에서 온 데이터를 noVNC로 전달
|
||||
rfb._sock.rQshiftBytes(data.length);
|
||||
});
|
||||
|
||||
rfb.addEventListener('connect', function() {
|
||||
// noVNC에서 보낸 데이터를 Proxmox로 전달
|
||||
rfb._sock.on('message', function(data) {
|
||||
socket.emit('vnc_data', data);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### 장점
|
||||
- ✅ 완전한 제어 가능
|
||||
- ✅ CORS 문제 해결
|
||||
- ✅ Flask 애플리케이션 내 통합
|
||||
- ✅ 추가 인증/로깅 가능
|
||||
|
||||
### 단점
|
||||
- ❌ 구현 복잡성 높음
|
||||
- ❌ 성능 오버헤드 존재
|
||||
- ❌ WebSocket 프로토콜 깊이 이해 필요
|
||||
|
||||
---
|
||||
|
||||
## 방안 2: Nginx 리버스 프록시 설정
|
||||
|
||||
### 개념도
|
||||
```
|
||||
[브라우저] --> [Nginx Proxy] --> [Proxmox WebSocket]
|
||||
ws://proxy/vnc/vm123 wss://pve7:443/api2/...
|
||||
```
|
||||
|
||||
### 구현 방법
|
||||
|
||||
#### 2.1 별도 Nginx 프록시 서버 설정
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/proxmox-vnc-proxy
|
||||
server {
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
|
||||
# WebSocket 프록시 설정
|
||||
location /vnc/ {
|
||||
# URL 경로에서 VM 정보 추출: /vnc/pve7/100
|
||||
# pve7 = node, 100 = vmid
|
||||
rewrite ^/vnc/([^/]+)/(\d+)$ /api2/json/nodes/$1/qemu/$2/vncwebsocket break;
|
||||
|
||||
proxy_pass https://pve7.0bin.in:443;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# WebSocket 업그레이드 헤더
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host pve7.0bin.in;
|
||||
proxy_set_header Origin https://pve7.0bin.in;
|
||||
|
||||
# 인증 헤더 전달 (VNC 티켓 포함)
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
|
||||
# SSL 설정
|
||||
proxy_ssl_verify off;
|
||||
proxy_ssl_server_name on;
|
||||
|
||||
# 타임아웃 설정 (VNC 세션용)
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 86400s; # 24시간
|
||||
|
||||
# 버퍼링 비활성화 (실시간 데이터용)
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
}
|
||||
|
||||
# CORS 헤더 추가
|
||||
location / {
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Origin, Content-Type, Accept, Authorization";
|
||||
|
||||
if ($request_method = 'OPTIONS') {
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2 Docker Compose로 Nginx 프록시 실행
|
||||
```yaml
|
||||
# docker-compose.vnc-proxy.yml
|
||||
version: '3.8'
|
||||
services:
|
||||
vnc-proxy:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./nginx-vnc-proxy.conf:/etc/nginx/conf.d/default.conf
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- farmq-network
|
||||
|
||||
networks:
|
||||
farmq-network:
|
||||
external: true
|
||||
```
|
||||
|
||||
#### 2.3 Flask 코드 수정
|
||||
```python
|
||||
# VNC WebSocket URL을 프록시 서버로 변경
|
||||
@app.route('/api/vm/vnc', methods=['POST'])
|
||||
def api_vm_vnc():
|
||||
# ... (기존 코드)
|
||||
|
||||
# 프록시 서버를 통한 WebSocket URL 생성
|
||||
proxy_ws_url = f"ws://localhost:8080/vnc/{node}/{vmid}?port={vnc_data['port']}&vncticket={vnc_data['ticket']}"
|
||||
|
||||
vnc_sessions[session_id] = {
|
||||
'websocket_url': proxy_ws_url,
|
||||
# ...
|
||||
}
|
||||
```
|
||||
|
||||
### 장점
|
||||
- ✅ Flask 코드 변경 최소화
|
||||
- ✅ 높은 성능 (Nginx 네이티브)
|
||||
- ✅ 확장성 우수
|
||||
- ✅ SSL 종료 지원
|
||||
|
||||
### 단점
|
||||
- ❌ 추가 인프라 필요
|
||||
- ❌ Nginx 설정 복잡성
|
||||
- ❌ 디버깅 어려움
|
||||
|
||||
---
|
||||
|
||||
## 방안 3: Proxmox 서버 설정 변경
|
||||
|
||||
### 개념도
|
||||
```
|
||||
[브라우저] ----직접연결----> [Proxmox WebSocket (수정됨)]
|
||||
wss://pve7:443/api2/json/...
|
||||
```
|
||||
|
||||
### 구현 방법
|
||||
|
||||
#### 3.1 Proxmox Nginx 설정 수정
|
||||
```bash
|
||||
# Proxmox 서버에 SSH 접속
|
||||
ssh root@pve7.0bin.in
|
||||
|
||||
# nginx 설정 백업
|
||||
cp /etc/nginx/sites-available/proxmox /etc/nginx/sites-available/proxmox.backup
|
||||
|
||||
# nginx 설정 편집
|
||||
nano /etc/nginx/sites-available/proxmox
|
||||
```
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/proxmox 수정
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name pve7.0bin.in;
|
||||
|
||||
# ... 기존 설정 유지 ...
|
||||
|
||||
# CORS 헤더 추가 (VNC WebSocket용)
|
||||
location /api2/json/nodes/*/qemu/*/vncwebsocket {
|
||||
# CORS 헤더
|
||||
add_header Access-Control-Allow-Origin "http://localhost:5002";
|
||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Origin, Content-Type, Accept, Authorization";
|
||||
add_header Access-Control-Allow-Credentials true;
|
||||
|
||||
# Preflight 요청 처리
|
||||
if ($request_method = 'OPTIONS') {
|
||||
return 204;
|
||||
}
|
||||
|
||||
# 기존 proxy_pass 설정 유지
|
||||
proxy_pass http://localhost:8006;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# WebSocket 업그레이드 헤더
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 타임아웃 설정
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
# 버퍼링 비활성화
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
}
|
||||
|
||||
# ... 나머지 설정 ...
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 Proxmox nginx 재시작
|
||||
```bash
|
||||
# 설정 검증
|
||||
nginx -t
|
||||
|
||||
# nginx 재시작
|
||||
systemctl restart nginx
|
||||
|
||||
# 방화벽 확인 (필요시)
|
||||
iptables -L -n | grep 8006
|
||||
```
|
||||
|
||||
#### 3.3 Flask noVNC 클라이언트 수정
|
||||
```javascript
|
||||
// 직접 Proxmox WebSocket 연결
|
||||
const websocketUrl = '{{ websocket_url }}'; // wss://pve7.0bin.in:443/api2/json/...
|
||||
|
||||
// CORS 설정으로 직접 연결 가능
|
||||
const rfb = new RFB(canvas, websocketUrl, {
|
||||
credentials: { password: '' }, // VNC 티켓으로 인증
|
||||
shared: true
|
||||
});
|
||||
```
|
||||
|
||||
### 장점
|
||||
- ✅ 가장 직접적인 해결책
|
||||
- ✅ 최고 성능
|
||||
- ✅ 추가 인프라 불필요
|
||||
|
||||
### 단점
|
||||
- ❌ Proxmox 서버 수정 필요
|
||||
- ❌ 업데이트 시 설정 초기화 위험
|
||||
- ❌ 보안 정책 변경
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 구현 우선순위 및 권장사항
|
||||
|
||||
### 1단계: Flask WebSocket 프록시 (권장)
|
||||
**기간**: 2-3일
|
||||
**난이도**: 중상
|
||||
|
||||
```bash
|
||||
# 구현 단계
|
||||
1. Flask-SocketIO 설치 및 설정
|
||||
2. VNC WebSocket 프록시 함수 구현
|
||||
3. noVNC 클라이언트 수정
|
||||
4. 테스트 및 디버깅
|
||||
```
|
||||
|
||||
### 2단계: Nginx 프록시 (백업 방안)
|
||||
**기간**: 1-2일
|
||||
**난이도**: 중
|
||||
|
||||
```bash
|
||||
# 구현 단계
|
||||
1. nginx 프록시 설정 작성
|
||||
2. Docker Compose로 프록시 실행
|
||||
3. Flask WebSocket URL 수정
|
||||
4. 연결 테스트
|
||||
```
|
||||
|
||||
### 3단계: Proxmox 설정 변경 (최후 수단)
|
||||
**기간**: 1일
|
||||
**난이도**: 하 (위험도: 상)
|
||||
|
||||
```bash
|
||||
# 주의사항
|
||||
- Proxmox 서버 직접 수정
|
||||
- 백업 필수
|
||||
- 업데이트 시 재설정 필요
|
||||
```
|
||||
|
||||
## 🔧 즉시 구현 가능한 임시 해결책
|
||||
|
||||
현재 리다이렉트 방식을 개선하여 사용성 향상:
|
||||
|
||||
```python
|
||||
# app.py - 자동 팝업 + 인증 정보 전달
|
||||
@app.route('/vnc/<session_id>')
|
||||
def vnc_console(session_id):
|
||||
# Proxmox 자동 로그인 URL 생성
|
||||
client = ProxmoxClient(PROXMOX_HOST, PROXMOX_USERNAME, PROXMOX_PASSWORD)
|
||||
login_ticket = client.ticket
|
||||
|
||||
proxmox_url = f"https://{PROXMOX_HOST}:443/?console=kvm&vmid={vmid}&node={node}&PVEAuthCookie={login_ticket}"
|
||||
|
||||
return render_template('vnc_auto_redirect.html',
|
||||
proxmox_url=proxmox_url,
|
||||
auto_open=True)
|
||||
```
|
||||
|
||||
## 📋 구현 체크리스트
|
||||
|
||||
### Flask WebSocket 프록시 구현
|
||||
- [ ] Flask-SocketIO 설치 및 기본 설정
|
||||
- [ ] Proxmox WebSocket 연결 테스트
|
||||
- [ ] 비동기 프록시 함수 구현
|
||||
- [ ] noVNC 클라이언트 SocketIO 연동
|
||||
- [ ] 에러 처리 및 재연결 로직
|
||||
- [ ] 성능 테스트 및 최적화
|
||||
|
||||
### 테스트 시나리오
|
||||
- [ ] 단일 VM VNC 연결 테스트
|
||||
- [ ] 동시 다중 VNC 연결 테스트
|
||||
- [ ] 네트워크 끊김 시 재연결 테스트
|
||||
- [ ] 브라우저별 호환성 테스트
|
||||
- [ ] 모바일 디바이스 테스트
|
||||
|
||||
### 운영 준비
|
||||
- [ ] 로깅 및 모니터링 설정
|
||||
- [ ] 에러 알림 시스템 구축
|
||||
- [ ] 백업 연결 방법 준비
|
||||
- [ ] 사용자 매뉴얼 작성
|
||||
|
||||
---
|
||||
|
||||
## 💡 결론
|
||||
|
||||
**권장 접근법**: Flask WebSocket 프록시 (방안 1)
|
||||
|
||||
1. **완전한 제어**: Flask 애플리케이션 내에서 모든 것을 제어
|
||||
2. **확장성**: 향후 추가 기능 (녹화, 공유 등) 쉽게 추가 가능
|
||||
3. **안정성**: Proxmox 서버 수정 없이 구현
|
||||
4. **통합성**: 기존 Flask 인증/권한 시스템과 완벽 통합
|
||||
|
||||
**다음 단계**: Flask-SocketIO 기반 WebSocket 프록시 구현 시작
|
||||
247
QUICK_INSTALL_GUIDE.md
Normal file
247
QUICK_INSTALL_GUIDE.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# 🚀 팜큐 Headscale 원클릭 설치 가이드
|
||||
|
||||
새로운 리눅스 서버를 팜큐 네트워크에 **한 번의 명령**으로 등록하는 방법입니다.
|
||||
|
||||
## 🎯 원클릭 설치 명령어
|
||||
|
||||
### 방법 1: curl 사용 (권장)
|
||||
```bash
|
||||
# 일반 사용자 계정에서
|
||||
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | sudo bash
|
||||
|
||||
# root 계정에서 (Proxmox, Docker 컨테이너 등)
|
||||
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | bash
|
||||
```
|
||||
|
||||
### 방법 2: wget 사용
|
||||
```bash
|
||||
# 일반 사용자 계정에서
|
||||
wget -qO- https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | sudo bash
|
||||
|
||||
# root 계정에서
|
||||
wget -qO- https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | bash
|
||||
```
|
||||
|
||||
### 방법 3: 스크립트 다운로드 후 실행
|
||||
```bash
|
||||
wget https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh
|
||||
chmod +x quick-install.sh
|
||||
sudo ./quick-install.sh
|
||||
```
|
||||
|
||||
### 방법 4: 기존 Tailscale 연결이 있는 경우 (강제 재등록)
|
||||
```bash
|
||||
# 일반 사용자 계정에서
|
||||
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | sudo bash -s -- --force
|
||||
|
||||
# root 계정에서 (Proxmox 등)
|
||||
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | bash -s -- --force
|
||||
```
|
||||
|
||||
### 방법 5: 스크립트 옵션 확인
|
||||
```bash
|
||||
# 사용 가능한 옵션 보기
|
||||
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | bash -s -- --help
|
||||
```
|
||||
|
||||
## ✨ 자동으로 수행되는 작업
|
||||
|
||||
### 1. 🔍 시스템 분석
|
||||
- 운영체제 자동 감지 (Ubuntu, Debian, CentOS, RHEL, Rocky, Fedora, Arch)
|
||||
- 시스템 요구사항 확인
|
||||
- 네트워크 연결 상태 점검
|
||||
|
||||
### 2. 📦 Tailscale 설치
|
||||
- **Ubuntu/Debian**: APT 리포지토리 추가 및 설치
|
||||
- **CentOS/RHEL/Rocky**: YUM/DNF 리포지토리 추가 및 설치
|
||||
- **Fedora**: DNF 패키지 관리자로 설치
|
||||
- **Arch Linux**: Pacman으로 설치
|
||||
- **기타 배포판**: Universal Binary 직접 다운로드
|
||||
|
||||
### 3. 🔧 서비스 설정
|
||||
- systemd 서비스 자동 등록
|
||||
- tailscaled 데몬 시작 및 활성화
|
||||
- 서비스 상태 확인 및 오류 처리
|
||||
|
||||
### 4. 🌐 Headscale 등록
|
||||
- Pre-auth Key를 사용한 자동 등록
|
||||
- 팜큐 Headscale 서버 (`https://head.0bin.in`)에 연결
|
||||
- DNS 및 라우팅 설정 자동 적용
|
||||
|
||||
### 5. 🔒 방화벽 설정
|
||||
- UFW (Ubuntu/Debian) 자동 설정
|
||||
- firewalld (CentOS/RHEL/Fedora) 자동 설정
|
||||
- Tailscale 포트 (41641/UDP) 자동 허용
|
||||
|
||||
### 6. ✅ 연결 검증
|
||||
- IP 주소 할당 확인
|
||||
- 네트워크 연결 테스트
|
||||
- 다른 노드와의 통신 확인
|
||||
|
||||
## 🖥️ 지원하는 운영체제
|
||||
|
||||
| OS | 버전 | 설치 방법 | 상태 |
|
||||
|---|---|---|---|
|
||||
| **Ubuntu** | 18.04+ | APT Repository | ✅ |
|
||||
| **Debian** | 10+ | APT Repository | ✅ |
|
||||
| **CentOS** | 7, 8, 9 | YUM/DNF Repository | ✅ |
|
||||
| **RHEL** | 7, 8, 9 | YUM/DNF Repository | ✅ |
|
||||
| **Rocky Linux** | 8, 9 | DNF Repository | ✅ |
|
||||
| **AlmaLinux** | 8, 9 | DNF Repository | ✅ |
|
||||
| **Fedora** | 35+ | DNF Package | ✅ |
|
||||
| **Arch Linux** | Rolling | Pacman Package | ✅ |
|
||||
| **기타 배포판** | - | Universal Binary | ⚠️ |
|
||||
|
||||
## 📋 설치 예시 출력
|
||||
|
||||
```bash
|
||||
$ curl -fsSL https://git.0bin.in/.../quick-install.sh | sudo bash
|
||||
|
||||
============================================
|
||||
팜큐(FARMQ) Headscale 원클릭 설치
|
||||
============================================
|
||||
|
||||
📋 감지된 OS: ubuntu 22.04 (jammy)
|
||||
|
||||
🔧 시스템 요구사항 확인 중...
|
||||
✅ 시스템 요구사항 확인 완료
|
||||
|
||||
🔧 Tailscale 클라이언트 설치 중...
|
||||
📋 Ubuntu/Debian용 Tailscale 설치 중...
|
||||
✅ Tailscale 설치 완료
|
||||
📋 설치된 버전: 1.52.1
|
||||
|
||||
🔧 Tailscale 서비스 시작 중...
|
||||
✅ Tailscaled 서비스가 실행 중입니다.
|
||||
|
||||
🔧 Headscale 서버에 등록 중...
|
||||
📋 Headscale 서버: https://head.0bin.in
|
||||
📋 Pre-auth Key: 8b3df41d***************
|
||||
🔧 등록 명령 실행 중...
|
||||
✅ Headscale 등록 성공!
|
||||
|
||||
🔧 방화벽 설정 확인 중...
|
||||
📋 UFW 방화벽 감지됨
|
||||
📋 Tailscale 트래픽 허용 중...
|
||||
✅ 방화벽 설정 완료
|
||||
|
||||
🔧 연결 상태 확인 중...
|
||||
✅ Headscale 네트워크 연결 완료!
|
||||
📋 할당된 IPv4: 100.64.0.5
|
||||
📋 할당된 IPv6: fd7a:115c:a1e0::5
|
||||
|
||||
🔧 네트워크 연결 테스트 중...
|
||||
✅ 팜큐 네트워크(100.64.0.0/10) 연결 정상!
|
||||
|
||||
============================================
|
||||
팜큐 Headscale 설치 완료!
|
||||
============================================
|
||||
|
||||
🎉 설치가 성공적으로 완료되었습니다!
|
||||
|
||||
📋 시스템 정보:
|
||||
호스트명: pharmacy-server-01
|
||||
Tailscale IP: 100.64.0.5
|
||||
OS: ubuntu 22.04
|
||||
Headscale 서버: https://head.0bin.in
|
||||
|
||||
🔧 유용한 명령어:
|
||||
tailscale status # 연결 상태 확인
|
||||
tailscale ip # 할당된 IP 확인
|
||||
tailscale ping <node> # 다른 노드와 연결 테스트
|
||||
tailscale logout # 네트워크에서 해제
|
||||
|
||||
🌐 팜큐 관리자 페이지:
|
||||
http://192.168.0.151:5002
|
||||
http://192.168.0.151:5002/vms (VM 관리)
|
||||
|
||||
============================================
|
||||
설치 완료 - 팜큐 네트워크를 사용할 수 있습니다!
|
||||
============================================
|
||||
```
|
||||
|
||||
## 🔧 설치 후 확인 명령어
|
||||
|
||||
### 연결 상태 확인
|
||||
```bash
|
||||
tailscale status
|
||||
```
|
||||
|
||||
### 할당된 IP 주소 확인
|
||||
```bash
|
||||
tailscale ip
|
||||
```
|
||||
|
||||
### 네트워크 테스트
|
||||
```bash
|
||||
# 다른 노드로 ping 테스트
|
||||
tailscale ping 100.64.0.1
|
||||
|
||||
# 또는 노드명으로 테스트
|
||||
tailscale ping desktop-emjd1dc
|
||||
```
|
||||
|
||||
### 서비스 상태 확인
|
||||
```bash
|
||||
systemctl status tailscaled
|
||||
journalctl -u tailscaled -f # 실시간 로그
|
||||
```
|
||||
|
||||
## 🚨 문제해결
|
||||
|
||||
### 1. 설치 중 권한 오류
|
||||
```bash
|
||||
# 해결방법: sudo 권한으로 실행
|
||||
sudo curl -fsSL https://git.0bin.in/.../quick-install.sh | sudo bash
|
||||
```
|
||||
|
||||
### 2. 네트워크 연결 실패
|
||||
```bash
|
||||
# 방화벽 상태 확인
|
||||
sudo ufw status
|
||||
sudo firewall-cmd --list-all
|
||||
|
||||
# 수동 포트 허용
|
||||
sudo ufw allow 41641/udp
|
||||
sudo firewall-cmd --add-port=41641/udp --permanent
|
||||
```
|
||||
|
||||
### 3. Pre-auth Key 만료
|
||||
```bash
|
||||
# 새로운 키 생성이 필요한 경우
|
||||
# Headscale 서버에서 실행:
|
||||
docker exec headscale headscale preauthkeys create --user 1 --expiration 7d --reusable
|
||||
```
|
||||
|
||||
### 4. 수동 등록 필요시
|
||||
```bash
|
||||
# 자동 등록 실패 시 수동 실행
|
||||
tailscale up --login-server="https://head.0bin.in" --authkey="YOUR_KEY_HERE"
|
||||
```
|
||||
|
||||
## 📊 현재 네트워크 정보
|
||||
|
||||
- **Headscale 서버**: https://head.0bin.in
|
||||
- **Flask 관리 페이지**: http://192.168.0.151:5002
|
||||
- **네트워크 대역**: 100.64.0.0/10
|
||||
- **Pre-auth Key 유효기간**: 7일 (재사용 가능)
|
||||
|
||||
## 🔄 기존 서버 업데이트
|
||||
|
||||
이미 등록된 서버에서 스크립트를 다시 실행하면:
|
||||
1. 기존 연결 감지
|
||||
2. 사용자 확인 후 재등록 옵션 제공
|
||||
3. 또는 기존 연결 유지
|
||||
|
||||
## 📞 지원
|
||||
|
||||
문제가 발생하면 다음 정보와 함께 연락주세요:
|
||||
|
||||
1. **OS 정보**: `cat /etc/os-release`
|
||||
2. **Tailscale 버전**: `tailscale version`
|
||||
3. **오류 로그**: `journalctl -u tailscaled --no-pager`
|
||||
4. **네트워크 상태**: `tailscale status`
|
||||
|
||||
---
|
||||
|
||||
**🎯 목표**: 새로운 서버를 30초 만에 팜큐 네트워크에 연결!
|
||||
62
README.md
62
README.md
@@ -52,7 +52,7 @@ docker-compose up -d headplane
|
||||
```
|
||||
|
||||
## 📋 접속 정보
|
||||
- **Headscale API**: http://localhost:8080
|
||||
- **Headscale API**: http://localhost:8070
|
||||
- **Headplane UI**: http://localhost:3000
|
||||
|
||||
## 👤 사용자 관리
|
||||
@@ -129,4 +129,62 @@ git status
|
||||
git add .
|
||||
git commit -m "Update: 설명"
|
||||
git push origin main
|
||||
```
|
||||
```
|
||||
|
||||
## ⚡ 새 서버 원클릭 등록
|
||||
|
||||
새로운 리눅스 서버를 팜큐 네트워크에 **한 번의 명령**으로 등록:
|
||||
|
||||
### 빠른 설치 (권장)
|
||||
```bash
|
||||
# 일반 사용자 계정
|
||||
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | sudo bash
|
||||
|
||||
# root 계정 (Proxmox 등)
|
||||
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | bash
|
||||
```
|
||||
|
||||
### 기존 Tailscale 연결이 있는 경우 (강제 재등록)
|
||||
```bash
|
||||
# 일반 사용자
|
||||
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | sudo bash -s -- --force
|
||||
|
||||
# root 계정
|
||||
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | bash -s -- --force
|
||||
```
|
||||
|
||||
### 지원 OS
|
||||
- Ubuntu, Debian, CentOS, RHEL, Rocky Linux, Fedora, Arch Linux
|
||||
- 자동 Tailscale 설치 + Headscale 등록
|
||||
- 방화벽 자동 설정 + 연결 검증
|
||||
|
||||
**30초 만에 팜큐 네트워크 연결 완료!** 🎉
|
||||
|
||||
## 🪟 Windows 원클릭 등록
|
||||
|
||||
Windows PC에서 **한 번의 복사 붙여넣기**로 팜큐 네트워크 연결:
|
||||
|
||||
### 기본 설치 (권장)
|
||||
```powershell
|
||||
# 관리자 PowerShell에서 복사 붙여넣기
|
||||
iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/farmq-install.ps1'))
|
||||
```
|
||||
|
||||
### 기존 Tailscale 있는 경우 (강제 재등록)
|
||||
```powershell
|
||||
# 기존 연결을 자동으로 해제하고 재등록
|
||||
$ForceInstall = $true; iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/farmq-install.ps1'))
|
||||
```
|
||||
|
||||
### 실행 방법
|
||||
1. **Windows 키 + X** → **"Windows PowerShell(관리자)"** 클릭
|
||||
2. 위 명령어 **복사 → 붙여넣기 → Enter**
|
||||
3. 자동 설치 진행 (2-3분)
|
||||
4. 팜큐 네트워크 연결 완료! 🎉
|
||||
|
||||
### Windows 자동 처리 기능
|
||||
- ✅ **Tailscale 자동 다운로드** 및 설치
|
||||
- ✅ **관리자 권한** 자동 확인
|
||||
- ✅ **기존 연결 스마트 처리** (Linux와 동일)
|
||||
- ✅ **Windows Defender 방화벽** 자동 설정
|
||||
- ✅ **네트워크 연결 테스트** 및 확인
|
||||
126
TROUBLESHOOTING_DATABASE_FOREIGN_KEY.md
Normal file
126
TROUBLESHOOTING_DATABASE_FOREIGN_KEY.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Headscale 데이터베이스 외래키 제약조건 문제 해결 기록
|
||||
|
||||
## 🔍 문제 상황
|
||||
|
||||
### 발생한 오류
|
||||
```
|
||||
backend error: handling register with auth key: registering node: failed register(save) node in the database: SQL logic error: foreign key mismatch - "pharmacy_info" referencing "users" (1)
|
||||
```
|
||||
|
||||
### 증상
|
||||
- Windows Tailscale 클라이언트 연결 시 Google SSO 로그인만 표시됨
|
||||
- `tailscale up --login-server` 명령어 실행 시 위 오류 발생
|
||||
- Headscale 컨테이너 로그에서 foreign key mismatch 오류 지속 발생
|
||||
|
||||
### 원인 분석
|
||||
1. **Flask Admin 앱 개발 과정에서 추가된 커스텀 테이블들이 Headscale 스키마와 충돌**
|
||||
- `pharmacy_info` 테이블이 `users` 테이블을 참조하는 외래키 제약조건 설정
|
||||
- `machine_specs`, `monitoring_data` 테이블도 유사한 외래키 제약조건 존재
|
||||
|
||||
2. **Headscale이 자체 사용자 관리 스키마를 가지고 있어 외부 테이블의 외래키 참조 거부**
|
||||
- Headscale은 내부적으로 `users`, `machines`, `nodes` 등의 테이블을 관리
|
||||
- 외부에서 추가된 테이블이 이들을 참조할 때 스키마 불일치 발생
|
||||
|
||||
## 🛠️ 해결 과정
|
||||
|
||||
### 1단계: 문제 테이블 식별
|
||||
```python
|
||||
# 문제가 되는 테이블들 확인
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%pharmacy%'")
|
||||
problem_tables = cursor.fetchall()
|
||||
# 결과: ['pharmacy_info', 'machine_specs', 'monitoring_data']
|
||||
```
|
||||
|
||||
### 2단계: 데이터베이스 정리 스크립트 실행
|
||||
```bash
|
||||
python3 clean-database.py
|
||||
```
|
||||
|
||||
#### 스크립트 수행 작업:
|
||||
1. **백업 생성**: `db.sqlite.clean_backup.20250909_170759`
|
||||
2. **외래키 제약조건 비활성화**: `PRAGMA foreign_keys = OFF`
|
||||
3. **문제 테이블 제거**:
|
||||
- `DROP TABLE IF EXISTS pharmacy_info`
|
||||
- `DROP TABLE IF EXISTS machine_specs`
|
||||
- `DROP TABLE IF EXISTS monitoring_data`
|
||||
4. **변경사항 커밋 및 무결성 검사**
|
||||
|
||||
### 3단계: Headscale 서비스 재시작
|
||||
```bash
|
||||
cd /srv/headscale-setup && docker-compose restart headscale
|
||||
```
|
||||
|
||||
## ✅ 해결 결과
|
||||
|
||||
### Before (문제 상황):
|
||||
```
|
||||
2025-09-09T17:07:02+09:00 FTL Migration failed: SQL logic error: foreign key mismatch - "pharmacy_info" referencing "users" (1)
|
||||
```
|
||||
|
||||
### After (해결 후):
|
||||
```
|
||||
2025-09-09T17:08:42+09:00 INF Using policy manager version: 2
|
||||
2025-09-09T17:08:42+09:00 INF Starting Headscale commit=474ea236d0c6d393dbcf7baa98da240ad20c1b66 version=0.26.1
|
||||
2025-09-09T17:08:46+09:00 INF node has connected, mapSession: 0xc000172600, chan: 0xc000286d90 node=DESKTOP-EMJD1DC node.id=2
|
||||
2025-09-09T17:08:48+09:00 INF node has connected, mapSession: 0xc00021b680, chan: 0xc000251180 node=0bin-Ubuntu-VM node.id=1
|
||||
```
|
||||
|
||||
### 성공 지표:
|
||||
- ✅ Headscale 정상 시작
|
||||
- ✅ Windows 클라이언트 (DESKTOP-EMJD1DC) 연결 성공
|
||||
- ✅ Ubuntu VM 클라이언트 (0bin-Ubuntu-VM) 연결 유지
|
||||
- ✅ Health check 통과: `{"status":"pass"}`
|
||||
|
||||
## 📚 교훈 및 베스트 프랙티스
|
||||
|
||||
### 1. Headscale과 커스텀 애플리케이션 분리 원칙
|
||||
```
|
||||
❌ 잘못된 접근: Headscale DB에 직접 외래키 제약조건으로 연결
|
||||
✅ 올바른 접근: 별도 데이터베이스 또는 느슨한 결합 방식 사용
|
||||
```
|
||||
|
||||
### 2. 외래키 제약조건 설계 시 고려사항
|
||||
- **Headscale 스키마 독립성 유지**
|
||||
- **별도 데이터베이스 사용** 또는 **ID 참조만 사용** (외래키 제약조건 없이)
|
||||
- **마이그레이션 전 스키마 호환성 검증**
|
||||
|
||||
### 3. 향후 개발 가이드라인
|
||||
```python
|
||||
# 권장하지 않음
|
||||
pharmacy_info = Table('pharmacy_info',
|
||||
Column('user_id', Integer, ForeignKey('users.id')) # ❌
|
||||
)
|
||||
|
||||
# 권장 방법
|
||||
pharmacy_info = Table('pharmacy_info',
|
||||
Column('headscale_user_id', Integer) # ✅ 단순 참조, 제약조건 없음
|
||||
)
|
||||
```
|
||||
|
||||
### 4. 데이터베이스 백업 중요성
|
||||
- 모든 스키마 변경 전 백업 생성 필수
|
||||
- 백업 파일명에 타임스탬프 포함으로 버전 관리
|
||||
- 롤백 절차 사전 준비
|
||||
|
||||
## 🔧 복구 방법 (필요시)
|
||||
|
||||
만약 문제가 재발하거나 백업에서 복원해야 할 경우:
|
||||
|
||||
```bash
|
||||
# 백업에서 복원
|
||||
cp /srv/headscale-setup/data/db.sqlite.clean_backup.20250909_170759 /srv/headscale-setup/data/db.sqlite
|
||||
|
||||
# 컨테이너 재시작
|
||||
cd /srv/headscale-setup && docker-compose restart headscale
|
||||
```
|
||||
|
||||
## 📝 관련 파일들
|
||||
- **해결 스크립트**: `/srv/headscale-setup/clean-database.py`
|
||||
- **백업 파일**: `/srv/headscale-setup/data/db.sqlite.clean_backup.20250909_170759`
|
||||
- **Docker Compose**: `/srv/headscale-setup/docker-compose.yml`
|
||||
- **로그 위치**: `docker logs headscale`
|
||||
|
||||
---
|
||||
*문제 해결일: 2025-09-09*
|
||||
*해결 소요시간: ~30분*
|
||||
*영향 범위: Windows/Ubuntu 클라이언트 연결 복구*
|
||||
455
WINDOWS_ONE_CLICK_INSTALLATION_PLAN.md
Normal file
455
WINDOWS_ONE_CLICK_INSTALLATION_PLAN.md
Normal file
@@ -0,0 +1,455 @@
|
||||
# 🪟 Windows용 팜큐 Headscale 원클릭 설치 패키지 기획서
|
||||
|
||||
## 🎯 목표
|
||||
|
||||
Windows 클라이언트에서 **단 한 번의 실행**으로 Tailscale 설치부터 팜큐 Headscale 네트워크 연결까지 완전 자동화
|
||||
|
||||
## 🔍 현재 Windows 상황 분석
|
||||
|
||||
### 기존 연결된 Windows 클라이언트들
|
||||
```
|
||||
100.79.125.82 upharm-1 thug0bin@ windows offline
|
||||
100.76.226.63 upharm thug0bin@ windows offline
|
||||
100.93.4.146 prox-win10-kiosk thug0bin@ windows offline
|
||||
100.109.121.8 desktop-06t3j0m thug0bin@ windows offline
|
||||
100.70.5.37 desktop-9a1aurp thug0bin@ windows offline
|
||||
100.126.213.6 desktop-m445evd thug0bin@ windows offline
|
||||
```
|
||||
|
||||
### Windows 특성
|
||||
- **관리자 권한** 필요 (UAC)
|
||||
- **PowerShell** 스크립트 실행 정책
|
||||
- **GUI 설치 마법사** 선호
|
||||
- **레지스트리** 설정 관리
|
||||
- **서비스** 자동 시작 설정
|
||||
|
||||
## 💡 Windows 설치 패키지 방안 (5가지)
|
||||
|
||||
## 방안 1: PowerShell 원클릭 스크립트 (권장)
|
||||
|
||||
### 개념도
|
||||
```
|
||||
[사용자] → [PowerShell 스크립트] → [Tailscale MSI 설치] → [Headscale 등록] → [완료]
|
||||
우클릭 "관리자로 실행" 자동 다운로드/설치 자동 서버 설정
|
||||
```
|
||||
|
||||
### 구현 방법
|
||||
|
||||
#### 1.1 PowerShell 스크립트 생성
|
||||
```powershell
|
||||
# farmq-headscale-installer.ps1
|
||||
param(
|
||||
[switch]$Force,
|
||||
[string]$HeadscaleServer = "https://head.0bin.in",
|
||||
[string]$PreAuthKey = "8b3df41d37cb158ea39f41fc32c9af46e761de817ad06038"
|
||||
)
|
||||
|
||||
# 관리자 권한 확인
|
||||
if (-NOT ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) {
|
||||
Write-Host "❌ 관리자 권한이 필요합니다." -ForegroundColor Red
|
||||
Write-Host "우클릭 → '관리자로 실행'을 사용해주세요." -ForegroundColor Yellow
|
||||
pause
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "🚀 팜큐 Headscale Windows 설치 시작..." -ForegroundColor Green
|
||||
```
|
||||
|
||||
#### 1.2 웹 실행 방법
|
||||
```powershell
|
||||
# 관리자 PowerShell에서
|
||||
iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/.../farmq-install.ps1'))
|
||||
|
||||
# 또는 강제 재등록
|
||||
iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/.../farmq-install.ps1?force=1'))
|
||||
```
|
||||
|
||||
### 장점
|
||||
- ✅ 단일 명령어로 실행 가능
|
||||
- ✅ 기존 Linux 스크립트와 유사한 UX
|
||||
- ✅ 웹에서 바로 실행 가능
|
||||
- ✅ 버전 관리 용이
|
||||
|
||||
### 단점
|
||||
- ❌ PowerShell 실행 정책 문제
|
||||
- ❌ 일반 사용자에게 복잡함
|
||||
- ❌ UAC 프롬프트 필요
|
||||
|
||||
---
|
||||
|
||||
## 방안 2: MSI 설치 패키지 (GUI 방식)
|
||||
|
||||
### 개념도
|
||||
```
|
||||
[사용자] → [farmq-headscale-installer.msi 실행] → [설치 마법사] → [완료]
|
||||
더블클릭 GUI 단계별 진행
|
||||
```
|
||||
|
||||
### 구현 방법
|
||||
|
||||
#### 2.1 WiX Toolset으로 MSI 제작
|
||||
```xml
|
||||
<!-- farmq-installer.wxs -->
|
||||
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
|
||||
<Product Id="*" Name="팜큐 Headscale 클라이언트" Language="1042"
|
||||
Version="1.0.0" Manufacturer="팜큐" UpgradeCode="...">
|
||||
|
||||
<Package InstallerVersion="200" Compressed="yes" InstallScope="perMachine"/>
|
||||
|
||||
<!-- Tailscale MSI 번들링 -->
|
||||
<Directory Id="TARGETDIR" Name="SourceDir">
|
||||
<Directory Id="ProgramFilesFolder">
|
||||
<Directory Id="INSTALLFOLDER" Name="FarmQ Headscale"/>
|
||||
</Directory>
|
||||
</Directory>
|
||||
|
||||
<!-- 사용자 정의 액션 -->
|
||||
<CustomAction Id="InstallTailscale"
|
||||
FileKey="TailscaleMSI"
|
||||
ExeCommand="msiexec /i tailscale-windows.msi /quiet"/>
|
||||
|
||||
<CustomAction Id="RegisterHeadscale"
|
||||
FileKey="RegisterScript"
|
||||
ExeCommand="powershell.exe -ExecutionPolicy Bypass -File register-headscale.ps1"/>
|
||||
</Product>
|
||||
</Wix>
|
||||
```
|
||||
|
||||
#### 2.2 설치 마법사 UI
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ 팜큐 Headscale 클라이언트 설치 │
|
||||
├─────────────────────────────────┤
|
||||
│ [ ] 기본 설치 (권장) │
|
||||
│ [ ] 기존 연결 해제 후 재설치 │
|
||||
│ │
|
||||
│ 서버: https://head.0bin.in │
|
||||
│ 네트워크: 100.64.0.0/10 │
|
||||
│ │
|
||||
│ [< 이전] [다음 >] [취소] │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 장점
|
||||
- ✅ Windows 사용자에게 친숙한 GUI
|
||||
- ✅ 제어판에서 제거 가능
|
||||
- ✅ 디지털 서명 가능
|
||||
- ✅ 그룹 정책 배포 가능
|
||||
|
||||
### 단점
|
||||
- ❌ 개발 복잡도 높음
|
||||
- ❌ 코드 서명 인증서 필요
|
||||
- ❌ 업데이트 배포 복잡
|
||||
|
||||
---
|
||||
|
||||
## 방안 3: 실행 파일 (EXE) + 내장 리소스
|
||||
|
||||
### 개념도
|
||||
```
|
||||
[사용자] → [farmq-installer.exe 실행] → [콘솔/GUI 선택] → [완료]
|
||||
단일 실행 파일 모든 리소스 내장
|
||||
```
|
||||
|
||||
### 구현 방법
|
||||
|
||||
#### 3.1 Go/C#으로 네이티브 실행파일
|
||||
```go
|
||||
// farmq-installer.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
//go:embed resources/tailscale-windows.msi
|
||||
//go:embed resources/register-script.ps1
|
||||
var resources embed.FS
|
||||
|
||||
func main() {
|
||||
fmt.Println("🚀 팜큐 Headscale Windows 설치")
|
||||
|
||||
// 관리자 권한 확인
|
||||
if !isAdmin() {
|
||||
fmt.Println("❌ 관리자 권한으로 다시 실행해주세요")
|
||||
return
|
||||
}
|
||||
|
||||
// Tailscale MSI 추출 및 설치
|
||||
installTailscale()
|
||||
|
||||
// Headscale 등록
|
||||
registerHeadscale()
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 배포 형태
|
||||
- **farmq-installer.exe** (단일 파일, ~50MB)
|
||||
- Tailscale MSI 포함
|
||||
- PowerShell 스크립트 포함
|
||||
- 모든 의존성 내장
|
||||
|
||||
### 장점
|
||||
- ✅ 단일 파일로 배포 간편
|
||||
- ✅ 오프라인 설치 가능
|
||||
- ✅ 의존성 문제 없음
|
||||
- ✅ 콘솔/GUI 하이브리드 가능
|
||||
|
||||
### 단점
|
||||
- ❌ 파일 크기 큼 (50MB+)
|
||||
- ❌ 네이티브 개발 필요
|
||||
- ❌ Tailscale 업데이트 시 재빌드
|
||||
|
||||
---
|
||||
|
||||
## 방안 4: 웹 기반 설치 (브라우저)
|
||||
|
||||
### 개념도
|
||||
```
|
||||
[사용자] → [웹페이지 방문] → [원클릭 다운로드] → [자동 실행] → [완료]
|
||||
설치 가이드 페이지 맞춤형 설치파일 브라우저 실행
|
||||
```
|
||||
|
||||
### 구현 방법
|
||||
|
||||
#### 4.1 웹 설치 페이지
|
||||
```html
|
||||
<!-- https://install.farmq.network -->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>팜큐 Headscale Windows 설치</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🪟 Windows용 팜큐 네트워크 설치</h1>
|
||||
|
||||
<div class="install-options">
|
||||
<button onclick="downloadInstaller('basic')">
|
||||
🚀 기본 설치
|
||||
</button>
|
||||
<button onclick="downloadInstaller('force')">
|
||||
🔄 강제 재설치
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function downloadInstaller(type) {
|
||||
const url = `https://install.farmq.network/download?type=${type}`;
|
||||
window.location.href = url;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
#### 4.2 동적 설치파일 생성
|
||||
```python
|
||||
# Flask 서버에서 실시간 생성
|
||||
@app.route('/download')
|
||||
def download_installer():
|
||||
install_type = request.args.get('type', 'basic')
|
||||
|
||||
# 사용자 맞춤형 PowerShell 스크립트 생성
|
||||
script = generate_powershell_script(
|
||||
force=install_type=='force',
|
||||
preauth_key=get_current_preauth_key(),
|
||||
server_url="https://head.0bin.in"
|
||||
)
|
||||
|
||||
response = make_response(script)
|
||||
response.headers['Content-Type'] = 'application/octet-stream'
|
||||
response.headers['Content-Disposition'] = 'attachment; filename=farmq-install.ps1'
|
||||
return response
|
||||
```
|
||||
|
||||
### 장점
|
||||
- ✅ 최신 설정 항상 반영
|
||||
- ✅ 사용자별 맞춤 설치
|
||||
- ✅ 통계 수집 가능
|
||||
- ✅ 웹 기반으로 접근성 좋음
|
||||
|
||||
### 단점
|
||||
- ❌ 인터넷 연결 필수
|
||||
- ❌ 웹 서버 인프라 필요
|
||||
- ❌ 브라우저 보안 정책 제약
|
||||
|
||||
---
|
||||
|
||||
## 방안 5: 배치 파일 (BAT) 스크립트
|
||||
|
||||
### 개념도
|
||||
```
|
||||
[사용자] → [farmq-install.bat 실행] → [Windows CMD 명령] → [완료]
|
||||
우클릭 "관리자로 실행" 전통적인 배치 방식
|
||||
```
|
||||
|
||||
### 구현 방법
|
||||
|
||||
#### 5.1 배치 스크립트
|
||||
```batch
|
||||
@echo off
|
||||
:: farmq-install.bat
|
||||
title 팜큐 Headscale Windows 설치
|
||||
|
||||
:: 관리자 권한 확인
|
||||
net session >nul 2>&1
|
||||
if %errorLevel% neq 0 (
|
||||
echo ❌ 관리자 권한이 필요합니다.
|
||||
echo 우클릭으로 "관리자로 실행"해주세요.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo 🚀 팜큐 Headscale Windows 설치 시작...
|
||||
|
||||
:: Tailscale 다운로드 및 설치
|
||||
echo 📦 Tailscale 다운로드 중...
|
||||
powershell -Command "Invoke-WebRequest -Uri 'https://pkgs.tailscale.com/stable/tailscale-setup.exe' -OutFile 'tailscale-setup.exe'"
|
||||
|
||||
echo 🔧 Tailscale 설치 중...
|
||||
tailscale-setup.exe /S
|
||||
|
||||
:: Headscale 등록
|
||||
echo 🌐 Headscale 서버 등록 중...
|
||||
"C:\Program Files\Tailscale\tailscale.exe" up --login-server=https://head.0bin.in --authkey=8b3df41d37cb158ea39f41fc32c9af46e761de817ad06038
|
||||
|
||||
echo ✅ 설치 완료!
|
||||
pause
|
||||
```
|
||||
|
||||
### 장점
|
||||
- ✅ 개발 간단
|
||||
- ✅ Windows 네이티브 지원
|
||||
- ✅ 의존성 없음
|
||||
- ✅ 디버깅 용이
|
||||
|
||||
### 단점
|
||||
- ❌ 기능 제한적
|
||||
- ❌ 에러 처리 복잡
|
||||
- ❌ 사용자 경험 떨어짐
|
||||
|
||||
---
|
||||
|
||||
## 🎯 권장 구현 우선순위
|
||||
|
||||
### 1단계: PowerShell 스크립트 (즉시 구현 가능)
|
||||
**기간**: 1-2일
|
||||
**난이도**: 하
|
||||
|
||||
```powershell
|
||||
# 사용자 실행 방법
|
||||
# 1. 관리자 PowerShell 열기
|
||||
# 2. 다음 명령 실행
|
||||
iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/.../farmq-install.ps1'))
|
||||
```
|
||||
|
||||
### 2단계: 실행 파일 (EXE) 방식
|
||||
**기간**: 3-5일
|
||||
**난이도**: 중
|
||||
|
||||
- Go언어로 크로스 컴파일
|
||||
- 단일 실행파일로 배포
|
||||
- GUI 옵션 포함
|
||||
|
||||
### 3단계: MSI 설치 패키지
|
||||
**기간**: 1주일
|
||||
**난이도**: 상
|
||||
|
||||
- 전문적인 설치 경험
|
||||
- 제어판 등록
|
||||
- 그룹 정책 배포 지원
|
||||
|
||||
## 📋 PowerShell 스크립트 구현 명세서
|
||||
|
||||
### 기본 기능
|
||||
```powershell
|
||||
# 1. 시스템 확인
|
||||
- Windows 버전 체크 (Windows 10/11 지원)
|
||||
- 관리자 권한 확인
|
||||
- 인터넷 연결 확인
|
||||
|
||||
# 2. Tailscale 설치
|
||||
- 기존 설치 확인
|
||||
- 최신 버전 다운로드
|
||||
- 자동 설치 (Silent Install)
|
||||
- 서비스 시작
|
||||
|
||||
# 3. Headscale 등록
|
||||
- 기존 연결 확인 및 해제
|
||||
- Pre-auth key로 자동 등록
|
||||
- 연결 상태 확인
|
||||
|
||||
# 4. 방화벽 설정
|
||||
- Windows Defender 예외 추가
|
||||
- 필요한 포트 허용
|
||||
|
||||
# 5. 완료 확인
|
||||
- IP 주소 할당 확인
|
||||
- 네트워크 연결 테스트
|
||||
- 상태 출력
|
||||
```
|
||||
|
||||
### 사용자 시나리오
|
||||
```
|
||||
1. 약국 직원이 새 PC 설정
|
||||
2. 관리자 PowerShell 실행
|
||||
3. 원클릭 명령어 붙여넣기
|
||||
4. 자동 설치 진행 (2-3분)
|
||||
5. 팜큐 네트워크 연결 완료
|
||||
```
|
||||
|
||||
## 🔧 즉시 구현 가능한 MVP
|
||||
|
||||
### 파일 구조
|
||||
```
|
||||
farmq-windows-installer/
|
||||
├── farmq-install.ps1 # 메인 설치 스크립트
|
||||
├── modules/
|
||||
│ ├── system-check.ps1 # 시스템 확인
|
||||
│ ├── tailscale-installer.ps1 # Tailscale 설치
|
||||
│ ├── headscale-register.ps1 # Headscale 등록
|
||||
│ └── network-verify.ps1 # 네트워크 확인
|
||||
├── resources/
|
||||
│ └── farmq-logo.ico # 아이콘
|
||||
└── README-windows.md # Windows 설치 가이드
|
||||
```
|
||||
|
||||
### 웹 실행 명령어
|
||||
```powershell
|
||||
# 기본 설치
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force; iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/main/farmq-install.ps1'))
|
||||
|
||||
# 강제 재설치
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force; iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/main/farmq-install.ps1?force=1'))
|
||||
```
|
||||
|
||||
## 📊 예상 사용 통계
|
||||
|
||||
### 대상 사용자
|
||||
- **팜큐 약국**: ~100개 약국 × 2-3대 PC = 200-300대
|
||||
- **신규 PC**: 월 10-20대 추가
|
||||
- **재설치**: 월 5-10건
|
||||
|
||||
### 성공 지표
|
||||
- ✅ **설치 성공률**: 95% 이상
|
||||
- ✅ **설치 시간**: 3분 이내
|
||||
- ✅ **사용자 만족도**: 5점 만점 4점 이상
|
||||
- ✅ **지원 요청**: 월 5건 이하
|
||||
|
||||
## 🚀 결론 및 추천
|
||||
|
||||
**즉시 구현 권장**: PowerShell 원클릭 스크립트
|
||||
|
||||
1. **개발 용이성** ⭐⭐⭐⭐⭐
|
||||
2. **사용자 편의성** ⭐⭐⭐⭐
|
||||
3. **유지보수성** ⭐⭐⭐⭐⭐
|
||||
4. **배포 편의성** ⭐⭐⭐⭐⭐
|
||||
|
||||
**다음 단계**: PowerShell 스크립트 구현 → EXE 파일 → MSI 패키지 순으로 단계적 발전
|
||||
|
||||
---
|
||||
|
||||
**목표**: "Linux처럼 Windows에서도 한 줄 명령어로 팜큐 네트워크 연결!" 🎯
|
||||
165
WINDOWS_QUICK_START.md
Normal file
165
WINDOWS_QUICK_START.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# 🪟 Windows 팜큐 네트워크 빠른 시작 가이드
|
||||
|
||||
Windows PC를 팜큐 네트워크에 **30초만에** 연결하는 방법입니다.
|
||||
|
||||
## 🎯 복사 붙여넣기 전용 명령어
|
||||
|
||||
### 📋 기본 설치 (가장 많이 사용)
|
||||
|
||||
**복사할 명령어:**
|
||||
```powershell
|
||||
iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/farmq-install.ps1'))
|
||||
```
|
||||
|
||||
### 📋 강제 재설치 (기존 Tailscale이 있는 경우)
|
||||
|
||||
**복사할 명령어:**
|
||||
```powershell
|
||||
$ForceInstall = $true; iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/farmq-install.ps1'))
|
||||
```
|
||||
|
||||
## 🚀 실행 방법 (3단계)
|
||||
|
||||
### 1단계: 관리자 PowerShell 열기
|
||||
- **Windows 10/11**: `Windows 키 + X` → `Windows PowerShell(관리자)` 클릭
|
||||
- **또는**: 시작 메뉴 → `PowerShell` 검색 → 우클릭 → `관리자로 실행`
|
||||
|
||||
### 2단계: 명령어 붙여넣기
|
||||
- 위의 **기본 설치 명령어**를 복사
|
||||
- PowerShell에 붙여넣기 (우클릭 또는 `Ctrl+V`)
|
||||
- `Enter` 키 누르기
|
||||
|
||||
### 3단계: 자동 설치 완료 대기
|
||||
- 2-3분 기다리기 ⏰
|
||||
- 완료 메시지 확인 ✅
|
||||
- 팜큐 네트워크 사용 시작! 🎉
|
||||
|
||||
## 📺 실행 화면 예시
|
||||
|
||||
```powershell
|
||||
PS C:\WINDOWS\system32> iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/farmq-install.ps1'))
|
||||
|
||||
============================================
|
||||
팜큐(FARMQ) Headscale Windows 원클릭 설치
|
||||
============================================
|
||||
|
||||
🔧 시스템 요구사항 확인 중...
|
||||
✅ 시스템 요구사항 확인 완료
|
||||
|
||||
🔧 Tailscale 클라이언트 확인 중...
|
||||
📋 Windows용 Tailscale 설치 중...
|
||||
🔧 Tailscale 다운로드 중...
|
||||
🔧 Tailscale 설치 중... (잠시 기다려주세요)
|
||||
✅ Tailscale 설치 완료
|
||||
|
||||
🔧 Tailscale 서비스 시작 중...
|
||||
✅ Tailscale 서비스가 실행 중입니다.
|
||||
|
||||
🔧 Headscale 서버에 등록 중...
|
||||
📋 Headscale 서버: https://head.0bin.in
|
||||
📋 Pre-auth Key: 8b3df41d***************
|
||||
🔧 등록 명령 실행 중...
|
||||
✅ Headscale 등록 성공!
|
||||
|
||||
🔧 방화벽 설정 확인 중...
|
||||
✅ 방화벽 설정 완료
|
||||
|
||||
🔧 연결 상태 확인 중...
|
||||
✅ Headscale 네트워크 연결 완료!
|
||||
📋 할당된 IPv4: 100.64.0.15
|
||||
📋 할당된 IPv6: fd7a:115c:a1e0::15
|
||||
|
||||
🔧 네트워크 연결 테스트 중...
|
||||
✅ 팜큐 네트워크(100.64.0.0/10) 연결 정상!
|
||||
|
||||
============================================
|
||||
팜큐 Headscale Windows 설치 완료!
|
||||
============================================
|
||||
|
||||
🎉 설치가 성공적으로 완료되었습니다!
|
||||
|
||||
📋 시스템 정보:
|
||||
컴퓨터명: PHARMACY-PC01
|
||||
Tailscale IP: 100.64.0.15
|
||||
OS: Windows 10.0
|
||||
Headscale 서버: https://head.0bin.in
|
||||
```
|
||||
|
||||
## ❓ 자주 묻는 질문 (FAQ)
|
||||
|
||||
### Q: "실행 정책" 오류가 나와요
|
||||
**A: 다음 명령을 먼저 실행하세요:**
|
||||
```powershell
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force
|
||||
```
|
||||
|
||||
### Q: 관리자 권한이 없다고 나와요
|
||||
**A: PowerShell을 관리자로 다시 실행하세요:**
|
||||
- `Windows 키 + X` → `Windows PowerShell(관리자)`
|
||||
|
||||
### Q: 이미 Tailscale이 설치되어 있어요
|
||||
**A: 강제 재설치 명령어를 사용하세요:**
|
||||
```powershell
|
||||
$ForceInstall = $true; iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/farmq-install.ps1'))
|
||||
```
|
||||
|
||||
### Q: 설치 후 어떻게 확인하나요?
|
||||
**A: PowerShell에서 다음 명령으로 확인:**
|
||||
```powershell
|
||||
tailscale status
|
||||
tailscale ip
|
||||
```
|
||||
|
||||
## 🔧 유용한 명령어
|
||||
|
||||
설치 완료 후 PowerShell에서 사용할 수 있는 명령어들:
|
||||
|
||||
```powershell
|
||||
# 연결 상태 확인
|
||||
tailscale status
|
||||
|
||||
# 내 IP 주소 확인
|
||||
tailscale ip
|
||||
|
||||
# 다른 컴퓨터와 연결 테스트
|
||||
tailscale ping 100.64.0.1
|
||||
|
||||
# 네트워크에서 나가기
|
||||
tailscale logout
|
||||
```
|
||||
|
||||
## 🏥 팜큐 관리 페이지 접속
|
||||
|
||||
설치 완료 후 브라우저에서 접속:
|
||||
|
||||
- **메인 관리 페이지**: http://192.168.0.151:5002
|
||||
- **VM 관리**: http://192.168.0.151:5002/vms
|
||||
- **약국 관리**: http://192.168.0.151:5002/pharmacy
|
||||
|
||||
## 🆘 문제 해결
|
||||
|
||||
### 연결이 안될 때
|
||||
1. **Windows 방화벽** 확인
|
||||
2. **백신 프로그램** 예외 설정
|
||||
3. **회사 네트워크 정책** 확인
|
||||
|
||||
### 완전 삭제 후 재설치
|
||||
```powershell
|
||||
# Tailscale 완전 제거
|
||||
tailscale logout
|
||||
# 제어판에서 Tailscale 제거
|
||||
# 다시 설치 스크립트 실행
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📞 지원
|
||||
|
||||
문제가 발생하면 다음 정보와 함께 연락주세요:
|
||||
|
||||
1. **Windows 버전**: `winver` 명령으로 확인
|
||||
2. **PowerShell 버전**: `$PSVersionTable` 명령으로 확인
|
||||
3. **오류 메시지**: 스크린샷 또는 텍스트 복사
|
||||
4. **컴퓨터 이름**: `$env:COMPUTERNAME` 명령으로 확인
|
||||
|
||||
**🎯 목표 달성: 복사 → 붙여넣기 → 30초 후 팜큐 네트워크 연결!** 🚀
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
server_url: http://localhost:8080
|
||||
server_url: http://localhost:8070
|
||||
listen_addr: 0.0.0.0:8080
|
||||
metrics_listen_addr: 0.0.0.0:9090
|
||||
|
||||
@@ -7,8 +7,9 @@ private_key_path: /var/lib/headscale/private.key
|
||||
noise:
|
||||
private_key_path: /var/lib/headscale/noise_private.key
|
||||
|
||||
ip_prefixes:
|
||||
- 100.64.0.0/10
|
||||
prefixes:
|
||||
v4: 100.64.0.0/10
|
||||
v6: fd7a:115c:a1e0::/48
|
||||
|
||||
derp:
|
||||
server:
|
||||
@@ -18,6 +19,7 @@ derp:
|
||||
|
||||
disable_check_updates: false
|
||||
ephemeral_node_inactivity_timeout: 30m
|
||||
|
||||
database:
|
||||
type: sqlite3
|
||||
sqlite:
|
||||
@@ -38,17 +40,21 @@ log:
|
||||
format: text
|
||||
level: info
|
||||
|
||||
acl_policy_path: ""
|
||||
|
||||
dns_config:
|
||||
# Updated DNS configuration format
|
||||
dns:
|
||||
override_local_dns: true
|
||||
nameservers:
|
||||
- 1.1.1.1
|
||||
- 8.8.8.8
|
||||
domains: []
|
||||
global:
|
||||
- 1.1.1.1
|
||||
- 8.8.8.8
|
||||
search_domains: []
|
||||
magic_dns: true
|
||||
base_domain: headscale.local
|
||||
|
||||
# Updated policy path
|
||||
policy:
|
||||
path: ""
|
||||
|
||||
unix_socket: /var/run/headscale/headscale.sock
|
||||
unix_socket_permission: "0770"
|
||||
|
||||
@@ -57,13 +63,13 @@ logtail:
|
||||
|
||||
randomize_client_port: false
|
||||
|
||||
# Simplified OIDC configuration (removed deprecated keys)
|
||||
oidc:
|
||||
only_start_if_oidc_is_available: true
|
||||
only_start_if_oidc_is_available: false
|
||||
issuer: ""
|
||||
client_id: ""
|
||||
client_secret: ""
|
||||
scope: ["openid", "profile", "email"]
|
||||
extra_params: {}
|
||||
allowed_domains: []
|
||||
allowed_users: []
|
||||
strip_email_domain: true
|
||||
allowed_users: []
|
||||
167
create-preauth-key.sh
Executable file
167
create-preauth-key.sh
Executable file
@@ -0,0 +1,167 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 팜큐(FARMQ) Pre-auth Key 생성 스크립트
|
||||
# 사용법: ./create-preauth-key.sh [사용자명] [유효기간(시간)]
|
||||
|
||||
set -e
|
||||
|
||||
# 기본 설정
|
||||
DEFAULT_USER="myuser"
|
||||
DEFAULT_EXPIRY="24h" # 24시간
|
||||
|
||||
# 사용법 출력
|
||||
usage() {
|
||||
echo "사용법: $0 [사용자명] [유효기간]"
|
||||
echo ""
|
||||
echo "예시:"
|
||||
echo " $0 # myuser 사용자, 24시간 유효"
|
||||
echo " $0 pharmacy1 # pharmacy1 사용자, 24시간 유효"
|
||||
echo " $0 pharmacy1 7d # pharmacy1 사용자, 7일 유효"
|
||||
echo " $0 pharmacy1 1h # pharmacy1 사용자, 1시간 유효"
|
||||
echo ""
|
||||
echo "유효기간 형식: 1h, 24h, 7d, 30d 등"
|
||||
}
|
||||
|
||||
# 색상 출력 함수
|
||||
print_status() {
|
||||
echo -e "\n🔧 $1"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "\n✅ $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "\n❌ $1"
|
||||
}
|
||||
|
||||
print_info() {
|
||||
echo -e "\n📋 $1"
|
||||
}
|
||||
|
||||
# 사용자 존재 확인
|
||||
check_user_exists() {
|
||||
local username=$1
|
||||
|
||||
print_status "사용자 '$username' 확인 중..."
|
||||
|
||||
if docker exec headscale headscale users list | grep -q "$username"; then
|
||||
print_info "사용자 '$username'이 존재합니다."
|
||||
return 0
|
||||
else
|
||||
print_error "사용자 '$username'이 존재하지 않습니다."
|
||||
print_info "사용자 생성 중..."
|
||||
|
||||
if docker exec headscale headscale users create "$username"; then
|
||||
print_success "사용자 '$username'이 생성되었습니다."
|
||||
else
|
||||
print_error "사용자 생성에 실패했습니다."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 사용자 ID 가져오기
|
||||
get_user_id() {
|
||||
local username=$1
|
||||
|
||||
local user_id=$(docker exec headscale headscale users list | grep "$username" | awk '{print $1}')
|
||||
|
||||
if [[ -n "$user_id" ]]; then
|
||||
echo $user_id
|
||||
else
|
||||
print_error "사용자 ID를 찾을 수 없습니다."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Pre-auth key 생성
|
||||
create_preauth_key() {
|
||||
local username=$1
|
||||
local expiry=$2
|
||||
|
||||
print_status "Pre-auth key 생성 중..."
|
||||
print_info "사용자: $username"
|
||||
print_info "유효기간: $expiry"
|
||||
|
||||
local user_id=$(get_user_id "$username")
|
||||
print_info "사용자 ID: $user_id"
|
||||
|
||||
# Pre-auth key 생성 (재사용 가능, 임시 아님)
|
||||
local preauth_output=$(docker exec headscale headscale preauthkeys create \
|
||||
-u "$user_id" \
|
||||
--expiration "$expiry" \
|
||||
--reusable)
|
||||
|
||||
if [[ $? -eq 0 ]]; then
|
||||
# Key 값 추출
|
||||
local preauth_key=$(echo "$preauth_output" | grep -o '[a-f0-9]\{48\}')
|
||||
|
||||
if [[ -n "$preauth_key" ]]; then
|
||||
print_success "Pre-auth key가 생성되었습니다!"
|
||||
print_info "Key: $preauth_key"
|
||||
|
||||
# 클라이언트 등록 스크립트에 추가할 명령어 출력
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "📋 클라이언트에서 사용할 명령어:"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Linux/macOS:"
|
||||
echo "sudo tailscale up \\"
|
||||
echo " --login-server=\"https://head.0bin.in\" \\"
|
||||
echo " --authkey=\"$preauth_key\" \\"
|
||||
echo " --accept-routes \\"
|
||||
echo " --accept-dns=false"
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "📋 등록 스크립트 업데이트:"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "register-client.sh 파일의 PREAUTH_KEY 값을 다음으로 업데이트하세요:"
|
||||
echo "PREAUTH_KEY=\"$preauth_key\""
|
||||
echo ""
|
||||
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
print_error "Pre-auth key 생성에 실패했습니다."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 기존 Pre-auth key 목록 표시
|
||||
list_existing_keys() {
|
||||
local username=$1
|
||||
local user_id=$(get_user_id "$username")
|
||||
|
||||
print_info "기존 Pre-auth key 목록 (사용자: $username):"
|
||||
docker exec headscale headscale preauthkeys list -u "$user_id"
|
||||
}
|
||||
|
||||
# 메인 함수
|
||||
main() {
|
||||
local username="${1:-$DEFAULT_USER}"
|
||||
local expiry="${2:-$DEFAULT_EXPIRY}"
|
||||
|
||||
echo "=========================================="
|
||||
echo " 🔑 팜큐(FARMQ) Pre-auth Key 생성"
|
||||
echo "=========================================="
|
||||
|
||||
# 도움말 요청 확인
|
||||
if [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
check_user_exists "$username"
|
||||
list_existing_keys "$username"
|
||||
create_preauth_key "$username" "$expiry"
|
||||
|
||||
echo ""
|
||||
print_success "완료!"
|
||||
print_info "이 key는 $expiry 동안 유효하며, 여러 번 사용할 수 있습니다."
|
||||
}
|
||||
|
||||
# 스크립트 실행
|
||||
main "$@"
|
||||
@@ -5,7 +5,7 @@ services:
|
||||
image: headscale/headscale:latest
|
||||
container_name: headscale
|
||||
restart: unless-stopped
|
||||
command: headscale serve
|
||||
command: serve
|
||||
environment:
|
||||
- TZ=Asia/Seoul
|
||||
volumes:
|
||||
@@ -13,16 +13,16 @@ services:
|
||||
- ./data:/var/lib/headscale
|
||||
- ./run:/var/run/headscale
|
||||
ports:
|
||||
- "8080:8080" # Headscale HTTP API
|
||||
- "8070:8080" # Headscale HTTP API (외부:내부)
|
||||
- "9090:9090" # Metrics (optional)
|
||||
networks:
|
||||
- headscale-net
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||
test: ["CMD", "/ko-app/headscale", "version"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
start_period: 30s
|
||||
|
||||
headplane:
|
||||
image: ghcr.io/tale/headplane:latest
|
||||
@@ -32,11 +32,12 @@ services:
|
||||
- TZ=Asia/Seoul
|
||||
- HEADSCALE_URL=http://headscale:8080
|
||||
- HEADSCALE_API_KEY=${HEADSCALE_API_KEY}
|
||||
volumes:
|
||||
- ./headplane-config:/etc/headplane
|
||||
ports:
|
||||
- "3000:3000" # Headplane Web UI
|
||||
depends_on:
|
||||
headscale:
|
||||
condition: service_healthy
|
||||
- headscale
|
||||
networks:
|
||||
- headscale-net
|
||||
|
||||
|
||||
241
farmq-admin/app.py
Normal file
241
farmq-admin/app.py
Normal file
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
팜큐 약국 관리 시스템 - Flask 애플리케이션
|
||||
Headscale + Headplane 고도화 관리자 페이지
|
||||
"""
|
||||
|
||||
from flask import Flask, render_template, jsonify, request, redirect, url_for
|
||||
import os
|
||||
from datetime import datetime
|
||||
from config import config
|
||||
from utils.database_new import (
|
||||
init_databases, get_farmq_session,
|
||||
get_dashboard_stats, get_all_pharmacies_with_stats, get_all_machines_with_details,
|
||||
get_machine_detail, get_pharmacy_detail, get_active_alerts,
|
||||
sync_machines_from_headscale, sync_users_from_headscale
|
||||
)
|
||||
|
||||
def create_app(config_name=None):
|
||||
"""Flask 애플리케이션 팩토리"""
|
||||
app = Flask(__name__)
|
||||
|
||||
# 설정 로드
|
||||
config_name = config_name or os.environ.get('FLASK_ENV', 'default')
|
||||
app.config.from_object(config[config_name])
|
||||
|
||||
# 데이터베이스 초기화
|
||||
init_databases(
|
||||
headscale_db_uri='sqlite:////srv/headscale-setup/data/db.sqlite',
|
||||
farmq_db_uri='sqlite:///farmq.db'
|
||||
)
|
||||
|
||||
# 데이터 동기화 실행
|
||||
sync_users_from_headscale()
|
||||
sync_machines_from_headscale()
|
||||
|
||||
# 메인 대시보드
|
||||
@app.route('/')
|
||||
def dashboard():
|
||||
"""메인 대시보드"""
|
||||
try:
|
||||
# 새로운 통합 통계 함수 사용
|
||||
stats = get_dashboard_stats()
|
||||
stats['alerts'] = get_active_alerts()[:5] # 최신 5개만
|
||||
stats['performance'] = {'status': 'good', 'summary': '모든 시스템이 정상 작동 중입니다.'}
|
||||
|
||||
# 약국별 상태 (상위 10개)
|
||||
pharmacies = get_all_pharmacies_with_stats()[:10]
|
||||
|
||||
return render_template('dashboard/index.html',
|
||||
stats=stats,
|
||||
pharmacies=pharmacies)
|
||||
except Exception as e:
|
||||
print(f"❌ Dashboard error: {e}")
|
||||
return render_template('error.html', error=str(e)), 500
|
||||
|
||||
# 약국 관리
|
||||
@app.route('/pharmacy')
|
||||
def pharmacy_list():
|
||||
"""약국 목록"""
|
||||
try:
|
||||
pharmacies = get_all_pharmacies_with_stats()
|
||||
return render_template('pharmacy/list.html', pharmacies=pharmacies)
|
||||
except Exception as e:
|
||||
return render_template('error.html', error=str(e)), 500
|
||||
|
||||
@app.route('/pharmacy/<int:pharmacy_id>')
|
||||
def pharmacy_detail(pharmacy_id):
|
||||
"""약국 상세 정보"""
|
||||
try:
|
||||
detail_data = get_pharmacy_detail(pharmacy_id)
|
||||
if not detail_data:
|
||||
return render_template('error.html', error='약국을 찾을 수 없습니다.'), 404
|
||||
|
||||
return render_template('pharmacy/detail.html',
|
||||
pharmacy=detail_data['pharmacy'],
|
||||
machines=detail_data['machines'])
|
||||
except Exception as e:
|
||||
print(f"❌ Pharmacy detail error: {e}")
|
||||
return render_template('error.html', error=str(e)), 500
|
||||
|
||||
# 머신 관리
|
||||
@app.route('/machines')
|
||||
def machine_list():
|
||||
"""머신 목록"""
|
||||
try:
|
||||
machines = get_all_machines_with_details()
|
||||
return render_template('machines/list.html', machines=machines)
|
||||
except Exception as e:
|
||||
print(f"❌ Machine list error: {e}")
|
||||
return render_template('error.html', error=str(e)), 500
|
||||
|
||||
@app.route('/machines/<int:machine_id>')
|
||||
def machine_detail(machine_id):
|
||||
"""머신 상세 정보"""
|
||||
try:
|
||||
print(f"🔍 Getting details for machine ID: {machine_id}")
|
||||
details = get_machine_detail(machine_id)
|
||||
|
||||
if not details:
|
||||
print(f"❌ No details found for machine ID: {machine_id}")
|
||||
return render_template('error.html', error='머신을 찾을 수 없습니다.'), 404
|
||||
|
||||
hostname = details.get('hostname', 'Unknown')
|
||||
print(f"✅ Rendering detail page for machine: {hostname}")
|
||||
|
||||
return render_template('machines/detail.html', machine=details)
|
||||
except Exception as e:
|
||||
print(f"❌ Error in machine_detail route: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return render_template('error.html', error=f'머신 상세 정보 로드 중 오류: {str(e)}'), 500
|
||||
|
||||
# API 엔드포인트
|
||||
@app.route('/api/dashboard/stats')
|
||||
def api_dashboard_stats():
|
||||
"""대시보드 통계 API"""
|
||||
try:
|
||||
stats = get_dashboard_stats()
|
||||
stats['performance'] = {'status': 'good', 'summary': '모든 시스템이 정상 작동 중입니다.'}
|
||||
return jsonify(stats)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/alerts')
|
||||
def api_alerts():
|
||||
"""실시간 알림 API"""
|
||||
try:
|
||||
alerts = get_active_alerts()
|
||||
return jsonify(alerts)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/machines/<int:machine_id>/monitoring')
|
||||
def api_machine_monitoring(machine_id):
|
||||
"""머신 모니터링 데이터 API"""
|
||||
try:
|
||||
details = get_machine_detail(machine_id)
|
||||
if not details:
|
||||
return jsonify({'error': '머신을 찾을 수 없습니다.'}), 404
|
||||
|
||||
# 최근 모니터링 데이터 반환
|
||||
metrics_history = details.get('metrics_history', [])
|
||||
return jsonify(metrics_history[:20]) # 최근 20개 데이터
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/sync/machines')
|
||||
def api_sync_machines():
|
||||
"""Headscale에서 머신 정보 동기화 API"""
|
||||
try:
|
||||
result = sync_machines_from_headscale()
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/sync/users')
|
||||
def api_sync_users():
|
||||
"""Headscale에서 사용자 정보 동기화 API"""
|
||||
try:
|
||||
result = sync_users_from_headscale()
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/pharmacy/<int:pharmacy_id>/update', methods=['PUT'])
|
||||
def api_update_pharmacy(pharmacy_id):
|
||||
"""약국 정보 업데이트 API"""
|
||||
try:
|
||||
from utils.database_new import get_farmq_session
|
||||
from models.farmq_models import PharmacyInfo
|
||||
|
||||
data = request.get_json()
|
||||
session = get_farmq_session()
|
||||
|
||||
try:
|
||||
pharmacy = session.query(PharmacyInfo).filter(
|
||||
PharmacyInfo.id == pharmacy_id
|
||||
).first()
|
||||
|
||||
if not pharmacy:
|
||||
return jsonify({'error': '약국을 찾을 수 없습니다.'}), 404
|
||||
|
||||
# 업데이트 가능한 필드들
|
||||
if 'pharmacy_name' in data:
|
||||
pharmacy.pharmacy_name = data['pharmacy_name']
|
||||
if 'business_number' in data:
|
||||
pharmacy.business_number = data['business_number']
|
||||
if 'manager_name' in data:
|
||||
pharmacy.manager_name = data['manager_name']
|
||||
if 'phone' in data:
|
||||
pharmacy.phone = data['phone']
|
||||
if 'address' in data:
|
||||
pharmacy.address = data['address']
|
||||
|
||||
pharmacy.updated_at = datetime.now()
|
||||
session.commit()
|
||||
|
||||
return jsonify({
|
||||
'message': '약국 정보가 업데이트되었습니다.',
|
||||
'pharmacy': pharmacy.to_dict()
|
||||
})
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
# 에러 핸들러
|
||||
@app.errorhandler(404)
|
||||
def not_found_error(error):
|
||||
return render_template('error.html',
|
||||
error='페이지를 찾을 수 없습니다.',
|
||||
error_code=404), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(error):
|
||||
return render_template('error.html',
|
||||
error='내부 서버 오류가 발생했습니다.',
|
||||
error_code=500), 500
|
||||
|
||||
return app
|
||||
|
||||
# 개발 서버 실행
|
||||
if __name__ == '__main__':
|
||||
app = create_app()
|
||||
|
||||
# 개발 환경에서만 실행
|
||||
if app.config.get('DEBUG'):
|
||||
print("🚀 Starting FARMQ Admin System...")
|
||||
print(f"📊 Dashboard: http://localhost:5001")
|
||||
print(f"🏥 Pharmacy Management: http://localhost:5001/pharmacy")
|
||||
print(f"💻 Machine Management: http://localhost:5001/machines")
|
||||
print("─" * 60)
|
||||
|
||||
app.run(
|
||||
host='0.0.0.0',
|
||||
port=5001,
|
||||
debug=True,
|
||||
use_reloader=True
|
||||
)
|
||||
56
farmq-admin/config.py
Normal file
56
farmq-admin/config.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Flask 애플리케이션 설정
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
class Config:
|
||||
"""기본 설정"""
|
||||
# Flask 기본 설정
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') or 'farmq-secret-key-change-in-production'
|
||||
|
||||
# 데이터베이스 설정 (기존 Headscale SQLite DB 사용)
|
||||
DATABASE_PATH = '/srv/headscale-setup/data/db.sqlite'
|
||||
SQLALCHEMY_DATABASE_URI = f'sqlite:///{DATABASE_PATH}'
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
# 기존 Headplane 연동 설정
|
||||
HEADPLANE_URL = os.environ.get('HEADPLANE_URL') or 'http://localhost:3000'
|
||||
HEADSCALE_URL = os.environ.get('HEADSCALE_URL') or 'http://localhost:8070'
|
||||
HEADSCALE_API_KEY = os.environ.get('HEADSCALE_API_KEY') or '8qRr1IB.tV95CmA0fLaCiGGIgBfeoN9daHceFkzI'
|
||||
|
||||
# 모니터링 설정
|
||||
MONITORING_INTERVAL = 30 # 30초마다 데이터 수집
|
||||
MAX_MONITORING_RECORDS = 1000 # 최대 저장 레코드 수
|
||||
|
||||
# UI 설정
|
||||
APP_TITLE = '팜큐 약국 관리 시스템'
|
||||
ITEMS_PER_PAGE = 20 # 페이지당 아이템 수
|
||||
|
||||
# Proxmox 설정 (추후 확장)
|
||||
PROXMOX_DEFAULT_PORT = 8006
|
||||
PROXMOX_VERIFY_SSL = False
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
"""개발 환경 설정"""
|
||||
DEBUG = True
|
||||
TESTING = False
|
||||
|
||||
class ProductionConfig(Config):
|
||||
"""프로덕션 환경 설정"""
|
||||
DEBUG = False
|
||||
TESTING = False
|
||||
|
||||
class TestingConfig(Config):
|
||||
"""테스트 환경 설정"""
|
||||
DEBUG = True
|
||||
TESTING = True
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
|
||||
|
||||
# 환경별 설정 매핑
|
||||
config = {
|
||||
'development': DevelopmentConfig,
|
||||
'production': ProductionConfig,
|
||||
'testing': TestingConfig,
|
||||
'default': DevelopmentConfig
|
||||
}
|
||||
34
farmq-admin/models/__init__.py
Normal file
34
farmq-admin/models/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
모델 패키지 초기화
|
||||
Headscale 호환성 모델과 FARMQ 전용 모델 분리
|
||||
"""
|
||||
|
||||
# Headscale 읽기 전용 모델 (외래키 제약조건 없음)
|
||||
from .headscale_models import (
|
||||
User, Node, PreAuthKey, ApiKey, Policy,
|
||||
Base, create_all_tables
|
||||
)
|
||||
|
||||
# FARMQ 전용 모델 (별도 데이터베이스)
|
||||
from .farmq_models import (
|
||||
PharmacyInfo, MachineProfile, MonitoringMetrics, SystemAlert,
|
||||
FarmqDatabaseManager, create_farmq_database_manager,
|
||||
FarmqBase
|
||||
)
|
||||
|
||||
# 하위 호환성을 위한 별칭
|
||||
MachineSpecs = MachineProfile # 기존 이름과의 호환성
|
||||
MonitoringData = MonitoringMetrics # 기존 이름과의 호환성
|
||||
|
||||
__all__ = [
|
||||
# Headscale 모델
|
||||
'User', 'Node', 'PreAuthKey', 'ApiKey', 'Policy',
|
||||
'Base', 'create_all_tables',
|
||||
|
||||
# FARMQ 모델
|
||||
'PharmacyInfo', 'MachineProfile', 'MonitoringMetrics', 'SystemAlert',
|
||||
'FarmqDatabaseManager', 'create_farmq_database_manager', 'FarmqBase',
|
||||
|
||||
# 하위 호환성 별칭
|
||||
'MachineSpecs', 'MonitoringData'
|
||||
]
|
||||
510
farmq-admin/models/farmq_models.py
Normal file
510
farmq-admin/models/farmq_models.py
Normal file
@@ -0,0 +1,510 @@
|
||||
"""
|
||||
FARMQ 독립적인 모델 설계
|
||||
Headscale과 충돌하지 않는 별도 데이터베이스 사용
|
||||
|
||||
설계 원칙:
|
||||
1. 별도 데이터베이스 사용 (farmq.sqlite)
|
||||
2. Headscale 테이블과 직접적인 외래키 제약조건 제거
|
||||
3. 느슨한 결합: ID 참조만 사용 (외래키 제약조건 없음)
|
||||
4. 능동적 대응: 데이터 무결성을 애플리케이션 레벨에서 관리
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
import json
|
||||
from sqlalchemy import (
|
||||
Column, Integer, String, DateTime, Boolean, Text, Float,
|
||||
Index, UniqueConstraint, create_engine
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from sqlalchemy.types import TypeDecorator, TEXT
|
||||
|
||||
# FARMQ 전용 Base 클래스
|
||||
FarmqBase = declarative_base()
|
||||
|
||||
|
||||
class JSONType(TypeDecorator):
|
||||
"""Custom JSON type for SQLAlchemy"""
|
||||
impl = TEXT
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if value is not None:
|
||||
return json.dumps(value)
|
||||
return value
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
if value is not None:
|
||||
return json.loads(value)
|
||||
return value
|
||||
|
||||
|
||||
class PharmacyInfo(FarmqBase):
|
||||
"""약국 정보 테이블 - Headscale과 독립적"""
|
||||
__tablename__ = 'pharmacies'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# Headscale 연결 정보 (느슨한 결합)
|
||||
headscale_user_name = Column(String(255)) # users.name 참조 (외래키 제약조건 없음)
|
||||
headscale_user_id = Column(Integer) # users.id 참조 (외래키 제약조건 없음)
|
||||
|
||||
# 약국 기본 정보
|
||||
pharmacy_name = Column(String(255), nullable=False)
|
||||
business_number = Column(String(20))
|
||||
manager_name = Column(String(100))
|
||||
phone = Column(String(20))
|
||||
address = Column(Text)
|
||||
|
||||
# 기술적 정보
|
||||
proxmox_host = Column(String(255))
|
||||
proxmox_username = Column(String(100))
|
||||
proxmox_api_token = Column(Text) # 암호화 권장
|
||||
tailscale_ip = Column(String(45)) # IPv4/IPv6 지원
|
||||
|
||||
# 상태 관리
|
||||
status = Column(String(20), default='active') # active, inactive, maintenance
|
||||
last_sync = Column(DateTime) # 마지막 동기화 시간
|
||||
notes = Column(Text) # 관리 메모
|
||||
|
||||
# 타임스탬프
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PharmacyInfo(id={self.id}, name='{self.pharmacy_name}', status='{self.status}')>"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""딕셔너리로 변환"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'headscale_user_name': self.headscale_user_name,
|
||||
'headscale_user_id': self.headscale_user_id,
|
||||
'pharmacy_name': self.pharmacy_name,
|
||||
'business_number': self.business_number,
|
||||
'manager_name': self.manager_name,
|
||||
'phone': self.phone,
|
||||
'address': self.address,
|
||||
'proxmox_host': self.proxmox_host,
|
||||
'tailscale_ip': self.tailscale_ip,
|
||||
'status': self.status,
|
||||
'last_sync': self.last_sync.isoformat() if self.last_sync else None,
|
||||
'created_at': self.created_at.isoformat(),
|
||||
'updated_at': self.updated_at.isoformat()
|
||||
}
|
||||
|
||||
|
||||
class MachineProfile(FarmqBase):
|
||||
"""머신 프로필 테이블 - 하드웨어 스펙 및 구성"""
|
||||
__tablename__ = 'machine_profiles'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# Headscale 연결 정보 (느슨한 결합)
|
||||
headscale_node_id = Column(Integer) # nodes.id 참조 (외래키 제약조건 없음)
|
||||
headscale_machine_key = Column(String(255)) # nodes.machine_key 참조
|
||||
pharmacy_id = Column(Integer) # pharmacies.id 참조 (외래키 제약조건 없음)
|
||||
|
||||
# 머신 식별 정보
|
||||
hostname = Column(String(255))
|
||||
machine_name = Column(String(255)) # 사용자 정의 머신명
|
||||
serial_number = Column(String(100)) # 하드웨어 시리얼 번호
|
||||
|
||||
# 하드웨어 스펙
|
||||
cpu_model = Column(String(255))
|
||||
cpu_cores = Column(Integer)
|
||||
cpu_threads = Column(Integer)
|
||||
ram_gb = Column(Integer)
|
||||
storage_type = Column(String(50)) # SSD, HDD, NVMe
|
||||
storage_gb = Column(Integer)
|
||||
gpu_model = Column(String(255))
|
||||
gpu_memory_gb = Column(Integer)
|
||||
|
||||
# 네트워크 정보
|
||||
network_interfaces = Column(JSONType) # 네트워크 인터페이스 목록
|
||||
tailscale_ip = Column(String(45))
|
||||
tailscale_status = Column(String(20), default='unknown') # online, offline, unknown
|
||||
|
||||
# 운영체제 및 소프트웨어
|
||||
os_type = Column(String(50)) # Windows, Linux, etc.
|
||||
os_version = Column(String(100))
|
||||
tailscale_version = Column(String(50))
|
||||
installed_software = Column(JSONType) # 설치된 소프트웨어 목록
|
||||
|
||||
# 상태 및 관리
|
||||
status = Column(String(20), default='active') # active, maintenance, retired
|
||||
location = Column(String(255)) # 물리적 위치
|
||||
purchase_date = Column(DateTime)
|
||||
warranty_expires = Column(DateTime)
|
||||
last_maintenance = Column(DateTime)
|
||||
|
||||
# 성능 기준선
|
||||
baseline_cpu_temp = Column(Float) # 정상 CPU 온도 기준
|
||||
baseline_cpu_usage = Column(Float) # 정상 CPU 사용률 기준
|
||||
baseline_memory_usage = Column(Float) # 정상 메모리 사용률 기준
|
||||
|
||||
# 타임스탬프
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
last_seen = Column(DateTime) # 마지막 활동 시간
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MachineProfile(id={self.id}, hostname='{self.hostname}', cpu='{self.cpu_model}')>"
|
||||
|
||||
def is_online(self, timeout_minutes: int = 10) -> bool:
|
||||
"""온라인 상태 확인"""
|
||||
if not self.last_seen:
|
||||
return False
|
||||
return (datetime.now() - self.last_seen).total_seconds() < (timeout_minutes * 60)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'id': self.id,
|
||||
'headscale_node_id': self.headscale_node_id,
|
||||
'pharmacy_id': self.pharmacy_id,
|
||||
'hostname': self.hostname,
|
||||
'machine_name': self.machine_name,
|
||||
'cpu_model': self.cpu_model,
|
||||
'cpu_cores': self.cpu_cores,
|
||||
'ram_gb': self.ram_gb,
|
||||
'storage_gb': self.storage_gb,
|
||||
'tailscale_ip': self.tailscale_ip,
|
||||
'tailscale_status': self.tailscale_status,
|
||||
'os_type': self.os_type,
|
||||
'os_version': self.os_version,
|
||||
'status': self.status,
|
||||
'is_online': self.is_online(),
|
||||
'created_at': self.created_at.isoformat(),
|
||||
'last_seen': self.last_seen.isoformat() if self.last_seen else None
|
||||
}
|
||||
|
||||
|
||||
class MonitoringMetrics(FarmqBase):
|
||||
"""실시간 모니터링 메트릭스 - 시계열 데이터"""
|
||||
__tablename__ = 'monitoring_metrics'
|
||||
__table_args__ = (
|
||||
Index('idx_machine_timestamp', 'machine_profile_id', 'collected_at'),
|
||||
Index('idx_collected_at', 'collected_at'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# 연결 정보
|
||||
machine_profile_id = Column(Integer) # machine_profiles.id 참조 (외래키 제약조건 없음)
|
||||
headscale_node_id = Column(Integer) # 빠른 조회를 위한 중복 저장
|
||||
|
||||
# 시스템 메트릭스
|
||||
cpu_usage_percent = Column(Float) # CPU 사용률
|
||||
memory_usage_percent = Column(Float) # 메모리 사용률
|
||||
memory_used_gb = Column(Float) # 사용 중인 메모리 (GB)
|
||||
memory_total_gb = Column(Float) # 총 메모리 (GB)
|
||||
|
||||
# 스토리지 메트릭스
|
||||
disk_usage_percent = Column(Float) # 디스크 사용률
|
||||
disk_used_gb = Column(Float) # 사용 중인 디스크 (GB)
|
||||
disk_total_gb = Column(Float) # 총 디스크 (GB)
|
||||
disk_io_read_mb = Column(Float) # 디스크 읽기 (MB/s)
|
||||
disk_io_write_mb = Column(Float) # 디스크 쓰기 (MB/s)
|
||||
|
||||
# 온도 및 전력
|
||||
cpu_temperature = Column(Float) # CPU 온도 (섭씨)
|
||||
gpu_temperature = Column(Float) # GPU 온도 (섭씨)
|
||||
system_temperature = Column(Float) # 시스템 온도
|
||||
power_consumption_watts = Column(Float) # 전력 소모 (와트)
|
||||
|
||||
# 네트워크 메트릭스
|
||||
network_rx_bytes_sec = Column(Integer) # 네트워크 수신 (bytes/sec)
|
||||
network_tx_bytes_sec = Column(Integer) # 네트워크 송신 (bytes/sec)
|
||||
network_latency_ms = Column(Float) # 네트워크 지연시간 (ms)
|
||||
|
||||
# 프로세스 및 서비스
|
||||
process_count = Column(Integer) # 실행 중인 프로세스 수
|
||||
service_status = Column(JSONType) # 중요 서비스 상태
|
||||
|
||||
# 가상머신 관련 (Proxmox)
|
||||
vm_count_total = Column(Integer) # 총 VM 수
|
||||
vm_count_running = Column(Integer) # 실행 중인 VM 수
|
||||
vm_count_stopped = Column(Integer) # 중지된 VM 수
|
||||
|
||||
# 상태 및 알림
|
||||
alert_level = Column(String(10), default='normal') # normal, warning, critical
|
||||
alert_message = Column(Text) # 알림 메시지
|
||||
|
||||
# 타임스탬프
|
||||
collected_at = Column(DateTime, default=datetime.now)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MonitoringMetrics(machine_id={self.machine_profile_id}, cpu={self.cpu_usage_percent}%, collected_at='{self.collected_at}')>"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'id': self.id,
|
||||
'machine_profile_id': self.machine_profile_id,
|
||||
'cpu_usage_percent': self.cpu_usage_percent,
|
||||
'memory_usage_percent': self.memory_usage_percent,
|
||||
'disk_usage_percent': self.disk_usage_percent,
|
||||
'cpu_temperature': self.cpu_temperature,
|
||||
'network_rx_bytes_sec': self.network_rx_bytes_sec,
|
||||
'network_tx_bytes_sec': self.network_tx_bytes_sec,
|
||||
'alert_level': self.alert_level,
|
||||
'collected_at': self.collected_at.isoformat()
|
||||
}
|
||||
|
||||
def get_alert_status(self) -> Dict[str, Any]:
|
||||
"""알림 상태 및 메시지 반환"""
|
||||
alerts = []
|
||||
|
||||
# CPU 온도 체크
|
||||
if self.cpu_temperature and self.cpu_temperature > 80:
|
||||
alerts.append({'type': 'temperature', 'message': f'CPU 온도 높음: {self.cpu_temperature}°C'})
|
||||
|
||||
# CPU 사용률 체크
|
||||
if self.cpu_usage_percent and self.cpu_usage_percent > 90:
|
||||
alerts.append({'type': 'cpu', 'message': f'CPU 사용률 높음: {self.cpu_usage_percent}%'})
|
||||
|
||||
# 메모리 사용률 체크
|
||||
if self.memory_usage_percent and self.memory_usage_percent > 85:
|
||||
alerts.append({'type': 'memory', 'message': f'메모리 사용률 높음: {self.memory_usage_percent}%'})
|
||||
|
||||
# 디스크 사용률 체크
|
||||
if self.disk_usage_percent and self.disk_usage_percent > 90:
|
||||
alerts.append({'type': 'disk', 'message': f'디스크 사용률 높음: {self.disk_usage_percent}%'})
|
||||
|
||||
return {
|
||||
'level': 'critical' if any(alert['type'] in ['temperature', 'cpu'] for alert in alerts) else
|
||||
'warning' if alerts else 'normal',
|
||||
'alerts': alerts,
|
||||
'count': len(alerts)
|
||||
}
|
||||
|
||||
|
||||
class SystemAlert(FarmqBase):
|
||||
"""시스템 알림 테이블"""
|
||||
__tablename__ = 'system_alerts'
|
||||
__table_args__ = (
|
||||
Index('idx_alert_status', 'status'),
|
||||
Index('idx_alert_created', 'created_at'),
|
||||
Index('idx_alert_severity', 'severity'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
# 연결 정보
|
||||
machine_profile_id = Column(Integer) # machine_profiles.id 참조
|
||||
pharmacy_id = Column(Integer) # pharmacies.id 참조
|
||||
|
||||
# 알림 정보
|
||||
alert_type = Column(String(50)) # cpu, memory, disk, temperature, network, service
|
||||
severity = Column(String(10)) # low, medium, high, critical
|
||||
title = Column(String(255)) # 알림 제목
|
||||
message = Column(Text) # 알림 상세 메시지
|
||||
|
||||
# 메트릭스 값
|
||||
current_value = Column(Float) # 현재 값
|
||||
threshold_value = Column(Float) # 임계값
|
||||
unit = Column(String(10)) # 단위 (%, GB, °C, etc.)
|
||||
|
||||
# 상태 관리
|
||||
status = Column(String(20), default='active') # active, acknowledged, resolved
|
||||
acknowledged_by = Column(String(100)) # 확인한 사용자
|
||||
acknowledged_at = Column(DateTime) # 확인 시간
|
||||
resolved_at = Column(DateTime) # 해결 시간
|
||||
|
||||
# 반복 방지
|
||||
fingerprint = Column(String(255)) # 중복 알림 방지용 핑거프린트
|
||||
occurrence_count = Column(Integer, default=1) # 발생 횟수
|
||||
first_occurred = Column(DateTime, default=datetime.now) # 최초 발생 시간
|
||||
last_occurred = Column(DateTime, default=datetime.now) # 최근 발생 시간
|
||||
|
||||
# 타임스탬프
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SystemAlert(id={self.id}, type='{self.alert_type}', severity='{self.severity}', status='{self.status}')>"
|
||||
|
||||
def acknowledge(self, user: str = 'system'):
|
||||
"""알림 확인 처리"""
|
||||
self.status = 'acknowledged'
|
||||
self.acknowledged_by = user
|
||||
self.acknowledged_at = datetime.now()
|
||||
|
||||
def resolve(self):
|
||||
"""알림 해결 처리"""
|
||||
self.status = 'resolved'
|
||||
self.resolved_at = datetime.now()
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'id': self.id,
|
||||
'machine_profile_id': self.machine_profile_id,
|
||||
'pharmacy_id': self.pharmacy_id,
|
||||
'alert_type': self.alert_type,
|
||||
'severity': self.severity,
|
||||
'title': self.title,
|
||||
'message': self.message,
|
||||
'current_value': self.current_value,
|
||||
'threshold_value': self.threshold_value,
|
||||
'unit': self.unit,
|
||||
'status': self.status,
|
||||
'occurrence_count': self.occurrence_count,
|
||||
'created_at': self.created_at.isoformat(),
|
||||
'last_occurred': self.last_occurred.isoformat()
|
||||
}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Database Manager Class
|
||||
# ==========================================
|
||||
|
||||
class FarmqDatabaseManager:
|
||||
"""FARMQ 데이터베이스 관리 클래스"""
|
||||
|
||||
def __init__(self, database_url: str = "sqlite:///farmq-admin/farmq.sqlite"):
|
||||
self.database_url = database_url
|
||||
self.engine = create_engine(database_url, echo=False)
|
||||
self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine)
|
||||
self._create_tables()
|
||||
|
||||
def _create_tables(self):
|
||||
"""테이블 생성"""
|
||||
FarmqBase.metadata.create_all(self.engine)
|
||||
|
||||
def get_session(self) -> Session:
|
||||
"""세션 생성"""
|
||||
return self.SessionLocal()
|
||||
|
||||
def close_session(self, session: Session):
|
||||
"""세션 종료"""
|
||||
session.close()
|
||||
|
||||
# ==========================================
|
||||
# Pharmacy Management
|
||||
# ==========================================
|
||||
|
||||
def get_pharmacy_by_headscale_user(self, headscale_user_name: str) -> Optional[PharmacyInfo]:
|
||||
"""Headscale 사용자명으로 약국 정보 조회"""
|
||||
session = self.get_session()
|
||||
try:
|
||||
return session.query(PharmacyInfo).filter(
|
||||
PharmacyInfo.headscale_user_name == headscale_user_name
|
||||
).first()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def create_or_update_pharmacy(self, pharmacy_data: Dict[str, Any]) -> PharmacyInfo:
|
||||
"""약국 정보 생성 또는 업데이트"""
|
||||
session = self.get_session()
|
||||
try:
|
||||
pharmacy = session.query(PharmacyInfo).filter(
|
||||
PharmacyInfo.headscale_user_name == pharmacy_data.get('headscale_user_name')
|
||||
).first()
|
||||
|
||||
if pharmacy:
|
||||
# 업데이트
|
||||
for key, value in pharmacy_data.items():
|
||||
if hasattr(pharmacy, key):
|
||||
setattr(pharmacy, key, value)
|
||||
pharmacy.updated_at = datetime.now()
|
||||
else:
|
||||
# 생성
|
||||
pharmacy = PharmacyInfo(**pharmacy_data)
|
||||
session.add(pharmacy)
|
||||
|
||||
session.commit()
|
||||
session.refresh(pharmacy)
|
||||
return pharmacy
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# ==========================================
|
||||
# Machine Management
|
||||
# ==========================================
|
||||
|
||||
def sync_machine_from_headscale(self, headscale_node_data: Dict[str, Any]) -> MachineProfile:
|
||||
"""Headscale 노드 데이터로 머신 프로필 동기화"""
|
||||
session = self.get_session()
|
||||
try:
|
||||
machine = session.query(MachineProfile).filter(
|
||||
MachineProfile.headscale_node_id == headscale_node_data.get('id')
|
||||
).first()
|
||||
|
||||
if machine:
|
||||
# 기존 머신 업데이트
|
||||
machine.hostname = headscale_node_data.get('hostname')
|
||||
machine.tailscale_ip = headscale_node_data.get('ipv4')
|
||||
machine.tailscale_status = 'online' if headscale_node_data.get('is_online') else 'offline'
|
||||
machine.last_seen = datetime.now()
|
||||
machine.updated_at = datetime.now()
|
||||
else:
|
||||
# 새 머신 생성
|
||||
machine = MachineProfile(
|
||||
headscale_node_id=headscale_node_data.get('id'),
|
||||
headscale_machine_key=headscale_node_data.get('machine_key'),
|
||||
hostname=headscale_node_data.get('hostname'),
|
||||
machine_name=headscale_node_data.get('hostname'),
|
||||
tailscale_ip=headscale_node_data.get('ipv4'),
|
||||
tailscale_status='online' if headscale_node_data.get('is_online') else 'offline',
|
||||
last_seen=datetime.now()
|
||||
)
|
||||
session.add(machine)
|
||||
|
||||
session.commit()
|
||||
session.refresh(machine)
|
||||
return machine
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def get_machine_stats(self) -> Dict[str, int]:
|
||||
"""머신 통계 조회"""
|
||||
session = self.get_session()
|
||||
try:
|
||||
total = session.query(MachineProfile).count()
|
||||
online = session.query(MachineProfile).filter(
|
||||
MachineProfile.tailscale_status == 'online'
|
||||
).count()
|
||||
|
||||
return {
|
||||
'total': total,
|
||||
'online': online,
|
||||
'offline': total - online
|
||||
}
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Factory Function
|
||||
# ==========================================
|
||||
|
||||
def create_farmq_database_manager(database_url: str = None) -> FarmqDatabaseManager:
|
||||
"""FARMQ 데이터베이스 매니저 생성"""
|
||||
if database_url is None:
|
||||
database_url = "sqlite:///farmq.sqlite"
|
||||
|
||||
return FarmqDatabaseManager(database_url)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 테스트 실행
|
||||
manager = create_farmq_database_manager()
|
||||
|
||||
print("✅ FARMQ 데이터베이스 매니저 생성 완료")
|
||||
print(f"📊 데이터베이스 URL: {manager.database_url}")
|
||||
|
||||
# 테스트 데이터 생성
|
||||
test_pharmacy = {
|
||||
'headscale_user_name': 'test-pharmacy',
|
||||
'pharmacy_name': '테스트 약국',
|
||||
'business_number': '123-45-67890',
|
||||
'manager_name': '김약사',
|
||||
'phone': '02-1234-5678',
|
||||
'address': '서울특별시 강남구 테스트동 123',
|
||||
'proxmox_host': '192.168.1.100'
|
||||
}
|
||||
|
||||
pharmacy = manager.create_or_update_pharmacy(test_pharmacy)
|
||||
print(f"✅ 테스트 약국 생성: {pharmacy}")
|
||||
|
||||
stats = manager.get_machine_stats()
|
||||
print(f"📈 머신 통계: {stats}")
|
||||
385
farmq-admin/models/headscale_models.py
Normal file
385
farmq-admin/models/headscale_models.py
Normal file
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
Headscale SQLite Database Models for SQLAlchemy
|
||||
Based on actual schema analysis of Headscale v0.23.0
|
||||
|
||||
Generated from: /var/lib/headscale/db.sqlite
|
||||
Schema Analysis Date: 2025-09-09
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
import json
|
||||
from sqlalchemy import (
|
||||
Column, Integer, String, DateTime, Boolean, Text,
|
||||
ForeignKey, LargeBinary, Index, UniqueConstraint
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship, Session
|
||||
from sqlalchemy.types import TypeDecorator, TEXT
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class JSONType(TypeDecorator):
|
||||
"""Custom JSON type for SQLAlchemy that handles JSON serialization"""
|
||||
impl = TEXT
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if value is not None:
|
||||
return json.dumps(value)
|
||||
return value
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
if value is not None:
|
||||
return json.loads(value)
|
||||
return value
|
||||
|
||||
|
||||
class Migration(Base):
|
||||
"""Migration tracking table"""
|
||||
__tablename__ = 'migrations'
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Migration(id='{self.id}')>"
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""Headscale Users table
|
||||
|
||||
Represents individual users/namespaces in the Headscale network.
|
||||
Each user can have multiple nodes (machines) associated with them.
|
||||
"""
|
||||
__tablename__ = 'users'
|
||||
__table_args__ = (
|
||||
Index('idx_users_deleted_at', 'deleted_at'),
|
||||
Index('idx_provider_identifier', 'provider_identifier',
|
||||
postgresql_where="provider_identifier IS NOT NULL"),
|
||||
Index('idx_name_provider_identifier', 'name', 'provider_identifier'),
|
||||
Index('idx_name_no_provider_identifier', 'name',
|
||||
postgresql_where="provider_identifier IS NULL"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
created_at = Column(DateTime)
|
||||
updated_at = Column(DateTime)
|
||||
deleted_at = Column(DateTime) # Soft delete
|
||||
name = Column(String) # User identifier (e.g., "myuser")
|
||||
display_name = Column(String) # Human-readable display name
|
||||
email = Column(String) # User email address
|
||||
provider_identifier = Column(String) # External auth provider ID
|
||||
provider = Column(String) # Auth provider name (OIDC, etc.)
|
||||
profile_pic_url = Column(String) # Profile picture URL
|
||||
|
||||
# Relationships
|
||||
nodes = relationship("Node", back_populates="user", cascade="all, delete-orphan")
|
||||
pre_auth_keys = relationship("PreAuthKey", back_populates="user")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(id={self.id}, name='{self.name}', display_name='{self.display_name}')>"
|
||||
|
||||
def is_deleted(self) -> bool:
|
||||
"""Check if user is soft-deleted"""
|
||||
return self.deleted_at is not None
|
||||
|
||||
|
||||
class Node(Base):
|
||||
"""Headscale Nodes (Machines) table
|
||||
|
||||
Represents individual devices/machines connected to the Tailnet.
|
||||
Each node belongs to a user and has various networking attributes.
|
||||
"""
|
||||
__tablename__ = 'nodes'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
machine_key = Column(String) # Machine's public key
|
||||
node_key = Column(String) # Node's network key
|
||||
disco_key = Column(String) # Discovery key for peer-to-peer connections
|
||||
endpoints = Column(JSONType) # List of network endpoints (JSON array)
|
||||
host_info = Column(JSONType) # Detailed host information (JSON object)
|
||||
ipv4 = Column(String) # Assigned IPv4 address (e.g., "100.64.0.1")
|
||||
ipv6 = Column(String) # Assigned IPv6 address
|
||||
hostname = Column(String) # Machine hostname
|
||||
given_name = Column(String) # User-assigned machine name
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
|
||||
register_method = Column(String) # Registration method (e.g., "authkey")
|
||||
forced_tags = Column(JSONType) # Tags forced on this node (JSON array)
|
||||
auth_key_id = Column(Integer, ForeignKey('pre_auth_keys.id'))
|
||||
expiry = Column(DateTime) # Node expiration date
|
||||
last_seen = Column(DateTime) # Last activity timestamp
|
||||
approved_routes = Column(JSONType) # Approved subnet routes (JSON array)
|
||||
created_at = Column(DateTime)
|
||||
updated_at = Column(DateTime)
|
||||
deleted_at = Column(DateTime) # Soft delete
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="nodes")
|
||||
auth_key = relationship("PreAuthKey")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Node(id={self.id}, hostname='{self.hostname}', ipv4='{self.ipv4}', user_id={self.user_id})>"
|
||||
|
||||
def is_online(self, timeout_minutes: int = 1440) -> bool: # 24 hours timeout like Tailscale
|
||||
"""Check if node is considered online based on last_seen"""
|
||||
if not self.last_seen:
|
||||
return False
|
||||
|
||||
# Handle timezone-aware datetime properly
|
||||
now = datetime.now()
|
||||
last_seen = self.last_seen
|
||||
|
||||
# Convert both to naive datetime to avoid timezone issues
|
||||
if last_seen.tzinfo is not None:
|
||||
last_seen = last_seen.replace(tzinfo=None)
|
||||
if now.tzinfo is not None:
|
||||
now = now.replace(tzinfo=None)
|
||||
|
||||
try:
|
||||
time_diff_seconds = (now - last_seen).total_seconds()
|
||||
# Consider online if last seen within timeout_minutes
|
||||
is_recent = time_diff_seconds < (timeout_minutes * 60)
|
||||
return is_recent
|
||||
except TypeError as e:
|
||||
# Fallback: just check if we have a recent timestamp
|
||||
return True
|
||||
|
||||
def get_host_info(self) -> dict:
|
||||
"""Get parsed host information"""
|
||||
return self.host_info or {}
|
||||
|
||||
def get_endpoints(self) -> List[str]:
|
||||
"""Get list of network endpoints"""
|
||||
return self.endpoints or []
|
||||
|
||||
def get_forced_tags(self) -> List[str]:
|
||||
"""Get list of forced tags"""
|
||||
return self.forced_tags or []
|
||||
|
||||
def get_approved_routes(self) -> List[str]:
|
||||
"""Get list of approved routes"""
|
||||
return self.approved_routes or []
|
||||
|
||||
|
||||
class PreAuthKey(Base):
|
||||
"""Pre-authentication keys table
|
||||
|
||||
Keys used for automatic node registration without manual approval.
|
||||
"""
|
||||
__tablename__ = 'pre_auth_keys'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
key = Column(String) # The actual pre-auth key string
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'))
|
||||
reusable = Column(Boolean) # Can be used multiple times
|
||||
ephemeral = Column(Boolean, default=False) # Temporary key
|
||||
used = Column(Boolean, default=False) # Has been used
|
||||
tags = Column(JSONType) # Tags to apply to nodes using this key
|
||||
created_at = Column(DateTime)
|
||||
expiration = Column(DateTime) # When the key expires
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="pre_auth_keys")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PreAuthKey(id={self.id}, key='{self.key[:8]}...', user_id={self.user_id}, reusable={self.reusable})>"
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if the pre-auth key is expired"""
|
||||
if not self.expiration:
|
||||
return False
|
||||
|
||||
now = datetime.now()
|
||||
expiration = self.expiration
|
||||
|
||||
# Handle timezone-aware datetime
|
||||
if expiration.tzinfo is not None and now.tzinfo is None:
|
||||
from datetime import timezone
|
||||
now = now.replace(tzinfo=timezone.utc)
|
||||
elif expiration.tzinfo is not None:
|
||||
expiration = expiration.replace(tzinfo=None)
|
||||
|
||||
try:
|
||||
return now > expiration
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
"""Check if the key is still valid for use"""
|
||||
if self.is_expired():
|
||||
return False
|
||||
if self.used and not self.reusable:
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_tags(self) -> List[str]:
|
||||
"""Get list of tags for this key"""
|
||||
return self.tags or []
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
"""API Keys table
|
||||
|
||||
Keys used for API authentication to the Headscale server.
|
||||
"""
|
||||
__tablename__ = 'api_keys'
|
||||
__table_args__ = (
|
||||
Index('idx_api_keys_prefix', 'prefix', unique=True),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
prefix = Column(String) # Key prefix for identification (e.g., "8qRr1IB")
|
||||
hash = Column(LargeBinary) # Hashed key value
|
||||
created_at = Column(DateTime)
|
||||
expiration = Column(DateTime) # When the key expires
|
||||
last_seen = Column(DateTime) # Last time key was used
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ApiKey(id={self.id}, prefix='{self.prefix}', created_at='{self.created_at}')>"
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if the API key is expired"""
|
||||
if not self.expiration:
|
||||
return False
|
||||
|
||||
now = datetime.now()
|
||||
expiration = self.expiration
|
||||
|
||||
# Handle timezone-aware datetime
|
||||
if expiration.tzinfo is not None and now.tzinfo is None:
|
||||
from datetime import timezone
|
||||
now = now.replace(tzinfo=timezone.utc)
|
||||
elif expiration.tzinfo is not None:
|
||||
expiration = expiration.replace(tzinfo=None)
|
||||
|
||||
try:
|
||||
return now > expiration
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
|
||||
class Policy(Base):
|
||||
"""ACL Policies table
|
||||
|
||||
Stores Access Control List policies in JSON format.
|
||||
"""
|
||||
__tablename__ = 'policies'
|
||||
__table_args__ = (
|
||||
Index('idx_policies_deleted_at', 'deleted_at'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
created_at = Column(DateTime)
|
||||
updated_at = Column(DateTime)
|
||||
deleted_at = Column(DateTime) # Soft delete
|
||||
data = Column(Text) # JSON policy data
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Policy(id={self.id}, created_at='{self.created_at}')>"
|
||||
|
||||
def get_policy_data(self) -> dict:
|
||||
"""Parse and return policy data as dictionary"""
|
||||
try:
|
||||
return json.loads(self.data) if self.data else {}
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Extended Models for FARMQ Customization
|
||||
# ==========================================
|
||||
|
||||
# ==========================================
|
||||
# Extended Models for FARMQ Customization - DEPRECATED
|
||||
# ==========================================
|
||||
#
|
||||
# 주의: 이 모델들은 Headscale과 외래키 충돌을 일으키므로 더 이상 사용하지 않습니다.
|
||||
# 대신 farmq_models.py의 독립적인 모델들을 사용하세요.
|
||||
#
|
||||
# 마이그레이션 가이드:
|
||||
# - PharmacyInfo -> farmq_models.PharmacyInfo
|
||||
# - MachineSpecs -> farmq_models.MachineProfile
|
||||
# - MonitoringData -> farmq_models.MonitoringMetrics
|
||||
#
|
||||
# 이 클래스들은 하위 호환성을 위해 유지되지만 실제 테이블은 생성되지 않습니다.
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Database Helper Functions
|
||||
# ==========================================
|
||||
|
||||
def create_all_tables(engine):
|
||||
"""Create all tables in the database"""
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
|
||||
def get_active_nodes(session: Session) -> List[Node]:
|
||||
"""Get all non-deleted nodes"""
|
||||
return session.query(Node).filter(Node.deleted_at.is_(None)).all()
|
||||
|
||||
|
||||
def get_online_nodes(session: Session, timeout_minutes: int = 5) -> List[Node]:
|
||||
"""Get nodes that are currently online"""
|
||||
cutoff_time = datetime.now() - timedelta(minutes=timeout_minutes)
|
||||
return session.query(Node).filter(
|
||||
Node.deleted_at.is_(None),
|
||||
Node.last_seen > cutoff_time
|
||||
).all()
|
||||
|
||||
|
||||
def get_user_with_pharmacy_info(session: Session, user_name: str):
|
||||
"""Get user with associated pharmacy information"""
|
||||
return session.query(User).join(PharmacyInfo).filter(User.name == user_name).first()
|
||||
|
||||
|
||||
def get_node_with_specs_and_monitoring(session: Session, node_id: int):
|
||||
"""Get node with hardware specs and latest monitoring data"""
|
||||
return session.query(Node)\
|
||||
.outerjoin(MachineSpecs)\
|
||||
.outerjoin(MonitoringData)\
|
||||
.filter(Node.id == node_id)\
|
||||
.first()
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Usage Example
|
||||
# ==========================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# SQLite connection to Headscale database
|
||||
DATABASE_URL = "sqlite:///data/db.sqlite"
|
||||
engine = create_engine(DATABASE_URL)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# Create extended tables (if needed)
|
||||
create_all_tables(engine)
|
||||
|
||||
# Example usage
|
||||
session = SessionLocal()
|
||||
try:
|
||||
# Get all users
|
||||
users = session.query(User).all()
|
||||
print("=== Users ===")
|
||||
for user in users:
|
||||
print(f" {user}")
|
||||
|
||||
# Get all nodes
|
||||
nodes = session.query(Node).all()
|
||||
print("\n=== Nodes ===")
|
||||
for node in nodes:
|
||||
print(f" {node}")
|
||||
print(f" Online: {node.is_online()}")
|
||||
print(f" Host Info: {node.get_host_info().get('Hostname', 'Unknown')}")
|
||||
|
||||
# Get all API keys
|
||||
api_keys = session.query(ApiKey).all()
|
||||
print("\n=== API Keys ===")
|
||||
for key in api_keys:
|
||||
print(f" {key}")
|
||||
print(f" Expired: {key.is_expired()}")
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
8
farmq-admin/requirements.txt
Normal file
8
farmq-admin/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
Flask==3.0.0
|
||||
SQLAlchemy==2.0.23
|
||||
Jinja2==3.1.2
|
||||
Flask-Login==0.6.3
|
||||
APScheduler==3.10.4
|
||||
requests==2.31.0
|
||||
python-dateutil==2.8.2
|
||||
humanize==4.8.0
|
||||
341
farmq-admin/templates/base.html
Normal file
341
farmq-admin/templates/base.html
Normal file
@@ -0,0 +1,341 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}팜큐 약국 관리 시스템{% endblock %}</title>
|
||||
|
||||
<!-- Bootstrap 5 CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Font Awesome -->
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<!-- Chart.js -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<style>
|
||||
:root {
|
||||
--farmq-primary: #2c5282;
|
||||
--farmq-secondary: #4299e1;
|
||||
--farmq-success: #48bb78;
|
||||
--farmq-warning: #ed8936;
|
||||
--farmq-danger: #f56565;
|
||||
--farmq-light: #f7fafc;
|
||||
--farmq-dark: #2d3748;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--farmq-light);
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: bold;
|
||||
color: var(--farmq-primary) !important;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
min-height: calc(100vh - 56px);
|
||||
background: linear-gradient(180deg, var(--farmq-primary) 0%, var(--farmq-secondary) 100%);
|
||||
}
|
||||
|
||||
.sidebar .nav-link {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 8px;
|
||||
margin: 2px 0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar .nav-link:hover,
|
||||
.sidebar .nav-link.active {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: none;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
border-radius: 12px;
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: linear-gradient(135deg, var(--farmq-primary) 0%, var(--farmq-secondary) 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stat-card .card-body {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.alert-item {
|
||||
border-left: 4px solid;
|
||||
border-radius: 0 8px 8px 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
border-left-color: var(--farmq-warning);
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
border-left-color: var(--farmq-danger);
|
||||
}
|
||||
|
||||
.status-online {
|
||||
color: var(--farmq-success);
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
color: var(--farmq-danger);
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
color: var(--farmq-warning);
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: var(--farmq-dark);
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
/* 반응형 디자인 */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<!-- 상단 네비게이션 -->
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-white shadow-sm">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('dashboard') }}">
|
||||
<i class="fas fa-hospital"></i> 팜큐 약국 관리 시스템
|
||||
</a>
|
||||
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="fas fa-user-circle"></i> 관리자
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="#"><i class="fas fa-cog"></i> 설정</a></li>
|
||||
<li><a class="dropdown-item" href="#"><i class="fas fa-question-circle"></i> 도움말</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="#"><i class="fas fa-sign-out-alt"></i> 로그아웃</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<!-- 사이드바 -->
|
||||
<nav class="col-md-3 col-lg-2 d-md-block sidebar collapse">
|
||||
<div class="position-sticky pt-3">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'dashboard' %}active{% endif %}" href="{{ url_for('dashboard') }}">
|
||||
<i class="fas fa-tachometer-alt"></i> 대시보드
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and 'pharmacy' in request.endpoint %}active{% endif %}" href="{{ url_for('pharmacy_list') }}">
|
||||
<i class="fas fa-store"></i> 약국 관리
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and 'machine' in request.endpoint %}active{% endif %}" href="{{ url_for('machine_list') }}">
|
||||
<i class="fas fa-desktop"></i> 머신 관리
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#">
|
||||
<i class="fas fa-users"></i> 사용자 관리
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#">
|
||||
<i class="fas fa-chart-line"></i> 모니터링
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#">
|
||||
<i class="fas fa-cog"></i> 설정
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<hr class="my-3" style="border-color: rgba(255,255,255,0.2);">
|
||||
|
||||
<!-- 빠른 링크 -->
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="http://localhost:3000/admin/" target="_blank">
|
||||
<i class="fas fa-external-link-alt"></i> Headplane UI
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#" onclick="refreshData()">
|
||||
<i class="fas fa-sync-alt"></i> 데이터 새로고침
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 메인 콘텐츠 -->
|
||||
<main class="col-md-9 ms-sm-auto col-lg-10 main-content">
|
||||
{% block breadcrumb %}{% endblock %}
|
||||
|
||||
<!-- 알림 메시지 -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 푸터 -->
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<span>© 2025 팜큐(FARMQ). Powered by Flask + Headscale</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Bootstrap 5 JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- 공통 JavaScript -->
|
||||
<script>
|
||||
// 데이터 새로고침
|
||||
function refreshData() {
|
||||
location.reload();
|
||||
}
|
||||
|
||||
// 실시간 업데이트 (5초마다)
|
||||
setInterval(function() {
|
||||
// 현재 페이지가 대시보드인 경우 실시간 업데이트
|
||||
if (window.location.pathname === '/') {
|
||||
updateDashboardStats();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
function updateDashboardStats() {
|
||||
fetch('/api/dashboard/stats')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// 통계 업데이트
|
||||
document.getElementById('total-pharmacies').textContent = data.total_pharmacies;
|
||||
document.getElementById('online-machines').textContent = data.online_machines;
|
||||
document.getElementById('offline-machines').textContent = data.offline_machines;
|
||||
document.getElementById('avg-temp').textContent = data.avg_cpu_temp + '°C';
|
||||
})
|
||||
.catch(error => console.error('Stats update failed:', error));
|
||||
}
|
||||
|
||||
// 차트 생성 함수
|
||||
function createDoughnutChart(elementId, value, label, color) {
|
||||
const ctx = document.getElementById(elementId);
|
||||
if (!ctx) return;
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: [value, 100 - value],
|
||||
backgroundColor: [color, '#e2e8f0'],
|
||||
borderWidth: 0
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '75%',
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 토스트 알림 표시
|
||||
function showToast(message, type = 'info') {
|
||||
const toastHtml = `
|
||||
<div class="toast align-items-center text-bg-${type} border-0" role="alert">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">${message}</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const toastContainer = document.getElementById('toast-container');
|
||||
if (toastContainer) {
|
||||
toastContainer.insertAdjacentHTML('beforeend', toastHtml);
|
||||
const toast = new bootstrap.Toast(toastContainer.lastElementChild);
|
||||
toast.show();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
|
||||
<!-- 토스트 컨테이너 -->
|
||||
<div id="toast-container" class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1200;"></div>
|
||||
</body>
|
||||
</html>
|
||||
277
farmq-admin/templates/dashboard/index.html
Normal file
277
farmq-admin/templates/dashboard/index.html
Normal file
@@ -0,0 +1,277 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}대시보드 - 팜큐 약국 관리 시스템{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item active">
|
||||
<i class="fas fa-tachometer-alt"></i> 대시보드
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h1 class="h2 mb-0">
|
||||
<i class="fas fa-tachometer-alt text-primary"></i>
|
||||
대시보드
|
||||
</h1>
|
||||
<p class="text-muted">팜큐 약국 네트워크 전체 현황</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 통계 카드 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-3 col-md-6 mb-3">
|
||||
<div class="card stat-card">
|
||||
<div class="card-body text-center">
|
||||
<div class="stat-number" id="total-pharmacies">{{ stats.total_pharmacies }}</div>
|
||||
<div class="stat-label">
|
||||
<i class="fas fa-store"></i> 총 약국 수
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-md-6 mb-3">
|
||||
<div class="card" style="background: linear-gradient(135deg, #48bb78 0%, #38a169 100%); color: white;">
|
||||
<div class="card-body text-center">
|
||||
<div class="stat-number" id="online-machines">{{ stats.online_machines }}</div>
|
||||
<div class="stat-label">
|
||||
<i class="fas fa-circle text-success"></i> 온라인 머신
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-md-6 mb-3">
|
||||
<div class="card" style="background: linear-gradient(135deg, #f56565 0%, #e53e3e 100%); color: white;">
|
||||
<div class="card-body text-center">
|
||||
<div class="stat-number" id="offline-machines">{{ stats.offline_machines }}</div>
|
||||
<div class="stat-label">
|
||||
<i class="fas fa-circle text-danger"></i> 오프라인 머신
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3 col-md-6 mb-3">
|
||||
<div class="card" style="background: linear-gradient(135deg, #ed8936 0%, #dd6b20 100%); color: white;">
|
||||
<div class="card-body text-center">
|
||||
<div class="stat-number" id="avg-temp">{{ stats.avg_cpu_temp }}°C</div>
|
||||
<div class="stat-label">
|
||||
<i class="fas fa-thermometer-half"></i> 평균 CPU 온도
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- 실시간 알림 -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-exclamation-triangle text-warning"></i> 실시간 알림
|
||||
</h5>
|
||||
<span class="badge bg-primary">{{ stats.alerts|length }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if stats.alerts %}
|
||||
{% for alert in stats.alerts %}
|
||||
<div class="alert-item p-3 mb-2 bg-light {% if alert.type == 'warning' %}alert-warning{% elif alert.type == 'danger' %}alert-danger{% endif %}">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>
|
||||
{% if alert.level == 'high_temperature' %}
|
||||
<i class="fas fa-thermometer-full text-danger"></i>
|
||||
{% elif alert.level == 'high_disk' %}
|
||||
<i class="fas fa-hdd text-warning"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
{% endif %}
|
||||
{{ alert.machine.hostname }}
|
||||
</strong>
|
||||
<div class="small text-muted">{{ alert.message }}</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<span class="badge bg-{{ alert.type }}">
|
||||
{{ alert.value }}{% if alert.level == 'high_temperature' %}°C{% else %}%{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="fas fa-check-circle fa-3x mb-3 text-success"></i>
|
||||
<p>모든 시스템이 정상 작동 중입니다.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 성능 차트 -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-chart-pie text-info"></i> 전체 성능 현황
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<div class="col-6 mb-3">
|
||||
<div class="position-relative">
|
||||
<canvas id="cpuChart" width="100" height="100"></canvas>
|
||||
<div class="position-absolute top-50 start-50 translate-middle">
|
||||
<div class="fw-bold">{{ "%.1f"|format(stats.avg_cpu_temp) }}°C</div>
|
||||
<div class="small text-muted">CPU</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<div class="position-relative">
|
||||
<canvas id="memoryChart" width="100" height="100"></canvas>
|
||||
<div class="position-absolute top-50 start-50 translate-middle">
|
||||
<div class="fw-bold">75.0%</div>
|
||||
<div class="small text-muted">메모리</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="position-relative">
|
||||
<canvas id="diskChart" width="100" height="100"></canvas>
|
||||
<div class="position-absolute top-50 start-50 translate-middle">
|
||||
<div class="fw-bold">60.0%</div>
|
||||
<div class="small text-muted">디스크</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="text-center">
|
||||
<div class="display-4">🌡️</div>
|
||||
<div class="fw-bold">{{ "%.1f"|format(stats.avg_cpu_temp) }}°C</div>
|
||||
<div class="small text-muted">평균 온도</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 약국별 상태 -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-store text-primary"></i> 약국별 상태
|
||||
</h5>
|
||||
<a href="{{ url_for('pharmacy_list') }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-list"></i> 전체 보기
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if pharmacies %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>약국명</th>
|
||||
<th>Headscale 사용자</th>
|
||||
<th>사업자번호</th>
|
||||
<th>연결된 머신</th>
|
||||
<th>온라인 상태</th>
|
||||
<th>액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for pharmacy_data in pharmacies %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ pharmacy_data.pharmacy_name }}</strong><br>
|
||||
<small class="text-muted">{{ pharmacy_data.manager_name }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<code class="text-primary">{{ pharmacy_data.headscale_user_name }}</code>
|
||||
</td>
|
||||
<td>{{ pharmacy_data.business_number }}</td>
|
||||
<td>
|
||||
<span class="badge bg-info">{{ pharmacy_data.machine_count }}대</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="progress me-2" style="width: 100px; height: 8px;">
|
||||
<div class="progress-bar bg-success"
|
||||
style="width: {{ (pharmacy_data.online_count / pharmacy_data.machine_count * 100) if pharmacy_data.machine_count > 0 else 0 }}%"></div>
|
||||
</div>
|
||||
<small>{{ pharmacy_data.online_count }}/{{ pharmacy_data.machine_count }}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{{ url_for('pharmacy_detail', pharmacy_id=pharmacy_data.id) }}"
|
||||
class="btn btn-outline-primary">상세</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="fas fa-store fa-3x mb-3"></i>
|
||||
<p>등록된 약국이 없습니다.</p>
|
||||
<a href="{{ url_for('pharmacy_list') }}" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> 약국 등록하기
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// 성능 차트 생성
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
createDoughnutChart('cpuChart', {{ stats.avg_cpu_temp }}, '온도', '#3b82f6');
|
||||
createDoughnutChart('memoryChart', 75, '메모리', '#10b981');
|
||||
createDoughnutChart('diskChart', 60, '디스크', '#f59e0b');
|
||||
});
|
||||
|
||||
// 실시간 알림 업데이트
|
||||
function updateAlerts() {
|
||||
fetch('/api/alerts')
|
||||
.then(response => response.json())
|
||||
.then(alerts => {
|
||||
// 알림 개수 업데이트
|
||||
const alertBadge = document.querySelector('.card-header .badge');
|
||||
if (alertBadge) {
|
||||
alertBadge.textContent = alerts.length;
|
||||
}
|
||||
|
||||
// 새로운 알림이 있으면 토스트 표시
|
||||
alerts.forEach(alert => {
|
||||
if (!document.querySelector(`[data-machine-id="${alert.machine.id}"]`)) {
|
||||
showToast(`${alert.machine.hostname}: ${alert.message}`, alert.type);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(error => console.error('Alert update failed:', error));
|
||||
}
|
||||
|
||||
// 알림 업데이트 (30초마다)
|
||||
setInterval(updateAlerts, 30000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
39
farmq-admin/templates/error.html
Normal file
39
farmq-admin/templates/error.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}오류 - 팜큐 약국 관리 시스템{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-body text-center py-5">
|
||||
<div class="mb-4">
|
||||
{% if error_code == 404 %}
|
||||
<i class="fas fa-search fa-5x text-warning mb-3"></i>
|
||||
<h1 class="display-4">404</h1>
|
||||
<h4>페이지를 찾을 수 없습니다</h4>
|
||||
{% elif error_code == 500 %}
|
||||
<i class="fas fa-exclamation-triangle fa-5x text-danger mb-3"></i>
|
||||
<h1 class="display-4">500</h1>
|
||||
<h4>내부 서버 오류</h4>
|
||||
{% else %}
|
||||
<i class="fas fa-times-circle fa-5x text-danger mb-3"></i>
|
||||
<h4>오류가 발생했습니다</h4>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<p class="text-muted mb-4">{{ error }}</p>
|
||||
|
||||
<div class="d-grid gap-2 d-md-block">
|
||||
<a href="{{ url_for('dashboard') }}" class="btn btn-primary">
|
||||
<i class="fas fa-home"></i> 대시보드로 돌아가기
|
||||
</a>
|
||||
<button onclick="history.back()" class="btn btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left"></i> 이전 페이지
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
388
farmq-admin/templates/machines/detail.html
Normal file
388
farmq-admin/templates/machines/detail.html
Normal file
@@ -0,0 +1,388 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}머신 상세 정보 - 팜큐 약국 관리 시스템{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('dashboard') }}">대시보드</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('machine_list') }}">머신 관리</a></li>
|
||||
<li class="breadcrumb-item active">{{ machine.given_name or machine.hostname }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- 머신 정보 헤더 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-3">
|
||||
{% if is_online %}
|
||||
<i class="fas fa-desktop fa-3x text-success"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-desktop fa-3x text-muted"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="h2 mb-0">{{ machine.given_name or machine.hostname }}</h1>
|
||||
<p class="text-muted mb-1">{{ machine.hostname }}</p>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
{% if is_online %}
|
||||
<span class="badge bg-success">
|
||||
<i class="fas fa-circle"></i> 온라인
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">
|
||||
<i class="fas fa-circle"></i> 오프라인
|
||||
</span>
|
||||
{% endif %}
|
||||
<small class="text-muted">마지막 접속: {{ last_seen_humanized }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary" onclick="refreshMachineDetail()">
|
||||
<i class="fas fa-sync-alt"></i> 새로고침
|
||||
</button>
|
||||
{% if is_online %}
|
||||
<button class="btn btn-outline-warning">
|
||||
<i class="fas fa-redo"></i> 재시작
|
||||
</button>
|
||||
{% endif %}
|
||||
<button class="btn btn-outline-info" onclick="showMonitoringModal()">
|
||||
<i class="fas fa-chart-line"></i> 실시간 모니터링
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 기본 정보 및 네트워크 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-info-circle"></i> 기본 정보</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-borderless">
|
||||
<tr>
|
||||
<th width="30%">머신 ID</th>
|
||||
<td>{{ machine.id }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>호스트명</th>
|
||||
<td>{{ machine.hostname }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>표시 이름</th>
|
||||
<td>{{ machine.given_name or '미설정' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>사용자</th>
|
||||
<td>
|
||||
{% if machine.user %}
|
||||
<span class="badge bg-primary">{{ machine.user.name }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">미지정</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>등록 방식</th>
|
||||
<td>{{ machine.register_method or '알 수 없음' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>등록일</th>
|
||||
<td>{{ machine.created_at.strftime('%Y년 %m월 %d일 %H:%M') if machine.created_at else '알 수 없음' }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-network-wired"></i> 네트워크 정보</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-borderless">
|
||||
<tr>
|
||||
<th width="30%">IPv4 주소</th>
|
||||
<td><code>{{ machine.ipv4 }}</code></td>
|
||||
</tr>
|
||||
{% if machine.ipv6 %}
|
||||
<tr>
|
||||
<th>IPv6 주소</th>
|
||||
<td><code class="small">{{ machine.ipv6 }}</code></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th>엔드포인트</th>
|
||||
<td>
|
||||
{% if machine.get_endpoints() %}
|
||||
<div class="small">
|
||||
{% for endpoint in machine.get_endpoints()[:3] %}
|
||||
<div><code>{{ endpoint }}</code></div>
|
||||
{% endfor %}
|
||||
{% if machine.get_endpoints()|length > 3 %}
|
||||
<div class="text-muted">... 및 {{ machine.get_endpoints()|length - 3 }}개 더</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-muted">없음</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>마지막 접속</th>
|
||||
<td>
|
||||
{% if machine.last_seen %}
|
||||
{{ machine.last_seen.strftime('%Y-%m-%d %H:%M:%S') }}
|
||||
<br><small class="text-muted">{{ last_seen_humanized }}</small>
|
||||
{% else %}
|
||||
<span class="text-muted">알 수 없음</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 하드웨어 사양 -->
|
||||
{% if specs %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-microchip"></i> 하드웨어 사양</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-microchip fa-2x text-primary mb-2"></i>
|
||||
<h6>CPU</h6>
|
||||
<p class="mb-1">{{ specs.cpu_model }}</p>
|
||||
<small class="text-muted">{{ specs.cpu_cores }}코어</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-memory fa-2x text-success mb-2"></i>
|
||||
<h6>메모리</h6>
|
||||
<p class="mb-1">{{ specs.ram_gb }}GB</p>
|
||||
<small class="text-muted">RAM</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-hdd fa-2x text-warning mb-2"></i>
|
||||
<h6>저장소</h6>
|
||||
<p class="mb-1">{{ specs.storage_gb }}GB</p>
|
||||
<small class="text-muted">디스크</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-wifi fa-2x text-info mb-2"></i>
|
||||
<h6>네트워크</h6>
|
||||
<p class="mb-1">{{ specs.network_speed }}Mbps</p>
|
||||
<small class="text-muted">{{ specs.os_info or '알 수 없음' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 현재 상태 모니터링 -->
|
||||
{% if latest_monitoring %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-chart-line"></i> 현재 상태</h5>
|
||||
<small class="text-muted">최종 업데이트: {{ latest_monitoring.collected_at.strftime('%Y-%m-%d %H:%M:%S') }}</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<canvas id="cpuChart" width="100" height="100"></canvas>
|
||||
<h6 class="mt-2">CPU 사용률</h6>
|
||||
<span class="h4">{{ "%.1f"|format(latest_monitoring.cpu_usage|float) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<canvas id="memoryChart" width="100" height="100"></canvas>
|
||||
<h6 class="mt-2">메모리 사용률</h6>
|
||||
<span class="h4">{{ "%.1f"|format(latest_monitoring.memory_usage|float) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<canvas id="diskChart" width="100" height="100"></canvas>
|
||||
<h6 class="mt-2">디스크 사용률</h6>
|
||||
<span class="h4">{{ "%.1f"|format(latest_monitoring.disk_usage|float) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<div class="mb-2">
|
||||
<i class="fas fa-thermometer-half fa-3x
|
||||
{% if latest_monitoring.cpu_temperature > 80 %}text-danger
|
||||
{% elif latest_monitoring.cpu_temperature > 70 %}text-warning
|
||||
{% else %}text-success{% endif %}"></i>
|
||||
</div>
|
||||
<h6>CPU 온도</h6>
|
||||
<span class="h4
|
||||
{% if latest_monitoring.cpu_temperature > 80 %}text-danger
|
||||
{% elif latest_monitoring.cpu_temperature > 70 %}text-warning
|
||||
{% else %}text-success{% endif %}">
|
||||
{{ latest_monitoring.cpu_temperature }}°C
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 소속 약국 정보 -->
|
||||
{% if pharmacy %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-store"></i> 소속 약국</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6>{{ pharmacy.pharmacy_name }}</h6>
|
||||
<p class="text-muted">{{ pharmacy.address or '주소 미등록' }}</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<strong>담당자:</strong> {{ pharmacy.manager_name or '미등록' }}
|
||||
</div>
|
||||
<div>
|
||||
<strong>연락처:</strong> {{ pharmacy.phone or '미등록' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<a href="{{ url_for('pharmacy_detail', pharmacy_id=pharmacy.id) }}"
|
||||
class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-eye"></i> 약국 상세 보기
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- 실시간 모니터링 모달 -->
|
||||
<div class="modal fade" id="monitoringModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-chart-line"></i> 실시간 모니터링 - {{ machine.hostname }}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<canvas id="realtimeCpuChart"></canvas>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<canvas id="realtimeMemoryChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 text-center">
|
||||
<div id="monitoringStatus" class="alert alert-info">
|
||||
실시간 데이터를 불러오는 중...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
let monitoringModal;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
monitoringModal = new bootstrap.Modal(document.getElementById('monitoringModal'));
|
||||
|
||||
{% if latest_monitoring %}
|
||||
// 도넛 차트 생성
|
||||
createDoughnutChart('cpuChart', {{ latest_monitoring.cpu_usage|float }}, 'CPU', '#007bff');
|
||||
createDoughnutChart('memoryChart', {{ latest_monitoring.memory_usage|float }}, 'Memory', '#28a745');
|
||||
createDoughnutChart('diskChart', {{ latest_monitoring.disk_usage|float }}, 'Disk', '#ffc107');
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
function refreshMachineDetail() {
|
||||
showToast('머신 정보를 새로고침 중...', 'info');
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function showMonitoringModal() {
|
||||
monitoringModal.show();
|
||||
loadRealtimeData();
|
||||
}
|
||||
|
||||
function loadRealtimeData() {
|
||||
fetch(`/api/machines/{{ machine.id }}/monitoring`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
document.getElementById('monitoringStatus').innerHTML =
|
||||
`<i class="fas fa-check-circle"></i> 최근 ${data.length}개 데이터 포인트 로드됨`;
|
||||
document.getElementById('monitoringStatus').className = 'alert alert-success';
|
||||
|
||||
// 실시간 차트 업데이트 (구현 예정)
|
||||
console.log('Monitoring data:', data);
|
||||
})
|
||||
.catch(error => {
|
||||
document.getElementById('monitoringStatus').innerHTML =
|
||||
`<i class="fas fa-exclamation-triangle"></i> 데이터 로드 실패: ${error.message}`;
|
||||
document.getElementById('monitoringStatus').className = 'alert alert-danger';
|
||||
});
|
||||
}
|
||||
|
||||
// 10초마다 현재 상태 업데이트
|
||||
setInterval(() => {
|
||||
if ({{ machine.id }}) {
|
||||
updateCurrentStatus({{ machine.id }});
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
function updateCurrentStatus(machineId) {
|
||||
// 실시간 상태 업데이트 구현 (향후)
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
397
farmq-admin/templates/machines/list.html
Normal file
397
farmq-admin/templates/machines/list.html
Normal file
@@ -0,0 +1,397 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}머신 관리 - 팜큐 약국 관리 시스템{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('dashboard') }}">대시보드</a></li>
|
||||
<li class="breadcrumb-item active">머신 관리</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h1 class="h2 mb-0">
|
||||
<i class="fas fa-desktop text-primary"></i>
|
||||
머신 관리
|
||||
</h1>
|
||||
<p class="text-muted">연결된 모든 머신의 상태 및 하드웨어 정보</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary" onclick="refreshMachineList()">
|
||||
<i class="fas fa-sync-alt"></i> 새로고침
|
||||
</button>
|
||||
<div class="btn-group" role="group">
|
||||
<input type="radio" class="btn-check" name="viewMode" id="listView" autocomplete="off" checked>
|
||||
<label class="btn btn-outline-primary" for="listView">
|
||||
<i class="fas fa-list"></i> 목록
|
||||
</label>
|
||||
|
||||
<input type="radio" class="btn-check" name="viewMode" id="cardView" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="cardView">
|
||||
<i class="fas fa-th-large"></i> 카드
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 및 검색 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-3 mb-2">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="fas fa-search"></i></span>
|
||||
<input type="text" class="form-control" id="searchMachine" placeholder="머신 검색...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 mb-2">
|
||||
<select class="form-select" id="filterStatus">
|
||||
<option value="">전체 상태</option>
|
||||
<option value="online">온라인</option>
|
||||
<option value="offline">오프라인</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2 mb-2">
|
||||
<select class="form-select" id="filterPharmacy">
|
||||
<option value="">전체 약국</option>
|
||||
<!-- 약국 목록은 동적으로 로드 -->
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 mb-2">
|
||||
<div class="d-flex gap-2">
|
||||
<span class="badge bg-success">온라인: <span id="onlineCount">0</span></span>
|
||||
<span class="badge bg-danger">오프라인: <span id="offlineCount">0</span></span>
|
||||
<span class="badge bg-secondary">전체: <span id="totalCount">0</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 머신 목록 (테이블 뷰) -->
|
||||
<div id="listView" class="machine-view">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{% if machines %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>머신 정보</th>
|
||||
<th>네트워크</th>
|
||||
<th>하드웨어</th>
|
||||
<th>상태</th>
|
||||
<th>소속 약국</th>
|
||||
<th>액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for machine_data in machines %}
|
||||
<tr class="machine-row" data-status="{% if machine_data.is_online %}online{% else %}offline{% endif %}">
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="me-3">
|
||||
{% if machine_data.is_online %}
|
||||
<i class="fas fa-desktop fa-2x text-success"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-desktop fa-2x text-muted"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{ machine_data.machine_name or machine_data.hostname }}</strong>
|
||||
<div class="small text-muted">{{ machine_data.hostname }}</div>
|
||||
<div class="small">
|
||||
<i class="fas fa-user"></i> {{ machine_data.headscale_user_name or '미지정' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>
|
||||
<code class="small">{{ machine_data.tailscale_ip }}</code>
|
||||
</div>
|
||||
{% if machine_data.ipv6 %}
|
||||
<div>
|
||||
<code class="small text-muted">{{ machine_data.ipv6 }}</code>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="small text-muted">
|
||||
엔드포인트: 0개
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if machine_data.specs %}
|
||||
<div class="small">
|
||||
<div><i class="fas fa-microchip"></i> {{ machine_data.specs.cpu_model[:20] }}{% if machine_data.specs.cpu_model|length > 20 %}...{% endif %}</div>
|
||||
<div><i class="fas fa-memory"></i> {{ machine_data.specs.ram_gb }}GB RAM</div>
|
||||
<div><i class="fas fa-hdd"></i> {{ machine_data.specs.storage_gb }}GB</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-muted small">정보 없음</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex flex-column">
|
||||
{% if machine_data.is_online %}
|
||||
<span class="badge bg-success mb-1">
|
||||
<i class="fas fa-circle"></i> 온라인
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger mb-1">
|
||||
<i class="fas fa-circle"></i> 오프라인
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if machine_data.latest_monitoring %}
|
||||
<div class="small">
|
||||
<div>CPU: {{ machine_data.latest_monitoring.cpu_usage }}%</div>
|
||||
<div>온도: {{ machine_data.latest_monitoring.cpu_temperature }}°C</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if machine_data.pharmacy %}
|
||||
<div>
|
||||
<strong>{{ machine_data.pharmacy.pharmacy_name }}</strong>
|
||||
<div class="small text-muted">{{ machine_data.pharmacy.manager_name }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-muted">미지정</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{{ url_for('machine_detail', machine_id=machine_data.id) }}"
|
||||
class="btn btn-outline-primary" title="상세 정보">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<button class="btn btn-outline-info"
|
||||
onclick="showMonitoring({{ machine_data.id }})" title="모니터링">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
</button>
|
||||
{% if machine_data.is_online %}
|
||||
<button class="btn btn-outline-warning" title="재시작">
|
||||
<i class="fas fa-redo"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="fas fa-desktop fa-4x mb-4 text-secondary"></i>
|
||||
<h4>연결된 머신이 없습니다</h4>
|
||||
<p class="mb-4">아직 등록된 머신이 없습니다. Headscale에 머신을 연결해주세요.</p>
|
||||
<a href="http://localhost:3000/admin/" target="_blank" class="btn btn-primary">
|
||||
<i class="fas fa-external-link-alt"></i> Headplane에서 머신 등록
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 머신 목록 (카드 뷰) -->
|
||||
<div id="cardView" class="machine-view d-none">
|
||||
<div class="row">
|
||||
{% for machine_data in machines %}
|
||||
<div class="col-lg-4 col-md-6 mb-4">
|
||||
<div class="card h-100 machine-card" data-status="{% if machine_data.is_online %}online{% else %}offline{% endif %}">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h5 class="card-title mb-1">{{ machine_data.machine_name or machine_data.hostname }}</h5>
|
||||
<p class="card-text text-muted small">{{ machine_data.hostname }}</p>
|
||||
</div>
|
||||
{% if machine_data.is_online %}
|
||||
<span class="badge bg-success">온라인</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">오프라인</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="small mb-2">
|
||||
<i class="fas fa-network-wired"></i> {{ machine_data.tailscale_ip }}
|
||||
</div>
|
||||
{% if machine_data.pharmacy %}
|
||||
<div class="small mb-2">
|
||||
<i class="fas fa-store"></i> {{ machine_data.pharmacy.pharmacy_name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="small">
|
||||
<i class="fas fa-clock"></i> {{ machine_data.last_seen_humanized }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if machine_data.specs %}
|
||||
<div class="mb-3">
|
||||
<hr>
|
||||
<div class="row text-center">
|
||||
<div class="col-4">
|
||||
<div class="small text-muted">CPU</div>
|
||||
<div class="small">{{ machine_data.specs.cpu_cores }}코어</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="small text-muted">RAM</div>
|
||||
<div class="small">{{ machine_data.specs.ram_gb }}GB</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="small text-muted">Storage</div>
|
||||
<div class="small">{{ machine_data.specs.storage_gb }}GB</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if machine_data.latest_monitoring %}
|
||||
<div class="mb-3">
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<div class="small text-muted">CPU 사용률</div>
|
||||
<div class="progress" style="height: 6px;">
|
||||
<div class="progress-bar bg-primary"
|
||||
style="width: {{ machine_data.latest_monitoring.cpu_usage }}%"></div>
|
||||
</div>
|
||||
<div class="small">{{ machine_data.latest_monitoring.cpu_usage }}%</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="small text-muted">온도</div>
|
||||
<div class="text-center">
|
||||
<span class="h6 {% if machine_data.latest_monitoring.cpu_temperature > 80 %}text-danger{% elif machine_data.latest_monitoring.cpu_temperature > 70 %}text-warning{% else %}text-success{% endif %}">
|
||||
{{ machine_data.latest_monitoring.cpu_temperature }}°C
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-footer bg-transparent">
|
||||
<div class="d-grid gap-2 d-md-block">
|
||||
<a href="{{ url_for('machine_detail', machine_id=machine_data.id) }}"
|
||||
class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-eye"></i> 상세
|
||||
</a>
|
||||
<button class="btn btn-outline-info btn-sm"
|
||||
onclick="showMonitoring({{ machine_data.id }})">
|
||||
<i class="fas fa-chart-line"></i> 모니터링
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// 뷰 모드 전환
|
||||
document.querySelectorAll('input[name="viewMode"]').forEach(radio => {
|
||||
radio.addEventListener('change', function() {
|
||||
document.querySelectorAll('.machine-view').forEach(view => {
|
||||
view.classList.add('d-none');
|
||||
});
|
||||
|
||||
if (this.id === 'listView') {
|
||||
document.getElementById('listView').classList.remove('d-none');
|
||||
} else {
|
||||
document.getElementById('cardView').classList.remove('d-none');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 머신 검색
|
||||
document.getElementById('searchMachine').addEventListener('input', function() {
|
||||
const searchTerm = this.value.toLowerCase();
|
||||
filterMachines();
|
||||
});
|
||||
|
||||
// 상태 필터
|
||||
document.getElementById('filterStatus').addEventListener('change', function() {
|
||||
filterMachines();
|
||||
});
|
||||
|
||||
function filterMachines() {
|
||||
const searchTerm = document.getElementById('searchMachine').value.toLowerCase();
|
||||
const statusFilter = document.getElementById('filterStatus').value;
|
||||
|
||||
let visibleCount = 0;
|
||||
let onlineCount = 0;
|
||||
let offlineCount = 0;
|
||||
|
||||
document.querySelectorAll('.machine-row, .machine-card').forEach(element => {
|
||||
const machineText = element.textContent.toLowerCase();
|
||||
const machineStatus = element.dataset.status;
|
||||
|
||||
let showElement = true;
|
||||
|
||||
// 검색어 필터
|
||||
if (searchTerm && !machineText.includes(searchTerm)) {
|
||||
showElement = false;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter && machineStatus !== statusFilter) {
|
||||
showElement = false;
|
||||
}
|
||||
|
||||
if (showElement) {
|
||||
element.style.display = '';
|
||||
visibleCount++;
|
||||
if (machineStatus === 'online') onlineCount++;
|
||||
else offlineCount++;
|
||||
} else {
|
||||
element.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// 카운터 업데이트
|
||||
document.getElementById('onlineCount').textContent = onlineCount;
|
||||
document.getElementById('offlineCount').textContent = offlineCount;
|
||||
document.getElementById('totalCount').textContent = visibleCount;
|
||||
}
|
||||
|
||||
// 모니터링 모달
|
||||
function showMonitoring(machineId) {
|
||||
// TODO: 모니터링 모달 구현
|
||||
showToast(`머신 ${machineId} 모니터링 기능 준비 중`, 'info');
|
||||
}
|
||||
|
||||
// 머신 목록 새로고침
|
||||
function refreshMachineList() {
|
||||
showToast('머신 목록을 새로고침 중...', 'info');
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// 초기 카운터 설정
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
filterMachines();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
238
farmq-admin/templates/pharmacy/list.html
Normal file
238
farmq-admin/templates/pharmacy/list.html
Normal file
@@ -0,0 +1,238 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}약국 관리 - 팜큐 약국 관리 시스템{% endblock %}
|
||||
|
||||
{% block breadcrumb %}
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('dashboard') }}">대시보드</a></li>
|
||||
<li class="breadcrumb-item active">약국 관리</li>
|
||||
</ol>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h1 class="h2 mb-0">
|
||||
<i class="fas fa-store text-primary"></i>
|
||||
약국 관리
|
||||
</h1>
|
||||
<p class="text-muted">등록된 약국 정보 및 연결 상태 관리</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="showAddModal()">
|
||||
<i class="fas fa-plus"></i> 새 약국 등록
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{% if pharmacies %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>약국 정보</th>
|
||||
<th>담당자</th>
|
||||
<th>연결된 머신</th>
|
||||
<th>네트워크 상태</th>
|
||||
<th>액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for pharmacy_data in pharmacies %}
|
||||
<tr>
|
||||
<td>
|
||||
<div>
|
||||
<strong class="d-block">{{ pharmacy_data.pharmacy_name }}</strong>
|
||||
<small class="text-muted">{{ pharmacy_data.business_number }}</small>
|
||||
<div class="small mt-1">
|
||||
<code class="text-primary">{{ pharmacy_data.headscale_user_name }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="small text-muted mt-1">
|
||||
<i class="fas fa-map-marker-alt"></i> {{ pharmacy_data.address or '주소 미등록' }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>
|
||||
<strong>{{ pharmacy_data.manager_name or '미등록' }}</strong>
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
<i class="fas fa-phone"></i> {{ pharmacy_data.phone or '연락처 미등록' }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="badge bg-info me-2">{{ pharmacy_data.machine_count }}대</span>
|
||||
<div class="progress" style="width: 60px; height: 8px;">
|
||||
<div class="progress-bar bg-success"
|
||||
style="width: {{ (pharmacy_data.online_count / pharmacy_data.machine_count * 100) if pharmacy_data.machine_count > 0 else 0 }}%"
|
||||
title="{{ pharmacy_data.online_count }}/{{ pharmacy_data.machine_count }} 온라인"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
온라인: {{ pharmacy_data.online_count }} / 오프라인: {{ pharmacy_data.offline_count }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if pharmacy_data.online_count == pharmacy_data.machine_count and pharmacy_data.machine_count > 0 %}
|
||||
<span class="badge bg-success">
|
||||
<i class="fas fa-check-circle"></i> 모든 머신 온라인
|
||||
</span>
|
||||
{% elif pharmacy_data.online_count > 0 %}
|
||||
<span class="badge bg-warning">
|
||||
<i class="fas fa-exclamation-triangle"></i> 부분적 연결
|
||||
</span>
|
||||
{% elif pharmacy_data.machine_count > 0 %}
|
||||
<span class="badge bg-danger">
|
||||
<i class="fas fa-times-circle"></i> 전체 오프라인
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">
|
||||
<i class="fas fa-question-circle"></i> 머신 없음
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{{ url_for('pharmacy_detail', pharmacy_id=pharmacy_data.id) }}"
|
||||
class="btn btn-outline-primary" title="상세 정보">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<button class="btn btn-outline-warning"
|
||||
onclick="showEditModal({{ pharmacy_data.id }})" title="수정">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button class="btn btn-outline-info" title="모니터링">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="fas fa-store fa-4x mb-4 text-secondary"></i>
|
||||
<h4>등록된 약국이 없습니다</h4>
|
||||
<p class="mb-4">첫 번째 약국을 등록하여 시작해보세요.</p>
|
||||
<button class="btn btn-primary btn-lg" onclick="showAddModal()">
|
||||
<i class="fas fa-plus"></i> 첫 번째 약국 등록
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 약국 등록/수정 모달 -->
|
||||
<div class="modal fade" id="pharmacyModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="pharmacyModalTitle">
|
||||
<i class="fas fa-store"></i> 약국 정보
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form id="pharmacyForm">
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="pharmacy_name" class="form-label">약국명 <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" id="pharmacy_name" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="business_number" class="form-label">사업자번호</label>
|
||||
<input type="text" class="form-control" id="business_number" placeholder="000-00-00000">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="manager_name" class="form-label">담당자명</label>
|
||||
<input type="text" class="form-control" id="manager_name">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="phone" class="form-label">전화번호</label>
|
||||
<input type="tel" class="form-control" id="phone" placeholder="000-0000-0000">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="address" class="form-label">주소</label>
|
||||
<textarea class="form-control" id="address" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="proxmox_host" class="form-label">Proxmox 호스트 IP</label>
|
||||
<input type="text" class="form-control" id="proxmox_host" placeholder="192.168.1.100">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="user_id" class="form-label">연결된 사용자 ID</label>
|
||||
<input type="text" class="form-control" id="user_id" placeholder="user1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save"></i> 저장
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
let pharmacyModal;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
pharmacyModal = new bootstrap.Modal(document.getElementById('pharmacyModal'));
|
||||
});
|
||||
|
||||
function showAddModal() {
|
||||
document.getElementById('pharmacyModalTitle').innerHTML =
|
||||
'<i class="fas fa-plus"></i> 새 약국 등록';
|
||||
document.getElementById('pharmacyForm').reset();
|
||||
pharmacyModal.show();
|
||||
}
|
||||
|
||||
function showEditModal(pharmacyId) {
|
||||
document.getElementById('pharmacyModalTitle').innerHTML =
|
||||
'<i class="fas fa-edit"></i> 약국 정보 수정';
|
||||
|
||||
// TODO: 기존 데이터를 로드하여 폼에 채우기
|
||||
// fetch(`/api/pharmacy/${pharmacyId}`)
|
||||
|
||||
pharmacyModal.show();
|
||||
}
|
||||
|
||||
document.getElementById('pharmacyForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
const data = Object.fromEntries(formData);
|
||||
|
||||
// TODO: API를 통한 약국 정보 저장
|
||||
showToast('약국 정보가 저장되었습니다.', 'success');
|
||||
pharmacyModal.hide();
|
||||
|
||||
// 페이지 새로고침 (임시)
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
});
|
||||
|
||||
// 테이블 정렬 및 검색 기능 추가 (향후)
|
||||
</script>
|
||||
{% endblock %}
|
||||
1
farmq-admin/utils/__init__.py
Normal file
1
farmq-admin/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Utils package
|
||||
240
farmq-admin/utils/database.py
Normal file
240
farmq-admin/utils/database.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
데이터베이스 연결 및 유틸리티 함수
|
||||
"""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, scoped_session
|
||||
from models import Base, User, Node, PharmacyInfo, MachineSpecs, MonitoringData
|
||||
from datetime import datetime, timedelta
|
||||
import humanize
|
||||
from typing import List, Optional
|
||||
|
||||
# 글로벌 세션 관리
|
||||
db_session = scoped_session(sessionmaker())
|
||||
|
||||
def init_database(database_url: str):
|
||||
"""데이터베이스 초기화"""
|
||||
engine = create_engine(database_url, echo=False)
|
||||
db_session.configure(bind=engine)
|
||||
Base.metadata.bind = engine
|
||||
|
||||
# 확장 테이블 생성 (기존 테이블은 건드리지 않음)
|
||||
try:
|
||||
Base.metadata.create_all(engine)
|
||||
print("✅ Database initialized successfully")
|
||||
except Exception as e:
|
||||
print(f"❌ Database initialization failed: {e}")
|
||||
|
||||
return engine
|
||||
|
||||
def get_session():
|
||||
"""데이터베이스 세션 반환"""
|
||||
return db_session
|
||||
|
||||
def close_session():
|
||||
"""데이터베이스 세션 종료"""
|
||||
db_session.remove()
|
||||
|
||||
# 약국 관련 유틸리티 함수
|
||||
def get_pharmacy_count() -> int:
|
||||
"""총 약국 수 반환"""
|
||||
session = get_session()
|
||||
return session.query(PharmacyInfo).count()
|
||||
|
||||
def get_pharmacy_with_stats(pharmacy_id: int) -> Optional[dict]:
|
||||
"""약국 정보와 통계 반환"""
|
||||
session = get_session()
|
||||
pharmacy = session.query(PharmacyInfo).filter_by(id=pharmacy_id).first()
|
||||
if not pharmacy:
|
||||
return None
|
||||
|
||||
# 연결된 머신 수
|
||||
machine_count = session.query(Node).join(User).filter(User.name == pharmacy.user_id).count()
|
||||
|
||||
# 온라인 머신 수
|
||||
online_count = session.query(Node).join(User).filter(
|
||||
User.name == pharmacy.user_id,
|
||||
Node.last_seen > datetime.now() - timedelta(minutes=5)
|
||||
).count()
|
||||
|
||||
return {
|
||||
'pharmacy': pharmacy,
|
||||
'machine_count': machine_count,
|
||||
'online_count': online_count,
|
||||
'offline_count': machine_count - online_count
|
||||
}
|
||||
|
||||
def get_all_pharmacies_with_stats() -> List[dict]:
|
||||
"""모든 약국 정보와 통계 반환"""
|
||||
session = get_session()
|
||||
pharmacies = session.query(PharmacyInfo).all()
|
||||
result = []
|
||||
|
||||
for pharmacy in pharmacies:
|
||||
stats = get_pharmacy_with_stats(pharmacy.id)
|
||||
if stats:
|
||||
result.append(stats)
|
||||
|
||||
return result
|
||||
|
||||
# 머신 관련 유틸리티 함수
|
||||
def get_online_machines_count() -> int:
|
||||
"""온라인 머신 수 반환"""
|
||||
session = get_session()
|
||||
cutoff_time = datetime.now() - timedelta(minutes=5)
|
||||
return session.query(Node).filter(Node.last_seen > cutoff_time).count()
|
||||
|
||||
def get_offline_machines_count() -> int:
|
||||
"""오프라인 머신 수 반환"""
|
||||
session = get_session()
|
||||
total_machines = session.query(Node).count()
|
||||
online_machines = get_online_machines_count()
|
||||
return total_machines - online_machines
|
||||
|
||||
def get_machine_with_details(machine_id: int) -> Optional[dict]:
|
||||
"""머신 상세 정보 반환 (하드웨어 사양, 모니터링 데이터 포함)"""
|
||||
session = get_session()
|
||||
|
||||
try:
|
||||
machine = session.query(Node).filter_by(id=machine_id).first()
|
||||
if not machine:
|
||||
return None
|
||||
|
||||
# 하드웨어 사양
|
||||
specs = session.query(MachineSpecs).filter_by(machine_id=machine_id).first()
|
||||
|
||||
# 최신 모니터링 데이터
|
||||
latest_monitoring = session.query(MonitoringData).filter_by(
|
||||
machine_id=machine_id
|
||||
).order_by(MonitoringData.collected_at.desc()).first()
|
||||
|
||||
# 약국 정보 (specs가 있고 pharmacy_id가 있는 경우)
|
||||
pharmacy = None
|
||||
if specs and hasattr(specs, 'pharmacy_id') and specs.pharmacy_id:
|
||||
pharmacy = session.query(PharmacyInfo).filter_by(id=specs.pharmacy_id).first()
|
||||
|
||||
# is_online 상태 확인
|
||||
try:
|
||||
is_online = machine.is_online() if hasattr(machine, 'is_online') else False
|
||||
except:
|
||||
# last_seen이 최근 5분 이내인지 확인
|
||||
if machine.last_seen:
|
||||
from datetime import datetime, timedelta
|
||||
is_online = machine.last_seen > (datetime.now() - timedelta(minutes=5))
|
||||
else:
|
||||
is_online = False
|
||||
|
||||
result = {
|
||||
'machine': machine,
|
||||
'specs': specs,
|
||||
'latest_monitoring': latest_monitoring,
|
||||
'pharmacy': pharmacy,
|
||||
'is_online': is_online,
|
||||
'last_seen_humanized': humanize_datetime(machine.last_seen)
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error in get_machine_with_details: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
# 모니터링 관련 유틸리티 함수
|
||||
def get_average_cpu_temperature() -> float:
|
||||
"""평균 CPU 온도 반환"""
|
||||
session = get_session()
|
||||
|
||||
# 최근 5분 내 데이터만 사용
|
||||
cutoff_time = datetime.now() - timedelta(minutes=5)
|
||||
|
||||
result = session.query(MonitoringData).filter(
|
||||
MonitoringData.collected_at > cutoff_time,
|
||||
MonitoringData.cpu_temperature.isnot(None)
|
||||
).all()
|
||||
|
||||
if not result:
|
||||
return 0.0
|
||||
|
||||
temperatures = [r.cpu_temperature for r in result if r.cpu_temperature]
|
||||
return sum(temperatures) / len(temperatures) if temperatures else 0.0
|
||||
|
||||
def get_active_alerts() -> List[dict]:
|
||||
"""활성 알림 목록 반환"""
|
||||
session = get_session()
|
||||
alerts = []
|
||||
|
||||
# CPU 온도 경고 (80도 이상)
|
||||
high_temp_machines = session.query(MonitoringData, Node).join(Node).filter(
|
||||
MonitoringData.cpu_temperature > 80,
|
||||
MonitoringData.collected_at > datetime.now() - timedelta(minutes=5)
|
||||
).all()
|
||||
|
||||
for monitoring, machine in high_temp_machines:
|
||||
alerts.append({
|
||||
'type': 'warning',
|
||||
'level': 'high_temperature',
|
||||
'machine': machine,
|
||||
'message': f'{machine.hostname}: CPU 온도 {monitoring.cpu_temperature}°C',
|
||||
'value': monitoring.cpu_temperature
|
||||
})
|
||||
|
||||
# 디스크 사용률 경고 (90% 이상)
|
||||
high_disk_machines = session.query(MonitoringData, Node).join(Node).filter(
|
||||
MonitoringData.disk_usage > 90,
|
||||
MonitoringData.collected_at > datetime.now() - timedelta(minutes=5)
|
||||
).all()
|
||||
|
||||
for monitoring, machine in high_disk_machines:
|
||||
alerts.append({
|
||||
'type': 'danger',
|
||||
'level': 'high_disk',
|
||||
'machine': machine,
|
||||
'message': f'{machine.hostname}: 디스크 사용률 {monitoring.disk_usage}%',
|
||||
'value': float(monitoring.disk_usage)
|
||||
})
|
||||
|
||||
return alerts
|
||||
|
||||
# 유틸리티 헬퍼 함수
|
||||
def humanize_datetime(dt) -> str:
|
||||
"""datetime을 사람이 읽기 쉬운 형태로 변환"""
|
||||
if not dt:
|
||||
return '알 수 없음'
|
||||
|
||||
try:
|
||||
# 한국어 설정
|
||||
humanize.i18n.activate('ko_KR')
|
||||
return humanize.naturaltime(dt)
|
||||
except:
|
||||
# 한국어 로케일이 없으면 영어로 fallback
|
||||
return humanize.naturaltime(dt)
|
||||
|
||||
def get_performance_summary() -> dict:
|
||||
"""전체 성능 요약 반환"""
|
||||
session = get_session()
|
||||
cutoff_time = datetime.now() - timedelta(minutes=5)
|
||||
|
||||
recent_data = session.query(MonitoringData).filter(
|
||||
MonitoringData.collected_at > cutoff_time
|
||||
).all()
|
||||
|
||||
if not recent_data:
|
||||
return {
|
||||
'avg_cpu': 0,
|
||||
'avg_memory': 0,
|
||||
'avg_disk': 0,
|
||||
'avg_temperature': 0
|
||||
}
|
||||
|
||||
cpu_values = [float(d.cpu_usage) for d in recent_data if d.cpu_usage]
|
||||
memory_values = [float(d.memory_usage) for d in recent_data if d.memory_usage]
|
||||
disk_values = [float(d.disk_usage) for d in recent_data if d.disk_usage]
|
||||
temp_values = [d.cpu_temperature for d in recent_data if d.cpu_temperature]
|
||||
|
||||
return {
|
||||
'avg_cpu': sum(cpu_values) / len(cpu_values) if cpu_values else 0,
|
||||
'avg_memory': sum(memory_values) / len(memory_values) if memory_values else 0,
|
||||
'avg_disk': sum(disk_values) / len(disk_values) if disk_values else 0,
|
||||
'avg_temperature': sum(temp_values) / len(temp_values) if temp_values else 0
|
||||
}
|
||||
610
farmq-admin/utils/database_new.py
Normal file
610
farmq-admin/utils/database_new.py
Normal file
@@ -0,0 +1,610 @@
|
||||
"""
|
||||
새로운 데이터베이스 유틸리티 - Headscale과 분리된 FARMQ 전용
|
||||
외래키 제약조건 없이 능동적으로 데이터를 관리
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import create_engine, text, and_, or_, desc
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from models.farmq_models import (
|
||||
PharmacyInfo, MachineProfile, MonitoringMetrics, SystemAlert,
|
||||
FarmqDatabaseManager, create_farmq_database_manager,
|
||||
FarmqBase
|
||||
)
|
||||
from models.headscale_models import User, Node, PreAuthKey, ApiKey
|
||||
|
||||
# 전역 데이터베이스 매니저들
|
||||
farmq_manager: Optional[FarmqDatabaseManager] = None
|
||||
headscale_engine = None
|
||||
headscale_session_maker = None
|
||||
|
||||
def init_databases(headscale_db_uri: str, farmq_db_uri: str = None):
|
||||
"""두 개의 데이터베이스 초기화"""
|
||||
global farmq_manager, headscale_engine, headscale_session_maker
|
||||
|
||||
# FARMQ 전용 데이터베이스 (외래키 제약조건 없음)
|
||||
if farmq_db_uri is None:
|
||||
farmq_db_uri = "sqlite:///farmq-admin/farmq.sqlite"
|
||||
|
||||
farmq_manager = create_farmq_database_manager(farmq_db_uri)
|
||||
print(f"✅ FARMQ 데이터베이스 초기화: {farmq_db_uri}")
|
||||
|
||||
# Headscale 읽기 전용 데이터베이스
|
||||
headscale_engine = create_engine(headscale_db_uri, echo=False)
|
||||
headscale_session_maker = sessionmaker(bind=headscale_engine)
|
||||
print(f"✅ Headscale 데이터베이스 연결: {headscale_db_uri}")
|
||||
|
||||
def get_farmq_session() -> Session:
|
||||
"""FARMQ 데이터베이스 세션 가져오기"""
|
||||
if farmq_manager is None:
|
||||
raise RuntimeError("FARMQ database not initialized")
|
||||
return farmq_manager.get_session()
|
||||
|
||||
def get_headscale_session() -> Session:
|
||||
"""Headscale 데이터베이스 세션 가져오기 (읽기 전용)"""
|
||||
if headscale_session_maker is None:
|
||||
raise RuntimeError("Headscale database not initialized")
|
||||
return headscale_session_maker()
|
||||
|
||||
def close_session(session: Session):
|
||||
"""세션 종료"""
|
||||
if session:
|
||||
session.close()
|
||||
|
||||
# ==========================================
|
||||
# Dashboard Statistics
|
||||
# ==========================================
|
||||
|
||||
def get_dashboard_stats() -> Dict[str, Any]:
|
||||
"""대시보드 통계 조회"""
|
||||
farmq_session = get_farmq_session()
|
||||
headscale_session = get_headscale_session()
|
||||
|
||||
try:
|
||||
# 약국 수
|
||||
total_pharmacies = farmq_session.query(PharmacyInfo).filter(
|
||||
PharmacyInfo.status == 'active'
|
||||
).count()
|
||||
|
||||
# 머신 상태
|
||||
total_machines = farmq_session.query(MachineProfile).filter(
|
||||
MachineProfile.status == 'active'
|
||||
).count()
|
||||
|
||||
online_machines = farmq_session.query(MachineProfile).filter(
|
||||
MachineProfile.status == 'active',
|
||||
MachineProfile.tailscale_status == 'online'
|
||||
).count()
|
||||
|
||||
offline_machines = total_machines - online_machines
|
||||
|
||||
# 최근 알림 수
|
||||
recent_alerts = farmq_session.query(SystemAlert).filter(
|
||||
SystemAlert.status == 'active',
|
||||
SystemAlert.created_at > (datetime.now() - timedelta(hours=24))
|
||||
).count()
|
||||
|
||||
# 평균 CPU 온도 (최근 1시간)
|
||||
cutoff_time = datetime.now() - timedelta(hours=1)
|
||||
avg_temp_result = farmq_session.query(
|
||||
MonitoringMetrics.cpu_temperature
|
||||
).filter(
|
||||
MonitoringMetrics.collected_at > cutoff_time,
|
||||
MonitoringMetrics.cpu_temperature.isnot(None)
|
||||
).all()
|
||||
|
||||
avg_cpu_temp = 0
|
||||
if avg_temp_result:
|
||||
temps = [temp[0] for temp in avg_temp_result if temp[0] is not None]
|
||||
avg_cpu_temp = sum(temps) / len(temps) if temps else 0
|
||||
|
||||
return {
|
||||
'total_pharmacies': total_pharmacies,
|
||||
'total_machines': total_machines,
|
||||
'online_machines': online_machines,
|
||||
'offline_machines': offline_machines,
|
||||
'recent_alerts': recent_alerts,
|
||||
'avg_cpu_temp': round(avg_cpu_temp, 1)
|
||||
}
|
||||
|
||||
finally:
|
||||
close_session(farmq_session)
|
||||
close_session(headscale_session)
|
||||
|
||||
# ==========================================
|
||||
# Pharmacy Management
|
||||
# ==========================================
|
||||
|
||||
def get_all_pharmacies_with_stats() -> List[Dict[str, Any]]:
|
||||
"""모든 약국과 통계 정보 조회 - Headscale Node 데이터 사용"""
|
||||
farmq_session = get_farmq_session()
|
||||
headscale_session = get_headscale_session()
|
||||
|
||||
try:
|
||||
pharmacies = farmq_session.query(PharmacyInfo).filter(
|
||||
PharmacyInfo.status == 'active'
|
||||
).all()
|
||||
|
||||
result = []
|
||||
for pharmacy in pharmacies:
|
||||
# Headscale에서 해당 사용자의 머신 수 조회
|
||||
user_machines = headscale_session.query(Node).join(User).filter(
|
||||
User.name == pharmacy.headscale_user_name,
|
||||
Node.deleted_at.is_(None)
|
||||
).all()
|
||||
|
||||
machine_count = len(user_machines)
|
||||
|
||||
# 온라인 머신 수 계산 (24시간 timeout)
|
||||
online_count = 0
|
||||
for machine in user_machines:
|
||||
if machine.last_seen:
|
||||
try:
|
||||
from datetime import timezone
|
||||
if machine.last_seen.tzinfo is not None:
|
||||
cutoff_time = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||
else:
|
||||
cutoff_time = datetime.now() - timedelta(hours=24)
|
||||
if machine.last_seen > cutoff_time:
|
||||
online_count += 1
|
||||
except Exception:
|
||||
online_count += 1 # 타임존 에러 시 온라인으로 간주
|
||||
|
||||
# 활성 알림 수 (현재는 0으로 설정, 나중에 구현)
|
||||
alert_count = 0
|
||||
|
||||
pharmacy_data = pharmacy.to_dict()
|
||||
pharmacy_data.update({
|
||||
'machine_count': machine_count,
|
||||
'online_count': online_count,
|
||||
'offline_count': machine_count - online_count,
|
||||
'alert_count': alert_count
|
||||
})
|
||||
|
||||
result.append(pharmacy_data)
|
||||
|
||||
return result
|
||||
|
||||
finally:
|
||||
close_session(farmq_session)
|
||||
close_session(headscale_session)
|
||||
|
||||
def get_pharmacy_detail(pharmacy_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""약국 상세 정보 조회"""
|
||||
farmq_session = get_farmq_session()
|
||||
|
||||
try:
|
||||
pharmacy = farmq_session.query(PharmacyInfo).filter(
|
||||
PharmacyInfo.id == pharmacy_id
|
||||
).first()
|
||||
|
||||
if not pharmacy:
|
||||
return None
|
||||
|
||||
# 약국의 머신들 조회
|
||||
machines = farmq_session.query(MachineProfile).filter(
|
||||
MachineProfile.pharmacy_id == pharmacy_id,
|
||||
MachineProfile.status == 'active'
|
||||
).all()
|
||||
|
||||
machine_list = []
|
||||
for machine in machines:
|
||||
machine_data = machine.to_dict()
|
||||
|
||||
# 최근 모니터링 데이터
|
||||
latest_metrics = farmq_session.query(MonitoringMetrics).filter(
|
||||
MonitoringMetrics.machine_profile_id == machine.id
|
||||
).order_by(desc(MonitoringMetrics.collected_at)).first()
|
||||
|
||||
if latest_metrics:
|
||||
machine_data['latest_metrics'] = latest_metrics.to_dict()
|
||||
|
||||
machine_list.append(machine_data)
|
||||
|
||||
return {
|
||||
'pharmacy': pharmacy.to_dict(),
|
||||
'machines': machine_list
|
||||
}
|
||||
|
||||
finally:
|
||||
close_session(farmq_session)
|
||||
|
||||
# ==========================================
|
||||
# Machine Management
|
||||
# ==========================================
|
||||
|
||||
def get_all_machines_with_details() -> List[Dict[str, Any]]:
|
||||
"""모든 머신 상세 정보 조회 - Headscale Node 데이터 사용"""
|
||||
headscale_session = get_headscale_session()
|
||||
farmq_session = get_farmq_session()
|
||||
|
||||
try:
|
||||
# Headscale에서 모든 노드 조회
|
||||
nodes = headscale_session.query(Node).filter(
|
||||
Node.deleted_at.is_(None)
|
||||
).all()
|
||||
|
||||
result = []
|
||||
for node in nodes:
|
||||
# 기본 머신 정보
|
||||
machine_data = {
|
||||
'id': node.id,
|
||||
'hostname': node.hostname,
|
||||
'machine_name': node.hostname, # 표시용 이름
|
||||
'tailscale_ip': node.ipv4,
|
||||
'ipv6': node.ipv6,
|
||||
'headscale_user_name': node.user.name if node.user else '미지정',
|
||||
'user_id': node.user_id,
|
||||
'last_seen': node.last_seen,
|
||||
'created_at': node.created_at,
|
||||
'updated_at': node.updated_at
|
||||
}
|
||||
|
||||
# 온라인 상태 확인 (24시간 timeout)
|
||||
if node.last_seen:
|
||||
try:
|
||||
from datetime import timezone
|
||||
# node.last_seen이 timezone-aware인지 확인
|
||||
if node.last_seen.tzinfo is not None:
|
||||
cutoff_time = datetime.now(timezone.utc) - timedelta(hours=24)
|
||||
else:
|
||||
cutoff_time = datetime.now() - timedelta(hours=24)
|
||||
machine_data['is_online'] = node.last_seen > cutoff_time
|
||||
except Exception as e:
|
||||
# 타임존 비교 에러가 발생하면 기본적으로 온라인으로 가정
|
||||
print(f"Timezone comparison error for {node.hostname}: {e}")
|
||||
machine_data['is_online'] = True
|
||||
else:
|
||||
machine_data['is_online'] = False
|
||||
|
||||
# 사용자 이름으로 약국 정보 찾기
|
||||
machine_data['pharmacy'] = None
|
||||
if node.user:
|
||||
pharmacy = farmq_session.query(PharmacyInfo).filter(
|
||||
PharmacyInfo.headscale_user_name == node.user.name
|
||||
).first()
|
||||
if pharmacy:
|
||||
machine_data['pharmacy'] = {
|
||||
'id': pharmacy.id,
|
||||
'pharmacy_name': pharmacy.pharmacy_name,
|
||||
'manager_name': pharmacy.manager_name,
|
||||
'business_number': pharmacy.business_number
|
||||
}
|
||||
|
||||
# 마지막 접속 시간을 사람이 읽기 쉬운 형태로
|
||||
machine_data['last_seen_humanized'] = humanize_datetime(node.last_seen)
|
||||
|
||||
# 하드웨어 사양 및 모니터링 데이터는 나중에 추가 예정
|
||||
machine_data['specs'] = None
|
||||
machine_data['latest_monitoring'] = None
|
||||
|
||||
result.append(machine_data)
|
||||
|
||||
return result
|
||||
|
||||
finally:
|
||||
close_session(farmq_session)
|
||||
close_session(headscale_session)
|
||||
|
||||
def get_machine_detail(machine_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""머신 상세 정보 조회"""
|
||||
farmq_session = get_farmq_session()
|
||||
|
||||
try:
|
||||
machine = farmq_session.query(MachineProfile).filter(
|
||||
MachineProfile.id == machine_id
|
||||
).first()
|
||||
|
||||
if not machine:
|
||||
return None
|
||||
|
||||
machine_data = machine.to_dict()
|
||||
|
||||
# 약국 정보
|
||||
if machine.pharmacy_id:
|
||||
pharmacy = farmq_session.query(PharmacyInfo).filter(
|
||||
PharmacyInfo.id == machine.pharmacy_id
|
||||
).first()
|
||||
if pharmacy:
|
||||
machine_data['pharmacy'] = pharmacy.to_dict()
|
||||
|
||||
# 최근 모니터링 데이터 (24시간)
|
||||
cutoff_time = datetime.now() - timedelta(hours=24)
|
||||
metrics = farmq_session.query(MonitoringMetrics).filter(
|
||||
MonitoringMetrics.machine_profile_id == machine_id,
|
||||
MonitoringMetrics.collected_at > cutoff_time
|
||||
).order_by(desc(MonitoringMetrics.collected_at)).limit(100).all()
|
||||
|
||||
machine_data['metrics_history'] = [metric.to_dict() for metric in metrics]
|
||||
|
||||
# 최신 메트릭스
|
||||
if metrics:
|
||||
latest = metrics[0]
|
||||
machine_data['latest_metrics'] = latest.to_dict()
|
||||
machine_data['alerts'] = latest.get_alert_status()
|
||||
|
||||
# 활성 알림들
|
||||
active_alerts = farmq_session.query(SystemAlert).filter(
|
||||
SystemAlert.machine_profile_id == machine_id,
|
||||
SystemAlert.status == 'active'
|
||||
).order_by(desc(SystemAlert.created_at)).limit(10).all()
|
||||
|
||||
machine_data['active_alerts'] = [alert.to_dict() for alert in active_alerts]
|
||||
|
||||
return machine_data
|
||||
|
||||
finally:
|
||||
close_session(farmq_session)
|
||||
|
||||
# ==========================================
|
||||
# Headscale Synchronization
|
||||
# ==========================================
|
||||
|
||||
def sync_machines_from_headscale() -> Dict[str, int]:
|
||||
"""Headscale에서 머신 정보 동기화"""
|
||||
farmq_session = get_farmq_session()
|
||||
headscale_session = get_headscale_session()
|
||||
|
||||
try:
|
||||
# Headscale에서 모든 노드 조회
|
||||
nodes = headscale_session.query(Node).filter(
|
||||
Node.deleted_at.is_(None)
|
||||
).all()
|
||||
|
||||
synced = 0
|
||||
created = 0
|
||||
|
||||
for node in nodes:
|
||||
# FARMQ 데이터베이스에서 해당 머신 찾기
|
||||
machine = farmq_session.query(MachineProfile).filter(
|
||||
MachineProfile.headscale_node_id == node.id
|
||||
).first()
|
||||
|
||||
if machine:
|
||||
# 기존 머신 업데이트
|
||||
is_online = node.is_online()
|
||||
status = 'online' if is_online else 'offline'
|
||||
|
||||
machine.hostname = node.hostname
|
||||
machine.tailscale_ip = node.ipv4
|
||||
machine.tailscale_status = status
|
||||
machine.last_seen = node.last_seen or datetime.now()
|
||||
machine.updated_at = datetime.now()
|
||||
synced += 1
|
||||
else:
|
||||
# 새 머신 생성
|
||||
machine = MachineProfile(
|
||||
headscale_node_id=node.id,
|
||||
headscale_machine_key=node.machine_key,
|
||||
hostname=node.hostname or 'unknown',
|
||||
machine_name=node.given_name or node.hostname or 'unknown',
|
||||
tailscale_ip=node.ipv4,
|
||||
tailscale_status='online' if node.is_online() else 'offline',
|
||||
os_type='unknown',
|
||||
status='active',
|
||||
last_seen=node.last_seen or datetime.now()
|
||||
)
|
||||
farmq_session.add(machine)
|
||||
created += 1
|
||||
|
||||
farmq_session.commit()
|
||||
|
||||
return {
|
||||
'total_nodes': len(nodes),
|
||||
'synced': synced,
|
||||
'created': created
|
||||
}
|
||||
|
||||
finally:
|
||||
close_session(farmq_session)
|
||||
close_session(headscale_session)
|
||||
|
||||
def sync_users_from_headscale() -> Dict[str, int]:
|
||||
"""Headscale에서 사용자 정보 동기화"""
|
||||
farmq_session = get_farmq_session()
|
||||
headscale_session = get_headscale_session()
|
||||
|
||||
try:
|
||||
# Headscale에서 모든 사용자 조회
|
||||
users = headscale_session.query(User).filter(
|
||||
User.deleted_at.is_(None)
|
||||
).all()
|
||||
|
||||
synced = 0
|
||||
created = 0
|
||||
|
||||
for user in users:
|
||||
# FARMQ 데이터베이스에서 해당 약국 찾기
|
||||
pharmacy = farmq_session.query(PharmacyInfo).filter(
|
||||
PharmacyInfo.headscale_user_name == user.name
|
||||
).first()
|
||||
|
||||
if pharmacy:
|
||||
# 기존 약국 업데이트
|
||||
pharmacy.headscale_user_id = user.id
|
||||
# 약국명이 사용자명과 같으면 더 나은 이름으로 업데이트
|
||||
if pharmacy.pharmacy_name == user.name:
|
||||
if user.display_name and user.display_name != user.name:
|
||||
pharmacy.pharmacy_name = user.display_name
|
||||
else:
|
||||
pharmacy.pharmacy_name = f"{user.name} 약국" # 더 나은 기본 이름
|
||||
|
||||
# 기본값들이 None인 경우 업데이트
|
||||
if not pharmacy.business_number or pharmacy.business_number == "None":
|
||||
pharmacy.business_number = "000-00-00000"
|
||||
if not pharmacy.manager_name or pharmacy.manager_name == "None":
|
||||
pharmacy.manager_name = "관리자"
|
||||
|
||||
pharmacy.last_sync = datetime.now()
|
||||
pharmacy.updated_at = datetime.now()
|
||||
synced += 1
|
||||
else:
|
||||
# 새 약국 생성 (기본 정보로)
|
||||
pharmacy_name = user.display_name if user.display_name else f"{user.name} 약국"
|
||||
pharmacy = PharmacyInfo(
|
||||
headscale_user_name=user.name,
|
||||
headscale_user_id=user.id,
|
||||
pharmacy_name=pharmacy_name,
|
||||
business_number="000-00-00000", # 기본 사업자번호
|
||||
manager_name="관리자",
|
||||
status='active',
|
||||
last_sync=datetime.now()
|
||||
)
|
||||
farmq_session.add(pharmacy)
|
||||
created += 1
|
||||
|
||||
farmq_session.commit()
|
||||
|
||||
return {
|
||||
'total_users': len(users),
|
||||
'synced': synced,
|
||||
'created': created
|
||||
}
|
||||
|
||||
finally:
|
||||
close_session(farmq_session)
|
||||
close_session(headscale_session)
|
||||
|
||||
# ==========================================
|
||||
# Alert Management
|
||||
# ==========================================
|
||||
|
||||
def get_active_alerts(limit: int = 50) -> List[Dict[str, Any]]:
|
||||
"""활성 알림 조회"""
|
||||
farmq_session = get_farmq_session()
|
||||
|
||||
try:
|
||||
alerts = farmq_session.query(SystemAlert).filter(
|
||||
SystemAlert.status == 'active'
|
||||
).order_by(desc(SystemAlert.created_at)).limit(limit).all()
|
||||
|
||||
return [alert.to_dict() for alert in alerts]
|
||||
|
||||
finally:
|
||||
close_session(farmq_session)
|
||||
|
||||
def create_alert(machine_profile_id: int, alert_data: Dict[str, Any]) -> SystemAlert:
|
||||
"""새로운 알림 생성"""
|
||||
farmq_session = get_farmq_session()
|
||||
|
||||
try:
|
||||
# 중복 알림 확인
|
||||
fingerprint = f"{machine_profile_id}_{alert_data.get('alert_type')}_{alert_data.get('current_value')}"
|
||||
|
||||
existing_alert = farmq_session.query(SystemAlert).filter(
|
||||
SystemAlert.fingerprint == fingerprint,
|
||||
SystemAlert.status == 'active'
|
||||
).first()
|
||||
|
||||
if existing_alert:
|
||||
# 기존 알림 업데이트
|
||||
existing_alert.occurrence_count += 1
|
||||
existing_alert.last_occurred = datetime.now()
|
||||
existing_alert.updated_at = datetime.now()
|
||||
farmq_session.commit()
|
||||
return existing_alert
|
||||
else:
|
||||
# 새 알림 생성
|
||||
alert = SystemAlert(
|
||||
machine_profile_id=machine_profile_id,
|
||||
fingerprint=fingerprint,
|
||||
**alert_data
|
||||
)
|
||||
farmq_session.add(alert)
|
||||
farmq_session.commit()
|
||||
farmq_session.refresh(alert)
|
||||
return alert
|
||||
|
||||
finally:
|
||||
close_session(farmq_session)
|
||||
|
||||
# ==========================================
|
||||
# Backward Compatibility Functions
|
||||
# ==========================================
|
||||
|
||||
def get_pharmacy_count() -> int:
|
||||
"""약국 수 조회 (하위 호환성)"""
|
||||
stats = get_dashboard_stats()
|
||||
return stats['total_pharmacies']
|
||||
|
||||
def get_online_machines_count() -> int:
|
||||
"""온라인 머신 수 조회 (하위 호환성)"""
|
||||
stats = get_dashboard_stats()
|
||||
return stats['online_machines']
|
||||
|
||||
def get_offline_machines_count() -> int:
|
||||
"""오프라인 머신 수 조회 (하위 호환성)"""
|
||||
stats = get_dashboard_stats()
|
||||
return stats['offline_machines']
|
||||
|
||||
def get_average_cpu_temperature() -> float:
|
||||
"""평균 CPU 온도 조회 (하위 호환성)"""
|
||||
stats = get_dashboard_stats()
|
||||
return stats['avg_cpu_temp']
|
||||
|
||||
def humanize_datetime(dt) -> str:
|
||||
"""datetime을 사람이 읽기 쉬운 형태로 변환"""
|
||||
if not dt:
|
||||
return '알 수 없음'
|
||||
|
||||
try:
|
||||
import humanize
|
||||
# 한국어 설정 시도
|
||||
try:
|
||||
humanize.i18n.activate('ko_KR')
|
||||
except:
|
||||
pass
|
||||
return humanize.naturaltime(dt)
|
||||
except ImportError:
|
||||
# humanize가 없으면 기본 형식 사용
|
||||
if isinstance(dt, str):
|
||||
return dt
|
||||
return dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
def get_machine_with_details(machine_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""머신 상세 정보 조회 (하위 호환성)"""
|
||||
return get_machine_detail(machine_id)
|
||||
|
||||
def get_performance_summary() -> Dict[str, Any]:
|
||||
"""성능 요약 조회"""
|
||||
return {
|
||||
'status': 'good',
|
||||
'summary': '모든 시스템이 정상 작동 중입니다.'
|
||||
}
|
||||
|
||||
# ==========================================
|
||||
# 초기화 함수
|
||||
# ==========================================
|
||||
|
||||
def init_database(headscale_db_uri: str):
|
||||
"""데이터베이스 초기화 (하위 호환성)"""
|
||||
# FARMQ 데이터베이스는 자동으로 생성
|
||||
farmq_db_uri = "sqlite:///farmq-admin/farmq.sqlite"
|
||||
|
||||
# 디렉토리 생성
|
||||
os.makedirs("farmq-admin", exist_ok=True)
|
||||
|
||||
init_databases(headscale_db_uri, farmq_db_uri)
|
||||
|
||||
# 초기 동기화 실행
|
||||
try:
|
||||
print("🔄 Headscale에서 데이터 동기화 중...")
|
||||
user_sync = sync_users_from_headscale()
|
||||
machine_sync = sync_machines_from_headscale()
|
||||
|
||||
print(f"✅ 사용자 동기화: {user_sync}")
|
||||
print(f"✅ 머신 동기화: {machine_sync}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ 동기화 중 오류 발생: {e}")
|
||||
|
||||
def get_session():
|
||||
"""FARMQ 세션 가져오기 (하위 호환성)"""
|
||||
return get_farmq_session()
|
||||
|
||||
def close_session(session=None):
|
||||
"""세션 종료 (하위 호환성)"""
|
||||
if session:
|
||||
session.close()
|
||||
# 새로운 구조에서는 각 함수가 자체적으로 세션을 관리하므로 여기서는 아무것도 하지 않음
|
||||
483
farmq-install.ps1
Normal file
483
farmq-install.ps1
Normal file
@@ -0,0 +1,483 @@
|
||||
# 팜큐(FARMQ) Headscale Windows 원클릭 설치 스크립트
|
||||
# 사용법: 관리자 PowerShell에서 실행
|
||||
# iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/.../farmq-install.ps1'))
|
||||
|
||||
param(
|
||||
[switch]$Force,
|
||||
[string]$HeadscaleServer = "https://head.0bin.in",
|
||||
[string]$PreAuthKey = "8b3df41d37cb158ea39f41fc32c9af46e761de817ad06038",
|
||||
[string]$FarmqNetwork = "100.64.0.0/10"
|
||||
)
|
||||
|
||||
# ================================
|
||||
# 색상 출력 함수
|
||||
# ================================
|
||||
function Write-ColorOutput {
|
||||
param(
|
||||
[string]$Message,
|
||||
[string]$ForegroundColor = "White"
|
||||
)
|
||||
Write-Host $Message -ForegroundColor $ForegroundColor
|
||||
}
|
||||
|
||||
function Write-Header {
|
||||
param([string]$Text)
|
||||
Write-Host ""
|
||||
Write-Host "============================================" -ForegroundColor Magenta
|
||||
Write-Host $Text -ForegroundColor White
|
||||
Write-Host "============================================" -ForegroundColor Magenta
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
function Write-Status {
|
||||
param([string]$Message)
|
||||
Write-Host ""
|
||||
Write-ColorOutput "🔧 $Message" "Blue"
|
||||
}
|
||||
|
||||
function Write-Success {
|
||||
param([string]$Message)
|
||||
Write-Host ""
|
||||
Write-ColorOutput "✅ $Message" "Green"
|
||||
}
|
||||
|
||||
function Write-Error {
|
||||
param([string]$Message)
|
||||
Write-Host ""
|
||||
Write-ColorOutput "❌ $Message" "Red"
|
||||
}
|
||||
|
||||
function Write-Warning {
|
||||
param([string]$Message)
|
||||
Write-Host ""
|
||||
Write-ColorOutput "⚠️ $Message" "Yellow"
|
||||
}
|
||||
|
||||
function Write-Info {
|
||||
param([string]$Message)
|
||||
Write-Host ""
|
||||
Write-ColorOutput "📋 $Message" "Cyan"
|
||||
}
|
||||
|
||||
# ================================
|
||||
# 시스템 요구사항 확인
|
||||
# ================================
|
||||
function Test-Requirements {
|
||||
Write-Status "시스템 요구사항 확인 중..."
|
||||
|
||||
# 관리자 권한 확인
|
||||
$currentUser = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = New-Object Security.Principal.WindowsPrincipal($currentUser)
|
||||
|
||||
if (-NOT $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
Write-Error "이 스크립트는 관리자 권한으로 실행해야 합니다."
|
||||
Write-Info "다음 방법으로 다시 실행해주세요:"
|
||||
Write-Info "1. Windows 키 + X → 'Windows PowerShell(관리자)' 클릭"
|
||||
Write-Info "2. 스크립트 명령어 다시 실행"
|
||||
Write-Host ""
|
||||
Read-Host "아무 키나 누르세요..."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Windows 버전 확인
|
||||
$osVersion = [System.Environment]::OSVersion.Version
|
||||
if ($osVersion.Major -lt 10) {
|
||||
Write-Warning "Windows 10 이상을 권장합니다. 현재: Windows $($osVersion.Major).$($osVersion.Minor)"
|
||||
}
|
||||
|
||||
# 인터넷 연결 확인
|
||||
try {
|
||||
Test-Connection "8.8.8.8" -Count 1 -Quiet | Out-Null
|
||||
}
|
||||
catch {
|
||||
Write-Warning "인터넷 연결을 확인해주세요."
|
||||
}
|
||||
|
||||
Write-Success "시스템 요구사항 확인 완료"
|
||||
}
|
||||
|
||||
# ================================
|
||||
# Tailscale 설치
|
||||
# ================================
|
||||
function Install-Tailscale {
|
||||
Write-Status "Tailscale 클라이언트 확인 중..."
|
||||
|
||||
# 기존 설치 확인
|
||||
$tailscalePath = Get-Command "tailscale" -ErrorAction SilentlyContinue
|
||||
if ($tailscalePath) {
|
||||
$version = & tailscale version 2>$null | Select-Object -First 1
|
||||
Write-Info "Tailscale이 이미 설치되어 있습니다."
|
||||
Write-Info "현재 버전: $version"
|
||||
return
|
||||
}
|
||||
|
||||
Write-Info "Windows용 Tailscale 설치 중..."
|
||||
|
||||
# 임시 다운로드 경로
|
||||
$tempPath = "$env:TEMP\tailscale-setup.exe"
|
||||
$downloadUrl = "https://pkgs.tailscale.com/stable/tailscale-setup.exe"
|
||||
|
||||
try {
|
||||
Write-Status "Tailscale 다운로드 중..."
|
||||
Invoke-WebRequest -Uri $downloadUrl -OutFile $tempPath -UseBasicParsing
|
||||
|
||||
Write-Status "Tailscale 설치 중... (잠시 기다려주세요)"
|
||||
Start-Process -FilePath $tempPath -ArgumentList "/S" -Wait
|
||||
|
||||
# PATH 환경변수 새로고침
|
||||
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
|
||||
|
||||
# 설치 확인
|
||||
Start-Sleep -Seconds 3
|
||||
$tailscaleInstalled = Get-Command "tailscale" -ErrorAction SilentlyContinue
|
||||
if (-not $tailscaleInstalled) {
|
||||
# 직접 경로 시도
|
||||
$tailscaleExe = "C:\Program Files\Tailscale\tailscale.exe"
|
||||
if (Test-Path $tailscaleExe) {
|
||||
# PATH에 Tailscale 경로 추가
|
||||
$currentPath = [Environment]::GetEnvironmentVariable("Path", "Machine")
|
||||
if ($currentPath -notlike "*Tailscale*") {
|
||||
[Environment]::SetEnvironmentVariable("Path", "$currentPath;C:\Program Files\Tailscale", "Machine")
|
||||
$env:Path = "$env:Path;C:\Program Files\Tailscale"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Remove-Item $tempPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Success "Tailscale 설치 완료"
|
||||
|
||||
}
|
||||
catch {
|
||||
Write-Error "Tailscale 설치 실패: $($_.Exception.Message)"
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
# ================================
|
||||
# Tailscale 서비스 시작
|
||||
# ================================
|
||||
function Start-TailscaleService {
|
||||
Write-Status "Tailscale 서비스 시작 중..."
|
||||
|
||||
try {
|
||||
# Tailscale 서비스 시작
|
||||
$service = Get-Service -Name "Tailscale" -ErrorAction SilentlyContinue
|
||||
if ($service) {
|
||||
if ($service.Status -ne "Running") {
|
||||
Start-Service -Name "Tailscale"
|
||||
Start-Sleep -Seconds 3
|
||||
}
|
||||
Write-Success "Tailscale 서비스가 실행 중입니다."
|
||||
} else {
|
||||
Write-Warning "Tailscale 서비스를 찾을 수 없습니다. 수동 시작을 시도합니다."
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Warning "서비스 시작에 실패했습니다: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# ================================
|
||||
# Headscale 등록
|
||||
# ================================
|
||||
function Register-Headscale {
|
||||
Write-Status "Headscale 서버에 등록 중..."
|
||||
|
||||
# Tailscale 실행파일 경로 확인
|
||||
$tailscaleCmd = Get-Command "tailscale" -ErrorAction SilentlyContinue
|
||||
if (-not $tailscaleCmd) {
|
||||
$tailscaleExe = "C:\Program Files\Tailscale\tailscale.exe"
|
||||
if (Test-Path $tailscaleExe) {
|
||||
$tailscaleCmd = @{Source = $tailscaleExe}
|
||||
} else {
|
||||
Write-Error "Tailscale 실행파일을 찾을 수 없습니다."
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
$tailscalePath = $tailscaleCmd.Source
|
||||
|
||||
# 기존 연결 확인
|
||||
try {
|
||||
$status = & $tailscalePath status 2>$null
|
||||
if ($LASTEXITCODE -eq 0 -and $status) {
|
||||
Write-Warning "이미 Tailscale/Headscale에 연결되어 있습니다."
|
||||
|
||||
# 현재 연결 상태 표시
|
||||
Write-Info "현재 연결 상태:"
|
||||
$status | Select-Object -First 5 | ForEach-Object { Write-Host " $_" }
|
||||
|
||||
# 강제 등록 옵션 확인
|
||||
if ($Force) {
|
||||
Write-Warning "강제 재등록 옵션이 활성화되었습니다."
|
||||
Write-Info "기존 연결을 해제하고 재등록합니다..."
|
||||
} else {
|
||||
$response = Read-Host "기존 연결을 해제하고 팜큐 Headscale로 등록하시겠습니까? (Y/n)"
|
||||
if ($response -eq "" -or $response -match "^[Yy]") {
|
||||
Write-Info "기존 연결을 해제합니다..."
|
||||
} else {
|
||||
Write-Info "등록을 건너뜁니다."
|
||||
return $true
|
||||
}
|
||||
}
|
||||
|
||||
# 기존 연결 해제
|
||||
try {
|
||||
& $tailscalePath logout 2>$null
|
||||
Start-Sleep -Seconds 3
|
||||
Write-Success "기존 연결이 해제되었습니다."
|
||||
}
|
||||
catch {
|
||||
Write-Warning "연결 해제 중 오류가 발생했지만 계속 진행합니다."
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
# 연결되어 있지 않음 (정상)
|
||||
}
|
||||
|
||||
Write-Info "Headscale 서버: $HeadscaleServer"
|
||||
Write-Info "Pre-auth Key: $($PreAuthKey.Substring(0,8))***************"
|
||||
|
||||
# Headscale 등록 시도
|
||||
Write-Status "등록 명령 실행 중..."
|
||||
|
||||
try {
|
||||
$arguments = @(
|
||||
"up",
|
||||
"--login-server=$HeadscaleServer",
|
||||
"--authkey=$PreAuthKey",
|
||||
"--accept-routes",
|
||||
"--accept-dns=false"
|
||||
)
|
||||
|
||||
& $tailscalePath $arguments
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Success "Headscale 등록 성공!"
|
||||
return $true
|
||||
} else {
|
||||
Write-Error "자동 등록에 실패했습니다."
|
||||
Write-Info "수동 등록 명령어:"
|
||||
Write-Host "tailscale up --login-server=`"$HeadscaleServer`" --authkey=`"$PreAuthKey`""
|
||||
return $false
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Error "등록 중 오류 발생: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# ================================
|
||||
# 방화벽 설정
|
||||
# ================================
|
||||
function Configure-Firewall {
|
||||
Write-Status "방화벽 설정 확인 중..."
|
||||
|
||||
try {
|
||||
# Windows Defender 방화벽 예외 추가
|
||||
$ruleName = "Tailscale-FarmQ"
|
||||
$existingRule = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
|
||||
|
||||
if (-not $existingRule) {
|
||||
New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Protocol UDP -LocalPort 41641 -Action Allow -Profile Any | Out-Null
|
||||
New-NetFirewallRule -DisplayName "$ruleName-Outbound" -Direction Outbound -Protocol UDP -LocalPort 41641 -Action Allow -Profile Any | Out-Null
|
||||
Write-Info "Windows Defender 방화벽 예외를 추가했습니다."
|
||||
}
|
||||
|
||||
Write-Success "방화벽 설정 완료"
|
||||
}
|
||||
catch {
|
||||
Write-Warning "방화벽 설정 중 오류 발생: $($_.Exception.Message)"
|
||||
Write-Info "수동으로 방화벽에서 Tailscale을 허용해주세요."
|
||||
}
|
||||
}
|
||||
|
||||
# ================================
|
||||
# 연결 상태 확인
|
||||
# ================================
|
||||
function Test-Connection {
|
||||
Write-Status "연결 상태 확인 중..."
|
||||
|
||||
Start-Sleep -Seconds 5
|
||||
|
||||
# Tailscale 실행파일 경로 확인
|
||||
$tailscaleCmd = Get-Command "tailscale" -ErrorAction SilentlyContinue
|
||||
if (-not $tailscaleCmd) {
|
||||
$tailscaleExe = "C:\Program Files\Tailscale\tailscale.exe"
|
||||
if (Test-Path $tailscaleExe) {
|
||||
$tailscaleCmd = @{Source = $tailscaleExe}
|
||||
} else {
|
||||
Write-Error "Tailscale 실행파일을 찾을 수 없습니다."
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
$tailscalePath = $tailscaleCmd.Source
|
||||
|
||||
try {
|
||||
$status = & $tailscalePath status 2>$null
|
||||
if ($LASTEXITCODE -ne 0 -or -not $status) {
|
||||
Write-Error "Tailscale 연결에 문제가 있습니다."
|
||||
return
|
||||
}
|
||||
|
||||
# IP 주소 확인
|
||||
$ipv4 = & $tailscalePath ip -4 2>$null
|
||||
$ipv6 = & $tailscalePath ip -6 2>$null
|
||||
|
||||
Write-Success "Headscale 네트워크 연결 완료!"
|
||||
Write-Info "할당된 IPv4: $(if($ipv4){$ipv4}else{'N/A'})"
|
||||
Write-Info "할당된 IPv6: $(if($ipv6){$ipv6}else{'N/A'})"
|
||||
|
||||
# 네트워크 테스트
|
||||
Write-Status "네트워크 연결 테스트 중..."
|
||||
|
||||
try {
|
||||
Test-Connection "100.64.0.1" -Count 2 -Quiet | Out-Null
|
||||
Write-Success "팜큐 네트워크($FarmqNetwork) 연결 정상!"
|
||||
}
|
||||
catch {
|
||||
Write-Warning "네트워크 테스트 실패. 방화벽을 확인해주세요."
|
||||
}
|
||||
|
||||
# 연결된 노드 확인
|
||||
Write-Info "네트워크 상태:"
|
||||
$status | Select-Object -First 10 | ForEach-Object {
|
||||
Write-Host " $_" -ForegroundColor Gray
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Error "연결 상태 확인 실패: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# ================================
|
||||
# 정리 작업
|
||||
# ================================
|
||||
function Complete-Installation {
|
||||
Write-Status "설치 완료 작업 중..."
|
||||
|
||||
# 임시 파일 정리
|
||||
Get-ChildItem "$env:TEMP\tailscale*" -ErrorAction SilentlyContinue | Remove-Item -Force -ErrorAction SilentlyContinue
|
||||
|
||||
Write-Success "정리 작업 완료"
|
||||
}
|
||||
|
||||
# ================================
|
||||
# 최종 정보 출력
|
||||
# ================================
|
||||
function Show-FinalInfo {
|
||||
Write-Header "팜큐 Headscale Windows 설치 완료!"
|
||||
|
||||
# 시스템 정보
|
||||
$computerName = $env:COMPUTERNAME
|
||||
$tailscaleCmd = Get-Command "tailscale" -ErrorAction SilentlyContinue
|
||||
if (-not $tailscaleCmd) {
|
||||
$tailscaleExe = "C:\Program Files\Tailscale\tailscale.exe"
|
||||
if (Test-Path $tailscaleExe) {
|
||||
$tailscaleCmd = @{Source = $tailscaleExe}
|
||||
}
|
||||
}
|
||||
|
||||
if ($tailscaleCmd) {
|
||||
$tailscaleIP = & $tailscaleCmd.Source ip -4 2>$null
|
||||
}
|
||||
|
||||
$osVersion = [System.Environment]::OSVersion.Version
|
||||
|
||||
Write-ColorOutput "🎉 설치가 성공적으로 완료되었습니다!" "Green"
|
||||
Write-Host ""
|
||||
|
||||
Write-ColorOutput "📋 시스템 정보:" "Cyan"
|
||||
Write-Host " 컴퓨터명: $computerName"
|
||||
Write-Host " Tailscale IP: $(if($tailscaleIP){$tailscaleIP}else{'N/A'})"
|
||||
Write-Host " OS: Windows $($osVersion.Major).$($osVersion.Minor)"
|
||||
Write-Host " Headscale 서버: $HeadscaleServer"
|
||||
|
||||
Write-Host ""
|
||||
Write-ColorOutput "🔧 유용한 명령어:" "Yellow"
|
||||
Write-Host " tailscale status # 연결 상태 확인"
|
||||
Write-Host " tailscale ip # 할당된 IP 확인"
|
||||
Write-Host " tailscale ping <node> # 다른 노드와 연결 테스트"
|
||||
Write-Host " tailscale logout # 네트워크에서 해제"
|
||||
|
||||
Write-Host ""
|
||||
Write-ColorOutput "🌐 팜큐 관리자 페이지:" "Magenta"
|
||||
Write-Host " http://192.168.0.151:5002"
|
||||
Write-Host " http://192.168.0.151:5002/vms (VM 관리)"
|
||||
|
||||
Write-Host ""
|
||||
Write-ColorOutput "문제가 있을 경우 다음을 확인하세요:" "White"
|
||||
Write-Host " 1. Windows 방화벽 설정"
|
||||
Write-Host " 2. 바이러스 백신 프로그램 예외 설정"
|
||||
Write-Host " 3. 회사 네트워크 정책 확인"
|
||||
|
||||
Write-Header "설치 완료 - 팜큐 네트워크를 사용할 수 있습니다!"
|
||||
}
|
||||
|
||||
# ================================
|
||||
# 메인 함수
|
||||
# ================================
|
||||
function Main {
|
||||
# 에러 발생 시 중단
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Header "팜큐(FARMQ) Headscale Windows 원클릭 설치"
|
||||
|
||||
try {
|
||||
# 설치 과정
|
||||
Test-Requirements
|
||||
Install-Tailscale
|
||||
Start-TailscaleService
|
||||
$registerSuccess = Register-Headscale
|
||||
|
||||
if ($registerSuccess) {
|
||||
Configure-Firewall
|
||||
Test-Connection
|
||||
Complete-Installation
|
||||
Show-FinalInfo
|
||||
} else {
|
||||
Write-Warning "등록에 실패했지만 Tailscale은 설치되었습니다."
|
||||
Write-Info "수동으로 등록을 완료해주세요."
|
||||
}
|
||||
|
||||
}
|
||||
catch {
|
||||
Write-Error "설치 중 오류가 발생했습니다: $($_.Exception.Message)"
|
||||
Write-Info "문제가 지속되면 관리자에게 문의하세요."
|
||||
Write-Host ""
|
||||
Read-Host "아무 키나 누르세요..."
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# ================================
|
||||
# 스크립트 실행
|
||||
# ================================
|
||||
|
||||
# 파라미터 처리
|
||||
if ($args -contains "--help" -or $args -contains "-h") {
|
||||
Write-Host "팜큐 Headscale Windows 설치 스크립트"
|
||||
Write-Host ""
|
||||
Write-Host "사용법:"
|
||||
Write-Host " iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/.../farmq-install.ps1'))"
|
||||
Write-Host ""
|
||||
Write-Host "옵션:"
|
||||
Write-Host " -Force 기존 연결을 강제로 해제하고 재등록"
|
||||
Write-Host " -HeadscaleServer 서버 주소 (기본값: https://head.0bin.in)"
|
||||
Write-Host ""
|
||||
Write-Host "예시:"
|
||||
Write-Host " # 강제 재등록"
|
||||
Write-Host " iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/.../farmq-install.ps1?force=1'))"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Force 파라미터 URL에서 처리
|
||||
if ($MyInvocation.MyCommand.Path -like "*force=1*") {
|
||||
$Force = $true
|
||||
}
|
||||
|
||||
# 메인 함수 실행
|
||||
Main
|
||||
15
headplane-config/config.yaml
Normal file
15
headplane-config/config.yaml
Normal file
@@ -0,0 +1,15 @@
|
||||
headscale:
|
||||
url: http://headscale:8080
|
||||
api_key: 8qRr1IB.tV95CmA0fLaCiGGIgBfeoN9daHceFkzI
|
||||
config_strict: false
|
||||
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 3000
|
||||
cookie_secret: headscale-ui-secret-32-chars-key
|
||||
cookie_secure: false
|
||||
|
||||
settings:
|
||||
title: "Headscale 관리 패널"
|
||||
favicon_url: ""
|
||||
custom_css: ""
|
||||
451
headscale_models.py
Normal file
451
headscale_models.py
Normal file
@@ -0,0 +1,451 @@
|
||||
"""
|
||||
Headscale SQLite Database Models for SQLAlchemy
|
||||
Based on actual schema analysis of Headscale v0.23.0
|
||||
|
||||
Generated from: /var/lib/headscale/db.sqlite
|
||||
Schema Analysis Date: 2025-09-09
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
import json
|
||||
from sqlalchemy import (
|
||||
Column, Integer, String, DateTime, Boolean, Text,
|
||||
ForeignKey, LargeBinary, Index, UniqueConstraint
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship, Session
|
||||
from sqlalchemy.types import TypeDecorator, TEXT
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class JSONType(TypeDecorator):
|
||||
"""Custom JSON type for SQLAlchemy that handles JSON serialization"""
|
||||
impl = TEXT
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if value is not None:
|
||||
return json.dumps(value)
|
||||
return value
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
if value is not None:
|
||||
return json.loads(value)
|
||||
return value
|
||||
|
||||
|
||||
class Migration(Base):
|
||||
"""Migration tracking table"""
|
||||
__tablename__ = 'migrations'
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Migration(id='{self.id}')>"
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""Headscale Users table
|
||||
|
||||
Represents individual users/namespaces in the Headscale network.
|
||||
Each user can have multiple nodes (machines) associated with them.
|
||||
"""
|
||||
__tablename__ = 'users'
|
||||
__table_args__ = (
|
||||
Index('idx_users_deleted_at', 'deleted_at'),
|
||||
Index('idx_provider_identifier', 'provider_identifier',
|
||||
postgresql_where="provider_identifier IS NOT NULL"),
|
||||
Index('idx_name_provider_identifier', 'name', 'provider_identifier'),
|
||||
Index('idx_name_no_provider_identifier', 'name',
|
||||
postgresql_where="provider_identifier IS NULL"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
created_at = Column(DateTime)
|
||||
updated_at = Column(DateTime)
|
||||
deleted_at = Column(DateTime) # Soft delete
|
||||
name = Column(String) # User identifier (e.g., "myuser")
|
||||
display_name = Column(String) # Human-readable display name
|
||||
email = Column(String) # User email address
|
||||
provider_identifier = Column(String) # External auth provider ID
|
||||
provider = Column(String) # Auth provider name (OIDC, etc.)
|
||||
profile_pic_url = Column(String) # Profile picture URL
|
||||
|
||||
# Relationships
|
||||
nodes = relationship("Node", back_populates="user", cascade="all, delete-orphan")
|
||||
pre_auth_keys = relationship("PreAuthKey", back_populates="user")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(id={self.id}, name='{self.name}', display_name='{self.display_name}')>"
|
||||
|
||||
def is_deleted(self) -> bool:
|
||||
"""Check if user is soft-deleted"""
|
||||
return self.deleted_at is not None
|
||||
|
||||
|
||||
class Node(Base):
|
||||
"""Headscale Nodes (Machines) table
|
||||
|
||||
Represents individual devices/machines connected to the Tailnet.
|
||||
Each node belongs to a user and has various networking attributes.
|
||||
"""
|
||||
__tablename__ = 'nodes'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
machine_key = Column(String) # Machine's public key
|
||||
node_key = Column(String) # Node's network key
|
||||
disco_key = Column(String) # Discovery key for peer-to-peer connections
|
||||
endpoints = Column(JSONType) # List of network endpoints (JSON array)
|
||||
host_info = Column(JSONType) # Detailed host information (JSON object)
|
||||
ipv4 = Column(String) # Assigned IPv4 address (e.g., "100.64.0.1")
|
||||
ipv6 = Column(String) # Assigned IPv6 address
|
||||
hostname = Column(String) # Machine hostname
|
||||
given_name = Column(String) # User-assigned machine name
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
|
||||
register_method = Column(String) # Registration method (e.g., "authkey")
|
||||
forced_tags = Column(JSONType) # Tags forced on this node (JSON array)
|
||||
auth_key_id = Column(Integer, ForeignKey('pre_auth_keys.id'))
|
||||
expiry = Column(DateTime) # Node expiration date
|
||||
last_seen = Column(DateTime) # Last activity timestamp
|
||||
approved_routes = Column(JSONType) # Approved subnet routes (JSON array)
|
||||
created_at = Column(DateTime)
|
||||
updated_at = Column(DateTime)
|
||||
deleted_at = Column(DateTime) # Soft delete
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="nodes")
|
||||
auth_key = relationship("PreAuthKey")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Node(id={self.id}, hostname='{self.hostname}', ipv4='{self.ipv4}', user_id={self.user_id})>"
|
||||
|
||||
def is_online(self, timeout_minutes: int = 5) -> bool:
|
||||
"""Check if node is considered online based on last_seen"""
|
||||
if not self.last_seen:
|
||||
return False
|
||||
|
||||
# Handle timezone-aware datetime
|
||||
now = datetime.now()
|
||||
last_seen = self.last_seen
|
||||
|
||||
# If last_seen is timezone-aware, make now timezone-aware too
|
||||
if last_seen.tzinfo is not None and now.tzinfo is None:
|
||||
from datetime import timezone
|
||||
now = now.replace(tzinfo=timezone.utc)
|
||||
# If last_seen is naive, make it naive too
|
||||
elif last_seen.tzinfo is not None and now.tzinfo is None:
|
||||
last_seen = last_seen.replace(tzinfo=None)
|
||||
|
||||
try:
|
||||
return (now - last_seen).total_seconds() < (timeout_minutes * 60)
|
||||
except TypeError:
|
||||
# Fallback: just check if we have a recent timestamp
|
||||
return True
|
||||
|
||||
def get_host_info(self) -> dict:
|
||||
"""Get parsed host information"""
|
||||
return self.host_info or {}
|
||||
|
||||
def get_endpoints(self) -> List[str]:
|
||||
"""Get list of network endpoints"""
|
||||
return self.endpoints or []
|
||||
|
||||
def get_forced_tags(self) -> List[str]:
|
||||
"""Get list of forced tags"""
|
||||
return self.forced_tags or []
|
||||
|
||||
def get_approved_routes(self) -> List[str]:
|
||||
"""Get list of approved routes"""
|
||||
return self.approved_routes or []
|
||||
|
||||
|
||||
class PreAuthKey(Base):
|
||||
"""Pre-authentication keys table
|
||||
|
||||
Keys used for automatic node registration without manual approval.
|
||||
"""
|
||||
__tablename__ = 'pre_auth_keys'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
key = Column(String) # The actual pre-auth key string
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'))
|
||||
reusable = Column(Boolean) # Can be used multiple times
|
||||
ephemeral = Column(Boolean, default=False) # Temporary key
|
||||
used = Column(Boolean, default=False) # Has been used
|
||||
tags = Column(JSONType) # Tags to apply to nodes using this key
|
||||
created_at = Column(DateTime)
|
||||
expiration = Column(DateTime) # When the key expires
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="pre_auth_keys")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PreAuthKey(id={self.id}, key='{self.key[:8]}...', user_id={self.user_id}, reusable={self.reusable})>"
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if the pre-auth key is expired"""
|
||||
if not self.expiration:
|
||||
return False
|
||||
|
||||
now = datetime.now()
|
||||
expiration = self.expiration
|
||||
|
||||
# Handle timezone-aware datetime
|
||||
if expiration.tzinfo is not None and now.tzinfo is None:
|
||||
from datetime import timezone
|
||||
now = now.replace(tzinfo=timezone.utc)
|
||||
elif expiration.tzinfo is not None:
|
||||
expiration = expiration.replace(tzinfo=None)
|
||||
|
||||
try:
|
||||
return now > expiration
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
"""Check if the key is still valid for use"""
|
||||
if self.is_expired():
|
||||
return False
|
||||
if self.used and not self.reusable:
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_tags(self) -> List[str]:
|
||||
"""Get list of tags for this key"""
|
||||
return self.tags or []
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
"""API Keys table
|
||||
|
||||
Keys used for API authentication to the Headscale server.
|
||||
"""
|
||||
__tablename__ = 'api_keys'
|
||||
__table_args__ = (
|
||||
Index('idx_api_keys_prefix', 'prefix', unique=True),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
prefix = Column(String) # Key prefix for identification (e.g., "8qRr1IB")
|
||||
hash = Column(LargeBinary) # Hashed key value
|
||||
created_at = Column(DateTime)
|
||||
expiration = Column(DateTime) # When the key expires
|
||||
last_seen = Column(DateTime) # Last time key was used
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ApiKey(id={self.id}, prefix='{self.prefix}', created_at='{self.created_at}')>"
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if the API key is expired"""
|
||||
if not self.expiration:
|
||||
return False
|
||||
|
||||
now = datetime.now()
|
||||
expiration = self.expiration
|
||||
|
||||
# Handle timezone-aware datetime
|
||||
if expiration.tzinfo is not None and now.tzinfo is None:
|
||||
from datetime import timezone
|
||||
now = now.replace(tzinfo=timezone.utc)
|
||||
elif expiration.tzinfo is not None:
|
||||
expiration = expiration.replace(tzinfo=None)
|
||||
|
||||
try:
|
||||
return now > expiration
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
|
||||
class Policy(Base):
|
||||
"""ACL Policies table
|
||||
|
||||
Stores Access Control List policies in JSON format.
|
||||
"""
|
||||
__tablename__ = 'policies'
|
||||
__table_args__ = (
|
||||
Index('idx_policies_deleted_at', 'deleted_at'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
created_at = Column(DateTime)
|
||||
updated_at = Column(DateTime)
|
||||
deleted_at = Column(DateTime) # Soft delete
|
||||
data = Column(Text) # JSON policy data
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Policy(id={self.id}, created_at='{self.created_at}')>"
|
||||
|
||||
def get_policy_data(self) -> dict:
|
||||
"""Parse and return policy data as dictionary"""
|
||||
try:
|
||||
return json.loads(self.data) if self.data else {}
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Extended Models for FARMQ Customization
|
||||
# ==========================================
|
||||
|
||||
class PharmacyInfo(Base):
|
||||
"""Extended table for pharmacy information
|
||||
|
||||
This extends the base Headscale functionality to store
|
||||
pharmacy-specific information for FARMQ management.
|
||||
"""
|
||||
__tablename__ = 'pharmacy_info'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(String, ForeignKey('users.name'), unique=True) # Link to users.name
|
||||
pharmacy_name = Column(String, nullable=False) # 약국명
|
||||
business_number = Column(String(20)) # 사업자번호
|
||||
address = Column(Text) # 주소
|
||||
phone = Column(String(20)) # 전화번호
|
||||
manager_name = Column(String(100)) # 담당자명
|
||||
proxmox_host = Column(String(255)) # Proxmox 호스트 IP
|
||||
proxmox_api_token = Column(Text) # Proxmox API 토큰
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PharmacyInfo(id={self.id}, name='{self.pharmacy_name}', business_number='{self.business_number}')>"
|
||||
|
||||
|
||||
class MachineSpecs(Base):
|
||||
"""Extended table for machine specifications
|
||||
|
||||
Stores detailed hardware specifications for each machine/node.
|
||||
"""
|
||||
__tablename__ = 'machine_specs'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
machine_id = Column(Integer, ForeignKey('nodes.id'), nullable=False)
|
||||
pharmacy_id = Column(Integer, ForeignKey('pharmacy_info.id'))
|
||||
cpu_model = Column(String(255)) # CPU 모델명
|
||||
cpu_cores = Column(Integer) # CPU 코어 수
|
||||
ram_gb = Column(Integer) # RAM 용량 (GB)
|
||||
storage_gb = Column(Integer) # 스토리지 용량 (GB)
|
||||
gpu_model = Column(String(255)) # GPU 모델명
|
||||
last_updated = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# Relationships
|
||||
machine = relationship("Node")
|
||||
pharmacy = relationship("PharmacyInfo")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MachineSpecs(id={self.id}, cpu='{self.cpu_model}', ram={self.ram_gb}GB)>"
|
||||
|
||||
|
||||
class MonitoringData(Base):
|
||||
"""Real-time monitoring data table
|
||||
|
||||
Stores time-series monitoring data collected from Proxmox hosts.
|
||||
"""
|
||||
__tablename__ = 'monitoring_data'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
machine_id = Column(Integer, ForeignKey('nodes.id'), nullable=False)
|
||||
cpu_usage = Column(String(5)) # CPU 사용률 (예: "75.50")
|
||||
memory_usage = Column(String(5)) # 메모리 사용률
|
||||
disk_usage = Column(String(5)) # 디스크 사용률
|
||||
cpu_temperature = Column(Integer) # CPU 온도 (섭씨)
|
||||
network_rx_bytes = Column(Integer) # 네트워크 수신 바이트
|
||||
network_tx_bytes = Column(Integer) # 네트워크 송신 바이트
|
||||
vm_count = Column(Integer) # 총 VM 개수
|
||||
vm_running = Column(Integer) # 실행중인 VM 개수
|
||||
collected_at = Column(DateTime, default=datetime.now)
|
||||
|
||||
# Relationships
|
||||
machine = relationship("Node")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MonitoringData(machine_id={self.machine_id}, cpu={self.cpu_usage}%, temp={self.cpu_temperature}°C)>"
|
||||
|
||||
def get_cpu_usage_float(self) -> float:
|
||||
"""CPU 사용률을 float로 반환"""
|
||||
try:
|
||||
return float(self.cpu_usage) if self.cpu_usage else 0.0
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Database Helper Functions
|
||||
# ==========================================
|
||||
|
||||
def create_all_tables(engine):
|
||||
"""Create all tables in the database"""
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
|
||||
def get_active_nodes(session: Session) -> List[Node]:
|
||||
"""Get all non-deleted nodes"""
|
||||
return session.query(Node).filter(Node.deleted_at.is_(None)).all()
|
||||
|
||||
|
||||
def get_online_nodes(session: Session, timeout_minutes: int = 5) -> List[Node]:
|
||||
"""Get nodes that are currently online"""
|
||||
cutoff_time = datetime.now() - timedelta(minutes=timeout_minutes)
|
||||
return session.query(Node).filter(
|
||||
Node.deleted_at.is_(None),
|
||||
Node.last_seen > cutoff_time
|
||||
).all()
|
||||
|
||||
|
||||
def get_user_with_pharmacy_info(session: Session, user_name: str):
|
||||
"""Get user with associated pharmacy information"""
|
||||
return session.query(User).join(PharmacyInfo).filter(User.name == user_name).first()
|
||||
|
||||
|
||||
def get_node_with_specs_and_monitoring(session: Session, node_id: int):
|
||||
"""Get node with hardware specs and latest monitoring data"""
|
||||
return session.query(Node)\
|
||||
.outerjoin(MachineSpecs)\
|
||||
.outerjoin(MonitoringData)\
|
||||
.filter(Node.id == node_id)\
|
||||
.first()
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Usage Example
|
||||
# ==========================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# SQLite connection to Headscale database
|
||||
DATABASE_URL = "sqlite:///data/db.sqlite"
|
||||
engine = create_engine(DATABASE_URL)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# Create extended tables (if needed)
|
||||
create_all_tables(engine)
|
||||
|
||||
# Example usage
|
||||
session = SessionLocal()
|
||||
try:
|
||||
# Get all users
|
||||
users = session.query(User).all()
|
||||
print("=== Users ===")
|
||||
for user in users:
|
||||
print(f" {user}")
|
||||
|
||||
# Get all nodes
|
||||
nodes = session.query(Node).all()
|
||||
print("\n=== Nodes ===")
|
||||
for node in nodes:
|
||||
print(f" {node}")
|
||||
print(f" Online: {node.is_online()}")
|
||||
print(f" Host Info: {node.get_host_info().get('Hostname', 'Unknown')}")
|
||||
|
||||
# Get all API keys
|
||||
api_keys = session.query(ApiKey).all()
|
||||
print("\n=== API Keys ===")
|
||||
for key in api_keys:
|
||||
print(f" {key}")
|
||||
print(f" Expired: {key.is_expired()}")
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
519
quick-install.sh
Executable file
519
quick-install.sh
Executable file
@@ -0,0 +1,519 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 팜큐(FARMQ) Headscale 원클릭 설치 및 등록 스크립트
|
||||
# 사용법: curl -fsSL https://git.0bin.in/.../quick-install.sh | sudo bash
|
||||
# 또는: wget -qO- https://git.0bin.in/.../quick-install.sh | sudo bash
|
||||
# root 계정: curl -fsSL https://git.0bin.in/.../quick-install.sh | bash
|
||||
# 강제 재등록: curl -fsSL https://git.0bin.in/.../quick-install.sh | bash -s -- --force
|
||||
|
||||
set -e
|
||||
|
||||
# ================================
|
||||
# 설정 (필요시 수정)
|
||||
# ================================
|
||||
HEADSCALE_SERVER="https://head.0bin.in" # Headscale 서버 주소
|
||||
PREAUTH_KEY="8b3df41d37cb158ea39f41fc32c9af46e761de817ad06038" # 7일간 재사용 가능한 키
|
||||
FARMQ_NETWORK="100.64.0.0/10" # 팜큐 네트워크 대역
|
||||
|
||||
# 명령행 옵션 처리
|
||||
FORCE_REGISTER=false
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--force|-f)
|
||||
FORCE_REGISTER=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
echo "사용법: $0 [옵션]"
|
||||
echo "옵션:"
|
||||
echo " --force, -f 기존 연결을 강제로 해제하고 재등록"
|
||||
echo " --help, -h 도움말 표시"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
# 알 수 없는 옵션 무시
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ================================
|
||||
# 색상 출력 함수
|
||||
# ================================
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
PURPLE='\033[0;35m'
|
||||
CYAN='\033[0;36m'
|
||||
WHITE='\033[1;37m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
print_header() {
|
||||
echo -e "\n${PURPLE}============================================${NC}"
|
||||
echo -e "${WHITE}$1${NC}"
|
||||
echo -e "${PURPLE}============================================${NC}\n"
|
||||
}
|
||||
|
||||
print_status() {
|
||||
echo -e "\n${BLUE}🔧 $1${NC}"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "\n${GREEN}✅ $1${NC}"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "\n${RED}❌ $1${NC}"
|
||||
}
|
||||
|
||||
print_info() {
|
||||
echo -e "\n${CYAN}📋 $1${NC}"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "\n${YELLOW}⚠️ $1${NC}"
|
||||
}
|
||||
|
||||
# ================================
|
||||
# 운영체제 감지
|
||||
# ================================
|
||||
detect_os() {
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
OS=$ID
|
||||
VERSION=$VERSION_ID
|
||||
CODENAME=$VERSION_CODENAME
|
||||
else
|
||||
print_error "지원하지 않는 운영체제입니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_info "감지된 OS: $OS $VERSION ($CODENAME)"
|
||||
}
|
||||
|
||||
# ================================
|
||||
# 시스템 요구사항 확인
|
||||
# ================================
|
||||
check_requirements() {
|
||||
print_status "시스템 요구사항 확인 중..."
|
||||
|
||||
# Root 권한 확인
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
print_error "이 스크립트는 root 권한으로 실행해야 합니다."
|
||||
print_info "다음 중 하나의 방법으로 다시 실행해주세요:"
|
||||
print_info "1. sudo가 있는 경우: curl ... | sudo bash"
|
||||
print_info "2. root 계정인 경우: curl ... | bash"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# curl 또는 wget 확인
|
||||
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
|
||||
print_error "curl 또는 wget이 필요합니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 네트워크 연결 확인
|
||||
if ! ping -c 1 8.8.8.8 >/dev/null 2>&1; then
|
||||
print_warning "인터넷 연결을 확인해주세요."
|
||||
fi
|
||||
|
||||
print_success "시스템 요구사항 확인 완료"
|
||||
}
|
||||
|
||||
# ================================
|
||||
# Tailscale 설치
|
||||
# ================================
|
||||
install_tailscale() {
|
||||
print_status "Tailscale 클라이언트 설치 중..."
|
||||
|
||||
# 이미 설치되어 있는지 확인
|
||||
if command -v tailscale >/dev/null 2>&1; then
|
||||
print_info "Tailscale이 이미 설치되어 있습니다."
|
||||
TAILSCALE_VERSION=$(tailscale version | head -n1)
|
||||
print_info "현재 버전: $TAILSCALE_VERSION"
|
||||
return
|
||||
fi
|
||||
|
||||
case $OS in
|
||||
ubuntu|debian)
|
||||
print_info "Ubuntu/Debian용 Tailscale 설치 중..."
|
||||
|
||||
# GPG 키 추가
|
||||
curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.noarmor.gpg | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null
|
||||
curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.tailscale-keyring.list | tee /etc/apt/sources.list.d/tailscale.list
|
||||
|
||||
# 패키지 설치
|
||||
apt-get update -qq
|
||||
apt-get install -y tailscale
|
||||
;;
|
||||
|
||||
centos|rhel|rocky|almalinux)
|
||||
print_info "CentOS/RHEL/Rocky용 Tailscale 설치 중..."
|
||||
|
||||
# 리포지토리 추가
|
||||
curl -fsSL https://pkgs.tailscale.com/stable/rhel/tailscale.repo | tee /etc/yum.repos.d/tailscale.repo
|
||||
|
||||
# 패키지 설치
|
||||
if command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y tailscale
|
||||
else
|
||||
yum install -y tailscale
|
||||
fi
|
||||
;;
|
||||
|
||||
fedora)
|
||||
print_info "Fedora용 Tailscale 설치 중..."
|
||||
dnf install -y tailscale
|
||||
;;
|
||||
|
||||
arch)
|
||||
print_info "Arch Linux용 Tailscale 설치 중..."
|
||||
pacman -S --noconfirm tailscale
|
||||
;;
|
||||
|
||||
*)
|
||||
print_warning "지원하지 않는 배포판입니다. 수동 설치를 시도합니다."
|
||||
# Universal binary 다운로드
|
||||
ARCH=$(uname -m)
|
||||
case $ARCH in
|
||||
x86_64) TAILSCALE_ARCH="amd64" ;;
|
||||
aarch64) TAILSCALE_ARCH="arm64" ;;
|
||||
armv7l) TAILSCALE_ARCH="arm" ;;
|
||||
*)
|
||||
print_error "지원하지 않는 아키텍처: $ARCH"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# 최신 버전 다운로드
|
||||
TAILSCALE_VERSION=$(curl -s https://api.github.com/repos/tailscale/tailscale/releases/latest | grep '"tag_name"' | cut -d'"' -f4)
|
||||
DOWNLOAD_URL="https://pkgs.tailscale.com/stable/tailscale_${TAILSCALE_VERSION#v}_linux_${TAILSCALE_ARCH}.tgz"
|
||||
|
||||
cd /tmp
|
||||
curl -LO "$DOWNLOAD_URL"
|
||||
tar xzf "tailscale_${TAILSCALE_VERSION#v}_linux_${TAILSCALE_ARCH}.tgz"
|
||||
|
||||
# 바이너리 복사
|
||||
cp "tailscale_${TAILSCALE_VERSION#v}_linux_${TAILSCALE_ARCH}/tailscale" /usr/bin/
|
||||
cp "tailscale_${TAILSCALE_VERSION#v}_linux_${TAILSCALE_ARCH}/tailscaled" /usr/sbin/
|
||||
|
||||
# 시스템 서비스 파일 생성
|
||||
cat > /etc/systemd/system/tailscaled.service << 'EOF'
|
||||
[Unit]
|
||||
Description=Tailscale node agent
|
||||
Documentation=https://tailscale.com/kb/
|
||||
Wants=network-pre.target
|
||||
After=network-pre.target NetworkManager.service systemd-resolved.service
|
||||
|
||||
[Service]
|
||||
EnvironmentFile=/etc/default/tailscaled
|
||||
ExecStart=/usr/sbin/tailscaled --state=/var/lib/tailscale/tailscaled.state --socket=/run/tailscale/tailscaled.sock --port=$PORT $FLAGS
|
||||
ExecStopPost=/usr/bin/tailscale logout
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
Type=notify
|
||||
RuntimeDirectory=tailscale
|
||||
RuntimeDirectoryMode=0755
|
||||
StateDirectory=tailscale
|
||||
StateDirectoryMode=0700
|
||||
CacheDirectory=tailscale
|
||||
CacheDirectoryMode=0750
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# 환경 설정 파일
|
||||
mkdir -p /etc/default
|
||||
echo 'FLAGS=""' > /etc/default/tailscaled
|
||||
echo 'PORT="41641"' >> /etc/default/tailscaled
|
||||
|
||||
systemctl daemon-reload
|
||||
;;
|
||||
esac
|
||||
|
||||
print_success "Tailscale 설치 완료"
|
||||
|
||||
# 버전 확인
|
||||
TAILSCALE_VERSION=$(tailscale version | head -n1)
|
||||
print_info "설치된 버전: $TAILSCALE_VERSION"
|
||||
}
|
||||
|
||||
# ================================
|
||||
# Tailscale 서비스 시작
|
||||
# ================================
|
||||
start_tailscale() {
|
||||
print_status "Tailscale 서비스 시작 중..."
|
||||
|
||||
# systemd 서비스 활성화 및 시작
|
||||
systemctl enable tailscaled >/dev/null 2>&1 || true
|
||||
systemctl start tailscaled >/dev/null 2>&1 || true
|
||||
|
||||
# 서비스 상태 확인
|
||||
sleep 3
|
||||
if systemctl is-active --quiet tailscaled; then
|
||||
print_success "Tailscaled 서비스가 실행 중입니다."
|
||||
else
|
||||
print_error "Tailscaled 서비스 시작에 실패했습니다."
|
||||
print_info "수동으로 시작을 시도합니다..."
|
||||
/usr/sbin/tailscaled --state=/var/lib/tailscale/tailscaled.state &
|
||||
sleep 5
|
||||
fi
|
||||
}
|
||||
|
||||
# ================================
|
||||
# Headscale 등록
|
||||
# ================================
|
||||
register_headscale() {
|
||||
print_status "Headscale 서버에 등록 중..."
|
||||
|
||||
# 기존 연결 확인
|
||||
if tailscale status >/dev/null 2>&1; then
|
||||
print_warning "이미 Tailscale/Headscale에 연결되어 있습니다."
|
||||
|
||||
# 현재 연결 상태 표시
|
||||
CURRENT_STATUS=$(tailscale status 2>/dev/null | head -5)
|
||||
print_info "현재 연결 상태:"
|
||||
echo "$CURRENT_STATUS"
|
||||
|
||||
# 현재 서버 확인
|
||||
CURRENT_SERVER=$(tailscale status --json 2>/dev/null | grep -o '"CurrentTailnet":[^,]*' | cut -d'"' -f4 2>/dev/null || echo "알 수 없음")
|
||||
TARGET_SERVER=$(echo "$HEADSCALE_SERVER" | sed 's|https\?://||' | sed 's|:[0-9]*||')
|
||||
|
||||
print_info "현재 서버: $CURRENT_SERVER"
|
||||
print_info "대상 서버: $TARGET_SERVER"
|
||||
|
||||
# 강제 등록 옵션 확인
|
||||
if [ "$FORCE_REGISTER" = true ]; then
|
||||
print_warning "강제 재등록 옵션이 활성화되었습니다."
|
||||
print_info "기존 연결을 해제하고 재등록합니다..."
|
||||
tailscale logout >/dev/null 2>&1 || true
|
||||
sleep 3
|
||||
# 같은 서버인지 확인
|
||||
elif [[ "$CURRENT_SERVER" == *"$TARGET_SERVER"* ]] || [[ "$TARGET_SERVER" == *"$CURRENT_SERVER"* ]]; then
|
||||
print_success "이미 올바른 Headscale 서버에 연결되어 있습니다!"
|
||||
print_info "등록을 건너뜁니다."
|
||||
return 0
|
||||
# 대화형 실행인지 확인 (터미널에서 직접 실행)
|
||||
elif [ -t 0 ] && [ -t 1 ]; then
|
||||
print_warning "다른 서버에 연결되어 있습니다."
|
||||
echo -n "기존 연결을 해제하고 팜큐 Headscale로 등록하시겠습니까? (Y/n): "
|
||||
read -r REPLY
|
||||
|
||||
# 기본값을 Y로 변경 (엔터만 누르면 Y)
|
||||
if [[ -z "$REPLY" ]] || [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
print_info "기존 연결을 해제합니다..."
|
||||
tailscale logout >/dev/null 2>&1 || true
|
||||
sleep 3
|
||||
else
|
||||
print_info "등록을 건너뜁니다."
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
# 파이프 실행 시 자동으로 재등록 (기본값: Y)
|
||||
print_warning "다른 서버에 연결되어 있어 자동으로 팜큐 Headscale로 재등록합니다."
|
||||
print_info "기존 연결을 해제합니다..."
|
||||
tailscale logout >/dev/null 2>&1 || true
|
||||
sleep 3
|
||||
fi
|
||||
|
||||
# 추가 확인: 완전히 로그아웃되었는지 검증
|
||||
print_status "연결 해제 확인 중..."
|
||||
for i in {1..10}; do
|
||||
if ! tailscale status >/dev/null 2>&1; then
|
||||
print_success "기존 연결이 완전히 해제되었습니다."
|
||||
break
|
||||
fi
|
||||
print_info "로그아웃 대기 중... ($i/10)"
|
||||
sleep 2
|
||||
|
||||
if [ $i -eq 10 ]; then
|
||||
print_warning "로그아웃이 완료되지 않았지만 계속 진행합니다."
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
print_info "Headscale 서버: $HEADSCALE_SERVER"
|
||||
print_info "Pre-auth Key: ${PREAUTH_KEY:0:8}***************"
|
||||
|
||||
# Headscale 등록 시도
|
||||
print_status "등록 명령 실행 중..."
|
||||
|
||||
if tailscale up \
|
||||
--login-server="$HEADSCALE_SERVER" \
|
||||
--authkey="$PREAUTH_KEY" \
|
||||
--accept-routes \
|
||||
--accept-dns=false >/dev/null 2>&1; then
|
||||
|
||||
print_success "Headscale 등록 성공!"
|
||||
|
||||
else
|
||||
print_error "자동 등록에 실패했습니다. 수동 등록을 진행합니다."
|
||||
|
||||
# 수동 등록 모드
|
||||
print_info "다음 명령을 실행하여 수동 등록하세요:"
|
||||
echo ""
|
||||
echo "tailscale up --login-server=\"$HEADSCALE_SERVER\" --authkey=\"$PREAUTH_KEY\""
|
||||
echo ""
|
||||
|
||||
# 등록 URL 시도
|
||||
REGISTER_URL=$(tailscale up --login-server="$HEADSCALE_SERVER" 2>&1 | grep -o 'https://[^[:space:]]*' | head -1)
|
||||
if [ -n "$REGISTER_URL" ]; then
|
||||
print_info "또는 다음 URL을 방문하여 등록하세요:"
|
||||
echo "$REGISTER_URL"
|
||||
fi
|
||||
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ================================
|
||||
# 연결 상태 확인
|
||||
# ================================
|
||||
verify_connection() {
|
||||
print_status "연결 상태 확인 중..."
|
||||
|
||||
# 잠시 대기 (연결 안정화)
|
||||
sleep 5
|
||||
|
||||
# Tailscale 상태 확인
|
||||
if ! tailscale status >/dev/null 2>&1; then
|
||||
print_error "Tailscale 연결에 문제가 있습니다."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# IP 주소 확인
|
||||
TAILSCALE_IP=$(tailscale ip -4 2>/dev/null || echo "N/A")
|
||||
TAILSCALE_IP6=$(tailscale ip -6 2>/dev/null || echo "N/A")
|
||||
|
||||
print_success "Headscale 네트워크 연결 완료!"
|
||||
print_info "할당된 IPv4: $TAILSCALE_IP"
|
||||
print_info "할당된 IPv6: $TAILSCALE_IP6"
|
||||
|
||||
# 네트워크 테스트
|
||||
print_status "네트워크 연결 테스트 중..."
|
||||
|
||||
if ping -c 3 -W 5 100.64.0.1 >/dev/null 2>&1; then
|
||||
print_success "팜큐 네트워크($FARMQ_NETWORK) 연결 정상!"
|
||||
else
|
||||
print_warning "네트워크 테스트 실패. 방화벽을 확인해주세요."
|
||||
fi
|
||||
|
||||
# 연결된 노드 확인
|
||||
print_info "네트워크 상태:"
|
||||
tailscale status | head -10
|
||||
}
|
||||
|
||||
# ================================
|
||||
# 방화벽 설정 (선택사항)
|
||||
# ================================
|
||||
configure_firewall() {
|
||||
print_status "방화벽 설정 확인 중..."
|
||||
|
||||
# UFW (Ubuntu/Debian)
|
||||
if command -v ufw >/dev/null 2>&1; then
|
||||
print_info "UFW 방화벽 감지됨"
|
||||
if ufw status | grep -q "Status: active"; then
|
||||
print_info "Tailscale 트래픽 허용 중..."
|
||||
ufw allow in on tailscale0 >/dev/null 2>&1 || true
|
||||
ufw allow 41641/udp comment "Tailscale" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# firewalld (CentOS/RHEL/Fedora)
|
||||
if command -v firewall-cmd >/dev/null 2>&1; then
|
||||
print_info "firewalld 방화벽 감지됨"
|
||||
if firewall-cmd --state >/dev/null 2>&1; then
|
||||
print_info "Tailscale 트래픽 허용 중..."
|
||||
firewall-cmd --permanent --add-service=tailscale >/dev/null 2>&1 || true
|
||||
firewall-cmd --permanent --add-port=41641/udp >/dev/null 2>&1 || true
|
||||
firewall-cmd --reload >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
print_success "방화벽 설정 완료"
|
||||
}
|
||||
|
||||
# ================================
|
||||
# 정리 작업
|
||||
# ================================
|
||||
cleanup() {
|
||||
print_status "정리 작업 수행 중..."
|
||||
|
||||
# 임시 파일 정리
|
||||
rm -rf /tmp/tailscale_* >/dev/null 2>&1 || true
|
||||
|
||||
# 시스템 정보 업데이트
|
||||
if command -v updatedb >/dev/null 2>&1; then
|
||||
updatedb >/dev/null 2>&1 &
|
||||
fi
|
||||
|
||||
print_success "정리 작업 완료"
|
||||
}
|
||||
|
||||
# ================================
|
||||
# 최종 정보 출력
|
||||
# ================================
|
||||
show_final_info() {
|
||||
print_header "팜큐 Headscale 설치 완료!"
|
||||
|
||||
# 시스템 정보
|
||||
HOSTNAME=$(hostname)
|
||||
TAILSCALE_IP=$(tailscale ip -4 2>/dev/null || echo "N/A")
|
||||
|
||||
echo -e "${GREEN}🎉 설치가 성공적으로 완료되었습니다!${NC}\n"
|
||||
|
||||
echo -e "${CYAN}📋 시스템 정보:${NC}"
|
||||
echo -e " 호스트명: $HOSTNAME"
|
||||
echo -e " Tailscale IP: $TAILSCALE_IP"
|
||||
echo -e " OS: $OS $VERSION"
|
||||
echo -e " Headscale 서버: $HEADSCALE_SERVER"
|
||||
|
||||
echo -e "\n${YELLOW}🔧 유용한 명령어:${NC}"
|
||||
echo -e " tailscale status # 연결 상태 확인"
|
||||
echo -e " tailscale ip # 할당된 IP 확인"
|
||||
echo -e " tailscale ping <node> # 다른 노드와 연결 테스트"
|
||||
echo -e " tailscale logout # 네트워크에서 해제"
|
||||
|
||||
echo -e "\n${PURPLE}🌐 팜큐 관리자 페이지:${NC}"
|
||||
echo -e " http://192.168.0.151:5002"
|
||||
echo -e " http://192.168.0.151:5002/vms (VM 관리)"
|
||||
|
||||
echo -e "\n${WHITE}문제가 있을 경우 로그를 확인하세요:${NC}"
|
||||
echo -e " journalctl -u tailscaled -f"
|
||||
|
||||
print_header "설치 완료 - 팜큐 네트워크를 사용할 수 있습니다!"
|
||||
}
|
||||
|
||||
# ================================
|
||||
# 메인 함수
|
||||
# ================================
|
||||
main() {
|
||||
print_header "팜큐(FARMQ) Headscale 원클릭 설치"
|
||||
|
||||
# 사전 체크
|
||||
detect_os
|
||||
check_requirements
|
||||
|
||||
# 설치 과정
|
||||
install_tailscale
|
||||
start_tailscale
|
||||
register_headscale
|
||||
|
||||
# 사후 설정
|
||||
configure_firewall
|
||||
verify_connection
|
||||
|
||||
# 정리 및 완료
|
||||
cleanup
|
||||
show_final_info
|
||||
}
|
||||
|
||||
# ================================
|
||||
# 에러 핸들링
|
||||
# ================================
|
||||
trap 'echo -e "\n❌ 설치 중 오류가 발생했습니다. 로그를 확인해주세요."; exit 1' ERR
|
||||
|
||||
# 스크립트 실행
|
||||
main "$@"
|
||||
162
register-client.sh
Executable file
162
register-client.sh
Executable file
@@ -0,0 +1,162 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 팜큐(FARMQ) Headscale 클라이언트 등록 스크립트
|
||||
# 사용법: ./register-client.sh
|
||||
|
||||
set -e
|
||||
|
||||
# 설정
|
||||
HEADSCALE_SERVER="https://head.0bin.in"
|
||||
PREAUTH_KEY="fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21"
|
||||
|
||||
# 색상 출력 함수
|
||||
print_status() {
|
||||
echo -e "\n🔧 $1"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "\n✅ $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "\n❌ $1"
|
||||
}
|
||||
|
||||
print_info() {
|
||||
echo -e "\n📋 $1"
|
||||
}
|
||||
|
||||
# 운영체제 감지
|
||||
detect_os() {
|
||||
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||
if command -v apt &> /dev/null; then
|
||||
OS="ubuntu"
|
||||
elif command -v yum &> /dev/null; then
|
||||
OS="centos"
|
||||
else
|
||||
OS="linux"
|
||||
fi
|
||||
elif [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
OS="macos"
|
||||
elif [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "cygwin" ]]; then
|
||||
OS="windows"
|
||||
else
|
||||
OS="unknown"
|
||||
fi
|
||||
echo $OS
|
||||
}
|
||||
|
||||
# Tailscale 설치 확인 및 설치
|
||||
install_tailscale() {
|
||||
OS=$(detect_os)
|
||||
|
||||
if command -v tailscale &> /dev/null; then
|
||||
print_info "Tailscale이 이미 설치되어 있습니다."
|
||||
return 0
|
||||
fi
|
||||
|
||||
print_status "Tailscale 설치 중..."
|
||||
|
||||
case $OS in
|
||||
"ubuntu")
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
;;
|
||||
"centos")
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
;;
|
||||
"macos")
|
||||
echo "macOS용 Tailscale을 다운로드합니다."
|
||||
echo "다음 URL에서 수동으로 설치하세요: https://tailscale.com/download/mac"
|
||||
exit 1
|
||||
;;
|
||||
"windows")
|
||||
echo "Windows용 Tailscale을 다운로드합니다."
|
||||
echo "다음 URL에서 수동으로 설치하세요: https://tailscale.com/download/windows"
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
print_error "지원되지 않는 운영체제입니다: $OSTYPE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 기존 Tailscale 연결 해제
|
||||
disconnect_existing() {
|
||||
if tailscale status --json &> /dev/null; then
|
||||
local current_status=$(tailscale status --json 2>/dev/null || echo "{}")
|
||||
if echo "$current_status" | grep -q '"BackendState":"Running"'; then
|
||||
print_status "기존 Tailscale 연결을 해제합니다..."
|
||||
sudo tailscale logout || true
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Headscale에 등록
|
||||
register_to_headscale() {
|
||||
print_status "팜큐 Headscale 서버에 등록 중..."
|
||||
print_info "서버: $HEADSCALE_SERVER"
|
||||
|
||||
# Tailscale을 Headscale 서버로 설정하고 등록
|
||||
sudo tailscale up \
|
||||
--login-server="$HEADSCALE_SERVER" \
|
||||
--authkey="$PREAUTH_KEY" \
|
||||
--accept-routes \
|
||||
--accept-dns=false
|
||||
}
|
||||
|
||||
# 연결 상태 확인
|
||||
check_connection() {
|
||||
print_status "연결 상태 확인 중..."
|
||||
|
||||
# 잠시 대기
|
||||
sleep 3
|
||||
|
||||
# 상태 확인
|
||||
if tailscale status &> /dev/null; then
|
||||
local tailscale_ip=$(tailscale ip -4 2>/dev/null || echo "")
|
||||
if [[ -n "$tailscale_ip" ]]; then
|
||||
print_success "성공적으로 연결되었습니다!"
|
||||
print_info "할당된 IP: $tailscale_ip"
|
||||
|
||||
print_info "네트워크 상태:"
|
||||
tailscale status
|
||||
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
print_error "연결에 실패했습니다."
|
||||
print_info "수동으로 상태를 확인해보세요: tailscale status"
|
||||
return 1
|
||||
}
|
||||
|
||||
# 메인 함수
|
||||
main() {
|
||||
echo "=========================================="
|
||||
echo " 🏥 팜큐(FARMQ) Headscale 클라이언트 등록"
|
||||
echo "=========================================="
|
||||
|
||||
# 루트 권한 확인
|
||||
if [[ $EUID -ne 0 ]] && ! sudo -n true 2>/dev/null; then
|
||||
print_error "이 스크립트는 sudo 권한이 필요합니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 단계별 실행
|
||||
install_tailscale
|
||||
disconnect_existing
|
||||
register_to_headscale
|
||||
|
||||
if check_connection; then
|
||||
print_success "🎉 등록 완료!"
|
||||
print_info "이제 팜큐 네트워크에 연결되었습니다."
|
||||
print_info "문제가 있으면 관리자에게 문의하세요."
|
||||
else
|
||||
print_error "등록 과정에서 문제가 발생했습니다."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 스크립트 실행
|
||||
main "$@"
|
||||
7
start.sh
7
start.sh
@@ -67,8 +67,9 @@ echo ""
|
||||
echo "🎉 설치 완료!"
|
||||
echo ""
|
||||
echo "📋 접속 정보:"
|
||||
echo " - Headscale API: http://localhost:8080"
|
||||
echo " - Headplane UI: http://localhost:3000"
|
||||
echo " - Headscale API: http://localhost:8070"
|
||||
echo " - Headplane UI: http://localhost:3000/admin/"
|
||||
echo " - 외부 접속: http://192.168.0.151:3000/admin/"
|
||||
echo " - API 키: $API_KEY"
|
||||
echo ""
|
||||
echo "📖 다음 단계:"
|
||||
@@ -79,7 +80,7 @@ echo " 2. Pre-auth 키 생성:"
|
||||
echo " docker-compose exec headscale headscale preauthkeys create --user myuser --reusable --expiration 24h"
|
||||
echo ""
|
||||
echo " 3. 클라이언트 연결:"
|
||||
echo " tailscale up --login-server=http://localhost:8080"
|
||||
echo " tailscale up --login-server=http://localhost:8070"
|
||||
echo ""
|
||||
echo "📊 상태 확인:"
|
||||
echo " docker-compose ps"
|
||||
|
||||
284
test_headscale_models.py
Normal file
284
test_headscale_models.py
Normal file
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Headscale Database Model Test Script
|
||||
테스트를 위해 실제 SQLite DB에 연결하여 데이터 조회
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
# Add current directory to path for importing models
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
try:
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from headscale_models import (
|
||||
User, Node, PreAuthKey, ApiKey, Policy,
|
||||
PharmacyInfo, MachineSpecs, MonitoringData,
|
||||
create_all_tables
|
||||
)
|
||||
print("✅ SQLAlchemy models imported successfully")
|
||||
except ImportError as e:
|
||||
print(f"❌ Failed to import models: {e}")
|
||||
print("💡 Install required packages: pip install sqlalchemy")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def test_database_connection():
|
||||
"""데이터베이스 연결 테스트"""
|
||||
db_path = Path("data/db.sqlite")
|
||||
if not db_path.exists():
|
||||
print(f"❌ Database file not found: {db_path}")
|
||||
return None
|
||||
|
||||
DATABASE_URL = f"sqlite:///{db_path}"
|
||||
print(f"🔗 Connecting to: {DATABASE_URL}")
|
||||
|
||||
try:
|
||||
engine = create_engine(DATABASE_URL)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
session = SessionLocal()
|
||||
|
||||
# Test connection with a simple query
|
||||
result = session.execute(text("SELECT COUNT(*) FROM users")).scalar()
|
||||
print(f"✅ Database connection successful. User count: {result}")
|
||||
return session, engine
|
||||
except Exception as e:
|
||||
print(f"❌ Database connection failed: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
def test_user_model(session):
|
||||
"""User 모델 테스트"""
|
||||
print("\n" + "="*50)
|
||||
print("📊 TESTING USER MODEL")
|
||||
print("="*50)
|
||||
|
||||
users = session.query(User).all()
|
||||
print(f"📋 Total users: {len(users)}")
|
||||
|
||||
for user in users:
|
||||
print(f"\n👤 {user}")
|
||||
print(f" - Created: {user.created_at}")
|
||||
print(f" - Display Name: {user.display_name or 'Not set'}")
|
||||
print(f" - Email: {user.email or 'Not set'}")
|
||||
print(f" - Deleted: {user.is_deleted()}")
|
||||
print(f" - Nodes Count: {len(user.nodes)}")
|
||||
|
||||
|
||||
def test_node_model(session):
|
||||
"""Node 모델 테스트"""
|
||||
print("\n" + "="*50)
|
||||
print("💻 TESTING NODE MODEL")
|
||||
print("="*50)
|
||||
|
||||
nodes = session.query(Node).all()
|
||||
print(f"📋 Total nodes: {len(nodes)}")
|
||||
|
||||
for node in nodes:
|
||||
print(f"\n🖥️ {node}")
|
||||
print(f" - Given Name: {node.given_name}")
|
||||
print(f" - User: {node.user.name if node.user else 'None'}")
|
||||
print(f" - Online: {'🟢 Yes' if node.is_online() else '🔴 No'}")
|
||||
print(f" - Last Seen: {node.last_seen}")
|
||||
print(f" - Endpoints: {len(node.get_endpoints())} endpoint(s)")
|
||||
|
||||
# Host info details
|
||||
host_info = node.get_host_info()
|
||||
if host_info:
|
||||
print(f" - OS: {host_info.get('OS', 'Unknown')} {host_info.get('OSVersion', '')}")
|
||||
print(f" - Hostname: {host_info.get('Hostname', 'Unknown')}")
|
||||
print(f" - Machine: {host_info.get('Machine', 'Unknown')}")
|
||||
|
||||
|
||||
def test_api_key_model(session):
|
||||
"""API Key 모델 테스트"""
|
||||
print("\n" + "="*50)
|
||||
print("🔑 TESTING API KEY MODEL")
|
||||
print("="*50)
|
||||
|
||||
api_keys = session.query(ApiKey).all()
|
||||
print(f"📋 Total API keys: {len(api_keys)}")
|
||||
|
||||
for key in api_keys:
|
||||
print(f"\n🔐 {key}")
|
||||
print(f" - Expired: {'❌ Yes' if key.is_expired() else '✅ No'}")
|
||||
print(f" - Created: {key.created_at}")
|
||||
print(f" - Expires: {key.expiration}")
|
||||
print(f" - Last Used: {key.last_seen or 'Never'}")
|
||||
|
||||
|
||||
def test_pre_auth_key_model(session):
|
||||
"""Pre-Auth Key 모델 테스트"""
|
||||
print("\n" + "="*50)
|
||||
print("🎫 TESTING PRE-AUTH KEY MODEL")
|
||||
print("="*50)
|
||||
|
||||
pre_auth_keys = session.query(PreAuthKey).all()
|
||||
print(f"📋 Total pre-auth keys: {len(pre_auth_keys)}")
|
||||
|
||||
for key in pre_auth_keys:
|
||||
print(f"\n🎟️ {key}")
|
||||
print(f" - User: {key.user.name if key.user else 'None'}")
|
||||
print(f" - Reusable: {'✅ Yes' if key.reusable else '❌ No'}")
|
||||
print(f" - Used: {'✅ Yes' if key.used else '❌ No'}")
|
||||
print(f" - Valid: {'✅ Yes' if key.is_valid() else '❌ No'}")
|
||||
print(f" - Expires: {key.expiration}")
|
||||
print(f" - Tags: {key.get_tags()}")
|
||||
|
||||
|
||||
def test_policy_model(session):
|
||||
"""Policy 모델 테스트"""
|
||||
print("\n" + "="*50)
|
||||
print("📜 TESTING POLICY MODEL")
|
||||
print("="*50)
|
||||
|
||||
policies = session.query(Policy).all()
|
||||
print(f"📋 Total policies: {len(policies)}")
|
||||
|
||||
for policy in policies:
|
||||
print(f"\n📄 {policy}")
|
||||
policy_data = policy.get_policy_data()
|
||||
if policy_data:
|
||||
print(f" - ACL Rules: {len(policy_data.get('acls', []))}")
|
||||
print(f" - Groups: {len(policy_data.get('groups', {}))}")
|
||||
|
||||
|
||||
def create_sample_extended_data(session, engine):
|
||||
"""확장 테이블용 샘플 데이터 생성"""
|
||||
print("\n" + "="*50)
|
||||
print("🏥 CREATING SAMPLE PHARMACY DATA")
|
||||
print("="*50)
|
||||
|
||||
# Create extended tables
|
||||
create_all_tables(engine)
|
||||
|
||||
# Get first user
|
||||
user = session.query(User).first()
|
||||
if not user:
|
||||
print("❌ No users found. Cannot create pharmacy info.")
|
||||
return
|
||||
|
||||
# Check if pharmacy info already exists
|
||||
existing_pharmacy = session.query(PharmacyInfo).filter_by(user_id=user.name).first()
|
||||
if existing_pharmacy:
|
||||
print(f"ℹ️ Pharmacy info already exists for user '{user.name}'")
|
||||
return
|
||||
|
||||
# Create pharmacy info
|
||||
pharmacy = PharmacyInfo(
|
||||
user_id=user.name,
|
||||
pharmacy_name="서울중앙약국",
|
||||
business_number="123-45-67890",
|
||||
address="서울시 강남구 테헤란로 123",
|
||||
phone="02-1234-5678",
|
||||
manager_name="홍길동",
|
||||
proxmox_host="192.168.1.100",
|
||||
proxmox_api_token="sample_token_here"
|
||||
)
|
||||
session.add(pharmacy)
|
||||
|
||||
# Get first node
|
||||
node = session.query(Node).first()
|
||||
if node:
|
||||
# Create machine specs
|
||||
specs = MachineSpecs(
|
||||
machine_id=node.id,
|
||||
pharmacy_id=1, # Will be set properly after pharmacy is committed
|
||||
cpu_model="Intel Core i7-12700",
|
||||
cpu_cores=12,
|
||||
ram_gb=32,
|
||||
storage_gb=1000,
|
||||
gpu_model="NVIDIA GTX 1660"
|
||||
)
|
||||
session.add(specs)
|
||||
|
||||
# Create monitoring data
|
||||
monitoring = MonitoringData(
|
||||
machine_id=node.id,
|
||||
cpu_usage="75.50",
|
||||
memory_usage="60.25",
|
||||
disk_usage="45.00",
|
||||
cpu_temperature=65,
|
||||
network_rx_bytes=1024000,
|
||||
network_tx_bytes=512000,
|
||||
vm_count=5,
|
||||
vm_running=4
|
||||
)
|
||||
session.add(monitoring)
|
||||
|
||||
try:
|
||||
session.commit()
|
||||
print("✅ Sample extended data created successfully")
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
print(f"❌ Failed to create sample data: {e}")
|
||||
|
||||
|
||||
def test_extended_models(session):
|
||||
"""확장된 모델 테스트"""
|
||||
print("\n" + "="*50)
|
||||
print("🏥 TESTING EXTENDED MODELS (FARMQ)")
|
||||
print("="*50)
|
||||
|
||||
# Test pharmacy info
|
||||
pharmacies = session.query(PharmacyInfo).all()
|
||||
print(f"🏪 Total pharmacies: {len(pharmacies)}")
|
||||
for pharmacy in pharmacies:
|
||||
print(f" - {pharmacy}")
|
||||
|
||||
# Test machine specs
|
||||
specs = session.query(MachineSpecs).all()
|
||||
print(f"⚙️ Total machine specs: {len(specs)}")
|
||||
for spec in specs:
|
||||
print(f" - {spec}")
|
||||
|
||||
# Test monitoring data
|
||||
monitoring = session.query(MonitoringData).all()
|
||||
print(f"📊 Total monitoring records: {len(monitoring)}")
|
||||
for monitor in monitoring:
|
||||
print(f" - {monitor}")
|
||||
|
||||
|
||||
def main():
|
||||
"""메인 테스트 함수"""
|
||||
print("🧪 HEADSCALE DATABASE MODEL TEST")
|
||||
print("=" * 60)
|
||||
|
||||
# Connect to database
|
||||
session, engine = test_database_connection()
|
||||
if not session:
|
||||
return
|
||||
|
||||
try:
|
||||
# Test core models
|
||||
test_user_model(session)
|
||||
test_node_model(session)
|
||||
test_api_key_model(session)
|
||||
test_pre_auth_key_model(session)
|
||||
test_policy_model(session)
|
||||
|
||||
# Create sample extended data (if needed)
|
||||
create_sample_extended_data(session, engine)
|
||||
|
||||
# Test extended models
|
||||
test_extended_models(session)
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("🎉 ALL TESTS COMPLETED SUCCESSFULLY!")
|
||||
print("="*60)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ Test failed with error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user