45 Commits

Author SHA1 Message Date
PharmQ Admin
99fd031d5a fix(windows): skip install if Tailscale already present (avoid MSI 1603)
A prior .exe(NSIS) install leaves Tailscale on disk but not on PATH; the MSI
then fails with error 1603 trying to install over it. Now detect the existing
binary at C:\Program Files\Tailscale\tailscale.exe and skip installation, and
treat a nonzero msiexec exit as success when the binary is already present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 01:17:07 +00:00
PharmQ Admin
32118e7c6c fix(windows): add service polling + tailscaled fallback to KO script
EN script에만 들어갔던 서비스 폴링/tailscaled 폴백을 한글 스크립트에도 반영.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 01:08:20 +00:00
PharmQ Admin
b581a2b3a8 fix(windows): install Tailscale via MSI (msiexec /quiet) + wait loops
.exe /S (NSIS) 사일런트 설치가 서비스/실행파일을 스크립트 진행 전에 확실히
등록하지 못해 설치 직후 'Tailscale service not found' / 'executable not found'
오류가 났다. 공식 무인 설치 경로인 MSI(msiexec /i /quiet /norestart)로 변경하고,
설치 후 tailscale.exe 와 서비스가 나타날 때까지 폴링하는 재시도 루프를 추가.
서비스가 끝내 없으면 tailscaled install 폴백.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 01:07:31 +00:00
PharmQ Admin
7f3a6b9302 fix(windows): use pkgs stable 'latest' installer to avoid 404
GitHub의 releases/latest 태그(예: 1.98.3)와 pkgs.tailscale.com/stable
채널 버전(예: 1.98.4)이 어긋나면 tailscale-setup-<버전>.exe URL이 404가
난다. 버전 조립을 없애고 항상 존재하는 tailscale-setup-latest.exe를 사용.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 15:54:29 +00:00
PharmQ Admin
e778ecd4fc Disable MagicDNS in quick-install script (accept-dns=false)
시스템 기본 DNS 설정을 유지하도록 --accept-dns=false로 변경

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:55:39 +00:00
PharmQ Admin
1ae707a985 Fix DNS resolution: Add fallback DNS for external domains
Problem:
- When --accept-dns=true is used, MagicDNS (100.100.100.100) becomes
  the only DNS resolver for systemd-resolved
- If MagicDNS fails to forward external queries, domains like
  google.com become unreachable
- This commonly occurs due to network latency or connectivity issues

Solution:
- Add configure_dns_fallback() function to quick-install.sh
- Create /etc/systemd/resolved.conf.d/headscale-fallback.conf
- Set FallbackDNS to 1.1.1.1, 8.8.8.8, 168.126.63.1 (Korea DNS)
- Add external DNS verification test in verify_connection()
- Support non-systemd systems via /etc/resolv.conf modification

Result:
- MagicDNS continues to work for *.headscale.local internal domains
- External domains resolve via fallback DNS when MagicDNS fails
- Installation script verifies DNS resolution before completion

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 11:10:27 +00:00
PharmQ Admin
41d3e7d946 Add code-server one-line installation to README
- Add new section: 개발 환경 설정 (code-server)
- One-line installation with curl command
- Support custom port configuration
- Support unattended installation with PASSWORD env var
- Include security recommendations (reverse proxy, strong password, firewall, VPN)
- Auto-install code-server if not present
- Auto-cleanup existing processes
- nohup background execution

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 07:55:49 +00:00
PharmQ Admin
8d27461f76 Add pharmacy auto-registration and infrastructure improvements
- Auto-generate pharmacy_code (P001~P999) when creating new pharmacy
- Add new pharmacy fields: owner info, institution code/type, API port
- Change Headplane port mapping: 3000 → 3001 to avoid conflicts
- Add code-server setup script for development environment
- Add LXC Caddy setup documentation
- Update .gitignore to exclude farmq-admin submodule

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-02 07:54:47 +00:00
PharmQ Admin
f739916737 🔑 Fix preauth key in Linux quick-install script
Update PREAUTH_KEY to match the correct key used in Windows PowerShell script:
- Old: 8b3df41d37cb158ea39f41fc32c9af46e761de817ad06038
- New: b46923995afeaec90e588168f2e1bf99801775e8657ce003

This fixes the registration failure issue.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 11:20:07 +00:00
PharmQ Admin
46192651f9 🔧 Update Linux quick-install script to use live production server
Update HEADSCALE_SERVER from https://head.0bin.in to http://head.pharmq.kr to match the Windows PowerShell script configuration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 11:17:38 +00:00
PharmQ Admin
efcd653db2 📝 Update README URLs to use live production branch
- Change all script URLs from feature/working-headscale-setup to live/pharmq-headscale-production
- Update Windows PowerShell installation commands to use live server settings
- Update Linux quick-install script URLs to point to live branch
- Ensure all users get the latest production-ready scripts with correct server configuration

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-27 10:41:09 +00:00
PharmQ Admin
be4d337e2c 🔧 Update Windows PowerShell scripts to use live production server
- Change HeadscaleServer from https://head.0bin.in to http://head.pharmq.kr
- Update PreAuthKey to match live production environment
- Set --accept-dns=false to align with Linux script configuration
- Update help text to reflect new default server address

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-27 10:36:55 +00:00
PharmQ Admin
057c5ccd0a 📚 Add comprehensive FARMQ infrastructure architecture documentation
- Add detailed analysis of VPN-free SSL direct access architecture
- Document innovative approach superior to traditional Magic DNS
- Include real-time system status verification (2025-09-22)
- Explain Headscale management role vs user access separation
- Cover 100-pharmacy scalability and security considerations

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-22 13:40:51 +00:00
PharmQ Admin
560de20778 🔧 Fix sudo dependency for root execution
 Root 사용자 지원 개선
- root 권한일 때 sudo 없이 실행
- sudo 미설치 시 적절한 안내 메시지
- Debian/Proxmox 환경 호환성 강화

🐛 해결된 문제:
- Debian 시스템에서 sudo 미설치로 인한 실행 실패
- root 권한 실행 시 불필요한 sudo 호출

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-22 11:25:24 +00:00
PharmQ Admin
46b1580e52 📝 Add comprehensive README for live production
 사용자 친화적인 설치 가이드 추가
- 한 줄 curl 설치 명령어
- 다운로드 후 설치 방법
- 스크립트 파일 직접 링크

🔗 Gitea 웹 인터페이스 최적화
- 클릭 가능한 파일 링크
- 명확한 서비스 주소 정리
- 네트워크 정보 및 관리 도구 설명

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-22 11:22:57 +00:00
PharmQ Admin
36a4dca165 🚀 Pharmq.kr Live Production Setup Complete
 Headscale + farmq-admin 라이브 서버 구축 완료
- Docker Headscale 서버 설정 (포트 8070)
- farmq-admin 웹 GUI 연동 완료 (포트 5001)
- head.pharmq.kr 도메인 연결
- 1년 유효 재사용 가능 preauth key 생성
- 클라이언트 자동 등록 스크립트 완성

🔧 farmq-admin 설정:
- Headscale CLI API 래퍼 구현
- 실시간 노드/사용자 관리 GUI
- 데이터베이스 경로 수정 (/srv/headscale-tailscale-replacement/data/)
- 안전한 JSON 파싱 및 에러 처리 개선

📋 클라이언트 등록:
- register-client-pharmq-live.sh 스크립트
- head.pharmq.kr 도메인 사용
- 자동 Tailscale 설치 및 등록
- 테스트 완료 (100.64.0.1 IP 할당됨)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-22 11:17:10 +00:00
7aa08682b8 Implement smart Magic DNS copy with automatic port detection
### Magic DNS Smart Copy Features:
- **PBS servers**: Automatically append `:8007` port when copying
- **PVE servers**: Automatically append `:8006` port when copying
- **Other machines**: Copy Magic DNS address without port (existing behavior)

### UI Improvements:
- PBS servers: Blue button with `:8007` port hint
- PVE servers: Orange button with `:8006` port hint
- Enhanced tooltips with service-specific port information
- Visual distinction between different server types

### PBS Backup Server Monitoring:
- Complete PBS API integration with authentication
- Real-time backup/restore task monitoring with detailed logs
- Namespace-separated backup visualization with color coding
- Datastore usage monitoring and status tracking
- Task history with success/failure status and error details

### Technical Implementation:
- Smart port detection via JavaScript `addSmartPort()` function
- Jinja2 template logic for conditional button styling
- PBS API endpoints for comprehensive backup monitoring
- Enhanced clipboard functionality with user feedback

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-14 10:48:47 +09:00
be3795c7bf Add Git repository link to sidebar navigation
- Added Git repository navigation item below Medivault link
- Links to working Headscale setup branch
- Opens in new tab for easy access to project source

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:54:12 +09:00
c68ed59946 Implement Headscale machine rename functionality in FarmQ Admin
Add machine name editing feature similar to Headplane:
- REST API endpoint POST /api/machines/{id}/rename with Headscale CLI integration
- Edit button next to each machine name in the machine list
- Modal dialog with real-time Magic DNS preview
- DNS-compatible name validation (lowercase, digits, hyphens only)
- Immediate UI updates after successful rename
- Loading states and error handling

Features:
- farmq-admin/app.py: New rename API endpoint with subprocess Headscale CLI calls
- farmq-admin/templates/machines/list.html: Edit buttons, rename modal, JavaScript functions
- Real-time validation and Magic DNS preview
- Bootstrap modal with form validation
- Error handling with toast notifications

Users can now rename machines and their Magic DNS addresses directly from the web interface,
matching the functionality available in Headplane.

Tested with machine ID 2: desktop-emjd1dc ↔ desktop-emjd1dc-test 

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:38:57 +09:00
a9aa31cc4a Implement FarmQ Admin machine name display fix for Magic DNS
Fix machine management page to display proper Magic DNS names:
- Use given_name instead of hostname for machine display
- Add Magic DNS address with copy-to-clipboard functionality
- Distinguish between machine name and OS hostname like Headplane
- Enhance UI with Magic DNS information (.headscale.local)

Changes:
- farmq-admin/utils/database_new.py: Use given_name for machine_name
- farmq-admin/models/farmq_models.py: Update sync logic for given_name
- farmq-admin/templates/machines/list.html: Add Magic DNS display with copy feature
- FARMQ_ADMIN_MACHINE_NAME_FIX_PLAN.md: Complete analysis and implementation plan

Now displays:
- Machine Name: pbs-hp (Magic DNS name)
- Magic DNS: pbs-hp.headscale.local (with copy button)
- OS Hostname: proxmox-backup-server (system name)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 23:28:20 +09:00
b4ce883546 Fix Magic DNS issue: Enable DNS acceptance in Linux installation script
Changed --accept-dns=false to --accept-dns=true in quick-install.sh:
- Automatic registration command (line 347)
- Manual registration example command (line 357)

This ensures Linux clients also receive Headscale Magic DNS configuration
(100.64.0.1) automatically during installation, enabling proper name resolution
for *.headscale.local domains on all platforms.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 22:43:28 +09:00
4123babcea Fix Magic DNS issue: Enable DNS acceptance in Windows installation scripts
Changed --accept-dns=false to --accept-dns=true in both:
- farmq-install-en.ps1
- farmq-install.ps1

This allows Windows clients to receive Headscale Magic DNS configuration
(100.64.0.1) automatically during installation, enabling proper name resolution
for *.headscale.local domains.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 22:34:18 +09:00
fb00b0a5fd Add multi-host Proxmox support with SSL certificate handling
- Added support for multiple Proxmox hosts (pve7.0bin.in:443, Healthport PVE:8006)
- Enhanced VM management APIs to accept host parameter
- Fixed WebSocket URL generation bug (dynamic port handling)
- Added comprehensive SSL certificate trust help system
- Implemented host selection dropdown in UI
- Added VNC connection failure detection and automatic SSL help redirection
- Updated session management to store host_key information
- Enhanced error handling for different Proxmox configurations

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-13 00:03:25 +09:00
ac620a0e15 VNC WebSocket 연결 문제 - 브라우저 보안 정책으로 인한 미해결 상태
WebSocket 1006 오류로 인해 브라우저에서 VNC 연결 실패
- 서버 환경에서는 연결 가능하나 브라우저 보안 정책으로 차단
- 역방향 프록시 솔루션 문서화 완료
- 추후 nginx 프록시 구현 필요

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-12 01:44:47 +09:00
1dc09101cc VNC 인증 실패 자동 해결 시스템 구현
- VNC 티켓 만료 시 자동 새로고침 API 엔드포인트 추가 (/api/vnc/refresh/<session_id>)
- credentialsrequired 및 securityfailure 이벤트 시 자동 티켓 새로고침 로직 구현
- 새 티켓으로 자동 재연결 기능 추가 (기존 연결 종료 후 새 연결 생성)
- 수동 새로고침 버튼 추가 (🔄 Refresh 버튼)
- 인증 실패 발생 시 사용자에게 진행 상황 표시
- VNC 세션 데이터 자동 업데이트 및 타임스탬프 추가

이제 "Authentication failed" 오류 시 자동으로 새 VNC 티켓을 받아와서 재연결되어
사용자가 수동으로 새로고침할 필요가 없음

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 23:49:41 +09:00
0dda1423f8 VNC WebSocket 직접 연결 및 Canvas 렌더링 문제 완전 해결
- noVNC API 함수명 오류 수정 (sendPointer, sendKeyEvent -> sendKey)
- Canvas 크기 자동 조정 문제 해결을 위한 단순화된 구현 도입
- 기존 Proxmox vnc_lite.html과 동일한 방식으로 재구현
- 복잡한 Canvas 조작 로직 제거하고 noVNC 자체 렌더링에 의존
- 로컬 noVNC 라이브러리 사용으로 버전 호환성 보장
- VNC 연결, 인증, 화면 표시 모든 기능 정상 작동 확인

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 23:17:41 +09:00
895b7a8ee7 VNC WebSocket 인증 문제 해결 및 사용자 포털 계획 추가
- Proxmox VNC 티켓 생성 시 패스워드 생성 활성화
- VNC 세션에 생성된 패스워드 저장 및 전달
- noVNC 클라이언트에서 실제 패스워드 사용으로 인증 문제 해결
- ES6 모듈 방식으로 noVNC 라이브러리 로드
- HTML 엔티티 디코딩으로 WebSocket URL 문제 해결
- PharmQ 사용자 포털 서비스 계획서 추가 (KakaoTalk SSO, TossPayments)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 22:56:58 +09:00
2cfe37fd53 매출 대시보드 사이드바 링크 활성화
- base.html에서 매출 대시보드 링크를 showComingSoon()에서 실제 경로로 변경
- href="#" onclick="showComingSoon('매출 대시보드')" → href="{{ url_for('revenue_dashboard') }}"
- 이제 사이드바에서 매출 대시보드에 정상 접근 가능
- 구독 서비스 관리와 매출 대시보드 모두 완전 구현 완료

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 20:02:27 +09:00
6116f3fd15 매출 대시보드 완전 구현 완료
- /revenue 경로로 매출 대시보드 페이지 추가
- 4개 매출 분석 API 엔드포인트 구현:
  * /api/analytics/revenue/monthly - 12개월 매출 트렌드
  * /api/analytics/revenue/by-service - 서비스별 매출 비중
  * /api/analytics/pharmacy-ranking - 약국별 구독료 순위
  * /api/analytics/subscription-trends - 구독 성장/해지 트렌드
- Chart.js 기반 종합 시각화 대시보드:
  * 월별 매출 라인 차트
  * 서비스별 매출 도넛 차트
  * 약국 순위 테이블
  * 구독 트렌드 바 차트
- 실시간 데이터 로딩 및 오류 처리 구현

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 19:59:29 +09:00
e93f96abe4 사이드바 메뉴 및 구독 서비스 관리 페이지 구현 완료
🔧 사이드바 메뉴 개선:
- 구독 서비스 관리 메뉴 추가 (/subscriptions)
- 매출 대시보드 메뉴 추가 (준비 중 알림)
- VM 관리 (VNC) 메뉴 정리
- PQON 사용자 관리로 명칭 통일
- showComingSoon() 함수로 준비 중 기능 알림

📊 구독 서비스 관리 페이지 (/subscriptions):
- 구독 현황 통계 카드 (월 매출, 구독 수, 구독률)
- 서비스별 구독 현황 차트 (클라우드PC, AI CCTV, CRM)
- 약국별 구독 현황 테이블 (검색 및 필터링 지원)
- 실시간 데이터 로딩 및 새로고침 기능

🎨 UI/UX 기능:
- 서비스별 이모지 아이콘 시스템 (💻📷📊)
- 반응형 디자인 및 색상 코딩
- 엔터키 지원 검색 기능
- 로딩 스피너 및 오류 처리

🔄 데이터 연동:
- /api/subscriptions/stats 통계 API 활용
- /api/pharmacies/subscriptions 약국 현황 API 활용
- 약국 상세 페이지 연동

📱 사용자 경험:
- 직관적인 네비게이션 구조
- 실시간 검색 및 필터링
- 상세 관리 버튼으로 약국별 구독 관리 접근

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 19:52:19 +09:00
35ecd4748e PharmQ SaaS 구독 서비스 관리 시스템 완전 구현
📋 기획 및 설계:
- PharmQ SaaS 서비스 기획서 작성
- 구독 서비스 라인업 정의 (클라우드PC, AI CCTV, CRM)
- DB 스키마 설계 및 API 아키텍처 설계

🗄️ 데이터베이스 구조:
- service_products: 서비스 상품 마스터 테이블
- pharmacy_subscriptions: 약국별 구독 현황 테이블
- subscription_usage_logs: 서비스 이용 로그 테이블
- billing_history: 결제 이력 테이블
- 샘플 데이터 자동 생성 (21개 구독, 월 118만원 매출)

🔧 백엔드 API 구현:
- 구독 현황 통계 API (/api/subscriptions/stats)
- 약국별 구독 조회 API (/api/pharmacies/subscriptions)
- 구독 상세 정보 API (/api/pharmacy/{id}/subscriptions)
- 구독 생성/해지 API (/api/subscriptions)

🖥️ 프론트엔드 UI 구현:
- 대시보드 구독 현황 카드 (월 매출, 구독 수, 구독률 등)
- 약국 목록에 구독 상태 아이콘 및 월 구독료 표시
- 약국 상세 페이지 구독 서비스 섹션 추가
- 실시간 구독 생성/해지 기능 구현

 주요 특징:
- 서비스별 색상 코딩 및 이모지 아이콘 시스템
- 실시간 업데이트 (구독 생성/해지 즉시 반영)
- 반응형 디자인 (모바일/태블릿 최적화)
- 툴팁 기반 상세 정보 표시

📊 현재 구독 현황:
- 총 월 매출: ₩1,180,000
- 구독 약국: 10/14개 (71.4%)
- AI CCTV: 6개 약국, CRM: 10개 약국, 클라우드PC: 5개 약국

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 19:48:12 +09:00
c37cf023c1 사이드바 브랜딩을 PharmQ Super Admin (PSA)로 업데이트
- base.html 상단 네비게이션 브랜딩 변경
- 팜큐 약국 관리 시스템 → PharmQ Super Admin (PSA)
- UI 일관성 향상을 위한 브랜딩 통합

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 13:45:29 +09:00
8dbf35d955 약국 상세 페이지 Headscale CLI 기반 완전 구현
- get_pharmacy_detail 함수를 Headscale CLI 기반으로 완전 재작성
- 기존 FARMQ DB의 MachineProfile 의존성 제거
- 약국 상세 페이지 템플릿 신규 생성 (detail.html)
- 실시간 머신 상태 및 통계 표시: "머신: 2/4 온라인"
- 사용자-약국 매핑을 통한 머신 연결 관리
- 연결된 머신 목록: IP, 상태, 등록방식, 마지막 접속시간
- datetime 객체 안전 처리로 strftime 오류 방지
- 머신별 상세보기/재연결/연결해제 액션 버튼
- 빈 상태 처리 및 사용자 가이드 제공
- 약국 기본정보: 사업자번호, 담당자, 연락처, 주소
- 네트워크 정보: Proxmox 호스트, Headscale 사용자 연결
- 상태별 아이콘 및 배지 시각화 (온라인/부분연결/오프라인)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 12:14:08 +09:00
f3965a67fd 머신 상세 페이지 Headscale CLI 기반 완전 재구현
- get_machine_detail 함수를 Headscale CLI 기반으로 완전 교체
- 기존 FARMQ DB 의존성에서 실시간 Headscale 데이터로 전환
- strftime 템플릿 오류 완전 해결 (datetime 객체 타입 체크 추가)
- 실제 머신 정보 표시: 호스트명, IP 주소, 온라인 상태, 사용자 정보
- 약국 정보 매핑: Headscale 사용자명을 통한 약국 연동
- 시간 정보 인간화: "N시간 전", "N분 전" 형식으로 표시
- 네트워크 정보: IPv4/IPv6 주소, 엔드포인트, 키 정보 표시
- 조건부 모니터링 데이터 표시 (향후 확장 대비)
- 전체 머신 상세 페이지 기능 정상화

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 12:07:53 +09:00
56b72629f9 사용자-약국 매핑 시스템 개선 및 UI 업데이트
- 자동 매핑 버그 수정: 이름만으로 자동 연결되던 문제 해결
- 매핑되지 않은 약국 목록 API 추가 (/api/pharmacies/available)
- 사용자 연결 드롭다운에서 매핑 가능한 약국만 표시하도록 개선
- 기존 잘못된 매핑 초기화하여 명시적 링크만 허용
- UI 텍스트 업데이트: "Headscale 사용자 목록" → "PQON 사용자 목록"
- UI 텍스트 업데이트: "Headscale 네트워크 사용자" → "PharmQ-ON 사용자"
- 사이드 메뉴 링크 변경: "Headplane UI" → "Medivault" (https://medivault.co.kr/)
- SQLAlchemy or_ import 추가하여 복합 조건 쿼리 지원

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 11:49:17 +09:00
e71cdb2cda 완전한 약국 관리 및 사용자-약국 매칭 시스템 구현
🏥 약국 관리 API 구현:
- POST /api/pharmacy - 새 약국 생성 (모든 DB 칼럼 지원)
- PUT /api/pharmacy/<id> - 약국 정보 수정
- DELETE /api/pharmacy/<id>/delete - 약국 삭제
- 약국 관리 페이지 UI 완전 연동

👤 사용자-약국 매칭 시스템:
- POST /api/users/<user>/link-pharmacy - 사용자와 약국 연결
- 실시간 매칭 상태 표시 및 업데이트
- Headscale 사용자와 FARMQ 약국 간 완전한 연결

🔧 핵심 설계 원칙 100% 준수:
- Headscale CLI 기반 제어 (사용자 생성/삭제)
- 이중 사용자 구분 (Headscale ↔ FARMQ 약국)
- 느슨한 결합 (headscale_user_name 매핑)
- 실시간 동기화 (API 호출 즉시 반영)

 전체 시스템 통합 테스트 완료:
- 약국 생성 → 사용자 생성 → 매칭 → 실시간 확인
- DB 칼럼 구조와 완벽 일치
- UI/API 완전 연동

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 11:17:13 +09:00
fd8c5cbb81 Headscale 사용자 관리 기능 완전 구현
- Headscale CLI 기반 사용자 생성/삭제 API 엔드포인트 추가
- 사용자-약국 매칭 정보 실시간 표시 및 관리
- 완전한 사용자 관리 웹 인터페이스 구현
- 통계 대시보드: 총 사용자, 약국 연결, 미연결, 노드 수
- 사용자별 노드 연결 상태 및 약국 정보 매칭 표시
- 새 사용자 생성 모달 (display_name, email 지원)
- 안전한 사용자 삭제 확인 기능
- 네비게이션 메뉴에 사용자 관리 추가
- Headplane과 동일한 기능 + 약국 매칭 정보 추가 제공

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 11:00:34 +09:00
24cf84fda3 팜큐 관리자 시스템 구현 가이드 및 설계 원칙 문서 추가
- Headscale CLI 기반 제어 방식의 핵심 아키텍처 설명
- 이중 데이터베이스 전략 및 실시간 동기화 방법론 정리
- 실제 구현 코드 예시와 표준 패턴 제시
- Phase별 기능 확장 로드맵 및 개발 가이드라인
- 성능 최적화, 보안, 디버깅 방안 포함
- 향후 모든 기능 구현의 기준 문서로 활용

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 10:49:48 +09:00
45c952258b Add node deletion functionality to FARMQ Admin
- Add DELETE API endpoint for node deletion via Headscale CLI
- Add delete buttons to both table and card views in machine list
- Implement confirmation dialog with clear warning message
- Add proper error handling and user feedback with toast messages
- Auto-refresh page after successful deletion
- Match Headplane functionality for complete node management

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 10:44:49 +09:00
11f6ff16d0 Implement real-time online status synchronization with Headplane
- Add Headscale CLI integration to get real-time online status
- Replace timeout-based logic with exact same logic as Headplane
- Use 'online' field from Headscale CLI JSON output
- Update dashboard statistics to show 3 online nodes matching Headplane
- Update pharmacy and machine management views with real-time status

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 10:38:14 +09:00
1f0afd4cae Fix machine count display and pharmacy edit functionality
🔧 Machine Management Fixes:
- Fix duplicate machine counting (was showing 10 instead of 5)
- Update dashboard stats to use Headscale nodes instead of FARMQ profiles
- Fix JavaScript counting to only count active view (List/Card)
- Add view change listeners to update counters correctly

🏥 Pharmacy Management Fixes:
- Add API endpoint for individual pharmacy data retrieval
- Fix pharmacy edit modal to load existing data as form values
- Add proper form validation and error handling
- Implement edit vs add mode detection

📊 Database Integration:
- Improve machine counting logic using Headscale Node table
- Fix online/offline status calculation with 5-minute threshold
- Add debug logging for machine data retrieval

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 09:41:55 +09:00
5d89277e5c Fix Tailscale download URLs: Use dynamic version detection
- Add GitHub API integration to get latest Tailscale version
- Fix broken download URLs by using correct versioned filenames
- Add fallback version (1.86.2) if API call fails
- Update both English and Korean PowerShell scripts
- Resolves download errors in installation process

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 08:34:43 +09:00
bd33604982 Fix infinite loop in PowerShell script: Rename function to avoid collision
- Rename Test-Connection function to Test-NetworkConnection
- Fix PowerShell function name collision with built-in cmdlet
- Use fully qualified names for PowerShell Test-Connection cmdlet
- Resolves infinite recursion in network verification

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 01:03:40 +09:00
176c6bb1c2 Update documentation for PowerShell encoding fix
- Update README.md with English PowerShell commands
- Update WINDOWS_QUICK_START.md with encoding issue solution
- Add FAQ section for Korean character encoding problems
- Recommend farmq-install-en.ps1 to prevent character display issues

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 01:01:36 +09:00
b7c621f294 Fix PowerShell encoding issues: Add English-only version
- Add farmq-install-en.ps1 with UTF-8 encoding support
- Remove Korean characters to prevent PowerShell encoding issues
- Use ASCII-only status indicators ([*], [+], [!])
- Maintain all functionality while ensuring compatibility

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-11 01:00:28 +09:00
260 changed files with 54265 additions and 282 deletions

5
.gitignore vendored
View File

@@ -158,4 +158,7 @@ venv.bak/
dmypy.json
# Pyre type checker
.pyre/
.pyre/
# Submodules managed separately
farmq-admin/

View 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*

View 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 머신 이름 표시 문제 분석 및 해결 계획 수립 완료

View File

@@ -0,0 +1,362 @@
# FARMQ 인프라 아키텍처 완전 분석
## 📋 개요
FARMQ는 100개 약국 네트워크를 관리하는 혁신적인 인프라로, **기존 VPN 방식의 한계를 뛰어넘는 SSL 도메인 직접 접속 구조**를 구현했습니다. Headscale을 관리 목적으로 활용하면서도, 일반 사용자는 VPN 설치 없이 웹브라우저만으로 각 지역 Proxmox에 접속할 수 있는 독창적인 아키텍처입니다.
## 🏗️ 전체 네트워크 구성
### 1. 외부 인증 및 라우팅 계층
```
[클라우드플레어]
├── DNS-01 챌린지 (API 인증)
├── Let's Encrypt SSL 인증서 발급
└── *.pharmq.kr 와일드카드 인증서
```
### 2. 물리적 네트워크 구성
```
[ISP KT] 192.168.0.1 (게이트웨이)
└── Proxmox Host: 192.168.0.200
├── Ubuntu VM 104: 192.168.0.100 (Headscale 중앙서버)
└── Debian LXC 103: 192.168.0.19 (Caddy 리버스 프록시)
```
## 🔧 현재 시스템 상태 확인 (2025-09-22 13:28 기준)
### Docker 컨테이너 상태
```
CONTAINER: headscale (a1e850fbf942)
├── 상태: Up 4 hours (healthy)
├── 포트: 8070→8080 (FARMQ Admin), 9090→9090 (Headscale API)
├── 이미지: headscale/headscale:latest
└── 헬스체크: 정상
```
### 활성 프로세스
```
✅ Docker 서비스: Active (running) - PID 36505
✅ Headscale 컨테이너: /ko-app/headscale serve - PID 38393
✅ FARMQ Admin: Python Flask 앱 - PID 53064
✅ Docker 프록시: 4개 프로세스 (포트 8070, 9090 바인딩)
```
### Headscale 네트워크 상태
```
노드 상태 (docker exec headscale headscale nodes list):
├── ubuntu (ID: 1, 100.64.0.1): 🟢 온라인 - Last seen: 2025-09-22 12:17:26
├── pve5 (ID: 2, 100.64.0.2): 🟢 온라인 - Last seen: 2025-09-22 12:17:26
└── caddy (ID: 3, 100.64.0.3): 🟢 온라인 - Last seen: 2025-09-22 12:17:26
사용자: default (ID: 1, Created: 2025-09-22 09:12:02)
IP 대역: 100.64.0.x/10, fd7a:115c:a1e0::/48 (IPv6)
```
### 서비스 엔드포인트 상태
```
✅ FARMQ Health Check (localhost:8070): {"status":"pass"}
✅ 포트 바인딩 확인 (ss -tlnp):
- 0.0.0.0:8070 (FARMQ Admin)
- 0.0.0.0:9090 (Headscale API)
- IPv6 지원 활성화
```
### 시스템 리소스
```
디스크 사용량: 4.2G/14G (31% 사용)
메모리 사용량:
├── Headscale 컨테이너: ~56MB
├── FARMQ Admin Python: ~83MB
└── Docker 데몬: ~84MB
```
## ⚡ 핵심 혁신: VPN 불필요 SSL 접속
### 기존 Magic DNS vs 우리 아키텍처
#### ❌ 기존 Magic DNS 한계
```
Magic DNS (Tailscale/Headscale 표준)
├── 클라이언트가 반드시 VPN 네트워크에 포함되어야 함
├── 100.x.x.x 내부 IP로만 접근 가능
├── 외부 네트워크에서 직접 접근 불가
├── 모든 접속 장치에 노드 설치 필요
├── 복잡한 키 관리 및 네트워크 설정
└── 방화벽 및 보안 정책 충돌 가능성
```
#### ✅ 우리의 혁신적 구조
```
SSL 도메인 직접 접속 (VPN 불필요)
├── 외부 인터넷에서 바로 pve1.pharmq.kr 접속
├── SSL 인증서로 보안 연결 (Let's Encrypt)
├── 클라이언트에 VPN 설치 불필요
├── 일반 웹브라우저로 즉시 접근 가능
├── 복잡한 네트워크 설정 없음
└── 사용자 친화적 웹 인터페이스
```
## 🌐 SSL 도메인 직접 접속 구조
### 접속 플로우 (VPN 없이)
```
[외부 사용자]
↓ HTTPS 요청 (웹브라우저)
[pve1.pharmq.kr:8006]
↓ Cloudflare DNS 조회
[Caddy 리버스 프록시] (192.168.0.19)
↓ SSL 터미네이션 + 라우팅
[지역 Proxmox Host] (로컬망/LTE망)
↓ 웹 인터페이스 제공
[Proxmox 관리 화면]
```
### 각 지역 Proxmox 접속 예시
```
🏥 약국 A: pve1.pharmq.kr → 부산 지역 Proxmox
🏥 약국 B: pve2.pharmq.kr → 서울 지역 Proxmox
🏥 약국 C: pve3.pharmq.kr → 대구 지역 Proxmox
🏥 약국 D: pve4.pharmq.kr → 대전 지역 Proxmox
모든 접속이 SSL 보안 + 공인 도메인으로 가능
브라우저 주소창에 직접 입력하여 접속
```
## 🔄 Headscale의 실제 역할
### Headscale 네트워크 (내부 관리용)
```
Headscale은 관리 목적으로만 사용:
├── 중앙 서버 ↔ 지역 Proxmox 간 관리 통신
├── 모니터링 및 상태 확인
├── 원격 유지보수 및 업데이트
├── FARMQ Admin 웹 인터페이스 데이터 수집
└── 100.64.0.x 대역으로 내부 관리망 구성
```
### 일반 사용자 접속 (Headscale 독립)
```
일반 사용자는 Headscale 불필요:
├── 웹브라우저로 pve1.pharmq.kr 직접 접속
├── VPN 클라이언트 설치 없음
├── 복잡한 네트워크 설정 없음
├── 즉시 Proxmox 웹 인터페이스 사용
└── 스마트폰에서도 동일하게 접속 가능
```
## 🎯 아키텍처의 혁신적 장점
### 1. 사용자 편의성
```
기존 VPN 방식:
❌ 각 PC에 Tailscale/Headscale 클라이언트 설치
❌ 복잡한 네트워크 설정 및 키 관리
❌ 방화벽 및 보안 정책 충돌 가능성
❌ 모바일 장치에서 복잡한 설정
우리 SSL 방식:
✅ 웹브라우저만 있으면 즉시 접속
✅ 설치나 설정 과정 불필요
✅ 일반 웹사이트처럼 직관적 접근
✅ 모든 플랫폼에서 동일한 사용자 경험
```
### 2. 네트워크 투명성
```
지역별 Proxmox 환경:
├── 로컬 라우터 뒤 (NAT 환경)
├── LTE/5G 모바일 연결
├── 기업용 방화벽 뒤
├── 공공 WiFi 환경
└── 모든 환경에서 동일한 pveX.pharmq.kr 접속
```
### 3. 보안 및 확장성
```
SSL 인증서 자동 관리:
├── Let's Encrypt 자동 갱신 (90일마다)
├── Cloudflare DNS-01 챌린지
├── 와일드카드 인증서로 무제한 서브도메인
├── 각 지역별 독립적 보안 정책 적용 가능
└── TLS 1.3 최신 보안 프로토콜 지원
```
## 📊 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 │ │
│ │ (약국정보) │ (노드정보) │ │
│ └─────────────┴─────────────────┘ │
└─────────────────────────────────────┘
```
### CLI 기반 기능 구현 패턴
```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}
```
## 🌐 데이터 플로우 및 연결성
### 인바운드 트래픽
```
외부 클라이언트
↓ HTTPS://pve1.pharmq.kr
Cloudflare DNS
↓ IP 주소 해석
KT ISP (192.168.0.1)
↓ 라우팅
Caddy LXC (192.168.0.19)
↓ SSL 터미네이션 + 프록시
Headscale VM (192.168.0.100)
↓ 최종 서비스
```
### 내부 네트워크 통신
```
FARMQ Admin (8070) ← 웹 인터페이스 및 API
Headscale API (9090) ← CLI 명령 처리 및 노드 관리
Docker 네트워크 (172.18.0.2) ← 컨테이너 간 통신
Headscale VPN (100.64.0.x) ← 관리 목적 내부 통신
```
## 🚀 확장 시나리오
### 새로운 지역 추가 시
```
1. 새 Proxmox Host 설치 (임의의 네트워크 환경)
2. pveN.pharmq.kr DNS 레코드 추가 (Cloudflare)
3. Caddy 라우팅 규칙 업데이트
4. SSL 인증서 자동 발급 (Let's Encrypt)
5. 즉시 외부 접속 가능
선택사항:
- Headscale VPN 노드 추가 (관리 목적)
- FARMQ Admin에서 모니터링 설정
```
### 100개 약국 확장 예시
```
pve1.pharmq.kr → 서울 강남구 약국
pve2.pharmq.kr → 부산 해운대구 약국
pve3.pharmq.kr → 대구 중구 약국
...
pve100.pharmq.kr → 제주도 약국
각각 독립적인 SSL 도메인으로 접속
중앙에서 FARMQ Admin으로 통합 관리
```
## 🔐 보안 고려사항
### 1. SSL/TLS 보안
```
인증서 관리:
├── Let's Encrypt 무료 인증서
├── 90일 자동 갱신
├── TLS 1.3 최신 프로토콜
├── Perfect Forward Secrecy (PFS)
└── HSTS (HTTP Strict Transport Security)
```
### 2. 네트워크 보안
```
접근 제어:
├── Cloudflare DDoS 보호
├── Caddy 리버스 프록시 보안 헤더
├── Proxmox 자체 인증 시스템
├── 각 지역별 독립적 보안 정책
└── VPN 관리망은 별도 보안 채널
```
### 3. 관리 보안
```
FARMQ Admin:
├── Flask 세션 관리
├── API 엔드포인트 권한 확인
├── Headscale CLI 명령 검증
├── 약국별 데이터 접근 제한
└── 관리자/사용자 역할 구분
```
## 📈 성능 최적화
### 1. 네트워크 최적화
```
연결 경로 최적화:
├── Cloudflare CDN 활용
├── Caddy HTTP/2 지원
├── Keep-Alive 연결 유지
├── Gzip 압축 활성화
└── 정적 자원 캐싱
```
### 2. 서버 최적화
```
리소스 관리:
├── Docker 컨테이너 리소스 제한
├── Python Flask 앱 최적화
├── 데이터베이스 쿼리 최적화
├── CLI 호출 최소화
└── 결과 캐싱 (단기간)
```
## 🎯 결론
### 핵심 혁신점
1. **VPN 설치 불필요**: 웹브라우저만으로 모든 Proxmox 접속
2. **사용자 친화성**: 복잡한 네트워크 설정 없이 즉시 사용
3. **확장성**: 새 지역 추가 시 DNS 레코드만 추가하면 완료
4. **보안성**: SSL/TLS 표준 보안 + Cloudflare 보호
5. **투명성**: 네트워크 환경에 관계없이 동일한 접속 방법
### 기존 방식 대비 우위
```
Magic DNS/VPN 방식:
- 복잡한 클라이언트 설치 및 설정
- 네트워크 정책 충돌 가능성
- 모바일에서 사용성 제한
우리 SSL 방식:
- 웹 표준 기술 활용
- 모든 플랫폼에서 동일한 경험
- 기업 방화벽과 충돌 없음
```
FARMQ 인프라는 **Magic DNS보다 훨씬 실용적이고 사용자 친화적**인 구조로, **VPN의 복잡성 없이 SSL의 보안성**을 제공하는 **혁신적인 하이브리드 아키텍처**입니다.
---
*Document Generated: 2025-09-22 13:28 UTC*
*System Status: All Services Operational*
*Generated with [Claude Code](https://claude.ai/code)*

View 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 기반 관리자 시스템

424
HEADSCALE_COMPLETE_GUIDE.md Normal file
View 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 네트워크를 운영할 수 있습니다!**

View 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 호스트 관리 시스템을 다중 호스트 환경으로 확장하기 위한 상세 계획을 담고 있습니다. 단계적 구현을 통해 안정적이고 확장 가능한 시스템을 구축할 수 있을 것입니다.*

View 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 네트워크를 운영하세요!**

View 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 화면 접속**

297
PharmQ-SaaS-Service-Plan.md Normal file
View 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

View 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 조사 및 피드백 수집

138
README.md
View File

@@ -1,6 +1,71 @@
# 🚀 Headscale + Headplane Docker Setup
# 🏥 PharmQ Headscale Network - Live Production
Tailscale을 완전히 대체하는 자체 호스팅 솔루션
pharmq.kr 도메인을 사용하는 Headscale VPN 네트워크 구축 완료
## 🚀 클라이언트 자동 등록
### 한 줄 설치 (권장)
```bash
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/live/pharmq-headscale-production/register-client-pharmq-live.sh | bash
```
### 다운로드 후 설치
```bash
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/live/pharmq-headscale-production/register-client-pharmq-live.sh -o register-client.sh
chmod +x register-client.sh
./register-client.sh
```
### 📋 스크립트 파일 직접 다운로드
- [register-client-pharmq-live.sh](./register-client-pharmq-live.sh) - 클라이언트 자동 등록 스크립트
## 🌐 서비스 주소
### 메인 서비스
- **Headscale 서버**: http://head.pharmq.kr:8070
- **관리자 대시보드**: http://head.pharmq.kr:5001
### 개발/테스트 (내부용)
- **Headscale**: http://192.168.0.100:8070
- **farmq-admin**: http://192.168.0.100:5001
## 📊 네트워크 정보
- **네트워크 대역**: 100.64.0.0/10
- **IPv6 대역**: fd7a:115c:a1e0::/48
- **Magic DNS**: headscale.local
- **기본 사용자**: default
## 🔧 관리자 도구
### farmq-admin 웹 GUI
- 사용자 관리
- 머신/노드 관리
- 실시간 네트워크 모니터링
- Headscale CLI API 래퍼
### 주요 기능
- ✅ Docker 기반 Headscale 서버
- ✅ 웹 기반 관리 인터페이스
- ✅ 자동 클라이언트 등록 스크립트
- ✅ 1년 유효 재사용 가능 preauth key
- ✅ Magic DNS 지원
## 📋 클라이언트 등록 과정
1. **스크립트 실행**: 위 curl 명령어 실행
2. **Tailscale 자동 설치**: 시스템에 맞게 설치
3. **Headscale 서버 연결**: head.pharmq.kr 연결
4. **자동 인증**: preauth key로 즉시 승인
5. **네트워크 참여**: Tailscale IP 할당 완료
## 🛠️ 기술 스택
- **Headscale**: v0.26.1 (Docker)
- **farmq-admin**: Flask + SQLAlchemy
- **Database**: SQLite3
- **Frontend**: HTML/CSS/JavaScript
- **Network**: Tailscale protocol
## 📁 파일 구조
```
@@ -138,19 +203,19 @@ 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
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/live/pharmq-headscale-production/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
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/live/pharmq-headscale-production/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
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/live/pharmq-headscale-production/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
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/live/pharmq-headscale-production/quick-install.sh | bash -s -- --force
```
### 지원 OS
@@ -164,16 +229,22 @@ curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/bran
Windows PC에서 **한 번의 복사 붙여넣기**로 팜큐 네트워크 연결:
### 기본 설치 (권장)
### 기본 설치 (권장) - 인코딩 문제 해결됨
```powershell
# 관리자 PowerShell에서 복사 붙여넣기
iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/farmq-install.ps1'))
# 관리자 PowerShell에서 복사 붙여넣기 (English version - 한글 깨짐 해결)
iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/live/pharmq-headscale-production/farmq-install-en.ps1'))
```
### 기존 Tailscale 있는 경우 (강제 재등록)
```powershell
# 기존 연결을 자동으로 해제하고 재등록
$ForceInstall = $true; iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/farmq-install.ps1'))
# 기존 연결을 자동으로 해제하고 재등록 (English version)
$Force = $true; iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/live/pharmq-headscale-production/farmq-install-en.ps1'))
```
### 한글 버전 (인코딩 문제 발생 가능)
```powershell
# 한글이 깨져 보일 수 있음 - 위 English 버전 권장
iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/live/pharmq-headscale-production/farmq-install.ps1'))
```
### 실행 방법
@@ -184,7 +255,48 @@ $ForceInstall = $true; iex ((New-Object System.Net.WebClient).DownloadString('ht
### Windows 자동 처리 기능
-**Tailscale 자동 다운로드** 및 설치
-**관리자 권한** 자동 확인
-**관리자 권한** 자동 확인
-**기존 연결 스마트 처리** (Linux와 동일)
-**Windows Defender 방화벽** 자동 설정
-**네트워크 연결 테스트** 및 확인
-**네트워크 연결 테스트** 및 확인
## 💻 개발 환경 설정 (code-server)
팜큐 네트워크에 연결된 서버에서 **웹 기반 VS Code 개발 환경**을 빠르게 구축:
### 한 줄 설치 (권장)
```bash
# 기본 포트 8080으로 설치
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/live/pharmq-headscale-production/docs/code-server.sh | bash
# 포트 지정 설치 (예: 8443)
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/live/pharmq-headscale-production/docs/code-server.sh | PORT=8443 bash
```
### 무인 설치 (비밀번호 환경변수 설정)
```bash
# 비밀번호를 환경변수로 전달
curl -fsSL https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/live/pharmq-headscale-production/docs/code-server.sh | PASSWORD="your-secure-password" SKIP_CONFIRM=1 bash
```
### 자동 설치 기능
-**code-server 자동 설치** (미설치 시)
-**설정 파일 자동 생성** 및 구성
-**기존 프로세스 정리** (중복 실행 방지)
-**0.0.0.0 바인딩** (외부 접속 가능)
-**nohup 백그라운드 실행** (세션 종료 후에도 유지)
### 설치 후 접속
```bash
# 브라우저에서 접속
http://<서버IP>:8080
# 로그 확인
tail -f ~/code-server.log
```
### 보안 권장사항
- 🔒 **역프록시 사용**: Caddy 또는 Nginx로 HTTPS 설정
- 🔒 **강력한 비밀번호**: 복잡한 비밀번호 사용
- 🔒 **방화벽 설정**: 필요한 IP만 접근 허용
- 🔒 **VPN 접속**: 팜큐 네트워크 내부에서만 접속

View 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 역방향 프록시를 통해 이 문제를 근본적으로 해결할 수 있으며, 이는 가장 실용적이고 안정적인 솔루션입니다.

View File

@@ -4,17 +4,28 @@ 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.ps1'))
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'))
```
@@ -37,52 +48,52 @@ $ForceInstall = $true; iex ((New-Object System.Net.WebClient).DownloadString('ht
## 📺 실행 화면 예시
```powershell
PS C:\WINDOWS\system32> iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/farmq-install.ps1'))
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 원클릭 설치
FARMQ Headscale Windows One-Click Installation
============================================
🔧 시스템 요구사항 확인 ...
시스템 요구사항 확인 완료
[*] Checking system requirements...
[+] System requirements check completed
🔧 Tailscale 클라이언트 확인 ...
📋 Windows용 Tailscale 설치 ...
🔧 Tailscale 다운로드 ...
🔧 Tailscale 설치 ... (잠시 기다려주세요)
Tailscale 설치 완료
[*] Checking Tailscale installation...
[i] Installing Tailscale for Windows...
[*] Downloading Tailscale...
[*] Installing Tailscale... (please wait)
[+] Tailscale installation completed
🔧 Tailscale 서비스 시작 ...
Tailscale 서비스가 실행 중입니다.
[*] Starting Tailscale service...
[+] Tailscale service is running.
🔧 Headscale 서버에 등록 ...
📋 Headscale 서버: https://head.0bin.in
📋 Pre-auth Key: 8b3df41d***************
🔧 등록 명령 실행 ...
Headscale 등록 성공!
[*] 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
🔧 연결 상태 확인 ...
Headscale 네트워크 연결 완료!
📋 할당된 IPv4: 100.64.0.15
📋 할당된 IPv6: fd7a:115c:a1e0::15
[*] Verifying network connection...
[+] Headscale network connection completed!
[i] Assigned IPv4: 100.64.0.15
[i] Assigned IPv6: fd7a:115c:a1e0::15
🔧 네트워크 연결 테스트 ...
팜큐 네트워크(100.64.0.0/10) 연결 정상!
[*] Testing network connectivity...
[+] FARMQ network (100.64.0.0/10) connection successful!
============================================
팜큐 Headscale Windows 설치 완료!
FARMQ Headscale Windows Installation Complete!
============================================
🎉 설치가 성공적으로 완료되었습니다!
Installation completed successfully!
📋 시스템 정보:
컴퓨터명: PHARMACY-PC01
System Information:
Computer Name: PHARMACY-PC01
Tailscale IP: 100.64.0.15
OS: Windows 10.0
Headscale 서버: https://head.0bin.in
Headscale Server: https://head.0bin.in
```
## ❓ 자주 묻는 질문 (FAQ)
@@ -98,9 +109,16 @@ Set-ExecutionPolicy Bypass -Scope Process -Force
- `Windows 키 + X``Windows PowerShell(관리자)`
### Q: 이미 Tailscale이 설치되어 있어요
**A: 강제 재설치 명령어를 사용하세요:**
**A: 강제 재설치 명령어를 사용하세요 (English 버전):**
```powershell
$ForceInstall = $true; iex ((New-Object System.Net.WebClient).DownloadString('https://git.0bin.in/thug0bin/headscale-tailscale-replacement/raw/branch/feature/working-headscale-setup/farmq-install.ps1'))
$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: 설치 후 어떻게 확인하나요?

196
add-client.sh Executable file
View 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
View 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()

View File

@@ -35,7 +35,7 @@ services:
volumes:
- ./headplane-config:/etc/headplane
ports:
- "3000:3000" # Headplane Web UI
- "3001:3000" # Headplane Web UI (외부:3001, 내부:3000)
depends_on:
- headscale
networks:

128
docs/code-server.sh Normal file
View File

@@ -0,0 +1,128 @@
#!/usr/bin/env bash
#
# setup-code-server.sh
# - code-server 미설치 시 자동 설치
# - 최초 1회 실행해 ~/.config/code-server/config.yaml 생성
# - config.yaml을 0.0.0.0:<PORT> + 지정 비밀번호로 갱신
# - 기존에 떠있는 code-server(수동/비-systemd) 프로세스 정리
# - systemd 미사용: nohup으로 백그라운드 실행
#
# 환경변수:
# PORT=8080 # 바인드 포트 (기본 8080)
# PASSWORD= # 비밀번호(무인 실행용)
# SKIP_CONFIRM=0/1 # 비밀번호 확인 입력 생략
#
set -euo pipefail
PORT="${PORT:-8080}"
CONFIG_DIR="${HOME}/.config/code-server"
CONFIG_FILE="${CONFIG_DIR}/config.yaml"
LOG_FILE="${HOME}/code-server.log"
say() { echo -e "$@"; }
die() { echo -e "$@" >&2; exit 1; }
# 0) 필수 도구 준비 (curl/timeout/pgrep 등)
if ! command -v curl >/dev/null 2>&1 || ! command -v timeout >/dev/null 2>&1; then
say "📦 필요 패키지 설치 중 (curl, coreutils, procps 등)..."
if command -v apt >/dev/null 2>&1; then
apt update -y >/dev/null 2>&1 || true
apt install -y curl ca-certificates coreutils procps >/dev/null 2>&1
else
die "apt 환경이 아닙니다. curl/timeout/pgrep가 필요합니다."
fi
fi
# 1) code-server 설치 확인 및 자동 설치
if ! command -v code-server >/dev/null 2>&1; then
say "📦 code-server 미설치 상태 → 설치 진행..."
bash <(curl -fsSL https://code-server.dev/install.sh)
command -v code-server >/dev/null 2>&1 || die "code-server 설치 실패"
say "✅ code-server 설치 완료"
else
say "✅ code-server 이미 설치됨"
fi
# 2) config.yaml 생성 (없으면 최초 1회 3~5초 실행)
if [ ! -f "${CONFIG_FILE}" ]; then
say "📝 config.yaml 이 없어 최초 1회 실행으로 생성합니다..."
mkdir -p "${CONFIG_DIR}"
timeout 5s code-server >/dev/null 2>&1 || true
[ -f "${CONFIG_FILE}" ] || die "config.yaml 생성 실패"
say "✅ 기본 config.yaml 생성됨: ${CONFIG_FILE}"
else
say " 기존 config.yaml 감지: ${CONFIG_FILE}"
fi
# 3) 비밀번호 입력/확정
if [ "${PASSWORD-}" = "" ]; then
read -rsp "🔐 code-server 접속 비밀번호 입력: " PASS; echo
if [ "${SKIP_CONFIRM-0}" != "1" ]; then
read -rsp "🔐 비밀번호 확인 입력: " PASS2; echo
[ "$PASS" = "$PASS2" ] || die "비밀번호 불일치"
fi
else
PASS="$PASSWORD"
fi
[ -n "$PASS" ] || die "비밀번호는 비어 있을 수 없습니다."
# 4) 기존 파일 백업 후 config.yaml 갱신
ts="$(date +%Y%m%d%H%M%S)"
if [ -f "${CONFIG_FILE}" ]; then
cp -a "${CONFIG_FILE}" "${CONFIG_FILE}.bak.${ts}"
say "🗂 백업 생성: ${CONFIG_FILE}.bak.${ts}"
fi
cat > "${CONFIG_FILE}" <<EOF
bind-addr: 0.0.0.0:${PORT}
auth: password
password: ${PASS}
cert: false
EOF
say "✅ config.yaml 갱신됨 (bind-addr=0.0.0.0:${PORT})"
# 5) systemd로 떠있다면 중지(원하시는 게 '비-systemd' 운영이므로)
if command -v systemctl >/dev/null 2>&1; then
if systemctl is-active --quiet "code-server@${USER}"; then
say "⏹ systemd 서비스(code-server@${USER}) 중지"
systemctl stop "code-server@${USER}" || true
fi
if [ "$EUID" -eq 0 ] && systemctl is-active --quiet "code-server@root"; then
say "⏹ systemd 서비스(code-server@root}) 중지"
systemctl stop "code-server@root" || true
fi
fi
# 6) 수동/기존 실행 프로세스 정리 (부모/자식 순서 종료)
say "🧹 기존 code-server 수동 프로세스 정리..."
# 부모 엔트리(메인/entry) TERM
pids="$(pgrep -f "/usr/lib/code-server/lib/node /usr/lib/code-server($|/out/node/entry)" || true)"
if [ -n "${pids}" ]; then
for p in $pids; do
pkill -TERM -P "$p" 2>/dev/null || true
kill -TERM "$p" 2>/dev/null || true
done
sleep 2
fi
# 남아있으면 KILL
pids="$(pgrep -f "/usr/lib/code-server/lib/node /usr/lib/code-server($|/out/node/entry)" || true)"
[ -n "${pids}" ] && kill -9 $pids 2>/dev/null || true
# 보조 호스트/터미널 프로세스 잔여물 정리(있어도 없어도 무방)
pkill -f "vscode/out/bootstrap-fork --type=ptyHost" 2>/dev/null || true
pkill -f "vscode/out/bootstrap-fork --type=extensionHost" 2>/dev/null || true
pkill -f "shellIntegration-bash.sh" 2>/dev/null || true
# 7) 비-systemd 백그라운드 실행
say "🚀 code-server 일반 실행(nohup 백그라운드) 시작..."
nohup code-server > "${LOG_FILE}" 2>&1 &
pid=$!
disown || true
sleep 1
say "✅ 실행됨 (PID: ${pid})"
say "📄 로그 보기: tail -f ${LOG_FILE}"
say "🌐 접속 URL: http://<서버IP>:${PORT}"
say "🔑 비밀번호: (방금 설정한 값)"
say "🔒 보안 권장: 역프록시(Caddy/Nginx) + HTTPS 사용 시 config는 127.0.0.1로 바꾸세요."

View 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 프록시를 통해 브라우저 환경에서도 안정적으로 구현하자는 것이 이 전략의 핵심입니다.

View 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**: 최신 버전

View 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에 접근할 수 있도록 하는 것이 가장 현실적인 해결책입니다.

View 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 서버에서 발생했습니다.

File diff suppressed because it is too large Load Diff

View 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()

View File

@@ -10,14 +10,14 @@ class Config:
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'
DATABASE_PATH = '/srv/headscale-tailscale-replacement/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'
HEADSCALE_API_KEY = os.environ.get('HEADSCALE_API_KEY') or '0HHiZBM.lULHUaFbCQPQqU7-3tUCqFuB9JeueS9r'
# 모니터링 설정
MONITORING_INTERVAL = 30 # 30초마다 데이터 수집

View 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)

View 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)

View 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"

View 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

View 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

View 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

View 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())

View 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
View 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())

View 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())

View 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())

View File

@@ -0,0 +1 @@
python3

View File

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

View File

@@ -0,0 +1 @@
python3

View 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())

View File

@@ -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 */

View File

@@ -0,0 +1 @@
lib

View 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

View 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()

View File

@@ -42,31 +42,51 @@ class JSONType(TypeDecorator):
class PharmacyInfo(FarmqBase):
"""약국 정보 테이블 - Headscale과 독립적"""
__tablename__ = 'pharmacies'
id = Column(Integer, primary_key=True, autoincrement=True)
# 약국 코드 (핵심 식별자) - P001~P999
pharmacy_code = Column(String(10), unique=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))
manager_name = Column(String(100)) # deprecated - use owner_name
phone = Column(String(20))
address = Column(Text)
# 기술적 정보
# 대표자 정보 (신규)
owner_name = Column(String(100))
owner_license = Column(String(50))
owner_phone = Column(String(20))
owner_email = Column(String(100))
# 요양기관 정보 (신규)
institution_code = Column(String(20))
institution_type = Column(String(20))
# 운영 정보 (신규)
opening_date = Column(DateTime)
business_hours = Column(Text)
# API 포트 (신규)
api_port = Column(Integer, default=8082)
# 기술적 정보 (deprecated - pharmacy_servers로 이동)
proxmox_host = Column(String(255))
proxmox_username = Column(String(100))
proxmox_api_token = Column(Text) # 암호화 권장
tailscale_ip = Column(String(45)) # IPv4/IPv6 지원
tailscale_ip = Column(String(45)) # IPv4/IPv6 지원 (deprecated)
# 상태 관리
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)
@@ -78,6 +98,7 @@ class PharmacyInfo(FarmqBase):
"""딕셔너리로 변환"""
return {
'id': self.id,
'pharmacy_code': self.pharmacy_code,
'headscale_user_name': self.headscale_user_name,
'headscale_user_id': self.headscale_user_id,
'pharmacy_name': self.pharmacy_name,
@@ -85,6 +106,15 @@ class PharmacyInfo(FarmqBase):
'manager_name': self.manager_name,
'phone': self.phone,
'address': self.address,
'owner_name': self.owner_name,
'owner_license': self.owner_license,
'owner_phone': self.owner_phone,
'owner_email': self.owner_email,
'institution_code': self.institution_code,
'institution_type': self.institution_type,
'opening_date': self.opening_date.isoformat() if self.opening_date else None,
'business_hours': self.business_hours,
'api_port': self.api_port,
'proxmox_host': self.proxmox_host,
'tailscale_ip': self.tailscale_ip,
'status': self.status,
@@ -179,6 +209,97 @@ class MachineProfile(FarmqBase):
}
class PharmacyServer(FarmqBase):
"""약국 서버 테이블 - 약국과 서버 분리"""
__tablename__ = 'pharmacy_servers'
id = Column(Integer, primary_key=True, autoincrement=True)
# 약국 연결
pharmacy_id = Column(Integer, nullable=False)
pharmacy_code = Column(String(10), nullable=False)
# Headscale 노드 연결
headscale_node_id = Column(Integer, unique=True)
headscale_user_id = Column(Integer)
# 네트워크 정보
vpn_ip = Column(String(45), nullable=False)
api_port = Column(Integer, default=8082)
is_online = Column(Boolean, default=False)
last_seen_at = Column(DateTime)
# 서버 역할
server_role = Column(String(20), default='primary') # primary, backup, test
is_active = Column(Boolean, default=True)
# 하드웨어 정보
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))
storage_gb = Column(Integer)
gpu_model = Column(String(255))
gpu_memory_gb = Column(Integer)
network_interfaces = Column(JSONType)
os_type = Column(String(50))
os_version = Column(String(100))
tailscale_version = Column(String(50))
installed_software = Column(JSONType)
# 관리 정보
status = Column(String(20), default='active')
location = Column(String(255))
purchase_date = Column(DateTime)
warranty_expires = Column(DateTime)
last_maintenance = Column(DateTime)
# 베이스라인 메트릭
baseline_cpu_temp = Column(Float)
baseline_cpu_usage = Column(Float)
baseline_memory_usage = Column(Float)
# 메타데이터
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"<PharmacyServer(id={self.id}, pharmacy_code='{self.pharmacy_code}', vpn_ip='{self.vpn_ip}')>"
def to_dict(self) -> Dict[str, Any]:
return {
'id': self.id,
'pharmacy_id': self.pharmacy_id,
'pharmacy_code': self.pharmacy_code,
'headscale_node_id': self.headscale_node_id,
'vpn_ip': self.vpn_ip,
'api_port': self.api_port,
'is_online': self.is_online,
'last_seen_at': self.last_seen_at.isoformat() if self.last_seen_at else None,
'server_role': self.server_role,
'is_active': self.is_active,
'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,
'os_type': self.os_type,
'os_version': self.os_version,
'status': self.status,
'location': self.location,
'created_at': self.created_at.isoformat(),
'updated_at': self.updated_at.isoformat()
}
class MonitoringMetrics(FarmqBase):
"""실시간 모니터링 메트릭스 - 시계열 데이터"""
__tablename__ = 'monitoring_metrics'
@@ -432,6 +553,7 @@ class FarmqDatabaseManager:
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()
@@ -442,7 +564,7 @@ class FarmqDatabaseManager:
headscale_node_id=headscale_node_data.get('id'),
headscale_machine_key=headscale_node_data.get('machine_key'),
hostname=headscale_node_data.get('hostname'),
machine_name=headscale_node_data.get('hostname'),
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()

View 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.

View 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

View File

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

View File

@@ -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));

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

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

View 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"
}

View 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."
}

View File

@@ -0,0 +1,69 @@
{
"Connecting...": "Συνδέεται...",
"Disconnecting...": "Aποσυνδέεται...",
"Reconnecting...": "Επανασυνδέεται...",
"Internal error": "Εσωτερικό σφάλμα",
"Must set host": "Πρέπει να οριστεί ο διακομιστής",
"Connected (encrypted) to ": "Συνδέθηκε (κρυπτογραφημένα) με το ",
"Connected (unencrypted) to ": "Συνδέθηκε (μη κρυπτογραφημένα) με το ",
"Something went wrong, connection is closed": "Κάτι πήγε στραβά, η σύνδεση διακόπηκε",
"Disconnected": "Αποσυνδέθηκε",
"New connection has been rejected with reason: ": "Η νέα σύνδεση απορρίφθηκε διότι: ",
"New connection has been rejected": "Η νέα σύνδεση απορρίφθηκε ",
"Password is required": "Απαιτείται ο κωδικός πρόσβασης",
"noVNC encountered an error:": "το noVNC αντιμετώπισε ένα σφάλμα:",
"Hide/Show the control bar": "Απόκρυψη/Εμφάνιση γραμμής ελέγχου",
"Move/Drag Viewport": "Μετακίνηση/Σύρσιμο Θεατού πεδίου",
"viewport drag": "σύρσιμο θεατού πεδίου",
"Active Mouse Button": "Ενεργό Πλήκτρο Ποντικιού",
"No mousebutton": "Χωρίς Πλήκτρο Ποντικιού",
"Left mousebutton": "Αριστερό Πλήκτρο Ποντικιού",
"Middle mousebutton": "Μεσαίο Πλήκτρο Ποντικιού",
"Right mousebutton": "Δεξί Πλήκτρο Ποντικιού",
"Keyboard": "Πληκτρολόγιο",
"Show Keyboard": "Εμφάνιση Πληκτρολογίου",
"Extra keys": "Επιπλέον πλήκτρα",
"Show Extra Keys": "Εμφάνιση Επιπλέον Πλήκτρων",
"Ctrl": "Ctrl",
"Toggle Ctrl": "Εναλλαγή Ctrl",
"Alt": "Alt",
"Toggle Alt": "Εναλλαγή Alt",
"Send Tab": "Αποστολή Tab",
"Tab": "Tab",
"Esc": "Esc",
"Send Escape": "Αποστολή Escape",
"Ctrl+Alt+Del": "Ctrl+Alt+Del",
"Send Ctrl-Alt-Del": "Αποστολή Ctrl-Alt-Del",
"Shutdown/Reboot": "Κλείσιμο/Επανεκκίνηση",
"Shutdown/Reboot...": "Κλείσιμο/Επανεκκίνηση...",
"Power": "Απενεργοποίηση",
"Shutdown": "Κλείσιμο",
"Reboot": "Επανεκκίνηση",
"Reset": "Επαναφορά",
"Clipboard": "Πρόχειρο",
"Clear": "Καθάρισμα",
"Fullscreen": "Πλήρης Οθόνη",
"Settings": "Ρυθμίσεις",
"Shared Mode": "Κοινόχρηστη Λειτουργία",
"View Only": "Μόνο Θέαση",
"Clip to Window": "Αποκοπή στο όριο του Παράθυρου",
"Scaling Mode:": "Λειτουργία Κλιμάκωσης:",
"None": "Καμία",
"Local Scaling": "Τοπική Κλιμάκωση",
"Remote Resizing": "Απομακρυσμένη Αλλαγή μεγέθους",
"Advanced": "Για προχωρημένους",
"Repeater ID:": "Repeater ID:",
"WebSocket": "WebSocket",
"Encrypt": "Κρυπτογράφηση",
"Host:": "Όνομα διακομιστή:",
"Port:": "Πόρτα διακομιστή:",
"Path:": "Διαδρομή:",
"Automatic Reconnect": "Αυτόματη επανασύνδεση",
"Reconnect Delay (ms):": "Καθυστέρηση επανασύνδεσης (ms):",
"Logging:": "Καταγραφή:",
"Disconnect": "Αποσύνδεση",
"Connect": "Σύνδεση",
"Password:": "Κωδικός Πρόσβασης:",
"Cancel": "Ακύρωση",
"Canvas not supported.": "Δεν υποστηρίζεται το στοιχείο Canvas"
}

View File

@@ -0,0 +1,68 @@
{
"Connecting...": "Conectando...",
"Connected (encrypted) to ": "Conectado (con encriptación) a",
"Connected (unencrypted) to ": "Conectado (sin encriptación) a",
"Disconnecting...": "Desconectando...",
"Disconnected": "Desconectado",
"Must set host": "Se debe configurar el host",
"Reconnecting...": "Reconectando...",
"Password is required": "La contraseña es obligatoria",
"Disconnect timeout": "Tiempo de desconexión agotado",
"noVNC encountered an error:": "noVNC ha encontrado un error:",
"Hide/Show the control bar": "Ocultar/Mostrar la barra de control",
"Move/Drag Viewport": "Mover/Arrastrar la ventana",
"viewport drag": "Arrastrar la ventana",
"Active Mouse Button": "Botón activo del ratón",
"No mousebutton": "Ningún botón del ratón",
"Left mousebutton": "Botón izquierdo del ratón",
"Middle mousebutton": "Botón central del ratón",
"Right mousebutton": "Botón derecho del ratón",
"Keyboard": "Teclado",
"Show Keyboard": "Mostrar teclado",
"Extra keys": "Teclas adicionales",
"Show Extra Keys": "Mostrar Teclas Adicionales",
"Ctrl": "Ctrl",
"Toggle Ctrl": "Pulsar/Soltar Ctrl",
"Alt": "Alt",
"Toggle Alt": "Pulsar/Soltar Alt",
"Send Tab": "Enviar Tabulación",
"Tab": "Tabulación",
"Esc": "Esc",
"Send Escape": "Enviar Escape",
"Ctrl+Alt+Del": "Ctrl+Alt+Del",
"Send Ctrl-Alt-Del": "Enviar Ctrl+Alt+Del",
"Shutdown/Reboot": "Apagar/Reiniciar",
"Shutdown/Reboot...": "Apagar/Reiniciar...",
"Power": "Encender",
"Shutdown": "Apagar",
"Reboot": "Reiniciar",
"Reset": "Restablecer",
"Clipboard": "Portapapeles",
"Clear": "Vaciar",
"Fullscreen": "Pantalla Completa",
"Settings": "Configuraciones",
"Encrypt": "Encriptar",
"Shared Mode": "Modo Compartido",
"View Only": "Solo visualización",
"Clip to Window": "Recortar al tamaño de la ventana",
"Scaling Mode:": "Modo de escalado:",
"None": "Ninguno",
"Local Scaling": "Escalado Local",
"Local Downscaling": "Reducción de escala local",
"Remote Resizing": "Cambio de tamaño remoto",
"Advanced": "Avanzado",
"Local Cursor": "Cursor Local",
"Repeater ID:": "ID del Repetidor:",
"WebSocket": "WebSocket",
"Host:": "Host:",
"Port:": "Puerto:",
"Path:": "Ruta:",
"Automatic Reconnect": "Reconexión automática",
"Reconnect Delay (ms):": "Retraso en la reconexión (ms):",
"Logging:": "Registrando:",
"Disconnect": "Desconectar",
"Connect": "Conectar",
"Password:": "Contraseña:",
"Cancel": "Cancelar",
"Canvas not supported.": "Canvas no soportado."
}

View File

@@ -0,0 +1,78 @@
{
"HTTPS is required for full functionality": "",
"Connecting...": "En cours de connexion...",
"Disconnecting...": "Déconnexion en cours...",
"Reconnecting...": "Reconnexion en cours...",
"Internal error": "Erreur interne",
"Must set host": "Doit définir l'hôte",
"Connected (encrypted) to ": "Connecté (chiffré) à ",
"Connected (unencrypted) to ": "Connecté (non chiffré) à ",
"Something went wrong, connection is closed": "Quelque chose s'est mal passé, la connexion a été fermée",
"Failed to connect to server": "Échec de connexion au serveur",
"Disconnected": "Déconnecté",
"New connection has been rejected with reason: ": "Une nouvelle connexion a été rejetée avec motif : ",
"New connection has been rejected": "Une nouvelle connexion a été rejetée",
"Credentials are required": "Les identifiants sont requis",
"noVNC encountered an error:": "noVNC a rencontré une erreur :",
"Hide/Show the control bar": "Masquer/Afficher la barre de contrôle",
"Drag": "Faire glisser",
"Move/Drag Viewport": "Déplacer/faire glisser le Viewport",
"Keyboard": "Clavier",
"Show Keyboard": "Afficher le clavier",
"Extra keys": "Touches supplémentaires",
"Show Extra Keys": "Afficher les touches supplémentaires",
"Ctrl": "Ctrl",
"Toggle Ctrl": "Basculer Ctrl",
"Alt": "Alt",
"Toggle Alt": "Basculer Alt",
"Toggle Windows": "Basculer Windows",
"Windows": "Windows",
"Send Tab": "Envoyer l'onglet",
"Tab": "l'onglet",
"Esc": "Esc",
"Send Escape": "Envoyer Escape",
"Ctrl+Alt+Del": "Ctrl+Alt+Del",
"Send Ctrl-Alt-Del": "Envoyer Ctrl-Alt-Del",
"Shutdown/Reboot": "Arrêter/Redémarrer",
"Shutdown/Reboot...": "Arrêter/Redémarrer...",
"Power": "Alimentation",
"Shutdown": "Arrêter",
"Reboot": "Redémarrer",
"Reset": "Réinitialiser",
"Clipboard": "Presse-papiers",
"Edit clipboard content in the textarea below.": "",
"Settings": "Paramètres",
"Shared Mode": "Mode partagé",
"View Only": "Afficher uniquement",
"Clip to Window": "Clip à fenêtre",
"Scaling Mode:": "Mode mise à l'échelle :",
"None": "Aucun",
"Local Scaling": "Mise à l'échelle locale",
"Remote Resizing": "Redimensionnement à distance",
"Advanced": "Avancé",
"Quality:": "Qualité :",
"Compression level:": "Niveau de compression :",
"Repeater ID:": "ID Répéteur :",
"WebSocket": "WebSocket",
"Encrypt": "Chiffrer",
"Host:": "Hôte :",
"Port:": "Port :",
"Path:": "Chemin :",
"Automatic Reconnect": "Reconnecter automatiquemen",
"Reconnect Delay (ms):": "Délai de reconnexion (ms) :",
"Show Dot when No Cursor": "Afficher le point lorsqu'il n'y a pas de curseur",
"Logging:": "Se connecter :",
"Version:": "Version :",
"Disconnect": "Déconnecter",
"Connect": "Connecter",
"Server identity": "",
"The server has provided the following identifying information:": "",
"Fingerprint:": "",
"Please verify that the information is correct and press \"Approve\". Otherwise press \"Reject\".": "",
"Approve": "",
"Reject": "",
"Username:": "Nom d'utilisateur :",
"Password:": "Mot de passe :",
"Send Credentials": "Envoyer les identifiants",
"Cancel": "Annuler"
}

View File

@@ -0,0 +1,72 @@
{
"Connecting...": "Connessione in corso...",
"Disconnecting...": "Disconnessione...",
"Reconnecting...": "Riconnessione...",
"Internal error": "Errore interno",
"Must set host": "Devi impostare l'host",
"Connected (encrypted) to ": "Connesso (crittografato) a ",
"Connected (unencrypted) to ": "Connesso (non crittografato) a",
"Something went wrong, connection is closed": "Qualcosa è andato storto, la connessione è stata chiusa",
"Failed to connect to server": "Impossibile connettersi al server",
"Disconnected": "Disconnesso",
"New connection has been rejected with reason: ": "La nuova connessione è stata rifiutata con motivo: ",
"New connection has been rejected": "La nuova connessione è stata rifiutata",
"Credentials are required": "Le credenziali sono obbligatorie",
"noVNC encountered an error:": "noVNC ha riscontrato un errore:",
"Hide/Show the control bar": "Nascondi/Mostra la barra di controllo",
"Drag": "",
"Move/Drag Viewport": "",
"Keyboard": "Tastiera",
"Show Keyboard": "Mostra tastiera",
"Extra keys": "Tasti Aggiuntivi",
"Show Extra Keys": "Mostra Tasti Aggiuntivi",
"Ctrl": "Ctrl",
"Toggle Ctrl": "Tieni premuto Ctrl",
"Alt": "Alt",
"Toggle Alt": "Tieni premuto Alt",
"Toggle Windows": "Tieni premuto Windows",
"Windows": "Windows",
"Send Tab": "Invia Tab",
"Tab": "Tab",
"Esc": "Esc",
"Send Escape": "Invia Esc",
"Ctrl+Alt+Del": "Ctrl+Alt+Canc",
"Send Ctrl-Alt-Del": "Invia Ctrl-Alt-Canc",
"Shutdown/Reboot": "Spegnimento/Riavvio",
"Shutdown/Reboot...": "Spegnimento/Riavvio...",
"Power": "Alimentazione",
"Shutdown": "Spegnimento",
"Reboot": "Riavvio",
"Reset": "Reset",
"Clipboard": "Clipboard",
"Clear": "Pulisci",
"Fullscreen": "Schermo intero",
"Settings": "Impostazioni",
"Shared Mode": "Modalità condivisa",
"View Only": "Sola Visualizzazione",
"Clip to Window": "",
"Scaling Mode:": "Modalità di ridimensionamento:",
"None": "Nessuna",
"Local Scaling": "Ridimensionamento Locale",
"Remote Resizing": "Ridimensionamento Remoto",
"Advanced": "Avanzate",
"Quality:": "Qualità:",
"Compression level:": "Livello Compressione:",
"Repeater ID:": "ID Ripetitore:",
"WebSocket": "WebSocket",
"Encrypt": "Crittografa",
"Host:": "Host:",
"Port:": "Porta:",
"Path:": "Percorso:",
"Automatic Reconnect": "Riconnessione Automatica",
"Reconnect Delay (ms):": "Ritardo Riconnessione (ms):",
"Show Dot when No Cursor": "Mostra Punto quando Nessun Cursore",
"Logging:": "",
"Version:": "Versione:",
"Disconnect": "Disconnetti",
"Connect": "Connetti",
"Username:": "Utente:",
"Password:": "Password:",
"Send Credentials": "Invia Credenziale",
"Cancel": "Annulla"
}

View File

@@ -0,0 +1,72 @@
{
"Connecting...": "接続しています...",
"Disconnecting...": "切断しています...",
"Reconnecting...": "再接続しています...",
"Internal error": "内部エラー",
"Must set host": "ホストを設定する必要があります",
"Connected (encrypted) to ": "接続しました (暗号化済み): ",
"Connected (unencrypted) to ": "接続しました (暗号化されていません): ",
"Something went wrong, connection is closed": "何らかの問題で、接続が閉じられました",
"Failed to connect to server": "サーバーへの接続に失敗しました",
"Disconnected": "切断しました",
"New connection has been rejected with reason: ": "新規接続は次の理由で拒否されました: ",
"New connection has been rejected": "新規接続は拒否されました",
"Credentials are required": "資格情報が必要です",
"noVNC encountered an error:": "noVNC でエラーが発生しました:",
"Hide/Show the control bar": "コントロールバーを隠す/表示する",
"Drag": "ドラッグ",
"Move/Drag Viewport": "ビューポートを移動/ドラッグ",
"Keyboard": "キーボード",
"Show Keyboard": "キーボードを表示",
"Extra keys": "追加キー",
"Show Extra Keys": "追加キーを表示",
"Ctrl": "Ctrl",
"Toggle Ctrl": "Ctrl キーを切り替え",
"Alt": "Alt",
"Toggle Alt": "Alt キーを切り替え",
"Toggle Windows": "Windows キーを切り替え",
"Windows": "Windows",
"Send Tab": "Tab キーを送信",
"Tab": "Tab",
"Esc": "Esc",
"Send Escape": "Escape キーを送信",
"Ctrl+Alt+Del": "Ctrl+Alt+Del",
"Send Ctrl-Alt-Del": "Ctrl-Alt-Del を送信",
"Shutdown/Reboot": "シャットダウン/再起動",
"Shutdown/Reboot...": "シャットダウン/再起動...",
"Power": "電源",
"Shutdown": "シャットダウン",
"Reboot": "再起動",
"Reset": "リセット",
"Clipboard": "クリップボード",
"Clear": "クリア",
"Fullscreen": "全画面表示",
"Settings": "設定",
"Shared Mode": "共有モード",
"View Only": "表示のみ",
"Clip to Window": "ウィンドウにクリップ",
"Scaling Mode:": "スケーリングモード:",
"None": "なし",
"Local Scaling": "ローカルスケーリング",
"Remote Resizing": "リモートでリサイズ",
"Advanced": "高度",
"Quality:": "品質:",
"Compression level:": "圧縮レベル:",
"Repeater ID:": "リピーター ID:",
"WebSocket": "WebSocket",
"Encrypt": "暗号化",
"Host:": "ホスト:",
"Port:": "ポート:",
"Path:": "パス:",
"Automatic Reconnect": "自動再接続",
"Reconnect Delay (ms):": "再接続する遅延 (ミリ秒):",
"Show Dot when No Cursor": "カーソルがないときにドットを表示",
"Logging:": "ロギング:",
"Version:": "バージョン:",
"Disconnect": "切断",
"Connect": "接続",
"Username:": "ユーザー名:",
"Password:": "パスワード:",
"Send Credentials": "資格情報を送信",
"Cancel": "キャンセル"
}

View File

@@ -0,0 +1,70 @@
{
"Connecting...": "연결중...",
"Disconnecting...": "연결 해제중...",
"Reconnecting...": "재연결중...",
"Internal error": "내부 오류",
"Must set host": "호스트는 설정되어야 합니다.",
"Connected (encrypted) to ": "다음과 (암호화되어) 연결되었습니다:",
"Connected (unencrypted) to ": "다음과 (암호화 없이) 연결되었습니다:",
"Something went wrong, connection is closed": "무언가 잘못되었습니다, 연결이 닫혔습니다.",
"Failed to connect to server": "서버에 연결하지 못했습니다.",
"Disconnected": "연결이 해제되었습니다.",
"New connection has been rejected with reason: ": "새 연결이 다음 이유로 거부되었습니다:",
"New connection has been rejected": "새 연결이 거부되었습니다.",
"Password is required": "비밀번호가 필요합니다.",
"noVNC encountered an error:": "noVNC에 오류가 발생했습니다:",
"Hide/Show the control bar": "컨트롤 바 숨기기/보이기",
"Move/Drag Viewport": "움직이기/드래그 뷰포트",
"viewport drag": "뷰포트 드래그",
"Active Mouse Button": "마우스 버튼 활성화",
"No mousebutton": "마우스 버튼 없음",
"Left mousebutton": "왼쪽 마우스 버튼",
"Middle mousebutton": "중간 마우스 버튼",
"Right mousebutton": "오른쪽 마우스 버튼",
"Keyboard": "키보드",
"Show Keyboard": "키보드 보이기",
"Extra keys": "기타 키들",
"Show Extra Keys": "기타 키들 보이기",
"Ctrl": "Ctrl",
"Toggle Ctrl": "Ctrl 켜기/끄기",
"Alt": "Alt",
"Toggle Alt": "Alt 켜기/끄기",
"Send Tab": "Tab 보내기",
"Tab": "Tab",
"Esc": "Esc",
"Send Escape": "Esc 보내기",
"Ctrl+Alt+Del": "Ctrl+Alt+Del",
"Send Ctrl-Alt-Del": "Ctrl+Alt+Del 보내기",
"Shutdown/Reboot": "셧다운/리붓",
"Shutdown/Reboot...": "셧다운/리붓...",
"Power": "전원",
"Shutdown": "셧다운",
"Reboot": "리붓",
"Reset": "리셋",
"Clipboard": "클립보드",
"Clear": "지우기",
"Fullscreen": "전체화면",
"Settings": "설정",
"Shared Mode": "공유 모드",
"View Only": "보기 전용",
"Clip to Window": "창에 클립",
"Scaling Mode:": "스케일링 모드:",
"None": "없음",
"Local Scaling": "로컬 스케일링",
"Remote Resizing": "원격 크기 조절",
"Advanced": "고급",
"Repeater ID:": "중계 ID",
"WebSocket": "웹소켓",
"Encrypt": "암호화",
"Host:": "호스트:",
"Port:": "포트:",
"Path:": "위치:",
"Automatic Reconnect": "자동 재연결",
"Reconnect Delay (ms):": "재연결 지연 시간 (ms)",
"Logging:": "로깅",
"Disconnect": "연결 해제",
"Connect": "연결",
"Password:": "비밀번호:",
"Send Password": "비밀번호 전송",
"Cancel": "취소"
}

View File

@@ -0,0 +1,73 @@
{
"Connecting...": "Verbinden...",
"Disconnecting...": "Verbinding verbreken...",
"Reconnecting...": "Opnieuw verbinding maken...",
"Internal error": "Interne fout",
"Must set host": "Host moeten worden ingesteld",
"Connected (encrypted) to ": "Verbonden (versleuteld) met ",
"Connected (unencrypted) to ": "Verbonden (onversleuteld) met ",
"Something went wrong, connection is closed": "Er iets fout gelopen, verbinding werd verbroken",
"Failed to connect to server": "Verbinding maken met server is mislukt",
"Disconnected": "Verbinding verbroken",
"New connection has been rejected with reason: ": "Nieuwe verbinding is geweigerd omwille van de volgende reden: ",
"New connection has been rejected": "Nieuwe verbinding is geweigerd",
"Password is required": "Wachtwoord is vereist",
"noVNC encountered an error:": "noVNC heeft een fout bemerkt:",
"Hide/Show the control bar": "Verberg/Toon de bedieningsbalk",
"Move/Drag Viewport": "Verplaats/Versleep Kijkvenster",
"viewport drag": "kijkvenster slepen",
"Active Mouse Button": "Actieve Muisknop",
"No mousebutton": "Geen muisknop",
"Left mousebutton": "Linker muisknop",
"Middle mousebutton": "Middelste muisknop",
"Right mousebutton": "Rechter muisknop",
"Keyboard": "Toetsenbord",
"Show Keyboard": "Toon Toetsenbord",
"Extra keys": "Extra toetsen",
"Show Extra Keys": "Toon Extra Toetsen",
"Ctrl": "Ctrl",
"Toggle Ctrl": "Ctrl omschakelen",
"Alt": "Alt",
"Toggle Alt": "Alt omschakelen",
"Toggle Windows": "Windows omschakelen",
"Windows": "Windows",
"Send Tab": "Tab Sturen",
"Tab": "Tab",
"Esc": "Esc",
"Send Escape": "Escape Sturen",
"Ctrl+Alt+Del": "Ctrl-Alt-Del",
"Send Ctrl-Alt-Del": "Ctrl-Alt-Del Sturen",
"Shutdown/Reboot": "Uitschakelen/Herstarten",
"Shutdown/Reboot...": "Uitschakelen/Herstarten...",
"Power": "Systeem",
"Shutdown": "Uitschakelen",
"Reboot": "Herstarten",
"Reset": "Resetten",
"Clipboard": "Klembord",
"Clear": "Wissen",
"Fullscreen": "Volledig Scherm",
"Settings": "Instellingen",
"Shared Mode": "Gedeelde Modus",
"View Only": "Alleen Kijken",
"Clip to Window": "Randen buiten venster afsnijden",
"Scaling Mode:": "Schaalmodus:",
"None": "Geen",
"Local Scaling": "Lokaal Schalen",
"Remote Resizing": "Op Afstand Formaat Wijzigen",
"Advanced": "Geavanceerd",
"Repeater ID:": "Repeater ID:",
"WebSocket": "WebSocket",
"Encrypt": "Versleutelen",
"Host:": "Host:",
"Port:": "Poort:",
"Path:": "Pad:",
"Automatic Reconnect": "Automatisch Opnieuw Verbinden",
"Reconnect Delay (ms):": "Vertraging voor Opnieuw Verbinden (ms):",
"Show Dot when No Cursor": "Geef stip weer indien geen cursor",
"Logging:": "Logmeldingen:",
"Disconnect": "Verbinding verbreken",
"Connect": "Verbinden",
"Password:": "Wachtwoord:",
"Send Password": "Verzend Wachtwoord:",
"Cancel": "Annuleren"
}

View File

@@ -0,0 +1,69 @@
{
"Connecting...": "Łączenie...",
"Disconnecting...": "Rozłączanie...",
"Reconnecting...": "Łączenie...",
"Internal error": "Błąd wewnętrzny",
"Must set host": "Host i port są wymagane",
"Connected (encrypted) to ": "Połączenie (szyfrowane) z ",
"Connected (unencrypted) to ": "Połączenie (nieszyfrowane) z ",
"Something went wrong, connection is closed": "Coś poszło źle, połączenie zostało zamknięte",
"Disconnected": "Rozłączony",
"New connection has been rejected with reason: ": "Nowe połączenie zostało odrzucone z powodu: ",
"New connection has been rejected": "Nowe połączenie zostało odrzucone",
"Password is required": "Hasło jest wymagane",
"noVNC encountered an error:": "noVNC napotkało błąd:",
"Hide/Show the control bar": "Pokaż/Ukryj pasek ustawień",
"Move/Drag Viewport": "Ruszaj/Przeciągaj Viewport",
"viewport drag": "przeciągnij viewport",
"Active Mouse Button": "Aktywny Przycisk Myszy",
"No mousebutton": "Brak przycisku myszy",
"Left mousebutton": "Lewy przycisk myszy",
"Middle mousebutton": "Środkowy przycisk myszy",
"Right mousebutton": "Prawy przycisk myszy",
"Keyboard": "Klawiatura",
"Show Keyboard": "Pokaż klawiaturę",
"Extra keys": "Przyciski dodatkowe",
"Show Extra Keys": "Pokaż przyciski dodatkowe",
"Ctrl": "Ctrl",
"Toggle Ctrl": "Przełącz Ctrl",
"Alt": "Alt",
"Toggle Alt": "Przełącz Alt",
"Send Tab": "Wyślij Tab",
"Tab": "Tab",
"Esc": "Esc",
"Send Escape": "Wyślij Escape",
"Ctrl+Alt+Del": "Ctrl+Alt+Del",
"Send Ctrl-Alt-Del": "Wyślij Ctrl-Alt-Del",
"Shutdown/Reboot": "Wyłącz/Uruchom ponownie",
"Shutdown/Reboot...": "Wyłącz/Uruchom ponownie...",
"Power": "Włączony",
"Shutdown": "Wyłącz",
"Reboot": "Uruchom ponownie",
"Reset": "Resetuj",
"Clipboard": "Schowek",
"Clear": "Wyczyść",
"Fullscreen": "Pełny ekran",
"Settings": "Ustawienia",
"Shared Mode": "Tryb Współdzielenia",
"View Only": "Tylko Podgląd",
"Clip to Window": "Przytnij do Okna",
"Scaling Mode:": "Tryb Skalowania:",
"None": "Brak",
"Local Scaling": "Skalowanie lokalne",
"Remote Resizing": "Skalowanie zdalne",
"Advanced": "Zaawansowane",
"Repeater ID:": "ID Repeatera:",
"WebSocket": "WebSocket",
"Encrypt": "Szyfrowanie",
"Host:": "Host:",
"Port:": "Port:",
"Path:": "Ścieżka:",
"Automatic Reconnect": "Automatycznie wznawiaj połączenie",
"Reconnect Delay (ms):": "Opóźnienie wznawiania (ms):",
"Logging:": "Poziom logowania:",
"Disconnect": "Rozłącz",
"Connect": "Połącz",
"Password:": "Hasło:",
"Cancel": "Anuluj",
"Canvas not supported.": "Element Canvas nie jest wspierany."
}

View File

@@ -0,0 +1,72 @@
{
"Connecting...": "Conectando...",
"Disconnecting...": "Desconectando...",
"Reconnecting...": "Reconectando...",
"Internal error": "Erro interno",
"Must set host": "É necessário definir o host",
"Connected (encrypted) to ": "Conectado (com criptografia) a ",
"Connected (unencrypted) to ": "Conectado (sem criptografia) a ",
"Something went wrong, connection is closed": "Algo deu errado. A conexão foi encerrada.",
"Failed to connect to server": "Falha ao conectar-se ao servidor",
"Disconnected": "Desconectado",
"New connection has been rejected with reason: ": "A nova conexão foi rejeitada pelo motivo: ",
"New connection has been rejected": "A nova conexão foi rejeitada",
"Credentials are required": "Credenciais são obrigatórias",
"noVNC encountered an error:": "O noVNC encontrou um erro:",
"Hide/Show the control bar": "Esconder/mostrar a barra de controles",
"Drag": "Arrastar",
"Move/Drag Viewport": "Mover/arrastar a janela",
"Keyboard": "Teclado",
"Show Keyboard": "Mostrar teclado",
"Extra keys": "Teclas adicionais",
"Show Extra Keys": "Mostar teclas adicionais",
"Ctrl": "Ctrl",
"Toggle Ctrl": "Pressionar/soltar Ctrl",
"Alt": "Alt",
"Toggle Alt": "Pressionar/soltar Alt",
"Toggle Windows": "Pressionar/soltar Windows",
"Windows": "Windows",
"Send Tab": "Enviar Tab",
"Tab": "Tab",
"Esc": "Esc",
"Send Escape": "Enviar Esc",
"Ctrl+Alt+Del": "Ctrl+Alt+Del",
"Send Ctrl-Alt-Del": "Enviar Ctrl-Alt-Del",
"Shutdown/Reboot": "Desligar/reiniciar",
"Shutdown/Reboot...": "Desligar/reiniciar...",
"Power": "Ligar",
"Shutdown": "Desligar",
"Reboot": "Reiniciar",
"Reset": "Reiniciar (forçado)",
"Clipboard": "Área de transferência",
"Clear": "Limpar",
"Fullscreen": "Tela cheia",
"Settings": "Configurações",
"Shared Mode": "Modo compartilhado",
"View Only": "Apenas visualizar",
"Clip to Window": "Recortar à janela",
"Scaling Mode:": "Modo de dimensionamento:",
"None": "Nenhum",
"Local Scaling": "Local",
"Remote Resizing": "Remoto",
"Advanced": "Avançado",
"Quality:": "Qualidade:",
"Compression level:": "Nível de compressão:",
"Repeater ID:": "ID do repetidor:",
"WebSocket": "WebSocket",
"Encrypt": "Criptografar",
"Host:": "Host:",
"Port:": "Porta:",
"Path:": "Caminho:",
"Automatic Reconnect": "Reconexão automática",
"Reconnect Delay (ms):": "Atraso da reconexão (ms)",
"Show Dot when No Cursor": "Mostrar ponto quando não há cursor",
"Logging:": "Registros:",
"Version:": "Versão:",
"Disconnect": "Desconectar",
"Connect": "Conectar",
"Username:": "Nome de usuário:",
"Password:": "Senha:",
"Send Credentials": "Enviar credenciais",
"Cancel": "Cancelar"
}

View File

@@ -0,0 +1,72 @@
{
"Connecting...": "Подключение...",
"Disconnecting...": "Отключение...",
"Reconnecting...": "Переподключение...",
"Internal error": "Внутренняя ошибка",
"Must set host": "Задайте имя сервера или IP",
"Connected (encrypted) to ": "Подключено (с шифрованием) к ",
"Connected (unencrypted) to ": "Подключено (без шифрования) к ",
"Something went wrong, connection is closed": "Что-то пошло не так, подключение разорвано",
"Failed to connect to server": "Ошибка подключения к серверу",
"Disconnected": "Отключено",
"New connection has been rejected with reason: ": "Новое соединение отклонено по причине: ",
"New connection has been rejected": "Новое соединение отклонено",
"Credentials are required": "Требуются учетные данные",
"noVNC encountered an error:": "Ошибка noVNC: ",
"Hide/Show the control bar": "Скрыть/Показать контрольную панель",
"Drag": "Переместить",
"Move/Drag Viewport": "Переместить окно",
"Keyboard": "Клавиатура",
"Show Keyboard": "Показать клавиатуру",
"Extra keys": "Дополнительные Кнопки",
"Show Extra Keys": "Показать Дополнительные Кнопки",
"Ctrl": "Ctrl",
"Toggle Ctrl": "Переключение нажатия Ctrl",
"Alt": "Alt",
"Toggle Alt": "Переключение нажатия Alt",
"Toggle Windows": "Переключение вкладок",
"Windows": "Вкладка",
"Send Tab": "Передать нажатие Tab",
"Tab": "Tab",
"Esc": "Esc",
"Send Escape": "Передать нажатие Escape",
"Ctrl+Alt+Del": "Ctrl+Alt+Del",
"Send Ctrl-Alt-Del": "Передать нажатие Ctrl-Alt-Del",
"Shutdown/Reboot": "Выключить/Перезагрузить",
"Shutdown/Reboot...": "Выключить/Перезагрузить...",
"Power": "Питание",
"Shutdown": "Выключить",
"Reboot": "Перезагрузить",
"Reset": "Сброс",
"Clipboard": "Буфер обмена",
"Clear": "Очистить",
"Fullscreen": "Во весь экран",
"Settings": "Настройки",
"Shared Mode": "Общий режим",
"View Only": "Только Просмотр",
"Clip to Window": "В окно",
"Scaling Mode:": "Масштаб:",
"None": "Нет",
"Local Scaling": "Локльный масштаб",
"Remote Resizing": "Удаленная перенастройка размера",
"Advanced": "Дополнительно",
"Quality:": "Качество",
"Compression level:": "Уровень Сжатия",
"Repeater ID:": "Идентификатор ID:",
"WebSocket": "WebSocket",
"Encrypt": "Шифрование",
"Host:": "Сервер:",
"Port:": "Порт:",
"Path:": "Путь:",
"Automatic Reconnect": "Автоматическое переподключение",
"Reconnect Delay (ms):": "Задержка переподключения (мс):",
"Show Dot when No Cursor": "Показать точку вместо курсора",
"Logging:": "Лог:",
"Version:": "Версия",
"Disconnect": "Отключение",
"Connect": "Подключение",
"Username:": "Имя Пользователя",
"Password:": "Пароль:",
"Send Credentials": "Передача Учетных Данных",
"Cancel": "Выход"
}

View File

@@ -0,0 +1,80 @@
{
"HTTPS is required for full functionality": "HTTPS krävs för full funktionalitet",
"Connecting...": "Ansluter...",
"Disconnecting...": "Kopplar ner...",
"Reconnecting...": "Återansluter...",
"Internal error": "Internt fel",
"Must set host": "Du måste specifiera en värd",
"Connected (encrypted) to ": "Ansluten (krypterat) till ",
"Connected (unencrypted) to ": "Ansluten (okrypterat) till ",
"Something went wrong, connection is closed": "Något gick fel, anslutningen avslutades",
"Failed to connect to server": "Misslyckades att ansluta till servern",
"Disconnected": "Frånkopplad",
"New connection has been rejected with reason: ": "Ny anslutning har blivit nekad med följande skäl: ",
"New connection has been rejected": "Ny anslutning har blivit nekad",
"Credentials are required": "Användaruppgifter krävs",
"noVNC encountered an error:": "noVNC stötte på ett problem:",
"Hide/Show the control bar": "Göm/Visa kontrollbaren",
"Drag": "Dra",
"Move/Drag Viewport": "Flytta/Dra Vyn",
"Keyboard": "Tangentbord",
"Show Keyboard": "Visa Tangentbord",
"Extra keys": "Extraknappar",
"Show Extra Keys": "Visa Extraknappar",
"Ctrl": "Ctrl",
"Toggle Ctrl": "Växla Ctrl",
"Alt": "Alt",
"Toggle Alt": "Växla Alt",
"Toggle Windows": "Växla Windows",
"Windows": "Windows",
"Send Tab": "Skicka Tab",
"Tab": "Tab",
"Esc": "Esc",
"Send Escape": "Skicka Escape",
"Ctrl+Alt+Del": "Ctrl+Alt+Del",
"Send Ctrl-Alt-Del": "Skicka Ctrl-Alt-Del",
"Shutdown/Reboot": "Stäng av/Boota om",
"Shutdown/Reboot...": "Stäng av/Boota om...",
"Power": "Ström",
"Shutdown": "Stäng av",
"Reboot": "Boota om",
"Reset": "Återställ",
"Clipboard": "Urklipp",
"Edit clipboard content in the textarea below.": "Redigera urklippets innehåll i fältet nedan.",
"Full Screen": "Fullskärm",
"Settings": "Inställningar",
"Shared Mode": "Delat Läge",
"View Only": "Endast Visning",
"Clip to Window": "Begränsa till Fönster",
"Scaling Mode:": "Skalningsläge:",
"None": "Ingen",
"Local Scaling": "Lokal Skalning",
"Remote Resizing": "Ändra Storlek",
"Advanced": "Avancerat",
"Quality:": "Kvalitet:",
"Compression level:": "Kompressionsnivå:",
"Repeater ID:": "Repeater-ID:",
"WebSocket": "WebSocket",
"Encrypt": "Kryptera",
"Host:": "Värd:",
"Port:": "Port:",
"Path:": "Sökväg:",
"Automatic Reconnect": "Automatisk Återanslutning",
"Reconnect Delay (ms):": "Fördröjning (ms):",
"Show Dot when No Cursor": "Visa prick när ingen muspekare finns",
"Logging:": "Loggning:",
"Version:": "Version:",
"Disconnect": "Koppla från",
"Connect": "Anslut",
"Server identity": "Server-identitet",
"The server has provided the following identifying information:": "Servern har gett följande identifierande information:",
"Fingerprint:": "Fingeravtryck:",
"Please verify that the information is correct and press \"Approve\". Otherwise press \"Reject\".": "Kontrollera att informationen är korrekt och tryck sedan \"Godkänn\". Tryck annars \"Neka\".",
"Approve": "Godkänn",
"Reject": "Neka",
"Credentials": "Användaruppgifter",
"Username:": "Användarnamn:",
"Password:": "Lösenord:",
"Send Credentials": "Skicka Användaruppgifter",
"Cancel": "Avbryt"
}

View File

@@ -0,0 +1,69 @@
{
"Connecting...": "Bağlanıyor...",
"Disconnecting...": "Bağlantı kesiliyor...",
"Reconnecting...": "Yeniden bağlantı kuruluyor...",
"Internal error": "İç hata",
"Must set host": "Sunucuyu kur",
"Connected (encrypted) to ": "Bağlı (şifrelenmiş)",
"Connected (unencrypted) to ": "Bağlandı (şifrelenmemiş)",
"Something went wrong, connection is closed": "Bir şeyler ters gitti, bağlantı kesildi",
"Disconnected": "Bağlantı kesildi",
"New connection has been rejected with reason: ": "Bağlantı aşağıdaki nedenlerden dolayı reddedildi: ",
"New connection has been rejected": "Bağlantı reddedildi",
"Password is required": "Şifre gerekli",
"noVNC encountered an error:": "Bir hata oluştu:",
"Hide/Show the control bar": "Denetim masasını Gizle/Göster",
"Move/Drag Viewport": "Görünümü Taşı/Sürükle",
"viewport drag": "Görüntü penceresini sürükle",
"Active Mouse Button": "Aktif Fare Düğmesi",
"No mousebutton": "Fare düğmesi yok",
"Left mousebutton": "Farenin sol düğmesi",
"Middle mousebutton": "Farenin orta düğmesi",
"Right mousebutton": "Farenin sağ düğmesi",
"Keyboard": "Klavye",
"Show Keyboard": "Klavye Düzenini Göster",
"Extra keys": "Ekstra tuşlar",
"Show Extra Keys": "Ekstra tuşları göster",
"Ctrl": "Ctrl",
"Toggle Ctrl": "Ctrl Değiştir ",
"Alt": "Alt",
"Toggle Alt": "Alt Değiştir",
"Send Tab": "Sekme Gönder",
"Tab": "Sekme",
"Esc": "Esc",
"Send Escape": "Boşluk Gönder",
"Ctrl+Alt+Del": "Ctrl + Alt + Del",
"Send Ctrl-Alt-Del": "Ctrl-Alt-Del Gönder",
"Shutdown/Reboot": "Kapat/Yeniden Başlat",
"Shutdown/Reboot...": "Kapat/Yeniden Başlat...",
"Power": "Güç",
"Shutdown": "Kapat",
"Reboot": "Yeniden Başlat",
"Reset": "Sıfırla",
"Clipboard": "Pano",
"Clear": "Temizle",
"Fullscreen": "Tam Ekran",
"Settings": "Ayarlar",
"Shared Mode": "Paylaşım Modu",
"View Only": "Sadece Görüntüle",
"Clip to Window": "Pencereye Tıkla",
"Scaling Mode:": "Ölçekleme Modu:",
"None": "Bilinmeyen",
"Local Scaling": "Yerel Ölçeklendirme",
"Remote Resizing": "Uzaktan Yeniden Boyutlandırma",
"Advanced": "Gelişmiş",
"Repeater ID:": "Tekralayıcı ID:",
"WebSocket": "WebSocket",
"Encrypt": "Şifrele",
"Host:": "Ana makine:",
"Port:": "Port:",
"Path:": "Yol:",
"Automatic Reconnect": "Otomatik Yeniden Bağlan",
"Reconnect Delay (ms):": "Yeniden Bağlanma Süreci (ms):",
"Logging:": "Giriş yapılıyor:",
"Disconnect": "Bağlantıyı Kes",
"Connect": "Bağlan",
"Password:": "Parola:",
"Cancel": "Vazgeç",
"Canvas not supported.": "Tuval desteklenmiyor."
}

View File

@@ -0,0 +1,69 @@
{
"Connecting...": "连接中...",
"Disconnecting...": "正在断开连接...",
"Reconnecting...": "重新连接中...",
"Internal error": "内部错误",
"Must set host": "请提供主机名",
"Connected (encrypted) to ": "已连接到(加密)",
"Connected (unencrypted) to ": "已连接到(未加密)",
"Something went wrong, connection is closed": "发生错误,连接已关闭",
"Failed to connect to server": "无法连接到服务器",
"Disconnected": "已断开连接",
"New connection has been rejected with reason: ": "连接被拒绝,原因:",
"New connection has been rejected": "连接被拒绝",
"Password is required": "请提供密码",
"noVNC encountered an error:": "noVNC 遇到一个错误:",
"Hide/Show the control bar": "显示/隐藏控制栏",
"Move/Drag Viewport": "拖放显示范围",
"viewport drag": "显示范围拖放",
"Active Mouse Button": "启动鼠标按鍵",
"No mousebutton": "禁用鼠标按鍵",
"Left mousebutton": "鼠标左鍵",
"Middle mousebutton": "鼠标中鍵",
"Right mousebutton": "鼠标右鍵",
"Keyboard": "键盘",
"Show Keyboard": "显示键盘",
"Extra keys": "额外按键",
"Show Extra Keys": "显示额外按键",
"Ctrl": "Ctrl",
"Toggle Ctrl": "切换 Ctrl",
"Alt": "Alt",
"Toggle Alt": "切换 Alt",
"Send Tab": "发送 Tab 键",
"Tab": "Tab",
"Esc": "Esc",
"Send Escape": "发送 Escape 键",
"Ctrl+Alt+Del": "Ctrl-Alt-Del",
"Send Ctrl-Alt-Del": "发送 Ctrl-Alt-Del 键",
"Shutdown/Reboot": "关机/重新启动",
"Shutdown/Reboot...": "关机/重新启动...",
"Power": "电源",
"Shutdown": "关机",
"Reboot": "重新启动",
"Reset": "重置",
"Clipboard": "剪贴板",
"Clear": "清除",
"Fullscreen": "全屏",
"Settings": "设置",
"Shared Mode": "分享模式",
"View Only": "仅查看",
"Clip to Window": "限制/裁切窗口大小",
"Scaling Mode:": "缩放模式:",
"None": "无",
"Local Scaling": "本地缩放",
"Remote Resizing": "远程调整大小",
"Advanced": "高级",
"Repeater ID:": "中继站 ID",
"WebSocket": "WebSocket",
"Encrypt": "加密",
"Host:": "主机:",
"Port:": "端口:",
"Path:": "路径:",
"Automatic Reconnect": "自动重新连接",
"Reconnect Delay (ms):": "重新连接间隔 (ms)",
"Logging:": "日志级别:",
"Disconnect": "中断连接",
"Connect": "连接",
"Password:": "密码:",
"Cancel": "取消"
}

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