Compare commits

..

No commits in common. "feature/working-headscale-setup" and "main" have entirely different histories.

277 changed files with 25 additions and 61308 deletions

View File

@ -2,7 +2,7 @@
HEADSCALE_API_KEY=your_api_key_here
# Server configuration
SERVER_URL=http://localhost:8070
SERVER_URL=http://localhost:8080
LISTEN_ADDR=0.0.0.0:8080
# Database (SQLite by default)

99
.gitignore vendored
View File

@ -61,101 +61,4 @@ tmp/
temp/
# Docker Compose override files
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/
docker-compose.override.yml

View File

@ -1,301 +0,0 @@
# 🔗 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 솔루션이 구축되었습니다!

View File

@ -1,193 +0,0 @@
# 팜큐(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`

View File

@ -1,351 +0,0 @@
# FARMQ Admin 구현 가이드 및 설계 원칙
## 📋 개요
FARMQ Admin은 Headscale을 기반으로 한 100개 약국 네트워크 관리 시스템의 웹 인터페이스입니다. Headplane의 기능을 대체하면서 추가적인 약국 관리 기능을 제공하는 통합 관리 플랫폼입니다.
## 🏗️ 아키텍처 설계 원칙
### 핵심 설계 철학
```
FARMQ Admin (Frontend/API) → Headscale CLI (Backend Engine) → Network Management
```
**FARMQ Admin**은 **프론트엔드 인터페이스**이고, **Headscale**은 **백엔드 엔진**으로 작동합니다.
모든 네트워크 관리 기능은 **Headscale CLI를 통해 제어**되며, FARMQ Admin은 이를 웹 인터페이스로 래핑합니다.
### 계층 구조
```
┌─────────────────────────────────────┐
│ FARMQ Admin │ ← 웹 UI, 약국 관리, 대시보드
│ (Flask + Bootstrap + JS) │
├─────────────────────────────────────┤
│ API Layer │ ← REST API, CLI 인터페이스
│ (Python subprocess calls) │
├─────────────────────────────────────┤
│ Headscale CLI │ ← 네트워크 관리 엔진
│ (Docker containerized) │
├─────────────────────────────────────┤
│ Database Layer │ ← 이중 데이터베이스
│ ┌─────────────┬─────────────────┐ │
│ │ FARMQ DB │ Headscale DB │ │
│ │ (약국정보) │ (노드정보) │ │
│ └─────────────┴─────────────────┘ │
└─────────────────────────────────────┘
```
## 🔧 구현 방법론
### 1. CLI 기반 기능 구현 패턴
모든 Headscale 관련 기능은 다음 패턴을 따라 구현합니다:
```python
# 표준 구현 패턴
def headscale_function():
try:
# Docker를 통해 Headscale CLI 실행
result = subprocess.run(
['docker', 'exec', 'headscale', 'headscale', 'command', 'args'],
capture_output=True,
text=True,
check=True
)
# JSON 출력 파싱 (가능한 경우)
if '-o json' in args:
data = json.loads(result.stdout)
return data
return {'success': True, 'output': result.stdout}
except subprocess.CalledProcessError as e:
return {'success': False, 'error': e.stderr}
```
#### 구현 예시들
**1. 실시간 온라인 상태 조회**
```python
def get_headscale_online_status() -> Dict[str, bool]:
"""Headscale CLI를 통해 실시간 온라인 상태 조회"""
result = subprocess.run([
'docker', 'exec', 'headscale',
'headscale', 'nodes', 'list', '-o', 'json'
], capture_output=True, text=True, check=True)
nodes_data = json.loads(result.stdout)
online_status = {}
for node in nodes_data:
node_name = node.get('given_name') or node.get('name', '')
is_online = node.get('online', False) == True
online_status[node_name.lower()] = is_online
return online_status
```
**2. 노드 삭제 기능**
```python
@app.route('/api/nodes/<int:node_id>/delete', methods=['DELETE'])
def api_delete_node(node_id):
"""노드 삭제 API"""
result = subprocess.run([
'docker', 'exec', 'headscale',
'headscale', 'nodes', 'delete',
'-i', str(node_id), '--force'
], capture_output=True, text=True, check=True)
return jsonify({
'success': True,
'message': f'노드 {node_id}가 성공적으로 삭제되었습니다.'
})
```
### 2. 이중 데이터베이스 전략
#### FARMQ Database (자체 관리)
- **목적**: 약국 정보, 관리자 데이터, 커스텀 설정
- **특징**: 완전한 제어권, 외래키 제약 없음, 능동적 관리
- **테이블**: `pharmacy_info`, `machine_profiles`, `monitoring_metrics`, `system_alerts`
```python
# FARMQ DB - 약국 정보 관리
class PharmacyInfo(FarmqBase):
__tablename__ = 'pharmacy_info'
id = Column(Integer, primary_key=True)
pharmacy_name = Column(String(100), nullable=False)
business_number = Column(String(20), unique=True)
manager_name = Column(String(50))
headscale_user_name = Column(String(50)) # Headscale과 연결점
```
#### Headscale Database (읽기 전용)
- **목적**: 네트워크 노드 정보, 실시간 상태
- **특징**: 읽기 전용 접근, Headscale이 관리
- **활용**: 실시간 쿼리로 최신 상태 반영
```python
# Headscale DB - 읽기 전용 조회
def get_dashboard_stats():
headscale_session = get_headscale_session()
# 실시간 노드 상태
active_nodes = headscale_session.query(Node).filter(
Node.deleted_at.is_(None)
).all()
# CLI로 온라인 상태 확인
online_status = get_headscale_online_status()
# 두 데이터 소스 결합
for node in active_nodes:
node_name = (node.given_name or '').lower()
is_online = online_status.get(node_name, False)
```
### 3. 실시간 동기화 전략
#### 기존 문제점
- 타임아웃 기반 온라인 판단 (부정확)
- 캐시된 데이터 사용 (지연)
- Headplane과 상태 불일치
#### 해결책: 직접 CLI 조회
```javascript
// 실시간 업데이트 (프론트엔드)
function updateStats() {
fetch('/api/dashboard/stats')
.then(response => response.json())
.then(stats => {
// Headplane과 동일한 3/5 온라인 표시
document.querySelector('[data-stat="online"]').textContent = stats.online_machines;
document.querySelector('[data-stat="offline"]').textContent = stats.offline_machines;
});
}
// 10초마다 업데이트 (Headplane보다 빠름)
setInterval(updateStats, 10000);
```
## 🚀 확장 가능한 기능 구현 로드맵
### Phase 1: 기본 Headplane 기능 대체 ✅
- [x] 실시간 노드 상태 동기화
- [x] 노드 삭제 기능
- [x] 대시보드 통계
- [x] 머신 목록 관리
### Phase 2: 고급 네트워크 관리 기능
- [ ] **노드 이름 변경**
```python
@app.route('/api/nodes/<int:node_id>/rename', methods=['POST'])
def api_rename_node(node_id):
new_name = request.json.get('new_name')
subprocess.run(['docker', 'exec', 'headscale',
'headscale', 'nodes', 'rename',
'-i', str(node_id), new_name])
```
- [ ] **노드 만료/로그아웃**
```python
@app.route('/api/nodes/<int:node_id>/expire', methods=['POST'])
def api_expire_node(node_id):
subprocess.run(['docker', 'exec', 'headscale',
'headscale', 'nodes', 'expire',
'-i', str(node_id)])
```
- [ ] **라우트 관리**
```python
@app.route('/api/nodes/<int:node_id>/routes', methods=['GET'])
def api_node_routes(node_id):
result = subprocess.run(['docker', 'exec', 'headscale',
'headscale', 'nodes', 'list-routes',
'-i', str(node_id), '-o', 'json'])
```
### Phase 3: 약국별 네트워크 관리
- [ ] **약국별 사용자 그룹 관리**
```python
@app.route('/api/pharmacy/<int:pharmacy_id>/users', methods=['GET'])
def api_pharmacy_users(pharmacy_id):
# 약국에 속한 Headscale 사용자 조회
pharmacy = get_pharmacy_by_id(pharmacy_id)
subprocess.run(['docker', 'exec', 'headscale',
'headscale', 'users', 'list', '-o', 'json'])
```
- [ ] **약국별 PreAuth Key 생성**
```python
@app.route('/api/pharmacy/<int:pharmacy_id>/preauth-key', methods=['POST'])
def api_create_pharmacy_preauth_key(pharmacy_id):
pharmacy = get_pharmacy_by_id(pharmacy_id)
user_name = pharmacy.headscale_user_name
subprocess.run(['docker', 'exec', 'headscale',
'headscale', 'preauthkeys', 'create',
'--user', user_name, '--reusable'])
```
### Phase 4: 고급 모니터링 및 자동화
- [ ] **실시간 네트워크 토폴로지**
- [ ] **자동 장애 감지 및 알림**
- [ ] **성능 메트릭 수집**
- [ ] **백업 및 복구 자동화**
## 🎯 개발 가이드라인
### 1. 모든 새 기능은 CLI 우선
```python
# ❌ 잘못된 접근
def bad_implementation():
# 직접 DB 조작 시도
session.execute("UPDATE nodes SET ...")
# ✅ 올바른 접근
def good_implementation():
# Headscale CLI 사용
subprocess.run(['docker', 'exec', 'headscale', 'headscale', 'command'])
```
### 2. 에러 처리 표준화
```python
def standard_error_handling():
try:
result = subprocess.run(headscale_command, check=True)
return {'success': True, 'data': result.stdout}
except subprocess.CalledProcessError as e:
return {'success': False, 'error': e.stderr}
except Exception as e:
return {'success': False, 'error': f'서버 오류: {str(e)}'}
```
### 3. UI 일관성 유지
```javascript
// 표준 삭제 확인 패턴
function confirmDelete(itemType, itemName, deleteFunction) {
if (confirm(`정말로 ${itemType} "${itemName}"를 삭제하시겠습니까?\n\n삭제된 항목은 복구할 수 없습니다.`)) {
deleteFunction();
}
}
// 표준 피드백 패턴
function showFeedback(message, type = 'info') {
showToast(message, type);
if (type === 'success') {
setTimeout(() => location.reload(), 1500);
}
}
```
## 🔍 디버깅 및 로깅
### CLI 호출 로깅
```python
def log_cli_call(command, result):
print(f"🔧 Headscale CLI: {' '.join(command)}")
print(f"📤 Output: {result.stdout}")
if result.stderr:
print(f"⚠️ Error: {result.stderr}")
```
### 프론트엔드 상태 디버깅
```javascript
// 개발 모드에서만 활성화
if (window.location.hostname === 'localhost') {
console.log('🔍 FARMQ Admin Debug Mode');
window.farmqDebug = {
showNodeStatus: () => console.table(onlineStatus),
refreshStats: updateStats,
testAPI: (endpoint) => fetch(endpoint).then(r => r.json())
};
}
```
## 📊 성능 최적화
### 1. CLI 호출 최적화
- JSON 출력 사용으로 파싱 효율화
- 불필요한 CLI 호출 최소화
- 결과 캐싱 (단기간)
### 2. 프론트엔드 최적화
- 실시간 업데이트 주기 조정 (10초)
- 필요한 데이터만 요청
- 사용자 상호작용 우선순위
## 🔐 보안 고려사항
### 1. CLI 명령 검증
```python
def validate_node_id(node_id):
if not isinstance(node_id, int) or node_id <= 0:
raise ValueError("Invalid node ID")
return node_id
def sanitize_command_args(args):
# 특수문자 및 인젝션 방지
return [arg for arg in args if is_safe_arg(arg)]
```
### 2. 권한 관리
- API 엔드포인트별 권한 확인
- 약국별 데이터 접근 제한
- 관리자/사용자 역할 구분
## 📝 결론
FARMQ Admin은 **Headscale CLI를 core engine으로 활용**하는 **웹 프론트엔드 래퍼**입니다.
이 접근 방식을 통해:
1. **Headplane과 100% 호환성** 유지
2. **실시간 정확한 상태** 반영
3. **확장 가능한 구조** 제공
4. **약국 특화 기능** 추가 가능
모든 새로운 기능은 이 원칙을 따라 구현하여 **일관성 있고 안정적인 시스템**을 구축합니다.
---
*Generated with [Claude Code](https://claude.ai/code) - FARMQ Admin Implementation Guide v1.0*

View File

@ -1,289 +0,0 @@
# FarmQ Admin 머신 이름 표시 문제 해결 계획
## 📋 문제 상황
현재 FarmQ Admin의 머신 관리 페이지에서 **머신 이름이 hostname으로만 표시**되고 있어, Magic DNS에서 사용되는 실제 노드 이름(`given_name`)과 일치하지 않는 문제가 발생하고 있습니다.
## 🔍 문제 분석
### 1. 현재 상황
**Headscale CLI API 실제 데이터:**
```json
{
"id": 1,
"name": "0bin-Ubuntu-VM", // ❌ 실제 Magic DNS에서 사용되는 이름
"given_name": "0bin-ubuntu-vm", // ✅ Magic DNS 접두어 (pev.headscale.local)
"ip_addresses": ["100.64.0.1"],
"user": {"name": "myuser"},
"online": true
}
```
**FarmQ Admin 현재 표시:**
```html
<strong>{{ machine_data.machine_name or machine_data.hostname }}</strong>
<div class="small text-muted">{{ machine_data.hostname }}</div>
```
### 2. 문제의 근본 원인
#### A. 데이터베이스 스키마 불일치
**Headscale 모델:**
```python
# models/headscale_models.py
class Node:
hostname = Column(String) # 시스템 호스트명 (0bin-Ubuntu-VM)
given_name = Column(String) # 사용자 지정 이름 (0bin-ubuntu-vm) - Magic DNS용
```
**FarmQ 모델:**
```python
# models/farmq_models.py
class MachineProfile:
hostname = Column(String) # hostname으로 복사됨
machine_name = Column(String) # hostname으로 중복 저장됨 ❌
```
#### B. 동기화 로직 문제
```python
# utils/database_new.py (line 343)
machine_data = {
'hostname': node.hostname, # "0bin-Ubuntu-VM"
'machine_name': node.hostname, # ❌ hostname 중복! given_name이어야 함
'tailscale_ip': node.ipv4,
}
```
#### C. 템플릿 표시 문제
```html
<!-- templates/machines/list.html (line 115) -->
<strong>{{ machine_data.machine_name or machine_data.hostname }}</strong>
<!-- 현재: "0bin-Ubuntu-VM" (hostname) -->
<!-- 원하는: "0bin-ubuntu-vm" (given_name) -->
```
## ✅ 해결 방안
### 1. 즉시 수정 (Quick Fix)
#### A. 데이터베이스 동기화 로직 수정
```python
# farmq-admin/utils/database_new.py
def get_all_machines_with_details():
machine_data = {
'hostname': node.hostname, # 시스템 호스트명
'machine_name': node.given_name, # ✅ Magic DNS 이름으로 변경
'display_name': node.given_name or node.hostname, # 표시용 이름
'tailscale_ip': node.ipv4,
}
```
#### B. FarmQ 모델 동기화 수정
```python
# models/farmq_models.py - sync_machine_from_headscale
machine = MachineProfile(
hostname=headscale_node_data.get('hostname'),
machine_name=headscale_node_data.get('given_name'), # ✅ given_name 사용
tailscale_ip=headscale_node_data.get('ipv4'),
)
```
#### C. 템플릿 표시 개선
```html
<!-- templates/machines/list.html -->
<strong>{{ machine_data.machine_name or machine_data.hostname }}</strong>
<div class="small text-muted">
<i class="fas fa-network-wired"></i> {{ machine_data.machine_name }}.headscale.local
</div>
<div class="small text-muted">
<i class="fas fa-server"></i> {{ machine_data.hostname }}
</div>
```
### 2. 근본적 개선 (Long-term)
#### A. 필드 명칭 명확화
```python
class MachineProfile:
system_hostname = Column(String) # 시스템 호스트명 (0bin-Ubuntu-VM)
headscale_name = Column(String) # Headscale given_name (0bin-ubuntu-vm)
magic_dns_name = Column(String) # Magic DNS 전체 이름 (0bin-ubuntu-vm.headscale.local)
display_name = Column(String) # 사용자 표시용 이름
```
#### B. Magic DNS 정보 표시 강화
```html
<div class="machine-info">
<h5>{{ machine.display_name }}</h5>
<div class="dns-info">
<code>{{ machine.magic_dns_name }}</code>
<button class="btn btn-sm" onclick="copyToClipboard('{{ machine.magic_dns_name }}')">
<i class="fas fa-copy"></i>
</button>
</div>
<div class="system-info small text-muted">
시스템: {{ machine.system_hostname }}
</div>
</div>
```
## 🚀 구현 단계
### Phase 1: 긴급 수정 (1-2시간)
1. **데이터 동기화 로직 수정**
```bash
# 파일: farmq-admin/utils/database_new.py
# 라인: 343
'machine_name': node.given_name, # hostname → given_name
```
2. **템플릿 표시 개선**
```bash
# 파일: farmq-admin/templates/machines/list.html
# Magic DNS 정보 추가 표시
```
3. **동기화 함수 수정**
```bash
# 파일: farmq-admin/models/farmq_models.py
# sync_machine_from_headscale 함수 수정
```
### Phase 2: 표시 개선 (2-3시간)
1. **Magic DNS 정보 강화 표시**
- `.headscale.local` 접미사 자동 표시
- 클립보드 복사 기능
- 연결 테스트 기능
2. **필터링 기능 추가**
- Magic DNS 이름으로 검색
- 온라인/오프라인 필터
- 사용자별 필터
### Phase 3: 구조적 개선 (4-6시간)
1. **데이터베이스 스키마 개선**
- 필드명 명확화
- Magic DNS 전용 필드 추가
- 인덱스 최적화
2. **API 통합 개선**
- Headscale CLI 응답 캐싱
- 실시간 상태 업데이트
- WebSocket을 통한 실시간 알림
## 📊 예상 결과
### Before (현재)
```
머신 이름: 0bin-Ubuntu-VM # hostname (시스템명)
호스트명: 0bin-Ubuntu-VM # 중복 정보
Magic DNS: 사용불가 ❌ # given_name 정보 부족
```
### After (수정 후)
```
머신 이름: 0bin-ubuntu-vm # given_name (Magic DNS용)
Magic DNS: 0bin-ubuntu-vm.headscale.local ✅
시스템명: 0bin-Ubuntu-VM # hostname (참고 정보)
IP 주소: 100.64.0.1 # 현재와 동일
```
## 🧪 테스트 계획
### 1. 데이터 검증
```python
# 테스트 스크립트
def test_machine_name_mapping():
nodes = headscale_session.query(Node).all()
for node in nodes:
print(f"ID: {node.id}")
print(f"Hostname: {node.hostname}") # 0bin-Ubuntu-VM
print(f"Given Name: {node.given_name}") # 0bin-ubuntu-vm
print(f"Magic DNS: {node.given_name}.headscale.local")
print("---")
```
### 2. Magic DNS 연결 테스트
```bash
# 각 노드별 Magic DNS 테스트
ping 0bin-ubuntu-vm.headscale.local
ping pev.headscale.local
ping pqserver.headscale.local
```
### 3. UI 표시 확인
- 머신 목록에서 올바른 이름 표시
- Magic DNS 주소 복사 기능
- 연결 상태와 일치성 확인
## 📝 체크리스트
### 코드 수정
- [ ] `farmq-admin/utils/database_new.py` - 동기화 로직 수정
- [ ] `farmq-admin/models/farmq_models.py` - 모델 동기화 수정
- [ ] `farmq-admin/templates/machines/list.html` - 표시 개선
- [ ] `farmq-admin/templates/machines/detail.html` - 상세 페이지 수정
### 테스트
- [ ] 데이터베이스 동기화 테스트
- [ ] Magic DNS 이름 표시 확인
- [ ] UI 표시 정상성 확인
- [ ] 기존 기능 호환성 테스트
### 문서화
- [ ] 변경사항 README 업데이트
- [ ] API 문서 갱신
- [ ] 사용자 가이드 수정
## 🔧 구현 파일 목록
### 수정할 파일들
1. **`farmq-admin/utils/database_new.py`** (라인 343)
- `machine_name` 필드를 `given_name`으로 변경
2. **`farmq-admin/models/farmq_models.py`** (라인 445)
- 동기화 시 `given_name` 사용
3. **`farmq-admin/templates/machines/list.html`** (라인 115-119)
- Magic DNS 정보 추가 표시
4. **`farmq-admin/templates/machines/detail.html`**
- 상세 페이지 Magic DNS 정보 개선
### 새로 추가할 기능
- Magic DNS 주소 클립보드 복사
- 연결 테스트 버튼
- 실시간 온라인 상태 표시
## 💡 장기적 개선사항
### 1. Headscale API 직접 통합
현재 CLI 기반 → REST API 직접 호출로 전환하여 성능 개선
### 2. 실시간 모니터링
WebSocket을 통한 실시간 노드 상태 업데이트
### 3. Magic DNS 관리 기능
- 노드 이름 변경
- Magic DNS 도메인 설정
- DNS 해석 테스트 도구
## 📅 구현 일정
| 단계 | 작업 | 소요시간 | 완료일 |
|------|------|---------|---------|
| Phase 1 | 긴급 수정 | 2시간 | 당일 |
| Phase 2 | 표시 개선 | 3시간 | 1일 |
| Phase 3 | 구조 개선 | 6시간 | 2-3일 |
---
**작성일:** 2025년 9월 13일
**업데이트:** FarmQ Admin 머신 이름 표시 문제 분석 및 해결 계획 수립 완료

View File

@ -1,349 +0,0 @@
# 🏥 팜큐(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 인프라 관리 시스템

View File

@ -1,508 +0,0 @@
# 🌐 Flask + Jinja2 Headplane 고도화 관리자 페이지 개발 계획
## 📋 프로젝트 개요
### 개발 목표
기존 Headplane UI를 포크하지 않고, **Flask + Jinja2**로 별도 관리자 페이지를 구축하여 Headscale 데이터베이스와 직접 연동하는 고도화된 관리 시스템 개발
### 핵심 컨셉
- **기존 Headplane**: 기본 기능 유지 (3000번 포트)
- **Flask Admin**: 고도화된 관리 기능 (5000번 포트)
- **데이터 통합**: 동일한 SQLite DB 공유로 실시간 동기화
- **팜큐 특화**: 약국 관리에 최적화된 UI/UX
## 🏗️ 아키텍처 설계
### 시스템 구조
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Headplane UI │ │ Flask Admin │ │ Headscale API │
│ (포트: 3000) │ │ (포트: 5000) │ │ (포트: 8070) │
│ 기본 기능 │ │ 고도화 기능 │ │ 백엔드 API │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│ │ │
└───────────────────────┼───────────────────────┘
┌──────────────────┐
│ SQLite Database │
│ (공유 데이터) │
└──────────────────┘
```
### 포트 구성
- **Headscale API**: 8070 (기존 유지)
- **Headplane UI**: 3000 (기존 유지)
- **Flask Admin**: 5000 (신규 추가)
## 📂 Flask 프로젝트 구조
```
farmq-admin/
├── app.py # Flask 애플리케이션 메인
├── config.py # 설정 파일
├── requirements.txt # Python 의존성
├── models/
│ ├── __init__.py
│ ├── headscale_models.py # SQLAlchemy 모델 (재사용)
│ └── pharmacy_models.py # 팜큐 확장 모델
├── routes/
│ ├── __init__.py
│ ├── dashboard.py # 메인 대시보드
│ ├── pharmacy.py # 약국 관리
│ ├── machines.py # 머신 관리 (고도화)
│ ├── users.py # 사용자 관리 (고도화)
│ ├── monitoring.py # 실시간 모니터링
│ └── api.py # REST API 엔드포인트
├── templates/
│ ├── base.html # 기본 레이아웃
│ ├── dashboard/
│ │ ├── index.html # 메인 대시보드
│ │ └── stats.html # 통계 대시보드
│ ├── pharmacy/
│ │ ├── list.html # 약국 목록
│ │ ├── detail.html # 약국 상세
│ │ ├── create.html # 약국 등록
│ │ └── edit.html # 약국 수정
│ ├── machines/
│ │ ├── list.html # 머신 목록 (고도화)
│ │ ├── detail.html # 머신 상세 (하드웨어 정보)
│ │ └── monitoring.html # 실시간 모니터링
│ └── users/
│ ├── list.html # 사용자 목록 (약국 정보 포함)
│ └── detail.html # 사용자 상세
├── static/
│ ├── css/
│ │ ├── bootstrap.min.css # Bootstrap 5
│ │ ├── custom.css # 커스텀 스타일
│ │ └── dashboard.css # 대시보드 전용 스타일
│ ├── js/
│ │ ├── bootstrap.min.js # Bootstrap JS
│ │ ├── chart.min.js # Chart.js 라이브러리
│ │ ├── dashboard.js # 대시보드 JS
│ │ └── monitoring.js # 실시간 모니터링 JS
│ └── img/
│ ├── logo.png # 팜큐 로고
│ └── icons/ # 아이콘들
├── utils/
│ ├── __init__.py
│ ├── database.py # DB 연결 유틸리티
│ ├── auth.py # 인증 관련
│ ├── monitoring.py # 모니터링 데이터 수집
│ └── proxmox.py # Proxmox API 연동
└── docker/
├── Dockerfile # Flask 앱용 도커파일
└── docker-compose.yml # 통합 컨테이너 구성
```
## 🎨 UI/UX 설계
### 디자인 컨셉
- **Modern Dashboard**: Bootstrap 5 기반 반응형 디자인
- **팜큐 브랜딩**: 약국 관리에 특화된 색상/아이콘 사용
- **Korean-First**: 한국어 우선 인터페이스
- **Mobile Responsive**: 모바일/태블릿 완벽 지원
### 메인 대시보드 레이아웃
```
┌────────────────────────────────────────────────────────────┐
│ 🏥 팜큐 약국 관리 시스템 [관리자: admin] [로그아웃] │
├────────────────────────────────────────────────────────────┤
│ [대시보드] [약국관리] [머신관리] [사용자관리] [모니터링] [설정] │
├────────────────────────────────────────────────────────────┤
│ 📊 전체 현황 │
│ ┌──────────┬──────────┬──────────┬──────────────────────┐ │
│ │총 약국 수 │온라인 │오프라인 │평균 CPU 온도 │ │
│ │ 100 │ 95 │ 5 │ 62°C │ │
│ └──────────┴──────────┴──────────┴──────────────────────┘ │
│ │
│ 🚨 실시간 알림 📈 성능 차트 │
│ ┌─────────────────────────┐ ┌────────────────────┐ │
│ │• 부산해운약국: CPU 85°C │ │ [CPU 사용률 차트] │ │
│ │• 대구중앙약국: 디스크95% │ │ [메모리 사용률] │ │
│ │• 서울약국: 연결 끊김 │ │ [네트워크 트래픽] │ │
│ └─────────────────────────┘ └────────────────────┘ │
│ │
│ 📋 약국별 상태 (실시간) │
│ ┌─────────────┬────────┬────────┬────────┬──────────────┐ │
│ │약국명 │상태 │CPU온도 │메모리 │마지막 접속 │ │
│ ├─────────────┼────────┼────────┼────────┼──────────────┤ │
│ │서울중앙약국 │🟢 온라인│ 65°C │ 80% │ 2분 전 │ │
│ │부산해운약국 │🟡 경고 │ 85°C │ 60% │ 5분 전 │ │
│ │대구중앙약국 │🔴 위험 │ 70°C │ 95% │ 10분 전 │ │
│ └─────────────┴────────┴────────┴────────┴──────────────┘ │
└────────────────────────────────────────────────────────────┘
```
## 🎯 핵심 기능 명세
### 1. 통합 대시보드
```python
# routes/dashboard.py
@app.route('/')
def dashboard():
stats = {
'total_pharmacies': get_pharmacy_count(),
'online_machines': get_online_machines_count(),
'offline_machines': get_offline_machines_count(),
'avg_cpu_temp': get_average_cpu_temperature(),
'alerts': get_active_alerts(),
'recent_activities': get_recent_activities()
}
return render_template('dashboard/index.html', stats=stats)
```
### 2. 약국 관리 시스템
#### 2-1. 약국 목록 페이지
```html
<!-- templates/pharmacy/list.html -->
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between">
<h5>🏥 약국 관리</h5>
<button class="btn btn-primary" onclick="location.href='/pharmacy/create'">
<i class="fas fa-plus"></i> 새 약국 등록
</button>
</div>
<div class="card-body">
<table class="table table-hover">
<thead>
<tr>
<th>약국명</th>
<th>사업자번호</th>
<th>담당자</th>
<th>연결된 머신</th>
<th>상태</th>
<th>마지막 접속</th>
<th>액션</th>
</tr>
</thead>
<tbody>
{% for pharmacy in pharmacies %}
<tr>
<td>
<strong>{{ pharmacy.pharmacy_name }}</strong><br>
<small class="text-muted">{{ pharmacy.address }}</small>
</td>
<td>{{ pharmacy.business_number }}</td>
<td>
{{ pharmacy.manager_name }}<br>
<small class="text-muted">{{ pharmacy.phone }}</small>
</td>
<td>
<span class="badge bg-info">{{ pharmacy.machine_count }}대</span>
</td>
<td>
{% if pharmacy.is_online %}
<span class="badge bg-success">🟢 온라인</span>
{% else %}
<span class="badge bg-danger">🔴 오프라인</span>
{% endif %}
</td>
<td>{{ pharmacy.last_seen_humanized }}</td>
<td>
<div class="btn-group btn-group-sm">
<a href="/pharmacy/{{ pharmacy.id }}" class="btn btn-outline-primary">상세</a>
<a href="/pharmacy/{{ pharmacy.id }}/edit" class="btn btn-outline-warning">수정</a>
<a href="/pharmacy/{{ pharmacy.id }}/monitoring" class="btn btn-outline-info">모니터링</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
```
### 3. 고도화된 머신 관리
#### 3-1. 머신 상세 페이지 (하드웨어 정보 포함)
```html
<!-- templates/machines/detail.html -->
<div class="container-fluid">
<div class="row">
<!-- 기본 정보 -->
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5>🖥️ 머신 기본 정보</h5>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-4">머신명:</dt>
<dd class="col-sm-8">{{ machine.given_name }}</dd>
<dt class="col-sm-4">호스트명:</dt>
<dd class="col-sm-8">{{ machine.hostname }}</dd>
<dt class="col-sm-4">IP 주소:</dt>
<dd class="col-sm-8">
<code>{{ machine.ipv4 }}</code>
</dd>
<dt class="col-sm-4">소속 약국:</dt>
<dd class="col-sm-8">
<a href="/pharmacy/{{ machine.pharmacy.id }}">
{{ machine.pharmacy.pharmacy_name }}
</a>
</dd>
<dt class="col-sm-4">마지막 접속:</dt>
<dd class="col-sm-8">
{% if machine.is_online() %}
<span class="badge bg-success">🟢 온라인</span>
{% else %}
<span class="badge bg-danger">🔴 {{ machine.last_seen_humanized }}</span>
{% endif %}
</dd>
</dl>
</div>
</div>
</div>
<!-- 하드웨어 사양 -->
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5>⚙️ 하드웨어 사양</h5>
</div>
<div class="card-body">
{% if machine.specs %}
<dl class="row">
<dt class="col-sm-4">CPU:</dt>
<dd class="col-sm-8">{{ machine.specs.cpu_model }} ({{ machine.specs.cpu_cores }}코어)</dd>
<dt class="col-sm-4">RAM:</dt>
<dd class="col-sm-8">{{ machine.specs.ram_gb }}GB</dd>
<dt class="col-sm-4">Storage:</dt>
<dd class="col-sm-8">{{ machine.specs.storage_gb }}GB</dd>
<dt class="col-sm-4">GPU:</dt>
<dd class="col-sm-8">{{ machine.specs.gpu_model or '없음' }}</dd>
</dl>
{% else %}
<p class="text-muted">하드웨어 정보가 등록되지 않았습니다.</p>
<a href="/machines/{{ machine.id }}/specs" class="btn btn-outline-primary btn-sm">
하드웨어 정보 등록
</a>
{% endif %}
</div>
</div>
</div>
</div>
<!-- 실시간 모니터링 -->
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5>📊 실시간 모니터링</h5>
</div>
<div class="card-body">
{% if machine.latest_monitoring %}
<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 text-primary">{{ machine.latest_monitoring.cpu_usage }}%</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 text-info">{{ machine.latest_monitoring.memory_usage }}%</span>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<div class="display-4 text-warning">🌡️</div>
<h6>CPU 온도</h6>
<span class="h4 text-warning">{{ machine.latest_monitoring.cpu_temperature }}°C</span>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<div class="display-4 text-success">💾</div>
<h6>디스크 사용률</h6>
<span class="h4 text-success">{{ machine.latest_monitoring.disk_usage }}%</span>
</div>
</div>
</div>
{% else %}
<div class="alert alert-info">
<i class="fas fa-info-circle"></i>
아직 모니터링 데이터가 없습니다. 잠시 후 다시 확인해주세요.
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<script>
// 실시간 업데이트를 위한 JavaScript
function updateMonitoring() {
fetch(`/api/machines/{{ machine.id }}/monitoring`)
.then(response => response.json())
.then(data => {
// 차트 업데이트 로직
updateCharts(data);
});
}
// 5초마다 업데이트
setInterval(updateMonitoring, 5000);
</script>
```
### 4. 실시간 모니터링 시스템
```python
# routes/monitoring.py
from flask import Blueprint, jsonify
from utils.monitoring import collect_monitoring_data
from utils.proxmox import ProxmoxAPI
monitoring_bp = Blueprint('monitoring', __name__)
@monitoring_bp.route('/api/monitoring/realtime')
def realtime_monitoring():
"""실시간 모니터링 데이터 API"""
data = {
'total_machines': get_total_machines(),
'online_count': get_online_machines_count(),
'alerts': get_active_alerts(),
'performance': get_performance_summary()
}
return jsonify(data)
@monitoring_bp.route('/api/machines/<int:machine_id>/monitoring')
def machine_monitoring(machine_id):
"""특정 머신 모니터링 데이터"""
monitoring_data = collect_monitoring_data(machine_id)
return jsonify(monitoring_data)
```
## 🔧 기술 스택
### Backend
- **Flask 3.0**: 웹 프레임워크
- **SQLAlchemy 2.0**: ORM (기존 모델 재사용)
- **Jinja2**: 템플릿 엔진
- **Flask-Login**: 사용자 인증
- **APScheduler**: 백그라운드 작업 (모니터링 데이터 수집)
### Frontend
- **Bootstrap 5**: CSS 프레임워크
- **Chart.js**: 차트 라이브러리
- **Font Awesome**: 아이콘
- **jQuery**: DOM 조작
- **Socket.io**: 실시간 통신
### 데이터베이스
- **SQLite**: 기존 Headscale DB 공유
- **확장 테이블**: PharmacyInfo, MachineSpecs, MonitoringData
### 배포
- **Docker**: 컨테이너화
- **Nginx**: 리버스 프록시 (옵션)
## 📅 개발 로드맵
### Phase 1: 기본 프레임워크 구축 (1-2일)
- [ ] Flask 애플리케이션 기본 구조 생성
- [ ] SQLAlchemy 모델 연동 (기존 모델 재사용)
- [ ] Bootstrap 기반 기본 템플릿 구성
- [ ] 라우팅 구조 설계
### Phase 2: 핵심 기능 구현 (3-4일)
- [ ] 메인 대시보드 구현
- [ ] 약국 관리 CRUD 기능
- [ ] 머신 관리 고도화 (하드웨어 정보 포함)
- [ ] 사용자 관리 확장 (약국 정보 연동)
### Phase 3: 실시간 기능 (2-3일)
- [ ] 모니터링 데이터 수집 시스템
- [ ] 실시간 차트 및 알림
- [ ] WebSocket 기반 라이브 업데이트
- [ ] Proxmox API 연동
### Phase 4: 통합 및 최적화 (2-3일)
- [ ] 기존 Headplane과 데이터 동기화 테스트
- [ ] Docker 컨테이너화
- [ ] 성능 최적화
- [ ] 사용자 테스트 및 피드백 반영
### Phase 5: 배포 및 운영 (1-2일)
- [ ] Docker Compose 통합 구성
- [ ] 프로덕션 배포
- [ ] 모니터링 및 로깅 설정
- [ ] 사용자 교육 자료 작성
## 💰 예상 리소스
### 개발 시간
- **총 개발 기간**: 8-12일
- **개발자**: 1명 (풀타임)
- **일일 작업량**: 6-8시간
### 기술적 요구사항
- **Python 3.8+**
- **메모리**: 최소 512MB (Flask 앱)
- **디스크**: 추가 100MB (정적 파일 포함)
## 🚀 시작하기
### 1단계: 개발 환경 준비
```bash
# Flask 프로젝트 디렉터리 생성
mkdir farmq-admin
cd farmq-admin
# Python 가상환경 생성
python3 -m venv flask-venv
source flask-venv/bin/activate
# 필수 패키지 설치
pip install flask sqlalchemy jinja2 flask-login apscheduler
```
### 2단계: 기본 구조 생성
```bash
# 프로젝트 구조 생성
mkdir -p {routes,templates,static/{css,js,img},utils,models}
touch app.py config.py requirements.txt
```
### 3단계: 첫 번째 구현
- 기본 Flask 앱 생성
- SQLAlchemy 연동
- 간단한 대시보드 페이지
## 🎯 성공 지표
### 기능적 목표
- [ ] 100개 약국 데이터 완벽 관리
- [ ] 실시간 모니터링 정확도 95% 이상
- [ ] 기존 Headplane과 데이터 100% 동기화
- [ ] 페이지 로딩 시간 2초 이내
### 사용성 목표
- [ ] 관리 업무 효율성 70% 향상
- [ ] 모바일 접근성 완벽 지원
- [ ] 한국어 UI 100% 완성
- [ ] 사용자 만족도 4.8/5.0 이상
이제 이 계획을 바탕으로 Flask 관리자 페이지 개발을 시작하시겠습니까?
---
**📅 작성일**: 2025-09-09
**👤 작성자**: Claude Code Assistant
**🎯 목표**: Headplane UI 고도화를 위한 Flask 기반 관리자 시스템

View File

@ -1,320 +0,0 @@
# 📋 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

View File

@ -1,424 +0,0 @@
# 🌐 FARMQ Headscale 완전 가이드
## 📚 목차
1. [개념 정리](#개념-정리)
2. [아키텍처 개요](#아키텍처-개요)
3. [포트 구성](#포트-구성)
4. [설치 및 구성](#설치-및-구성)
5. [클라이언트 연결 워크플로우](#클라이언트-연결-워크플로우)
6. [실제 사용 시나리오](#실제-사용-시나리오)
7. [문제 해결](#문제-해결)
---
## 🧠 개념 정리
### Tailscale vs Headscale vs Headplane
#### 1. **Tailscale** (원본 서비스)
- **정의**: 상용 VPN 서비스 (SaaS)
- **특징**:
- 클라우드 기반 coordination server 사용
- 구독 기반 유료 서비스
- 자동화된 관리
- **단점**:
- 데이터가 외부 서버를 거침
- 비용 발생
- 프라이버시 우려
#### 2. **Headscale** (오픈소스 서버)
- **정의**: Tailscale의 coordination server를 대체하는 오픈소스 구현
- **특징**:
- 자체 호스팅 가능
- 완전한 프라이버시 제어
- 무료 사용
- REST API 제공
- **역할**:
- 클라이언트 인증 및 등록
- IP 주소 할당
- 라우팅 테이블 관리
- 키 교환 coordination
#### 3. **Headplane** (웹 UI)
- **정의**: Headscale을 관리하기 위한 웹 인터페이스
- **특징**:
- 브라우저에서 노드 관리
- 시각적 네트워크 상태 확인
- 사용자 및 키 관리
#### 4. **클라이언트 (Tailscale 클라이언트)**
- **정의**: 실제 VPN 연결을 담당하는 클라이언트 프로그램
- **중요**: Tailscale의 **클라이언트 소프트웨어**를 그대로 사용
- **변경점**: 서버 주소만 Headscale 서버로 지정
---
## 🏗️ 아키텍처 개요
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 클라이언트 PC │ │ Headscale │ │ 클라이언트 PC │
│ │ │ 서버 │ │ │
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
│ │ Tailscale │◄────┼─┤ Headscale │─┼────┤ │ Tailscale │ │
│ │ Client │ │ │ │ Server │ │ │ │ Client │ │
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
│ │ │ ┌─────────────┐ │ │ │
│ │ │ │ Headplane │ │ │ │
│ │ │ │ Web UI │ │ │ │
│ │ │ └─────────────┘ │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
└────────────────────────┼────────────────────────┘
┌─────────────────┐
│ FARMQ Flask │
│ Admin Panel │
└─────────────────┘
```
### 데이터 흐름
1. **등록**: 클라이언트 → Headscale 서버 (인증)
2. **키 교환**: Headscale 서버 → 클라이언트들 (P2P 키 정보)
3. **실제 통신**: 클라이언트 ↔ 클라이언트 (직접 P2P)
4. **관리**: Headplane/Flask UI → Headscale API
---
## 🔌 포트 구성
### 현재 FARMQ 설정
| 서비스 | 포트 | 프로토콜 | 용도 | 접근 |
|--------|------|----------|------|------|
| **Headscale Server** | `8070` | HTTP | 클라이언트 등록/관리 | 클라이언트 ← → 서버 |
| **Headplane UI** | `3000` | HTTP | 웹 관리 인터페이스 | 관리자 → 웹브라우저 |
| **FARMQ Admin** | `5001` | HTTP | 한국어 관리 페이지 | 관리자 → 웹브라우저 |
### 중요한 포인트
- **클라이언트가 사용하는 포트**: `8070` (Headscale 서버)
- **관리자가 사용하는 포트**: `3000` (Headplane), `5001` (FARMQ Admin)
- **내부 컨테이너 포트**: `8080` (Docker 내부에서만 사용)
---
## 🛠️ 설치 및 구성
### 1. 서버 구성 (이미 완료)
```bash
# 서버 시작
cd /srv/headscale-setup
docker-compose up -d
# 서비스 확인
docker-compose ps
```
### 2. 클라이언트 설치 과정
#### Step 1: Tailscale 클라이언트 설치
```bash
# Ubuntu/Debian
curl -fsSL https://tailscale.com/install.sh | sh
# 또는 수동 설치
curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/$(lsb_release -cs).noarmor.gpg | sudo tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null
curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/$(lsb_release -cs).tailscale-keyring.list | sudo tee /etc/apt/sources.list.d/tailscale.list
sudo apt update
sudo apt install tailscale
```
#### Step 2: Headscale 서버에 등록
```bash
# 기본 명령어 형식
sudo tailscale up --login-server=http://[서버IP]:8070 --authkey=[PreAuth키]
# FARMQ 실제 명령어 예시
sudo tailscale up \
--login-server=http://192.168.0.151:8070 \
--authkey=YOUR_PREAUTH_KEY_HERE \
--hostname=pharmacy-busan-pc1 \
--accept-dns=false
```
---
## 🔄 클라이언트 연결 워크플로우
### 전체 프로세스
```mermaid
sequenceDiagram
participant Admin as 관리자
participant Server as Headscale 서버
participant Client as 클라이언트 PC
participant Network as VPN 네트워크
Admin->>Server: 1. 사용자 생성
Admin->>Server: 2. PreAuth 키 생성
Admin->>Client: 3. PreAuth 키 전달
Client->>Client: 4. Tailscale 클라이언트 설치
Client->>Server: 5. 등록 요청 (PreAuth 키 포함)
Server->>Client: 6. 인증 완료 및 설정 전달
Client->>Network: 7. VPN 네트워크 참여
Network->>Client: 8. 다른 노드들과 P2P 연결
```
### 상세 단계별 설명
#### 1. 서버 측 작업 (관리자)
```bash
# 1-1. 사용자 생성 (한 번만)
docker exec headscale headscale users create pharmacy-busan
# 1-2. 사용자 목록 확인
docker exec headscale headscale users list
# 1-3. PreAuth 키 생성
docker exec headscale headscale preauthkeys create --user 1 --expiration 1h
# 출력 예시:
# f8d9c7e4b2a6c8f4d9e7b3a5c8f4d9e7b3a5c8f4d9e7b3a5c8f4d9e7b3a5c8f4
```
#### 2. 클라이언트 측 작업
```bash
# 2-1. 기존 연결 해제 (있다면)
sudo tailscale logout
sudo tailscale down
# 2-2. Headscale 서버에 등록
sudo tailscale up \
--login-server=http://192.168.0.151:8070 \
--authkey=f8d9c7e4b2a6c8f4d9e7b3a5c8f4d9e7b3a5c8f4d9e7b3a5c8f4d9e7b3a5c8f4 \
--hostname=pharmacy-busan-pc1 \
--accept-dns=false
# 2-3. 연결 상태 확인
tailscale status
# 2-4. IP 주소 확인
tailscale ip -4
```
#### 3. 결과 확인
```bash
# 서버에서 노드 목록 확인
docker exec headscale headscale nodes list
# 웹 UI에서 확인
# - Headplane: http://192.168.0.151:3000
# - FARMQ Admin: http://192.168.0.151:5001
```
---
## 🏥 실제 사용 시나리오
### 시나리오 1: 새 약국 등록
```bash
# 서버 작업 (관리자)
docker exec headscale headscale users create pharmacy-seoul
docker exec headscale headscale preauthkeys create --user [USER_ID] --expiration 2h
# 클라이언트 작업 (약국 PC)
curl -O http://192.168.0.151:8000/add-client.sh
chmod +x add-client.sh
./add-client.sh pharmacy-seoul pos-terminal-1
# PreAuth 키 입력 시 위에서 생성한 키 사용
```
### 시나리오 2: 여러 PC가 있는 약국
```bash
# 같은 사용자로 여러 머신 등록 가능
sudo tailscale up --login-server=http://192.168.0.151:8070 --authkey=[KEY] --hostname=busan-pos1
sudo tailscale up --login-server=http://192.168.0.151:8070 --authkey=[KEY] --hostname=busan-pos2
sudo tailscale up --login-server=http://192.168.0.151:8070 --authkey=[KEY] --hostname=busan-office
```
### 시나리오 3: 임시 접속 (노트북)
```bash
# 짧은 만료시간으로 키 생성
docker exec headscale headscale preauthkeys create --user [USER_ID] --expiration 30m
# 노트북에서 임시 연결
sudo tailscale up --login-server=http://192.168.0.151:8070 --authkey=[TEMP_KEY] --hostname=manager-laptop
```
---
## 🔍 상태 확인 및 관리
### 명령어 모음
```bash
# === 서버 측 (Headscale) ===
# 사용자 관리
docker exec headscale headscale users list
docker exec headscale headscale users create [username]
# 노드 관리
docker exec headscale headscale nodes list
docker exec headscale headscale nodes expire [node_id]
# PreAuth 키 관리
docker exec headscale headscale preauthkeys list --user [user_id]
docker exec headscale headscale preauthkeys create --user [user_id] --expiration [time]
# === 클라이언트 측 (Tailscale) ===
# 상태 확인
tailscale status # 네트워크 상태 및 연결된 노드들
tailscale ip # 내 IP 주소들
tailscale netcheck # 네트워크 연결성 테스트
tailscale ping [node] # 특정 노드 ping
# 연결 관리
tailscale up # 연결 시작
tailscale down # 연결 중단
tailscale logout # 로그아웃
# 로그 확인
sudo journalctl -u tailscaled -f
```
### 웹 UI 접근
```bash
# Headplane (기본 관리 UI)
http://192.168.0.151:3000
# API 키: 8qRr1IB.tV95CmA0fLaCiGGIgBfeoN9daHceFkzI
# FARMQ 관리자 페이지 (한국어)
http://192.168.0.151:5001
```
---
## ⚠️ 중요한 주의사항
### 1. 포트 혼동 방지
- **클라이언트 등록**: `8070` 포트 사용
- **웹 관리**: `3000`, `5001` 포트 사용
- **Docker 내부**: `8080` 포트 (외부에서 직접 접근 불가)
### 2. PreAuth 키 보안
- 키는 일회용 또는 제한된 횟수만 사용 가능
- 짧은 만료시간 설정 권장 (1h ~ 24h)
- 키 노출 시 즉시 새 키 생성
### 3. 네트워크 구성
- 모든 클라이언트는 `100.64.0.0/10` 대역 IP 할당
- 첫 번째 클라이언트: `100.64.0.1` (서버 역할도 함)
- 이후 클라이언트들: `100.64.0.2`, `100.64.0.3`, ...
### 4. 방화벽 설정
```bash
# 서버 측 방화벽 (필요시)
sudo ufw allow 8070/tcp # Headscale
sudo ufw allow 3000/tcp # Headplane
sudo ufw allow 5001/tcp # FARMQ Admin
# 클라이언트 측 (Tailscale이 자동 처리)
sudo ufw allow in on tailscale0
```
---
## 🚨 문제 해결
### 일반적인 문제들
#### 1. "connection refused" 오류
```bash
# 원인: 서버 포트 접근 불가
# 해결:
docker-compose ps # 서버 실행 확인
sudo ufw status # 방화벽 확인
ping 192.168.0.151 # 서버 연결 확인
```
#### 2. "invalid auth key" 오류
```bash
# 원인: PreAuth 키 만료 또는 잘못된 키
# 해결:
docker exec headscale headscale preauthkeys create --user [user_id] --expiration 1h
```
#### 3. "user not found" 오류
```bash
# 원인: 존재하지 않는 사용자
# 해결:
docker exec headscale headscale users list # 사용자 확인
docker exec headscale headscale users create [username] # 사용자 생성
```
#### 4. IP 할당되지 않음
```bash
# 진단:
tailscale status # 연결 상태 확인
tailscale netcheck # 네트워크 테스트
sudo journalctl -u tailscaled -f # 로그 확인
# 해결:
sudo systemctl restart tailscaled # 서비스 재시작
```
### 로그 위치
```bash
# Headscale 서버 로그
docker logs headscale
# Headplane 로그
docker logs headplane
# Tailscale 클라이언트 로그
sudo journalctl -u tailscaled -f
```
---
## 📋 체크리스트
### 서버 설치 완료 확인
- [ ] Docker 및 Docker Compose 설치됨
- [ ] Headscale 컨테이너 실행 중 (포트 8070)
- [ ] Headplane 컨테이너 실행 중 (포트 3000)
- [ ] FARMQ Admin 실행 중 (포트 5001)
- [ ] 방화벽에서 필요 포트 열림
### 클라이언트 연결 확인
- [ ] Tailscale 클라이언트 설치됨
- [ ] 서버에 사용자 생성됨
- [ ] PreAuth 키 생성됨 (유효한 만료시간)
- [ ] `tailscale up` 명령어 성공
- [ ] `tailscale status`에서 다른 노드들 보임
- [ ] VPN IP 주소 할당됨 (`100.64.0.x`)
### 네트워크 연결 확인
- [ ] 서버 ping 성공 (`ping 100.64.0.1`)
- [ ] 다른 클라이언트와 ping 성공
- [ ] 웹 UI 접근 가능
---
## 🎯 다음 단계
1. **자동화 스크립트 활용**: `add-client.sh`, `create-preauth-key.sh` 사용
2. **모니터링 설정**: FARMQ Admin에서 실시간 상태 확인
3. **백업 전략**: Headscale 설정 및 데이터베이스 백업
4. **확장**: 새로운 약국 및 지점 추가
---
**🎊 이제 완전한 프라이빗 VPN 네트워크를 운영할 수 있습니다!**

View File

@ -1,370 +0,0 @@
# 🚀 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을 완전히 대체할 수 있는 환경이 준비되었습니다.

View File

@ -1,343 +0,0 @@
# 다중 Proxmox 호스트 관리 시스템 기획서
## 🎯 프로젝트 개요
약국별 독립적인 Proxmox 서버를 중앙에서 통합 관리하는 시스템 구축. Headscale 네트워크를 통해 여러 Proxmox 호스트에 접속하여 VM을 관리하고 VNC 원격 접속을 제공.
## 📋 현재 상황 분석
### 기존 구현 현황
- ✅ 단일 Proxmox 서버 (`pve7.0bin.in`) 연동 완료
- ✅ VNC WebSocket 직접 연결 구현
- ✅ VNC 인증 실패 자동 해결 시스템
- ✅ VM 목록 조회 및 상태 확인
- ✅ VM 시작/정지 기능
### 확장 요구사항
- 약국 수: 100개 (예상)
- Proxmox 호스트: 약국별 1개씩 (총 100대)
- 네트워크: Headscale VPN으로 연결된 사설 IP (예: 100.64.0.x)
- 인증: 모든 Proxmox에서 동일한 `root@pam` 계정 사용
## 🏗️ 시스템 아키텍처
```
┌─────────────────────────────────────────┐
│ Central Management Web UI │
│ (farmq-admin) │
└─────────────────┬───────────────────────┘
┌─────────┴─────────┐
│ Headscale VPN │
│ Network │
└─────────┬─────────┘
┌─────────────┼─────────────┐
│ │ │
┌───▼───┐ ┌───▼───┐ ┌───▼───┐
│약국 A │ │약국 B │ │약국 C │
│Proxmox│ │Proxmox│ │Proxmox│
│100.64.│ │100.64.│ │100.64.│
│0.10 │ │0.11 │ │0.12 │
└───┬───┘ └───┬───┘ └───┬───┘
│ │ │
┌───▼───┐ ┌───▼───┐ ┌───▼───┐
│VM들 │ │VM들 │ │VM들 │
│- 키오스│ │- POS │ │- 서버 │
│- POS │ │- 키오스│ │- 백업 │
└───────┘ └───────┘ └───────┘
```
## 📊 데이터베이스 설계
### 1. Proxmox 호스트 관리 테이블
```sql
CREATE TABLE proxmox_hosts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pharmacy_id INTEGER NOT NULL,
pharmacy_name VARCHAR(255) NOT NULL,
host_ip VARCHAR(15) NOT NULL, -- 100.64.0.x
host_port INTEGER DEFAULT 443,
username VARCHAR(50) DEFAULT 'root@pam',
password VARCHAR(255) NOT NULL,
status ENUM('online', 'offline', 'maintenance') DEFAULT 'offline',
last_check TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (pharmacy_id) REFERENCES pharmacies(pharmacy_id)
);
```
### 2. VM 인벤토리 테이블
```sql
CREATE TABLE vm_inventory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
proxmox_host_id INTEGER NOT NULL,
vmid INTEGER NOT NULL,
vm_name VARCHAR(255) NOT NULL,
vm_type ENUM('kiosk', 'pos', 'server', 'backup', 'other') DEFAULT 'other',
node_name VARCHAR(100) NOT NULL,
status ENUM('running', 'stopped', 'suspended') DEFAULT 'stopped',
cpu_cores INTEGER,
memory_mb INTEGER,
disk_gb INTEGER,
ip_address VARCHAR(15),
description TEXT,
last_sync TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (proxmox_host_id) REFERENCES proxmox_hosts(id)
);
```
### 3. VNC 세션 확장 테이블
```sql
CREATE TABLE vnc_sessions_extended (
session_id VARCHAR(36) PRIMARY KEY,
proxmox_host_id INTEGER NOT NULL,
vmid INTEGER NOT NULL,
vm_name VARCHAR(255) NOT NULL,
websocket_url TEXT NOT NULL,
password VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
last_refresh TIMESTAMP,
FOREIGN KEY (proxmox_host_id) REFERENCES proxmox_hosts(id)
);
```
## 🔧 주요 기능 구현 계획
### 1. Proxmox 호스트 관리
- **호스트 등록/수정/삭제**
- 약국 정보와 연동하여 Proxmox 호스트 정보 관리
- IP, 포트, 인증 정보 저장
- **호스트 상태 모니터링**
- 주기적 헬스체크 (ping, API 연결 확인)
- 오프라인 호스트 알림 및 상태 표시
- **보안 관리**
- 패스워드 암호화 저장
- API 토큰 관리 옵션 제공
### 2. 통합 VM 관리 대시보드
- **멀티 호스트 VM 목록**
```
약국A (100.64.0.10) - 온라인
├── VM 101: Kiosk-1 (실행중)
├── VM 102: POS-System (정지됨)
└── VM 103: Backup-Server (실행중)
약국B (100.64.0.11) - 오프라인
├── 연결 불가
약국C (100.64.0.12) - 온라인
├── VM 201: Main-Server (실행중)
└── VM 202: Kiosk-Terminal (실행중)
```
- **통합 검색 및 필터링**
- 약국별, VM 타입별, 상태별 필터링
- VM 이름 및 IP 주소 검색
- 상태별 대시보드 (전체 실행중/정지됨 VM 수)
- **배치 작업**
- 여러 VM 동시 시작/정지
- 약국별 전체 VM 관리
- 예약 작업 (특정 시간에 VM 시작/정지)
### 3. 동적 VNC 접속 시스템
- **호스트 자동 선택**
```python
def connect_to_vm(pharmacy_id, vmid):
# 1. pharmacy_id로 Proxmox 호스트 정보 조회
host_info = get_proxmox_host_by_pharmacy(pharmacy_id)
# 2. 해당 호스트에서 VM 정보 확인
vm_info = get_vm_info(host_info, vmid)
# 3. VNC 티켓 생성 및 연결
vnc_ticket = create_vnc_ticket(host_info, vm_info)
return vnc_ticket
```
- **다중 세션 관리**
- 서로 다른 Proxmox 호스트의 여러 VM에 동시 VNC 접속
- 세션별 독립적인 티켓 관리
- 탭 기반 다중 VNC 창 지원
### 4. 모니터링 및 알림
- **리소스 모니터링**
- 모든 Proxmox 호스트의 CPU, 메모리, 스토리지 사용량
- VM별 리소스 사용 현황
- 임계치 초과 시 알림
- **이벤트 로그**
- VM 시작/정지 이력
- VNC 접속 이력
- 시스템 오류 및 복구 이력
## 🛠️ API 설계
### Proxmox 호스트 관리 API
```http
GET /api/proxmox/hosts # 호스트 목록 조회
POST /api/proxmox/hosts # 호스트 등록
PUT /api/proxmox/hosts/{host_id} # 호스트 정보 수정
DELETE /api/proxmox/hosts/{host_id} # 호스트 삭제
GET /api/proxmox/hosts/{host_id}/health # 호스트 상태 확인
```
### 통합 VM 관리 API
```http
GET /api/vms # 모든 호스트의 VM 목록
GET /api/vms/pharmacy/{pharmacy_id} # 특정 약국의 VM 목록
GET /api/vms/sync # VM 정보 동기화
POST /api/vms/batch/start # 여러 VM 일괄 시작
POST /api/vms/batch/stop # 여러 VM 일괄 정지
```
### 동적 VNC 접속 API
```http
POST /api/vnc/connect # 동적 VNC 연결 생성
# Request Body:
{
"pharmacy_id": 1,
"vmid": 101,
"vm_type": "kiosk"
}
GET /api/vnc/sessions # 활성 VNC 세션 목록
POST /api/vnc/refresh/{session_id} # VNC 티켓 새로고침 (기존)
```
## 🎨 UI/UX 설계
### 1. 대시보드 개선
```
┌─────────────────────────────────────────────────────────────┐
│ 🏥 PharmQ - 다중 Proxmox 관리 시스템 │
├─────────────────────────────────────────────────────────────┤
│ 📊 전체 현황 │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │호스트수 │ │총VM수 │ │실행중 │ │오프라인 │ │
│ │ 100 │ │ 450 │ │ 380 │ │ 15 │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
├─────────────────────────────────────────────────────────────┤
│ 🔍 검색 및 필터 │
│ [약국명/IP 검색____] [호스트상태▼] [VM타입▼] [VM상태▼] │
└─────────────────────────────────────────────────────────────┘
```
### 2. 호스트 목록 페이지
```
┌─────────────────────────────────────────────────────────────┐
│ 🖥️ Proxmox 호스트 관리 [+ 호스트 추가] │
├─────────────────────────────────────────────────────────────┤
│ 약국명 │ IP주소 │ 상태 │ VM수 │ 마지막확인 │
├─────────────────────────────────────────────────────────────┤
│ 🟢 약국A │ 100.64.0.10 │ 온라인 │ 5개 │ 1분전 │
│ 🔴 약국B │ 100.64.0.11 │ 오프라인│ - │ 30분전 │
│ 🟢 약국C │ 100.64.0.12 │ 온라인 │ 3개 │ 2분전 │
└─────────────────────────────────────────────────────────────┘
```
### 3. 통합 VM 관리 페이지
```
┌─────────────────────────────────────────────────────────────┐
│ 🔧 통합 VM 관리 │
├─────────────────────────────────────────────────────────────┤
│ 📍 약국A (100.64.0.10) - 온라인 │
│ ├── 🖥️ VM101: Kiosk-1 [실행중] [VNC] [정지] │
│ ├── 🖥️ VM102: POS-System [정지됨] [시작] [VNC] │
│ └── 🖥️ VM103: Backup [실행중] [VNC] [정지] │
├─────────────────────────────────────────────────────────────┤
│ 📍 약국C (100.64.0.12) - 온라인 │
│ ├── 🖥️ VM201: Server [실행중] [VNC] [정지] │
│ └── 🖥️ VM202: Kiosk [실행중] [VNC] [정지] │
└─────────────────────────────────────────────────────────────┘
```
## 🔧 구현 단계
### Phase 1: 기반 시스템 확장 (1-2주)
1. **데이터베이스 스키마 확장**
- 다중 호스트 테이블 생성
- 기존 데이터 마이그레이션
2. **Proxmox 클라이언트 다중화**
- 호스트별 클라이언트 풀 관리
- 연결 관리 및 로드밸런싱
### Phase 2: 호스트 관리 시스템 (2-3주)
1. **호스트 등록/관리 기능**
- CRUD API 구현
- 관리 UI 개발
2. **상태 모니터링**
- 헬스체크 시스템
- 알림 기능
### Phase 3: 통합 VM 관리 (2-3주)
1. **다중 호스트 VM 조회**
- 통합 대시보드
- 검색/필터링 기능
2. **배치 작업 시스템**
- 일괄 작업 API
- 스케줄링 기능
### Phase 4: 고도화 기능 (2-3주)
1. **성능 최적화**
- 캐싱 시스템
- 비동기 처리
2. **보안 강화**
- 접근 권한 관리
- 감사 로그
## 🚨 고려사항
### 기술적 도전과제
1. **네트워크 지연**
- Headscale VPN을 통한 다중 호스트 접근 시 지연 가능성
- 연결 타임아웃 및 재시도 로직 필요
2. **확장성**
- 100개 호스트 동시 관리 시 리소스 사용량
- 동시 VNC 세션 수 제한 고려
3. **장애 처리**
- 개별 호스트 오프라인 시 graceful degradation
- 부분 장애 상황에서의 서비스 연속성
### 보안 고려사항
1. **인증 정보 관리**
- 각 Proxmox 호스트별 패스워드 암호화 저장
- API 토큰 순환 정책
2. **네트워크 보안**
- Headscale 네트워크 내부 통신 암호화
- VNC 세션 보안 강화
## 📈 성공 지표
### 기능적 지표
- ✅ 100개 Proxmox 호스트 동시 관리
- ✅ 1000개 이상 VM 통합 관리
- ✅ 동시 VNC 세션 50개 이상 지원
- ✅ 호스트 상태 확인 응답 시간 < 5초
### 성능 지표
- ✅ 대시보드 로딩 시간 < 3초
- ✅ VM 목록 조회 시간 < 5초
- ✅ VNC 연결 설정 시간 < 10초
- ✅ 시스템 가용성 99.5% 이상
## 🎯 결론
이 다중 Proxmox 호스트 관리 시스템을 통해 약국별로 분산된 IT 인프라를 중앙에서 효율적으로 관리할 수 있게 됩니다. Headscale 네트워크를 활용한 안전한 연결과 통합 관리 인터페이스를 제공하여 운영 효율성을 크게 향상시킬 수 있을 것입니다.
---
*이 기획서는 현재 단일 Proxmox 호스트 관리 시스템을 다중 호스트 환경으로 확장하기 위한 상세 계획을 담고 있습니다. 단계적 구현을 통해 안정적이고 확장 가능한 시스템을 구축할 수 있을 것입니다.*

View File

@ -1,480 +0,0 @@
# 🔑 FARMQ Headscale Pre-auth Key 관리 가이드
## 📚 목차
1. [Pre-auth Key 개념](#pre-auth-key-개념)
2. [키 유형별 비교](#키-유형별-비교)
3. [약국 환경별 사용 전략](#약국-환경별-사용-전략)
4. [실제 명령어 예시](#실제-명령어-예시)
5. [보안 관리](#보안-관리)
6. [문제 해결](#문제-해결)
7. [체크리스트](#체크리스트)
---
## 🧠 Pre-auth Key 개념
### Pre-auth Key란?
- **사전 인증 키**: 클라이언트가 Headscale 서버에 자동 등록할 수 있는 "입장권"
- **일회용 패스워드** 개념으로, 관리자가 미리 생성해서 배포
- **보안 계층**: 무작위 접속을 방지하는 첫 번째 보안 장벽
### 작동 원리
```mermaid
sequenceDiagram
participant Admin as 관리자
participant Server as Headscale 서버
participant Client as 클라이언트
Admin->>Server: 1. PreAuth 키 생성
Server-->>Admin: 2. 키 반환 (abc123def456...)
Admin->>Client: 3. 키 전달
Client->>Server: 4. 키와 함께 등록 요청
Server->>Server: 5. 키 검증
Server-->>Client: 6. 승인 및 VPN 설정 전송
Server->>Server: 7. 키 사용됨 표시 (일회용인 경우)
```
---
## 🔄 키 유형별 비교
### 1. 일회용 키 (Single-use Key)
```bash
# 생성 명령어
docker exec headscale headscale preauthkeys create --user [USER_ID] --expiration 1h
# 특징
✅ 최고 수준 보안
✅ 정확한 기기 추적 가능
❌ 매번 새 키 생성 필요
❌ 관리 복잡도 높음
```
### 2. 재사용 키 (Reusable Key)
```bash
# 생성 명령어
docker exec headscale headscale preauthkeys create --user [USER_ID] --expiration 7d --reusable
# 특징
✅ 편리한 관리
✅ 여러 기기에서 동일 키 사용
⚠️ 키 노출 시 보안 위험
⚠️ 기기별 구분 어려움
```
### 3. 임시 키 (Ephemeral Key)
```bash
# 생성 명령어
docker exec headscale headscale preauthkeys create --user [USER_ID] --expiration 30m --ephemeral
# 특징
✅ 일시적 접속용 최적
✅ 네트워크에서 자동 제거
❌ 영구 연결 불가
❌ 재시작 시 재등록 필요
```
---
## 🏥 약국 환경별 사용 전략
### 전략 1: 약국별 개별 키 (🌟 권장)
#### 적용 대상
- 정기적으로 운영되는 약국
- 여러 POS 단말기가 있는 매장
- 보안이 중요한 환경
#### 설정 예시
```bash
# 1단계: 약국별 사용자 생성
docker exec headscale headscale users create pharmacy-gangnam
docker exec headscale headscale users create pharmacy-hongdae
docker exec headscale headscale users create pharmacy-itaewon
# 2단계: 사용자 ID 확인
docker exec headscale headscale users list
# 출력:
# ID | Name
# 1 | myuser
# 2 | pharmacy-gangnam
# 3 | pharmacy-hongdae
# 4 | pharmacy-itaewon
# 3단계: 약국별 재사용 키 생성
docker exec headscale headscale preauthkeys create --user 2 --expiration 30d --reusable
# 강남약국용: a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0
docker exec headscale headscale preauthkeys create --user 3 --expiration 30d --reusable
# 홍대약국용: z9y8x7w6v5u4t3s2r1q0p9o8n7m6l5k4j3i2h1g0f9e8d7c6b5a4z3y2x1w0
docker exec headscale headscale preauthkeys create --user 4 --expiration 30d --reusable
# 이태원약국용: m5n6o7p8q9r0s1t2u3v4w5x6y7z8a9b0c1d2e3f4g5h6i7j8k9l0m1n2o3p4
```
#### 장점
- ✅ **약국별 구분**: 네트워크에서 약국별로 명확히 구분
- ✅ **부분적 보안**: 한 약국의 키 노출이 다른 약국에 영향 없음
- ✅ **관리 용이**: 약국별로 키 갱신 및 관리 가능
- ✅ **확장성**: 새 약국 추가 시 독립적으로 관리
### 전략 2: 지역별 그룹 키
#### 적용 대상
- 같은 지역 내 여러 지점
- 관리 구역별 분할 필요 시
- 중간 규모 보안 요구사항
```bash
# 지역별 사용자 생성
docker exec headscale headscale users create region-seoul
docker exec headscale headscale users create region-busan
docker exec headscale headscale users create region-daegu
# 지역별 키 생성 (서울 지역 모든 약국이 공유)
docker exec headscale headscale preauthkeys create --user 2 --expiration 14d --reusable
```
### 전략 3: 단일 공통 키 (⚠️ 비권장)
#### 적용 대상
- 테스트 환경
- 매우 소규모 운영 (5개 미만 약국)
- 관리 리소스 극도로 제한적인 경우
```bash
# 모든 약국이 하나의 키 공유
docker exec headscale headscale preauthkeys create --user 1 --expiration 90d --reusable
```
#### 단점
- ❌ **보안 위험**: 키 하나만 노출되면 전체 네트워크 위험
- ❌ **관리 복잡**: 문제 발생 시 원인 추적 어려움
- ❌ **확장성 부족**: 규모 증가 시 관리 한계
---
## 💻 실제 명령어 예시
### FARMQ 표준 설정 (권장)
#### 1단계: 약국 등록 준비
```bash
# 새 약국 등록 시 실행할 명령어들
# 약국명 변수 설정 (편의를 위해)
PHARMACY_NAME="pharmacy-myeongdong"
EXPIRATION="30d" # 30일 만료
echo "🏥 새 약국 등록: $PHARMACY_NAME"
```
#### 2단계: 사용자 생성
```bash
# 사용자 생성
docker exec headscale headscale users create "$PHARMACY_NAME"
# 생성 결과 확인
docker exec headscale headscale users list
```
#### 3단계: 사용자 ID 확인
```bash
# 방법 1: 수동 확인
docker exec headscale headscale users list | grep "$PHARMACY_NAME"
# 방법 2: 자동 추출 (스크립트용)
USER_ID=$(docker exec headscale headscale users list | grep "$PHARMACY_NAME" | awk '{print $1}')
echo "사용자 ID: $USER_ID"
```
#### 4단계: Pre-auth 키 생성
```bash
# 재사용 가능한 키 생성
PREAUTH_KEY=$(docker exec headscale headscale preauthkeys create --user "$USER_ID" --expiration "$EXPIRATION" --reusable | tail -1)
echo "🔑 생성된 Pre-auth Key:"
echo "$PREAUTH_KEY"
```
#### 5단계: 클라이언트에서 사용
```bash
# 약국의 각 기기에서 실행
sudo tailscale up \
--login-server=http://192.168.0.151:8070 \
--authkey="$PREAUTH_KEY" \
--hostname=myeongdong-pos1 \
--accept-dns=false
sudo tailscale up \
--login-server=http://192.168.0.151:8070 \
--authkey="$PREAUTH_KEY" \
--hostname=myeongdong-pos2 \
--accept-dns=false
sudo tailscale up \
--login-server=http://192.168.0.151:8070 \
--authkey="$PREAUTH_KEY" \
--hostname=myeongdong-office \
--accept-dns=false
```
### 특수 상황별 명령어
#### 임시 접속 (매니저 노트북)
```bash
# 2시간 짜리 일회용 키
docker exec headscale headscale preauthkeys create --user "$USER_ID" --expiration 2h
# 일시적 접속 (재부팅 시 자동 해제)
docker exec headscale headscale preauthkeys create --user "$USER_ID" --expiration 1h --ephemeral
```
#### 기술 지원용 (원격 지원)
```bash
# 30분 짜리 ephemeral 키 (지원 완료 후 자동 삭제)
docker exec headscale headscale preauthkeys create --user "$USER_ID" --expiration 30m --ephemeral
```
#### 테스트용 (개발/검증)
```bash
# 테스트 사용자 및 짧은 만료시간
docker exec headscale headscale users create test-environment
docker exec headscale headscale preauthkeys create --user [TEST_USER_ID] --expiration 15m --reusable
```
---
## 🔐 보안 관리
### 키 생명주기 관리
#### 1. 키 생성 정책
```bash
# 권장 만료시간 설정
# - 일반 약국: 30일
# - 임시 접속: 2-8시간
# - 기술 지원: 30분-1시간
# - 테스트: 15분-1시간
# 예시: 단계별 만료시간
docker exec headscale headscale preauthkeys create --user 2 --expiration 30d --reusable # 운영
docker exec headscale headscale preauthkeys create --user 2 --expiration 4h # 임시
docker exec headscale headscale preauthkeys create --user 2 --expiration 30m --ephemeral # 지원
```
#### 2. 키 갱신 스케줄
```bash
# 월별 키 갱신 스크립트 (cron 등록 권장)
#!/bin/bash
# monthly-key-renewal.sh
PHARMACIES=("pharmacy-gangnam" "pharmacy-hongdae" "pharmacy-itaewon")
for pharmacy in "${PHARMACIES[@]}"; do
echo "🔄 갱신 중: $pharmacy"
# 기존 키 만료 처리 (수동)
echo "⚠️ 기존 키를 수동으로 비활성화하세요"
# 새 키 생성
USER_ID=$(docker exec headscale headscale users list | grep "$pharmacy" | awk '{print $1}')
NEW_KEY=$(docker exec headscale headscale preauthkeys create --user "$USER_ID" --expiration 30d --reusable | tail -1)
echo "🔑 $pharmacy 새 키: $NEW_KEY"
echo "📧 약국에 새 키 전달 필요"
done
```
#### 3. 키 모니터링
```bash
# 활성 키 확인
docker exec headscale headscale preauthkeys list --user [USER_ID]
# 만료 예정 키 확인 (스크립트화 권장)
docker exec headscale headscale preauthkeys list --user [USER_ID] | grep -E "(expires|expired)"
```
### 보안 사고 대응
#### 키 노출 시 대응 절차
```bash
# 1단계: 즉시 새 키 생성
EMERGENCY_KEY=$(docker exec headscale headscale preauthkeys create --user [USER_ID] --expiration 7d --reusable | tail -1)
# 2단계: 해당 약국에 긴급 연락
echo "🚨 긴급 키 교체 필요"
echo "새 키: $EMERGENCY_KEY"
# 3단계: 기존 키로 등록된 노드 확인
docker exec headscale headscale nodes list --user [USER_ID]
# 4단계: 의심스러운 노드 제거 (필요시)
# docker exec headscale headscale nodes delete [NODE_ID]
```
### 접근 제한 설정
#### 태그 기반 접근 제어 (고급)
```bash
# 약국별 태그 설정
docker exec headscale headscale preauthkeys create \
--user [USER_ID] \
--expiration 30d \
--reusable \
--tags "pharmacy:gangnam,role:pos"
# 지역별 접근 제한
docker exec headscale headscale preauthkeys create \
--user [USER_ID] \
--expiration 30d \
--reusable \
--tags "region:seoul,type:retail"
```
---
## 🔧 문제 해결
### 일반적인 문제들
#### 1. "invalid auth key" 오류
```bash
# 원인: 키 만료, 잘못된 키, 이미 사용된 일회용 키
# 진단:
docker exec headscale headscale preauthkeys list --user [USER_ID]
# 해결: 새 키 생성
docker exec headscale headscale preauthkeys create --user [USER_ID] --expiration 1h --reusable
```
#### 2. "user not found" 오류
```bash
# 원인: 존재하지 않는 사용자 ID
# 진단:
docker exec headscale headscale users list
# 해결: 사용자 생성
docker exec headscale headscale users create [USERNAME]
```
#### 3. "foreign key constraint" 오류
```bash
# 원인: 데이터베이스 무결성 문제 (FARMQ 확장 테이블과 충돌)
# 해결: 기존 사용자 사용 또는 데이터베이스 정리
docker exec headscale headscale users list # 기존 사용자 확인
# 기존 사용자 ID로 키 생성
```
### 디버깅 명령어
```bash
# 전체 키 목록 확인
docker exec headscale headscale preauthkeys list
# 특정 사용자의 키 목록
docker exec headscale headscale preauthkeys list --user [USER_ID]
# 노드 등록 상태 확인
docker exec headscale headscale nodes list
# 로그 확인
docker logs headscale | grep -i "preauth\|auth\|key"
```
---
## 📋 체크리스트
### 새 약국 등록 체크리스트
- [ ] 약국명 결정 (naming convention 준수)
- [ ] Headscale 사용자 생성
- [ ] 사용자 ID 확인
- [ ] 적절한 만료시간으로 Pre-auth 키 생성
- [ ] 키를 안전한 방법으로 약국에 전달
- [ ] 약국에서 클라이언트 등록 테스트
- [ ] 네트워크 연결 확인
- [ ] FARMQ 관리자 페이지에서 확인
### 정기 보안 점검 체크리스트
- [ ] 만료 예정 키 확인 (30일 전 알림)
- [ ] 사용되지 않는 키 정리
- [ ] 의심스러운 노드 연결 확인
- [ ] 키 사용 로그 검토
- [ ] 백업된 키 정보 업데이트
### 긴급 상황 대응 체크리스트
- [ ] 키 노출 확인 시 즉시 새 키 생성
- [ ] 해당 약국에 긴급 연락
- [ ] 의심스러운 노드 차단
- [ ] 사고 경위 문서화
- [ ] 재발 방지 대책 수립
---
## 📚 명령어 참조 카드
### 자주 사용하는 명령어
```bash
# === 사용자 관리 ===
docker exec headscale headscale users create [USERNAME]
docker exec headscale headscale users list
# === 키 생성 ===
# 일회용
docker exec headscale headscale preauthkeys create --user [USER_ID] --expiration 1h
# 재사용 (일반적)
docker exec headscale headscale preauthkeys create --user [USER_ID] --expiration 30d --reusable
# 임시 (ephemeral)
docker exec headscale headscale preauthkeys create --user [USER_ID] --expiration 30m --ephemeral
# === 키 관리 ===
docker exec headscale headscale preauthkeys list --user [USER_ID]
docker exec headscale headscale preauthkeys expire [KEY_ID]
# === 노드 관리 ===
docker exec headscale headscale nodes list
docker exec headscale headscale nodes list --user [USER_ID]
docker exec headscale headscale nodes delete [NODE_ID]
```
### 클라이언트 명령어
```bash
# 표준 등록
sudo tailscale up \
--login-server=http://192.168.0.151:8070 \
--authkey=[PREAUTH_KEY] \
--hostname=[HOSTNAME] \
--accept-dns=false
# 상태 확인
tailscale status
tailscale ip -4
# 연결 해제
sudo tailscale down
sudo tailscale logout
```
---
## 🎯 모범 사례 요약
### DO ✅
- **약국별 개별 키 사용**
- **적절한 만료시간 설정** (30일 권장)
- **정기적인 키 갱신**
- **키 전달 시 보안 채널 사용**
- **키 사용 로그 모니터링**
### DON'T ❌
- **모든 약국이 하나의 키 공유하지 않기**
- **만료시간 너무 길게 설정하지 않기** (90일 이상)
- **키를 평문으로 이메일 전송하지 않기**
- **만료된 키 방치하지 않기**
- **키 백업 없이 운영하지 않기**
---
**🎊 체계적인 키 관리로 안전한 FARMQ 네트워크를 운영하세요!**

View File

@ -1,376 +0,0 @@
# 팜큐(FARMQ) Proxmox VNC 통합 시스템 기획서
## 🎯 프로젝트 개요
### 목표
Flask Admin 웹 인터페이스에서 **한 번의 클릭**으로 Proxmox VM의 VNC 화면에 접속할 수 있는 통합 시스템 구축
### 핵심 아이디어
1. **Headscale 네트워크**를 통해 모든 약국의 Proxmox 호스트에 접근 가능
2. **Proxmox VNC API**를 활용하여 VM 화면 원격 제어
3. **브라우저 기반 VNC 클라이언트** (Guacamole/noVNC)로 즉시 접속
4. **Flask Admin 버튼****VNC 화면** 원클릭 연결
## 🏗️ 시스템 아키텍처
```
[Flask Admin Dashboard]
↓ (클릭)
[VNC 연결 요청 API]
[Headscale 네트워크]
↓ (100.64.0.x)
[Proxmox Host Server]
↓ (VNC API)
[VM VNC Console]
[noVNC/Guacamole Web Client]
[사용자 브라우저]
```
## 📋 기술 스택
### Frontend
- **noVNC**: HTML5 VNC 클라이언트 (가벼움, 쉬운 통합)
- **Apache Guacamole**: 더 고급 기능 (클립보드, 파일 전송)
- **Bootstrap 5**: UI 프레임워크
### Backend
- **Flask**: 웹 서버 및 API
- **Proxmox VE API**: VM 관리 및 VNC 토큰 생성
- **WebSocket Proxy**: VNC 트래픽 중계
### Network
- **Headscale**: 팜큐 네트워크 (100.64.0.0/10)
- **Tailscale**: 각 Proxmox 호스트 연결
## 🔧 구현 단계
### Phase 1: Proxmox API 통합 (1-2일)
#### 1.1 Proxmox API 클라이언트 구현
```python
# utils/proxmox_client.py
class ProxmoxClient:
def __init__(self, host, username, password):
self.host = host # 100.64.0.x (Headscale IP)
def get_vm_list(self):
"""VM 목록 조회"""
def get_vnc_ticket(self, vmid):
"""VNC 접속 티켓 생성"""
def get_vm_status(self, vmid):
"""VM 상태 확인"""
```
#### 1.2 데이터베이스 모델 확장
```python
# models/farmq_models.py
class ProxmoxVM(FarmqBase):
__tablename__ = 'proxmox_vms'
id = Column(Integer, primary_key=True)
pharmacy_id = Column(Integer, ForeignKey('pharmacy_info.id'))
proxmox_host_ip = Column(String(15)) # 100.64.0.x
vmid = Column(Integer)
vm_name = Column(String(100))
vm_type = Column(String(50)) # windows, linux
status = Column(String(20)) # running, stopped
cpu_cores = Column(Integer)
memory_mb = Column(Integer)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
```
### Phase 2: VNC 웹 클라이언트 구현 (2-3일)
#### 2.1 noVNC 통합 (권장)
```html
<!-- templates/vnc/novnc.html -->
<!DOCTYPE html>
<html>
<head>
<title>{{ vm_name }} - VNC Console</title>
<script src="/static/novnc/vnc.js"></script>
</head>
<body>
<div id="vnc-container">
<canvas id="vnc-canvas"></canvas>
</div>
<script>
const rfb = new RFB(document.getElementById('vnc-canvas'),
'ws://{{ flask_server }}/vnc/{{ session_id }}');
</script>
</body>
</html>
```
#### 2.2 WebSocket Proxy 서버
```python
# vnc_proxy.py
import websockets
import asyncio
class VNCProxy:
async def proxy_vnc_connection(self, websocket, path):
# Flask → Proxmox VNC 연결 중계
session_id = extract_session_id(path)
proxmox_vnc = await connect_to_proxmox_vnc(session_id)
# 양방향 데이터 중계
await asyncio.gather(
relay_websocket_to_vnc(websocket, proxmox_vnc),
relay_vnc_to_websocket(proxmox_vnc, websocket)
)
```
### Phase 3: Flask Admin 통합 (1일)
#### 3.1 VNC 연결 API 엔드포인트
```python
# app.py
@app.route('/api/vm/<int:vm_id>/vnc', methods=['POST'])
def connect_vm_vnc(vm_id):
"""VM VNC 연결 세션 생성"""
try:
vm = get_vm_by_id(vm_id)
proxmox = ProxmoxClient(vm.proxmox_host_ip, username, password)
# VNC 티켓 생성
vnc_ticket = proxmox.get_vnc_ticket(vm.vmid)
# 세션 생성
session_id = create_vnc_session(vm_id, vnc_ticket)
return jsonify({
'session_id': session_id,
'vnc_url': f'/vnc/console/{session_id}',
'vm_info': vm.to_dict()
})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/vnc/console/<session_id>')
def vnc_console(session_id):
"""VNC 콘솔 페이지"""
session = get_vnc_session(session_id)
return render_template('vnc/novnc.html',
session=session,
vm_name=session['vm_name'])
```
#### 3.2 약국 관리 페이지 업데이트
```html
<!-- templates/pharmacy/detail.html -->
<div class="card">
<div class="card-header">
<h5>가상 머신 목록</h5>
</div>
<div class="card-body">
<table class="table">
<thead>
<tr>
<th>VM 이름</th>
<th>상태</th>
<th>타입</th>
<th>리소스</th>
<th>액션</th>
</tr>
</thead>
<tbody>
{% for vm in pharmacy_vms %}
<tr>
<td>{{ vm.vm_name }}</td>
<td>
{% if vm.status == 'running' %}
<span class="badge bg-success">실행 중</span>
{% else %}
<span class="badge bg-secondary">정지</span>
{% endif %}
</td>
<td>{{ vm.vm_type }}</td>
<td>{{ vm.cpu_cores }}C / {{ vm.memory_mb }}MB</td>
<td>
{% if vm.status == 'running' %}
<button class="btn btn-primary btn-sm"
onclick="openVNC({{ vm.id }})">
<i class="fas fa-desktop"></i> VNC 접속
</button>
{% endif %}
<button class="btn btn-info btn-sm"
onclick="showVMDetails({{ vm.id }})">
<i class="fas fa-info-circle"></i> 상세
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<script>
async function openVNC(vmId) {
try {
showSpinner('VNC 연결 준비 중...');
const response = await fetch(`/api/vm/${vmId}/vnc`, {
method: 'POST'
});
const data = await response.json();
if (response.ok) {
// 새 탭에서 VNC 콘솔 열기
window.open(data.vnc_url, '_blank',
'width=1024,height=768,scrollbars=yes,resizable=yes');
} else {
showToast(data.error, 'error');
}
} catch (error) {
showToast('VNC 연결 실패: ' + error.message, 'error');
} finally {
hideSpinner();
}
}
</script>
```
## 📊 데이터 흐름
### 1. VM 목록 동기화
```
Proxmox API → Flask Backend → Database → Admin Dashboard
```
### 2. VNC 연결 프로세스
```
1. 사용자가 "VNC 접속" 버튼 클릭
2. Flask API가 Proxmox API 호출하여 VNC 티켓 생성
3. WebSocket 프록시 세션 생성
4. 새 브라우저 탭에서 noVNC 클라이언트 실행
5. 실시간 VM 화면 표시
```
## 🔐 보안 고려사항
### 인증 및 권한
```python
# 약국별 VM 접근 권한 검증
def check_vm_access_permission(user_id, vm_id):
"""사용자가 해당 VM에 접근 권한이 있는지 확인"""
user_pharmacy = get_user_pharmacy(user_id)
vm_pharmacy = get_vm_pharmacy(vm_id)
return user_pharmacy.id == vm_pharmacy.id
# VNC 세션 시간 제한
VNC_SESSION_TIMEOUT = 3600 # 1시간
```
### 네트워크 보안
- **Headscale 네트워크 내부**에서만 Proxmox 접근
- **HTTPS/WSS** 암호화 통신
- **세션 기반** 일회성 VNC 토큰
## 🎨 UI/UX 설계
### 메인 대시보드
```
┌─────────────────────────────────────────┐
│ 📊 팜큐 관리 대시보드 │
├─────────────────────────────────────────┤
│ 약국: 세종온누리약국 │
│ ┌─────────┬──────────┬────────┬─────────┐ │
│ │ VM 이름 │ 상태 │ 타입 │ 액션 │ │
│ ├─────────┼──────────┼────────┼─────────┤ │
│ │ POS-01 │ 🟢 실행중 │ Win11 │ [VNC접속]│ │
│ │ SERVER │ 🟢 실행중 │ Ubuntu │ [VNC접속]│ │
│ │ BACKUP │ ⚪ 정지 │ Win10 │ [시작] │ │
│ └─────────┴──────────┴────────┴─────────┘ │
└─────────────────────────────────────────┘
```
### VNC 콘솔 화면
```
┌─────────────────────────────────────────┐
│ 🖥️ POS-01 (Windows 11) - VNC Console │
├─────────────────────────────────────────┤
│ [전체화면] [클립보드] [Ctrl+Alt+Del] │
├─────────────────────────────────────────┤
│ │
│ VM 화면이 여기에 표시 │
│ (noVNC Canvas) │
│ │
└─────────────────────────────────────────┘
```
## 📋 구현 체크리스트
### Backend (Flask)
- [ ] Proxmox API 클라이언트 구현
- [ ] ProxmoxVM 데이터 모델 생성
- [ ] VNC 세션 관리 시스템
- [ ] WebSocket 프록시 서버
- [ ] API 엔드포인트 구현
### Frontend (Templates)
- [ ] noVNC 라이브러리 통합
- [ ] VNC 콘솔 페이지 템플릿
- [ ] 약국 상세 페이지 VM 섹션
- [ ] JavaScript VNC 연결 함수
### 시스템 통합
- [ ] VM 목록 자동 동기화
- [ ] 권한 검증 시스템
- [ ] 에러 처리 및 로깅
- [ ] 성능 최적화
## 🚀 배포 계획
### 개발 환경 테스트
1. **로컬 Proxmox 테스트**: VirtualBox/VMware로 Proxmox VE 설치
2. **noVNC 연동 테스트**: 기본 VNC 연결 확인
3. **Headscale 네트워크 테스트**: 원격 Proxmox 접근
### 운영 환경 적용
1. **점진적 배포**: 1개 약국부터 테스트
2. **모니터링 시스템**: VNC 연결 로그 및 성능 측정
3. **백업 접근 방법**: VNC 실패 시 SSH/RDP 대안
## 💡 추가 기능 아이디어
### Phase 2 고급 기능
- **다중 모니터 지원**: VM이 여러 화면을 사용하는 경우
- **클립보드 공유**: 로컬 PC ↔ 원격 VM 텍스트 복사
- **파일 전송**: 드래그앤드롭 파일 업로드
- **스크린샷 캡처**: 문제 해결을 위한 화면 저장
- **세션 녹화**: 작업 과정 기록
### 모니터링 및 분석
- **VNC 사용 통계**: 접속 시간, 빈도 분석
- **VM 성능 모니터링**: CPU, 메모리 사용률 실시간 표시
- **접속 이력 관리**: 언제, 누가, 어떤 VM에 접속했는지 로그
## 🎯 예상 효과
### 업무 효율성
- **즉시 원격 지원**: 약국 직원 도움 요청 시 바로 화면 접속
- **중앙 집중 관리**: 모든 약국 VM을 한 곳에서 관리
- **문제 해결 시간 단축**: 전화 설명 → 직접 화면 제어
### 기술적 장점
- **Headscale 네트워크 활용**: 기존 인프라 최대한 활용
- **브라우저 기반**: 별도 소프트웨어 설치 불필요
- **확장성**: 새로운 약국 추가 시 자동 연동
---
## 📞 문의 및 지원
이 시스템 구현 과정에서 기술적 이슈나 추가 요구사항이 있으면 언제든 문의하세요.
**핵심 목표**: 🖱️ **원클릭** → 🖥️ **VM 화면 접속**

View File

@ -1,458 +0,0 @@
# 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 프록시 구현 시작

View File

@ -1,297 +0,0 @@
# PharmQ SaaS 구독 서비스 관리 시스템 기획서
## 1. 프로젝트 개요
### 1.1 목적
- PharmQ가 제공하는 다양한 서비스에 대한 약국별 구독 관리
- SaaS 형태의 과금 서비스 기반 마련
- 약국별 서비스 이용 현황 실시간 모니터링
### 1.2 서비스 라인업
1. **클라우드 PC** (Proxmox 기반 가상 데스크톱)
2. **AI CCTV** (인공지능 기반 보안 모니터링)
3. **CRM** (고객 관계 관리 시스템)
## 2. 데이터베이스 스키마 설계
### 2.1 새로 추가할 테이블
#### 2.1.1 `service_products` - 서비스 상품 마스터
```sql
CREATE TABLE service_products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_code VARCHAR(20) UNIQUE NOT NULL, -- 'CLOUD_PC', 'AI_CCTV', 'CRM'
product_name VARCHAR(100) NOT NULL, -- '클라우드 PC', 'AI CCTV', 'CRM'
description TEXT, -- 서비스 상세 설명
monthly_price DECIMAL(10,2) NOT NULL, -- 월 구독료
setup_fee DECIMAL(10,2) DEFAULT 0, -- 초기 설치비
is_active BOOLEAN DEFAULT TRUE, -- 서비스 활성화 여부
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
#### 2.1.2 `pharmacy_subscriptions` - 약국별 구독 현황
```sql
CREATE TABLE pharmacy_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pharmacy_id INTEGER NOT NULL, -- pharmacy_info.id 참조
product_id INTEGER NOT NULL, -- service_products.id 참조
subscription_status VARCHAR(20) NOT NULL, -- 'ACTIVE', 'SUSPENDED', 'CANCELLED'
start_date DATE NOT NULL, -- 구독 시작일
end_date DATE, -- 구독 종료일 (NULL이면 무제한)
next_billing_date DATE, -- 다음 결제일
monthly_fee DECIMAL(10,2) NOT NULL, -- 실제 적용 월 구독료
notes TEXT, -- 특이사항
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (pharmacy_id) REFERENCES pharmacy_info(id),
FOREIGN KEY (product_id) REFERENCES service_products(id),
UNIQUE(pharmacy_id, product_id) -- 약국-상품당 하나의 구독만
);
```
#### 2.1.3 `subscription_usage_logs` - 서비스 이용 로그
```sql
CREATE TABLE subscription_usage_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subscription_id INTEGER NOT NULL, -- pharmacy_subscriptions.id 참조
usage_type VARCHAR(50) NOT NULL, -- 'LOGIN', 'API_CALL', 'STORAGE_USE' 등
usage_amount INTEGER DEFAULT 1, -- 사용량 (로그인 횟수, API 호출 수 등)
usage_date DATE NOT NULL, -- 사용일
metadata JSON, -- 추가 메타데이터
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (subscription_id) REFERENCES pharmacy_subscriptions(id)
);
```
#### 2.1.4 `billing_history` - 결제 이력
```sql
CREATE TABLE billing_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subscription_id INTEGER NOT NULL, -- pharmacy_subscriptions.id 참조
billing_period_start DATE NOT NULL, -- 과금 기간 시작
billing_period_end DATE NOT NULL, -- 과금 기간 종료
amount DECIMAL(10,2) NOT NULL, -- 청구 금액
billing_status VARCHAR(20) NOT NULL, -- 'PENDING', 'PAID', 'OVERDUE', 'CANCELLED'
billing_date DATE, -- 실제 결제일
payment_method VARCHAR(50), -- 결제 수단
invoice_number VARCHAR(100), -- 청구서 번호
notes TEXT, -- 결제 관련 메모
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (subscription_id) REFERENCES pharmacy_subscriptions(id)
);
```
### 2.2 기존 테이블 연동
- `pharmacy_info` 테이블과 `pharmacy_subscriptions` 테이블을 연결
- 사용자 관리에서 약국별 구독 서비스 현황 표시
## 3. 프론트엔드 UI/UX 설계
### 3.1 대시보드 개선
#### 3.1.1 메인 대시보드 (`/`)
- **구독 서비스 현황 카드** 추가
```
┌─────────────────────────────────────────┐
│ 📊 구독 서비스 현황 │
├─────────────────────────────────────────┤
│ 클라우드 PC │ 12/14 약국 (85.7%) │
│ AI CCTV │ 8/14 약국 (57.1%) │
│ CRM │ 10/14 약국 (71.4%) │
├─────────────────────────────────────────┤
│ 총 월 매출 │ ₩2,450,000 │
└─────────────────────────────────────────┘
```
#### 3.1.2 서비스별 상태 인디케이터
- 각 서비스별 색상 코드 적용
- 🟢 클라우드 PC (녹색)
- 🔵 AI CCTV (파란색)
- 🟡 CRM (노란색)
### 3.2 약국 관리 페이지 개선 (`/pharmacy`)
#### 3.2.1 약국 목록에 구독 상태 표시
```
┌─────────────────────────────────────────────────────────────────┐
│ 약국명 │ 위치 │ 구독 서비스 │ 월 구독료 │ 액션 │
├─────────────────────────────────────────────────────────────────┤
│ 서울약국 │ 서울 강남구 │ 🟢 💻 🔵 📷 🟡 📊 │ ₩180,000 │ ⚙️ │
│ 부산약국 │ 부산 해운대 │ 🟢 💻 🔵 📷 │ ₩120,000 │ ⚙️ │
│ 대구약국 │ 대구 중구 │ 🟢 💻 │ ₩60,000 │ ⚙️ │
└─────────────────────────────────────────────────────────────────┘
```
#### 3.2.2 약국 상세 페이지 구독 탭 추가
- 기존: 기본정보, 네트워크정보, 연결된 머신
- **신규**: **구독 서비스** 탭 추가
```
┌─────────────────────────────────────────────────────────────────┐
│ [기본정보] [네트워크정보] [연결된 머신] [구독 서비스] ←← 신규 탭 │
├─────────────────────────────────────────────────────────────────┤
│ 📦 구독 중인 서비스 │
│ │
│ 🟢 클라우드 PC ₩60,000/월 [관리] │
│ ├─ 구독기간: 2024.01.15 ~ 무제한 │
│ ├─ 다음결제: 2025.10.15 │
│ └─ 상태: 정상 (ACTIVE) │
│ │
│ 🔵 AI CCTV ₩80,000/월 [관리] │
│ ├─ 구독기간: 2024.03.01 ~ 무제한 │
│ ├─ 다음결제: 2025.10.01 │
│ └─ 상태: 정상 (ACTIVE) │
│ │
│ 📦 구독 가능한 서비스 │
│ │
│ 🟡 CRM 시스템 ₩40,000/월 [가입] │
│ └─ 고객 관계 관리 및 매출 분석 도구 │
│ │
│ [+ 새 서비스 구독하기] │
└─────────────────────────────────────────────────────────────────┘
```
### 3.3 새로운 메뉴 추가
#### 3.3.1 사이드바 메뉴 구조 개선
```
PharmQ Super Admin (PSA)
├── 📊 대시보드
├── 🏥 약국 관리
├── 👥 PQON 사용자 관리
├── 💻 머신 관리
├── 🖥️ VM 관리 (VNC)
├── 📦 구독 서비스 관리 ←← 신규 메뉴
│ ├── 구독 현황 조회
│ ├── 서비스 상품 관리
│ ├── 결제 이력 조회
│ └── 사용량 통계
├── 📈 매출 대시보드 ←← 신규 메뉴
└── 🔗 Medivault
```
#### 3.3.2 구독 서비스 관리 페이지 (`/subscriptions`)
- **전체 구독 현황 테이블**
- **서비스별 필터링**
- **구독 상태별 필터링** (활성/일시정지/해지)
- **월별 매출 차트**
#### 3.3.3 매출 대시보드 (`/revenue`)
- **월별 매출 트렌드**
- **서비스별 매출 비중**
- **약국별 구독료 순위**
- **신규 구독/해지 통계**
## 4. API 설계
### 4.1 구독 관리 API
#### 4.1.1 구독 현황 조회
```
GET /api/subscriptions
GET /api/subscriptions/pharmacy/{pharmacy_id}
GET /api/subscriptions/product/{product_code}
```
#### 4.1.2 구독 생성/수정
```
POST /api/subscriptions # 새 구독 생성
PUT /api/subscriptions/{subscription_id} # 구독 정보 수정
DELETE /api/subscriptions/{subscription_id} # 구독 해지
```
#### 4.1.3 서비스 상품 관리
```
GET /api/products # 상품 목록
POST /api/products # 상품 등록
PUT /api/products/{product_id} # 상품 수정
```
### 4.2 결제 및 통계 API
#### 4.2.1 결제 관련
```
GET /api/billing/history/{subscription_id} # 결제 이력
POST /api/billing/invoice # 청구서 생성
PUT /api/billing/{billing_id}/status # 결제 상태 업데이트
```
#### 4.2.2 통계 및 리포트
```
GET /api/analytics/revenue/monthly # 월별 매출
GET /api/analytics/subscriptions/summary # 구독 요약
GET /api/analytics/usage/{subscription_id} # 서비스 사용량
```
## 5. 구현 단계별 로드맵
### Phase 1: 기본 구조 구축 (1-2주)
- [x] 데이터베이스 스키마 생성
- [ ] 기본 API 엔드포인트 구현
- [ ] 대시보드 구독 현황 카드 추가
### Phase 2: 약국별 구독 관리 (2-3주)
- [ ] 약국 상세 페이지 구독 탭 추가
- [ ] 구독 생성/수정/해지 기능
- [ ] 약국 목록 구독 상태 표시
### Phase 3: 서비스 관리 및 통계 (3-4주)
- [ ] 구독 서비스 관리 메뉴 구현
- [ ] 매출 대시보드 구현
- [ ] 사용량 로깅 시스템
### Phase 4: 과금 시스템 (4-6주)
- [ ] 자동 결제 시스템
- [ ] 청구서 생성
- [ ] 결제 연동 (포트원/토스페이먼츠 등)
## 6. 기술 스택
### 6.1 백엔드
- **Database**: SQLite (개발) → PostgreSQL (운영)
- **API**: Flask REST API
- **결제**: 포트원(PortOne) 또는 토스페이먼츠
### 6.2 프론트엔드
- **Framework**: Bootstrap 5 + Jinja2
- **Charts**: Chart.js 또는 D3.js
- **Icons**: Font Awesome
### 6.3 모니터링
- **사용량 추적**: Custom logging system
- **알림**: 결제 실패, 구독 만료 등
## 7. 보안 및 컴플라이언스
### 7.1 데이터 보안
- 결제 정보 암호화
- 개인정보 보호법 준수
- API 인증/인가 체계
### 7.2 백업 및 복구
- 데이터베이스 일일 백업
- 결제 데이터 별도 보관
- 장애 복구 프로세스
---
## 8. 예상 효과
### 8.1 비즈니스 효과
- **매출 가시화**: 실시간 구독 매출 현황 파악
- **고객 관리**: 약국별 서비스 이용 패턴 분석
- **확장성**: 새로운 서비스 추가 용이
### 8.2 운영 효율화
- **자동화**: 구독/해지/결제 프로세스 자동화
- **모니터링**: 서비스별 이용 현황 실시간 추적
- **리포팅**: 월별/분기별 매출 리포트 자동 생성
---
**작성일**: 2025년 9월 11일
**작성자**: PharmQ Development Team
**버전**: v1.0

View File

@ -1,404 +0,0 @@
# PharmQ 사용자 포털 및 구독 서비스 기획서
## 1. 프로젝트 개요
### 1.1 목적
- 약국 사용자가 PharmQ 서비스를 쉽게 이해하고 가입할 수 있는 포털 제공
- 카카오 SSO 기반 간편 가입 및 로그인 시스템
- 토스페이먼츠 연동을 통한 안전한 구독 결제 서비스
- 구독 후 사용자가 실제 서비스를 이용할 수 있는 사용자 대시보드
### 1.2 서비스 구조
```
PharmQ 생태계
├── 🏢 Super Admin (PSA) - 관리자용 (현재 구현됨)
├── 🌐 Public Portal - 서비스 소개 및 가입 페이지 (신규 개발)
└── 👤 User Dashboard - 구독자 전용 관리 페이지 (신규 개발)
```
---
## 2. Public Portal (서비스 소개 및 가입 페이지)
### 2.1 페이지 구성
#### 2.1.1 메인 페이지 (`/`)
- **헤더**: PharmQ 로고, 네비게이션, 로그인 버튼
- **히어로 섹션**: 캐치프레이즈 및 주요 가치 제안
- **서비스 소개**: 3가지 서비스 (클라우드 PC, AI CCTV, CRM) 간단 소개
- **고객 후기**: 기존 고객 사례 (익명화)
- **가격 안내**: 서비스별 월 구독료
- **CTA 버튼**: "무료 상담 신청" 또는 "지금 시작하기"
#### 2.1.2 서비스 상세 페이지 (`/services/{service_code}`)
- **클라우드 PC** (`/services/cloud-pc`)
- Proxmox 기반 가상 데스크톱 서비스 설명
- 원격 근무, 보안, 백업의 장점
- 스크린샷 및 데모 영상
- **AI CCTV** (`/services/ai-cctv`)
- 인공지능 기반 보안 모니터링 설명
- 실시간 알림, 이상행동 감지 기능
- 설치 사례 및 효과
- **CRM 시스템** (`/services/crm`)
- 고객 관계 관리 및 매출 분석 도구
- 고객 데이터 관리, 리포트 기능
- ROI 계산기
#### 2.1.3 가격 및 플랜 페이지 (`/pricing`)
```
┌─────────────────────────────────────────────────────────────────┐
│ PharmQ 서비스 플랜 │
├─────────────────────────────────────────────────────────────────┤
│ 🖥️ 클라우드 PC 📹 AI CCTV 📊 CRM 시스템 │
│ ₩60,000/월 ₩80,000/월 ₩40,000/월 │
│ • 가상 데스크톱 제공 • 24시간 모니터링 • 고객 DB 관리 │
│ • 자동 백업 • AI 이상행동 감지 • 매출 분석 리포트 │
│ • 원격 접속 지원 • 실시간 알림 • 마케팅 도구 │
│ │
│ ⭐ 추천: 전체 패키지 ₩150,000/월 (₩30,000 할인) │
│ [무료 상담 신청] [패키지 선택하기] │
└─────────────────────────────────────────────────────────────────┘
```
#### 2.1.4 회사 소개 페이지 (`/about`)
- Medivault & PharmQ 소개
- 팀 소개 및 비전
- 오시는 길
#### 2.1.5 고객센터 (`/support`)
- FAQ (자주 묻는 질문)
- 문의하기 폼
- 연락처 정보
### 2.2 사용자 가입 프로세스
#### 2.2.1 카카오 SSO 로그인 (`/auth/kakao`)
```
사용자 클릭: "카카오로 시작하기"
카카오 OAuth 인증 페이지 이동
사용자 카카오 로그인 및 동의
PharmQ로 리다이렉트 + 사용자 정보 수신
신규 사용자 → 추가 정보 입력 페이지
기존 사용자 → 사용자 대시보드 이동
```
#### 2.2.2 추가 정보 입력 페이지 (`/signup/pharmacy-info`)
사용자가 카카오 로그인 후 입력해야 할 추가 정보:
```sql
-- 사용자 기본 정보 (카카오에서 받음)
kakao_user_id: VARCHAR(100) -- 카카오 고유 ID
name: VARCHAR(50) -- 실명
email: VARCHAR(100) -- 이메일
phone: VARCHAR(20) -- 휴대폰 번호
-- 약국 정보 (사용자 직접 입력 또는 약준모 약사면허인증 API활용)
pharmacy_name: VARCHAR(100) -- 약국명 *
business_license: VARCHAR(20) -- 사업자등록번호 *
pharmacy_address: TEXT -- 약국 주소 *
pharmacist_license: VARCHAR(30) -- 약사 면허번호 *
establishment_date: DATE -- 개업일
pharmacy_phone: VARCHAR(20) -- 약국 전화번호
fax_number: VARCHAR(20) -- 팩스번호
-- 선택 정보
staff_count: INTEGER -- 직원 수
monthly_customers: INTEGER -- 월 평균 고객 수
current_pos_system: VARCHAR(50) -- 현재 사용 중인 POS 시스템
special_requirements: TEXT -- 특별 요구사항
```
#### 2.2.3 약관 동의
- **필수 동의**
- 서비스 이용약관
- 개인정보 처리방침
- 약국 정보 수집 및 이용 동의
- **선택 동의**
- 마케팅 정보 수신 동의 (SMS, 이메일)
- 서비스 개선을 위한 데이터 활용 동의
#### 2.2.4 서비스 선택 및 구독 (`/subscription/select`)
1. **서비스 선택**
- 개별 서비스 선택 (클라우드 PC, AI CCTV, CRM)
- 패키지 선택 (전체 패키지 할인)
- 구독 기간 선택 (월간, 연간 - 연간 10% 할인)
2. **결제 정보 확인**
- 선택 서비스 및 금액 확인
- 할인 적용 여부 확인
- 첫 달 무료 체험 적용
3. **토스페이먼츠 결제** (`/payment/process`)
```javascript
// 토스페이먼츠 연동 플로우
const payment = TossPayments(clientKey);
payment.requestPayment('카드', {
amount: subscription.amount,
orderId: generate_order_id(),
orderName: `PharmQ ${services.join(', ')} 구독`,
customerName: user.name,
customerEmail: user.email,
successUrl: 'https://pharmq.co.kr/payment/success',
failUrl: 'https://pharmq.co.kr/payment/fail',
});
```
---
## 3. User Dashboard (구독자 전용 관리 페이지)
### 3.1 인증 및 접근 제어
- **로그인**: 카카오 SSO를 통한 로그인만 허용
- **권한 체크**: 활성 구독이 있는 사용자만 접근 가능
- **세션 관리**: JWT 토큰 기반 세션 관리
### 3.2 대시보드 구성 (`/dashboard`)
#### 3.2.1 메인 대시보드
```
┌─────────────────────────────────────────────────────────────────┐
│ 👋 안녕하세요, [약국명] [대표자명]님 │
├─────────────────────────────────────────────────────────────────┤
│ 📊 구독 현황 │
│ • 🖥️ 클라우드 PC: 활성 (다음 결제: 2025.10.15) │
│ • 📹 AI CCTV: 활성 (다음 결제: 2025.10.01) │
│ • 📊 CRM: 구독 안함 [구독하기] │
│ │
│ 💳 이번 달 구독료: ₩140,000 │
│ 📈 서비스 이용 현황 │
│ • 클라우드 PC 접속: 15회 (이번 달) │
│ • AI CCTV 알림: 3건 (최근 7일) │
└─────────────────────────────────────────────────────────────────┘
```
#### 3.2.2 서비스별 관리 페이지
**클라우드 PC 관리** (`/dashboard/cloud-pc`)
- 가상 머신 상태 확인
- 원격 접속 링크 (VNC)
- 백업 현황 및 복구 요청
- 사용량 통계
**AI CCTV 관리** (`/dashboard/ai-cctv`)
- 실시간 모니터링 화면
- 알림 히스토리
- 이상행동 감지 로그
- 카메라 설정 관리
**CRM 시스템** (`/dashboard/crm`)
- 고객 데이터 관리
- 매출 분석 리포트
- 마케팅 캠페인 관리
- 데이터 내보내기
#### 3.2.3 구독 관리 (`/dashboard/subscription`)
- 현재 구독 서비스 확인
- 구독 업그레이드/다운그레이드
- 결제 이력 조회
- 구독 해지 (해지 사유 조사)
#### 3.2.4 계정 설정 (`/dashboard/settings`)
- 약국 정보 수정
- 비밀번호 변경 (카카오 연동이므로 제한적)
- 알림 설정 (이메일, SMS)
- 개인정보 수정
#### 3.2.5 고객지원 (`/dashboard/support`)
- 1:1 문의하기
- 문의 내역 조회
- FAQ 및 도움말
- 원격 지원 요청
---
## 4. 데이터베이스 설계 확장
### 4.1 신규 테이블 추가
#### 4.1.1 사용자 계정 관리
```sql
CREATE TABLE user_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kakao_user_id VARCHAR(100) UNIQUE NOT NULL,
email VARCHAR(100) NOT NULL,
name VARCHAR(50) NOT NULL,
phone VARCHAR(20),
profile_image_url TEXT,
is_active BOOLEAN DEFAULT TRUE,
last_login_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE user_pharmacy_info (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
pharmacy_name VARCHAR(100) NOT NULL,
business_license VARCHAR(20) NOT NULL,
pharmacy_address TEXT NOT NULL,
pharmacist_license VARCHAR(30) NOT NULL,
establishment_date DATE,
pharmacy_phone VARCHAR(20),
fax_number VARCHAR(20),
staff_count INTEGER,
monthly_customers INTEGER,
current_pos_system VARCHAR(50),
special_requirements TEXT,
verification_status VARCHAR(20) DEFAULT 'PENDING', -- PENDING, VERIFIED, REJECTED
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES user_accounts(id)
);
```
#### 4.1.2 구독 및 결제 관리
```sql
CREATE TABLE user_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
subscription_status VARCHAR(20) NOT NULL, -- ACTIVE, CANCELLED, SUSPENDED
start_date DATE NOT NULL,
end_date DATE,
next_billing_date DATE,
monthly_fee DECIMAL(10,2) NOT NULL,
billing_cycle VARCHAR(10) DEFAULT 'MONTHLY', -- MONTHLY, YEARLY
auto_renewal BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES user_accounts(id),
FOREIGN KEY (product_id) REFERENCES service_products(id)
);
CREATE TABLE payment_transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
subscription_id INTEGER,
toss_payment_key VARCHAR(200) UNIQUE,
toss_order_id VARCHAR(100) UNIQUE,
amount DECIMAL(10,2) NOT NULL,
payment_method VARCHAR(50),
payment_status VARCHAR(20), -- SUCCESS, FAILED, CANCELLED, REFUNDED
paid_at TIMESTAMP,
failed_reason TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES user_accounts(id),
FOREIGN KEY (subscription_id) REFERENCES user_subscriptions(id)
);
```
#### 4.1.3 서비스 사용 로그
```sql
CREATE TABLE service_usage_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
subscription_id INTEGER NOT NULL,
service_type VARCHAR(20) NOT NULL, -- CLOUD_PC, AI_CCTV, CRM
action_type VARCHAR(50) NOT NULL, -- LOGIN, ACCESS, DOWNLOAD, etc.
session_duration INTEGER, -- 세션 지속 시간 (초)
ip_address VARCHAR(45),
user_agent TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES user_accounts(id),
FOREIGN KEY (subscription_id) REFERENCES user_subscriptions(id)
);
```
---
## 5. 기술 스택 및 구현
### 5.1 백엔드 기술 스택
- **Framework**: Flask (기존 시스템과 통합)
- **Authentication**: 카카오 OAuth 2.0
- **Payment**: 토스페이먼츠 API
- **Database**: SQLite (개발) → PostgreSQL (운영)
- **Session**: Flask-Session + Redis
### 5.2 프론트엔드 기술 스택
- **Template Engine**: Jinja2
- **CSS Framework**: Bootstrap 5
- **JavaScript**: Vanilla JS + Chart.js
- **Icons**: Font Awesome
- **Responsive**: Mobile-First 디자인
### 5.3 보안 고려사항
- **데이터 암호화**: 민감 정보 AES-256 암호화
- **API 보안**: JWT 토큰 + Rate Limiting
- **결제 보안**: 토스페이먼츠 Webhook 검증
- **개인정보 보호**: GDPR 준수, 최소한의 정보 수집
---
## 6. 개발 로드맵
### Phase 1: 기본 포털 구축 (2-3주)
- [ ] Public Portal 메인 페이지 구현
- [ ] 서비스 소개 페이지 구현
- [ ] 가격 안내 페이지 구현
- [ ] 카카오 SSO 연동
### Phase 2: 사용자 가입 및 구독 (3-4주)
- [ ] 사용자 가입 플로우 구현
- [ ] 약국 정보 입력 및 검증 시스템
- [ ] 토스페이먼츠 결제 연동
- [ ] 구독 생성 및 관리 API
### Phase 3: 사용자 대시보드 (4-5주)
- [ ] 사용자 대시보드 메인 페이지
- [ ] 서비스별 관리 페이지 구현
- [ ] 구독 관리 및 결제 이력 페이지
- [ ] 계정 설정 및 고객 지원
### Phase 4: 고도화 및 운영 (2-3주)
- [ ] 사용량 추적 및 분석 시스템
- [ ] 이메일/SMS 알림 시스템
- [ ] 관리자 승인 워크플로우
- [ ] 성능 최적화 및 보안 강화
---
## 7. 예상 효과
### 7.1 비즈니스 효과
- **자동화된 고객 획득**: 24/7 온라인 가입 가능
- **결제 자동화**: 토스페이먼츠를 통한 안정적인 정기 결제
- **고객 셀프 서비스**: 고객 지원 비용 절감
- **데이터 기반 의사결정**: 사용자 행동 분석을 통한 서비스 개선
### 7.2 사용자 경험 개선
- **간편한 가입**: 카카오 SSO로 1분 내 가입 완료
- **투명한 가격**: 명확한 가격 정보 및 할인 혜택
- **통합 관리**: 모든 서비스를 하나의 대시보드에서 관리
- **실시간 지원**: 채팅 및 원격 지원을 통한 빠른 문제 해결
---
**작성일**: 2025년 9월 11일
**작성자**: PharmQ Development Team
**버전**: v1.0
## 8. 추가 고려사항
### 8.1 법적 컴플라이언스
- **약사법** 준수: 약국 정보 관리 시 관련 법규 준수
- **개인정보보호법**: 고객 데이터 수집, 처리, 보관 시 법적 요구사항 충족
- **전자상거래법**: 구독 서비스 약관 및 취소 정책 명시
### 8.2 확장성 고려
- **멀티테넌트 아키텍처**: 향후 다양한 업종 확장 대비
- **API 우선 설계**: 모바일 앱 및 제3자 연동 대비
- **클라우드 네이티브**: 서비스 확장에 따른 인프라 자동 스케일링
### 8.3 운영 및 모니터링
- **서비스 상태 모니터링**: 각 서비스별 실시간 상태 체크
- **사용자 행동 분석**: 가입 전환율, 이탈률, 서비스 사용 패턴 분석
- **고객 만족도 조사**: 정기적인 NPS 조사 및 피드백 수집

View File

@ -1,247 +0,0 @@
# 🚀 팜큐 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초 만에 팜큐 네트워크에 연결!

View File

@ -52,7 +52,7 @@ docker-compose up -d headplane
```
## 📋 접속 정보
- **Headscale API**: http://localhost:8070
- **Headscale API**: http://localhost:8080
- **Headplane UI**: http://localhost:3000
## 👤 사용자 관리
@ -129,68 +129,4 @@ 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에서 복사 붙여넣기 (English version - 한글 깨짐 해결)
iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/farmq-install-en.ps1'))
```
### 기존 Tailscale 있는 경우 (강제 재등록)
```powershell
# 기존 연결을 자동으로 해제하고 재등록 (English version)
$Force = $true; iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/farmq-install-en.ps1'))
```
### 한글 버전 (인코딩 문제 발생 가능)
```powershell
# 한글이 깨져 보일 수 있음 - 위 English 버전 권장
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 방화벽** 자동 설정
- ✅ **네트워크 연결 테스트** 및 확인
```

View File

@ -1,126 +0,0 @@
# 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 클라이언트 연결 복구*

View File

@ -1,230 +0,0 @@
# VNC WebSocket 연결 문제 및 프록시 솔루션
## 🚨 현재 문제 상황
### WebSocket 1006 연결 실패 원인
```
WebSocket connection to 'wss://pve7.0bin.in/api2/json/nodes/pve7/qemu/102/vncwebsocket...' failed:
Failed when connecting: Connection closed (code: 1006)
```
**WebSocket Error Code 1006**는 "Abnormal Closure"를 의미하며, 연결이 설정되기도 전에 종료되었음을 나타냅니다.
## 🔍 근본 원인 분석
### 1. 브라우저 보안 정책 (Mixed Content)
- **현재 상황**: HTTPS 웹사이트 (`pqadmin.0bin.in`)에서 자체 서명 인증서를 사용하는 WebSocket 연결 시도
- **브라우저 제한**: Chrome, Firefox 등 모던 브라우저는 HTTPS 페이지에서 신뢰할 수 없는 SSL 인증서로의 WebSocket 연결을 자동 차단
- **에러 발생**: 브라우저가 연결 시도 자체를 거부하여 1006 에러 발생
### 2. SSL/TLS 인증서 신뢰 문제
```bash
# Proxmox 서버의 자체 서명 인증서
Subject: CN=Proxmox Virtual Environment
Issuer: PVE Cluster Manager CA
```
- **자체 서명 인증서**: `pve7.0bin.in`이 공인 CA가 아닌 자체 서명 인증서 사용
- **브라우저 불신**: 사용자가 명시적으로 인증서를 신뢰하지 않으면 WebSocket 연결 거부
### 3. CORS (Cross-Origin Resource Sharing) 정책
- **Origin 불일치**: `pqadmin.0bin.in`에서 `pve7.0bin.in`으로의 크로스 오리진 WebSocket 연결
- **Preflight 검사**: 브라우저가 연결 전 OPTIONS 요청을 보내지만 Proxmox가 적절한 CORS 헤더를 반환하지 않을 가능성
## 💡 해결책 1: 역방향 프록시 (Reverse Proxy)
### 구현 방식: nginx를 통한 WebSocket 프록시
```nginx
# /etc/nginx/sites-available/pqadmin-vnc
server {
listen 443 ssl;
server_name pqadmin.0bin.in;
ssl_certificate /path/to/ssl/cert.pem;
ssl_certificate_key /path/to/ssl/key.pem;
# VNC WebSocket 프록시
location /vnc-proxy/ {
# WebSocket 업그레이드 헤더
proxy_pass https://pve7.0bin.in/api2/json/nodes/pve7/qemu/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $proxy_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;
# SSL 검증 비활성화 (자체 서명 인증서 때문)
proxy_ssl_verify off;
proxy_ssl_server_name on;
# 타임아웃 설정
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
```
### 장점
- ✅ **같은 도메인**: `pqladmin.0bin.in/vnc-proxy/`로 WebSocket 연결하여 CORS 문제 해결
- ✅ **신뢰할 수 있는 SSL**: nginx가 공인 인증서를 사용하여 브라우저 신뢰 확보
- ✅ **투명한 프록시**: 클라이언트는 Proxmox 직접 연결과 동일하게 작동
### 구현 단계
```bash
# 1. nginx 설정 파일 생성
sudo nano /etc/nginx/sites-available/pqadmin-vnc
# 2. 심볼릭 링크 생성
sudo ln -s /etc/nginx/sites-available/pqadmin-vnc /etc/nginx/sites-enabled/
# 3. nginx 설정 테스트
sudo nginx -t
# 4. nginx 재시작
sudo systemctl reload nginx
```
### 클라이언트 코드 수정
```javascript
// 기존: 직접 Proxmox 연결
// const websocketUrl = 'wss://pve7.0bin.in/api2/json/nodes/pve7/qemu/102/vncwebsocket?...'
// 변경: nginx 프록시를 통한 연결
const websocketUrl = 'wss://pqladmin.0bin.in/vnc-proxy/102/vncwebsocket?...'
```
## 💡 해결책 2: WebSocket 프록시 서버
### 구현 방식: Node.js/Python WebSocket 중계 서버
```python
# vnc_websocket_proxy.py
import asyncio
import websockets
import ssl
import json
from urllib.parse import parse_qs
class VNCWebSocketProxy:
def __init__(self):
self.proxmox_host = "pve7.0bin.in"
self.proxmox_port = 443
async def proxy_websocket(self, websocket, path):
# 클라이언트 요청 파싱
query_params = parse_qs(path.split('?', 1)[1] if '?' in path else '')
vmid = query_params.get('vmid', [''])[0]
ticket = query_params.get('vncticket', [''])[0]
# Proxmox WebSocket URL 생성
proxmox_url = f"wss://{self.proxmox_host}:{self.proxmox_port}/api2/json/nodes/pve7/qemu/{vmid}/vncwebsocket?port=5900&vncticket={ticket}"
# SSL 컨텍스트 (자체 서명 인증서 허용)
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
try:
# Proxmox WebSocket 연결
async with websockets.connect(proxmox_url, ssl=ssl_context) as proxmox_ws:
# 양방향 데이터 중계
await asyncio.gather(
self.forward_messages(websocket, proxmox_ws),
self.forward_messages(proxmox_ws, websocket)
)
except Exception as e:
await websocket.send(json.dumps({"error": f"Proxmox connection failed: {str(e)}"}))
async def forward_messages(self, source, destination):
try:
async for message in source:
await destination.send(message)
except websockets.exceptions.ConnectionClosed:
pass
# 프록시 서버 시작
if __name__ == "__main__":
proxy = VNCWebSocketProxy()
start_server = websockets.serve(proxy.proxy_websocket, "0.0.0.0", 8765)
asyncio.get_event_loop().run_until_complete(start_server)
asyncio.get_event_loop().run_forever()
```
### 장점
- ✅ **완전한 제어**: WebSocket 연결 과정을 완전히 제어 가능
- ✅ **에러 처리**: 연결 실패 시 상세한 에러 메시지 제공
- ✅ **로깅**: 모든 WebSocket 트래픽 로깅 및 디버깅 가능
- ✅ **인증 확장**: 추가적인 인증 로직 구현 가능
### 구현 단계
```bash
# 1. 의존성 설치
pip install websockets asyncio
# 2. 프록시 서버 실행
python vnc_websocket_proxy.py
# 3. systemd 서비스 등록
sudo systemctl enable vnc-websocket-proxy.service
```
### 클라이언트 코드 수정
```javascript
// WebSocket 프록시 서버로 연결
const websocketUrl = 'ws://localhost:8765/vnc-proxy?vmid=102&vncticket=' + encodeURIComponent(ticket);
```
## 📊 솔루션 비교
| 기준 | 역방향 프록시 (nginx) | WebSocket 프록시 서버 |
|------|----------------------|---------------------|
| **구현 복잡도** | 🟡 중간 | 🔴 높음 |
| **성능** | 🟢 우수 | 🟡 양호 |
| **유지보수** | 🟢 쉬움 | 🟡 보통 |
| **확장성** | 🟡 제한적 | 🟢 우수 |
| **디버깅** | 🟡 제한적 | 🟢 우수 |
| **보안** | 🟢 안전 | 🟡 주의 필요 |
| **리소스 사용량** | 🟢 낮음 | 🟡 보통 |
## 🎯 추천 솔루션
### 단기 해결책: 역방향 프록시 (nginx)
1. **빠른 구현**: 기존 nginx 설정에 location 블록만 추가
2. **안정성**: nginx의 검증된 WebSocket 프록시 기능 활용
3. **보안**: 공인 SSL 인증서로 브라우저 신뢰 확보
### 장기 해결책: 하이브리드 접근
1. **nginx 프록시**: 기본적인 WebSocket 중계
2. **커스텀 미들웨어**: 인증, 로깅, 모니터링 기능 추가
3. **로드 밸런싱**: 다중 Proxmox 호스트 지원
## 🚀 구현 우선순위
### Phase 1: nginx 역방향 프록시 구현
```bash
# 1. nginx 설정 파일 작성
# 2. SSL 인증서 설정
# 3. WebSocket 프록시 규칙 추가
# 4. 클라이언트 URL 변경
```
### Phase 2: 에러 처리 및 모니터링 강화
```bash
# 1. 연결 실패 시 재시도 로직
# 2. WebSocket 상태 모니터링
# 3. 로그 수집 및 분석
```
### Phase 3: 다중 호스트 지원
```bash
# 1. 동적 백엔드 라우팅
# 2. 헬스체크 구현
# 3. 로드 밸런싱 설정
```
---
**결론**: WebSocket 1006 에러는 브라우저 보안 정책과 인증서 신뢰 문제가 주요 원인입니다. nginx 역방향 프록시를 통해 이 문제를 근본적으로 해결할 수 있으며, 이는 가장 실용적이고 안정적인 솔루션입니다.

View File

@ -1,455 +0,0 @@
# 🪟 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에서도 한 줄 명령어로 팜큐 네트워크 연결!" 🎯

View File

@ -1,183 +0,0 @@
# 🪟 Windows 팜큐 네트워크 빠른 시작 가이드
Windows PC를 팜큐 네트워크에 **30초만에** 연결하는 방법입니다.
## 🎯 복사 붙여넣기 전용 명령어
### 📋 기본 설치 (가장 많이 사용) - 인코딩 문제 해결됨 ✅
**복사할 명령어 (권장 - English 버전):**
```powershell
iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/farmq-install-en.ps1'))
```
### 📋 강제 재설치 (기존 Tailscale이 있는 경우)
**복사할 명령어 (권장 - English 버전):**
```powershell
$Force = $true; iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/farmq-install-en.ps1'))
```
### 📋 한글 버전 (인코딩 문제 발생 가능)
**한글이 깨져서 나올 수 있습니다 - 위 English 버전을 사용하세요:**
```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'))
# 강제 재설치 (한글 깨짐 가능)
$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-en.ps1'))
============================================
FARMQ Headscale Windows One-Click Installation
============================================
[*] Checking system requirements...
[+] System requirements check completed
[*] Checking Tailscale installation...
[i] Installing Tailscale for Windows...
[*] Downloading Tailscale...
[*] Installing Tailscale... (please wait)
[+] Tailscale installation completed
[*] Starting Tailscale service...
[+] Tailscale service is running.
[*] Registering with Headscale server...
[i] Headscale Server: https://head.0bin.in
[i] Pre-auth Key: 8b3df41d***************
[*] Executing registration command...
[+] Headscale registration successful!
[*] Configuring firewall settings...
[+] Firewall configuration completed
[*] Verifying network connection...
[+] Headscale network connection completed!
[i] Assigned IPv4: 100.64.0.15
[i] Assigned IPv6: fd7a:115c:a1e0::15
[*] Testing network connectivity...
[+] FARMQ network (100.64.0.0/10) connection successful!
============================================
FARMQ Headscale Windows Installation Complete!
============================================
Installation completed successfully!
System Information:
Computer Name: PHARMACY-PC01
Tailscale IP: 100.64.0.15
OS: Windows 10.0
Headscale Server: https://head.0bin.in
```
## ❓ 자주 묻는 질문 (FAQ)
### Q: "실행 정책" 오류가 나와요
**A: 다음 명령을 먼저 실행하세요:**
```powershell
Set-ExecutionPolicy Bypass -Scope Process -Force
```
### Q: 관리자 권한이 없다고 나와요
**A: PowerShell을 관리자로 다시 실행하세요:**
- `Windows 키 + X``Windows PowerShell(관리자)`
### Q: 이미 Tailscale이 설치되어 있어요
**A: 강제 재설치 명령어를 사용하세요 (English 버전):**
```powershell
$Force = $true; iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/farmq-install-en.ps1'))
```
### Q: 한글이 깨져서 나와요
**A: English 버전을 사용하세요 (인코딩 문제 해결됨):**
```powershell
# 기본 설치 (English 버전)
iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/farmq-install-en.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초 후 팜큐 네트워크 연결!** 🚀

View File

@ -1,196 +0,0 @@
#!/bin/bash
# =============================================================================
# FARMQ Headscale 클라이언트 자동 등록 스크립트
# =============================================================================
# 사용법: ./add-client.sh [사용자명] [머신명]
# 예시: ./add-client.sh pharmacy-01 busan-store-pc
# =============================================================================
set -e # 오류 발생 시 스크립트 중단
# 색상 정의
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 로고
echo -e "${BLUE}"
echo " ███████╗ █████╗ ██████╗ ██╗ ██╗ ██████╗ "
echo " ██╔════╝██╔══██╗██╔══██╗████╗ ████║██╔═══██╗"
echo " █████╗ ███████║██████╔╝██╔████╔██║██║ ██║"
echo " ██╔══╝ ██╔══██║██╔══██╗██║╚██╔╝██║██║▄▄ ██║"
echo " ██║ ██║ ██║██║ ██║██║ ╚═╝ ██║╚██████╔╝"
echo " ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚══▀▀═╝ "
echo -e "${NC}"
echo -e "${GREEN}🏥 FARMQ Headscale 클라이언트 자동 등록 스크립트${NC}"
echo "============================================================"
# 설정 변수
HEADSCALE_SERVER="192.168.0.151:8070"
HEADSCALE_URL="http://${HEADSCALE_SERVER}"
FARMQ_ADMIN_URL="http://192.168.0.151:5001"
# 파라미터 확인
USER_NAME=${1:-""}
MACHINE_NAME=${2:-""}
# 사용자 입력 받기
if [[ -z "$USER_NAME" ]]; then
echo -e "${YELLOW}📝 사용자명을 입력하세요 (예: pharmacy-01, store-busan):${NC}"
read -p "사용자명: " USER_NAME
fi
if [[ -z "$MACHINE_NAME" ]]; then
echo -e "${YELLOW}📝 머신명을 입력하세요 (예: pos-terminal, office-pc):${NC}"
read -p "머신명: " MACHINE_NAME
fi
# 입력값 검증
if [[ -z "$USER_NAME" ]] || [[ -z "$MACHINE_NAME" ]]; then
echo -e "${RED}❌ 사용자명과 머신명은 필수입니다.${NC}"
exit 1
fi
echo -e "${BLUE}📋 설정 정보:${NC}"
echo " 사용자명: $USER_NAME"
echo " 머신명: $MACHINE_NAME"
echo " Headscale 서버: $HEADSCALE_SERVER"
echo ""
# 확인 메시지
echo -e "${YELLOW}⚠️ 이 설정으로 진행하시겠습니까? (y/N)${NC}"
read -p "진행: " CONFIRM
if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
echo -e "${RED}❌ 취소되었습니다.${NC}"
exit 1
fi
echo -e "${GREEN}🚀 클라이언트 등록을 시작합니다...${NC}"
# 1. 시스템 업데이트
echo -e "${BLUE}📦 시스템 업데이트 중...${NC}"
sudo apt update -qq
# 2. Tailscale 설치 확인
if ! command -v tailscale &> /dev/null; then
echo -e "${YELLOW}📥 Tailscale 설치 중...${NC}"
curl -fsSL https://tailscale.com/install.sh | sh
else
echo -e "${GREEN}✅ Tailscale이 이미 설치되어 있습니다.${NC}"
fi
# 3. 기존 Tailscale 연결 해제 (있는 경우)
echo -e "${BLUE}🧹 기존 연결 정리 중...${NC}"
sudo tailscale logout 2>/dev/null || true
sudo tailscale down 2>/dev/null || true
# 4. Headscale 서버 연결 테스트
echo -e "${BLUE}🔍 Headscale 서버 연결 테스트 중...${NC}"
if ! curl -s --connect-timeout 5 "$HEADSCALE_URL/health" > /dev/null 2>&1; then
echo -e "${RED}❌ Headscale 서버에 연결할 수 없습니다: $HEADSCALE_URL${NC}"
echo -e "${YELLOW}💡 서버가 실행 중이고 방화벽 설정을 확인하세요.${NC}"
exit 1
fi
echo -e "${GREEN}✅ Headscale 서버 연결 성공${NC}"
# 5. Pre-auth key 생성 (서버에서 실행)
echo -e "${BLUE}🔑 Pre-auth key 생성 중...${NC}"
echo -e "${YELLOW}📝 Headscale 서버에서 다음 명령어를 실행해야 합니다:${NC}"
echo ""
echo -e "${GREEN}# SSH로 서버 접속 후 실행:${NC}"
echo "cd /srv/headscale-setup"
echo "docker exec headscale headscale users create $USER_NAME 2>/dev/null || true"
echo "docker exec headscale headscale preauthkeys create --user $USER_NAME --expiration 1h"
echo ""
# Pre-auth key 입력 받기
echo -e "${YELLOW}🔐 생성된 Pre-auth key를 입력하세요:${NC}"
read -p "Pre-auth key: " PREAUTH_KEY
if [[ -z "$PREAUTH_KEY" ]]; then
echo -e "${RED}❌ Pre-auth key는 필수입니다.${NC}"
exit 1
fi
# 6. Tailscale을 Headscale 서버에 연결
echo -e "${BLUE}🔗 Headscale 서버에 연결 중...${NC}"
# 머신명 설정
sudo tailscale up \
--login-server="$HEADSCALE_URL" \
--authkey="$PREAUTH_KEY" \
--hostname="$MACHINE_NAME" \
--accept-dns=false \
--reset
# 7. 연결 상태 확인
echo -e "${BLUE}🔍 연결 상태 확인 중...${NC}"
sleep 5
# Tailscale 상태 확인
if tailscale status >/dev/null 2>&1; then
echo -e "${GREEN}✅ Tailscale 서비스 정상 작동${NC}"
# IP 주소 확인
TAILSCALE_IP=$(tailscale ip -4 2>/dev/null || echo "IP 조회 실패")
echo -e "${GREEN}📍 할당된 IP 주소: $TAILSCALE_IP${NC}"
# 연결된 노드 목록
echo -e "${BLUE}🌐 연결된 노드 목록:${NC}"
tailscale status
else
echo -e "${RED}❌ Tailscale 연결에 실패했습니다.${NC}"
echo -e "${YELLOW}💡 로그 확인: sudo journalctl -u tailscaled -f${NC}"
exit 1
fi
# 8. 네트워크 연결 테스트
echo -e "${BLUE}🧪 네트워크 연결 테스트 중...${NC}"
# 서버와의 연결 테스트
if ping -c 3 100.64.0.1 >/dev/null 2>&1; then
echo -e "${GREEN}✅ Headscale 서버와 통신 성공 (100.64.0.1)${NC}"
else
echo -e "${YELLOW}⚠️ 서버와의 직접 통신은 실패했지만 정상일 수 있습니다.${NC}"
fi
# 9. 시스템 서비스 활성화
echo -e "${BLUE}⚙️ 시스템 서비스 설정 중...${NC}"
sudo systemctl enable tailscaled
sudo systemctl start tailscaled
# 10. 방화벽 설정 (선택사항)
if command -v ufw &> /dev/null; then
echo -e "${BLUE}🔥 방화벽 설정 중...${NC}"
sudo ufw allow in on tailscale0 2>/dev/null || true
fi
# 11. 완료 메시지
echo -e "${GREEN}"
echo "🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉"
echo " FARMQ 클라이언트 등록 완료!"
echo "🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉"
echo -e "${NC}"
echo -e "${GREEN}✅ 클라이언트가 성공적으로 등록되었습니다!${NC}"
echo ""
echo -e "${BLUE}📊 연결 정보:${NC}"
echo " • 사용자명: $USER_NAME"
echo " • 머신명: $MACHINE_NAME"
echo " • Tailscale IP: $TAILSCALE_IP"
echo " • 서버 주소: $HEADSCALE_SERVER"
echo ""
echo -e "${BLUE}🌐 관리 페이지:${NC}"
echo " • FARMQ 관리자 페이지: $FARMQ_ADMIN_URL"
echo " • Headplane UI: http://192.168.0.151:3000"
echo ""
echo -e "${BLUE}🔧 유용한 명령어:${NC}"
echo " • 상태 확인: tailscale status"
echo " • IP 주소 확인: tailscale ip"
echo " • 로그 확인: sudo journalctl -u tailscaled -f"
echo " • 재시작: sudo systemctl restart tailscaled"
echo ""
echo -e "${GREEN}🎊 이제 FARMQ 네트워크의 일부가 되었습니다!${NC}"

View File

@ -1,74 +0,0 @@
#!/usr/bin/env python3
"""
데이터베이스 정리 - 문제가 되는 테이블들 제거
"""
import sqlite3
import os
from datetime import datetime
def clean_database():
"""문제가 되는 테이블들 제거"""
db_path = '/srv/headscale-setup/data/db.sqlite'
backup_path = f'/srv/headscale-setup/data/db.sqlite.clean_backup.{datetime.now().strftime("%Y%m%d_%H%M%S")}'
print("🧹 데이터베이스 정리 - 문제 테이블 제거")
print("=" * 50)
# 백업 생성
print(f"📦 백업 생성: {backup_path}")
import shutil
shutil.copy2(db_path, backup_path)
# 데이터베이스 연결
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
# 외래키 제약조건 비활성화
cursor.execute("PRAGMA foreign_keys = OFF")
# 문제가 되는 테이블들 확인
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%pharmacy%'")
problem_tables = cursor.fetchall()
print(f"🎯 제거할 테이블들: {[table[0] for table in problem_tables]}")
# 테이블들 제거
for table in problem_tables:
table_name = table[0]
print(f"🗑️ 테이블 제거: {table_name}")
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
# monitoring_data, machine_specs 등도 제거 (필요시)
additional_tables = ['monitoring_data', 'machine_specs']
for table_name in additional_tables:
cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}'")
if cursor.fetchone():
print(f"🗑️ 추가 테이블 제거: {table_name}")
cursor.execute(f"DROP TABLE IF EXISTS {table_name}")
# 변경사항 커밋
conn.commit()
# 남은 테이블 확인
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
remaining_tables = cursor.fetchall()
print(f"✅ 남은 테이블들: {[table[0] for table in remaining_tables]}")
# 무결성 검사
cursor.execute("PRAGMA integrity_check")
integrity = cursor.fetchone()[0]
print(f"🔍 무결성 검사: {integrity}")
print("✅ 데이터베이스 정리 완료!")
print(f"📦 백업 위치: {backup_path}")
except Exception as e:
print(f"❌ 오류: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == '__main__':
clean_database()

View File

@ -1,5 +1,5 @@
---
server_url: http://localhost:8070
server_url: http://localhost:8080
listen_addr: 0.0.0.0:8080
metrics_listen_addr: 0.0.0.0:9090
@ -7,9 +7,8 @@ private_key_path: /var/lib/headscale/private.key
noise:
private_key_path: /var/lib/headscale/noise_private.key
prefixes:
v4: 100.64.0.0/10
v6: fd7a:115c:a1e0::/48
ip_prefixes:
- 100.64.0.0/10
derp:
server:
@ -19,7 +18,6 @@ derp:
disable_check_updates: false
ephemeral_node_inactivity_timeout: 30m
database:
type: sqlite3
sqlite:
@ -40,21 +38,17 @@ log:
format: text
level: info
# Updated DNS configuration format
dns:
acl_policy_path: ""
dns_config:
override_local_dns: true
nameservers:
global:
- 1.1.1.1
- 8.8.8.8
search_domains: []
- 1.1.1.1
- 8.8.8.8
domains: []
magic_dns: true
base_domain: headscale.local
# Updated policy path
policy:
path: ""
unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"
@ -63,13 +57,13 @@ logtail:
randomize_client_port: false
# Simplified OIDC configuration (removed deprecated keys)
oidc:
only_start_if_oidc_is_available: false
only_start_if_oidc_is_available: true
issuer: ""
client_id: ""
client_secret: ""
scope: ["openid", "profile", "email"]
extra_params: {}
allowed_domains: []
allowed_users: []
allowed_users: []
strip_email_domain: true

View File

@ -1,167 +0,0 @@
#!/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 "$@"

View File

@ -5,7 +5,7 @@ services:
image: headscale/headscale:latest
container_name: headscale
restart: unless-stopped
command: serve
command: headscale serve
environment:
- TZ=Asia/Seoul
volumes:
@ -13,16 +13,16 @@ services:
- ./data:/var/lib/headscale
- ./run:/var/run/headscale
ports:
- "8070:8080" # Headscale HTTP API (외부:내부)
- "8080:8080" # Headscale HTTP API
- "9090:9090" # Metrics (optional)
networks:
- headscale-net
healthcheck:
test: ["CMD", "/ko-app/headscale", "version"]
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
start_period: 40s
headplane:
image: ghcr.io/tale/headplane:latest
@ -32,12 +32,11 @@ 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
headscale:
condition: service_healthy
networks:
- headscale-net

View File

@ -1,190 +0,0 @@
# 안정적인 PVE 인증 정보 전달 전략
## 브라우저 WebSocket VNC 연결 개선 방안
### 🚨 현재 문제 상황
**간헐적 연결 실패 원인 분석:**
- 브라우저에서 PVE 인증 쿠키가 WebSocket 연결 시 불안정하게 전달됨
- NPM 리버스 프록시에서 인증 헤더 전달 불일치
- Proxmox 세션 만료 및 브라우저 쿠키 상태 변화
- CORS/보안 정책으로 인한 쿠키 차단
**Claude Code 환경 성공 요인:**
```python
headers = {'Cookie': f'PVEAuthCookie={client.ticket}'}
async with websockets.connect(websocket_url, ssl=ssl_context, additional_headers=headers)
```
→ **명시적 인증 헤더 전달로 100% 안정적 연결**
### 🎯 안정적인 인증 전략 옵션
#### 방법 1: Flask 백엔드 인증 프록시 (권장 ⭐⭐⭐⭐⭐)
**개념:**
- Flask가 Proxmox 인증을 대신 처리
- 브라우저는 Flask 세션만 관리
- Flask가 WebSocket을 Proxmox로 프록시
**장점:**
- ✅ 브라우저 보안 정책 우회
- ✅ 인증 상태 중앙 관리
- ✅ 세션 만료 자동 갱신 가능
- ✅ NPM 설정 변경 불필요
**구현 구조:**
```
브라우저 WebSocket → Flask WebSocket 프록시 → Proxmox VNC WebSocket
↑ (PVE 인증 헤더 자동 추가)
```
#### 방법 2: Flask API를 통한 인증 토큰 전달 (보통 ⭐⭐⭐)
**개념:**
- Flask API로 PVE 인증 정보 조회
- JavaScript에서 받아서 WebSocket 연결 시 사용
**장점:**
- ✅ 기존 코드 수정 최소화
- ✅ 명시적 인증 제어
**단점:**
- ❌ 브라우저 JavaScript에 인증 정보 노출
- ❌ noVNC 라이브러리 제약 (커스텀 헤더 지원 제한)
#### 방법 3: NPM 설정 개선 (복잡함 ⭐⭐)
**개념:**
- Nginx 설정으로 WebSocket 인증 헤더 자동 추가
**단점:**
- ❌ NPM 설정 복잡도 증가
- ❌ 여전히 브라우저 보안 정책 제약
- ❌ 디버깅 어려움
### 🏆 권장 솔루션: Flask WebSocket 프록시
#### 아키텍처 설계
```
┌─────────────┐ WebSocket ┌─────────────┐ WebSocket+Auth ┌─────────────┐
│ 브라우저 │ ────────────────→ │ Flask │ ───────────────────→ │ Proxmox VE │
│ (noVNC) │ │ Proxy │ │ VNC Server │
└─────────────┘ └─────────────┘ └─────────────┘
자동 PVE 인증
헤더 추가 처리
```
#### 핵심 컴포넌트
**1. Flask WebSocket 프록시 엔드포인트**
```python
# 새로운 엔드포인트: /vnc/<vm_id>/proxy
@app.websocket('/vnc/<int:vm_id>/proxy')
async def vnc_websocket_proxy(vm_id):
# 1. Flask 세션 검증
# 2. Proxmox 인증 정보 준비
# 3. Proxmox VNC WebSocket 연결 (PVE 쿠키 포함)
# 4. 브라우저 ↔ Proxmox 간 데이터 양방향 중계
```
**2. 브라우저 WebSocket URL 변경**
```javascript
// 기존 (직접 Proxmox 연결)
const websocketUrl = 'wss://pve7.0bin.in:443/api2/json/nodes/pve7/qemu/102/vncwebsocket?...'
// 새로운 (Flask 프록시 경유)
const websocketUrl = 'wss://farmq.0bin.in/vnc/102/proxy'
```
### 🔧 현재 코드 개선 계획
#### Phase 1: Flask WebSocket 프록시 구현
```python
# app.py에 추가할 함수들
async def create_proxmox_vnc_connection(vm_id):
"""Proxmox VNC WebSocket 연결 생성 (인증 헤더 포함)"""
async def proxy_websocket_data(browser_ws, proxmox_ws):
"""브라우저와 Proxmox 간 WebSocket 데이터 중계"""
@app.websocket('/vnc/<int:vm_id>/proxy')
async def vnc_websocket_proxy(vm_id):
"""VNC WebSocket 프록시 메인 핸들러"""
```
#### Phase 2: 브라우저 클라이언트 수정
```javascript
// vnc_simple.html 수정 계획
// 1. WebSocket URL을 Flask 프록시로 변경
// 2. VNC 티켓 생성 로직 제거 (Flask에서 처리)
// 3. 연결 상태 관리 개선
```
#### Phase 3: 세션 관리 강화
```python
# 세션 만료 감지 및 자동 갱신
# Proxmox 인증 상태 모니터링
# 오류 상황 처리 및 복구
```
### 🎯 구현 우선순위
**즉시 구현 (High Priority):**
- [ ] Flask WebSocket 라이브러리 추가 (flask-socketio 또는 quart)
- [ ] VNC WebSocket 프록시 기본 구조 구현
- [ ] 브라우저 WebSocket URL 변경
**단계적 개선 (Medium Priority):**
- [ ] 세션 만료 자동 처리
- [ ] 연결 상태 모니터링
- [ ] 오류 복구 메커니즘
**최적화 (Low Priority):**
- [ ] 성능 튜닝 (연결 풀링, 캐싱)
- [ ] 로깅 및 모니터링 강화
- [ ] 다중 VM 동시 연결 지원
### 🔒 보안 고려사항
**인증 보안:**
- Flask 세션으로 사용자 권한 검증
- Proxmox 인증 정보는 서버 메모리에만 보관
- 브라우저에 민감 정보 노출 방지
**네트워크 보안:**
- Flask ↔ Proxmox 통신은 내부 네트워크
- SSL/TLS 종단간 암호화 유지
- CORS 정책 적절한 설정
### 📊 예상 효과
**안정성 개선:**
- ✅ 인증 실패로 인한 연결 오류 완전 제거
- ✅ 세션 만료 자동 처리
- ✅ 네트워크 오류 복구 능력 향상
**사용자 경험:**
- ✅ 일관된 연결 성공률
- ✅ 빠른 연결 속도 (중간 단계 제거)
- ✅ 오류 상황 사용자 친화적 처리
**유지보수성:**
- ✅ 중앙집중식 인증 관리
- ✅ 디버깅 용이성 향상
- ✅ 코드 복잡도 감소
### 🚀 다음 단계
1. **Flask WebSocket 라이브러리 선택 및 설치**
2. **간단한 프록시 프로토타입 구현**
3. **기본 연결 테스트**
4. **브라우저 클라이언트 수정**
5. **통합 테스트 및 검증**
**⚠️ 주의사항:**
이 변경은 아키텍처 수준의 개선이므로 충분한 테스트와 백업 계획이 필요합니다.
---
**💡 핵심 메시지:**
Claude Code 환경에서 증명된 "명시적 PVE 인증 헤더 전달" 방식을 Flask 프록시를 통해 브라우저 환경에서도 안정적으로 구현하자는 것이 이 전략의 핵심입니다.

View File

@ -1,249 +0,0 @@
# VNC 웹소켓 구현 기술 문서
## Proxmox API를 이용한 noVNC 통합 가이드
### 📋 개요
이 문서는 Proxmox VE API를 활용하여 웹 브라우저에서 직접 가상머신에 VNC 접속할 수 있는 시스템의 기술적 구현 내용을 설명합니다. Flask 백엔드와 noVNC 클라이언트를 통해 브라우저에서 직접 VM 콘솔에 접근할 수 있도록 구현되었습니다.
### 🏗️ 시스템 아키텍처
```
[웹 브라우저]
↓ HTTPS
[NPM 리버스 프록시]
↓ HTTP
[Flask 애플리케이션]
↓ HTTPS API
[Proxmox VE 서버]
↓ WebSocket (WSS)
[VM VNC 서버]
```
### 🔧 핵심 구성요소
#### 1. Proxmox API 클라이언트 (`utils/proxmox_client.py`)
**주요 기능:**
- Proxmox VE API 인증 및 세션 관리
- VNC 티켓 생성 및 WebSocket URL 생성
- VM 상태 관리 (시작/정지/상태확인)
**핵심 구현:**
```python
def get_vnc_ticket(self, node: str, vmid: int) -> Optional[Dict]:
"""VNC 접속 티켓 생성"""
data = {
'websocket': '1',
'generate-password': '1' # 자동 패스워드 생성
}
response = self.session.post(
f"{self.base_url}/nodes/{node}/qemu/{vmid}/vncproxy",
data=data,
timeout=10
)
if response.status_code == 200:
vnc_data = response.json()['data']
encoded_ticket = quote_plus(vnc_data['ticket'])
vnc_data['websocket_url'] = f"wss://{self.host}:443/api2/json/nodes/{node}/qemu/{vmid}/vncwebsocket?port={vnc_data['port']}&vncticket={encoded_ticket}"
return vnc_data
```
**인증 방식:**
- 세션 쿠키 방식 (PVEAuthCookie)
- CSRF 토큰 헤더 (CSRFPreventionToken)
- API 토큰 방식 지원
#### 2. Flask 웹 애플리케이션 (`app.py`)
**주요 엔드포인트:**
- `/vnc/<int:vm_id>`: VNC 클라이언트 페이지 렌더링
- API 엔드포인트들을 통한 VM 관리
**로깅 시스템:**
```python
def setup_logging():
if not os.path.exists('logs'):
os.makedirs('logs')
file_handler = RotatingFileHandler(
'logs/farmq-admin.log',
maxBytes=10*1024*1024,
backupCount=5
)
```
#### 3. noVNC 클라이언트 (`templates/vnc_simple.html`)
**핵심 기능:**
- WebSocket을 통한 VNC 프로토콜 처리
- 자동 리사이징 및 스케일링
- HTML 엔티티 디코딩 (패스워드 처리)
**WebSocket 연결 코드:**
```javascript
function connectVNC() {
const rfb = new RFB(document.getElementById('screen'), websocketUrl, {
credentials: {
password: decodeHtmlEntities('{{ vnc_data.password }}')
}
});
rfb.addEventListener("connect", () => {
console.log("VNC 연결 성공");
resizeScreen();
});
rfb.addEventListener("disconnect", (e) => {
console.log(`VNC 연결 종료: ${e.detail.clean ? '정상' : '비정상'}`);
});
}
```
**HTML 엔티티 디코딩:**
```javascript
function decodeHtmlEntities(text) {
const textarea = document.createElement('textarea');
textarea.innerHTML = text;
return textarea.value;
}
```
### 🔐 보안 및 인증
#### VNC 인증 플로우
1. **티켓 요청**: Flask → Proxmox API (`/vncproxy`)
2. **티켓 생성**: Proxmox가 일회용 VNC 티켓 및 패스워드 생성
3. **WebSocket 연결**: 브라우저 → Proxmox VNC WebSocket
4. **VNC 인증**: 생성된 패스워드로 VNC 서버 인증
#### 보안 설정
```python
# SSL 검증 무시 (내부 네트워크)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
self.session.verify = False
# WebSocket URL에 티켓 인코딩
encoded_ticket = quote_plus(vnc_data['ticket'])
```
### 🌐 네트워크 구성
#### NPM (Nginx Proxy Manager) 설정
- **외부 도메인**: `https://pve7.0bin.in`
- **내부 주소**: `https://192.168.0.5:8006`
- **WebSocket 지원**: Upgrade 헤더 프록시 필요
#### WebSocket 프록시 요구사항
```nginx
# WebSocket 업그레이드 헤더
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
```
### ⚡ 성능 최적화
#### Canvas 자동 리사이징
```javascript
function resizeScreen() {
const canvas = document.querySelector('#screen canvas');
if (canvas) {
const container = document.getElementById('screen');
const scaleX = container.clientWidth / canvas.width;
const scaleY = container.clientHeight / canvas.height;
const scale = Math.min(scaleX, scaleY, 1);
canvas.style.transform = `scale(${scale})`;
canvas.style.transformOrigin = 'top left';
}
}
```
#### 연결 상태 관리
- 자동 재연결 로직
- 연결 품질 모니터링
- 에러 처리 및 사용자 피드백
### 🐛 문제 해결 가이드
#### 일반적인 문제들
**1. WebSocket 1006 에러 (비정상 연결 종료)**
- **원인**: NPM 프록시 WebSocket 설정 부족
- **해결**: WebSocket 업그레이드 헤더 설정 확인
**2. VNC 인증 실패 (HTTP 401)**
- **원인**: 잘못된 티켓 또는 패스워드
- **해결**: `generate-password: 1` 설정 확인
**3. 빈 화면 또는 검은 화면**
- **원인**: Canvas 리사이징 문제
- **해결**: `resizeScreen()` 함수 호출 확인
**4. HTML 엔티티 문제**
- **원인**: 패스워드의 특수문자 인코딩
- **해결**: `decodeHtmlEntities()` 함수 적용
#### 디버깅 도구
**1. 서버 사이드 테스트**
```bash
cd /srv/headscale-setup/farmq-admin
python test_vnc_websocket.py
```
**2. 로그 확인**
```bash
tail -f logs/farmq-admin.log
```
**3. 브라우저 개발자 도구**
- Network 탭: WebSocket 연결 상태 확인
- Console 탭: JavaScript 에러 확인
### 📊 모니터링 및 로깅
#### 로그 레벨
- **INFO**: 정상 동작 로그
- **WARNING**: 경고사항
- **ERROR**: 오류 발생
- **DEBUG**: 상세 디버깅 정보
#### 중요 로그 포인트
- VNC 티켓 생성 성공/실패
- WebSocket 연결 시도
- VNC 인증 결과
- 연결 종료 사유
### 🔄 배포 및 유지보수
#### 배포 체크리스트
- [ ] Proxmox API 연결 테스트
- [ ] Flask 애플리케이션 시작 확인
- [ ] NPM 프록시 설정 검증
- [ ] WebSocket 연결 테스트
- [ ] VNC 클라이언트 동작 확인
#### 백업 및 복구
```bash
# Git 커밋 상태 확인
git log --oneline -10
# 안정된 버전으로 롤백
git reset --hard 1dc09101cc7afdf09ca3b8cbbc4f95e21bb5746f
```
### 📚 참고 자료
- [Proxmox VE API 문서](https://pve.proxmox.com/pve-docs/api-viewer/)
- [noVNC 프로젝트](https://github.com/novnc/noVNC)
- [WebSocket RFC 6455](https://tools.ietf.org/html/rfc6455)
- [VNC 프로토콜 스펙](https://tools.ietf.org/html/rfc6143)
### 🏷️ 버전 정보
- **프로젝트**: FarmQ Admin VNC Integration
- **마지막 업데이트**: 2024년
- **안정 버전**: commit `1dc09101cc7afdf09ca3b8cbbc4f95e21bb5746f`
- **Python**: 3.x
- **Flask**: 최신 안정 버전
- **noVNC**: 최신 버전

View File

@ -1,161 +0,0 @@
# VNC 네트워크 아키텍처 문제 분석 및 해결방안
## 🔍 현재 상황 분석
### 네트워크 구조
```
[외부 사용자 PC]
↓ (인터넷)
[pqadmin.0bin.in] (Let's Encrypt SSL + 리버스 프록시)
↓ (로컬/VPN)
[Headscale 서버 - Flask Admin]
↓ (Headscale VPN: 100.64.x.x)
[Proxmox Hosts]
- pve7.0bin.in (443)
- 100.64.0.6:8006 (Healthport PVE)
```
### 문제 상황
1. **Flask 서버**: Headscale VPN 내부에서 실행 중
2. **외부 사용자**: `pqadmin.0bin.in`을 통해 Flask Admin에 접속
3. **VNC WebSocket URL**: `wss://100.64.0.6:8006/...` (Headscale VPN 내부 IP)
4. **접속 실패**: 외부 사용자는 100.64.x.x 대역에 접근 불가
## ❌ 핵심 문제
### 네트워크 분리 문제
- **Flask Admin**: 인터넷에서 접근 가능 (`pqadmin.0bin.in`)
- **Proxmox VNC**: Headscale VPN 내부에서만 접근 가능 (`100.64.x.x`)
- **사용자**: 두 네트워크를 동시에 접근할 수 없음
### 현재 WebSocket 연결 방식
```javascript
// 문제가 되는 현재 방식
WebSocket URL: wss://100.64.0.6:8006/api2/json/nodes/pev/qemu/103/vncwebsocket
```
## 💡 해결방안
### 1. WebSocket 프록시 구현 (권장)
#### 구조
```
[외부 사용자] → [pqadmin.0bin.in] → [Flask 서버] → [Proxmox VNC]
↓ ↓ ↓ ↓
wss://pqadmin.0bin.in/vnc-proxy/{session_id} → wss://100.64.0.6:8006/...
```
#### 구현 방법
1. **Flask에서 WebSocket 프록시 서버 구현**
- Socket.IO 또는 웹소켓 라이브러리 사용
- 외부 → Flask → Proxmox 중계 역할
2. **URL 변경**
```javascript
// 변경 전
wss://100.64.0.6:8006/api2/json/nodes/pev/qemu/103/vncwebsocket
// 변경 후
wss://pqadmin.0bin.in/vnc-proxy/session_id_here
```
### 2. Nginx 리버스 프록시 확장
#### Nginx 설정 추가
```nginx
# VNC WebSocket 프록시
location /vnc-ws/ {
proxy_pass https://100.64.0.6:8006/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_ssl_verify off;
}
```
#### URL 변경
```javascript
// 변경 전
wss://100.64.0.6:8006/api2/json/nodes/pev/qemu/103/vncwebsocket
// 변경 후
wss://pqadmin.0bin.in/vnc-ws/api2/json/nodes/pev/qemu/103/vncwebsocket
```
### 3. VPN 클라이언트 요구 (비권장)
#### 방법
- 모든 사용자가 Headscale VPN 클라이언트 설치
- 100.64.x.x 대역 직접 접근 가능
#### 단점
- 사용자 부담 증가
- 관리 복잡성
- 보안 위험
## 🎯 권장 솔루션: WebSocket 프록시
### 장점
1. **사용자 친화적**: 별도 설치 없이 웹브라우저로 접근
2. **보안**: Headscale VPN은 내부 트래픽만 처리
3. **확장성**: 다수의 Proxmox 호스트 지원
4. **SSL 인증서**: Let's Encrypt로 안전한 연결
### 구현 우선순위
1. **1단계**: Flask Socket.IO WebSocket 프록시 구현
2. **2단계**: 세션 관리 및 인증 강화
3. **3단계**: 다중 Proxmox 호스트 지원 완성
4. **4단계**: 성능 최적화 및 모니터링
## 🔧 기술 스택
### 현재 사용 중
- **Flask**: Web 서버
- **noVNC**: 브라우저 VNC 클라이언트
- **Headscale**: VPN 서버
- **Nginx**: 리버스 프록시
- **Let's Encrypt**: SSL 인증서
### 추가 필요
- **Flask-SocketIO**: WebSocket 프록시 구현
- **python-websockets**: WebSocket 클라이언트 (Proxmox 연결용)
## 📋 구현 단계
### Phase 1: WebSocket 프록시 서버
1. Flask-SocketIO 설치 및 설정
2. VNC WebSocket 프록시 핸들러 구현
3. 세션 관리 및 인증 연동
### Phase 2: URL 라우팅 변경
1. VNC 연결 URL 생성 로직 수정
2. 프록시 경로로 WebSocket URL 변경
3. noVNC 클라이언트 연결 테스트
### Phase 3: 다중 호스트 지원
1. 호스트별 프록시 라우팅
2. 동적 Proxmox 호스트 추가
3. 로드 밸런싱 고려
## ⚠️ 고려사항
### 성능
- WebSocket 프록시로 인한 지연 시간 증가
- 동시 연결 수 제한
- 서버 리소스 사용량 증가
### 보안
- VNC 트래픽 암호화 상태 유지
- 세션 만료 및 권한 관리
- DDoS 방어 메커니즘
### 확장성
- 다수 사용자 동시 접속
- Proxmox 호스트 동적 추가
- 클러스터 환경 지원
---
**결론**: WebSocket 프록시를 통해 외부 사용자가 안전하게 내부 Proxmox VNC에 접근할 수 있도록 하는 것이 가장 현실적인 해결책입니다.

View File

@ -1,207 +0,0 @@
# VNC WebSocket 연결 문제 해결 문서
## Proxmox VE API 기반 VNC 접속 문제 진단 및 해결
### 🚨 발생한 문제
**증상:**
- 브라우저에서 VNC 접속 시 WebSocket 1006 에러 (비정상 연결 종료)
- Proxmox 로그에 `TASK ERROR: connection timed out` 발생
- noVNC 클라이언트에서 검은 화면만 표시
- HTTP 401 Unauthorized 에러 발생
**영향:**
- 웹 브라우저를 통한 VM 콘솔 접속 불가능
- 사용자가 VM에 직접 접근할 수 없는 상황
### 🔍 문제 진단 과정
#### 1단계: 초기 상황 파악
```bash
# VM 상태 확인
VM 102 상태: running
VM 실행시간: 1463275초 (정상 실행 중)
VM PID: 3482
VNC 포트: N/A ← 문제 발견
```
#### 2단계: VM 설정 상세 조회
```bash
# Proxmox API로 VM 설정 확인
GET /api2/json/nodes/pve7/qemu/102/config
결과:
args: -vnc 0.0.0.0:77 ← 문제의 근본 원인 발견
```
**💡 핵심 발견:**
VM에 커스텀 VNC 설정 `-vnc 0.0.0.0:77`이 설정되어 있어서:
- VM은 포트 5977 (5900 + 77)에서 VNC 서비스 제공
- Proxmox VNC 프록시는 표준 포트 5900 기대
- 포트 불일치로 인한 연결 실패
### 🛠️ 해결 과정
#### 1단계: VM 설정 수정 (실제 Proxmox 서버 설정 변경)
```python
# Python 코드로 실제 Proxmox VM 설정 수정
PUT /api2/json/nodes/pve7/qemu/102/config
data = {'args': ''} # VNC 커스텀 설정 제거
결과: ✅ VNC args 제거 성공
```
**⚠️ 중요:** 이는 Python 설정이 아닌 **실제 Proxmox 서버의 VM 102 설정을 API를 통해 수정**한 것입니다.
#### 2단계: VM 재시작 (설정 적용)
```python
# VM 정지
POST /api2/json/nodes/pve7/qemu/102/status/stop
결과: VM 상태가 'stopped'로 변경 확인
# VM 시작
POST /api2/json/nodes/pve7/qemu/102/status/start
결과: VM 상태가 'running'으로 변경 확인
```
#### 3단계: VNC 프록시 연결 테스트
```python
# VNC 티켓 생성
POST /api2/json/nodes/pve7/qemu/102/vncproxy
data = {
'websocket': '1',
'generate-password': '1'
}
결과:
✅ 포트: 5900 (표준 포트로 정상화)
✅ 패스워드: 자동 생성됨
✅ WebSocket URL: 정상 생성
```
#### 4단계: 인증 문제 해결
**문제:** WebSocket 연결 시 HTTP 401 Unauthorized
**근본 원인:** Proxmox VNC WebSocket은 인증이 필요하지만 브라우저와 달리 수동으로 인증 헤더를 전달해야 함
**해결:** PVE 인증 쿠키를 WebSocket 헤더에 추가
```python
# 기존 (실패) - 인증 정보 없음
async with websockets.connect(websocket_url, ssl=ssl_context)
# 결과: HTTP 401 Unauthorized
# 수정 (성공) - PVE 인증 쿠키 추가
headers = {'Cookie': f'PVEAuthCookie={client.ticket}'}
async with websockets.connect(
websocket_url,
ssl=ssl_context,
additional_headers=headers # 핵심: 인증 헤더 추가
)
# 결과: ✅ WebSocket 연결 성공
```
**🔑 핵심 포인트:**
- `client.ticket`은 Proxmox 로그인 시 받은 인증 티켓
- VNC 티켓(`vncticket`)과는 별개의 **세션 인증 쿠키**
- 브라우저는 자동으로 쿠키를 전송하지만, Python WebSocket은 수동으로 헤더에 추가해야 함
### ✅ 해결 결과
**최종 테스트 성공:**
```
🎉 VNC WebSocket 연결 및 프로토콜 핸드셰이크 성공!
- WebSocket 연결: ✅ 성공
- VNC 프로토콜 버전: RFB 003.008
- 클라이언트 응답: ✅ 완료
```
### 📋 변경된 설정 요약
| 구분 | 변경 전 | 변경 후 |
|------|---------|---------|
| **VM args 설정** | `-vnc 0.0.0.0:77` | `''` (빈 값) |
| **VNC 포트** | 5977 (5900+77) | 5900 (표준) |
| **Proxmox 프록시** | 포트 불일치 오류 | 정상 연결 |
| **WebSocket 인증** | 인증 헤더 누락 | PVE 쿠키 추가 |
### 🔧 적용된 수정 사항
#### 1. Proxmox 서버 VM 설정 (실제 서버 변경)
```bash
# 변경 전
VM 102 설정: args = "-vnc 0.0.0.0:77"
# 변경 후
VM 102 설정: args = ""
```
#### 2. WebSocket 테스트 코드 (`test_vnc_websocket.py`)
```python
# 추가된 인증 헤더
headers = {'Cookie': f'PVEAuthCookie={client.ticket}'}
async with websockets.connect(
websocket_url,
ssl=ssl_context,
additional_headers=headers
)
```
### 🚀 브라우저 클라이언트 적용 방안
이제 서버 사이드 연결이 성공했으므로, 브라우저의 noVNC 클라이언트도 같은 방식으로 수정 가능:
**💡 핵심 인사이트:** Claude Code 환경에서 성공한 이유는 **PVE 인증 쿠키를 WebSocket 헤더에 명시적으로 추가**했기 때문
#### 브라우저에서 해결해야 할 사항:
1. **WebSocket 연결 시 PVE 인증 정보 전달**
```javascript
// 현재 브라우저 코드 (인증 정보 없음)
rfb = new RFB(document.getElementById('screen'), websocketUrl,
{ credentials: { password: vncPassword } });
// 필요한 수정: PVE 세션 쿠키 또는 인증 헤더 추가 방법 구현
```
2. **NPM 리버스 프록시 WebSocket 업그레이드 헤더 설정**
- WebSocket 연결을 위한 Upgrade 헤더 전달
- Cookie 헤더의 올바른 프록시 설정
3. **브라우저 보안 정책 (CORS, Mixed Content) 검토**
- HTTPS → WSS 프로토콜 일관성
- Same-Origin Policy 또는 적절한 CORS 설정
**🎯 가장 중요한 발견:**
서버 환경에서는 `additional_headers={'Cookie': f'PVEAuthCookie={client.ticket}'}`로 해결되었으므로, 브라우저에서도 동일한 인증 정보 전달 방식이 필요합니다.
### 📊 문제 해결 체크리스트
- [x] VM 커스텀 VNC 설정 제거
- [x] VM 재시작으로 설정 적용
- [x] VNC 티켓 생성 확인 (포트 5900)
- [x] WebSocket 인증 헤더 추가
- [x] VNC 프로토콜 핸드셰이크 성공
- [x] 서버 환경에서 완전한 연결 확인
- [ ] 브라우저 클라이언트 적용 (향후 작업)
- [ ] NPM 프록시 WebSocket 설정 개선 (향후 작업)
### 🎯 핵심 교훈
1. **VM 설정 충돌 주의**: 커스텀 VNC 설정이 Proxmox 표준 프록시와 충돌할 수 있음
2. **포트 일관성 중요**: VM VNC 포트와 프록시 기대 포트가 일치해야 함
3. **인증 정보 전달**: WebSocket 연결 시 적절한 인증 헤더 필수
4. **API 기반 설정 변경**: Proxmox API로 실시간 VM 설정 수정 가능
### 📅 해결 완료 시점
- **문제 발견**: Proxmox 로그 타임아웃 에러
- **원인 분석**: VM 커스텀 VNC 설정 충돌
- **해결 완료**: 2024년 (Claude Code 환경에서 완전한 VNC WebSocket 연결 성공)
- **테스트 결과**: VNC 프로토콜 핸드셰이크까지 완료
---
**💡 참고**: 이 문서는 실제 Proxmox 서버의 VM 설정을 변경한 내용을 기록한 것입니다. Python 코드는 Proxmox API 호출을 위한 클라이언트 역할만 수행했으며, 실제 변경은 Proxmox 서버에서 발생했습니다.

File diff suppressed because it is too large Load Diff

View File

@ -1,104 +0,0 @@
#!/usr/bin/env python3
"""
기존 Headscale 데이터 확인 스크립트
"""
import sqlite3
import os
from datetime import datetime
def check_headscale_data():
"""Headscale 데이터베이스의 기존 데이터 확인"""
db_path = '/srv/headscale-setup/data/db.sqlite'
if not os.path.exists(db_path):
print(f"❌ Database file not found: {db_path}")
return
print(f"📊 Checking Headscale database: {db_path}")
print("=" * 60)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
# 테이블 목록 확인
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = cursor.fetchall()
print(f"📋 Available tables: {[table[0] for table in tables]}")
print()
# Users 테이블 확인
try:
cursor.execute("SELECT * FROM users")
users = cursor.fetchall()
print(f"👥 Users ({len(users)} records):")
if users:
cursor.execute("PRAGMA table_info(users)")
columns = [col[1] for col in cursor.fetchall()]
print(f" Columns: {columns}")
for user in users:
print(f" - {dict(zip(columns, user))}")
print()
except Exception as e:
print(f" ❌ Error reading users: {e}")
# Nodes 테이블 확인
try:
cursor.execute("SELECT * FROM nodes")
nodes = cursor.fetchall()
print(f"💻 Nodes ({len(nodes)} records):")
if nodes:
cursor.execute("PRAGMA table_info(nodes)")
columns = [col[1] for col in cursor.fetchall()]
print(f" Columns: {columns}")
for node in nodes:
node_dict = dict(zip(columns, node))
print(f" - ID: {node_dict.get('id')}, Given Name: {node_dict.get('given_name')}, "
f"Hostname: {node_dict.get('hostname')}, IPv4: {node_dict.get('ipv4')}, "
f"User ID: {node_dict.get('user_id')}")
print()
except Exception as e:
print(f" ❌ Error reading nodes: {e}")
# PreAuthKeys 테이블 확인
try:
cursor.execute("SELECT * FROM pre_auth_keys")
keys = cursor.fetchall()
print(f"🔑 PreAuth Keys ({len(keys)} records):")
if keys:
cursor.execute("PRAGMA table_info(pre_auth_keys)")
columns = [col[1] for col in cursor.fetchall()]
print(f" Columns: {columns}")
for key in keys:
key_dict = dict(zip(columns, key))
print(f" - ID: {key_dict.get('id')}, Key: {key_dict.get('key')[:20]}..., "
f"Used: {key_dict.get('used')}, User ID: {key_dict.get('user_id')}")
print()
except Exception as e:
print(f" ❌ Error reading pre_auth_keys: {e}")
# API Keys 테이블 확인
try:
cursor.execute("SELECT * FROM api_keys")
api_keys = cursor.fetchall()
print(f"🗝️ API Keys ({len(api_keys)} records):")
if api_keys:
cursor.execute("PRAGMA table_info(api_keys)")
columns = [col[1] for col in cursor.fetchall()]
print(f" Columns: {columns}")
for api_key in api_keys:
key_dict = dict(zip(columns, api_key))
print(f" - ID: {key_dict.get('id')}, Prefix: {key_dict.get('prefix')}, "
f"Created: {key_dict.get('created_at')}")
print()
except Exception as e:
print(f" ❌ Error reading api_keys: {e}")
except Exception as e:
print(f"❌ Database connection error: {e}")
finally:
conn.close()
if __name__ == '__main__':
check_headscale_data()

View File

@ -1,56 +0,0 @@
"""
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
}

View File

@ -1,347 +0,0 @@
#!/usr/bin/env python3
"""
PharmQ SaaS 구독 서비스 데이터베이스 테이블 생성 스크립트
"""
import sqlite3
import os
from datetime import datetime
def create_subscription_tables():
"""구독 서비스 관련 테이블 생성"""
# FARMQ 데이터베이스 연결
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
print("🚀 PharmQ SaaS 구독 서비스 테이블 생성 중...")
try:
# 1. service_products - 서비스 상품 마스터
print("📦 service_products 테이블 생성 중...")
cursor.execute('''
CREATE TABLE IF NOT EXISTS service_products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_code VARCHAR(20) UNIQUE NOT NULL,
product_name VARCHAR(100) NOT NULL,
description TEXT,
monthly_price DECIMAL(10,2) NOT NULL,
setup_fee DECIMAL(10,2) DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# 2. pharmacy_subscriptions - 약국별 구독 현황
print("🏥 pharmacy_subscriptions 테이블 생성 중...")
cursor.execute('''
CREATE TABLE IF NOT EXISTS pharmacy_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pharmacy_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
subscription_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
start_date DATE NOT NULL,
end_date DATE,
next_billing_date DATE,
monthly_fee DECIMAL(10,2) NOT NULL,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (pharmacy_id) REFERENCES pharmacies(id),
FOREIGN KEY (product_id) REFERENCES service_products(id),
UNIQUE(pharmacy_id, product_id)
)
''')
# 3. subscription_usage_logs - 서비스 이용 로그
print("📊 subscription_usage_logs 테이블 생성 중...")
cursor.execute('''
CREATE TABLE IF NOT EXISTS subscription_usage_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subscription_id INTEGER NOT NULL,
usage_type VARCHAR(50) NOT NULL,
usage_amount INTEGER DEFAULT 1,
usage_date DATE NOT NULL,
metadata TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (subscription_id) REFERENCES pharmacy_subscriptions(id)
)
''')
# 4. billing_history - 결제 이력
print("💳 billing_history 테이블 생성 중...")
cursor.execute('''
CREATE TABLE IF NOT EXISTS billing_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
subscription_id INTEGER NOT NULL,
billing_period_start DATE NOT NULL,
billing_period_end DATE NOT NULL,
amount DECIMAL(10,2) NOT NULL,
billing_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
billing_date DATE,
payment_method VARCHAR(50),
invoice_number VARCHAR(100),
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (subscription_id) REFERENCES pharmacy_subscriptions(id)
)
''')
# 인덱스 생성
print("🔍 인덱스 생성 중...")
indexes = [
"CREATE INDEX IF NOT EXISTS idx_pharmacy_subscriptions_pharmacy_id ON pharmacy_subscriptions(pharmacy_id)",
"CREATE INDEX IF NOT EXISTS idx_pharmacy_subscriptions_product_id ON pharmacy_subscriptions(product_id)",
"CREATE INDEX IF NOT EXISTS idx_pharmacy_subscriptions_status ON pharmacy_subscriptions(subscription_status)",
"CREATE INDEX IF NOT EXISTS idx_usage_logs_subscription_id ON subscription_usage_logs(subscription_id)",
"CREATE INDEX IF NOT EXISTS idx_usage_logs_date ON subscription_usage_logs(usage_date)",
"CREATE INDEX IF NOT EXISTS idx_billing_subscription_id ON billing_history(subscription_id)",
"CREATE INDEX IF NOT EXISTS idx_billing_status ON billing_history(billing_status)"
]
for index_sql in indexes:
cursor.execute(index_sql)
conn.commit()
print("✅ 모든 테이블이 성공적으로 생성되었습니다!")
except Exception as e:
print(f"❌ 오류 발생: {e}")
conn.rollback()
raise
finally:
conn.close()
def insert_sample_service_products():
"""기본 서비스 상품 데이터 삽입"""
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
print("📦 기본 서비스 상품 데이터 삽입 중...")
# 기본 서비스 상품 정의
products = [
{
'product_code': 'CLOUD_PC',
'product_name': '클라우드 PC',
'description': 'Proxmox 기반 가상 데스크톱 서비스. 언제 어디서나 안전한 클라우드 환경에서 업무 처리 가능.',
'monthly_price': 60000.00,
'setup_fee': 0.00
},
{
'product_code': 'AI_CCTV',
'product_name': 'AI CCTV',
'description': '인공지능 기반 보안 모니터링 시스템. 실시간 이상 상황 탐지 및 알림 서비스.',
'monthly_price': 80000.00,
'setup_fee': 50000.00
},
{
'product_code': 'CRM',
'product_name': 'CRM 시스템',
'description': '고객 관계 관리 및 매출 분석 도구. 고객 데이터 통합 관리 및 마케팅 자동화.',
'monthly_price': 40000.00,
'setup_fee': 0.00
}
]
try:
for product in products:
# 중복 확인
cursor.execute("SELECT id FROM service_products WHERE product_code = ?", (product['product_code'],))
if cursor.fetchone():
print(f"⚠️ {product['product_name']} 상품이 이미 존재합니다.")
continue
cursor.execute('''
INSERT INTO service_products
(product_code, product_name, description, monthly_price, setup_fee)
VALUES (?, ?, ?, ?, ?)
''', (
product['product_code'],
product['product_name'],
product['description'],
product['monthly_price'],
product['setup_fee']
))
print(f"{product['product_name']} 상품 추가됨 (월 {product['monthly_price']:,.0f}원)")
conn.commit()
print("✅ 기본 서비스 상품 데이터 삽입 완료!")
except Exception as e:
print(f"❌ 서비스 상품 데이터 삽입 오류: {e}")
conn.rollback()
raise
finally:
conn.close()
def create_sample_subscriptions():
"""샘플 구독 데이터 생성 (기존 약국 데이터 기반)"""
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
print("🏥 샘플 구독 데이터 생성 중...")
try:
# 기존 약국 ID 조회
cursor.execute("SELECT id, pharmacy_name FROM pharmacies LIMIT 10")
pharmacies = cursor.fetchall()
# 서비스 상품 ID 조회
cursor.execute("SELECT id, product_code, monthly_price FROM service_products")
products = cursor.fetchall()
if not pharmacies:
print("⚠️ 약국 데이터가 없습니다. 구독 데이터를 생성할 수 없습니다.")
return
if not products:
print("⚠️ 서비스 상품 데이터가 없습니다.")
return
import random
from datetime import date, timedelta
subscription_count = 0
for pharmacy_id, pharmacy_name in pharmacies:
# 각 약국마다 랜덤하게 1-3개의 서비스 구독
num_subscriptions = random.randint(1, 3)
selected_products = random.sample(products, num_subscriptions)
for product_id, product_code, monthly_price in selected_products:
# 중복 구독 확인
cursor.execute('''
SELECT id FROM pharmacy_subscriptions
WHERE pharmacy_id = ? AND product_id = ?
''', (pharmacy_id, product_id))
if cursor.fetchone():
continue # 이미 구독 중
# 구독 시작일 (최근 1년 내 랜덤)
start_date = date.today() - timedelta(days=random.randint(0, 365))
# 다음 결제일 (시작일로부터 월 단위)
next_billing = start_date + timedelta(days=30)
if next_billing < date.today():
# 과거 날짜면 현재 날짜 기준으로 조정
days_since_start = (date.today() - start_date).days
months_passed = days_since_start // 30
next_billing = start_date + timedelta(days=(months_passed + 1) * 30)
cursor.execute('''
INSERT INTO pharmacy_subscriptions
(pharmacy_id, product_id, subscription_status, start_date,
next_billing_date, monthly_fee, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', (
pharmacy_id,
product_id,
'ACTIVE',
start_date.isoformat(),
next_billing.isoformat(),
monthly_price,
f'{pharmacy_name}{product_code} 서비스 구독'
))
subscription_count += 1
print(f"{pharmacy_name}{product_code} 구독 추가 (월 {monthly_price:,.0f}원)")
conn.commit()
print(f"✅ 총 {subscription_count}개의 샘플 구독 데이터가 생성되었습니다!")
except Exception as e:
print(f"❌ 샘플 구독 데이터 생성 오류: {e}")
conn.rollback()
raise
finally:
conn.close()
def show_subscription_summary():
"""구독 현황 요약 출력"""
db_path = '/srv/headscale-setup/farmq-admin/farmq.db'
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
print("\n" + "="*60)
print("📊 PharmQ SaaS 구독 현황 요약")
print("="*60)
try:
# 전체 구독 통계
cursor.execute('''
SELECT
sp.product_name,
COUNT(*) as subscription_count,
SUM(ps.monthly_fee) as total_monthly_revenue
FROM pharmacy_subscriptions ps
JOIN service_products sp ON ps.product_id = sp.id
WHERE ps.subscription_status = 'ACTIVE'
GROUP BY sp.product_name
ORDER BY total_monthly_revenue DESC
''')
results = cursor.fetchall()
total_revenue = 0
for product_name, count, revenue in results:
print(f"🔹 {product_name}: {count}개 약국 구독 (월 {revenue:,.0f}원)")
total_revenue += revenue
print(f"\n💰 총 월 매출: {total_revenue:,.0f}")
# 약국별 구독 수
cursor.execute('''
SELECT COUNT(DISTINCT pharmacy_id) as subscribed_pharmacies
FROM pharmacy_subscriptions
WHERE subscription_status = 'ACTIVE'
''')
subscribed_pharmacies = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM pharmacies")
total_pharmacies = cursor.fetchone()[0]
print(f"🏥 구독 약국: {subscribed_pharmacies}/{total_pharmacies}개 ({subscribed_pharmacies/total_pharmacies*100:.1f}%)")
except Exception as e:
print(f"❌ 요약 정보 조회 오류: {e}")
finally:
conn.close()
print("="*60)
if __name__ == "__main__":
print("🚀 PharmQ SaaS 구독 서비스 데이터베이스 초기화")
print("-" * 60)
try:
# 1. 테이블 생성
create_subscription_tables()
print()
# 2. 기본 서비스 상품 삽입
insert_sample_service_products()
print()
# 3. 샘플 구독 데이터 생성
create_sample_subscriptions()
print()
# 4. 구독 현황 요약
show_subscription_summary()
print("\n🎉 PharmQ SaaS 구독 서비스 데이터베이스 초기화 완료!")
except Exception as e:
print(f"\n💥 초기화 실패: {e}")
exit(1)

View File

@ -1,79 +0,0 @@
#!/usr/bin/env python3
"""
머신 상세 정보 디버깅
"""
from utils.database import init_database, get_session, get_machine_with_details
from models import Node, MachineSpecs, MonitoringData, PharmacyInfo
def debug_machine_detail(machine_id=1):
"""머신 상세 정보 디버깅"""
print(f"🔍 Debugging machine detail for ID: {machine_id}")
session = get_session()
try:
# 1. 머신 정보 확인
machine = session.query(Node).filter_by(id=machine_id).first()
print(f"📱 Machine: {machine}")
if machine:
print(f" - ID: {machine.id}")
print(f" - Hostname: {machine.hostname}")
print(f" - Given Name: {machine.given_name}")
print(f" - User ID: {machine.user_id}")
print(f" - IPv4: {machine.ipv4}")
print(f" - Last Seen: {machine.last_seen}")
# is_online 메서드 테스트
try:
online_status = machine.is_online()
print(f" - Online Status: {online_status}")
except Exception as e:
print(f" - Online Status Error: {e}")
# 2. 머신 스펙 확인
specs = session.query(MachineSpecs).filter_by(machine_id=machine_id).first()
print(f"💾 Specs: {specs}")
if specs:
print(f" - CPU: {specs.cpu_model}")
print(f" - RAM: {specs.ram_gb}GB")
print(f" - Storage: {specs.storage_gb}GB")
# 3. 모니터링 데이터 확인
monitoring = session.query(MonitoringData).filter_by(
machine_id=machine_id
).order_by(MonitoringData.collected_at.desc()).first()
print(f"📊 Latest Monitoring: {monitoring}")
if monitoring:
print(f" - CPU Usage: {monitoring.cpu_usage}%")
print(f" - Temperature: {monitoring.cpu_temperature}°C")
print(f" - Collected at: {monitoring.collected_at}")
# 4. get_machine_with_details 함수 테스트
print(f"\n🧪 Testing get_machine_with_details function...")
try:
details = get_machine_with_details(machine_id)
print(f" ✅ Success: {details is not None}")
if details:
print(f" - Machine: {details['machine'].hostname if details['machine'] else None}")
print(f" - Specs: {details['specs'] is not None}")
print(f" - Monitoring: {details['latest_monitoring'] is not None}")
print(f" - Online: {details['is_online']}")
print(f" - Pharmacy: {details['pharmacy']}")
except Exception as e:
print(f" ❌ Error: {e}")
import traceback
traceback.print_exc()
except Exception as e:
print(f"❌ Database error: {e}")
import traceback
traceback.print_exc()
finally:
session.close()
if __name__ == '__main__':
# 데이터베이스 초기화
init_database('sqlite:///srv/headscale-setup/data/db.sqlite')
debug_machine_detail(1)

View File

@ -1,247 +0,0 @@
<#
.Synopsis
Activate a Python virtual environment for the current PowerShell session.
.Description
Pushes the python executable for a virtual environment to the front of the
$Env:PATH environment variable and sets the prompt to signify that you are
in a Python virtual environment. Makes use of the command line switches as
well as the `pyvenv.cfg` file values present in the virtual environment.
.Parameter VenvDir
Path to the directory that contains the virtual environment to activate. The
default value for this is the parent of the directory that the Activate.ps1
script is located within.
.Parameter Prompt
The prompt prefix to display when this virtual environment is activated. By
default, this prompt is the name of the virtual environment folder (VenvDir)
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
.Example
Activate.ps1
Activates the Python virtual environment that contains the Activate.ps1 script.
.Example
Activate.ps1 -Verbose
Activates the Python virtual environment that contains the Activate.ps1 script,
and shows extra information about the activation as it executes.
.Example
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
Activates the Python virtual environment located in the specified location.
.Example
Activate.ps1 -Prompt "MyPython"
Activates the Python virtual environment that contains the Activate.ps1 script,
and prefixes the current prompt with the specified string (surrounded in
parentheses) while the virtual environment is active.
.Notes
On Windows, it may be required to enable this Activate.ps1 script by setting the
execution policy for the user. You can do this by issuing the following PowerShell
command:
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
For more information on Execution Policies:
https://go.microsoft.com/fwlink/?LinkID=135170
#>
Param(
[Parameter(Mandatory = $false)]
[String]
$VenvDir,
[Parameter(Mandatory = $false)]
[String]
$Prompt
)
<# Function declarations --------------------------------------------------- #>
<#
.Synopsis
Remove all shell session elements added by the Activate script, including the
addition of the virtual environment's Python executable from the beginning of
the PATH variable.
.Parameter NonDestructive
If present, do not remove this function from the global namespace for the
session.
#>
function global:deactivate ([switch]$NonDestructive) {
# Revert to original values
# The prior prompt:
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
}
# The prior PYTHONHOME:
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
}
# The prior PATH:
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
}
# Just remove the VIRTUAL_ENV altogether:
if (Test-Path -Path Env:VIRTUAL_ENV) {
Remove-Item -Path env:VIRTUAL_ENV
}
# Just remove VIRTUAL_ENV_PROMPT altogether.
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
}
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
}
# Leave deactivate function in the global namespace if requested:
if (-not $NonDestructive) {
Remove-Item -Path function:deactivate
}
}
<#
.Description
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
given folder, and returns them in a map.
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
two strings separated by `=` (with any amount of whitespace surrounding the =)
then it is considered a `key = value` line. The left hand string is the key,
the right hand is the value.
If the value starts with a `'` or a `"` then the first and last character is
stripped from the value before being captured.
.Parameter ConfigDir
Path to the directory that contains the `pyvenv.cfg` file.
#>
function Get-PyVenvConfig(
[String]
$ConfigDir
) {
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
# An empty map will be returned if no config file is found.
$pyvenvConfig = @{ }
if ($pyvenvConfigPath) {
Write-Verbose "File exists, parse `key = value` lines"
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
$pyvenvConfigContent | ForEach-Object {
$keyval = $PSItem -split "\s*=\s*", 2
if ($keyval[0] -and $keyval[1]) {
$val = $keyval[1]
# Remove extraneous quotations around a string value.
if ("'""".Contains($val.Substring(0, 1))) {
$val = $val.Substring(1, $val.Length - 2)
}
$pyvenvConfig[$keyval[0]] = $val
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
}
}
}
return $pyvenvConfig
}
<# Begin Activate script --------------------------------------------------- #>
# Determine the containing directory of this script
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
$VenvExecDir = Get-Item -Path $VenvExecPath
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
# Set values required in priority: CmdLine, ConfigFile, Default
# First, get the location of the virtual environment, it might not be
# VenvExecDir if specified on the command line.
if ($VenvDir) {
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
}
else {
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
Write-Verbose "VenvDir=$VenvDir"
}
# Next, read the `pyvenv.cfg` file to determine any required value such
# as `prompt`.
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
# Next, set the prompt from the command line, or the config file, or
# just use the name of the virtual environment folder.
if ($Prompt) {
Write-Verbose "Prompt specified as argument, using '$Prompt'"
}
else {
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
$Prompt = $pyvenvCfg['prompt'];
}
else {
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
$Prompt = Split-Path -Path $venvDir -Leaf
}
}
Write-Verbose "Prompt = '$Prompt'"
Write-Verbose "VenvDir='$VenvDir'"
# Deactivate any currently active virtual environment, but leave the
# deactivate function in place.
deactivate -nondestructive
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
# that there is an activated venv.
$env:VIRTUAL_ENV = $VenvDir
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
Write-Verbose "Setting prompt to '$Prompt'"
# Set the prompt to include the env name
# Make sure _OLD_VIRTUAL_PROMPT is global
function global:_OLD_VIRTUAL_PROMPT { "" }
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
function global:prompt {
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
_OLD_VIRTUAL_PROMPT
}
$env:VIRTUAL_ENV_PROMPT = $Prompt
}
# Clear PYTHONHOME
if (Test-Path -Path Env:PYTHONHOME) {
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
Remove-Item -Path Env:PYTHONHOME
}
# Add the venv to the PATH
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"

View File

@ -1,70 +0,0 @@
# This file must be used with "source bin/activate" *from bash*
# You cannot run it directly
deactivate () {
# reset old environment variables
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
PATH="${_OLD_VIRTUAL_PATH:-}"
export PATH
unset _OLD_VIRTUAL_PATH
fi
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
export PYTHONHOME
unset _OLD_VIRTUAL_PYTHONHOME
fi
# Call hash to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected
hash -r 2> /dev/null
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
PS1="${_OLD_VIRTUAL_PS1:-}"
export PS1
unset _OLD_VIRTUAL_PS1
fi
unset VIRTUAL_ENV
unset VIRTUAL_ENV_PROMPT
if [ ! "${1:-}" = "nondestructive" ] ; then
# Self destruct!
unset -f deactivate
fi
}
# unset irrelevant variables
deactivate nondestructive
# on Windows, a path can contain colons and backslashes and has to be converted:
if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then
# transform D:\path\to\venv to /d/path/to/venv on MSYS
# and to /cygdrive/d/path/to/venv on Cygwin
export VIRTUAL_ENV=$(cygpath /srv/headscale-setup/farmq-admin/flask-venv)
else
# use the path as-is
export VIRTUAL_ENV=/srv/headscale-setup/farmq-admin/flask-venv
fi
_OLD_VIRTUAL_PATH="$PATH"
PATH="$VIRTUAL_ENV/"bin":$PATH"
export PATH
# unset PYTHONHOME if set
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
# could use `if (set -u; : $PYTHONHOME) ;` in bash
if [ -n "${PYTHONHOME:-}" ] ; then
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
unset PYTHONHOME
fi
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
_OLD_VIRTUAL_PS1="${PS1:-}"
PS1='(flask-venv) '"${PS1:-}"
export PS1
VIRTUAL_ENV_PROMPT='(flask-venv) '
export VIRTUAL_ENV_PROMPT
fi
# Call hash to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected
hash -r 2> /dev/null

View File

@ -1,27 +0,0 @@
# This file must be used with "source bin/activate.csh" *from csh*.
# You cannot run it directly.
# Created by Davide Di Blasi <davidedb@gmail.com>.
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
# Unset irrelevant variables.
deactivate nondestructive
setenv VIRTUAL_ENV /srv/headscale-setup/farmq-admin/flask-venv
set _OLD_VIRTUAL_PATH="$PATH"
setenv PATH "$VIRTUAL_ENV/"bin":$PATH"
set _OLD_VIRTUAL_PROMPT="$prompt"
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
set prompt = '(flask-venv) '"$prompt"
setenv VIRTUAL_ENV_PROMPT '(flask-venv) '
endif
alias pydoc python -m pydoc
rehash

View File

@ -1,69 +0,0 @@
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
# (https://fishshell.com/). You cannot run it directly.
function deactivate -d "Exit virtual environment and return to normal shell environment"
# reset old environment variables
if test -n "$_OLD_VIRTUAL_PATH"
set -gx PATH $_OLD_VIRTUAL_PATH
set -e _OLD_VIRTUAL_PATH
end
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
set -e _OLD_VIRTUAL_PYTHONHOME
end
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
set -e _OLD_FISH_PROMPT_OVERRIDE
# prevents error when using nested fish instances (Issue #93858)
if functions -q _old_fish_prompt
functions -e fish_prompt
functions -c _old_fish_prompt fish_prompt
functions -e _old_fish_prompt
end
end
set -e VIRTUAL_ENV
set -e VIRTUAL_ENV_PROMPT
if test "$argv[1]" != "nondestructive"
# Self-destruct!
functions -e deactivate
end
end
# Unset irrelevant variables.
deactivate nondestructive
set -gx VIRTUAL_ENV /srv/headscale-setup/farmq-admin/flask-venv
set -gx _OLD_VIRTUAL_PATH $PATH
set -gx PATH "$VIRTUAL_ENV/"bin $PATH
# Unset PYTHONHOME if set.
if set -q PYTHONHOME
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
set -e PYTHONHOME
end
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
# fish uses a function instead of an env var to generate the prompt.
# Save the current fish_prompt function as the function _old_fish_prompt.
functions -c fish_prompt _old_fish_prompt
# With the original prompt function renamed, we can override with our own.
function fish_prompt
# Save the return status of the last command.
set -l old_status $status
# Output the venv prompt; color taken from the blue of the Python logo.
printf "%s%s%s" (set_color 4B8BBE) '(flask-venv) ' (set_color normal)
# Restore the return status of the previous command.
echo "exit $old_status" | .
# Output the original/"old" prompt.
_old_fish_prompt
end
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
set -gx VIRTUAL_ENV_PROMPT '(flask-venv) '
end

View File

@ -1,8 +0,0 @@
#!/srv/headscale-setup/farmq-admin/flask-venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from flask.cli import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -1,8 +0,0 @@
#!/srv/headscale-setup/farmq-admin/flask-venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from charset_normalizer.cli import cli_detect
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(cli_detect())

View File

@ -1,8 +0,0 @@
#!/srv/headscale-setup/farmq-admin/flask-venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -1,8 +0,0 @@
#!/srv/headscale-setup/farmq-admin/flask-venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -1,8 +0,0 @@
#!/srv/headscale-setup/farmq-admin/flask-venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -1 +0,0 @@
python3

View File

@ -1 +0,0 @@
/usr/bin/python3

View File

@ -1 +0,0 @@
python3

View File

@ -1,8 +0,0 @@
#!/srv/headscale-setup/farmq-admin/flask-venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from websockets.cli import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -1,164 +0,0 @@
/* -*- indent-tabs-mode: nil; tab-width: 4; -*- */
/* Greenlet object interface */
#ifndef Py_GREENLETOBJECT_H
#define Py_GREENLETOBJECT_H
#include <Python.h>
#ifdef __cplusplus
extern "C" {
#endif
/* This is deprecated and undocumented. It does not change. */
#define GREENLET_VERSION "1.0.0"
#ifndef GREENLET_MODULE
#define implementation_ptr_t void*
#endif
typedef struct _greenlet {
PyObject_HEAD
PyObject* weakreflist;
PyObject* dict;
implementation_ptr_t pimpl;
} PyGreenlet;
#define PyGreenlet_Check(op) (op && PyObject_TypeCheck(op, &PyGreenlet_Type))
/* C API functions */
/* Total number of symbols that are exported */
#define PyGreenlet_API_pointers 12
#define PyGreenlet_Type_NUM 0
#define PyExc_GreenletError_NUM 1
#define PyExc_GreenletExit_NUM 2
#define PyGreenlet_New_NUM 3
#define PyGreenlet_GetCurrent_NUM 4
#define PyGreenlet_Throw_NUM 5
#define PyGreenlet_Switch_NUM 6
#define PyGreenlet_SetParent_NUM 7
#define PyGreenlet_MAIN_NUM 8
#define PyGreenlet_STARTED_NUM 9
#define PyGreenlet_ACTIVE_NUM 10
#define PyGreenlet_GET_PARENT_NUM 11
#ifndef GREENLET_MODULE
/* This section is used by modules that uses the greenlet C API */
static void** _PyGreenlet_API = NULL;
# define PyGreenlet_Type \
(*(PyTypeObject*)_PyGreenlet_API[PyGreenlet_Type_NUM])
# define PyExc_GreenletError \
((PyObject*)_PyGreenlet_API[PyExc_GreenletError_NUM])
# define PyExc_GreenletExit \
((PyObject*)_PyGreenlet_API[PyExc_GreenletExit_NUM])
/*
* PyGreenlet_New(PyObject *args)
*
* greenlet.greenlet(run, parent=None)
*/
# define PyGreenlet_New \
(*(PyGreenlet * (*)(PyObject * run, PyGreenlet * parent)) \
_PyGreenlet_API[PyGreenlet_New_NUM])
/*
* PyGreenlet_GetCurrent(void)
*
* greenlet.getcurrent()
*/
# define PyGreenlet_GetCurrent \
(*(PyGreenlet * (*)(void)) _PyGreenlet_API[PyGreenlet_GetCurrent_NUM])
/*
* PyGreenlet_Throw(
* PyGreenlet *greenlet,
* PyObject *typ,
* PyObject *val,
* PyObject *tb)
*
* g.throw(...)
*/
# define PyGreenlet_Throw \
(*(PyObject * (*)(PyGreenlet * self, \
PyObject * typ, \
PyObject * val, \
PyObject * tb)) \
_PyGreenlet_API[PyGreenlet_Throw_NUM])
/*
* PyGreenlet_Switch(PyGreenlet *greenlet, PyObject *args)
*
* g.switch(*args, **kwargs)
*/
# define PyGreenlet_Switch \
(*(PyObject * \
(*)(PyGreenlet * greenlet, PyObject * args, PyObject * kwargs)) \
_PyGreenlet_API[PyGreenlet_Switch_NUM])
/*
* PyGreenlet_SetParent(PyObject *greenlet, PyObject *new_parent)
*
* g.parent = new_parent
*/
# define PyGreenlet_SetParent \
(*(int (*)(PyGreenlet * greenlet, PyGreenlet * nparent)) \
_PyGreenlet_API[PyGreenlet_SetParent_NUM])
/*
* PyGreenlet_GetParent(PyObject* greenlet)
*
* return greenlet.parent;
*
* This could return NULL even if there is no exception active.
* If it does not return NULL, you are responsible for decrementing the
* reference count.
*/
# define PyGreenlet_GetParent \
(*(PyGreenlet* (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_GET_PARENT_NUM])
/*
* deprecated, undocumented alias.
*/
# define PyGreenlet_GET_PARENT PyGreenlet_GetParent
# define PyGreenlet_MAIN \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_MAIN_NUM])
# define PyGreenlet_STARTED \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_STARTED_NUM])
# define PyGreenlet_ACTIVE \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_ACTIVE_NUM])
/* Macro that imports greenlet and initializes C API */
/* NOTE: This has actually moved to ``greenlet._greenlet._C_API``, but we
keep the older definition to be sure older code that might have a copy of
the header still works. */
# define PyGreenlet_Import() \
{ \
_PyGreenlet_API = (void**)PyCapsule_Import("greenlet._C_API", 0); \
}
#endif /* GREENLET_MODULE */
#ifdef __cplusplus
}
#endif
#endif /* !Py_GREENLETOBJECT_H */

View File

@ -1 +0,0 @@
lib

View File

@ -1,5 +0,0 @@
home = /usr/bin
include-system-site-packages = false
version = 3.12.3
executable = /usr/bin/python3.12
command = /usr/bin/python3 -m venv /srv/headscale-setup/farmq-admin/flask-venv

View File

@ -1,209 +0,0 @@
#!/usr/bin/env python3
"""
FARMQ 관리 시스템 - 샘플 데이터 초기화 스크립트
"""
import os
import sys
from datetime import datetime, timedelta
import random
from sqlalchemy import text
# 현재 디렉토리를 파이썬 경로에 추가
current_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, current_dir)
from config import config
from utils.database import init_database, get_session, close_session
from models import PharmacyInfo, MachineSpecs, MonitoringData, Alert, User, Node
def init_sample_data():
"""샘플 데이터 초기화"""
print("🔧 Initializing sample data for FARMQ Admin System...")
# 데이터베이스 세션 획득
session = get_session()
try:
# 1. 기존 샘플 데이터 정리
print("📝 Cleaning existing sample data...")
session.query(Alert).delete()
session.query(MonitoringData).delete()
session.query(MachineSpecs).delete()
session.query(PharmacyInfo).delete()
# 2. 샘플 약국 데이터 생성
print("🏥 Creating sample pharmacy data...")
pharmacies = [
{
'pharmacy_name': '서울약국',
'business_number': '123-45-67890',
'manager_name': '김철수',
'phone': '02-1234-5678',
'address': '서울특별시 강남구 테헤란로 123',
'proxmox_host': '192.168.1.100',
'user_id': 'seoul-pharmacy'
},
{
'pharmacy_name': '부산메디컬약국',
'business_number': '987-65-43210',
'manager_name': '이영희',
'phone': '051-2345-6789',
'address': '부산광역시 해운대구 센텀대로 456',
'proxmox_host': '192.168.1.101',
'user_id': 'busan-medical'
},
{
'pharmacy_name': '대구건강약국',
'business_number': '456-78-91234',
'manager_name': '박민수',
'phone': '053-3456-7890',
'address': '대구광역시 중구 동성로 789',
'proxmox_host': '192.168.1.102',
'user_id': 'daegu-health'
},
{
'pharmacy_name': '인천바다약국',
'business_number': '321-54-87695',
'manager_name': '최수진',
'phone': '032-4567-8901',
'address': '인천광역시 연수구 송도대로 321',
'proxmox_host': '192.168.1.103',
'user_id': 'incheon-sea'
},
{
'pharmacy_name': '광주햇살약국',
'business_number': '654-32-10987',
'manager_name': '정태영',
'phone': '062-5678-9012',
'address': '광주광역시 서구 상무대로 654',
'proxmox_host': '192.168.1.104',
'user_id': 'gwangju-sunshine'
}
]
pharmacy_objects = []
for pharmacy_data in pharmacies:
pharmacy = PharmacyInfo(**pharmacy_data)
session.add(pharmacy)
pharmacy_objects.append(pharmacy)
session.flush() # ID 생성을 위해 flush
# 3. 기존 Headscale 사용자와 연결
print("👥 Linking with existing Headscale users...")
existing_users = session.query(User).all()
existing_nodes = session.query(Node).all()
print(f"📊 Found {len(existing_users)} users and {len(existing_nodes)} nodes in Headscale")
# 4. 머신 스펙 데이터 생성
print("💻 Creating machine specifications...")
cpu_models = [
'Intel Core i5-11400',
'Intel Core i7-10700',
'AMD Ryzen 5 5600X',
'AMD Ryzen 7 5700G',
'Intel Core i3-10100'
]
machine_specs = []
for node in existing_nodes:
specs = MachineSpecs(
machine_id=node.id,
cpu_model=random.choice(cpu_models),
cpu_cores=random.choice([4, 6, 8]),
ram_gb=random.choice([8, 16, 32]),
storage_gb=random.choice([256, 512, 1024]),
network_speed=random.choice([100, 1000]),
os_info=f"Ubuntu 22.04 LTS",
created_at=datetime.now()
)
session.add(specs)
machine_specs.append(specs)
# 5. 모니터링 데이터 생성 (최근 24시간)
print("📈 Creating monitoring data...")
for spec in machine_specs:
# 각 머신별로 최근 24시간 데이터 생성
base_time = datetime.now() - timedelta(hours=24)
for i in range(144): # 10분 간격으로 24시간 = 144개 데이터
monitoring_time = base_time + timedelta(minutes=i * 10)
# 시간대별로 사실적인 패턴 생성
hour = monitoring_time.hour
base_cpu = 20 if 6 <= hour <= 22 else 10 # 업무시간 vs 야간
monitoring = MonitoringData(
machine_id=spec.machine_id,
cpu_usage=max(0, min(100, base_cpu + random.gauss(0, 10))),
memory_usage=max(10, min(90, 30 + random.gauss(0, 15))),
disk_usage=max(20, min(80, 45 + random.gauss(0, 5))),
cpu_temperature=max(35, min(85, 55 + random.gauss(0, 8))),
network_in_bytes=random.randint(1000, 50000),
network_out_bytes=random.randint(500, 25000),
collected_at=monitoring_time
)
session.add(monitoring)
# 6. 알림 데이터 생성
print("🚨 Creating alert data...")
alert_types = ['HIGH_CPU', 'HIGH_MEMORY', 'HIGH_TEMPERATURE', 'DISK_SPACE', 'NETWORK_ISSUE']
alert_levels = ['INFO', 'WARNING', 'ERROR', 'CRITICAL']
for i in range(15): # 15개의 알림 생성
alert_time = datetime.now() - timedelta(hours=random.randint(1, 72))
machine_id = random.choice([spec.machine_id for spec in machine_specs])
alert_type = random.choice(alert_types)
level = random.choice(alert_levels)
messages = {
'HIGH_CPU': f'CPU 사용률 80% 초과',
'HIGH_MEMORY': f'메모리 사용률 85% 초과',
'HIGH_TEMPERATURE': f'CPU 온도 75°C 초과',
'DISK_SPACE': f'디스크 사용률 80% 초과',
'NETWORK_ISSUE': f'네트워크 연결 불안정'
}
alert = Alert(
machine_id=machine_id,
alert_type=alert_type,
level=level,
message=messages[alert_type],
created_at=alert_time,
is_resolved=random.choice([True, False])
)
session.add(alert)
# 커밋
session.commit()
print("✅ Sample data initialization completed!")
print(f" - {len(pharmacy_objects)} pharmacies created")
print(f" - {len(machine_specs)} machine specifications created")
print(f" - {144 * len(machine_specs)} monitoring records created")
print(f" - 15 alerts created")
# 통계 출력
print("\n📊 Database Statistics:")
print(f" - Total Users: {session.query(User).count()}")
print(f" - Total Nodes: {session.query(Node).count()}")
print(f" - Total Pharmacies: {session.query(PharmacyInfo).count()}")
print(f" - Total Monitoring Records: {session.query(MonitoringData).count()}")
print(f" - Active Alerts: {session.query(Alert).filter_by(is_resolved=False).count()}")
except Exception as e:
print(f"❌ Error initializing sample data: {e}")
session.rollback()
raise
finally:
close_session()
if __name__ == '__main__':
# 데이터베이스 초기화
init_database('/srv/headscale-setup/headscale-data/db.sqlite')
# 샘플 데이터 생성
init_sample_data()

View File

@ -1,34 +0,0 @@
"""
모델 패키지 초기화
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'
]

View File

@ -1,511 +0,0 @@
"""
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.machine_name = headscale_node_data.get('given_name') or 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('given_name') or 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}")

View File

@ -1,385 +0,0 @@
"""
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()

View File

@ -1,8 +0,0 @@
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

View File

@ -1,13 +0,0 @@
maintainers:
- Samuel Mannehed for Cendio AB (@samhed)
- Pierre Ossman for Cendio AB (@CendioOssman)
maintainersEmeritus:
- Joel Martin (@kanaka)
- Solly Ross (@directxman12)
- @astrand
contributors:
# There are a bunch of people that should be here.
# If you want to be on this list, feel free send a PR
# to add yourself.
- jalf <git@jalf.dk>
- NTT corp.

View File

@ -1,62 +0,0 @@
noVNC is Copyright (C) 2022 The noVNC Authors
(./AUTHORS)
The noVNC core library files are licensed under the MPL 2.0 (Mozilla
Public License 2.0). The noVNC core library is composed of the
Javascript code necessary for full noVNC operation. This includes (but
is not limited to):
core/**/*.js
app/*.js
test/playback.js
The HTML, CSS, font and images files that included with the noVNC
source distibution (or repository) are not considered part of the
noVNC core library and are licensed under more permissive licenses.
The intent is to allow easy integration of noVNC into existing web
sites and web applications.
The HTML, CSS, font and image files are licensed as follows:
*.html : 2-Clause BSD license
app/styles/*.css : 2-Clause BSD license
app/styles/Orbitron* : SIL Open Font License 1.1
(Copyright 2009 Matt McInerney)
app/images/ : Creative Commons Attribution-ShareAlike
http://creativecommons.org/licenses/by-sa/3.0/
Some portions of noVNC are copyright to their individual authors.
Please refer to the individual source files and/or to the noVNC commit
history: https://github.com/novnc/noVNC/commits/master
The are several files and projects that have been incorporated into
the noVNC core library. Here is a list of those files and the original
licenses (all MPL 2.0 compatible):
core/base64.js : MPL 2.0
core/des.js : Various BSD style licenses
vendor/pako/ : MIT
Any other files not mentioned above are typically marked with
a copyright/license header at the top of the file. The default noVNC
license is MPL-2.0.
The following license texts are included:
docs/LICENSE.MPL-2.0
docs/LICENSE.OFL-1.1
docs/LICENSE.BSD-3-Clause (New BSD)
docs/LICENSE.BSD-2-Clause (Simplified BSD / FreeBSD)
vendor/pako/LICENSE (MIT)
Or alternatively the license texts may be found here:
http://www.mozilla.org/MPL/2.0/
http://scripts.sil.org/OFL
http://en.wikipedia.org/wiki/BSD_licenses
https://opensource.org/licenses/MIT

View File

@ -1,230 +0,0 @@
## noVNC: HTML VNC Client Library and Application
[![Test Status](https://github.com/novnc/noVNC/workflows/Test/badge.svg)](https://github.com/novnc/noVNC/actions?query=workflow%3ATest)
[![Lint Status](https://github.com/novnc/noVNC/workflows/Lint/badge.svg)](https://github.com/novnc/noVNC/actions?query=workflow%3ALint)
### Description
noVNC is both a HTML VNC client JavaScript library and an application built on
top of that library. noVNC runs well in any modern browser including mobile
browsers (iOS and Android).
Many companies, projects and products have integrated noVNC including
[OpenStack](http://www.openstack.org),
[OpenNebula](http://opennebula.org/),
[LibVNCServer](http://libvncserver.sourceforge.net), and
[ThinLinc](https://cendio.com/thinlinc). See
[the Projects and Companies wiki page](https://github.com/novnc/noVNC/wiki/Projects-and-companies-using-noVNC)
for a more complete list with additional info and links.
### Table of Contents
- [News/help/contact](#newshelpcontact)
- [Features](#features)
- [Screenshots](#screenshots)
- [Browser Requirements](#browser-requirements)
- [Server Requirements](#server-requirements)
- [Quick Start](#quick-start)
- [Installation from Snap Package](#installation-from-snap-package)
- [Integration and Deployment](#integration-and-deployment)
- [Authors/Contributors](#authorscontributors)
### News/help/contact
The project website is found at [novnc.com](http://novnc.com).
Notable commits, announcements and news are posted to
[@noVNC](http://www.twitter.com/noVNC).
If you are a noVNC developer/integrator/user (or want to be) please join the
[noVNC discussion group](https://groups.google.com/forum/?fromgroups#!forum/novnc).
Bugs and feature requests can be submitted via
[github issues](https://github.com/novnc/noVNC/issues). If you have questions
about using noVNC then please first use the
[discussion group](https://groups.google.com/forum/?fromgroups#!forum/novnc).
We also have a [wiki](https://github.com/novnc/noVNC/wiki/) with lots of
helpful information.
If you are looking for a place to start contributing to noVNC, a good place to
start would be the issues that are marked as
["patchwelcome"](https://github.com/novnc/noVNC/issues?labels=patchwelcome).
Please check our
[contribution guide](https://github.com/novnc/noVNC/wiki/Contributing) though.
If you want to show appreciation for noVNC you could donate to a great non-
profits such as:
[Compassion International](http://www.compassion.com/),
[SIL](http://www.sil.org),
[Habitat for Humanity](http://www.habitat.org),
[Electronic Frontier Foundation](https://www.eff.org/),
[Against Malaria Foundation](http://www.againstmalaria.com/),
[Nothing But Nets](http://www.nothingbutnets.net/), etc.
Please tweet [@noVNC](http://www.twitter.com/noVNC) if you do.
### Features
* Supports all modern browsers including mobile (iOS, Android)
* Supported authentication methods: none, classical VNC, RealVNC's
RSA-AES, Tight, VeNCrypt Plain, XVP, Apple's Diffie-Hellman,
UltraVNC's MSLogonII
* Supported VNC encodings: raw, copyrect, rre, hextile, tight, tightPNG,
ZRLE, JPEG
* Supports scaling, clipping and resizing the desktop
* Local cursor rendering
* Clipboard copy/paste with full Unicode support
* Translations
* Touch gestures for emulating common mouse actions
* Licensed mainly under the [MPL 2.0](http://www.mozilla.org/MPL/2.0/), see
[the license document](LICENSE.txt) for details
### Screenshots
Running in Firefox before and after connecting:
<img src="http://novnc.com/img/noVNC-1-login.png" width=400>&nbsp;
<img src="http://novnc.com/img/noVNC-3-connected.png" width=400>
See more screenshots
[here](http://novnc.com/screenshots.html).
### Browser Requirements
noVNC uses many modern web technologies so a formal requirement list is
not available. However these are the minimum versions we are currently
aware of:
* Chrome 64, Firefox 79, Safari 13.4, Opera 51, Edge 79
### Server Requirements
noVNC follows the standard VNC protocol, but unlike other VNC clients it does
require WebSockets support. Many servers include support (e.g.
[x11vnc/libvncserver](http://libvncserver.sourceforge.net/),
[QEMU](http://www.qemu.org/), and
[MobileVNC](http://www.smartlab.at/mobilevnc/)), but for the others you need to
use a WebSockets to TCP socket proxy. noVNC has a sister project
[websockify](https://github.com/novnc/websockify) that provides a simple such
proxy.
### Quick Start
* Use the `novnc_proxy` script to automatically download and start websockify, which
includes a mini-webserver and the WebSockets proxy. The `--vnc` option is
used to specify the location of a running VNC server:
`./utils/novnc_proxy --vnc localhost:5901`
* If you don't need to expose the web server to public internet, you can
bind to localhost:
`./utils/novnc_proxy --vnc localhost:5901 --listen localhost:6081`
* Point your browser to the cut-and-paste URL that is output by the `novnc_proxy`
script. Hit the Connect button, enter a password if the VNC server has one
configured, and enjoy!
### Installation from Snap Package
Running the command below will install the latest release of noVNC from Snap:
`sudo snap install novnc`
#### Running noVNC from Snap Directly
You can run the Snap-package installed novnc directly with, for example:
`novnc --listen 6081 --vnc localhost:5901 # /snap/bin/novnc if /snap/bin is not in your PATH`
If you want to use certificate files, due to standard Snap confinement restrictions you need to have them in the /home/\<user\>/snap/novnc/current/ directory. If your username is jsmith an example command would be:
`novnc --listen 8443 --cert ~jsmith/snap/novnc/current/self.crt --key ~jsmith/snap/novnc/current/self.key --vnc ubuntu.example.com:5901`
#### Running noVNC from Snap as a Service (Daemon)
The Snap package also has the capability to run a 'novnc' service which can be
configured to listen on multiple ports connecting to multiple VNC servers
(effectively a service runing multiple instances of novnc).
Instructions (with example values):
List current services (out-of-box this will be blank):
```
sudo snap get novnc services
Key Value
services.n6080 {...}
services.n6081 {...}
```
Create a new service that listens on port 6082 and connects to the VNC server
running on port 5902 on localhost:
`sudo snap set novnc services.n6082.listen=6082 services.n6082.vnc=localhost:5902`
(Any services you define with 'snap set' will be automatically started)
Note that the name of the service, 'n6082' in this example, can be anything
as long as it doesn't start with a number or contain spaces/special characters.
View the configuration of the service just created:
```
sudo snap get novnc services.n6082
Key Value
services.n6082.listen 6082
services.n6082.vnc localhost:5902
```
Disable a service (note that because of a limitation in Snap it's currently not
possible to unset config variables, setting them to blank values is the way
to disable a service):
`sudo snap set novnc services.n6082.listen='' services.n6082.vnc=''`
(Any services you set to blank with 'snap set' like this will be automatically stopped)
Verify that the service is disabled (blank values):
```
sudo snap get novnc services.n6082
Key Value
services.n6082.listen
services.n6082.vnc
```
### Integration and Deployment
Please see our other documents for how to integrate noVNC in your own software,
or deploying the noVNC application in production environments:
* [Embedding](docs/EMBEDDING.md) - For the noVNC application
* [Library](docs/LIBRARY.md) - For the noVNC JavaScript library
### Authors/Contributors
See [AUTHORS](AUTHORS) for a (full-ish) list of authors. If you're not on
that list and you think you should be, feel free to send a PR to fix that.
* Core team:
* [Samuel Mannehed](https://github.com/samhed) (Cendio)
* [Pierre Ossman](https://github.com/CendioOssman) (Cendio)
* Previous core contributors:
* [Joel Martin](https://github.com/kanaka) (Project founder)
* [Solly Ross](https://github.com/DirectXMan12) (Red Hat / OpenStack)
* Notable contributions:
* UI and Icons : Pierre Ossman, Chris Gordon
* Original Logo : Michael Sersen
* tight encoding : Michael Tinglof (Mercuri.ca)
* RealVNC RSA AES authentication : USTC Vlab Team
* Included libraries:
* base64 : Martijn Pieters (Digital Creations 2), Samuel Sieb (sieb.net)
* DES : Dave Zimmerman (Widget Workshop), Jef Poskanzer (ACME Labs)
* Pako : Vitaly Puzrin (https://github.com/nodeca/pako)
Do you want to be on this list? Check out our
[contribution guide](https://github.com/novnc/noVNC/wiki/Contributing) and
start hacking!

View File

@ -1,79 +0,0 @@
/*
* noVNC: HTML5 VNC client
* Copyright (C) 2019 The noVNC Authors
* Licensed under MPL 2.0 (see LICENSE.txt)
*
* See README.md for usage and integration instructions.
*/
// Fallback for all uncought errors
function handleError(event, err) {
try {
const msg = document.getElementById('noVNC_fallback_errormsg');
// Work around Firefox bug:
// https://bugzilla.mozilla.org/show_bug.cgi?id=1685038
if (event.message === "ResizeObserver loop completed with undelivered notifications.") {
return false;
}
// Only show the initial error
if (msg.hasChildNodes()) {
return false;
}
let div = document.createElement("div");
div.classList.add('noVNC_message');
div.appendChild(document.createTextNode(event.message));
msg.appendChild(div);
if (event.filename) {
div = document.createElement("div");
div.className = 'noVNC_location';
let text = event.filename;
if (event.lineno !== undefined) {
text += ":" + event.lineno;
if (event.colno !== undefined) {
text += ":" + event.colno;
}
}
div.appendChild(document.createTextNode(text));
msg.appendChild(div);
}
if (err && err.stack) {
div = document.createElement("div");
div.className = 'noVNC_stack';
div.appendChild(document.createTextNode(err.stack));
msg.appendChild(div);
}
document.getElementById('noVNC_fallback_error')
.classList.add("noVNC_open");
} catch (exc) {
document.write("noVNC encountered an error.");
}
// Try to disable keyboard interaction, best effort
try {
// Remove focus from the currently focused element in order to
// prevent keyboard interaction from continuing
if (document.activeElement) { document.activeElement.blur(); }
// Don't let any element be focusable when showing the error
let keyboardFocusable = 'a[href], button, input, textarea, select, details, [tabindex]';
document.querySelectorAll(keyboardFocusable).forEach((elem) => {
elem.setAttribute("tabindex", "-1");
});
} catch (exc) {
// Do nothing
}
// Don't return true since this would prevent the error
// from being printed to the browser console.
return false;
}
window.addEventListener('error', evt => handleError(evt, evt.error));
window.addEventListener('unhandledrejection', evt => handleError(evt.reason, evt.reason));

View File

@ -1,92 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="alt.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="16"
inkscape:cx="18.205425"
inkscape:cy="17.531398"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="true"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true"
inkscape:snap-global="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<g
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:48px;line-height:125%;font-family:'DejaVu Sans';-inkscape-font-specification:'Sans Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="text5290">
<path
d="m 9.9560547,1042.3329 -2.9394531,0 -0.4638672,1.3281 -1.8896485,0 2.7001953,-7.29 2.241211,0 2.7001958,7.29 -1.889649,0 -0.4589843,-1.3281 z m -2.4707031,-1.3526 1.9970703,0 -0.9960938,-2.9003 -1.0009765,2.9003 z"
style="font-size:10px;fill:#ffffff;fill-opacity:1"
id="path5340" />
<path
d="m 13.188477,1036.0634 1.748046,0 0,7.5976 -1.748046,0 0,-7.5976 z"
style="font-size:10px;fill:#ffffff;fill-opacity:1"
id="path5342" />
<path
d="m 18.535156,1036.6395 0,1.5528 1.801758,0 0,1.25 -1.801758,0 0,2.3193 q 0,0.3809 0.151367,0.5176 0.151368,0.1318 0.600586,0.1318 l 0.898438,0 0,1.25 -1.499024,0 q -1.035156,0 -1.469726,-0.4297 -0.429688,-0.4345 -0.429688,-1.4697 l 0,-2.3193 -0.86914,0 0,-1.25 0.86914,0 0,-1.5528 1.748047,0 z"
style="font-size:10px;fill:#ffffff;fill-opacity:1"
id="path5344" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -1,106 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="clipboard.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="15.366606"
inkscape:cy="16.42981"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="true"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true"
inkscape:snap-global="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<path
style="opacity:1;fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="M 9,6 6,6 C 5.4459889,6 5,6.4459889 5,7 l 0,13 c 0,0.554011 0.4459889,1 1,1 l 13,0 c 0.554011,0 1,-0.445989 1,-1 L 20,7 C 20,6.4459889 19.554011,6 19,6 l -3,0"
transform="translate(0,1027.3622)"
id="rect6083"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cssssssssc" />
<rect
style="opacity:1;fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect6085"
width="7"
height="4"
x="9"
y="1031.3622"
ry="1.00002" />
<path
style="fill:none;fill-rule:evenodd;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.50196081"
d="m 8.5071212,1038.8622 7.9999998,0"
id="path6087"
inkscape:connector-curvature="0" />
<path
style="fill:none;fill-rule:evenodd;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.50196081"
d="m 8.5071212,1041.8622 3.9999998,0"
id="path6089"
inkscape:connector-curvature="0" />
<path
style="fill:none;fill-rule:evenodd;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0.50196081"
d="m 8.5071212,1044.8622 5.9999998,0"
id="path6091"
inkscape:connector-curvature="0" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -1,96 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="connect.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="37.14834"
inkscape:cy="1.9525926"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="true"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<g
id="g5103"
transform="matrix(0.70710678,-0.70710678,0.70710678,0.70710678,-729.15757,315.8823)">
<path
sodipodi:nodetypes="cssssc"
inkscape:connector-curvature="0"
id="rect5096"
d="m 11,1040.3622 -5,0 c -1.108,0 -2,-0.892 -2,-2 l 0,-4 c 0,-1.108 0.892,-2 2,-2 l 5,0"
style="opacity:1;fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
<path
style="opacity:1;fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="m 14,1032.3622 5,0 c 1.108,0 2,0.892 2,2 l 0,4 c 0,1.108 -0.892,2 -2,2 l -5,0"
id="path5099"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cssssc" />
<path
inkscape:connector-curvature="0"
id="path5101"
d="m 9,1036.3622 7,0"
style="fill:none;fill-rule:evenodd;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -1,96 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="ctrl.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="16"
inkscape:cx="18.205425"
inkscape:cy="17.531398"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="true"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true"
inkscape:snap-global="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<g
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:48px;line-height:125%;font-family:'DejaVu Sans';-inkscape-font-specification:'Sans Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="text5290">
<path
d="m 9.1210938,1043.1898 q -0.5175782,0.2686 -1.0791016,0.4053 -0.5615235,0.1367 -1.171875,0.1367 -1.8212891,0 -2.8857422,-1.0156 -1.0644531,-1.0205 -1.0644531,-2.7637 0,-1.748 1.0644531,-2.7637 1.0644531,-1.0205 2.8857422,-1.0205 0.6103515,0 1.171875,0.1368 0.5615234,0.1367 1.0791016,0.4052 l 0,1.5088 q -0.522461,-0.3564 -1.0302735,-0.5224 -0.5078125,-0.1661 -1.0693359,-0.1661 -1.0058594,0 -1.5820313,0.6446 -0.5761719,0.6445 -0.5761719,1.7773 0,1.1279 0.5761719,1.7725 0.5761719,0.6445 1.5820313,0.6445 0.5615234,0 1.0693359,-0.166 0.5078125,-0.166 1.0302735,-0.5225 l 0,1.5088 z"
style="font-size:10px;fill:#ffffff;fill-opacity:1"
id="path5370" />
<path
d="m 12.514648,1036.5687 0,1.5528 1.801758,0 0,1.25 -1.801758,0 0,2.3193 q 0,0.3809 0.151368,0.5176 0.151367,0.1318 0.600586,0.1318 l 0.898437,0 0,1.25 -1.499023,0 q -1.035157,0 -1.469727,-0.4297 -0.429687,-0.4345 -0.429687,-1.4697 l 0,-2.3193 -0.8691411,0 0,-1.25 0.8691411,0 0,-1.5528 1.748046,0 z"
style="font-size:10px;fill:#ffffff;fill-opacity:1"
id="path5372" />
<path
d="m 19.453125,1039.6107 q -0.229492,-0.1074 -0.458984,-0.1562 -0.22461,-0.054 -0.454102,-0.054 -0.673828,0 -1.040039,0.4345 -0.361328,0.4297 -0.361328,1.2354 l 0,2.5195 -1.748047,0 0,-5.4687 1.748047,0 0,0.8984 q 0.336914,-0.5371 0.771484,-0.7813 0.439453,-0.249 1.049805,-0.249 0.08789,0 0.19043,0.01 0.102539,0 0.297851,0.029 l 0.0049,1.582 z"
style="font-size:10px;fill:#ffffff;fill-opacity:1"
id="path5374" />
<path
d="m 20.332031,1035.9926 1.748047,0 0,7.5976 -1.748047,0 0,-7.5976 z"
style="font-size:10px;fill:#ffffff;fill-opacity:1"
id="path5376" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -1,100 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="ctrlaltdel.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="8"
inkscape:cx="11.135667"
inkscape:cy="16.407428"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="true"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true"
inkscape:snap-global="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<rect
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect5253"
width="5"
height="5.0000172"
x="16"
y="1031.3622"
ry="1.0000174" />
<rect
y="1043.3622"
x="4"
height="5.0000172"
width="5"
id="rect5255"
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
ry="1.0000174" />
<rect
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect5257"
width="5"
height="5.0000172"
x="13"
y="1043.3622"
ry="1.0000174" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -1,94 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="disconnect.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="16"
inkscape:cx="25.05707"
inkscape:cy="11.594858"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="true"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true"
inkscape:snap-global="false">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<g
id="g5171"
transform="translate(-24.062499,-6.15775e-4)">
<path
id="path5110"
transform="translate(0,1027.3622)"
d="m 39.744141,3.4960938 c -0.769923,0 -1.539607,0.2915468 -2.121094,0.8730468 l -2.566406,2.5664063 1.414062,1.4140625 2.566406,-2.5664063 c 0.403974,-0.404 1.010089,-0.404 1.414063,0 l 2.828125,2.828125 c 0.40398,0.4039 0.403907,1.0101621 0,1.4140629 l -2.566406,2.566406 1.414062,1.414062 2.566406,-2.566406 c 1.163041,-1.1629 1.162968,-3.0791874 0,-4.2421874 L 41.865234,4.3691406 C 41.283747,3.7876406 40.514063,3.4960937 39.744141,3.4960938 Z M 39.017578,9.015625 a 1.0001,1.0001 0 0 0 -0.6875,0.3027344 l -0.445312,0.4453125 1.414062,1.4140621 0.445313,-0.445312 A 1.0001,1.0001 0 0 0 39.017578,9.015625 Z m -6.363281,0.7070312 a 1.0001,1.0001 0 0 0 -0.6875,0.3027348 L 28.431641,13.5625 c -1.163042,1.163 -1.16297,3.079187 0,4.242188 l 2.828125,2.828124 c 1.162974,1.163101 3.079213,1.163101 4.242187,0 l 3.535156,-3.535156 a 1.0001,1.0001 0 1 0 -1.414062,-1.414062 l -3.535156,3.535156 c -0.403974,0.404 -1.010089,0.404 -1.414063,0 l -2.828125,-2.828125 c -0.403981,-0.404 -0.403908,-1.010162 0,-1.414063 l 3.535156,-3.537109 A 1.0001,1.0001 0 0 0 32.654297,9.7226562 Z m 3.109375,2.1621098 -2.382813,2.384765 a 1.0001,1.0001 0 1 0 1.414063,1.414063 l 2.382812,-2.384766 -1.414062,-1.414062 z"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
inkscape:connector-curvature="0" />
<rect
transform="matrix(0.70710678,-0.70710678,0.70710678,0.70710678,0,0)"
y="752.29541"
x="-712.31262"
height="18.000017"
width="3"
id="rect5116"
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -1,76 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="drag.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="22.627417"
inkscape:cx="9.8789407"
inkscape:cy="9.5008608"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="true"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="false"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<path
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="m 7.039733,1049.3037 c -0.4309106,-0.1233 -0.7932634,-0.4631 -0.9705434,-0.9103 -0.04922,-0.1241 -0.057118,-0.2988 -0.071321,-1.5771 l -0.015972,-1.4375 -0.328125,-0.082 c -0.7668138,-0.1927 -1.1897046,-0.4275 -1.7031253,-0.9457 -0.4586773,-0.4629 -0.6804297,-0.8433 -0.867034,-1.4875 -0.067215,-0.232 -0.068001,-0.2642 -0.078682,-3.2188 -0.012078,-3.341 -0.020337,-3.2012 0.2099452,-3.5555 0.2246623,-0.3458 0.5798271,-0.5892 0.9667343,-0.6626 0.092506,-0.017 0.531898,-0.032 0.9764271,-0.032 l 0.8082347,0 1.157e-4,1.336 c 1.125e-4,1.2779 0.00281,1.3403 0.062214,1.4378 0.091785,0.1505 0.2357707,0.226 0.4314082,0.2261 0.285389,2e-4 0.454884,-0.1352 0.5058962,-0.4042 0.019355,-0.102 0.031616,-0.982 0.031616,-2.269 0,-1.9756 0.00357,-2.1138 0.059205,-2.2926 0.1645475,-0.5287 0.6307616,-0.9246 1.19078,-1.0113 0.8000572,-0.1238 1.5711277,0.4446 1.6860387,1.2429 0.01732,0.1203 0.03177,0.8248 0.03211,1.5657 6.19e-4,1.3449 7.22e-4,1.347 0.07093,1.4499 0.108355,0.1587 0.255268,0.2248 0.46917,0.2108 0.204069,-0.013 0.316116,-0.08 0.413642,-0.2453 0.06028,-0.1024 0.06307,-0.1778 0.07862,-2.1218 0.01462,-1.8283 0.02124,-2.0285 0.07121,-2.1549 0.260673,-0.659 0.934894,-1.0527 1.621129,-0.9465 0.640523,0.099 1.152269,0.6104 1.243187,1.2421 0.01827,0.1269 0.03175,0.9943 0.03211,2.0657 l 6.19e-4,1.8469 0.07031,0.103 c 0.108355,0.1587 0.255267,0.2248 0.46917,0.2108 0.204069,-0.013 0.316115,-0.08 0.413642,-0.2453 0.05951,-0.1011 0.06329,-0.1786 0.07907,-1.6218 0.01469,-1.3438 0.02277,-1.5314 0.07121,-1.6549 0.257975,-0.6576 0.934425,-1.0527 1.620676,-0.9465 0.640522,0.099 1.152269,0.6104 1.243186,1.2421 0.0186,0.1292 0.03179,1.0759 0.03222,2.3125 7.15e-4,2.0335 0.0025,2.0966 0.06283,2.1956 0.09178,0.1505 0.235771,0.226 0.431409,0.2261 0.285388,2e-4 0.454884,-0.1352 0.505897,-0.4042 0.01874,-0.099 0.03161,-0.8192 0.03161,-1.769 0,-1.4848 0.0043,-1.6163 0.0592,-1.7926 0.164548,-0.5287 0.630762,-0.9246 1.19078,-1.0113 0.800057,-0.1238 1.571128,0.4446 1.686039,1.2429 0.04318,0.2999 0.04372,9.1764 5.78e-4,9.4531 -0.04431,0.2841 -0.217814,0.6241 -0.420069,0.8232 -0.320102,0.315 -0.63307,0.4268 -1.194973,0.4268 l -0.35281,0 -2.51e-4,1.2734 c -1.25e-4,0.7046 -0.01439,1.3642 -0.03191,1.4766 -0.06665,0.4274 -0.372966,0.8704 -0.740031,1.0702 -0.349999,0.1905 0.01748,0.18 -6.242199,0.1776 -5.3622439,0 -5.7320152,-0.01 -5.9121592,-0.057 l 1.4e-5,0 z"
id="path4379"
inkscape:connector-curvature="0" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.6 KiB

View File

@ -1,81 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="error.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="14.00357"
inkscape:cy="12.443398"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="true"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true"
inkscape:snap-global="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<path
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="M 7 3 C 4.7839905 3 3 4.7839905 3 7 L 3 18 C 3 20.21601 4.7839905 22 7 22 L 18 22 C 20.21601 22 22 20.21601 22 18 L 22 7 C 22 4.7839905 20.21601 3 18 3 L 7 3 z M 7.6992188 6 A 1.6916875 1.6924297 0 0 1 8.9121094 6.5117188 L 12.5 10.101562 L 16.087891 6.5117188 A 1.6916875 1.6924297 0 0 1 17.251953 6 A 1.6916875 1.6924297 0 0 1 18.480469 8.90625 L 14.892578 12.496094 L 18.480469 16.085938 A 1.6916875 1.6924297 0 1 1 16.087891 18.478516 L 12.5 14.888672 L 8.9121094 18.478516 A 1.6916875 1.6924297 0 1 1 6.5214844 16.085938 L 10.109375 12.496094 L 6.5214844 8.90625 A 1.6916875 1.6924297 0 0 1 7.6992188 6 z "
transform="translate(0,1027.3622)"
id="rect4135" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -1,92 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="esc.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="16"
inkscape:cx="18.205425"
inkscape:cy="17.531398"
inkscape:document-units="px"
inkscape:current-layer="text5290"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="true"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true"
inkscape:snap-global="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<g
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:48px;line-height:125%;font-family:'DejaVu Sans';-inkscape-font-specification:'Sans Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="text5290">
<path
d="m 3.9331055,1036.1464 5.0732422,0 0,1.4209 -3.1933594,0 0,1.3574 3.0029297,0 0,1.4209 -3.0029297,0 0,1.6699 3.3007812,0 0,1.4209 -5.180664,0 0,-7.29 z"
style="font-size:10px;fill:#ffffff;fill-opacity:1"
id="path5314" />
<path
d="m 14.963379,1038.1385 0,1.3282 q -0.561524,-0.2344 -1.083984,-0.3516 -0.522461,-0.1172 -0.986329,-0.1172 -0.498046,0 -0.742187,0.127 -0.239258,0.122 -0.239258,0.3808 0,0.21 0.180664,0.3223 0.185547,0.1123 0.65918,0.166 l 0.307617,0.044 q 1.342773,0.1709 1.806641,0.5615 0.463867,0.3906 0.463867,1.2256 0,0.874 -0.644531,1.3134 -0.644532,0.4395 -1.923829,0.4395 -0.541992,0 -1.123046,-0.088 -0.576172,-0.083 -1.186524,-0.2539 l 0,-1.3281 q 0.522461,0.2539 1.069336,0.3808 0.551758,0.127 1.118164,0.127 0.512695,0 0.771485,-0.1416 0.258789,-0.1416 0.258789,-0.4199 0,-0.2344 -0.180664,-0.3467 -0.175782,-0.1172 -0.708008,-0.1807 l -0.307617,-0.039 q -1.166993,-0.1465 -1.635743,-0.542 -0.46875,-0.3955 -0.46875,-1.2012 0,-0.8691 0.595703,-1.2891 0.595704,-0.4199 1.826172,-0.4199 0.483399,0 1.015625,0.073 0.532227,0.073 1.157227,0.2294 z"
style="font-size:10px;fill:#ffffff;fill-opacity:1"
id="path5316" />
<path
d="m 21.066895,1038.1385 0,1.4258 q -0.356446,-0.2441 -0.717774,-0.3613 -0.356445,-0.1172 -0.742187,-0.1172 -0.732422,0 -1.142579,0.4297 -0.405273,0.4248 -0.405273,1.1914 0,0.7666 0.405273,1.1963 0.410157,0.4248 1.142579,0.4248 0.410156,0 0.776367,-0.1221 0.371094,-0.122 0.683594,-0.3613 l 0,1.4307 q -0.410157,0.1513 -0.834961,0.2246 -0.419922,0.078 -0.844727,0.078 -1.479492,0 -2.314453,-0.7568 -0.834961,-0.7618 -0.834961,-2.1143 0,-1.3525 0.834961,-2.1094 0.834961,-0.7617 2.314453,-0.7617 0.429688,0 0.844727,0.078 0.419921,0.073 0.834961,0.2246 z"
style="font-size:10px;fill:#ffffff;fill-opacity:1"
id="path5318" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.4 KiB

View File

@ -1,69 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="9"
height="10"
viewBox="0 0 9 10"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="expander.svg">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="45.254834"
inkscape:cx="9.8737281"
inkscape:cy="6.4583132"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="true"
units="px"
inkscape:snap-object-midpoints="false"
inkscape:object-nodes="true"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1042.3622)">
<path
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#000000;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:4;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="M 2.0800781,1042.3633 A 2.0002,2.0002 0 0 0 0,1044.3613 l 0,6 a 2.0002,2.0002 0 0 0 3.0292969,1.7168 l 5,-3 a 2.0002,2.0002 0 0 0 0,-3.4316 l -5,-3 a 2.0002,2.0002 0 0 0 -0.9492188,-0.2832 z"
id="path4138"
inkscape:connector-curvature="0" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -1,93 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="fullscreen.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="16.400723"
inkscape:cy="15.083758"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="false"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="false">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<rect
style="opacity:1;fill:none;fill-opacity:1;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect5006"
width="17"
height="17.000017"
x="4"
y="1031.3622"
ry="3.0000174" />
<path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#ffffff;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="m 7.5,1044.8622 4,0 -1.5,-1.5 1.5,-1.5 -1,-1 -1.5,1.5 -1.5,-1.5 0,4 z"
id="path5017"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path5025"
d="m 17.5,1034.8622 -4,0 1.5,1.5 -1.5,1.5 1,1 1.5,-1.5 1.5,1.5 0,-4 z"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#ffffff;stroke-width:1px;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -1,82 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="5"
height="6"
viewBox="0 0 5 6"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="handle.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="32"
inkscape:cx="1.3551778"
inkscape:cy="8.7800329"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="true"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="false"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true"
inkscape:snap-global="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1046.3622)">
<path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 4.0000803,1049.3622 -3,-2 0,4 z"
id="path4247"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccc" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -1,172 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="15"
height="50"
viewBox="0 0 15 50"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="handle_bg.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="16"
inkscape:cx="-10.001409"
inkscape:cy="24.512566"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="true"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="false"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true"
inkscape:snap-global="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1002.3622)">
<rect
style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4249"
width="1"
height="1.0000174"
x="9.5"
y="1008.8622"
ry="1.7382812e-05" />
<rect
ry="1.7382812e-05"
y="1013.8622"
x="9.5"
height="1.0000174"
width="1"
id="rect4255"
style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
<rect
ry="1.7382812e-05"
y="1008.8622"
x="4.5"
height="1.0000174"
width="1"
id="rect4261"
style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
<rect
style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4263"
width="1"
height="1.0000174"
x="4.5"
y="1013.8622"
ry="1.7382812e-05" />
<rect
ry="1.7382812e-05"
y="1039.8622"
x="9.5"
height="1.0000174"
width="1"
id="rect4265"
style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
<rect
style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4267"
width="1"
height="1.0000174"
x="9.5"
y="1044.8622"
ry="1.7382812e-05" />
<rect
style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4269"
width="1"
height="1.0000174"
x="4.5"
y="1039.8622"
ry="1.7382812e-05" />
<rect
ry="1.7382812e-05"
y="1044.8622"
x="4.5"
height="1.0000174"
width="1"
id="rect4271"
style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
<rect
style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4273"
width="1"
height="1.0000174"
x="9.5"
y="1018.8622"
ry="1.7382812e-05" />
<rect
ry="1.7382812e-05"
y="1018.8622"
x="4.5"
height="1.0000174"
width="1"
id="rect4275"
style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
<rect
style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4277"
width="1"
height="1.0000174"
x="9.5"
y="1034.8622"
ry="1.7382812e-05" />
<rect
ry="1.7382812e-05"
y="1034.8622"
x="4.5"
height="1.0000174"
width="1"
id="rect4279"
style="opacity:0.25;fill:#ffffff;fill-opacity:1;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.2 KiB

View File

@ -1,42 +0,0 @@
BROWSER_SIZES := 16 24 32 48 64
#ANDROID_SIZES := 72 96 144 192
# FIXME: The ICO is limited to 8 icons due to a Chrome bug:
# https://bugs.chromium.org/p/chromium/issues/detail?id=1381393
ANDROID_SIZES := 96 144 192
WEB_ICON_SIZES := $(BROWSER_SIZES) $(ANDROID_SIZES)
#IOS_1X_SIZES := 20 29 40 76 # No such devices exist anymore
IOS_2X_SIZES := 40 58 80 120 152 167
IOS_3X_SIZES := 60 87 120 180
ALL_IOS_SIZES := $(IOS_1X_SIZES) $(IOS_2X_SIZES) $(IOS_3X_SIZES)
ALL_ICONS := \
$(ALL_IOS_SIZES:%=novnc-ios-%.png) \
novnc.ico
all: $(ALL_ICONS)
# Our testing shows that the ICO file need to be sorted in largest to
# smallest to get the apporpriate behviour
WEB_ICON_SIZES_REVERSE := $(shell echo $(WEB_ICON_SIZES) | tr ' ' '\n' | sort -nr | tr '\n' ' ')
WEB_BASE_ICONS := $(WEB_ICON_SIZES_REVERSE:%=novnc-%.png)
.INTERMEDIATE: $(WEB_BASE_ICONS)
novnc.ico: $(WEB_BASE_ICONS)
convert $(WEB_BASE_ICONS) "$@"
# General conversion
novnc-%.png: novnc-icon.svg
convert -depth 8 -background transparent \
-size $*x$* "$(lastword $^)" "$@"
# iOS icons use their own SVG
novnc-ios-%.png: novnc-ios-icon.svg
convert -depth 8 -background transparent \
-size $*x$* "$(lastword $^)" "$@"
# The smallest sizes are generated using a different SVG
novnc-16.png novnc-24.png novnc-32.png: novnc-icon-sm.svg
clean:
rm -f *.png

View File

@ -1,163 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="16"
height="16"
viewBox="0 0 16 16"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="novnc-icon-sm.svg">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="45.254834"
inkscape:cx="9.722703"
inkscape:cy="5.5311896"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:object-nodes="true"
inkscape:snap-smooth-nodes="true"
inkscape:snap-midpoints="true"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1">
<inkscape:grid
type="xygrid"
id="grid4169" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1036.3621)">
<rect
style="opacity:1;fill:#494949;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4167"
width="16"
height="15.999992"
x="0"
y="1036.3622"
ry="2.6666584" />
<path
style="opacity:1;fill:#313131;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="M 2.6666667,1036.3621 C 1.1893373,1036.3621 0,1037.5515 0,1039.0288 l 0,10.6666 c 0,1.4774 1.1893373,2.6667 2.6666667,2.6667 l 4,0 C 11.837333,1052.3621 16,1046.7128 16,1039.6955 l 0,-0.6667 c 0,-1.4773 -1.189337,-2.6667 -2.666667,-2.6667 l -10.6666663,0 z"
id="rect4173"
inkscape:connector-curvature="0" />
<g
id="g4381">
<g
transform="translate(0.25,0.25)"
style="fill:#000000;fill-opacity:1"
id="g4365">
<g
style="fill:#000000;fill-opacity:1"
id="g4367">
<path
inkscape:connector-curvature="0"
id="path4369"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 4.3289754,1039.3621 c 0.1846149,0 0.3419956,0.071 0.4716623,0.2121 C 4.933546,1039.7121 5,1039.8793 5,1040.0759 l 0,3.2862 -1,0 0,-2.964 c 0,-0.024 -0.011592,-0.036 -0.034038,-0.036 l -1.931924,0 C 2.011349,1040.3621 2,1040.3741 2,1040.3981 l 0,2.964 -1,0 0,-4 z"
sodipodi:nodetypes="scsccsssscccs" />
<path
inkscape:connector-curvature="0"
id="path4371"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 6.6710244,1039.3621 2.6579513,0 c 0.184775,0 0.3419957,0.071 0.471662,0.2121 C 9.933546,1039.7121 10,1039.8793 10,1040.0759 l 0,2.5724 c 0,0.1966 -0.066454,0.3655 -0.1993623,0.5069 -0.1296663,0.1379 -0.286887,0.2069 -0.471662,0.2069 l -2.6579513,0 c -0.184775,0 -0.3436164,-0.069 -0.4765247,-0.2069 C 6.0648334,1043.0138 6,1042.8449 6,1042.6483 l 0,-2.5724 c 0,-0.1966 0.064833,-0.3638 0.1944997,-0.5017 0.1329083,-0.1414 0.2917497,-0.2121 0.4765247,-0.2121 z m 2.2949386,1 -1.931926,0 C 7.011344,1040.3621 7,1040.3741 7,1040.3981 l 0,1.928 c 0,0.024 0.011347,0.036 0.034037,0.036 l 1.931926,0 c 0.02269,0 0.034037,-0.012 0.034037,-0.036 l 0,-1.928 c 0,-0.024 -0.011347,-0.036 -0.034037,-0.036 z"
sodipodi:nodetypes="sscsscsscsscssssssssss" />
</g>
<g
style="fill:#000000;fill-opacity:1"
id="g4373">
<path
inkscape:connector-curvature="0"
id="path4375"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 3,1047.1121 1,-2.75 1,0 -1.5,4 -1,0 -1.5,-4 1,0 z"
sodipodi:nodetypes="cccccccc" />
<path
inkscape:connector-curvature="0"
id="path4377"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 9,1046.8621 0,-2.5 1,0 0,4 -1,0 -2,-2.5 0,2.5 -1,0 0,-4 1,0 z"
sodipodi:nodetypes="ccccccccccc" />
<path
inkscape:connector-curvature="0"
id="path4379"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 15,1045.3621 -2.96596,0 c -0.02269,0 -0.03404,0.012 -0.03404,0.036 l 0,1.928 c 0,0.024 0.01135,0.036 0.03404,0.036 l 2.96596,0 0,1 -3.324113,0 c -0.188017,0 -0.348479,-0.068 -0.481388,-0.2037 C 11.064833,1048.0192 11,1047.8511 11,1047.6542 l 0,-2.5842 c 0,-0.1969 0.06483,-0.3633 0.194499,-0.4991 0.132909,-0.1392 0.293371,-0.2088 0.481388,-0.2088 l 3.324113,0 z"
sodipodi:nodetypes="cssssccscsscscc" />
</g>
</g>
<g
id="g4356">
<g
id="g4347">
<path
sodipodi:nodetypes="scsccsssscccs"
d="m 4.3289754,1039.3621 c 0.1846149,0 0.3419956,0.071 0.4716623,0.2121 C 4.933546,1039.7121 5,1039.8793 5,1040.0759 l 0,3.2862 -1,0 0,-2.964 c 0,-0.024 -0.011592,-0.036 -0.034038,-0.036 l -1.931924,0 c -0.022689,0 -0.034038,0.012 -0.034038,0.036 l 0,2.964 -1,0 0,-4 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#008000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4143"
inkscape:connector-curvature="0" />
<path
sodipodi:nodetypes="sscsscsscsscssssssssss"
d="m 6.6710244,1039.3621 2.6579513,0 c 0.184775,0 0.3419957,0.071 0.471662,0.2121 C 9.933546,1039.7121 10,1039.8793 10,1040.0759 l 0,2.5724 c 0,0.1966 -0.066454,0.3655 -0.1993623,0.5069 -0.1296663,0.1379 -0.286887,0.2069 -0.471662,0.2069 l -2.6579513,0 c -0.184775,0 -0.3436164,-0.069 -0.4765247,-0.2069 C 6.0648334,1043.0138 6,1042.8449 6,1042.6483 l 0,-2.5724 c 0,-0.1966 0.064833,-0.3638 0.1944997,-0.5017 0.1329083,-0.1414 0.2917497,-0.2121 0.4765247,-0.2121 z m 2.2949386,1 -1.931926,0 C 7.011344,1040.3621 7,1040.3741 7,1040.3981 l 0,1.928 c 0,0.024 0.011347,0.036 0.034037,0.036 l 1.931926,0 c 0.02269,0 0.034037,-0.012 0.034037,-0.036 l 0,-1.928 c 0,-0.024 -0.011347,-0.036 -0.034037,-0.036 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#008000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4145"
inkscape:connector-curvature="0" />
</g>
<g
id="g4351">
<path
sodipodi:nodetypes="cccccccc"
d="m 3,1047.1121 1,-2.75 1,0 -1.5,4 -1,0 -1.5,-4 1,0 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4147"
inkscape:connector-curvature="0" />
<path
sodipodi:nodetypes="ccccccccccc"
d="m 9,1046.8621 0,-2.5 1,0 0,4 -1,0 -2,-2.5 0,2.5 -1,0 0,-4 1,0 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4149"
inkscape:connector-curvature="0" />
<path
sodipodi:nodetypes="cssssccscsscscc"
d="m 15,1045.3621 -2.96596,0 c -0.02269,0 -0.03404,0.012 -0.03404,0.036 l 0,1.928 c 0,0.024 0.01135,0.036 0.03404,0.036 l 2.96596,0 0,1 -3.324113,0 c -0.188017,0 -0.348479,-0.068 -0.481388,-0.2037 C 11.064833,1048.0192 11,1047.8511 11,1047.6542 l 0,-2.5842 c 0,-0.1969 0.06483,-0.3633 0.194499,-0.4991 0.132909,-0.1392 0.293371,-0.2088 0.481388,-0.2088 l 3.324113,0 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4151"
inkscape:connector-curvature="0" />
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 11 KiB

View File

@ -1,163 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="48"
height="48"
viewBox="0 0 48 48.000001"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="novnc-icon.svg">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="11.313708"
inkscape:cx="27.187245"
inkscape:cy="17.700974"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:object-nodes="true"
inkscape:snap-smooth-nodes="true"
inkscape:snap-midpoints="true"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1">
<inkscape:grid
type="xygrid"
id="grid4169" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1004.3621)">
<rect
style="opacity:1;fill:#494949;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4167"
width="48"
height="48"
x="0"
y="1004.3621"
ry="7.9999785" />
<path
style="opacity:1;fill:#313131;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="m 8,1004.3621 c -4.4319881,0 -8,3.568 -8,8 l 0,32 c 0,4.432 3.5680119,8 8,8 l 12,0 c 15.512,0 28,-16.948 28,-38 l 0,-2 c 0,-4.432 -3.568012,-8 -8,-8 l -32,0 z"
id="rect4173"
inkscape:connector-curvature="0" />
<g
id="g4300"
style="fill:#000000;fill-opacity:1;stroke:none"
transform="translate(0.5,0.5)">
<g
id="g4302"
style="fill:#000000;fill-opacity:1;stroke:none">
<path
sodipodi:nodetypes="scsccsssscccs"
d="m 11.986926,1016.3621 c 0.554325,0 1.025987,0.2121 1.414987,0.6362 0.398725,0.4138 0.600909,0.9155 0.598087,1.5052 l 0,6.8586 -2,0 0,-6.8914 c 0,-0.072 -0.03404,-0.1086 -0.102113,-0.1086 l -4.7957745,0 C 7.0340375,1018.3621 7,1018.3983 7,1018.4707 l 0,6.8914 -2,0 0,-9 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4304"
inkscape:connector-curvature="0" />
<path
sodipodi:nodetypes="sscsscsscsscssssssssss"
d="m 17.013073,1016.3621 4.973854,0 c 0.554325,0 1.025987,0.2121 1.414986,0.6362 0.398725,0.4138 0.598087,0.9155 0.598087,1.5052 l 0,4.7172 c 0,0.5897 -0.199362,1.0966 -0.598087,1.5207 -0.388999,0.4138 -0.860661,0.6207 -1.414986,0.6207 l -4.973854,0 c -0.554325,0 -1.030849,-0.2069 -1.429574,-0.6207 C 15.1945,1024.3173 15,1023.8104 15,1023.2207 l 0,-4.7172 c 0,-0.5897 0.1945,-1.0914 0.583499,-1.5052 0.398725,-0.4241 0.875249,-0.6362 1.429574,-0.6362 z m 4.884815,2 -4.795776,0 c -0.06808,0 -0.102112,0.036 -0.102112,0.1086 l 0,4.7828 c 0,0.072 0.03404,0.1086 0.102112,0.1086 l 4.795776,0 c 0.06807,0 0.102112,-0.036 0.102112,-0.1086 l 0,-4.7828 c 0,-0.072 -0.03404,-0.1086 -0.102112,-0.1086 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4306"
inkscape:connector-curvature="0" />
</g>
<g
id="g4308"
style="fill:#000000;fill-opacity:1;stroke:none">
<path
sodipodi:nodetypes="cccccccc"
d="m 12,1036.9177 4.768114,-8.5556 2.231886,0 -6,11 -2,0 -6,-11 2.2318854,0 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4310"
inkscape:connector-curvature="0" />
<path
sodipodi:nodetypes="ccccccccccc"
d="m 29,1036.3621 0,-8 2,0 0,11 -2,0 -7,-8 0,8 -2,0 0,-11 2,0 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4312"
inkscape:connector-curvature="0" />
<path
sodipodi:nodetypes="cssssccscsscscc"
d="m 43,1030.3621 -8.897887,0 c -0.06808,0 -0.102113,0.036 -0.102113,0.1069 l 0,6.7862 c 0,0.071 0.03404,0.1069 0.102113,0.1069 l 8.897887,0 0,2 -8.972339,0 c -0.56405,0 -1.045437,-0.2037 -1.444162,-0.6111 C 32.1945,1038.3334 32,1037.8292 32,1037.2385 l 0,-6.7528 c 0,-0.5907 0.1945,-1.0898 0.583499,-1.4972 0.398725,-0.4176 0.880112,-0.6264 1.444162,-0.6264 l 8.972339,0 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4314"
inkscape:connector-curvature="0" />
</g>
</g>
<g
id="g4291"
style="stroke:none">
<g
id="g4282"
style="stroke:none">
<path
inkscape:connector-curvature="0"
id="path4143"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#008000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 11.986926,1016.3621 c 0.554325,0 1.025987,0.2121 1.414987,0.6362 0.398725,0.4138 0.600909,0.9155 0.598087,1.5052 l 0,6.8586 -2,0 0,-6.8914 c 0,-0.072 -0.03404,-0.1086 -0.102113,-0.1086 l -4.7957745,0 C 7.0340375,1018.3621 7,1018.3983 7,1018.4707 l 0,6.8914 -2,0 0,-9 z"
sodipodi:nodetypes="scsccsssscccs" />
<path
inkscape:connector-curvature="0"
id="path4145"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#008000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 17.013073,1016.3621 4.973854,0 c 0.554325,0 1.025987,0.2121 1.414986,0.6362 0.398725,0.4138 0.598087,0.9155 0.598087,1.5052 l 0,4.7172 c 0,0.5897 -0.199362,1.0966 -0.598087,1.5207 -0.388999,0.4138 -0.860661,0.6207 -1.414986,0.6207 l -4.973854,0 c -0.554325,0 -1.030849,-0.2069 -1.429574,-0.6207 C 15.1945,1024.3173 15,1023.8104 15,1023.2207 l 0,-4.7172 c 0,-0.5897 0.1945,-1.0914 0.583499,-1.5052 0.398725,-0.4241 0.875249,-0.6362 1.429574,-0.6362 z m 4.884815,2 -4.795776,0 c -0.06808,0 -0.102112,0.036 -0.102112,0.1086 l 0,4.7828 c 0,0.072 0.03404,0.1086 0.102112,0.1086 l 4.795776,0 c 0.06807,0 0.102112,-0.036 0.102112,-0.1086 l 0,-4.7828 c 0,-0.072 -0.03404,-0.1086 -0.102112,-0.1086 z"
sodipodi:nodetypes="sscsscsscsscssssssssss" />
</g>
<g
id="g4286"
style="stroke:none">
<path
inkscape:connector-curvature="0"
id="path4147"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 12,1036.9177 4.768114,-8.5556 2.231886,0 -6,11 -2,0 -6,-11 2.2318854,0 z"
sodipodi:nodetypes="cccccccc" />
<path
inkscape:connector-curvature="0"
id="path4149"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 29,1036.3621 0,-8 2,0 0,11 -2,0 -7,-8 0,8 -2,0 0,-11 2,0 z"
sodipodi:nodetypes="ccccccccccc" />
<path
inkscape:connector-curvature="0"
id="path4151"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 43,1030.3621 -8.897887,0 c -0.06808,0 -0.102113,0.036 -0.102113,0.1069 l 0,6.7862 c 0,0.071 0.03404,0.1069 0.102113,0.1069 l 8.897887,0 0,2 -8.972339,0 c -0.56405,0 -1.045437,-0.2037 -1.444162,-0.6111 C 32.1945,1038.3334 32,1037.8292 32,1037.2385 l 0,-6.7528 c 0,-0.5907 0.1945,-1.0898 0.583499,-1.4972 0.398725,-0.4176 0.880112,-0.6264 1.444162,-0.6264 l 8.972339,0 z"
sodipodi:nodetypes="cssssccscsscscc" />
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -1,183 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="48"
height="48"
viewBox="0 0 48 48.000001"
id="svg2"
version="1.1"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="novnc-ios-icon.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="11.313708"
inkscape:cx="27.356195"
inkscape:cy="17.810253"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:object-nodes="true"
inkscape:snap-smooth-nodes="true"
inkscape:snap-midpoints="true"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1">
<inkscape:grid
type="xygrid"
id="grid4169" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1004.3621)">
<rect
style="opacity:1;fill:#494949;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
id="rect4167"
width="48"
height="48"
x="0"
y="1004.3621"
inkscape:label="background" />
<path
style="opacity:1;fill:#313131;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="m 0,1004.3621 v 48 h 20 c 15.512,0 28,-16.948 28,-38 v -10 z"
id="rect4173"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccc"
inkscape:label="darker_grey_plate" />
<g
id="g4300"
style="display:inline;fill:#000000;fill-opacity:1;stroke:none"
transform="translate(0.5,0.5)"
inkscape:label="shadows">
<g
id="g4302"
style="fill:#000000;fill-opacity:1;stroke:none"
inkscape:label="no">
<path
sodipodi:nodetypes="scsccsssscccs"
d="m 11.986926,1016.3621 c 0.554325,0 1.025987,0.2121 1.414987,0.6362 0.398725,0.4138 0.600909,0.9155 0.598087,1.5052 v 6.8586 h -2 v -6.8914 c 0,-0.072 -0.03404,-0.1086 -0.102113,-0.1086 H 7.1021125 C 7.0340375,1018.3621 7,1018.3983 7,1018.4707 v 6.8914 H 5 v -9 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4304"
inkscape:connector-curvature="0"
inkscape:label="n" />
<path
sodipodi:nodetypes="sscsscsscsscssssssssss"
d="m 17.013073,1016.3621 h 4.973854 c 0.554325,0 1.025987,0.2121 1.414986,0.6362 0.398725,0.4138 0.598087,0.9155 0.598087,1.5052 v 4.7172 c 0,0.5897 -0.199362,1.0966 -0.598087,1.5207 -0.388999,0.4138 -0.860661,0.6207 -1.414986,0.6207 h -4.973854 c -0.554325,0 -1.030849,-0.2069 -1.429574,-0.6207 C 15.1945,1024.3173 15,1023.8104 15,1023.2207 v -4.7172 c 0,-0.5897 0.1945,-1.0914 0.583499,-1.5052 0.398725,-0.4241 0.875249,-0.6362 1.429574,-0.6362 z m 4.884815,2 h -4.795776 c -0.06808,0 -0.102112,0.036 -0.102112,0.1086 v 4.7828 c 0,0.072 0.03404,0.1086 0.102112,0.1086 h 4.795776 c 0.06807,0 0.102112,-0.036 0.102112,-0.1086 v -4.7828 c 0,-0.072 -0.03404,-0.1086 -0.102112,-0.1086 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4306"
inkscape:connector-curvature="0"
inkscape:label="o" />
</g>
<g
id="g4308"
style="fill:#000000;fill-opacity:1;stroke:none"
inkscape:label="VNC">
<path
sodipodi:nodetypes="cccccccc"
d="m 12,1036.9177 4.768114,-8.5556 H 19 l -6,11 h -2 l -6,-11 h 2.2318854 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4310"
inkscape:connector-curvature="0"
inkscape:label="V" />
<path
sodipodi:nodetypes="ccccccccccc"
d="m 29,1036.3621 v -8 h 2 v 11 h -2 l -7,-8 v 8 h -2 v -11 h 2 z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4312"
inkscape:connector-curvature="0"
inkscape:label="N" />
<path
sodipodi:nodetypes="cssssccscsscscc"
d="m 43,1030.3621 h -8.897887 c -0.06808,0 -0.102113,0.036 -0.102113,0.1069 v 6.7862 c 0,0.071 0.03404,0.1069 0.102113,0.1069 H 43 v 2 h -8.972339 c -0.56405,0 -1.045437,-0.2037 -1.444162,-0.6111 C 32.1945,1038.3334 32,1037.8292 32,1037.2385 v -6.7528 c 0,-0.5907 0.1945,-1.0898 0.583499,-1.4972 0.398725,-0.4176 0.880112,-0.6264 1.444162,-0.6264 H 43 Z"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#000000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="path4314"
inkscape:connector-curvature="0"
inkscape:label="C" />
</g>
</g>
<g
id="g4291"
style="stroke:none"
inkscape:label="noVNC">
<g
id="g4282"
style="stroke:none"
inkscape:label="no">
<path
inkscape:connector-curvature="0"
id="path4143"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#008000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 11.986926,1016.3621 c 0.554325,0 1.025987,0.2121 1.414987,0.6362 0.398725,0.4138 0.600909,0.9155 0.598087,1.5052 l 0,6.8586 -2,0 0,-6.8914 c 0,-0.072 -0.03404,-0.1086 -0.102113,-0.1086 l -4.7957745,0 C 7.0340375,1018.3621 7,1018.3983 7,1018.4707 l 0,6.8914 -2,0 0,-9 z"
sodipodi:nodetypes="scsccsssscccs"
inkscape:label="n" />
<path
inkscape:connector-curvature="0"
id="path4145"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#008000;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 17.013073,1016.3621 4.973854,0 c 0.554325,0 1.025987,0.2121 1.414986,0.6362 0.398725,0.4138 0.598087,0.9155 0.598087,1.5052 l 0,4.7172 c 0,0.5897 -0.199362,1.0966 -0.598087,1.5207 -0.388999,0.4138 -0.860661,0.6207 -1.414986,0.6207 l -4.973854,0 c -0.554325,0 -1.030849,-0.2069 -1.429574,-0.6207 C 15.1945,1024.3173 15,1023.8104 15,1023.2207 l 0,-4.7172 c 0,-0.5897 0.1945,-1.0914 0.583499,-1.5052 0.398725,-0.4241 0.875249,-0.6362 1.429574,-0.6362 z m 4.884815,2 -4.795776,0 c -0.06808,0 -0.102112,0.036 -0.102112,0.1086 l 0,4.7828 c 0,0.072 0.03404,0.1086 0.102112,0.1086 l 4.795776,0 c 0.06807,0 0.102112,-0.036 0.102112,-0.1086 l 0,-4.7828 c 0,-0.072 -0.03404,-0.1086 -0.102112,-0.1086 z"
sodipodi:nodetypes="sscsscsscsscssssssssss"
inkscape:label="o" />
</g>
<g
id="g4286"
style="stroke:none"
inkscape:label="VNC">
<path
inkscape:connector-curvature="0"
id="path4147"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 12,1036.9177 4.768114,-8.5556 2.231886,0 -6,11 -2,0 -6,-11 2.2318854,0 z"
sodipodi:nodetypes="cccccccc"
inkscape:label="V" />
<path
inkscape:connector-curvature="0"
id="path4149"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 29,1036.3621 0,-8 2,0 0,11 -2,0 -7,-8 0,8 -2,0 0,-11 2,0 z"
sodipodi:nodetypes="ccccccccccc"
inkscape:label="N" />
<path
inkscape:connector-curvature="0"
id="path4151"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:medium;line-height:125%;font-family:Orbitron;-inkscape-font-specification:'Orbitron Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 43,1030.3621 -8.897887,0 c -0.06808,0 -0.102113,0.036 -0.102113,0.1069 l 0,6.7862 c 0,0.071 0.03404,0.1069 0.102113,0.1069 l 8.897887,0 0,2 -8.972339,0 c -0.56405,0 -1.045437,-0.2037 -1.444162,-0.6111 C 32.1945,1038.3334 32,1037.8292 32,1037.2385 l 0,-6.7528 c 0,-0.5907 0.1945,-1.0898 0.583499,-1.4972 0.398725,-0.4176 0.880112,-0.6264 1.444162,-0.6264 l 8.972339,0 z"
sodipodi:nodetypes="cssssccscsscscc"
inkscape:label="C" />
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 KiB

View File

@ -1,81 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="info.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="15.720838"
inkscape:cy="8.9111233"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="false"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true"
inkscape:snap-global="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<path
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="M 12.5 3 A 9.5 9.4999914 0 0 0 3 12.5 A 9.5 9.4999914 0 0 0 12.5 22 A 9.5 9.4999914 0 0 0 22 12.5 A 9.5 9.4999914 0 0 0 12.5 3 z M 12.5 5 A 1.5 1.5000087 0 0 1 14 6.5 A 1.5 1.5000087 0 0 1 12.5 8 A 1.5 1.5000087 0 0 1 11 6.5 A 1.5 1.5000087 0 0 1 12.5 5 z M 10.521484 8.9785156 L 12.521484 8.9785156 A 1.50015 1.50015 0 0 1 14.021484 10.478516 L 14.021484 15.972656 A 1.50015 1.50015 0 0 1 14.498047 18.894531 C 14.498047 18.894531 13.74301 19.228309 12.789062 18.912109 C 12.312092 18.754109 11.776235 18.366625 11.458984 17.828125 C 11.141734 17.289525 11.021484 16.668469 11.021484 15.980469 L 11.021484 11.980469 L 10.521484 11.980469 A 1.50015 1.50015 0 1 1 10.521484 8.9804688 L 10.521484 8.9785156 z "
transform="translate(0,1027.3622)"
id="path4136" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -1,88 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="keyboard.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/keyboard.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#717171"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="31.285341"
inkscape:cy="8.8028469"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:snap-bbox-midpoints="false"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:object-paths="true"
inkscape:snap-intersection-paths="true"
inkscape:object-nodes="true"
inkscape:snap-midpoints="true"
inkscape:snap-smooth-nodes="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<path
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="M 7,3 C 4.8012876,3 3,4.8013 3,7 3,11.166667 3,15.333333 3,19.5 3,20.8764 4.1236413,22 5.5,22 l 14,0 C 20.876358,22 22,20.8764 22,19.5 22,15.333333 22,11.166667 22,7 22,4.8013 20.198712,3 18,3 Z m 0,2 11,0 c 1.125307,0 2,0.8747 2,2 L 20,12 5,12 5,7 C 5,5.8747 5.8746931,5 7,5 Z M 6.5,14 C 6.777,14 7,14.223 7,14.5 7,14.777 6.777,15 6.5,15 6.223,15 6,14.777 6,14.5 6,14.223 6.223,14 6.5,14 Z m 2,0 C 8.777,14 9,14.223 9,14.5 9,14.777 8.777,15 8.5,15 8.223,15 8,14.777 8,14.5 8,14.223 8.223,14 8.5,14 Z m 2,0 C 10.777,14 11,14.223 11,14.5 11,14.777 10.777,15 10.5,15 10.223,15 10,14.777 10,14.5 10,14.223 10.223,14 10.5,14 Z m 2,0 C 12.777,14 13,14.223 13,14.5 13,14.777 12.777,15 12.5,15 12.223,15 12,14.777 12,14.5 12,14.223 12.223,14 12.5,14 Z m 2,0 C 14.777,14 15,14.223 15,14.5 15,14.777 14.777,15 14.5,15 14.223,15 14,14.777 14,14.5 14,14.223 14.223,14 14.5,14 Z m 2,0 C 16.777,14 17,14.223 17,14.5 17,14.777 16.777,15 16.5,15 16.223,15 16,14.777 16,14.5 16,14.223 16.223,14 16.5,14 Z m 2,0 C 18.777,14 19,14.223 19,14.5 19,14.777 18.777,15 18.5,15 18.223,15 18,14.777 18,14.5 18,14.223 18.223,14 18.5,14 Z m -13,2 C 5.777,16 6,16.223 6,16.5 6,16.777 5.777,17 5.5,17 5.223,17 5,16.777 5,16.5 5,16.223 5.223,16 5.5,16 Z m 2,0 C 7.777,16 8,16.223 8,16.5 8,16.777 7.777,17 7.5,17 7.223,17 7,16.777 7,16.5 7,16.223 7.223,16 7.5,16 Z m 2,0 C 9.777,16 10,16.223 10,16.5 10,16.777 9.777,17 9.5,17 9.223,17 9,16.777 9,16.5 9,16.223 9.223,16 9.5,16 Z m 2,0 C 11.777,16 12,16.223 12,16.5 12,16.777 11.777,17 11.5,17 11.223,17 11,16.777 11,16.5 11,16.223 11.223,16 11.5,16 Z m 2,0 C 13.777,16 14,16.223 14,16.5 14,16.777 13.777,17 13.5,17 13.223,17 13,16.777 13,16.5 13,16.223 13.223,16 13.5,16 Z m 2,0 C 15.777,16 16,16.223 16,16.5 16,16.777 15.777,17 15.5,17 15.223,17 15,16.777 15,16.5 15,16.223 15.223,16 15.5,16 Z m 2,0 C 17.777,16 18,16.223 18,16.5 18,16.777 17.777,17 17.5,17 17.223,17 17,16.777 17,16.5 17,16.223 17.223,16 17.5,16 Z m 2,0 C 19.777,16 20,16.223 20,16.5 20,16.777 19.777,17 19.5,17 19.223,17 19,16.777 19,16.5 19,16.223 19.223,16 19.5,16 Z M 6,18 c 0.554,0 1,0.446 1,1 0,0.554 -0.446,1 -1,1 -0.554,0 -1,-0.446 -1,-1 0,-0.554 0.446,-1 1,-1 z m 2.8261719,0 7.3476561,0 C 16.631643,18 17,18.368372 17,18.826172 l 0,0.347656 C 17,19.631628 16.631643,20 16.173828,20 L 8.8261719,20 C 8.3683573,20 8,19.631628 8,19.173828 L 8,18.826172 C 8,18.368372 8.3683573,18 8.8261719,18 Z m 10.1113281,0 0.125,0 C 19.581551,18 20,18.4184 20,18.9375 l 0,0.125 C 20,19.5816 19.581551,20 19.0625,20 l -0.125,0 C 18.418449,20 18,19.5816 18,19.0625 l 0,-0.125 C 18,18.4184 18.418449,18 18.9375,18 Z"
transform="translate(0,1027.3622)"
id="rect4160"
inkscape:connector-curvature="0"
sodipodi:nodetypes="sccssccsssssccssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss" />
<path
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#ffffff;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:round;stroke-opacity:1"
d="m 12.499929,1033.8622 -2,2 1.500071,0 0,2 1,0 0,-2 1.499929,0 z"
id="path4150"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccc" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.3 KiB

View File

@ -1,87 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="power.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="9.3159849"
inkscape:cy="13.436208"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="true"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true"
inkscape:snap-global="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<path
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="M 9 6.8183594 C 6.3418164 8.1213032 4.5 10.849161 4.5 14 C 4.5 18.4065 8.0935666 22 12.5 22 C 16.906433 22 20.5 18.4065 20.5 14 C 20.5 10.849161 18.658184 8.1213032 16 6.8183594 L 16 9.125 C 17.514327 10.211757 18.5 11.984508 18.5 14 C 18.5 17.3256 15.825553 20 12.5 20 C 9.1744469 20 6.5 17.3256 6.5 14 C 6.5 11.984508 7.4856727 10.211757 9 9.125 L 9 6.8183594 z "
transform="translate(0,1027.3622)"
id="path6140" />
<path
style="fill:none;fill-rule:evenodd;stroke:#ffffff;stroke-width:3;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 12.5,1031.8836 0,6.4786"
id="path6142"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -1,76 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="settings.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="22.627417"
inkscape:cx="14.69683"
inkscape:cy="8.8039511"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="true"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="false"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<path
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="M 11 3 L 11 5.1601562 A 7.5 7.5 0 0 0 8.3671875 6.2460938 L 6.84375 4.7226562 L 4.7226562 6.84375 L 6.2480469 8.3691406 A 7.5 7.5 0 0 0 5.1523438 11 L 3 11 L 3 14 L 5.1601562 14 A 7.5 7.5 0 0 0 6.2460938 16.632812 L 4.7226562 18.15625 L 6.84375 20.277344 L 8.3691406 18.751953 A 7.5 7.5 0 0 0 11 19.847656 L 11 22 L 14 22 L 14 19.839844 A 7.5 7.5 0 0 0 16.632812 18.753906 L 18.15625 20.277344 L 20.277344 18.15625 L 18.751953 16.630859 A 7.5 7.5 0 0 0 19.847656 14 L 22 14 L 22 11 L 19.839844 11 A 7.5 7.5 0 0 0 18.753906 8.3671875 L 20.277344 6.84375 L 18.15625 4.7226562 L 16.630859 6.2480469 A 7.5 7.5 0 0 0 14 5.1523438 L 14 3 L 11 3 z M 12.5 10 A 2.5 2.5 0 0 1 15 12.5 A 2.5 2.5 0 0 1 12.5 15 A 2.5 2.5 0 0 1 10 12.5 A 2.5 2.5 0 0 1 12.5 10 z "
transform="translate(0,1027.3622)"
id="rect4967" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -1,86 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="tab.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="16"
inkscape:cx="11.67335"
inkscape:cy="17.881696"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="true"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true"
inkscape:snap-global="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<path
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
d="m 3,1031.3622 0,8 2,0 0,-4 0,-4 -2,0 z m 2,4 4,4 0,-3 13,0 0,-2 -13,0 0,-3 -4,4 z"
id="rect5194"
inkscape:connector-curvature="0" />
<path
id="path5211"
d="m 22,1048.3622 0,-8 -2,0 0,4 0,4 2,0 z m -2,-4 -4,-4 0,3 -13,0 0,2 13,0 0,3 4,-4 z"
style="opacity:1;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1"
inkscape:connector-curvature="0" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -1,90 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="extrakeys.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="15.234555"
inkscape:cy="9.9710826"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="false"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="false">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<path
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="m 8,1031.3622 c -2.1987124,0 -4,1.8013 -4,4 l 0,8.9996 c 0,2.1987 1.8012876,4 4,4 l 9,0 c 2.198712,0 4,-1.8013 4,-4 l 0,-8.9996 c 0,-2.1987 -1.801288,-4 -4,-4 z m 0,2 9,0 c 1.125307,0 2,0.8747 2,2 l 0,7.0005 c 0,1.1253 -0.874693,2 -2,2 l -9,0 c -1.1253069,0 -2,-0.8747 -2,-2 l 0,-7.0005 c 0,-1.1253 0.8746931,-2 2,-2 z"
id="rect5006"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ssssssssssssssssss" />
<g
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:10px;line-height:125%;font-family:'DejaVu Sans';-inkscape-font-specification:'Sans Bold';text-align:start;letter-spacing:0px;word-spacing:0px;writing-mode:lr-tb;text-anchor:start;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
id="text4167"
transform="matrix(0.96021948,0,0,0.96021948,0.18921715,41.80659)">
<path
d="m 14.292969,1040.6791 -2.939453,0 -0.463868,1.3281 -1.889648,0 2.700195,-7.29 2.241211,0 2.700196,7.29 -1.889649,0 -0.458984,-1.3281 z m -2.470703,-1.3526 1.99707,0 -0.996094,-2.9004 -1.000976,2.9004 z"
id="path4172"
inkscape:connector-curvature="0" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -1,81 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="25"
height="25"
viewBox="0 0 25 25"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
sodipodi:docname="warning.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:export-xdpi="90"
inkscape:export-ydpi="90">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="16.457343"
inkscape:cy="12.179552"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:object-paths="true"
showguides="false"
inkscape:window-width="1920"
inkscape:window-height="1136"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:snap-smooth-nodes="true"
inkscape:object-nodes="true"
inkscape:snap-intersection-paths="true"
inkscape:snap-nodes="true"
inkscape:snap-global="true">
<inkscape:grid
type="xygrid"
id="grid4136" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-1027.3622)">
<path
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;direction:ltr;block-progression:tb;writing-mode:lr-tb;baseline-shift:baseline;text-anchor:start;white-space:normal;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:4;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
d="M 12.513672 3.0019531 C 11.751609 2.9919531 11.052563 3.4242687 10.710938 4.1054688 L 3.2109375 19.105469 C 2.5461937 20.435369 3.5132277 21.9999 5 22 L 20 22 C 21.486772 21.9999 22.453806 20.435369 21.789062 19.105469 L 14.289062 4.1054688 C 13.951849 3.4330688 13.265888 3.0066531 12.513672 3.0019531 z M 12.478516 6.9804688 A 1.50015 1.50015 0 0 1 14 8.5 L 14 14.5 A 1.50015 1.50015 0 1 1 11 14.5 L 11 8.5 A 1.50015 1.50015 0 0 1 12.478516 6.9804688 z M 12.5 17 A 1.5 1.5 0 0 1 14 18.5 A 1.5 1.5 0 0 1 12.5 20 A 1.5 1.5 0 0 1 11 18.5 A 1.5 1.5 0 0 1 12.5 17 z "
transform="translate(0,1027.3622)"
id="path4208" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -1,65 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="svg2"
inkscape:export-ydpi="90"
inkscape:export-xdpi="90"
sodipodi:docname="windows.svg"
inkscape:export-filename="/home/ossman/devel/noVNC/images/drag.png"
inkscape:version="0.92.4 (unknown)"
x="0px"
y="0px"
viewBox="-293 384 25 25"
xml:space="preserve"
width="25"
height="25"><metadata
id="metadata21"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
id="defs19" /><sodipodi:namedview
pagecolor="#959595"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1136"
id="namedview17"
showgrid="true"
inkscape:pagecheckerboard="false"
inkscape:zoom="32"
inkscape:cx="3.926913"
inkscape:cy="13.255959"
inkscape:window-x="1920"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="svg2"><inkscape:grid
type="xygrid"
id="grid818" /></sodipodi:namedview>
<style
type="text/css"
id="style2">
.st0{fill:#FFFFFF;}
</style>
<path
style="fill:#ffffff;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;fill-opacity:1"
d="M 21 4 L 11 5.1757812 L 11 12 L 21 12 L 21 4 z M 10 5.2949219 L 4 6 L 4 12 L 10 12 L 10 5.2949219 z "
transform="translate(-293,384)"
id="path853" /><path
id="path858"
d="m -272,405 -10,-1.17578 V 397 h 10 z M -283,403.70508 -289,403 v -6 h 6 z"
style="fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
inkscape:connector-curvature="0" /></svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -1 +0,0 @@
DO NOT MODIFY THE FILES IN THIS FOLDER, THEY ARE AUTOMATICALLY GENERATED FROM THE PO-FILES.

View File

@ -1,71 +0,0 @@
{
"Connecting...": "Připojení...",
"Disconnecting...": "Odpojení...",
"Reconnecting...": "Obnova připojení...",
"Internal error": "Vnitřní chyba",
"Must set host": "Hostitel musí být nastavení",
"Connected (encrypted) to ": "Připojení (šifrované) k ",
"Connected (unencrypted) to ": "Připojení (nešifrované) k ",
"Something went wrong, connection is closed": "Něco se pokazilo, odpojeno",
"Failed to connect to server": "Chyba připojení k serveru",
"Disconnected": "Odpojeno",
"New connection has been rejected with reason: ": "Nové připojení bylo odmítnuto s odůvodněním: ",
"New connection has been rejected": "Nové připojení bylo odmítnuto",
"Password is required": "Je vyžadováno heslo",
"noVNC encountered an error:": "noVNC narazilo na chybu:",
"Hide/Show the control bar": "Skrýt/zobrazit ovládací panel",
"Move/Drag Viewport": "Přesunout/přetáhnout výřez",
"viewport drag": "přesun výřezu",
"Active Mouse Button": "Aktivní tlačítka myši",
"No mousebutton": "Žádné",
"Left mousebutton": "Levé tlačítko myši",
"Middle mousebutton": "Prostřední tlačítko myši",
"Right mousebutton": "Pravé tlačítko myši",
"Keyboard": "Klávesnice",
"Show Keyboard": "Zobrazit klávesnici",
"Extra keys": "Extra klávesy",
"Show Extra Keys": "Zobrazit extra klávesy",
"Ctrl": "Ctrl",
"Toggle Ctrl": "Přepnout Ctrl",
"Alt": "Alt",
"Toggle Alt": "Přepnout Alt",
"Send Tab": "Odeslat tabulátor",
"Tab": "Tab",
"Esc": "Esc",
"Send Escape": "Odeslat Esc",
"Ctrl+Alt+Del": "Ctrl+Alt+Del",
"Send Ctrl-Alt-Del": "Poslat Ctrl-Alt-Del",
"Shutdown/Reboot": "Vypnutí/Restart",
"Shutdown/Reboot...": "Vypnutí/Restart...",
"Power": "Napájení",
"Shutdown": "Vypnout",
"Reboot": "Restart",
"Reset": "Reset",
"Clipboard": "Schránka",
"Clear": "Vymazat",
"Fullscreen": "Celá obrazovka",
"Settings": "Nastavení",
"Shared Mode": "Sdílený režim",
"View Only": "Pouze prohlížení",
"Clip to Window": "Přizpůsobit oknu",
"Scaling Mode:": "Přizpůsobení velikosti",
"None": "Žádné",
"Local Scaling": "Místní",
"Remote Resizing": "Vzdálené",
"Advanced": "Pokročilé",
"Repeater ID:": "ID opakovače",
"WebSocket": "WebSocket",
"Encrypt": "Šifrování:",
"Host:": "Hostitel:",
"Port:": "Port:",
"Path:": "Cesta",
"Automatic Reconnect": "Automatická obnova připojení",
"Reconnect Delay (ms):": "Zpoždění připojení (ms)",
"Show Dot when No Cursor": "Tečka místo chybějícího kurzoru myši",
"Logging:": "Logování:",
"Disconnect": "Odpojit",
"Connect": "Připojit",
"Password:": "Heslo",
"Send Password": "Odeslat heslo",
"Cancel": "Zrušit"
}

View File

@ -1,69 +0,0 @@
{
"Connecting...": "Verbinden...",
"Disconnecting...": "Verbindung trennen...",
"Reconnecting...": "Verbindung wiederherstellen...",
"Internal error": "Interner Fehler",
"Must set host": "Richten Sie den Server ein",
"Connected (encrypted) to ": "Verbunden mit (verschlüsselt) ",
"Connected (unencrypted) to ": "Verbunden mit (unverschlüsselt) ",
"Something went wrong, connection is closed": "Etwas lief schief, Verbindung wurde getrennt",
"Disconnected": "Verbindung zum Server getrennt",
"New connection has been rejected with reason: ": "Verbindung wurde aus folgendem Grund abgelehnt: ",
"New connection has been rejected": "Verbindung wurde abgelehnt",
"Password is required": "Passwort ist erforderlich",
"noVNC encountered an error:": "Ein Fehler ist aufgetreten:",
"Hide/Show the control bar": "Kontrollleiste verstecken/anzeigen",
"Move/Drag Viewport": "Ansichtsfenster verschieben/ziehen",
"viewport drag": "Ansichtsfenster ziehen",
"Active Mouse Button": "Aktive Maustaste",
"No mousebutton": "Keine Maustaste",
"Left mousebutton": "Linke Maustaste",
"Middle mousebutton": "Mittlere Maustaste",
"Right mousebutton": "Rechte Maustaste",
"Keyboard": "Tastatur",
"Show Keyboard": "Tastatur anzeigen",
"Extra keys": "Zusatztasten",
"Show Extra Keys": "Zusatztasten anzeigen",
"Ctrl": "Strg",
"Toggle Ctrl": "Strg umschalten",
"Alt": "Alt",
"Toggle Alt": "Alt umschalten",
"Send Tab": "Tab senden",
"Tab": "Tab",
"Esc": "Esc",
"Send Escape": "Escape senden",
"Ctrl+Alt+Del": "Strg+Alt+Entf",
"Send Ctrl-Alt-Del": "Strg+Alt+Entf senden",
"Shutdown/Reboot": "Herunterfahren/Neustarten",
"Shutdown/Reboot...": "Herunterfahren/Neustarten...",
"Power": "Energie",
"Shutdown": "Herunterfahren",
"Reboot": "Neustarten",
"Reset": "Zurücksetzen",
"Clipboard": "Zwischenablage",
"Clear": "Löschen",
"Fullscreen": "Vollbild",
"Settings": "Einstellungen",
"Shared Mode": "Geteilter Modus",
"View Only": "Nur betrachten",
"Clip to Window": "Auf Fenster begrenzen",
"Scaling Mode:": "Skalierungsmodus:",
"None": "Keiner",
"Local Scaling": "Lokales skalieren",
"Remote Resizing": "Serverseitiges skalieren",
"Advanced": "Erweitert",
"Repeater ID:": "Repeater ID:",
"WebSocket": "WebSocket",
"Encrypt": "Verschlüsselt",
"Host:": "Server:",
"Port:": "Port:",
"Path:": "Pfad:",
"Automatic Reconnect": "Automatisch wiederverbinden",
"Reconnect Delay (ms):": "Wiederverbindungsverzögerung (ms):",
"Logging:": "Protokollierung:",
"Disconnect": "Verbindung trennen",
"Connect": "Verbinden",
"Password:": "Passwort:",
"Cancel": "Abbrechen",
"Canvas not supported.": "Canvas nicht unterstützt."
}

Some files were not shown because too many files have changed in this diff Show More