Compare commits
47 Commits
main
...
feature/wo
| Author | SHA1 | Date | |
|---|---|---|---|
| 7aa08682b8 | |||
| be3795c7bf | |||
| c68ed59946 | |||
| a9aa31cc4a | |||
| b4ce883546 | |||
| 4123babcea | |||
| fb00b0a5fd | |||
| ac620a0e15 | |||
| 1dc09101cc | |||
| 0dda1423f8 | |||
| 895b7a8ee7 | |||
| 2cfe37fd53 | |||
| 6116f3fd15 | |||
| e93f96abe4 | |||
| 35ecd4748e | |||
| c37cf023c1 | |||
| 8dbf35d955 | |||
| f3965a67fd | |||
| 56b72629f9 | |||
| e71cdb2cda | |||
| fd8c5cbb81 | |||
| 24cf84fda3 | |||
| 45c952258b | |||
| 11f6ff16d0 | |||
| 1f0afd4cae | |||
| 5d89277e5c | |||
| bd33604982 | |||
| 176c6bb1c2 | |||
| b7c621f294 | |||
| 09cdb088af | |||
| ea11f92070 | |||
| f69ee95443 | |||
| 591173d1cf | |||
| 7e32632186 | |||
| 522d39d3df | |||
| 8bd6b1f400 | |||
| 53c1f45e02 | |||
| 1ea11a6a3c | |||
| ca61a89739 | |||
| 9155bf5479 | |||
| 92091bfe88 | |||
| 247b9dbee7 | |||
| c3963fc26c | |||
| 6e8a7b81fb | |||
| 3c6723bcde | |||
| 76e727fb99 | |||
| 52cc779bca |
@ -2,7 +2,7 @@
|
||||
HEADSCALE_API_KEY=your_api_key_here
|
||||
|
||||
# Server configuration
|
||||
SERVER_URL=http://localhost:8080
|
||||
SERVER_URL=http://localhost:8070
|
||||
LISTEN_ADDR=0.0.0.0:8080
|
||||
|
||||
# Database (SQLite by default)
|
||||
|
||||
99
.gitignore
vendored
@ -61,4 +61,101 @@ tmp/
|
||||
temp/
|
||||
|
||||
# Docker Compose override files
|
||||
docker-compose.override.yml
|
||||
docker-compose.override.yml
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
.venv/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
Pipfile.lock
|
||||
|
||||
# PEP 582
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
301
CLIENT_CONNECTION_TEST.md
Normal file
@ -0,0 +1,301 @@
|
||||
# 🔗 Tailscale 클라이언트 연결 및 테스트 가이드
|
||||
|
||||
## 📋 테스트 개요
|
||||
- **목적**: Headscale 서버에 Tailscale 클라이언트 연결 및 VPN 기능 검증
|
||||
- **환경**: Ubuntu 24.04 LTS, Tailscale 1.86.2
|
||||
- **서버**: Headscale (http://localhost:8070)
|
||||
|
||||
## 🛠️ 사전 준비사항
|
||||
- Headscale 서버가 정상 작동 중 (8070 포트)
|
||||
- 사용자 및 Pre-auth 키 생성 완료
|
||||
- 테스트할 클라이언트 장치 준비
|
||||
|
||||
## 📊 기본 정보 확인
|
||||
|
||||
### Headscale 서버 상태
|
||||
```bash
|
||||
# API 헬스 체크
|
||||
curl -s http://localhost:8070/health
|
||||
# 응답: {"status":"pass"}
|
||||
|
||||
# 컨테이너 상태 확인
|
||||
docker-compose ps
|
||||
# STATUS: Up (healthy 또는 running)
|
||||
```
|
||||
|
||||
### 사용자 및 키 정보
|
||||
```bash
|
||||
# 사용자 목록
|
||||
docker-compose exec headscale headscale users list
|
||||
# 결과: myuser (ID: 1)
|
||||
|
||||
# Pre-auth 키 확인
|
||||
echo "Pre-auth Key: fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21"
|
||||
```
|
||||
|
||||
## 🚀 Tailscale 클라이언트 설치
|
||||
|
||||
### Ubuntu/Debian 설치
|
||||
```bash
|
||||
# 공식 설치 스크립트 사용
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
|
||||
# 설치 확인
|
||||
tailscale version
|
||||
# 결과: 1.86.2
|
||||
```
|
||||
|
||||
### 설치 후 서비스 상태 확인
|
||||
```bash
|
||||
# Tailscale 데몬 상태 확인
|
||||
sudo systemctl status tailscaled
|
||||
# Active: active (running)
|
||||
|
||||
# Tailscale 명령어 확인
|
||||
which tailscale
|
||||
# /usr/bin/tailscale
|
||||
```
|
||||
|
||||
## 🔗 Headscale 서버 연결
|
||||
|
||||
### 연결 명령어 실행
|
||||
```bash
|
||||
# Pre-auth 키를 사용한 자동 연결
|
||||
tailscale up --login-server=http://localhost:8070 --authkey=fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21
|
||||
```
|
||||
|
||||
### 연결 성공 확인
|
||||
```bash
|
||||
# 연결 상태 확인
|
||||
tailscale status
|
||||
```
|
||||
|
||||
**성공적인 출력 예시:**
|
||||
```
|
||||
100.64.0.1 0bin-ubuntu-vm myuser linux -
|
||||
```
|
||||
|
||||
## 📡 네트워크 인터페이스 확인
|
||||
|
||||
### Tailscale 인터페이스 생성 확인
|
||||
```bash
|
||||
# tailscale0 인터페이스 확인
|
||||
ip addr show tailscale0
|
||||
```
|
||||
|
||||
**출력 결과:**
|
||||
```
|
||||
214: tailscale0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 1280 qdisc pfifo_fast state UNKNOWN group default qlen 500
|
||||
link/none
|
||||
inet 100.64.0.1/32 scope global tailscale0
|
||||
valid_lft forever preferred_lft forever
|
||||
inet6 fd7a:115c:a1e0::1/128 scope global
|
||||
valid_lft forever preferred_lft forever
|
||||
inet6 fe80::a49:8d96:4244:2fcf/64 scope link stable-privacy
|
||||
valid_lft forever preferred_lft forever
|
||||
```
|
||||
|
||||
### IP 주소 할당 확인
|
||||
- **IPv4**: `100.64.0.1/32`
|
||||
- **IPv6**: `fd7a:115c:a1e0::1/128`
|
||||
- **링크로컬**: `fe80::a49:8d96:4244:2fcf/64`
|
||||
|
||||
## 🌐 Headscale 서버에서 노드 확인
|
||||
|
||||
### 연결된 노드 목록 확인
|
||||
```bash
|
||||
docker-compose exec headscale headscale nodes list
|
||||
```
|
||||
|
||||
**출력 결과:**
|
||||
```
|
||||
ID | Hostname | Name | MachineKey | NodeKey | User | IP addresses | Ephemeral | Last seen | Expiration | Connected | Expired
|
||||
1 | 0bin-Ubuntu-VM | 0bin-ubuntu-vm| [rzOhs] | [SbpbT] | myuser | 100.64.0.1, fd7a:115c:a1e0::1| false | 2025-09-09 05:42:25 | N/A | online | no
|
||||
```
|
||||
|
||||
### 노드 세부 정보
|
||||
- **ID**: 1
|
||||
- **호스트명**: 0bin-Ubuntu-VM
|
||||
- **노드명**: 0bin-ubuntu-vm
|
||||
- **사용자**: myuser
|
||||
- **IP 주소**: 100.64.0.1 (IPv4), fd7a:115c:a1e0::1 (IPv6)
|
||||
- **상태**: online
|
||||
- **임시 노드**: false
|
||||
- **만료**: 없음
|
||||
|
||||
## 🧪 연결 테스트
|
||||
|
||||
### 1. 자기 자신 핑 테스트
|
||||
```bash
|
||||
# IPv4 핑 테스트
|
||||
ping -c 3 100.64.0.1
|
||||
```
|
||||
|
||||
**성공 결과:**
|
||||
```
|
||||
PING 100.64.0.1 (100.64.0.1) 56(84) bytes of data.
|
||||
64 bytes from 100.64.0.1: icmp_seq=1 ttl=64 time=0.032 ms
|
||||
64 bytes from 100.64.0.1: icmp_seq=2 ttl=64 time=0.044 ms
|
||||
64 bytes from 100.64.0.1: icmp_seq=3 ttl=64 time=0.050 ms
|
||||
|
||||
--- 100.64.0.1 ping statistics ---
|
||||
3 packets transmitted, 3 received, 0% packet loss, time 2080ms
|
||||
rtt min/avg/max/mdev = 0.032/0.042/0.050/0.007 ms
|
||||
```
|
||||
|
||||
### 2. IPv6 핑 테스트
|
||||
```bash
|
||||
# IPv6 핑 테스트
|
||||
ping6 -c 3 fd7a:115c:a1e0::1
|
||||
```
|
||||
|
||||
### 3. DNS 확인 (Magic DNS)
|
||||
```bash
|
||||
# Magic DNS 테스트 (설정된 경우)
|
||||
nslookup 0bin-ubuntu-vm.headscale.local
|
||||
```
|
||||
|
||||
## 📋 추가 클라이언트 연결 방법
|
||||
|
||||
### 다른 장치에서 연결하기
|
||||
|
||||
#### Windows
|
||||
```cmd
|
||||
# PowerShell 또는 Command Prompt에서
|
||||
tailscale up --login-server=http://YOUR_SERVER_IP:8070 --authkey=fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21
|
||||
```
|
||||
|
||||
#### macOS
|
||||
```bash
|
||||
# Terminal에서
|
||||
sudo tailscale up --login-server=http://YOUR_SERVER_IP:8070 --authkey=fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21
|
||||
```
|
||||
|
||||
#### 다른 Linux 장치
|
||||
```bash
|
||||
# 동일한 명령어 사용
|
||||
tailscale up --login-server=http://YOUR_SERVER_IP:8070 --authkey=fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21
|
||||
```
|
||||
|
||||
### 새로운 Pre-auth 키 생성 (필요시)
|
||||
```bash
|
||||
# 새로운 24시간 유효 키 생성
|
||||
docker-compose exec headscale headscale preauthkeys create --user 1 --reusable --expiration 24h
|
||||
```
|
||||
|
||||
## 🔍 모니터링 및 관리
|
||||
|
||||
### 실시간 연결 상태 모니터링
|
||||
```bash
|
||||
# 실시간 로그 확인
|
||||
docker-compose logs -f headscale
|
||||
|
||||
# Tailscale 상태 지속 확인
|
||||
watch -n 5 'tailscale status'
|
||||
```
|
||||
|
||||
### 네트워크 트래픽 모니터링
|
||||
```bash
|
||||
# tailscale0 인터페이스 트래픽 확인
|
||||
iftop -i tailscale0
|
||||
|
||||
# 또는 간단한 통계
|
||||
ip -s link show tailscale0
|
||||
```
|
||||
|
||||
## 🚨 문제 해결
|
||||
|
||||
### 연결 실패 시 체크리스트
|
||||
|
||||
#### 1. Headscale 서버 상태 확인
|
||||
```bash
|
||||
curl -f http://localhost:8070/health || echo "Headscale not responding"
|
||||
```
|
||||
|
||||
#### 2. 방화벽 설정 확인
|
||||
```bash
|
||||
# 8070 포트 오픈 확인
|
||||
sudo ufw status | grep 8070
|
||||
|
||||
# 필요시 포트 개방
|
||||
sudo ufw allow 8070
|
||||
```
|
||||
|
||||
#### 3. Pre-auth 키 유효성 확인
|
||||
```bash
|
||||
# 키 목록 확인
|
||||
docker-compose exec headscale headscale preauthkeys list
|
||||
```
|
||||
|
||||
#### 4. Tailscale 서비스 재시작
|
||||
```bash
|
||||
sudo systemctl restart tailscaled
|
||||
```
|
||||
|
||||
### 연결 해제 및 재연결
|
||||
```bash
|
||||
# 연결 해제
|
||||
tailscale down
|
||||
|
||||
# 재연결
|
||||
tailscale up --login-server=http://localhost:8070 --authkey=fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21
|
||||
```
|
||||
|
||||
## 📊 성능 테스트
|
||||
|
||||
### 대역폭 테스트 (2개 이상 클라이언트 연결 시)
|
||||
```bash
|
||||
# iperf3 설치
|
||||
sudo apt install iperf3
|
||||
|
||||
# 서버 모드 (첫 번째 클라이언트)
|
||||
iperf3 -s
|
||||
|
||||
# 클라이언트 모드 (두 번째 클라이언트)
|
||||
iperf3 -c 100.64.0.1
|
||||
```
|
||||
|
||||
### 지연시간 테스트
|
||||
```bash
|
||||
# 지속적인 핑 테스트
|
||||
ping -i 0.1 100.64.0.1
|
||||
```
|
||||
|
||||
## 🎯 테스트 결과 요약
|
||||
|
||||
### ✅ 성공적으로 확인된 기능
|
||||
1. **클라이언트 설치**: Tailscale 1.86.2 설치 완료
|
||||
2. **서버 연결**: Pre-auth 키를 통한 자동 인증 성공
|
||||
3. **IP 할당**: IPv4(100.64.0.1), IPv6(fd7a:115c:a1e0::1) 정상 할당
|
||||
4. **네트워크 통신**: 핑 테스트 성공 (0% 패킷 손실)
|
||||
5. **인터페이스 생성**: tailscale0 인터페이스 정상 생성
|
||||
6. **서버 인식**: Headscale에서 노드 정상 인식
|
||||
|
||||
### 📈 네트워크 성능
|
||||
- **핑 지연시간**: 평균 0.042ms (로컬)
|
||||
- **패킷 손실**: 0%
|
||||
- **MTU**: 1280 bytes
|
||||
- **상태**: UNKNOWN (정상 동작)
|
||||
|
||||
### 🔒 보안 확인사항
|
||||
- **암호화**: WireGuard 프로토콜 사용
|
||||
- **인증**: Pre-auth 키 기반 자동 인증
|
||||
- **키 관리**: 24시간 만료, 재사용 가능 설정
|
||||
|
||||
## 🚀 결론
|
||||
|
||||
Headscale 서버와 Tailscale 클라이언트 간의 연결이 완벽하게 성공했습니다.
|
||||
|
||||
**주요 성과:**
|
||||
- ✅ VPN 터널 구성 완료
|
||||
- ✅ IP 주소 자동 할당 성공
|
||||
- ✅ 실시간 통신 확인
|
||||
- ✅ Headscale 관리 인터페이스 정상 동작
|
||||
- ✅ Headplane 웹 UI 외부 접속 성공
|
||||
|
||||
### 🌐 완전한 관리 환경 구축
|
||||
- **Headscale API**: http://localhost:8070 (명령줄 관리)
|
||||
- **Headplane UI**: http://192.168.0.151:3000/admin/ (웹 관리)
|
||||
- **로그인 API Key**: `8qRr1IB.tV95CmA0fLaCiGGIgBfeoN9daHceFkzI`
|
||||
|
||||
이제 **Tailscale을 완전히 대체**할 수 있는 자체 호스팅 VPN 솔루션이 구축되었습니다!
|
||||
193
CLIENT_SETUP_GUIDE.md
Normal file
@ -0,0 +1,193 @@
|
||||
# 팜큐(FARMQ) Headscale 클라이언트 설치 가이드
|
||||
|
||||
## 🏥 개요
|
||||
|
||||
팜큐 네트워크에 PC를 연결하기 위한 간편한 설치 가이드입니다.
|
||||
|
||||
## 📋 Pre-auth Key 정보
|
||||
|
||||
### ✅ Pre-auth Key 특징:
|
||||
- **1회 등록**: 한 번 사용하면 해당 머신이 영구적으로 네트워크에 등록됩니다
|
||||
- **자동 재연결**: 재부팅 후에도 자동으로 연결됩니다
|
||||
- **재사용 가능**: 동일한 key로 여러 머신을 등록할 수 있습니다 (설정에 따라)
|
||||
|
||||
### 🔑 현재 유효한 Key:
|
||||
- **myuser**: `fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21` (재사용 가능)
|
||||
- **pharmacy1**: `5c15b41ea8b135dbed42455ad1a9a0cf0352b100defd241c` (7일 유효, 재사용 가능)
|
||||
|
||||
## 🚀 자동 설치 (권장)
|
||||
|
||||
### Linux/Ubuntu 시스템
|
||||
|
||||
1. **스크립트 다운로드**:
|
||||
```bash
|
||||
wget https://head.0bin.in/register-client.sh
|
||||
chmod +x register-client.sh
|
||||
```
|
||||
|
||||
2. **스크립트 실행**:
|
||||
```bash
|
||||
sudo ./register-client.sh
|
||||
```
|
||||
|
||||
### 수동으로 Pre-auth Key 업데이트
|
||||
|
||||
스크립트의 Pre-auth Key를 업데이트하려면:
|
||||
|
||||
```bash
|
||||
# 스크립트 편집
|
||||
nano register-client.sh
|
||||
|
||||
# PREAUTH_KEY 값을 새로운 key로 변경
|
||||
PREAUTH_KEY="새로운키값"
|
||||
```
|
||||
|
||||
## 🔧 수동 설치
|
||||
|
||||
### 1. Tailscale 설치
|
||||
|
||||
#### Ubuntu/Debian:
|
||||
```bash
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
```
|
||||
|
||||
#### CentOS/RHEL:
|
||||
```bash
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
```
|
||||
|
||||
#### macOS:
|
||||
```bash
|
||||
# Homebrew 사용
|
||||
brew install --cask tailscale
|
||||
|
||||
# 또는 직접 다운로드
|
||||
# https://tailscale.com/download/mac
|
||||
```
|
||||
|
||||
#### Windows:
|
||||
1. https://tailscale.com/download/windows 에서 다운로드
|
||||
2. 설치 후 아래 명령어를 관리자 권한 PowerShell에서 실행
|
||||
|
||||
### 2. Headscale 서버에 등록
|
||||
|
||||
#### Linux/macOS:
|
||||
```bash
|
||||
sudo tailscale up \
|
||||
--login-server="https://head.0bin.in" \
|
||||
--authkey="fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21" \
|
||||
--accept-routes \
|
||||
--accept-dns=false
|
||||
```
|
||||
|
||||
#### Windows (PowerShell 관리자 권한):
|
||||
```powershell
|
||||
tailscale up `
|
||||
--login-server="https://head.0bin.in" `
|
||||
--authkey="fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21" `
|
||||
--accept-routes `
|
||||
--accept-dns=false
|
||||
```
|
||||
|
||||
## 📊 연결 확인
|
||||
|
||||
### 연결 상태 확인:
|
||||
```bash
|
||||
tailscale status
|
||||
```
|
||||
|
||||
### IP 주소 확인:
|
||||
```bash
|
||||
tailscale ip -4
|
||||
```
|
||||
|
||||
### 네트워크 테스트:
|
||||
```bash
|
||||
# 다른 팜큐 머신으로 핑 테스트
|
||||
ping 100.64.0.1
|
||||
```
|
||||
|
||||
## 🔑 관리자용 - Pre-auth Key 생성
|
||||
|
||||
### 새로운 약국용 Key 생성:
|
||||
|
||||
1. **스크립트 사용** (권장):
|
||||
```bash
|
||||
./create-preauth-key.sh pharmacy2 30d
|
||||
```
|
||||
|
||||
2. **수동 생성**:
|
||||
```bash
|
||||
# 사용자 생성 (필요시)
|
||||
docker exec headscale headscale users create pharmacy2
|
||||
|
||||
# Pre-auth key 생성
|
||||
docker exec headscale headscale preauthkeys create \
|
||||
-u 2 --expiration 30d --reusable
|
||||
```
|
||||
|
||||
### Key 관리 명령어:
|
||||
|
||||
```bash
|
||||
# 사용자 목록 확인
|
||||
docker exec headscale headscale users list
|
||||
|
||||
# Pre-auth key 목록 확인 (사용자 ID 필요)
|
||||
docker exec headscale headscale preauthkeys list -u 1
|
||||
|
||||
# 만료된 key 삭제
|
||||
docker exec headscale headscale preauthkeys expire -k <key_id>
|
||||
```
|
||||
|
||||
## 🛠️ 문제해결
|
||||
|
||||
### 연결 안됨:
|
||||
1. **방화벽 확인**: 8080, 443 포트가 열려있는지 확인
|
||||
2. **DNS 확인**: `https://head.0bin.in` 접근 가능한지 확인
|
||||
3. **Key 유효성**: Pre-auth key가 만료되지 않았는지 확인
|
||||
|
||||
### 기존 연결 해제:
|
||||
```bash
|
||||
sudo tailscale logout
|
||||
```
|
||||
|
||||
### 완전 재설정:
|
||||
```bash
|
||||
sudo tailscale logout
|
||||
sudo tailscale up --login-server="https://head.0bin.in" --authkey="새로운키"
|
||||
```
|
||||
|
||||
## 📱 모바일 설정
|
||||
|
||||
### Android/iOS:
|
||||
1. Tailscale 앱 설치
|
||||
2. "Use a different server" 선택
|
||||
3. 서버 URL: `https://head.0bin.in`
|
||||
4. Pre-auth key 입력 (위 key 중 하나 사용)
|
||||
|
||||
## 🔐 보안 참고사항
|
||||
|
||||
- Pre-auth key는 민감한 정보입니다. 공유 시 주의하세요
|
||||
- Key가 만료되기 전에 새로운 key를 생성하세요
|
||||
- 불필요한 key는 정기적으로 만료시키세요
|
||||
- 각 약국별로 별도의 사용자와 key를 사용하는 것을 권장합니다
|
||||
|
||||
## 📞 지원
|
||||
|
||||
문제가 발생하면 다음 정보를 포함하여 문의하세요:
|
||||
- 운영체제 정보
|
||||
- `tailscale status` 출력
|
||||
- 에러 메시지
|
||||
- 사용한 Pre-auth key (마지막 8자리만)
|
||||
|
||||
---
|
||||
|
||||
## 📋 요약
|
||||
|
||||
1. **간편 설치**: `register-client.sh` 스크립트 실행
|
||||
2. **수동 설치**: Tailscale 설치 → `tailscale up` 명령어 실행
|
||||
3. **연결 확인**: `tailscale status` 및 `tailscale ip` 확인
|
||||
4. **문제 시**: 재부팅 또는 logout 후 재연결
|
||||
|
||||
**서버**: https://head.0bin.in
|
||||
**기본 Key**: `fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21`
|
||||
351
FARMQ_ADMIN_IMPLEMENTATION_GUIDE.md
Normal file
@ -0,0 +1,351 @@
|
||||
# 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*
|
||||
289
FARMQ_ADMIN_MACHINE_NAME_FIX_PLAN.md
Normal file
@ -0,0 +1,289 @@
|
||||
# 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 머신 이름 표시 문제 분석 및 해결 계획 수립 완료
|
||||
349
FARMQ_HEADPLANE_CUSTOMIZATION_PLAN.md
Normal file
@ -0,0 +1,349 @@
|
||||
# 🏥 팜큐(FARMQ) Headplane 커스터마이징 기획서
|
||||
|
||||
## 📋 프로젝트 개요
|
||||
|
||||
### 회사 현황
|
||||
- **회사명**: 팜큐(FARMQ)
|
||||
- **사업 규모**: 약국 100개소
|
||||
- **인프라**: 각 약국마다 Proxmox 호스트 PC 납품
|
||||
- **관리 대상**: 약국별 VM 환경 원격 관리
|
||||
- **네트워크**: WireGuard 기반 Headscale + Headplane 전환 완료
|
||||
|
||||
### 현재 환경
|
||||
- ✅ **Headscale 서버**: http://192.168.0.151:8070 (정상 운영)
|
||||
- ✅ **Headplane UI**: http://192.168.0.151:3000/admin/ (정상 운영)
|
||||
- ✅ **VPN 네트워크**: 100.64.0.0/10 대역 할당
|
||||
- ✅ **기본 기능**: Machine/User 관리 가능
|
||||
|
||||
### 개선 목표
|
||||
현재 기본 Headplane UI를 **팜큐 전용 약국 관리 시스템**으로 확장
|
||||
|
||||
## 🎯 요구사항 분석
|
||||
|
||||
### 1. 데이터 확장 요구사항
|
||||
|
||||
#### 현재 User 테이블 구조
|
||||
```
|
||||
User | Role | Created At | Last Seen
|
||||
myuser | Unmanaged | 2025.9.9 | Connected
|
||||
```
|
||||
|
||||
#### 확장 요구사항
|
||||
```
|
||||
User | 약국명 | 사업자번호 | Role | Created At | Last Seen
|
||||
myuser | 서울약국 | 123-45-67890 | Unmanaged | 2025.9.9 | Connected
|
||||
```
|
||||
|
||||
#### Machine 정보 확장 요구사항
|
||||
- **현재**: Machine 이름만 관리
|
||||
- **확장 후**:
|
||||
- 약국명 연동
|
||||
- PC 사양 정보 (CPU, RAM, Storage)
|
||||
- Proxmox 호스트 정보
|
||||
- 실시간 하드웨어 모니터링
|
||||
|
||||
### 2. 모니터링 요구사항
|
||||
- **Proxmox API 연동**: CPU 온도, 사용률, VM 상태
|
||||
- **실시간 대시보드**: 약국별 시스템 현황
|
||||
- **알림 시스템**: 장애 발생 시 실시간 알림
|
||||
|
||||
## 🏗️ 아키텍처 설계
|
||||
|
||||
### 시스템 구조
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Headplane UI │───►│ Custom Backend │───►│ Proxmox Hosts │
|
||||
│ (Frontend) │ │ (API Server) │ │ (100개 약국) │
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────────┐ │
|
||||
└─────────────►│ Headscale DB │◄────────────┘
|
||||
│ (Extended) │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
### 데이터베이스 확장 설계
|
||||
|
||||
#### 새로운 테이블: `pharmacy_info`
|
||||
```sql
|
||||
CREATE TABLE pharmacy_info (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id VARCHAR(255) UNIQUE REFERENCES users(name),
|
||||
pharmacy_name VARCHAR(255) NOT NULL,
|
||||
business_number VARCHAR(20),
|
||||
address TEXT,
|
||||
phone VARCHAR(20),
|
||||
manager_name VARCHAR(100),
|
||||
proxmox_host VARCHAR(255),
|
||||
proxmox_api_token TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
#### 새로운 테이블: `machine_specs`
|
||||
```sql
|
||||
CREATE TABLE machine_specs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
machine_id BIGINT REFERENCES machines(id),
|
||||
pharmacy_id INTEGER REFERENCES pharmacy_info(id),
|
||||
cpu_model VARCHAR(255),
|
||||
cpu_cores INTEGER,
|
||||
ram_gb INTEGER,
|
||||
storage_gb INTEGER,
|
||||
gpu_model VARCHAR(255),
|
||||
last_updated TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
#### 새로운 테이블: `monitoring_data`
|
||||
```sql
|
||||
CREATE TABLE monitoring_data (
|
||||
id SERIAL PRIMARY KEY,
|
||||
machine_id BIGINT REFERENCES machines(id),
|
||||
cpu_usage DECIMAL(5,2),
|
||||
memory_usage DECIMAL(5,2),
|
||||
disk_usage DECIMAL(5,2),
|
||||
cpu_temperature INTEGER,
|
||||
network_rx_bytes BIGINT,
|
||||
network_tx_bytes BIGINT,
|
||||
vm_count INTEGER,
|
||||
vm_running INTEGER,
|
||||
collected_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
## 🛠️ 구현 방안
|
||||
|
||||
### 방안 1: Headplane 포크 + 직접 수정 (권장)
|
||||
**장점**: 완전한 커스터마이징 가능
|
||||
**단점**: 업스트림 업데이트 반영 어려움
|
||||
**개발 기간**: 2-3주
|
||||
|
||||
#### 구현 단계:
|
||||
1. **포크 및 개발환경 구성**
|
||||
2. **데이터베이스 스키마 확장**
|
||||
3. **API 엔드포인트 추가**
|
||||
4. **UI 컴포넌트 커스터마이징**
|
||||
5. **Proxmox API 연동 모듈 개발**
|
||||
6. **모니터링 대시보드 구현**
|
||||
|
||||
### 방안 2: 별도 관리 시스템 + Headplane 연동
|
||||
**장점**: 기존 Headplane 유지, 독립적 개발
|
||||
**단점**: 시스템 분리로 복잡도 증가
|
||||
**개발 기간**: 3-4주
|
||||
|
||||
### 방안 3: Headplane 플러그인 시스템 구축
|
||||
**장점**: 모듈러 구조, 확장성 좋음
|
||||
**단점**: 플러그인 시스템 자체 개발 필요
|
||||
**개발 기간**: 4-5주
|
||||
|
||||
## 📋 세부 기능 명세
|
||||
|
||||
### 1. 약국 정보 관리
|
||||
#### 1-1. 약국 등록/수정 화면
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 약국 정보 등록 │
|
||||
├─────────────────────────────────────┤
|
||||
│ 약국명: [서울중앙약국 ] │
|
||||
│ 사업자번호: [123-45-67890 ] │
|
||||
│ 주소: [서울시 강남구... ] │
|
||||
│ 전화번호: [02-1234-5678 ] │
|
||||
│ 담당자: [홍길동 ] │
|
||||
│ Proxmox 호스트: [192.168.1.100 ] │
|
||||
│ API 토큰: [********************] │
|
||||
│ │
|
||||
│ [저장] [취소] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### 1-2. 사용자 테이블 확장
|
||||
```
|
||||
User | 약국명 | 사업자번호 | 전화번호 | Role | Last Seen
|
||||
pharmacy1| 서울중앙약국 | 123-45-67890 | 02-1234-5678 | Active | 5분 전
|
||||
pharmacy2| 부산해운약국 | 987-65-43210 | 051-9876-5432| Active | 1시간 전
|
||||
```
|
||||
|
||||
### 2. 머신 정보 확장
|
||||
#### 2-1. 머신 상세 정보 화면
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 머신 상세 정보: pharmacy1-main │
|
||||
├─────────────────────────────────────┤
|
||||
│ 약국명: 서울중앙약국 │
|
||||
│ IP 주소: 100.64.0.15 │
|
||||
│ 마지막 접속: 2분 전 │
|
||||
│ │
|
||||
│ 하드웨어 정보: │
|
||||
│ CPU: Intel i7-12700 (12코어) │
|
||||
│ RAM: 32GB │
|
||||
│ Storage: 1TB NVMe SSD │
|
||||
│ │
|
||||
│ 실시간 모니터링: │
|
||||
│ CPU 사용률: ████████░░ 80% │
|
||||
│ CPU 온도: 65°C │
|
||||
│ 메모리 사용률: ██████░░░░ 60% │
|
||||
│ 디스크 사용률: ███░░░░░░░ 30% │
|
||||
│ │
|
||||
│ VM 상태: │
|
||||
│ 총 VM: 5개 | 실행중: 4개 | 정지: 1개│
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3. 통합 대시보드
|
||||
#### 3-1. 메인 대시보드 레이아웃
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ 팜큐 약국 관리 시스템 [사용자: admin] │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ 📊 전체 현황 │
|
||||
│ ┌──────────┬──────────┬──────────┬──────────────────────┐ │
|
||||
│ │총 약국 수 │온라인 │오프라인 │평균 CPU 온도 │ │
|
||||
│ │ 100 │ 95 │ 5 │ 62°C │ │
|
||||
│ └──────────┴──────────┴──────────┴──────────────────────┘ │
|
||||
│ │
|
||||
│ 🚨 알림 │
|
||||
│ • 부산해운약국: CPU 온도 85°C (경고) │
|
||||
│ • 대구중앙약국: 디스크 사용률 95% (위험) │
|
||||
│ │
|
||||
│ 📈 약국별 상태 │
|
||||
│ ┌─────────────┬────────┬────────┬────────┬──────────────┐ │
|
||||
│ │약국명 │상태 │CPU온도 │메모리 │마지막 접속 │ │
|
||||
│ ├─────────────┼────────┼────────┼────────┼──────────────┤ │
|
||||
│ │서울중앙약국 │🟢 온라인│ 65°C │ 80% │ 2분 전 │ │
|
||||
│ │부산해운약국 │🟡 경고 │ 85°C │ 60% │ 5분 전 │ │
|
||||
│ │대구중앙약국 │🔴 위험 │ 70°C │ 95% │ 10분 전 │ │
|
||||
│ └─────────────┴────────┴────────┴────────┴──────────────┘ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4. Proxmox API 연동
|
||||
#### 4-1. 데이터 수집 프로세스
|
||||
```python
|
||||
# proxmox_monitor.py
|
||||
class ProxmoxMonitor:
|
||||
def collect_host_info(self, proxmox_host, api_token):
|
||||
"""Proxmox 호스트 정보 수집"""
|
||||
# CPU 온도, 사용률
|
||||
# 메모리 사용률
|
||||
# 디스크 사용률
|
||||
# VM 상태 및 개수
|
||||
# 네트워크 트래픽
|
||||
|
||||
def collect_vm_info(self, proxmox_host, vm_id):
|
||||
"""개별 VM 정보 수집"""
|
||||
# VM 상태 (running, stopped)
|
||||
# 리소스 할당량
|
||||
# 실제 리소스 사용량
|
||||
```
|
||||
|
||||
## 🔧 기술 스택
|
||||
|
||||
### Frontend (Headplane 기반)
|
||||
- **React 19.1.0** + TypeScript
|
||||
- **Tailwind CSS** (스타일링)
|
||||
- **React Router** (라우팅)
|
||||
- **Chart.js** (모니터링 차트)
|
||||
- **Socket.io Client** (실시간 업데이트)
|
||||
|
||||
### Backend (확장 API 서버)
|
||||
- **Node.js** + Express.js 또는 **Python FastAPI**
|
||||
- **PostgreSQL** (확장된 데이터베이스)
|
||||
- **Socket.io** (실시간 통신)
|
||||
- **Proxmox API Client**
|
||||
- **Cron Jobs** (주기적 데이터 수집)
|
||||
|
||||
### 모니터링 스택
|
||||
- **Proxmox API** (호스트 정보)
|
||||
- **Glances API** (시스템 메트릭)
|
||||
- **InfluxDB** (시계열 데이터 저장)
|
||||
- **Redis** (캐싱 및 실시간 데이터)
|
||||
|
||||
## 📅 개발 로드맵
|
||||
|
||||
### Phase 1: 기반 구축 (1주)
|
||||
- [ ] Headplane 포크 및 개발환경 구성
|
||||
- [ ] 데이터베이스 스키마 설계 및 확장
|
||||
- [ ] 기본 약국 정보 CRUD API 구현
|
||||
- [ ] UI 기본 레이아웃 수정
|
||||
|
||||
### Phase 2: 핵심 기능 (2주)
|
||||
- [ ] 사용자 테이블에 약국 정보 연동
|
||||
- [ ] 머신 정보 확장 및 UI 개선
|
||||
- [ ] Proxmox API 연동 모듈 개발
|
||||
- [ ] 기본 모니터링 대시보드 구현
|
||||
|
||||
### Phase 3: 고도화 (1주)
|
||||
- [ ] 실시간 모니터링 구현
|
||||
- [ ] 알림 시스템 구축
|
||||
- [ ] 통합 대시보드 완성
|
||||
- [ ] 성능 최적화 및 테스트
|
||||
|
||||
### Phase 4: 배포 및 운영 (1주)
|
||||
- [ ] 프로덕션 환경 배포
|
||||
- [ ] 100개 약국 데이터 마이그레이션
|
||||
- [ ] 운영 매뉴얼 작성
|
||||
- [ ] 사용자 교육 및 피드백 수집
|
||||
|
||||
## 💰 예상 비용 및 리소스
|
||||
|
||||
### 개발 리소스
|
||||
- **개발 기간**: 4-5주
|
||||
- **개발자**: 풀스택 개발자 1명
|
||||
- **디자이너**: UI/UX 디자이너 0.5명
|
||||
|
||||
### 인프라 비용
|
||||
- **확장 서버**: 추가 서버 인스턴스 (모니터링 데이터 처리)
|
||||
- **데이터베이스**: PostgreSQL + InfluxDB
|
||||
- **스토리지**: 시계열 데이터 저장용
|
||||
|
||||
## 🚀 시작하기
|
||||
|
||||
### 1단계: 요구사항 확정
|
||||
- [ ] 약국 정보 필드 최종 확정
|
||||
- [ ] 모니터링 지표 우선순위 결정
|
||||
- [ ] UI/UX 디자인 컨셉 결정
|
||||
|
||||
### 2단계: 개발환경 구성
|
||||
- [ ] Headplane 포크 및 클론
|
||||
- [ ] 로컬 개발환경 세팅
|
||||
- [ ] 테스트용 Proxmox 호스트 준비
|
||||
|
||||
### 3단계: 프로토타입 개발
|
||||
- [ ] 약국 정보 등록 화면 구현
|
||||
- [ ] 기본 모니터링 기능 구현
|
||||
- [ ] 초기 버전 데모
|
||||
|
||||
## 📊 성공 지표
|
||||
|
||||
### 기능적 지표
|
||||
- [ ] 100개 약국 정보 완전 등록
|
||||
- [ ] 실시간 모니터링 정확도 95% 이상
|
||||
- [ ] 알림 반응 시간 1분 이내
|
||||
- [ ] 시스템 가용성 99.5% 이상
|
||||
|
||||
### 사용성 지표
|
||||
- [ ] 관리자 업무 효율 50% 향상
|
||||
- [ ] 장애 발견 시간 80% 단축
|
||||
- [ ] 사용자 만족도 4.5/5.0 이상
|
||||
|
||||
## 📝 체크리스트
|
||||
|
||||
### 즉시 검토 필요사항
|
||||
- [ ] 현재 Headscale DB 구조 분석
|
||||
- [ ] Proxmox API 접근 권한 확인
|
||||
- [ ] 약국별 네트워크 접근성 테스트
|
||||
- [ ] 추가 하드웨어 리소스 요구사항 검토
|
||||
|
||||
### 장기적 고려사항
|
||||
- [ ] 확장성: 약국 수 증가 대비
|
||||
- [ ] 보안: 약국 데이터 보호
|
||||
- [ ] 백업: 중요 데이터 백업 전략
|
||||
- [ ] 업데이트: 원활한 시스템 업데이트 방안
|
||||
|
||||
---
|
||||
**📅 작성일**: 2025-09-09
|
||||
**👤 작성자**: Claude Code Assistant
|
||||
**🏢 대상**: 팜큐(FARMQ) - 약국 IT 인프라 관리 시스템
|
||||
508
FLASK_ADMIN_DEVELOPMENT_PLAN.md
Normal file
@ -0,0 +1,508 @@
|
||||
# 🌐 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 기반 관리자 시스템
|
||||
320
HEADPLANE_KOREAN_LOCALIZATION_PLAN.md
Normal file
@ -0,0 +1,320 @@
|
||||
# 📋 Headplane UI 한글화 계획서
|
||||
|
||||
## 🔍 현재 상황 분석
|
||||
|
||||
### Headplane 국제화 현황
|
||||
- ❌ i18n 라이브러리 미사용 (react-i18next, next-intl 등 없음)
|
||||
- ❌ 모든 UI 텍스트가 컴포넌트에 하드코딩
|
||||
- ❌ 언어 설정 옵션 없음
|
||||
- ❌ GitHub에 다국어 지원 요청이나 논의 없음
|
||||
|
||||
### 기술 스택
|
||||
- ✅ React 19.1.0 + TypeScript
|
||||
- ✅ Vite 빌드 시스템
|
||||
- ✅ Docker 멀티스테이지 빌드
|
||||
- ✅ pnpm 패키지 매니저
|
||||
|
||||
### 현재 접속 정보
|
||||
- **로컬**: http://localhost:3000/admin/
|
||||
- **외부**: http://192.168.0.151:3000/admin/
|
||||
- **API Key**: `8qRr1IB.tV95CmA0fLaCiGGIgBfeoN9daHceFkzI`
|
||||
|
||||
## 🎯 한글화 목표
|
||||
|
||||
### 우선순위 1: 핵심 UI 요소
|
||||
- [ ] 상단 헤더 "Headplane" → "헤드플레인"
|
||||
- [ ] 네비게이션 메뉴 (Machines → 장치 관리, Users → 사용자 관리, Settings → 설정 등)
|
||||
- [ ] 로그인 페이지 텍스트 (API Key → API 키, Sign In → 로그인)
|
||||
- [ ] 메인 대시보드 라벨들
|
||||
|
||||
### 우선순위 2: 상세 페이지
|
||||
- [ ] 테이블 헤더 (Name → 이름, IP Address → IP 주소, Status → 상태 등)
|
||||
- [ ] 버튼 텍스트 (Create → 생성, Delete → 삭제, Edit → 편집 등)
|
||||
- [ ] 폼 라벨 및 플레이스홀더
|
||||
|
||||
### 우선순위 3: 메시지 및 알림
|
||||
- [ ] 에러 메시지
|
||||
- [ ] 성공/확인 메시지
|
||||
- [ ] 도움말 텍스트
|
||||
|
||||
## 🛠️ 구현 방안별 비교
|
||||
|
||||
### 방안 1: 환경 변수 기반 (권장 - 단기)
|
||||
**난이도**: ⭐⭐
|
||||
**소요 시간**: 2-3시간
|
||||
**장점**:
|
||||
- 빠른 구현 가능
|
||||
- 기존 코드 최소 변경
|
||||
- Docker 환경변수로 쉽게 제어
|
||||
- 현재 환경에서 즉시 적용 가능
|
||||
|
||||
**단점**:
|
||||
- 제한적인 유연성
|
||||
- 텍스트별 개별 환경변수 필요
|
||||
|
||||
**구현 방법**:
|
||||
```typescript
|
||||
// app/config/texts.ts 생성
|
||||
export const UI_TEXTS = {
|
||||
app_title: process.env.HEADPLANE_TITLE || 'Headplane',
|
||||
nav: {
|
||||
machines: process.env.HEADPLANE_NAV_MACHINES || 'Machines',
|
||||
users: process.env.HEADPLANE_NAV_USERS || 'Users',
|
||||
settings: process.env.HEADPLANE_NAV_SETTINGS || 'Settings'
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 방안 2: react-i18next 도입 (권장 - 장기)
|
||||
**난이도**: ⭐⭐⭐⭐
|
||||
**소요 시간**: 1-2일
|
||||
**장점**:
|
||||
- 완전한 국제화 지원
|
||||
- 언어 전환 기능
|
||||
- 표준적인 접근 방식
|
||||
- 향후 확장성 좋음
|
||||
|
||||
**단점**:
|
||||
- 모든 컴포넌트 수정 필요
|
||||
- 라이브러리 의존성 추가
|
||||
- 상당한 코드 변경 필요
|
||||
|
||||
**구현 구조**:
|
||||
```
|
||||
public/locales/
|
||||
├── en/
|
||||
│ └── translation.json
|
||||
└── ko/
|
||||
└── translation.json
|
||||
```
|
||||
|
||||
### 방안 3: 브라우저 확장프로그램 (즉시 적용)
|
||||
**난이도**: ⭐
|
||||
**소요 시간**: 30분
|
||||
**장점**:
|
||||
- 즉시 적용 가능
|
||||
- 코드 수정 불필요
|
||||
- 테스트 목적으로 최적
|
||||
|
||||
**단점**:
|
||||
- 개인 환경에서만 동작
|
||||
- 동적 콘텐츠 한계
|
||||
- 일시적 해결책
|
||||
|
||||
### 방안 4: 포크 후 하드코딩 수정
|
||||
**난이도**: ⭐⭐⭐
|
||||
**소요 시간**: 4-6시간
|
||||
**장점**:
|
||||
- 완전한 제어
|
||||
- 즉시 적용 가능
|
||||
|
||||
**단점**:
|
||||
- 업스트림 업데이트 어려움
|
||||
- 유지보수 부담
|
||||
|
||||
## 📅 단계별 실행 계획
|
||||
|
||||
### Phase 1: 즉시 적용 (오늘)
|
||||
#### 1-1. 브라우저 확장프로그램 제작
|
||||
- [ ] Tampermonkey 스크립트 작성
|
||||
- [ ] 핵심 UI 요소 번역 매핑
|
||||
- [ ] 테스트 및 검증
|
||||
|
||||
#### 1-2. 브라우저 확장 스크립트 예시
|
||||
```javascript
|
||||
// ==UserScript==
|
||||
// @name Headplane 한글화
|
||||
// @match http://192.168.0.151:3000/*
|
||||
// @match http://localhost:3000/*
|
||||
// ==/UserScript==
|
||||
|
||||
const translations = {
|
||||
'Headplane': '헤드플레인',
|
||||
'Machines': '장치 관리',
|
||||
'Users': '사용자 관리',
|
||||
'Settings': '설정',
|
||||
'API Key': 'API 키',
|
||||
'Sign In': '로그인',
|
||||
'Welcome to Headplane': '헤드플레인에 오신 것을 환영합니다',
|
||||
'Enter an API key to authenticate': 'API 키를 입력하여 인증해주세요'
|
||||
};
|
||||
|
||||
function translatePage() {
|
||||
document.querySelectorAll('*').forEach(element => {
|
||||
if (element.children.length === 0 && element.textContent.trim()) {
|
||||
const text = element.textContent.trim();
|
||||
if (translations[text]) {
|
||||
element.textContent = translations[text];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 페이지 로드 시 번역 적용
|
||||
translatePage();
|
||||
|
||||
// 동적 콘텐츠 변경 감지 및 번역 적용
|
||||
new MutationObserver(translatePage).observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
```
|
||||
|
||||
### Phase 2: 임시 해결책 (1-2일 내)
|
||||
#### 2-1. 환경 변수 기반 커스터마이징
|
||||
- [ ] 텍스트 상수 파일 생성 (`app/config/texts.ts`)
|
||||
- [ ] 주요 컴포넌트에 텍스트 상수 적용
|
||||
- [ ] Docker Compose 환경변수 설정
|
||||
- [ ] 커스텀 Docker 이미지 빌드
|
||||
- [ ] 컨테이너 재배포 및 테스트
|
||||
|
||||
#### 2-2. Docker 환경변수 설정 예시
|
||||
```yaml
|
||||
# docker-compose.yml에 추가
|
||||
services:
|
||||
headplane:
|
||||
environment:
|
||||
- TZ=Asia/Seoul
|
||||
- HEADSCALE_URL=http://headscale:8080
|
||||
- HEADSCALE_API_KEY=${HEADSCALE_API_KEY}
|
||||
# 한글화 환경변수
|
||||
- HEADPLANE_TITLE=헤드플레인
|
||||
- HEADPLANE_NAV_MACHINES=장치 관리
|
||||
- HEADPLANE_NAV_USERS=사용자 관리
|
||||
- HEADPLANE_NAV_SETTINGS=설정
|
||||
- HEADPLANE_LOGIN_TITLE=로그인
|
||||
- HEADPLANE_API_KEY_LABEL=API 키
|
||||
```
|
||||
|
||||
### Phase 3: 완전한 해결책 (1주일 내)
|
||||
#### 3-1. react-i18next 라이브러리 도입
|
||||
- [ ] GitHub에서 tale/headplane 포크
|
||||
- [ ] 로컬 개발 환경 구성
|
||||
- [ ] react-i18next 라이브러리 설치
|
||||
- [ ] i18n 설정 및 번역 파일 구조 생성
|
||||
- [ ] 주요 컴포넌트 국제화 적용
|
||||
- [ ] 언어 전환 UI 추가
|
||||
- [ ] 커스텀 Docker 이미지 빌드 및 배포
|
||||
|
||||
#### 3-2. 번역 파일 구조 예시
|
||||
```json
|
||||
// public/locales/ko/translation.json
|
||||
{
|
||||
"app": {
|
||||
"title": "헤드플레인",
|
||||
"description": "헤드스케일 관리 웹 인터페이스"
|
||||
},
|
||||
"navigation": {
|
||||
"machines": "장치 관리",
|
||||
"users": "사용자 관리",
|
||||
"settings": "설정",
|
||||
"accessControl": "접근 제어",
|
||||
"dns": "DNS"
|
||||
},
|
||||
"login": {
|
||||
"title": "헤드플레인에 오신 것을 환영합니다",
|
||||
"description": "API 키를 입력하여 헤드플레인에 인증해주세요.",
|
||||
"apiKeyLabel": "API 키",
|
||||
"apiKeyPlaceholder": "API 키를 입력해주세요",
|
||||
"signInButton": "로그인",
|
||||
"helpText": "터미널에서 'headscale apikeys create' 명령을 실행하여 API 키를 생성할 수 있습니다."
|
||||
},
|
||||
"machines": {
|
||||
"title": "장치 관리",
|
||||
"description": "테일넷에 연결된 장치들을 관리합니다.",
|
||||
"tableHeaders": {
|
||||
"name": "이름",
|
||||
"addresses": "주소",
|
||||
"version": "버전",
|
||||
"lastSeen": "마지막 접속"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"title": "사용자 관리",
|
||||
"description": "헤드스케일 사용자들을 관리합니다."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 권장 실행 방안
|
||||
|
||||
### 즉시 실행: 방안 3 (브라우저 확장프로그램)
|
||||
현재 사용 중인 환경에서 바로 한글 UI를 확인할 수 있도록 **Tampermonkey 스크립트**부터 시작
|
||||
|
||||
### 단기 목표: 방안 1 (환경 변수 기반)
|
||||
Docker 환경변수를 통한 텍스트 커스터마이징으로 **안정적인 한글화** 구현
|
||||
|
||||
### 장기 목표: 방안 2 (react-i18next 도입)
|
||||
완전한 국제화 지원을 위한 **표준적인 다국어 지원** 구현
|
||||
|
||||
## 📊 예상 번역 범위
|
||||
|
||||
### 핵심 번역 대상 (총 약 50-80개 텍스트)
|
||||
|
||||
#### 공통 UI 요소
|
||||
- Headplane → 헤드플레인
|
||||
- API Key → API 키
|
||||
- Sign In → 로그인
|
||||
- Settings → 설정
|
||||
- Profile → 프로필
|
||||
- Logout → 로그아웃
|
||||
|
||||
#### 네비게이션 메뉴
|
||||
- Machines → 장치 관리
|
||||
- Users → 사용자 관리
|
||||
- Access Control → 접근 제어
|
||||
- DNS → DNS 설정
|
||||
|
||||
#### 테이블 및 폼 요소
|
||||
- Name → 이름
|
||||
- IP Address → IP 주소
|
||||
- Status → 상태
|
||||
- Version → 버전
|
||||
- Last Seen → 마지막 접속
|
||||
- Create → 생성
|
||||
- Delete → 삭제
|
||||
- Edit → 편집
|
||||
|
||||
## 🚀 시작하기
|
||||
|
||||
### 1단계: 브라우저 확장프로그램 설치
|
||||
1. Chrome/Edge에서 Tampermonkey 확장프로그램 설치
|
||||
2. 위의 스크립트 코드를 새 스크립트로 생성
|
||||
3. http://192.168.0.151:3000/admin/ 접속하여 한글화 확인
|
||||
|
||||
### 2단계: 환경변수 기반 구현 준비
|
||||
1. tale/headplane 저장소 포크
|
||||
2. 로컬 개발환경 구성
|
||||
3. 텍스트 상수 파일 생성 및 적용
|
||||
|
||||
## 📝 진행 상황 체크리스트
|
||||
|
||||
### Phase 1: 브라우저 확장프로그램
|
||||
- [ ] Tampermonkey 설치 및 스크립트 작성
|
||||
- [ ] 로그인 페이지 한글화 테스트
|
||||
- [ ] 메인 대시보드 한글화 테스트
|
||||
- [ ] 번역 품질 검증
|
||||
|
||||
### Phase 2: 환경변수 기반
|
||||
- [ ] 프로젝트 포크 및 클론
|
||||
- [ ] 개발 환경 구성
|
||||
- [ ] 텍스트 상수 파일 구조 설계
|
||||
- [ ] 주요 컴포넌트 수정
|
||||
- [ ] Docker 이미지 빌드 테스트
|
||||
- [ ] 프로덕션 배포
|
||||
|
||||
### Phase 3: react-i18next 도입
|
||||
- [ ] i18n 라이브러리 설치 및 설정
|
||||
- [ ] 번역 파일 구조 생성
|
||||
- [ ] 컴포넌트별 국제화 적용
|
||||
- [ ] 언어 전환 UI 구현
|
||||
- [ ] 전체 테스트 및 검증
|
||||
|
||||
## 🔗 관련 링크
|
||||
|
||||
- **Headplane GitHub**: https://github.com/tale/headplane
|
||||
- **React i18next 문서**: https://react.i18next.com/
|
||||
- **현재 Headplane UI**: http://192.168.0.151:3000/admin/
|
||||
|
||||
## 📅 생성일: 2025-09-09
|
||||
## 👤 작성자: Claude Code Assistant
|
||||
424
HEADSCALE_COMPLETE_GUIDE.md
Normal file
@ -0,0 +1,424 @@
|
||||
# 🌐 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 네트워크를 운영할 수 있습니다!**
|
||||
370
INSTALLATION_GUIDE.md
Normal file
@ -0,0 +1,370 @@
|
||||
# 🚀 Headscale + Headplane 완전 설치 가이드
|
||||
|
||||
## 📋 프로젝트 개요
|
||||
- **목표**: Tailscale을 완전히 대체하는 자체 호스팅 VPN 솔루션 구축
|
||||
- **기술 스택**: Docker, Docker Compose, Headscale, Headplane
|
||||
- **환경**: Ubuntu 24.04 LTS, Docker 27.2.0
|
||||
|
||||
## 🛠️ 사전 요구사항
|
||||
- Docker 및 Docker Compose 설치
|
||||
- 8070, 3000 포트 사용 가능
|
||||
- root 권한 또는 sudo 권한
|
||||
|
||||
## 📁 프로젝트 구조
|
||||
```
|
||||
headscale-setup/
|
||||
├── docker-compose.yml # Docker Compose 설정
|
||||
├── .env # 환경변수 (API 키 포함)
|
||||
├── .env.example # 환경변수 템플릿
|
||||
├── config/
|
||||
│ └── config.yaml # Headscale 최신 설정
|
||||
├── headplane-config/
|
||||
│ └── config.yaml # Headplane 설정
|
||||
├── data/ # SQLite 데이터베이스 (자동 생성)
|
||||
├── run/ # 런타임 파일 (자동 생성)
|
||||
└── start.sh # 자동 설치 스크립트
|
||||
```
|
||||
|
||||
## 🔧 상세 설치 과정
|
||||
|
||||
### 1단계: 환경 준비
|
||||
```bash
|
||||
# 작업 디렉토리 생성
|
||||
mkdir -p headscale-setup
|
||||
cd headscale-setup
|
||||
|
||||
# 필요한 하위 디렉토리 생성
|
||||
mkdir -p config data run headplane-config
|
||||
```
|
||||
|
||||
### 2단계: Docker Compose 설정
|
||||
|
||||
#### docker-compose.yml 작성
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
headscale:
|
||||
image: headscale/headscale:latest
|
||||
container_name: headscale
|
||||
restart: unless-stopped
|
||||
command: serve
|
||||
environment:
|
||||
- TZ=Asia/Seoul
|
||||
volumes:
|
||||
- ./config:/etc/headscale
|
||||
- ./data:/var/lib/headscale
|
||||
- ./run:/var/run/headscale
|
||||
ports:
|
||||
- "8070:8080" # 외부:내부 (포트 충돌 방지)
|
||||
- "9090:9090" # 메트릭스
|
||||
networks:
|
||||
- headscale-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "nc -z localhost 8080 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
headplane:
|
||||
image: ghcr.io/tale/headplane:latest
|
||||
container_name: headplane
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TZ=Asia/Seoul
|
||||
- HEADSCALE_URL=http://headscale:8080
|
||||
- HEADSCALE_API_KEY=${HEADSCALE_API_KEY}
|
||||
volumes:
|
||||
- ./headplane-config:/etc/headplane
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- headscale
|
||||
networks:
|
||||
- headscale-net
|
||||
|
||||
networks:
|
||||
headscale-net:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
### 3단계: Headscale 설정 파일
|
||||
|
||||
#### config/config.yaml (최신 형식)
|
||||
```yaml
|
||||
---
|
||||
server_url: http://localhost:8070
|
||||
listen_addr: 0.0.0.0:8080
|
||||
metrics_listen_addr: 0.0.0.0:9090
|
||||
|
||||
private_key_path: /var/lib/headscale/private.key
|
||||
noise:
|
||||
private_key_path: /var/lib/headscale/noise_private.key
|
||||
|
||||
# 최신 형식: prefixes 사용
|
||||
prefixes:
|
||||
v4: 100.64.0.0/10
|
||||
v6: fd7a:115c:a1e0::/48
|
||||
|
||||
derp:
|
||||
server:
|
||||
enabled: false
|
||||
urls:
|
||||
- https://controlplane.tailscale.com/derpmap/default
|
||||
|
||||
disable_check_updates: false
|
||||
ephemeral_node_inactivity_timeout: 30m
|
||||
|
||||
database:
|
||||
type: sqlite3
|
||||
sqlite:
|
||||
path: /var/lib/headscale/db.sqlite
|
||||
|
||||
# 최신 DNS 설정 형식
|
||||
dns:
|
||||
override_local_dns: true
|
||||
nameservers:
|
||||
global:
|
||||
- 1.1.1.1
|
||||
- 8.8.8.8
|
||||
search_domains: []
|
||||
magic_dns: true
|
||||
base_domain: headscale.local
|
||||
|
||||
# 최신 정책 설정
|
||||
policy:
|
||||
path: ""
|
||||
|
||||
log:
|
||||
format: text
|
||||
level: info
|
||||
|
||||
unix_socket: /var/run/headscale/headscale.sock
|
||||
unix_socket_permission: "0770"
|
||||
|
||||
logtail:
|
||||
enabled: false
|
||||
|
||||
randomize_client_port: false
|
||||
|
||||
# 간소화된 OIDC 설정
|
||||
oidc:
|
||||
only_start_if_oidc_is_available: false
|
||||
issuer: ""
|
||||
client_id: ""
|
||||
client_secret: ""
|
||||
scope: ["openid", "profile", "email"]
|
||||
extra_params: {}
|
||||
allowed_domains: []
|
||||
allowed_users: []
|
||||
```
|
||||
|
||||
### 4단계: Headplane 설정 파일
|
||||
|
||||
#### headplane-config/config.yaml
|
||||
```yaml
|
||||
headscale:
|
||||
url: http://headscale:8080
|
||||
api_key: YOUR_API_KEY_HERE # 자동 생성됨
|
||||
config_strict: false
|
||||
|
||||
server:
|
||||
host: 0.0.0.0
|
||||
port: 3000
|
||||
cookie_secret: headscale-ui-secret-32-chars-key # 정확히 32자
|
||||
cookie_secure: false
|
||||
|
||||
settings:
|
||||
title: "Headscale 관리 패널"
|
||||
favicon_url: ""
|
||||
custom_css: ""
|
||||
```
|
||||
|
||||
**중요 설정사항:**
|
||||
- `cookie_secret`: 정확히 32자여야 함 (설정 검증 오류 방지)
|
||||
- `config_strict: false`: 설정 검증 완화
|
||||
- `api_key`: 설치 시 자동 생성되어 교체됨
|
||||
- 설정 파일은 환경변수보다 우선순위가 높음
|
||||
|
||||
### 5단계: 환경변수 설정
|
||||
|
||||
#### .env.example
|
||||
```bash
|
||||
# Headscale API Key (설치 후 자동 생성됨)
|
||||
HEADSCALE_API_KEY=your_api_key_here
|
||||
|
||||
# 서버 설정
|
||||
SERVER_URL=http://localhost:8070
|
||||
LISTEN_ADDR=0.0.0.0:8080
|
||||
|
||||
# 데이터베이스 (SQLite 기본)
|
||||
DB_TYPE=sqlite3
|
||||
DB_PATH=/var/lib/headscale/db.sqlite
|
||||
|
||||
# Magic DNS
|
||||
MAGIC_DNS=true
|
||||
BASE_DOMAIN=headscale.local
|
||||
|
||||
# 네트워크 설정
|
||||
IP_PREFIXES=100.64.0.0/10
|
||||
|
||||
# 시간대
|
||||
TZ=Asia/Seoul
|
||||
```
|
||||
|
||||
### 5단계: 설치 실행
|
||||
|
||||
#### 환경변수 파일 복사
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
#### 자동 설치 스크립트 실행
|
||||
```bash
|
||||
chmod +x start.sh
|
||||
./start.sh
|
||||
```
|
||||
|
||||
#### 또는 수동 설치
|
||||
```bash
|
||||
# 1. Headscale 시작
|
||||
docker-compose up -d headscale
|
||||
|
||||
# 2. API 키 생성 (약 30초 대기 후)
|
||||
sleep 30
|
||||
API_KEY=$(docker-compose exec -T headscale headscale apikeys create)
|
||||
echo "Generated API Key: $API_KEY"
|
||||
|
||||
# 3. .env 파일에 API 키 입력
|
||||
sed -i "s/HEADSCALE_API_KEY=your_api_key_here/HEADSCALE_API_KEY=$API_KEY/" .env
|
||||
|
||||
# 4. Headplane 시작
|
||||
docker-compose up -d headplane
|
||||
```
|
||||
|
||||
## 🎯 중요한 설정 변경사항
|
||||
|
||||
### 포트 충돌 해결
|
||||
- **기존**: 8080:8080 (충돌 발생)
|
||||
- **변경**: 8070:8080 (외부 8070 포트 사용)
|
||||
|
||||
### 최신 Headscale 설정 형식 적용
|
||||
- `ip_prefixes` → `prefixes` (v4/v6 분리)
|
||||
- `dns_config` → `dns` (구조 변경)
|
||||
- `acl_policy_path` → `policy.path`
|
||||
- OIDC `strip_email_domain` 제거
|
||||
|
||||
### Docker 헬스체크 개선
|
||||
- `curl` → `nc` (netcat 사용)
|
||||
- Headplane 의존성 조건 완화
|
||||
|
||||
## 🔍 설치 확인 및 검증
|
||||
|
||||
### 1. 컨테이너 상태 확인
|
||||
```bash
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
### 2. Headscale API 테스트
|
||||
```bash
|
||||
curl -s http://localhost:8070/health
|
||||
# 응답: {"status":"pass"}
|
||||
```
|
||||
|
||||
### 3. 로그 확인
|
||||
```bash
|
||||
docker-compose logs headscale
|
||||
docker-compose logs headplane
|
||||
```
|
||||
|
||||
### 4. 사용자 생성
|
||||
```bash
|
||||
docker-compose exec headscale headscale users create myuser
|
||||
```
|
||||
|
||||
### 5. 사용자 목록 확인
|
||||
```bash
|
||||
docker-compose exec headscale headscale users list
|
||||
```
|
||||
|
||||
### 6. Pre-auth 키 생성
|
||||
```bash
|
||||
docker-compose exec headscale headscale preauthkeys create --user 1 --reusable --expiration 24h
|
||||
```
|
||||
|
||||
## 🚨 문제 해결
|
||||
|
||||
### 포트 충돌 문제
|
||||
```bash
|
||||
# 8080 포트 사용 중인 프로세스 확인
|
||||
lsof -i :8080
|
||||
|
||||
# 포트를 8070으로 변경하여 해결
|
||||
```
|
||||
|
||||
### Headplane 설정 파일 문제
|
||||
```bash
|
||||
# cookie_secret 길이 오류 시 (정확히 32자 필요)
|
||||
echo "headscale-ui-secret-32-chars-key" | wc -c # 32자 확인
|
||||
|
||||
# 설정 파일 재검증
|
||||
docker-compose logs headplane --tail 10
|
||||
|
||||
# 컨테이너 재시작으로 설정 재로드
|
||||
docker-compose restart headplane
|
||||
```
|
||||
|
||||
### 헬스체크 실패
|
||||
```bash
|
||||
# wget 대신 netcat 사용
|
||||
# CMD-SHELL을 사용하여 호환성 개선
|
||||
```
|
||||
|
||||
## 📊 최종 설치 결과
|
||||
|
||||
### 접속 정보
|
||||
- **Headscale API**: http://localhost:8070
|
||||
- **Headplane UI**: http://localhost:3000/admin/ (로그인 페이지)
|
||||
- **외부 접속**: http://192.168.0.151:3000/admin/ (네트워크 설정에 따라)
|
||||
- **메트릭스**: http://localhost:9090
|
||||
|
||||
### 생성된 정보
|
||||
- **사용자**: myuser (ID: 1)
|
||||
- **API 키**: 8qRr1IB.tV95CmA0fLaCiGGIgBfeoN9daHceFkzI (자동 생성됨)
|
||||
- **Pre-auth 키**: fc4f2dc55ee00c5352823d156129b9ce2df4db02f1d76a21 (24시간 유효, 재사용 가능)
|
||||
|
||||
### 🔑 Headplane 로그인
|
||||
1. 브라우저에서 http://localhost:3000/admin/ 또는 http://192.168.0.151:3000/admin/ 접속
|
||||
2. **API Key** 필드에 입력: `8qRr1IB.tV95CmA0fLaCiGGIgBfeoN9daHceFkzI`
|
||||
3. **Sign In** 버튼 클릭
|
||||
|
||||
### 네트워크 설정
|
||||
- **IPv4**: 100.64.0.0/10
|
||||
- **IPv6**: fd7a:115c:a1e0::/48
|
||||
- **Magic DNS**: headscale.local
|
||||
|
||||
## 🔄 Git 관리
|
||||
|
||||
### 브랜치 전략
|
||||
```bash
|
||||
# 기능 브랜치 생성
|
||||
git checkout -b feature/working-headscale-setup
|
||||
|
||||
# 변경사항 커밋
|
||||
git add .
|
||||
git commit -m "🎉 Working Headscale Setup Complete"
|
||||
|
||||
# 원격 저장소 푸시
|
||||
git push -u origin feature/working-headscale-setup
|
||||
```
|
||||
|
||||
## 📈 다음 단계
|
||||
1. Tailscale 클라이언트 연결 테스트
|
||||
2. HTTPS/TLS 인증서 구성
|
||||
3. Headplane 한글화 작업
|
||||
4. ACL 보안 규칙 설정
|
||||
5. 백업 및 모니터링 구성
|
||||
|
||||
## 🎉 결론
|
||||
Headscale과 Headplane을 사용한 완전한 자체 호스팅 VPN 솔루션이 성공적으로 구축되었습니다. 이제 Tailscale을 완전히 대체할 수 있는 환경이 준비되었습니다.
|
||||
343
Multi-Proxmox-Management-System.md
Normal file
@ -0,0 +1,343 @@
|
||||
# 다중 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 호스트 관리 시스템을 다중 호스트 환경으로 확장하기 위한 상세 계획을 담고 있습니다. 단계적 구현을 통해 안정적이고 확장 가능한 시스템을 구축할 수 있을 것입니다.*
|
||||
480
PREAUTH_KEY_MANAGEMENT_GUIDE.md
Normal file
@ -0,0 +1,480 @@
|
||||
# 🔑 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 네트워크를 운영하세요!**
|
||||
376
PROXMOX_VNC_INTEGRATION_PLAN.md
Normal file
@ -0,0 +1,376 @@
|
||||
# 팜큐(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 화면 접속**
|
||||
458
PROXMOX_WEBSOCKET_INTEGRATION_PLAN.md
Normal file
@ -0,0 +1,458 @@
|
||||
# Proxmox VNC WebSocket 직접 연결 해결 방안
|
||||
|
||||
## 🎯 목표
|
||||
|
||||
Flask 웹 애플리케이션에서 Proxmox VNC WebSocket에 **직접 연결**하여 브라우저 내에서 seamless한 VNC 경험 제공
|
||||
|
||||
## 🔍 현재 문제 상황
|
||||
|
||||
### 1. 문제 증상
|
||||
- Flask에서 VNC 버튼 클릭 시 `"Unsupported"... is not valid JSON` 오류 발생
|
||||
- noVNC 클라이언트가 Proxmox WebSocket 서버에 연결 실패
|
||||
- HTTP 501 응답 (nginx에서 WebSocket 요청 거부)
|
||||
|
||||
### 2. 근본 원인 분석
|
||||
```
|
||||
[브라우저] --> [Flask:5002] --> [Proxmox:443] --> [실제 WebSocket:5900]
|
||||
❌ CORS 이슈 ❌ nginx 차단 ✅ VM VNC 서버
|
||||
```
|
||||
|
||||
**주요 차단 요인:**
|
||||
- Proxmox nginx가 외부 WebSocket 연결 차단
|
||||
- CORS (Cross-Origin Resource Sharing) 정책
|
||||
- SSL/TLS 인증서 불일치
|
||||
- WebSocket Upgrade 헤더 처리 문제
|
||||
|
||||
## 💡 해결 방안 (3가지 접근법)
|
||||
|
||||
## 방안 1: Flask WebSocket 프록시 서버 (권장)
|
||||
|
||||
### 개념도
|
||||
```
|
||||
[브라우저] <--WebSocket--> [Flask Proxy] <--WebSocket--> [Proxmox VNC]
|
||||
ws://localhost:5002/ws/vnc/vm123 wss://pve7:443/api2/...
|
||||
```
|
||||
|
||||
### 구현 방법
|
||||
|
||||
#### 1.1 Flask-SocketIO 기반 프록시
|
||||
```python
|
||||
# requirements.txt에 추가
|
||||
flask-socketio==5.3.6
|
||||
python-socketio==5.8.0
|
||||
websockets==11.0.3
|
||||
|
||||
# app.py 수정
|
||||
from flask_socketio import SocketIO, emit
|
||||
import websockets
|
||||
import asyncio
|
||||
import ssl
|
||||
|
||||
socketio = SocketIO(app, cors_allowed_origins="*")
|
||||
|
||||
@socketio.on('vnc_connect')
|
||||
def handle_vnc_connect(data):
|
||||
"""VNC WebSocket 프록시 연결"""
|
||||
vm_id = data['vm_id']
|
||||
node = data['node']
|
||||
|
||||
# Proxmox VNC 티켓 생성
|
||||
client = ProxmoxClient(PROXMOX_HOST, PROXMOX_USERNAME, PROXMOX_PASSWORD)
|
||||
vnc_data = client.get_vnc_ticket(node, vm_id)
|
||||
|
||||
if vnc_data:
|
||||
# 백그라운드에서 프록시 실행
|
||||
socketio.start_background_task(proxy_vnc_connection,
|
||||
request.sid,
|
||||
vnc_data['websocket_url'])
|
||||
|
||||
def proxy_vnc_connection(session_id, proxmox_ws_url):
|
||||
"""Proxmox VNC WebSocket과 브라우저 간 프록시"""
|
||||
asyncio.run(proxy_websocket(session_id, proxmox_ws_url))
|
||||
|
||||
async def proxy_websocket(session_id, proxmox_ws_url):
|
||||
"""비동기 WebSocket 프록시"""
|
||||
try:
|
||||
# SSL 컨텍스트 설정 (인증서 검증 무시)
|
||||
ssl_context = ssl.create_default_context()
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
|
||||
# Proxmox WebSocket 연결
|
||||
async with websockets.connect(
|
||||
proxmox_ws_url,
|
||||
ssl=ssl_context,
|
||||
extra_headers={'Origin': f'https://{PROXMOX_HOST}'}
|
||||
) as proxmox_ws:
|
||||
|
||||
@socketio.on('vnc_data', namespace=f'/vnc/{session_id}')
|
||||
def forward_to_proxmox(data):
|
||||
asyncio.create_task(proxmox_ws.send(data))
|
||||
|
||||
# Proxmox → 브라우저
|
||||
async for message from proxmox_ws:
|
||||
socketio.emit('vnc_data', message,
|
||||
room=session_id,
|
||||
namespace=f'/vnc/{session_id}')
|
||||
|
||||
except Exception as e:
|
||||
socketio.emit('vnc_error', {'error': str(e)}, room=session_id)
|
||||
```
|
||||
|
||||
#### 1.2 noVNC 클라이언트 수정
|
||||
```html
|
||||
<!-- templates/vnc_console.html -->
|
||||
<script src="/static/js/socket.io.min.js"></script>
|
||||
<script>
|
||||
// Flask-SocketIO 연결
|
||||
const socket = io();
|
||||
|
||||
// VNC 연결 설정
|
||||
socket.emit('vnc_connect', {
|
||||
vm_id: {{ vmid }},
|
||||
node: '{{ node }}'
|
||||
});
|
||||
|
||||
// noVNC WebSocket URL을 Flask 프록시로 변경
|
||||
const websocketUrl = `ws://localhost:5002/socket.io/`;
|
||||
|
||||
// RFB 연결
|
||||
const rfb = new RFB(canvas, websocketUrl, {
|
||||
wsProtocols: ['base64']
|
||||
});
|
||||
|
||||
// 데이터 중계 설정
|
||||
socket.on('vnc_data', function(data) {
|
||||
// Proxmox에서 온 데이터를 noVNC로 전달
|
||||
rfb._sock.rQshiftBytes(data.length);
|
||||
});
|
||||
|
||||
rfb.addEventListener('connect', function() {
|
||||
// noVNC에서 보낸 데이터를 Proxmox로 전달
|
||||
rfb._sock.on('message', function(data) {
|
||||
socket.emit('vnc_data', data);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### 장점
|
||||
- ✅ 완전한 제어 가능
|
||||
- ✅ CORS 문제 해결
|
||||
- ✅ Flask 애플리케이션 내 통합
|
||||
- ✅ 추가 인증/로깅 가능
|
||||
|
||||
### 단점
|
||||
- ❌ 구현 복잡성 높음
|
||||
- ❌ 성능 오버헤드 존재
|
||||
- ❌ WebSocket 프로토콜 깊이 이해 필요
|
||||
|
||||
---
|
||||
|
||||
## 방안 2: Nginx 리버스 프록시 설정
|
||||
|
||||
### 개념도
|
||||
```
|
||||
[브라우저] --> [Nginx Proxy] --> [Proxmox WebSocket]
|
||||
ws://proxy/vnc/vm123 wss://pve7:443/api2/...
|
||||
```
|
||||
|
||||
### 구현 방법
|
||||
|
||||
#### 2.1 별도 Nginx 프록시 서버 설정
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/proxmox-vnc-proxy
|
||||
server {
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
|
||||
# WebSocket 프록시 설정
|
||||
location /vnc/ {
|
||||
# URL 경로에서 VM 정보 추출: /vnc/pve7/100
|
||||
# pve7 = node, 100 = vmid
|
||||
rewrite ^/vnc/([^/]+)/(\d+)$ /api2/json/nodes/$1/qemu/$2/vncwebsocket break;
|
||||
|
||||
proxy_pass https://pve7.0bin.in:443;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# WebSocket 업그레이드 헤더
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host pve7.0bin.in;
|
||||
proxy_set_header Origin https://pve7.0bin.in;
|
||||
|
||||
# 인증 헤더 전달 (VNC 티켓 포함)
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
|
||||
# SSL 설정
|
||||
proxy_ssl_verify off;
|
||||
proxy_ssl_server_name on;
|
||||
|
||||
# 타임아웃 설정 (VNC 세션용)
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 86400s; # 24시간
|
||||
|
||||
# 버퍼링 비활성화 (실시간 데이터용)
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
}
|
||||
|
||||
# CORS 헤더 추가
|
||||
location / {
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Origin, Content-Type, Accept, Authorization";
|
||||
|
||||
if ($request_method = 'OPTIONS') {
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2 Docker Compose로 Nginx 프록시 실행
|
||||
```yaml
|
||||
# docker-compose.vnc-proxy.yml
|
||||
version: '3.8'
|
||||
services:
|
||||
vnc-proxy:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./nginx-vnc-proxy.conf:/etc/nginx/conf.d/default.conf
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- farmq-network
|
||||
|
||||
networks:
|
||||
farmq-network:
|
||||
external: true
|
||||
```
|
||||
|
||||
#### 2.3 Flask 코드 수정
|
||||
```python
|
||||
# VNC WebSocket URL을 프록시 서버로 변경
|
||||
@app.route('/api/vm/vnc', methods=['POST'])
|
||||
def api_vm_vnc():
|
||||
# ... (기존 코드)
|
||||
|
||||
# 프록시 서버를 통한 WebSocket URL 생성
|
||||
proxy_ws_url = f"ws://localhost:8080/vnc/{node}/{vmid}?port={vnc_data['port']}&vncticket={vnc_data['ticket']}"
|
||||
|
||||
vnc_sessions[session_id] = {
|
||||
'websocket_url': proxy_ws_url,
|
||||
# ...
|
||||
}
|
||||
```
|
||||
|
||||
### 장점
|
||||
- ✅ Flask 코드 변경 최소화
|
||||
- ✅ 높은 성능 (Nginx 네이티브)
|
||||
- ✅ 확장성 우수
|
||||
- ✅ SSL 종료 지원
|
||||
|
||||
### 단점
|
||||
- ❌ 추가 인프라 필요
|
||||
- ❌ Nginx 설정 복잡성
|
||||
- ❌ 디버깅 어려움
|
||||
|
||||
---
|
||||
|
||||
## 방안 3: Proxmox 서버 설정 변경
|
||||
|
||||
### 개념도
|
||||
```
|
||||
[브라우저] ----직접연결----> [Proxmox WebSocket (수정됨)]
|
||||
wss://pve7:443/api2/json/...
|
||||
```
|
||||
|
||||
### 구현 방법
|
||||
|
||||
#### 3.1 Proxmox Nginx 설정 수정
|
||||
```bash
|
||||
# Proxmox 서버에 SSH 접속
|
||||
ssh root@pve7.0bin.in
|
||||
|
||||
# nginx 설정 백업
|
||||
cp /etc/nginx/sites-available/proxmox /etc/nginx/sites-available/proxmox.backup
|
||||
|
||||
# nginx 설정 편집
|
||||
nano /etc/nginx/sites-available/proxmox
|
||||
```
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/proxmox 수정
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name pve7.0bin.in;
|
||||
|
||||
# ... 기존 설정 유지 ...
|
||||
|
||||
# CORS 헤더 추가 (VNC WebSocket용)
|
||||
location /api2/json/nodes/*/qemu/*/vncwebsocket {
|
||||
# CORS 헤더
|
||||
add_header Access-Control-Allow-Origin "http://localhost:5002";
|
||||
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Origin, Content-Type, Accept, Authorization";
|
||||
add_header Access-Control-Allow-Credentials true;
|
||||
|
||||
# Preflight 요청 처리
|
||||
if ($request_method = 'OPTIONS') {
|
||||
return 204;
|
||||
}
|
||||
|
||||
# 기존 proxy_pass 설정 유지
|
||||
proxy_pass http://localhost:8006;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# WebSocket 업그레이드 헤더
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 타임아웃 설정
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
|
||||
# 버퍼링 비활성화
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
}
|
||||
|
||||
# ... 나머지 설정 ...
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 Proxmox nginx 재시작
|
||||
```bash
|
||||
# 설정 검증
|
||||
nginx -t
|
||||
|
||||
# nginx 재시작
|
||||
systemctl restart nginx
|
||||
|
||||
# 방화벽 확인 (필요시)
|
||||
iptables -L -n | grep 8006
|
||||
```
|
||||
|
||||
#### 3.3 Flask noVNC 클라이언트 수정
|
||||
```javascript
|
||||
// 직접 Proxmox WebSocket 연결
|
||||
const websocketUrl = '{{ websocket_url }}'; // wss://pve7.0bin.in:443/api2/json/...
|
||||
|
||||
// CORS 설정으로 직접 연결 가능
|
||||
const rfb = new RFB(canvas, websocketUrl, {
|
||||
credentials: { password: '' }, // VNC 티켓으로 인증
|
||||
shared: true
|
||||
});
|
||||
```
|
||||
|
||||
### 장점
|
||||
- ✅ 가장 직접적인 해결책
|
||||
- ✅ 최고 성능
|
||||
- ✅ 추가 인프라 불필요
|
||||
|
||||
### 단점
|
||||
- ❌ Proxmox 서버 수정 필요
|
||||
- ❌ 업데이트 시 설정 초기화 위험
|
||||
- ❌ 보안 정책 변경
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 구현 우선순위 및 권장사항
|
||||
|
||||
### 1단계: Flask WebSocket 프록시 (권장)
|
||||
**기간**: 2-3일
|
||||
**난이도**: 중상
|
||||
|
||||
```bash
|
||||
# 구현 단계
|
||||
1. Flask-SocketIO 설치 및 설정
|
||||
2. VNC WebSocket 프록시 함수 구현
|
||||
3. noVNC 클라이언트 수정
|
||||
4. 테스트 및 디버깅
|
||||
```
|
||||
|
||||
### 2단계: Nginx 프록시 (백업 방안)
|
||||
**기간**: 1-2일
|
||||
**난이도**: 중
|
||||
|
||||
```bash
|
||||
# 구현 단계
|
||||
1. nginx 프록시 설정 작성
|
||||
2. Docker Compose로 프록시 실행
|
||||
3. Flask WebSocket URL 수정
|
||||
4. 연결 테스트
|
||||
```
|
||||
|
||||
### 3단계: Proxmox 설정 변경 (최후 수단)
|
||||
**기간**: 1일
|
||||
**난이도**: 하 (위험도: 상)
|
||||
|
||||
```bash
|
||||
# 주의사항
|
||||
- Proxmox 서버 직접 수정
|
||||
- 백업 필수
|
||||
- 업데이트 시 재설정 필요
|
||||
```
|
||||
|
||||
## 🔧 즉시 구현 가능한 임시 해결책
|
||||
|
||||
현재 리다이렉트 방식을 개선하여 사용성 향상:
|
||||
|
||||
```python
|
||||
# app.py - 자동 팝업 + 인증 정보 전달
|
||||
@app.route('/vnc/<session_id>')
|
||||
def vnc_console(session_id):
|
||||
# Proxmox 자동 로그인 URL 생성
|
||||
client = ProxmoxClient(PROXMOX_HOST, PROXMOX_USERNAME, PROXMOX_PASSWORD)
|
||||
login_ticket = client.ticket
|
||||
|
||||
proxmox_url = f"https://{PROXMOX_HOST}:443/?console=kvm&vmid={vmid}&node={node}&PVEAuthCookie={login_ticket}"
|
||||
|
||||
return render_template('vnc_auto_redirect.html',
|
||||
proxmox_url=proxmox_url,
|
||||
auto_open=True)
|
||||
```
|
||||
|
||||
## 📋 구현 체크리스트
|
||||
|
||||
### Flask WebSocket 프록시 구현
|
||||
- [ ] Flask-SocketIO 설치 및 기본 설정
|
||||
- [ ] Proxmox WebSocket 연결 테스트
|
||||
- [ ] 비동기 프록시 함수 구현
|
||||
- [ ] noVNC 클라이언트 SocketIO 연동
|
||||
- [ ] 에러 처리 및 재연결 로직
|
||||
- [ ] 성능 테스트 및 최적화
|
||||
|
||||
### 테스트 시나리오
|
||||
- [ ] 단일 VM VNC 연결 테스트
|
||||
- [ ] 동시 다중 VNC 연결 테스트
|
||||
- [ ] 네트워크 끊김 시 재연결 테스트
|
||||
- [ ] 브라우저별 호환성 테스트
|
||||
- [ ] 모바일 디바이스 테스트
|
||||
|
||||
### 운영 준비
|
||||
- [ ] 로깅 및 모니터링 설정
|
||||
- [ ] 에러 알림 시스템 구축
|
||||
- [ ] 백업 연결 방법 준비
|
||||
- [ ] 사용자 매뉴얼 작성
|
||||
|
||||
---
|
||||
|
||||
## 💡 결론
|
||||
|
||||
**권장 접근법**: Flask WebSocket 프록시 (방안 1)
|
||||
|
||||
1. **완전한 제어**: Flask 애플리케이션 내에서 모든 것을 제어
|
||||
2. **확장성**: 향후 추가 기능 (녹화, 공유 등) 쉽게 추가 가능
|
||||
3. **안정성**: Proxmox 서버 수정 없이 구현
|
||||
4. **통합성**: 기존 Flask 인증/권한 시스템과 완벽 통합
|
||||
|
||||
**다음 단계**: Flask-SocketIO 기반 WebSocket 프록시 구현 시작
|
||||
297
PharmQ-SaaS-Service-Plan.md
Normal file
@ -0,0 +1,297 @@
|
||||
# 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
|
||||
404
PharmQ-User-Portal-Service-Plan.md
Normal file
@ -0,0 +1,404 @@
|
||||
# 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 조사 및 피드백 수집
|
||||
247
QUICK_INSTALL_GUIDE.md
Normal file
@ -0,0 +1,247 @@
|
||||
# 🚀 팜큐 Headscale 원클릭 설치 가이드
|
||||
|
||||
새로운 리눅스 서버를 팜큐 네트워크에 **한 번의 명령**으로 등록하는 방법입니다.
|
||||
|
||||
## 🎯 원클릭 설치 명령어
|
||||
|
||||
### 방법 1: curl 사용 (권장)
|
||||
```bash
|
||||
# 일반 사용자 계정에서
|
||||
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | sudo bash
|
||||
|
||||
# root 계정에서 (Proxmox, Docker 컨테이너 등)
|
||||
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | bash
|
||||
```
|
||||
|
||||
### 방법 2: wget 사용
|
||||
```bash
|
||||
# 일반 사용자 계정에서
|
||||
wget -qO- https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | sudo bash
|
||||
|
||||
# root 계정에서
|
||||
wget -qO- https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | bash
|
||||
```
|
||||
|
||||
### 방법 3: 스크립트 다운로드 후 실행
|
||||
```bash
|
||||
wget https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh
|
||||
chmod +x quick-install.sh
|
||||
sudo ./quick-install.sh
|
||||
```
|
||||
|
||||
### 방법 4: 기존 Tailscale 연결이 있는 경우 (강제 재등록)
|
||||
```bash
|
||||
# 일반 사용자 계정에서
|
||||
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | sudo bash -s -- --force
|
||||
|
||||
# root 계정에서 (Proxmox 등)
|
||||
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | bash -s -- --force
|
||||
```
|
||||
|
||||
### 방법 5: 스크립트 옵션 확인
|
||||
```bash
|
||||
# 사용 가능한 옵션 보기
|
||||
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/quick-install.sh | bash -s -- --help
|
||||
```
|
||||
|
||||
## ✨ 자동으로 수행되는 작업
|
||||
|
||||
### 1. 🔍 시스템 분석
|
||||
- 운영체제 자동 감지 (Ubuntu, Debian, CentOS, RHEL, Rocky, Fedora, Arch)
|
||||
- 시스템 요구사항 확인
|
||||
- 네트워크 연결 상태 점검
|
||||
|
||||
### 2. 📦 Tailscale 설치
|
||||
- **Ubuntu/Debian**: APT 리포지토리 추가 및 설치
|
||||
- **CentOS/RHEL/Rocky**: YUM/DNF 리포지토리 추가 및 설치
|
||||
- **Fedora**: DNF 패키지 관리자로 설치
|
||||
- **Arch Linux**: Pacman으로 설치
|
||||
- **기타 배포판**: Universal Binary 직접 다운로드
|
||||
|
||||
### 3. 🔧 서비스 설정
|
||||
- systemd 서비스 자동 등록
|
||||
- tailscaled 데몬 시작 및 활성화
|
||||
- 서비스 상태 확인 및 오류 처리
|
||||
|
||||
### 4. 🌐 Headscale 등록
|
||||
- Pre-auth Key를 사용한 자동 등록
|
||||
- 팜큐 Headscale 서버 (`https://head.0bin.in`)에 연결
|
||||
- DNS 및 라우팅 설정 자동 적용
|
||||
|
||||
### 5. 🔒 방화벽 설정
|
||||
- UFW (Ubuntu/Debian) 자동 설정
|
||||
- firewalld (CentOS/RHEL/Fedora) 자동 설정
|
||||
- Tailscale 포트 (41641/UDP) 자동 허용
|
||||
|
||||
### 6. ✅ 연결 검증
|
||||
- IP 주소 할당 확인
|
||||
- 네트워크 연결 테스트
|
||||
- 다른 노드와의 통신 확인
|
||||
|
||||
## 🖥️ 지원하는 운영체제
|
||||
|
||||
| OS | 버전 | 설치 방법 | 상태 |
|
||||
|---|---|---|---|
|
||||
| **Ubuntu** | 18.04+ | APT Repository | ✅ |
|
||||
| **Debian** | 10+ | APT Repository | ✅ |
|
||||
| **CentOS** | 7, 8, 9 | YUM/DNF Repository | ✅ |
|
||||
| **RHEL** | 7, 8, 9 | YUM/DNF Repository | ✅ |
|
||||
| **Rocky Linux** | 8, 9 | DNF Repository | ✅ |
|
||||
| **AlmaLinux** | 8, 9 | DNF Repository | ✅ |
|
||||
| **Fedora** | 35+ | DNF Package | ✅ |
|
||||
| **Arch Linux** | Rolling | Pacman Package | ✅ |
|
||||
| **기타 배포판** | - | Universal Binary | ⚠️ |
|
||||
|
||||
## 📋 설치 예시 출력
|
||||
|
||||
```bash
|
||||
$ curl -fsSL https://git.0bin.in/.../quick-install.sh | sudo bash
|
||||
|
||||
============================================
|
||||
팜큐(FARMQ) Headscale 원클릭 설치
|
||||
============================================
|
||||
|
||||
📋 감지된 OS: ubuntu 22.04 (jammy)
|
||||
|
||||
🔧 시스템 요구사항 확인 중...
|
||||
✅ 시스템 요구사항 확인 완료
|
||||
|
||||
🔧 Tailscale 클라이언트 설치 중...
|
||||
📋 Ubuntu/Debian용 Tailscale 설치 중...
|
||||
✅ Tailscale 설치 완료
|
||||
📋 설치된 버전: 1.52.1
|
||||
|
||||
🔧 Tailscale 서비스 시작 중...
|
||||
✅ Tailscaled 서비스가 실행 중입니다.
|
||||
|
||||
🔧 Headscale 서버에 등록 중...
|
||||
📋 Headscale 서버: https://head.0bin.in
|
||||
📋 Pre-auth Key: 8b3df41d***************
|
||||
🔧 등록 명령 실행 중...
|
||||
✅ Headscale 등록 성공!
|
||||
|
||||
🔧 방화벽 설정 확인 중...
|
||||
📋 UFW 방화벽 감지됨
|
||||
📋 Tailscale 트래픽 허용 중...
|
||||
✅ 방화벽 설정 완료
|
||||
|
||||
🔧 연결 상태 확인 중...
|
||||
✅ Headscale 네트워크 연결 완료!
|
||||
📋 할당된 IPv4: 100.64.0.5
|
||||
📋 할당된 IPv6: fd7a:115c:a1e0::5
|
||||
|
||||
🔧 네트워크 연결 테스트 중...
|
||||
✅ 팜큐 네트워크(100.64.0.0/10) 연결 정상!
|
||||
|
||||
============================================
|
||||
팜큐 Headscale 설치 완료!
|
||||
============================================
|
||||
|
||||
🎉 설치가 성공적으로 완료되었습니다!
|
||||
|
||||
📋 시스템 정보:
|
||||
호스트명: pharmacy-server-01
|
||||
Tailscale IP: 100.64.0.5
|
||||
OS: ubuntu 22.04
|
||||
Headscale 서버: https://head.0bin.in
|
||||
|
||||
🔧 유용한 명령어:
|
||||
tailscale status # 연결 상태 확인
|
||||
tailscale ip # 할당된 IP 확인
|
||||
tailscale ping <node> # 다른 노드와 연결 테스트
|
||||
tailscale logout # 네트워크에서 해제
|
||||
|
||||
🌐 팜큐 관리자 페이지:
|
||||
http://192.168.0.151:5002
|
||||
http://192.168.0.151:5002/vms (VM 관리)
|
||||
|
||||
============================================
|
||||
설치 완료 - 팜큐 네트워크를 사용할 수 있습니다!
|
||||
============================================
|
||||
```
|
||||
|
||||
## 🔧 설치 후 확인 명령어
|
||||
|
||||
### 연결 상태 확인
|
||||
```bash
|
||||
tailscale status
|
||||
```
|
||||
|
||||
### 할당된 IP 주소 확인
|
||||
```bash
|
||||
tailscale ip
|
||||
```
|
||||
|
||||
### 네트워크 테스트
|
||||
```bash
|
||||
# 다른 노드로 ping 테스트
|
||||
tailscale ping 100.64.0.1
|
||||
|
||||
# 또는 노드명으로 테스트
|
||||
tailscale ping desktop-emjd1dc
|
||||
```
|
||||
|
||||
### 서비스 상태 확인
|
||||
```bash
|
||||
systemctl status tailscaled
|
||||
journalctl -u tailscaled -f # 실시간 로그
|
||||
```
|
||||
|
||||
## 🚨 문제해결
|
||||
|
||||
### 1. 설치 중 권한 오류
|
||||
```bash
|
||||
# 해결방법: sudo 권한으로 실행
|
||||
sudo curl -fsSL https://git.0bin.in/.../quick-install.sh | sudo bash
|
||||
```
|
||||
|
||||
### 2. 네트워크 연결 실패
|
||||
```bash
|
||||
# 방화벽 상태 확인
|
||||
sudo ufw status
|
||||
sudo firewall-cmd --list-all
|
||||
|
||||
# 수동 포트 허용
|
||||
sudo ufw allow 41641/udp
|
||||
sudo firewall-cmd --add-port=41641/udp --permanent
|
||||
```
|
||||
|
||||
### 3. Pre-auth Key 만료
|
||||
```bash
|
||||
# 새로운 키 생성이 필요한 경우
|
||||
# Headscale 서버에서 실행:
|
||||
docker exec headscale headscale preauthkeys create --user 1 --expiration 7d --reusable
|
||||
```
|
||||
|
||||
### 4. 수동 등록 필요시
|
||||
```bash
|
||||
# 자동 등록 실패 시 수동 실행
|
||||
tailscale up --login-server="https://head.0bin.in" --authkey="YOUR_KEY_HERE"
|
||||
```
|
||||
|
||||
## 📊 현재 네트워크 정보
|
||||
|
||||
- **Headscale 서버**: https://head.0bin.in
|
||||
- **Flask 관리 페이지**: http://192.168.0.151:5002
|
||||
- **네트워크 대역**: 100.64.0.0/10
|
||||
- **Pre-auth Key 유효기간**: 7일 (재사용 가능)
|
||||
|
||||
## 🔄 기존 서버 업데이트
|
||||
|
||||
이미 등록된 서버에서 스크립트를 다시 실행하면:
|
||||
1. 기존 연결 감지
|
||||
2. 사용자 확인 후 재등록 옵션 제공
|
||||
3. 또는 기존 연결 유지
|
||||
|
||||
## 📞 지원
|
||||
|
||||
문제가 발생하면 다음 정보와 함께 연락주세요:
|
||||
|
||||
1. **OS 정보**: `cat /etc/os-release`
|
||||
2. **Tailscale 버전**: `tailscale version`
|
||||
3. **오류 로그**: `journalctl -u tailscaled --no-pager`
|
||||
4. **네트워크 상태**: `tailscale status`
|
||||
|
||||
---
|
||||
|
||||
**🎯 목표**: 새로운 서버를 30초 만에 팜큐 네트워크에 연결!
|
||||
68
README.md
@ -52,7 +52,7 @@ docker-compose up -d headplane
|
||||
```
|
||||
|
||||
## 📋 접속 정보
|
||||
- **Headscale API**: http://localhost:8080
|
||||
- **Headscale API**: http://localhost:8070
|
||||
- **Headplane UI**: http://localhost:3000
|
||||
|
||||
## 👤 사용자 관리
|
||||
@ -129,4 +129,68 @@ 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 방화벽** 자동 설정
|
||||
- ✅ **네트워크 연결 테스트** 및 확인
|
||||
126
TROUBLESHOOTING_DATABASE_FOREIGN_KEY.md
Normal file
@ -0,0 +1,126 @@
|
||||
# Headscale 데이터베이스 외래키 제약조건 문제 해결 기록
|
||||
|
||||
## 🔍 문제 상황
|
||||
|
||||
### 발생한 오류
|
||||
```
|
||||
backend error: handling register with auth key: registering node: failed register(save) node in the database: SQL logic error: foreign key mismatch - "pharmacy_info" referencing "users" (1)
|
||||
```
|
||||
|
||||
### 증상
|
||||
- Windows Tailscale 클라이언트 연결 시 Google SSO 로그인만 표시됨
|
||||
- `tailscale up --login-server` 명령어 실행 시 위 오류 발생
|
||||
- Headscale 컨테이너 로그에서 foreign key mismatch 오류 지속 발생
|
||||
|
||||
### 원인 분석
|
||||
1. **Flask Admin 앱 개발 과정에서 추가된 커스텀 테이블들이 Headscale 스키마와 충돌**
|
||||
- `pharmacy_info` 테이블이 `users` 테이블을 참조하는 외래키 제약조건 설정
|
||||
- `machine_specs`, `monitoring_data` 테이블도 유사한 외래키 제약조건 존재
|
||||
|
||||
2. **Headscale이 자체 사용자 관리 스키마를 가지고 있어 외부 테이블의 외래키 참조 거부**
|
||||
- Headscale은 내부적으로 `users`, `machines`, `nodes` 등의 테이블을 관리
|
||||
- 외부에서 추가된 테이블이 이들을 참조할 때 스키마 불일치 발생
|
||||
|
||||
## 🛠️ 해결 과정
|
||||
|
||||
### 1단계: 문제 테이블 식별
|
||||
```python
|
||||
# 문제가 되는 테이블들 확인
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%pharmacy%'")
|
||||
problem_tables = cursor.fetchall()
|
||||
# 결과: ['pharmacy_info', 'machine_specs', 'monitoring_data']
|
||||
```
|
||||
|
||||
### 2단계: 데이터베이스 정리 스크립트 실행
|
||||
```bash
|
||||
python3 clean-database.py
|
||||
```
|
||||
|
||||
#### 스크립트 수행 작업:
|
||||
1. **백업 생성**: `db.sqlite.clean_backup.20250909_170759`
|
||||
2. **외래키 제약조건 비활성화**: `PRAGMA foreign_keys = OFF`
|
||||
3. **문제 테이블 제거**:
|
||||
- `DROP TABLE IF EXISTS pharmacy_info`
|
||||
- `DROP TABLE IF EXISTS machine_specs`
|
||||
- `DROP TABLE IF EXISTS monitoring_data`
|
||||
4. **변경사항 커밋 및 무결성 검사**
|
||||
|
||||
### 3단계: Headscale 서비스 재시작
|
||||
```bash
|
||||
cd /srv/headscale-setup && docker-compose restart headscale
|
||||
```
|
||||
|
||||
## ✅ 해결 결과
|
||||
|
||||
### Before (문제 상황):
|
||||
```
|
||||
2025-09-09T17:07:02+09:00 FTL Migration failed: SQL logic error: foreign key mismatch - "pharmacy_info" referencing "users" (1)
|
||||
```
|
||||
|
||||
### After (해결 후):
|
||||
```
|
||||
2025-09-09T17:08:42+09:00 INF Using policy manager version: 2
|
||||
2025-09-09T17:08:42+09:00 INF Starting Headscale commit=474ea236d0c6d393dbcf7baa98da240ad20c1b66 version=0.26.1
|
||||
2025-09-09T17:08:46+09:00 INF node has connected, mapSession: 0xc000172600, chan: 0xc000286d90 node=DESKTOP-EMJD1DC node.id=2
|
||||
2025-09-09T17:08:48+09:00 INF node has connected, mapSession: 0xc00021b680, chan: 0xc000251180 node=0bin-Ubuntu-VM node.id=1
|
||||
```
|
||||
|
||||
### 성공 지표:
|
||||
- ✅ Headscale 정상 시작
|
||||
- ✅ Windows 클라이언트 (DESKTOP-EMJD1DC) 연결 성공
|
||||
- ✅ Ubuntu VM 클라이언트 (0bin-Ubuntu-VM) 연결 유지
|
||||
- ✅ Health check 통과: `{"status":"pass"}`
|
||||
|
||||
## 📚 교훈 및 베스트 프랙티스
|
||||
|
||||
### 1. Headscale과 커스텀 애플리케이션 분리 원칙
|
||||
```
|
||||
❌ 잘못된 접근: Headscale DB에 직접 외래키 제약조건으로 연결
|
||||
✅ 올바른 접근: 별도 데이터베이스 또는 느슨한 결합 방식 사용
|
||||
```
|
||||
|
||||
### 2. 외래키 제약조건 설계 시 고려사항
|
||||
- **Headscale 스키마 독립성 유지**
|
||||
- **별도 데이터베이스 사용** 또는 **ID 참조만 사용** (외래키 제약조건 없이)
|
||||
- **마이그레이션 전 스키마 호환성 검증**
|
||||
|
||||
### 3. 향후 개발 가이드라인
|
||||
```python
|
||||
# 권장하지 않음
|
||||
pharmacy_info = Table('pharmacy_info',
|
||||
Column('user_id', Integer, ForeignKey('users.id')) # ❌
|
||||
)
|
||||
|
||||
# 권장 방법
|
||||
pharmacy_info = Table('pharmacy_info',
|
||||
Column('headscale_user_id', Integer) # ✅ 단순 참조, 제약조건 없음
|
||||
)
|
||||
```
|
||||
|
||||
### 4. 데이터베이스 백업 중요성
|
||||
- 모든 스키마 변경 전 백업 생성 필수
|
||||
- 백업 파일명에 타임스탬프 포함으로 버전 관리
|
||||
- 롤백 절차 사전 준비
|
||||
|
||||
## 🔧 복구 방법 (필요시)
|
||||
|
||||
만약 문제가 재발하거나 백업에서 복원해야 할 경우:
|
||||
|
||||
```bash
|
||||
# 백업에서 복원
|
||||
cp /srv/headscale-setup/data/db.sqlite.clean_backup.20250909_170759 /srv/headscale-setup/data/db.sqlite
|
||||
|
||||
# 컨테이너 재시작
|
||||
cd /srv/headscale-setup && docker-compose restart headscale
|
||||
```
|
||||
|
||||
## 📝 관련 파일들
|
||||
- **해결 스크립트**: `/srv/headscale-setup/clean-database.py`
|
||||
- **백업 파일**: `/srv/headscale-setup/data/db.sqlite.clean_backup.20250909_170759`
|
||||
- **Docker Compose**: `/srv/headscale-setup/docker-compose.yml`
|
||||
- **로그 위치**: `docker logs headscale`
|
||||
|
||||
---
|
||||
*문제 해결일: 2025-09-09*
|
||||
*해결 소요시간: ~30분*
|
||||
*영향 범위: Windows/Ubuntu 클라이언트 연결 복구*
|
||||
230
VNC-WebSocket-Proxy-Solution.md
Normal file
@ -0,0 +1,230 @@
|
||||
# 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 역방향 프록시를 통해 이 문제를 근본적으로 해결할 수 있으며, 이는 가장 실용적이고 안정적인 솔루션입니다.
|
||||
455
WINDOWS_ONE_CLICK_INSTALLATION_PLAN.md
Normal file
@ -0,0 +1,455 @@
|
||||
# 🪟 Windows용 팜큐 Headscale 원클릭 설치 패키지 기획서
|
||||
|
||||
## 🎯 목표
|
||||
|
||||
Windows 클라이언트에서 **단 한 번의 실행**으로 Tailscale 설치부터 팜큐 Headscale 네트워크 연결까지 완전 자동화
|
||||
|
||||
## 🔍 현재 Windows 상황 분석
|
||||
|
||||
### 기존 연결된 Windows 클라이언트들
|
||||
```
|
||||
100.79.125.82 upharm-1 thug0bin@ windows offline
|
||||
100.76.226.63 upharm thug0bin@ windows offline
|
||||
100.93.4.146 prox-win10-kiosk thug0bin@ windows offline
|
||||
100.109.121.8 desktop-06t3j0m thug0bin@ windows offline
|
||||
100.70.5.37 desktop-9a1aurp thug0bin@ windows offline
|
||||
100.126.213.6 desktop-m445evd thug0bin@ windows offline
|
||||
```
|
||||
|
||||
### Windows 특성
|
||||
- **관리자 권한** 필요 (UAC)
|
||||
- **PowerShell** 스크립트 실행 정책
|
||||
- **GUI 설치 마법사** 선호
|
||||
- **레지스트리** 설정 관리
|
||||
- **서비스** 자동 시작 설정
|
||||
|
||||
## 💡 Windows 설치 패키지 방안 (5가지)
|
||||
|
||||
## 방안 1: PowerShell 원클릭 스크립트 (권장)
|
||||
|
||||
### 개념도
|
||||
```
|
||||
[사용자] → [PowerShell 스크립트] → [Tailscale MSI 설치] → [Headscale 등록] → [완료]
|
||||
우클릭 "관리자로 실행" 자동 다운로드/설치 자동 서버 설정
|
||||
```
|
||||
|
||||
### 구현 방법
|
||||
|
||||
#### 1.1 PowerShell 스크립트 생성
|
||||
```powershell
|
||||
# farmq-headscale-installer.ps1
|
||||
param(
|
||||
[switch]$Force,
|
||||
[string]$HeadscaleServer = "https://head.0bin.in",
|
||||
[string]$PreAuthKey = "8b3df41d37cb158ea39f41fc32c9af46e761de817ad06038"
|
||||
)
|
||||
|
||||
# 관리자 권한 확인
|
||||
if (-NOT ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) {
|
||||
Write-Host "❌ 관리자 권한이 필요합니다." -ForegroundColor Red
|
||||
Write-Host "우클릭 → '관리자로 실행'을 사용해주세요." -ForegroundColor Yellow
|
||||
pause
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "🚀 팜큐 Headscale Windows 설치 시작..." -ForegroundColor Green
|
||||
```
|
||||
|
||||
#### 1.2 웹 실행 방법
|
||||
```powershell
|
||||
# 관리자 PowerShell에서
|
||||
iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/.../farmq-install.ps1'))
|
||||
|
||||
# 또는 강제 재등록
|
||||
iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/.../farmq-install.ps1?force=1'))
|
||||
```
|
||||
|
||||
### 장점
|
||||
- ✅ 단일 명령어로 실행 가능
|
||||
- ✅ 기존 Linux 스크립트와 유사한 UX
|
||||
- ✅ 웹에서 바로 실행 가능
|
||||
- ✅ 버전 관리 용이
|
||||
|
||||
### 단점
|
||||
- ❌ PowerShell 실행 정책 문제
|
||||
- ❌ 일반 사용자에게 복잡함
|
||||
- ❌ UAC 프롬프트 필요
|
||||
|
||||
---
|
||||
|
||||
## 방안 2: MSI 설치 패키지 (GUI 방식)
|
||||
|
||||
### 개념도
|
||||
```
|
||||
[사용자] → [farmq-headscale-installer.msi 실행] → [설치 마법사] → [완료]
|
||||
더블클릭 GUI 단계별 진행
|
||||
```
|
||||
|
||||
### 구현 방법
|
||||
|
||||
#### 2.1 WiX Toolset으로 MSI 제작
|
||||
```xml
|
||||
<!-- farmq-installer.wxs -->
|
||||
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
|
||||
<Product Id="*" Name="팜큐 Headscale 클라이언트" Language="1042"
|
||||
Version="1.0.0" Manufacturer="팜큐" UpgradeCode="...">
|
||||
|
||||
<Package InstallerVersion="200" Compressed="yes" InstallScope="perMachine"/>
|
||||
|
||||
<!-- Tailscale MSI 번들링 -->
|
||||
<Directory Id="TARGETDIR" Name="SourceDir">
|
||||
<Directory Id="ProgramFilesFolder">
|
||||
<Directory Id="INSTALLFOLDER" Name="FarmQ Headscale"/>
|
||||
</Directory>
|
||||
</Directory>
|
||||
|
||||
<!-- 사용자 정의 액션 -->
|
||||
<CustomAction Id="InstallTailscale"
|
||||
FileKey="TailscaleMSI"
|
||||
ExeCommand="msiexec /i tailscale-windows.msi /quiet"/>
|
||||
|
||||
<CustomAction Id="RegisterHeadscale"
|
||||
FileKey="RegisterScript"
|
||||
ExeCommand="powershell.exe -ExecutionPolicy Bypass -File register-headscale.ps1"/>
|
||||
</Product>
|
||||
</Wix>
|
||||
```
|
||||
|
||||
#### 2.2 설치 마법사 UI
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ 팜큐 Headscale 클라이언트 설치 │
|
||||
├─────────────────────────────────┤
|
||||
│ [ ] 기본 설치 (권장) │
|
||||
│ [ ] 기존 연결 해제 후 재설치 │
|
||||
│ │
|
||||
│ 서버: https://head.0bin.in │
|
||||
│ 네트워크: 100.64.0.0/10 │
|
||||
│ │
|
||||
│ [< 이전] [다음 >] [취소] │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 장점
|
||||
- ✅ Windows 사용자에게 친숙한 GUI
|
||||
- ✅ 제어판에서 제거 가능
|
||||
- ✅ 디지털 서명 가능
|
||||
- ✅ 그룹 정책 배포 가능
|
||||
|
||||
### 단점
|
||||
- ❌ 개발 복잡도 높음
|
||||
- ❌ 코드 서명 인증서 필요
|
||||
- ❌ 업데이트 배포 복잡
|
||||
|
||||
---
|
||||
|
||||
## 방안 3: 실행 파일 (EXE) + 내장 리소스
|
||||
|
||||
### 개념도
|
||||
```
|
||||
[사용자] → [farmq-installer.exe 실행] → [콘솔/GUI 선택] → [완료]
|
||||
단일 실행 파일 모든 리소스 내장
|
||||
```
|
||||
|
||||
### 구현 방법
|
||||
|
||||
#### 3.1 Go/C#으로 네이티브 실행파일
|
||||
```go
|
||||
// farmq-installer.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
//go:embed resources/tailscale-windows.msi
|
||||
//go:embed resources/register-script.ps1
|
||||
var resources embed.FS
|
||||
|
||||
func main() {
|
||||
fmt.Println("🚀 팜큐 Headscale Windows 설치")
|
||||
|
||||
// 관리자 권한 확인
|
||||
if !isAdmin() {
|
||||
fmt.Println("❌ 관리자 권한으로 다시 실행해주세요")
|
||||
return
|
||||
}
|
||||
|
||||
// Tailscale MSI 추출 및 설치
|
||||
installTailscale()
|
||||
|
||||
// Headscale 등록
|
||||
registerHeadscale()
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 배포 형태
|
||||
- **farmq-installer.exe** (단일 파일, ~50MB)
|
||||
- Tailscale MSI 포함
|
||||
- PowerShell 스크립트 포함
|
||||
- 모든 의존성 내장
|
||||
|
||||
### 장점
|
||||
- ✅ 단일 파일로 배포 간편
|
||||
- ✅ 오프라인 설치 가능
|
||||
- ✅ 의존성 문제 없음
|
||||
- ✅ 콘솔/GUI 하이브리드 가능
|
||||
|
||||
### 단점
|
||||
- ❌ 파일 크기 큼 (50MB+)
|
||||
- ❌ 네이티브 개발 필요
|
||||
- ❌ Tailscale 업데이트 시 재빌드
|
||||
|
||||
---
|
||||
|
||||
## 방안 4: 웹 기반 설치 (브라우저)
|
||||
|
||||
### 개념도
|
||||
```
|
||||
[사용자] → [웹페이지 방문] → [원클릭 다운로드] → [자동 실행] → [완료]
|
||||
설치 가이드 페이지 맞춤형 설치파일 브라우저 실행
|
||||
```
|
||||
|
||||
### 구현 방법
|
||||
|
||||
#### 4.1 웹 설치 페이지
|
||||
```html
|
||||
<!-- https://install.farmq.network -->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>팜큐 Headscale Windows 설치</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🪟 Windows용 팜큐 네트워크 설치</h1>
|
||||
|
||||
<div class="install-options">
|
||||
<button onclick="downloadInstaller('basic')">
|
||||
🚀 기본 설치
|
||||
</button>
|
||||
<button onclick="downloadInstaller('force')">
|
||||
🔄 강제 재설치
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function downloadInstaller(type) {
|
||||
const url = `https://install.farmq.network/download?type=${type}`;
|
||||
window.location.href = url;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
#### 4.2 동적 설치파일 생성
|
||||
```python
|
||||
# Flask 서버에서 실시간 생성
|
||||
@app.route('/download')
|
||||
def download_installer():
|
||||
install_type = request.args.get('type', 'basic')
|
||||
|
||||
# 사용자 맞춤형 PowerShell 스크립트 생성
|
||||
script = generate_powershell_script(
|
||||
force=install_type=='force',
|
||||
preauth_key=get_current_preauth_key(),
|
||||
server_url="https://head.0bin.in"
|
||||
)
|
||||
|
||||
response = make_response(script)
|
||||
response.headers['Content-Type'] = 'application/octet-stream'
|
||||
response.headers['Content-Disposition'] = 'attachment; filename=farmq-install.ps1'
|
||||
return response
|
||||
```
|
||||
|
||||
### 장점
|
||||
- ✅ 최신 설정 항상 반영
|
||||
- ✅ 사용자별 맞춤 설치
|
||||
- ✅ 통계 수집 가능
|
||||
- ✅ 웹 기반으로 접근성 좋음
|
||||
|
||||
### 단점
|
||||
- ❌ 인터넷 연결 필수
|
||||
- ❌ 웹 서버 인프라 필요
|
||||
- ❌ 브라우저 보안 정책 제약
|
||||
|
||||
---
|
||||
|
||||
## 방안 5: 배치 파일 (BAT) 스크립트
|
||||
|
||||
### 개념도
|
||||
```
|
||||
[사용자] → [farmq-install.bat 실행] → [Windows CMD 명령] → [완료]
|
||||
우클릭 "관리자로 실행" 전통적인 배치 방식
|
||||
```
|
||||
|
||||
### 구현 방법
|
||||
|
||||
#### 5.1 배치 스크립트
|
||||
```batch
|
||||
@echo off
|
||||
:: farmq-install.bat
|
||||
title 팜큐 Headscale Windows 설치
|
||||
|
||||
:: 관리자 권한 확인
|
||||
net session >nul 2>&1
|
||||
if %errorLevel% neq 0 (
|
||||
echo ❌ 관리자 권한이 필요합니다.
|
||||
echo 우클릭으로 "관리자로 실행"해주세요.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo 🚀 팜큐 Headscale Windows 설치 시작...
|
||||
|
||||
:: Tailscale 다운로드 및 설치
|
||||
echo 📦 Tailscale 다운로드 중...
|
||||
powershell -Command "Invoke-WebRequest -Uri 'https://pkgs.tailscale.com/stable/tailscale-setup.exe' -OutFile 'tailscale-setup.exe'"
|
||||
|
||||
echo 🔧 Tailscale 설치 중...
|
||||
tailscale-setup.exe /S
|
||||
|
||||
:: Headscale 등록
|
||||
echo 🌐 Headscale 서버 등록 중...
|
||||
"C:\Program Files\Tailscale\tailscale.exe" up --login-server=https://head.0bin.in --authkey=8b3df41d37cb158ea39f41fc32c9af46e761de817ad06038
|
||||
|
||||
echo ✅ 설치 완료!
|
||||
pause
|
||||
```
|
||||
|
||||
### 장점
|
||||
- ✅ 개발 간단
|
||||
- ✅ Windows 네이티브 지원
|
||||
- ✅ 의존성 없음
|
||||
- ✅ 디버깅 용이
|
||||
|
||||
### 단점
|
||||
- ❌ 기능 제한적
|
||||
- ❌ 에러 처리 복잡
|
||||
- ❌ 사용자 경험 떨어짐
|
||||
|
||||
---
|
||||
|
||||
## 🎯 권장 구현 우선순위
|
||||
|
||||
### 1단계: PowerShell 스크립트 (즉시 구현 가능)
|
||||
**기간**: 1-2일
|
||||
**난이도**: 하
|
||||
|
||||
```powershell
|
||||
# 사용자 실행 방법
|
||||
# 1. 관리자 PowerShell 열기
|
||||
# 2. 다음 명령 실행
|
||||
iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/.../farmq-install.ps1'))
|
||||
```
|
||||
|
||||
### 2단계: 실행 파일 (EXE) 방식
|
||||
**기간**: 3-5일
|
||||
**난이도**: 중
|
||||
|
||||
- Go언어로 크로스 컴파일
|
||||
- 단일 실행파일로 배포
|
||||
- GUI 옵션 포함
|
||||
|
||||
### 3단계: MSI 설치 패키지
|
||||
**기간**: 1주일
|
||||
**난이도**: 상
|
||||
|
||||
- 전문적인 설치 경험
|
||||
- 제어판 등록
|
||||
- 그룹 정책 배포 지원
|
||||
|
||||
## 📋 PowerShell 스크립트 구현 명세서
|
||||
|
||||
### 기본 기능
|
||||
```powershell
|
||||
# 1. 시스템 확인
|
||||
- Windows 버전 체크 (Windows 10/11 지원)
|
||||
- 관리자 권한 확인
|
||||
- 인터넷 연결 확인
|
||||
|
||||
# 2. Tailscale 설치
|
||||
- 기존 설치 확인
|
||||
- 최신 버전 다운로드
|
||||
- 자동 설치 (Silent Install)
|
||||
- 서비스 시작
|
||||
|
||||
# 3. Headscale 등록
|
||||
- 기존 연결 확인 및 해제
|
||||
- Pre-auth key로 자동 등록
|
||||
- 연결 상태 확인
|
||||
|
||||
# 4. 방화벽 설정
|
||||
- Windows Defender 예외 추가
|
||||
- 필요한 포트 허용
|
||||
|
||||
# 5. 완료 확인
|
||||
- IP 주소 할당 확인
|
||||
- 네트워크 연결 테스트
|
||||
- 상태 출력
|
||||
```
|
||||
|
||||
### 사용자 시나리오
|
||||
```
|
||||
1. 약국 직원이 새 PC 설정
|
||||
2. 관리자 PowerShell 실행
|
||||
3. 원클릭 명령어 붙여넣기
|
||||
4. 자동 설치 진행 (2-3분)
|
||||
5. 팜큐 네트워크 연결 완료
|
||||
```
|
||||
|
||||
## 🔧 즉시 구현 가능한 MVP
|
||||
|
||||
### 파일 구조
|
||||
```
|
||||
farmq-windows-installer/
|
||||
├── farmq-install.ps1 # 메인 설치 스크립트
|
||||
├── modules/
|
||||
│ ├── system-check.ps1 # 시스템 확인
|
||||
│ ├── tailscale-installer.ps1 # Tailscale 설치
|
||||
│ ├── headscale-register.ps1 # Headscale 등록
|
||||
│ └── network-verify.ps1 # 네트워크 확인
|
||||
├── resources/
|
||||
│ └── farmq-logo.ico # 아이콘
|
||||
└── README-windows.md # Windows 설치 가이드
|
||||
```
|
||||
|
||||
### 웹 실행 명령어
|
||||
```powershell
|
||||
# 기본 설치
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force; iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/main/farmq-install.ps1'))
|
||||
|
||||
# 강제 재설치
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force; iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/main/farmq-install.ps1?force=1'))
|
||||
```
|
||||
|
||||
## 📊 예상 사용 통계
|
||||
|
||||
### 대상 사용자
|
||||
- **팜큐 약국**: ~100개 약국 × 2-3대 PC = 200-300대
|
||||
- **신규 PC**: 월 10-20대 추가
|
||||
- **재설치**: 월 5-10건
|
||||
|
||||
### 성공 지표
|
||||
- ✅ **설치 성공률**: 95% 이상
|
||||
- ✅ **설치 시간**: 3분 이내
|
||||
- ✅ **사용자 만족도**: 5점 만점 4점 이상
|
||||
- ✅ **지원 요청**: 월 5건 이하
|
||||
|
||||
## 🚀 결론 및 추천
|
||||
|
||||
**즉시 구현 권장**: PowerShell 원클릭 스크립트
|
||||
|
||||
1. **개발 용이성** ⭐⭐⭐⭐⭐
|
||||
2. **사용자 편의성** ⭐⭐⭐⭐
|
||||
3. **유지보수성** ⭐⭐⭐⭐⭐
|
||||
4. **배포 편의성** ⭐⭐⭐⭐⭐
|
||||
|
||||
**다음 단계**: PowerShell 스크립트 구현 → EXE 파일 → MSI 패키지 순으로 단계적 발전
|
||||
|
||||
---
|
||||
|
||||
**목표**: "Linux처럼 Windows에서도 한 줄 명령어로 팜큐 네트워크 연결!" 🎯
|
||||
183
WINDOWS_QUICK_START.md
Normal file
@ -0,0 +1,183 @@
|
||||
# 🪟 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초 후 팜큐 네트워크 연결!** 🚀
|
||||
196
add-client.sh
Executable file
@ -0,0 +1,196 @@
|
||||
#!/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}"
|
||||
74
clean-database.py
Normal file
@ -0,0 +1,74 @@
|
||||
#!/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()
|
||||
@ -1,5 +1,5 @@
|
||||
---
|
||||
server_url: http://localhost:8080
|
||||
server_url: http://localhost:8070
|
||||
listen_addr: 0.0.0.0:8080
|
||||
metrics_listen_addr: 0.0.0.0:9090
|
||||
|
||||
@ -7,8 +7,9 @@ private_key_path: /var/lib/headscale/private.key
|
||||
noise:
|
||||
private_key_path: /var/lib/headscale/noise_private.key
|
||||
|
||||
ip_prefixes:
|
||||
- 100.64.0.0/10
|
||||
prefixes:
|
||||
v4: 100.64.0.0/10
|
||||
v6: fd7a:115c:a1e0::/48
|
||||
|
||||
derp:
|
||||
server:
|
||||
@ -18,6 +19,7 @@ derp:
|
||||
|
||||
disable_check_updates: false
|
||||
ephemeral_node_inactivity_timeout: 30m
|
||||
|
||||
database:
|
||||
type: sqlite3
|
||||
sqlite:
|
||||
@ -38,17 +40,21 @@ log:
|
||||
format: text
|
||||
level: info
|
||||
|
||||
acl_policy_path: ""
|
||||
|
||||
dns_config:
|
||||
# Updated DNS configuration format
|
||||
dns:
|
||||
override_local_dns: true
|
||||
nameservers:
|
||||
- 1.1.1.1
|
||||
- 8.8.8.8
|
||||
domains: []
|
||||
global:
|
||||
- 1.1.1.1
|
||||
- 8.8.8.8
|
||||
search_domains: []
|
||||
magic_dns: true
|
||||
base_domain: headscale.local
|
||||
|
||||
# Updated policy path
|
||||
policy:
|
||||
path: ""
|
||||
|
||||
unix_socket: /var/run/headscale/headscale.sock
|
||||
unix_socket_permission: "0770"
|
||||
|
||||
@ -57,13 +63,13 @@ logtail:
|
||||
|
||||
randomize_client_port: false
|
||||
|
||||
# Simplified OIDC configuration (removed deprecated keys)
|
||||
oidc:
|
||||
only_start_if_oidc_is_available: true
|
||||
only_start_if_oidc_is_available: false
|
||||
issuer: ""
|
||||
client_id: ""
|
||||
client_secret: ""
|
||||
scope: ["openid", "profile", "email"]
|
||||
extra_params: {}
|
||||
allowed_domains: []
|
||||
allowed_users: []
|
||||
strip_email_domain: true
|
||||
allowed_users: []
|
||||
167
create-preauth-key.sh
Executable file
@ -0,0 +1,167 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 팜큐(FARMQ) Pre-auth Key 생성 스크립트
|
||||
# 사용법: ./create-preauth-key.sh [사용자명] [유효기간(시간)]
|
||||
|
||||
set -e
|
||||
|
||||
# 기본 설정
|
||||
DEFAULT_USER="myuser"
|
||||
DEFAULT_EXPIRY="24h" # 24시간
|
||||
|
||||
# 사용법 출력
|
||||
usage() {
|
||||
echo "사용법: $0 [사용자명] [유효기간]"
|
||||
echo ""
|
||||
echo "예시:"
|
||||
echo " $0 # myuser 사용자, 24시간 유효"
|
||||
echo " $0 pharmacy1 # pharmacy1 사용자, 24시간 유효"
|
||||
echo " $0 pharmacy1 7d # pharmacy1 사용자, 7일 유효"
|
||||
echo " $0 pharmacy1 1h # pharmacy1 사용자, 1시간 유효"
|
||||
echo ""
|
||||
echo "유효기간 형식: 1h, 24h, 7d, 30d 등"
|
||||
}
|
||||
|
||||
# 색상 출력 함수
|
||||
print_status() {
|
||||
echo -e "\n🔧 $1"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "\n✅ $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "\n❌ $1"
|
||||
}
|
||||
|
||||
print_info() {
|
||||
echo -e "\n📋 $1"
|
||||
}
|
||||
|
||||
# 사용자 존재 확인
|
||||
check_user_exists() {
|
||||
local username=$1
|
||||
|
||||
print_status "사용자 '$username' 확인 중..."
|
||||
|
||||
if docker exec headscale headscale users list | grep -q "$username"; then
|
||||
print_info "사용자 '$username'이 존재합니다."
|
||||
return 0
|
||||
else
|
||||
print_error "사용자 '$username'이 존재하지 않습니다."
|
||||
print_info "사용자 생성 중..."
|
||||
|
||||
if docker exec headscale headscale users create "$username"; then
|
||||
print_success "사용자 '$username'이 생성되었습니다."
|
||||
else
|
||||
print_error "사용자 생성에 실패했습니다."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 사용자 ID 가져오기
|
||||
get_user_id() {
|
||||
local username=$1
|
||||
|
||||
local user_id=$(docker exec headscale headscale users list | grep "$username" | awk '{print $1}')
|
||||
|
||||
if [[ -n "$user_id" ]]; then
|
||||
echo $user_id
|
||||
else
|
||||
print_error "사용자 ID를 찾을 수 없습니다."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Pre-auth key 생성
|
||||
create_preauth_key() {
|
||||
local username=$1
|
||||
local expiry=$2
|
||||
|
||||
print_status "Pre-auth key 생성 중..."
|
||||
print_info "사용자: $username"
|
||||
print_info "유효기간: $expiry"
|
||||
|
||||
local user_id=$(get_user_id "$username")
|
||||
print_info "사용자 ID: $user_id"
|
||||
|
||||
# Pre-auth key 생성 (재사용 가능, 임시 아님)
|
||||
local preauth_output=$(docker exec headscale headscale preauthkeys create \
|
||||
-u "$user_id" \
|
||||
--expiration "$expiry" \
|
||||
--reusable)
|
||||
|
||||
if [[ $? -eq 0 ]]; then
|
||||
# Key 값 추출
|
||||
local preauth_key=$(echo "$preauth_output" | grep -o '[a-f0-9]\{48\}')
|
||||
|
||||
if [[ -n "$preauth_key" ]]; then
|
||||
print_success "Pre-auth key가 생성되었습니다!"
|
||||
print_info "Key: $preauth_key"
|
||||
|
||||
# 클라이언트 등록 스크립트에 추가할 명령어 출력
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "📋 클라이언트에서 사용할 명령어:"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Linux/macOS:"
|
||||
echo "sudo tailscale up \\"
|
||||
echo " --login-server=\"https://head.0bin.in\" \\"
|
||||
echo " --authkey=\"$preauth_key\" \\"
|
||||
echo " --accept-routes \\"
|
||||
echo " --accept-dns=false"
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "📋 등록 스크립트 업데이트:"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "register-client.sh 파일의 PREAUTH_KEY 값을 다음으로 업데이트하세요:"
|
||||
echo "PREAUTH_KEY=\"$preauth_key\""
|
||||
echo ""
|
||||
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
print_error "Pre-auth key 생성에 실패했습니다."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 기존 Pre-auth key 목록 표시
|
||||
list_existing_keys() {
|
||||
local username=$1
|
||||
local user_id=$(get_user_id "$username")
|
||||
|
||||
print_info "기존 Pre-auth key 목록 (사용자: $username):"
|
||||
docker exec headscale headscale preauthkeys list -u "$user_id"
|
||||
}
|
||||
|
||||
# 메인 함수
|
||||
main() {
|
||||
local username="${1:-$DEFAULT_USER}"
|
||||
local expiry="${2:-$DEFAULT_EXPIRY}"
|
||||
|
||||
echo "=========================================="
|
||||
echo " 🔑 팜큐(FARMQ) Pre-auth Key 생성"
|
||||
echo "=========================================="
|
||||
|
||||
# 도움말 요청 확인
|
||||
if [[ "$1" == "-h" ]] || [[ "$1" == "--help" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
check_user_exists "$username"
|
||||
list_existing_keys "$username"
|
||||
create_preauth_key "$username" "$expiry"
|
||||
|
||||
echo ""
|
||||
print_success "완료!"
|
||||
print_info "이 key는 $expiry 동안 유효하며, 여러 번 사용할 수 있습니다."
|
||||
}
|
||||
|
||||
# 스크립트 실행
|
||||
main "$@"
|
||||
@ -5,7 +5,7 @@ services:
|
||||
image: headscale/headscale:latest
|
||||
container_name: headscale
|
||||
restart: unless-stopped
|
||||
command: headscale serve
|
||||
command: serve
|
||||
environment:
|
||||
- TZ=Asia/Seoul
|
||||
volumes:
|
||||
@ -13,16 +13,16 @@ services:
|
||||
- ./data:/var/lib/headscale
|
||||
- ./run:/var/run/headscale
|
||||
ports:
|
||||
- "8080:8080" # Headscale HTTP API
|
||||
- "8070:8080" # Headscale HTTP API (외부:내부)
|
||||
- "9090:9090" # Metrics (optional)
|
||||
networks:
|
||||
- headscale-net
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||
test: ["CMD", "/ko-app/headscale", "version"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
start_period: 30s
|
||||
|
||||
headplane:
|
||||
image: ghcr.io/tale/headplane:latest
|
||||
@ -32,11 +32,12 @@ services:
|
||||
- TZ=Asia/Seoul
|
||||
- HEADSCALE_URL=http://headscale:8080
|
||||
- HEADSCALE_API_KEY=${HEADSCALE_API_KEY}
|
||||
volumes:
|
||||
- ./headplane-config:/etc/headplane
|
||||
ports:
|
||||
- "3000:3000" # Headplane Web UI
|
||||
depends_on:
|
||||
headscale:
|
||||
condition: service_healthy
|
||||
- headscale
|
||||
networks:
|
||||
- headscale-net
|
||||
|
||||
|
||||
190
farmq-admin/Stable_PVE_Authentication_Strategy.md
Normal file
@ -0,0 +1,190 @@
|
||||
# 안정적인 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 프록시를 통해 브라우저 환경에서도 안정적으로 구현하자는 것이 이 전략의 핵심입니다.
|
||||
249
farmq-admin/VNC_Implementation_Technical_Documentation.md
Normal file
@ -0,0 +1,249 @@
|
||||
# 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**: 최신 버전
|
||||
161
farmq-admin/VNC_Network_Architecture_Issue.md
Normal file
@ -0,0 +1,161 @@
|
||||
# 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에 접근할 수 있도록 하는 것이 가장 현실적인 해결책입니다.
|
||||
207
farmq-admin/VNC_WebSocket_Connection_Issue_Resolution.md
Normal file
@ -0,0 +1,207 @@
|
||||
# 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 서버에서 발생했습니다.
|
||||
2270
farmq-admin/app.py
Normal file
104
farmq-admin/check_existing_data.py
Normal file
@ -0,0 +1,104 @@
|
||||
#!/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()
|
||||
56
farmq-admin/config.py
Normal file
@ -0,0 +1,56 @@
|
||||
"""
|
||||
Flask 애플리케이션 설정
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
class Config:
|
||||
"""기본 설정"""
|
||||
# Flask 기본 설정
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') or 'farmq-secret-key-change-in-production'
|
||||
|
||||
# 데이터베이스 설정 (기존 Headscale SQLite DB 사용)
|
||||
DATABASE_PATH = '/srv/headscale-setup/data/db.sqlite'
|
||||
SQLALCHEMY_DATABASE_URI = f'sqlite:///{DATABASE_PATH}'
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
# 기존 Headplane 연동 설정
|
||||
HEADPLANE_URL = os.environ.get('HEADPLANE_URL') or 'http://localhost:3000'
|
||||
HEADSCALE_URL = os.environ.get('HEADSCALE_URL') or 'http://localhost:8070'
|
||||
HEADSCALE_API_KEY = os.environ.get('HEADSCALE_API_KEY') or '8qRr1IB.tV95CmA0fLaCiGGIgBfeoN9daHceFkzI'
|
||||
|
||||
# 모니터링 설정
|
||||
MONITORING_INTERVAL = 30 # 30초마다 데이터 수집
|
||||
MAX_MONITORING_RECORDS = 1000 # 최대 저장 레코드 수
|
||||
|
||||
# UI 설정
|
||||
APP_TITLE = '팜큐 약국 관리 시스템'
|
||||
ITEMS_PER_PAGE = 20 # 페이지당 아이템 수
|
||||
|
||||
# Proxmox 설정 (추후 확장)
|
||||
PROXMOX_DEFAULT_PORT = 8006
|
||||
PROXMOX_VERIFY_SSL = False
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
"""개발 환경 설정"""
|
||||
DEBUG = True
|
||||
TESTING = False
|
||||
|
||||
class ProductionConfig(Config):
|
||||
"""프로덕션 환경 설정"""
|
||||
DEBUG = False
|
||||
TESTING = False
|
||||
|
||||
class TestingConfig(Config):
|
||||
"""테스트 환경 설정"""
|
||||
DEBUG = True
|
||||
TESTING = True
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
|
||||
|
||||
# 환경별 설정 매핑
|
||||
config = {
|
||||
'development': DevelopmentConfig,
|
||||
'production': ProductionConfig,
|
||||
'testing': TestingConfig,
|
||||
'default': DevelopmentConfig
|
||||
}
|
||||
347
farmq-admin/create_subscription_tables.py
Normal file
@ -0,0 +1,347 @@
|
||||
#!/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)
|
||||
79
farmq-admin/debug_machine.py
Normal file
@ -0,0 +1,79 @@
|
||||
#!/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)
|
||||
247
farmq-admin/flask-venv/bin/Activate.ps1
Normal file
@ -0,0 +1,247 @@
|
||||
<#
|
||||
.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"
|
||||
70
farmq-admin/flask-venv/bin/activate
Normal file
@ -0,0 +1,70 @@
|
||||
# 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
|
||||
27
farmq-admin/flask-venv/bin/activate.csh
Normal file
@ -0,0 +1,27 @@
|
||||
# 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
|
||||
69
farmq-admin/flask-venv/bin/activate.fish
Normal file
@ -0,0 +1,69 @@
|
||||
# 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
|
||||
8
farmq-admin/flask-venv/bin/flask
Executable file
@ -0,0 +1,8 @@
|
||||
#!/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())
|
||||
8
farmq-admin/flask-venv/bin/normalizer
Executable file
@ -0,0 +1,8 @@
|
||||
#!/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())
|
||||
8
farmq-admin/flask-venv/bin/pip
Executable file
@ -0,0 +1,8 @@
|
||||
#!/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())
|
||||
8
farmq-admin/flask-venv/bin/pip3
Executable file
@ -0,0 +1,8 @@
|
||||
#!/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())
|
||||
8
farmq-admin/flask-venv/bin/pip3.12
Executable file
@ -0,0 +1,8 @@
|
||||
#!/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())
|
||||
1
farmq-admin/flask-venv/bin/python
Symbolic link
@ -0,0 +1 @@
|
||||
python3
|
||||
1
farmq-admin/flask-venv/bin/python3
Symbolic link
@ -0,0 +1 @@
|
||||
/usr/bin/python3
|
||||
1
farmq-admin/flask-venv/bin/python3.12
Symbolic link
@ -0,0 +1 @@
|
||||
python3
|
||||
8
farmq-admin/flask-venv/bin/websockets
Executable file
@ -0,0 +1,8 @@
|
||||
#!/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())
|
||||
@ -0,0 +1,164 @@
|
||||
/* -*- 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 */
|
||||
1
farmq-admin/flask-venv/lib64
Symbolic link
@ -0,0 +1 @@
|
||||
lib
|
||||
5
farmq-admin/flask-venv/pyvenv.cfg
Normal file
@ -0,0 +1,5 @@
|
||||
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
|
||||
209
farmq-admin/init_sample_data.py
Normal file
@ -0,0 +1,209 @@
|
||||
#!/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()
|
||||
34
farmq-admin/models/__init__.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""
|
||||
모델 패키지 초기화
|
||||
Headscale 호환성 모델과 FARMQ 전용 모델 분리
|
||||
"""
|
||||
|
||||
# Headscale 읽기 전용 모델 (외래키 제약조건 없음)
|
||||
from .headscale_models import (
|
||||
User, Node, PreAuthKey, ApiKey, Policy,
|
||||
Base, create_all_tables
|
||||
)
|
||||
|
||||
# FARMQ 전용 모델 (별도 데이터베이스)
|
||||
from .farmq_models import (
|
||||
PharmacyInfo, MachineProfile, MonitoringMetrics, SystemAlert,
|
||||
FarmqDatabaseManager, create_farmq_database_manager,
|
||||
FarmqBase
|
||||
)
|
||||
|
||||
# 하위 호환성을 위한 별칭
|
||||
MachineSpecs = MachineProfile # 기존 이름과의 호환성
|
||||
MonitoringData = MonitoringMetrics # 기존 이름과의 호환성
|
||||
|
||||
__all__ = [
|
||||
# Headscale 모델
|
||||
'User', 'Node', 'PreAuthKey', 'ApiKey', 'Policy',
|
||||
'Base', 'create_all_tables',
|
||||
|
||||
# FARMQ 모델
|
||||
'PharmacyInfo', 'MachineProfile', 'MonitoringMetrics', 'SystemAlert',
|
||||
'FarmqDatabaseManager', 'create_farmq_database_manager', 'FarmqBase',
|
||||
|
||||
# 하위 호환성 별칭
|
||||
'MachineSpecs', 'MonitoringData'
|
||||
]
|
||||
511
farmq-admin/models/farmq_models.py
Normal file
@ -0,0 +1,511 @@
|
||||
"""
|
||||
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}")
|
||||
385
farmq-admin/models/headscale_models.py
Normal file
@ -0,0 +1,385 @@
|
||||
"""
|
||||
Headscale SQLite Database Models for SQLAlchemy
|
||||
Based on actual schema analysis of Headscale v0.23.0
|
||||
|
||||
Generated from: /var/lib/headscale/db.sqlite
|
||||
Schema Analysis Date: 2025-09-09
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List
|
||||
import json
|
||||
from sqlalchemy import (
|
||||
Column, Integer, String, DateTime, Boolean, Text,
|
||||
ForeignKey, LargeBinary, Index, UniqueConstraint
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship, Session
|
||||
from sqlalchemy.types import TypeDecorator, TEXT
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class JSONType(TypeDecorator):
|
||||
"""Custom JSON type for SQLAlchemy that handles JSON serialization"""
|
||||
impl = TEXT
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if value is not None:
|
||||
return json.dumps(value)
|
||||
return value
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
if value is not None:
|
||||
return json.loads(value)
|
||||
return value
|
||||
|
||||
|
||||
class Migration(Base):
|
||||
"""Migration tracking table"""
|
||||
__tablename__ = 'migrations'
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Migration(id='{self.id}')>"
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""Headscale Users table
|
||||
|
||||
Represents individual users/namespaces in the Headscale network.
|
||||
Each user can have multiple nodes (machines) associated with them.
|
||||
"""
|
||||
__tablename__ = 'users'
|
||||
__table_args__ = (
|
||||
Index('idx_users_deleted_at', 'deleted_at'),
|
||||
Index('idx_provider_identifier', 'provider_identifier',
|
||||
postgresql_where="provider_identifier IS NOT NULL"),
|
||||
Index('idx_name_provider_identifier', 'name', 'provider_identifier'),
|
||||
Index('idx_name_no_provider_identifier', 'name',
|
||||
postgresql_where="provider_identifier IS NULL"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
created_at = Column(DateTime)
|
||||
updated_at = Column(DateTime)
|
||||
deleted_at = Column(DateTime) # Soft delete
|
||||
name = Column(String) # User identifier (e.g., "myuser")
|
||||
display_name = Column(String) # Human-readable display name
|
||||
email = Column(String) # User email address
|
||||
provider_identifier = Column(String) # External auth provider ID
|
||||
provider = Column(String) # Auth provider name (OIDC, etc.)
|
||||
profile_pic_url = Column(String) # Profile picture URL
|
||||
|
||||
# Relationships
|
||||
nodes = relationship("Node", back_populates="user", cascade="all, delete-orphan")
|
||||
pre_auth_keys = relationship("PreAuthKey", back_populates="user")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(id={self.id}, name='{self.name}', display_name='{self.display_name}')>"
|
||||
|
||||
def is_deleted(self) -> bool:
|
||||
"""Check if user is soft-deleted"""
|
||||
return self.deleted_at is not None
|
||||
|
||||
|
||||
class Node(Base):
|
||||
"""Headscale Nodes (Machines) table
|
||||
|
||||
Represents individual devices/machines connected to the Tailnet.
|
||||
Each node belongs to a user and has various networking attributes.
|
||||
"""
|
||||
__tablename__ = 'nodes'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
machine_key = Column(String) # Machine's public key
|
||||
node_key = Column(String) # Node's network key
|
||||
disco_key = Column(String) # Discovery key for peer-to-peer connections
|
||||
endpoints = Column(JSONType) # List of network endpoints (JSON array)
|
||||
host_info = Column(JSONType) # Detailed host information (JSON object)
|
||||
ipv4 = Column(String) # Assigned IPv4 address (e.g., "100.64.0.1")
|
||||
ipv6 = Column(String) # Assigned IPv6 address
|
||||
hostname = Column(String) # Machine hostname
|
||||
given_name = Column(String) # User-assigned machine name
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'))
|
||||
register_method = Column(String) # Registration method (e.g., "authkey")
|
||||
forced_tags = Column(JSONType) # Tags forced on this node (JSON array)
|
||||
auth_key_id = Column(Integer, ForeignKey('pre_auth_keys.id'))
|
||||
expiry = Column(DateTime) # Node expiration date
|
||||
last_seen = Column(DateTime) # Last activity timestamp
|
||||
approved_routes = Column(JSONType) # Approved subnet routes (JSON array)
|
||||
created_at = Column(DateTime)
|
||||
updated_at = Column(DateTime)
|
||||
deleted_at = Column(DateTime) # Soft delete
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="nodes")
|
||||
auth_key = relationship("PreAuthKey")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Node(id={self.id}, hostname='{self.hostname}', ipv4='{self.ipv4}', user_id={self.user_id})>"
|
||||
|
||||
def is_online(self, timeout_minutes: int = 1440) -> bool: # 24 hours timeout like Tailscale
|
||||
"""Check if node is considered online based on last_seen"""
|
||||
if not self.last_seen:
|
||||
return False
|
||||
|
||||
# Handle timezone-aware datetime properly
|
||||
now = datetime.now()
|
||||
last_seen = self.last_seen
|
||||
|
||||
# Convert both to naive datetime to avoid timezone issues
|
||||
if last_seen.tzinfo is not None:
|
||||
last_seen = last_seen.replace(tzinfo=None)
|
||||
if now.tzinfo is not None:
|
||||
now = now.replace(tzinfo=None)
|
||||
|
||||
try:
|
||||
time_diff_seconds = (now - last_seen).total_seconds()
|
||||
# Consider online if last seen within timeout_minutes
|
||||
is_recent = time_diff_seconds < (timeout_minutes * 60)
|
||||
return is_recent
|
||||
except TypeError as e:
|
||||
# Fallback: just check if we have a recent timestamp
|
||||
return True
|
||||
|
||||
def get_host_info(self) -> dict:
|
||||
"""Get parsed host information"""
|
||||
return self.host_info or {}
|
||||
|
||||
def get_endpoints(self) -> List[str]:
|
||||
"""Get list of network endpoints"""
|
||||
return self.endpoints or []
|
||||
|
||||
def get_forced_tags(self) -> List[str]:
|
||||
"""Get list of forced tags"""
|
||||
return self.forced_tags or []
|
||||
|
||||
def get_approved_routes(self) -> List[str]:
|
||||
"""Get list of approved routes"""
|
||||
return self.approved_routes or []
|
||||
|
||||
|
||||
class PreAuthKey(Base):
|
||||
"""Pre-authentication keys table
|
||||
|
||||
Keys used for automatic node registration without manual approval.
|
||||
"""
|
||||
__tablename__ = 'pre_auth_keys'
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
key = Column(String) # The actual pre-auth key string
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'))
|
||||
reusable = Column(Boolean) # Can be used multiple times
|
||||
ephemeral = Column(Boolean, default=False) # Temporary key
|
||||
used = Column(Boolean, default=False) # Has been used
|
||||
tags = Column(JSONType) # Tags to apply to nodes using this key
|
||||
created_at = Column(DateTime)
|
||||
expiration = Column(DateTime) # When the key expires
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="pre_auth_keys")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PreAuthKey(id={self.id}, key='{self.key[:8]}...', user_id={self.user_id}, reusable={self.reusable})>"
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if the pre-auth key is expired"""
|
||||
if not self.expiration:
|
||||
return False
|
||||
|
||||
now = datetime.now()
|
||||
expiration = self.expiration
|
||||
|
||||
# Handle timezone-aware datetime
|
||||
if expiration.tzinfo is not None and now.tzinfo is None:
|
||||
from datetime import timezone
|
||||
now = now.replace(tzinfo=timezone.utc)
|
||||
elif expiration.tzinfo is not None:
|
||||
expiration = expiration.replace(tzinfo=None)
|
||||
|
||||
try:
|
||||
return now > expiration
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
"""Check if the key is still valid for use"""
|
||||
if self.is_expired():
|
||||
return False
|
||||
if self.used and not self.reusable:
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_tags(self) -> List[str]:
|
||||
"""Get list of tags for this key"""
|
||||
return self.tags or []
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
"""API Keys table
|
||||
|
||||
Keys used for API authentication to the Headscale server.
|
||||
"""
|
||||
__tablename__ = 'api_keys'
|
||||
__table_args__ = (
|
||||
Index('idx_api_keys_prefix', 'prefix', unique=True),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
prefix = Column(String) # Key prefix for identification (e.g., "8qRr1IB")
|
||||
hash = Column(LargeBinary) # Hashed key value
|
||||
created_at = Column(DateTime)
|
||||
expiration = Column(DateTime) # When the key expires
|
||||
last_seen = Column(DateTime) # Last time key was used
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ApiKey(id={self.id}, prefix='{self.prefix}', created_at='{self.created_at}')>"
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if the API key is expired"""
|
||||
if not self.expiration:
|
||||
return False
|
||||
|
||||
now = datetime.now()
|
||||
expiration = self.expiration
|
||||
|
||||
# Handle timezone-aware datetime
|
||||
if expiration.tzinfo is not None and now.tzinfo is None:
|
||||
from datetime import timezone
|
||||
now = now.replace(tzinfo=timezone.utc)
|
||||
elif expiration.tzinfo is not None:
|
||||
expiration = expiration.replace(tzinfo=None)
|
||||
|
||||
try:
|
||||
return now > expiration
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
|
||||
class Policy(Base):
|
||||
"""ACL Policies table
|
||||
|
||||
Stores Access Control List policies in JSON format.
|
||||
"""
|
||||
__tablename__ = 'policies'
|
||||
__table_args__ = (
|
||||
Index('idx_policies_deleted_at', 'deleted_at'),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
created_at = Column(DateTime)
|
||||
updated_at = Column(DateTime)
|
||||
deleted_at = Column(DateTime) # Soft delete
|
||||
data = Column(Text) # JSON policy data
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Policy(id={self.id}, created_at='{self.created_at}')>"
|
||||
|
||||
def get_policy_data(self) -> dict:
|
||||
"""Parse and return policy data as dictionary"""
|
||||
try:
|
||||
return json.loads(self.data) if self.data else {}
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Extended Models for FARMQ Customization
|
||||
# ==========================================
|
||||
|
||||
# ==========================================
|
||||
# Extended Models for FARMQ Customization - DEPRECATED
|
||||
# ==========================================
|
||||
#
|
||||
# 주의: 이 모델들은 Headscale과 외래키 충돌을 일으키므로 더 이상 사용하지 않습니다.
|
||||
# 대신 farmq_models.py의 독립적인 모델들을 사용하세요.
|
||||
#
|
||||
# 마이그레이션 가이드:
|
||||
# - PharmacyInfo -> farmq_models.PharmacyInfo
|
||||
# - MachineSpecs -> farmq_models.MachineProfile
|
||||
# - MonitoringData -> farmq_models.MonitoringMetrics
|
||||
#
|
||||
# 이 클래스들은 하위 호환성을 위해 유지되지만 실제 테이블은 생성되지 않습니다.
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Database Helper Functions
|
||||
# ==========================================
|
||||
|
||||
def create_all_tables(engine):
|
||||
"""Create all tables in the database"""
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
|
||||
def get_active_nodes(session: Session) -> List[Node]:
|
||||
"""Get all non-deleted nodes"""
|
||||
return session.query(Node).filter(Node.deleted_at.is_(None)).all()
|
||||
|
||||
|
||||
def get_online_nodes(session: Session, timeout_minutes: int = 5) -> List[Node]:
|
||||
"""Get nodes that are currently online"""
|
||||
cutoff_time = datetime.now() - timedelta(minutes=timeout_minutes)
|
||||
return session.query(Node).filter(
|
||||
Node.deleted_at.is_(None),
|
||||
Node.last_seen > cutoff_time
|
||||
).all()
|
||||
|
||||
|
||||
def get_user_with_pharmacy_info(session: Session, user_name: str):
|
||||
"""Get user with associated pharmacy information"""
|
||||
return session.query(User).join(PharmacyInfo).filter(User.name == user_name).first()
|
||||
|
||||
|
||||
def get_node_with_specs_and_monitoring(session: Session, node_id: int):
|
||||
"""Get node with hardware specs and latest monitoring data"""
|
||||
return session.query(Node)\
|
||||
.outerjoin(MachineSpecs)\
|
||||
.outerjoin(MonitoringData)\
|
||||
.filter(Node.id == node_id)\
|
||||
.first()
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Usage Example
|
||||
# ==========================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# SQLite connection to Headscale database
|
||||
DATABASE_URL = "sqlite:///data/db.sqlite"
|
||||
engine = create_engine(DATABASE_URL)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# Create extended tables (if needed)
|
||||
create_all_tables(engine)
|
||||
|
||||
# Example usage
|
||||
session = SessionLocal()
|
||||
try:
|
||||
# Get all users
|
||||
users = session.query(User).all()
|
||||
print("=== Users ===")
|
||||
for user in users:
|
||||
print(f" {user}")
|
||||
|
||||
# Get all nodes
|
||||
nodes = session.query(Node).all()
|
||||
print("\n=== Nodes ===")
|
||||
for node in nodes:
|
||||
print(f" {node}")
|
||||
print(f" Online: {node.is_online()}")
|
||||
print(f" Host Info: {node.get_host_info().get('Hostname', 'Unknown')}")
|
||||
|
||||
# Get all API keys
|
||||
api_keys = session.query(ApiKey).all()
|
||||
print("\n=== API Keys ===")
|
||||
for key in api_keys:
|
||||
print(f" {key}")
|
||||
print(f" Expired: {key.is_expired()}")
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
8
farmq-admin/requirements.txt
Normal file
@ -0,0 +1,8 @@
|
||||
Flask==3.0.0
|
||||
SQLAlchemy==2.0.23
|
||||
Jinja2==3.1.2
|
||||
Flask-Login==0.6.3
|
||||
APScheduler==3.10.4
|
||||
requests==2.31.0
|
||||
python-dateutil==2.8.2
|
||||
humanize==4.8.0
|
||||
13
farmq-admin/static/novnc/AUTHORS
Normal file
@ -0,0 +1,13 @@
|
||||
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.
|
||||
62
farmq-admin/static/novnc/LICENSE.txt
Normal file
@ -0,0 +1,62 @@
|
||||
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
|
||||
230
farmq-admin/static/novnc/README.md
Normal file
@ -0,0 +1,230 @@
|
||||
## noVNC: HTML VNC Client Library and Application
|
||||
|
||||
[](https://github.com/novnc/noVNC/actions?query=workflow%3ATest)
|
||||
[](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>
|
||||
<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!
|
||||
79
farmq-admin/static/novnc/app/error-handler.js
Normal file
@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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));
|
||||
92
farmq-admin/static/novnc/app/images/alt.svg
Normal file
@ -0,0 +1,92 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
106
farmq-admin/static/novnc/app/images/clipboard.svg
Normal file
@ -0,0 +1,106 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
96
farmq-admin/static/novnc/app/images/connect.svg
Normal file
@ -0,0 +1,96 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
96
farmq-admin/static/novnc/app/images/ctrl.svg
Normal file
@ -0,0 +1,96 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
100
farmq-admin/static/novnc/app/images/ctrlaltdel.svg
Normal file
@ -0,0 +1,100 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
94
farmq-admin/static/novnc/app/images/disconnect.svg
Normal file
@ -0,0 +1,94 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
76
farmq-admin/static/novnc/app/images/drag.svg
Normal file
@ -0,0 +1,76 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
81
farmq-admin/static/novnc/app/images/error.svg
Normal file
@ -0,0 +1,81 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
92
farmq-admin/static/novnc/app/images/esc.svg
Normal file
@ -0,0 +1,92 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
69
farmq-admin/static/novnc/app/images/expander.svg
Normal file
@ -0,0 +1,69 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
93
farmq-admin/static/novnc/app/images/fullscreen.svg
Normal file
@ -0,0 +1,93 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
82
farmq-admin/static/novnc/app/images/handle.svg
Normal file
@ -0,0 +1,82 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
172
farmq-admin/static/novnc/app/images/handle_bg.svg
Normal file
@ -0,0 +1,172 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 6.2 KiB |
42
farmq-admin/static/novnc/app/images/icons/Makefile
Normal file
@ -0,0 +1,42 @@
|
||||
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
|
||||
163
farmq-admin/static/novnc/app/images/icons/novnc-icon-sm.svg
Normal file
@ -0,0 +1,163 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 11 KiB |
163
farmq-admin/static/novnc/app/images/icons/novnc-icon.svg
Normal file
@ -0,0 +1,163 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 11 KiB |
BIN
farmq-admin/static/novnc/app/images/icons/novnc-ios-120.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
farmq-admin/static/novnc/app/images/icons/novnc-ios-152.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
farmq-admin/static/novnc/app/images/icons/novnc-ios-167.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
farmq-admin/static/novnc/app/images/icons/novnc-ios-180.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
farmq-admin/static/novnc/app/images/icons/novnc-ios-40.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
farmq-admin/static/novnc/app/images/icons/novnc-ios-58.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
farmq-admin/static/novnc/app/images/icons/novnc-ios-60.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
farmq-admin/static/novnc/app/images/icons/novnc-ios-80.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
farmq-admin/static/novnc/app/images/icons/novnc-ios-87.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
183
farmq-admin/static/novnc/app/images/icons/novnc-ios-icon.svg
Normal file
@ -0,0 +1,183 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 12 KiB |
BIN
farmq-admin/static/novnc/app/images/icons/novnc.ico
Normal file
|
After Width: | Height: | Size: 303 KiB |
81
farmq-admin/static/novnc/app/images/info.svg
Normal file
@ -0,0 +1,81 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
88
farmq-admin/static/novnc/app/images/keyboard.svg
Normal file
@ -0,0 +1,88 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 6.3 KiB |
87
farmq-admin/static/novnc/app/images/power.svg
Normal file
@ -0,0 +1,87 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
76
farmq-admin/static/novnc/app/images/settings.svg
Normal file
@ -0,0 +1,76 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
86
farmq-admin/static/novnc/app/images/tab.svg
Normal file
@ -0,0 +1,86 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
90
farmq-admin/static/novnc/app/images/toggleextrakeys.svg
Normal file
@ -0,0 +1,90 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
81
farmq-admin/static/novnc/app/images/warning.svg
Normal file
@ -0,0 +1,81 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
65
farmq-admin/static/novnc/app/images/windows.svg
Normal file
@ -0,0 +1,65 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
1
farmq-admin/static/novnc/app/locale/README
Normal file
@ -0,0 +1 @@
|
||||
DO NOT MODIFY THE FILES IN THIS FOLDER, THEY ARE AUTOMATICALLY GENERATED FROM THE PO-FILES.
|
||||
71
farmq-admin/static/novnc/app/locale/cs.json
Normal file
@ -0,0 +1,71 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
69
farmq-admin/static/novnc/app/locale/de.json
Normal file
@ -0,0 +1,69 @@
|
||||
{
|
||||
"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."
|
||||
}
|
||||