commit
b08695cf65
46 changed files with 9053 additions and 0 deletions
@ -0,0 +1,19 @@ |
|||||
|
.gocache/ |
||||
|
*.exe |
||||
|
runtime/cache/ |
||||
|
|
||||
|
# Go / build cache |
||||
|
.gocache/ |
||||
|
runtime/cache/ |
||||
|
*.exe |
||||
|
|
||||
|
# Runtime files |
||||
|
runtime/logs/ |
||||
|
runtime/*.db |
||||
|
runtime/*.db-shm |
||||
|
runtime/*.db-wal |
||||
|
|
||||
|
# Local database |
||||
|
data/*.db |
||||
|
data/*.db-shm |
||||
|
data/*.db-wal |
||||
@ -0,0 +1,56 @@ |
|||||
|
# TERACLONE 장비 Web UI 목업 |
||||
|
|
||||
|
## 실행 방법 |
||||
|
1. `cd code` |
||||
|
2. `go run -buildvcs=false ./cmd/server` |
||||
|
3. 브라우저에서 `http://localhost:8080` 접속 |
||||
|
|
||||
|
## 주요 경로 |
||||
|
- 새 목업 UI |
||||
|
- `/` |
||||
|
- `/network/interface-1` |
||||
|
- `/ports/1/parameters` |
||||
|
- `/admin/users` |
||||
|
- `/admin/snmp` |
||||
|
- `/logs/system` |
||||
|
- `/port-status` |
||||
|
- 레거시 UI |
||||
|
- `/legacy` |
||||
|
|
||||
|
## 구현된 화면 |
||||
|
- 대시보드 |
||||
|
- 네트워크 설정 전 영역 |
||||
|
- 포트 설정 전 영역 |
||||
|
- 포트 디버그 |
||||
|
- 관리자 운영 전 영역 |
||||
|
- 기타 설정 전 영역 |
||||
|
- 진단 도구 전 영역 |
||||
|
- 시스템/포트 로그 |
||||
|
- 포트 상태 |
||||
|
- 비밀번호 변경 |
||||
|
- 설정 저장 |
||||
|
- 포트 재시작 |
||||
|
- 장치 재시작 |
||||
|
|
||||
|
## 목업 동작 |
||||
|
- 저장, 적용, 추가, 삭제, 재시작, 다운로드 버튼은 실제 동작하지 않습니다. |
||||
|
- 모든 POST는 `mock: true` 를 포함한 더미 성공 응답을 반환합니다. |
||||
|
- 프론트엔드는 토스트와 `console.log` 로만 반응합니다. |
||||
|
|
||||
|
## 실제 기능 구현 시 다음 작업 |
||||
|
- 각 화면별 실제 설정 모델 정의 |
||||
|
- 유효성 검사 및 에러 메시지 체계 추가 |
||||
|
- 장비 통신 계층과 설정 저장 계층 연결 |
||||
|
- 인증/권한 처리 연결 |
||||
|
- 실시간 상태, 로그, 포트 상태 API 연동 |
||||
|
- 펌웨어/백업/복원 업로드 처리 |
||||
|
|
||||
|
## 현재 더미 API |
||||
|
- `GET /api/status` |
||||
|
- `GET /api/network` |
||||
|
- `POST /api/network/apply` |
||||
|
- `GET /api/system` |
||||
|
- `POST /api/system/reboot` |
||||
|
- `GET /api/logs/system` |
||||
|
- `GET /api/logs/ports` |
||||
|
- `POST /api/mock/action` |
||||
File diff suppressed because it is too large
@ -0,0 +1,49 @@ |
|||||
|
package main |
||||
|
|
||||
|
import ( |
||||
|
"log" |
||||
|
"net/http" |
||||
|
"os" |
||||
|
"path/filepath" |
||||
|
|
||||
|
"teraclone/internal/routes" |
||||
|
"teraclone/internal/service" |
||||
|
) |
||||
|
|
||||
|
func main() { |
||||
|
runServer() |
||||
|
} |
||||
|
|
||||
|
func runServer() { |
||||
|
port := os.Getenv("PORT") |
||||
|
if port == "" { |
||||
|
port = "8080" |
||||
|
} |
||||
|
|
||||
|
dbPath := os.Getenv("TERACLONE_DB_PATH") |
||||
|
if dbPath == "" { |
||||
|
dbPath = filepath.Join("data", "teraclone.db") |
||||
|
} |
||||
|
|
||||
|
appService, err := service.NewAppService(dbPath) |
||||
|
if err != nil { |
||||
|
log.Fatal(err) |
||||
|
} |
||||
|
defer func() { |
||||
|
if closeErr := appService.Close(); closeErr != nil { |
||||
|
log.Printf("sqlite close error: %v", closeErr) |
||||
|
} |
||||
|
}() |
||||
|
|
||||
|
mux := http.NewServeMux() |
||||
|
if err := routes.Register(mux, appService); err != nil { |
||||
|
log.Fatal(err) |
||||
|
} |
||||
|
|
||||
|
addr := ":" + port |
||||
|
log.Printf("server listening on http://localhost%s", addr) |
||||
|
|
||||
|
if err := http.ListenAndServe(addr, mux); err != nil { |
||||
|
log.Fatal(err) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,178 @@ |
|||||
|
# TERACLONE 진행 현황 |
||||
|
|
||||
|
- 작성일: 2026-06-29 |
||||
|
- 기준 프로젝트: `C:\Users\COMPUTER1\Desktop\projects\테라클론\code` |
||||
|
|
||||
|
## 개요 |
||||
|
|
||||
|
최근 작업은 크게 4개 축으로 진행됐다. |
||||
|
|
||||
|
1. 사용자 관리 목업을 실제 API + SQLite 기반으로 전환 |
||||
|
2. 로그인 페이지와 세션 기반 접근 제어 추가 |
||||
|
3. 포트 설정 UI 구조 조정 |
||||
|
4. 개발/QA 과정에서 드러난 UI 동작 이슈 수정 |
||||
|
|
||||
|
## 현재 구조 요약 |
||||
|
|
||||
|
- 서버 진입점: `cmd/server/main.go` |
||||
|
- 라우팅: `internal/routes/routes.go` |
||||
|
- 새 UI 핸들러: `internal/handlers/device_handler.go` |
||||
|
- 서비스 계층: `internal/service/app_service.go` |
||||
|
- SQLite 저장소: `internal/store/sqlite_store.go` |
||||
|
- 목업 페이지 데이터: `internal/mock/mock_data.go` |
||||
|
- 템플릿: `web/templates/*` |
||||
|
- 프런트 스크립트: `web/static/js/app.js` |
||||
|
- 스타일: `web/static/css/style.css` |
||||
|
|
||||
|
## 구현된 내용 |
||||
|
|
||||
|
### 1. 사용자 관리 실기능화 |
||||
|
|
||||
|
기존 `/admin/users`는 목업 데이터만 보여주던 화면이었는데, 현재는 SQLite를 사용하는 실제 CRUD 흐름으로 변경됐다. |
||||
|
|
||||
|
- 사용자 테이블 추가 |
||||
|
- 기본 사용자 시드 추가 |
||||
|
- 사용자 목록 조회 |
||||
|
- 사용자 생성 |
||||
|
- 사용자 수정 |
||||
|
- 사용자 삭제 |
||||
|
|
||||
|
관련 API: |
||||
|
|
||||
|
- `GET /api/users` |
||||
|
- `POST /api/users` |
||||
|
- `GET /api/users/{id}` |
||||
|
- `PUT /api/users/{id}` |
||||
|
- `DELETE /api/users/{id}` |
||||
|
|
||||
|
관련 파일: |
||||
|
|
||||
|
- `internal/store/sqlite_store.go` |
||||
|
- `internal/service/app_service.go` |
||||
|
- `internal/handlers/device_handler.go` |
||||
|
- `web/templates/partials/block_table.html` |
||||
|
- `web/templates/partials/block_modal.html` |
||||
|
- `web/static/js/app.js` |
||||
|
|
||||
|
### 2. 로그인 페이지 추가 |
||||
|
|
||||
|
로그인 화면과 세션 기반 보호가 추가됐다. |
||||
|
|
||||
|
- 로그인 페이지: `/login` |
||||
|
- 로그아웃: `/logout` |
||||
|
- 비로그인 시 새 UI 진입은 로그인 페이지로 리다이렉트 |
||||
|
- 인증 후에만 주요 페이지/API 접근 가능 |
||||
|
|
||||
|
기본 계정: |
||||
|
|
||||
|
- 아이디: `admin` |
||||
|
- 비밀번호: `admin` |
||||
|
|
||||
|
주의: |
||||
|
|
||||
|
- 화면에는 더 이상 기본 계정을 프리필하거나 노출하지 않음 |
||||
|
- 브라우저 비밀번호 저장 제안은 자동완성 속성으로 최대한 억제 |
||||
|
|
||||
|
관련 파일: |
||||
|
|
||||
|
- `web/templates/auth/login.html` |
||||
|
- `internal/handlers/device_handler.go` |
||||
|
- `internal/store/sqlite_store.go` |
||||
|
- `web/templates/layout.html` |
||||
|
- `web/static/css/style.css` |
||||
|
|
||||
|
### 3. 포트 설정 UI 조정 |
||||
|
|
||||
|
포트 설정은 여러 번 방향이 바뀌었고, 현재 상태는 아래와 같다. |
||||
|
|
||||
|
- 대시보드 포트 맵 UI는 유지 |
||||
|
- 포트 설정 화면 상단에는 포트 선택 UI가 존재 |
||||
|
- 좌측 메뉴의 `포트 설정 > Port 1~48` 펼침 UI는 제거 |
||||
|
- 포트 설정 화면 하단의 `적용 대상 포트` 카드 UI는 제거 |
||||
|
|
||||
|
현재 상단 포트 선택 UI 동작: |
||||
|
|
||||
|
- `개별 보기`: 해당 포트 상세 설정 화면으로 이동 |
||||
|
- `적용 대상 추가`: 상단 카드 내부 상태만 토글 |
||||
|
- 선택 상태는 `localStorage`에 저장 |
||||
|
|
||||
|
관련 파일: |
||||
|
|
||||
|
- `web/templates/partials/block_port-links.html` |
||||
|
- `web/templates/partials/block_port-selector.html` |
||||
|
- `web/templates/partials/block_form.html` |
||||
|
- `web/templates/layout.html` |
||||
|
- `web/static/js/app.js` |
||||
|
- `web/static/css/style.css` |
||||
|
- `internal/mock/mock_data.go` |
||||
|
|
||||
|
## SQLite 정보 |
||||
|
|
||||
|
기본 DB 경로: |
||||
|
|
||||
|
- `data/teraclone.db` |
||||
|
|
||||
|
환경변수로 변경 가능: |
||||
|
|
||||
|
- `TERACLONE_DB_PATH` |
||||
|
|
||||
|
예시: |
||||
|
|
||||
|
```powershell |
||||
|
$env:TERACLONE_DB_PATH="runtime\\qa.db" |
||||
|
.\scripts\dev.ps1 start |
||||
|
``` |
||||
|
|
||||
|
## 현재 확인된 동작 상태 |
||||
|
|
||||
|
### 정상 확인 |
||||
|
|
||||
|
- `go build -buildvcs=false ./...` |
||||
|
- 사용자 CRUD API 동작 |
||||
|
- 로그인 페이지 렌더링 |
||||
|
- 로그인 후 세션 쿠키 발급 |
||||
|
- 로그아웃 후 접근 차단 |
||||
|
- 포트 선택 UI 렌더링 |
||||
|
|
||||
|
### 현재 설계상 남아있는 점 |
||||
|
|
||||
|
- 그룹 관리는 아직 목업 기반 |
||||
|
- 포트 설정은 실제 저장 로직이 아니라 UI/목업 동작 중심 |
||||
|
- 일부 문자열/템플릿은 한글 인코딩 흔적이 남아 있음 |
||||
|
- 테스트 코드는 아직 없음 |
||||
|
|
||||
|
## 변경 이력 요약 |
||||
|
|
||||
|
### 사용자/인증 |
||||
|
|
||||
|
- 목업 사용자 화면을 실DB 기반으로 전환 |
||||
|
- `admin/admin` 계정 추가 |
||||
|
- 로그인/로그아웃/세션 추가 |
||||
|
|
||||
|
### UI/UX |
||||
|
|
||||
|
- 로그인 화면에서 기본 계정 노출 제거 |
||||
|
- 비밀번호 저장 팝업 억제용 속성 추가 |
||||
|
- 포트 설정 탭 동작 단순화 |
||||
|
- 포트 설정 좌측 포트 목록 제거 |
||||
|
|
||||
|
### 개발 편의 |
||||
|
|
||||
|
- SQLite 경로를 환경변수로 오버라이드 가능 |
||||
|
|
||||
|
## 다음 작업 추천 |
||||
|
|
||||
|
1. 그룹 관리도 사용자 관리처럼 실제 API/DB로 전환 |
||||
|
2. 포트 설정을 실제 저장 구조와 연결 |
||||
|
3. 포트 설정 상단 선택 UI의 저장 의미를 명확히 확정 |
||||
|
4. 로그인/사용자 관리 테스트 코드 추가 |
||||
|
5. 문자열 인코딩/하드코딩 정리 |
||||
|
|
||||
|
## Obsidian 메모용 태그 |
||||
|
|
||||
|
- #teraclone |
||||
|
- #progress |
||||
|
- #ui |
||||
|
- #sqlite |
||||
|
- #auth |
||||
|
- #user-management |
||||
@ -0,0 +1,17 @@ |
|||||
|
module teraclone |
||||
|
|
||||
|
go 1.26.4 |
||||
|
|
||||
|
require ( |
||||
|
github.com/dustin/go-humanize v1.0.1 // indirect |
||||
|
github.com/google/uuid v1.6.0 // indirect |
||||
|
github.com/gorilla/websocket v1.5.3 // indirect |
||||
|
github.com/mattn/go-isatty v0.0.20 // indirect |
||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect |
||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
||||
|
golang.org/x/sys v0.44.0 // indirect |
||||
|
modernc.org/libc v1.73.4 // indirect |
||||
|
modernc.org/mathutil v1.7.1 // indirect |
||||
|
modernc.org/memory v1.11.0 // indirect |
||||
|
modernc.org/sqlite v1.53.0 // indirect |
||||
|
) |
||||
@ -0,0 +1,23 @@ |
|||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= |
||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= |
||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= |
||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= |
||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |
||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= |
||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
||||
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= |
||||
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
||||
|
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= |
||||
|
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= |
||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= |
||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= |
||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= |
||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= |
||||
|
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= |
||||
|
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= |
||||
@ -0,0 +1,191 @@ |
|||||
|
package cli |
||||
|
|
||||
|
import ( |
||||
|
"bytes" |
||||
|
"fmt" |
||||
|
"os" |
||||
|
"os/exec" |
||||
|
"path/filepath" |
||||
|
"strings" |
||||
|
) |
||||
|
|
||||
|
const ( |
||||
|
pathMarker = "__TERACLONE_PWD__=" |
||||
|
) |
||||
|
|
||||
|
type TerminalSession struct { |
||||
|
workingDir string |
||||
|
} |
||||
|
|
||||
|
type CommandResult struct { |
||||
|
Prompt string `json:"prompt"` |
||||
|
WorkingDir string `json:"workingDir"` |
||||
|
Output string `json:"output"` |
||||
|
} |
||||
|
|
||||
|
func NewTerminalSession() (*TerminalSession, error) { |
||||
|
workingDir, err := os.Getwd() |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
return &TerminalSession{ |
||||
|
workingDir: workingDir, |
||||
|
}, nil |
||||
|
} |
||||
|
|
||||
|
func (s *TerminalSession) Prompt() string { |
||||
|
return fmt.Sprintf("PS %s>", s.workingDir) |
||||
|
} |
||||
|
|
||||
|
func (s *TerminalSession) WelcomeMessage() string { |
||||
|
return "Connected to local PowerShell bridge.\r\nType a Windows command and press Enter.\r\nUse 'clear' or 'cls' to reset the screen.\r\n" |
||||
|
} |
||||
|
|
||||
|
func (s *TerminalSession) Snapshot() CommandResult { |
||||
|
return CommandResult{ |
||||
|
Prompt: s.Prompt(), |
||||
|
WorkingDir: s.workingDir, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (s *TerminalSession) Execute(command string) CommandResult { |
||||
|
command = strings.TrimSpace(command) |
||||
|
if command == "" { |
||||
|
return s.Snapshot() |
||||
|
} |
||||
|
|
||||
|
if handled, result := s.handleChangeDirectory(command); handled { |
||||
|
return result |
||||
|
} |
||||
|
|
||||
|
output, workingDir, err := runPowerShellCommand(s.workingDir, command) |
||||
|
if workingDir != "" { |
||||
|
s.workingDir = workingDir |
||||
|
} |
||||
|
|
||||
|
result := s.Snapshot() |
||||
|
result.Output = normalizeOutput(output) |
||||
|
|
||||
|
if err != nil { |
||||
|
if result.Output != "" && !strings.HasSuffix(result.Output, "\r\n") { |
||||
|
result.Output += "\r\n" |
||||
|
} |
||||
|
result.Output += fmt.Sprintf("error: %v\r\n", err) |
||||
|
} |
||||
|
|
||||
|
return result |
||||
|
} |
||||
|
|
||||
|
func (s *TerminalSession) handleChangeDirectory(command string) (bool, CommandResult) { |
||||
|
trimmed := strings.TrimSpace(command) |
||||
|
lower := strings.ToLower(trimmed) |
||||
|
if lower != "cd" && !strings.HasPrefix(lower, "cd ") && !strings.HasPrefix(lower, "chdir ") { |
||||
|
return false, CommandResult{} |
||||
|
} |
||||
|
|
||||
|
target := "." |
||||
|
switch { |
||||
|
case lower == "cd": |
||||
|
target = os.Getenv("USERPROFILE") |
||||
|
if target == "" { |
||||
|
target = s.workingDir |
||||
|
} |
||||
|
case strings.HasPrefix(lower, "chdir "): |
||||
|
target = strings.TrimSpace(trimmed[6:]) |
||||
|
default: |
||||
|
target = strings.TrimSpace(trimmed[2:]) |
||||
|
} |
||||
|
|
||||
|
target = strings.Trim(target, "\"") |
||||
|
target = strings.TrimSpace(target) |
||||
|
if target == "" { |
||||
|
target = s.workingDir |
||||
|
} |
||||
|
|
||||
|
if !filepath.IsAbs(target) { |
||||
|
target = filepath.Join(s.workingDir, target) |
||||
|
} |
||||
|
|
||||
|
resolved, err := filepath.Abs(target) |
||||
|
if err != nil { |
||||
|
result := s.Snapshot() |
||||
|
result.Output = fmt.Sprintf("error: %v\r\n", err) |
||||
|
return true, result |
||||
|
} |
||||
|
|
||||
|
info, err := os.Stat(resolved) |
||||
|
if err != nil { |
||||
|
result := s.Snapshot() |
||||
|
result.Output = fmt.Sprintf("error: %v\r\n", err) |
||||
|
return true, result |
||||
|
} |
||||
|
|
||||
|
if !info.IsDir() { |
||||
|
result := s.Snapshot() |
||||
|
result.Output = "error: target is not a directory\r\n" |
||||
|
return true, result |
||||
|
} |
||||
|
|
||||
|
s.workingDir = resolved |
||||
|
return true, s.Snapshot() |
||||
|
} |
||||
|
|
||||
|
func runPowerShellCommand(workingDir string, command string) (string, string, error) { |
||||
|
var stdout bytes.Buffer |
||||
|
var stderr bytes.Buffer |
||||
|
|
||||
|
script := buildPowerShellScript(workingDir, command) |
||||
|
cmd := exec.Command("powershell", "-NoLogo", "-NoProfile", "-Command", script) |
||||
|
cmd.Stdout = &stdout |
||||
|
cmd.Stderr = &stderr |
||||
|
|
||||
|
err := cmd.Run() |
||||
|
combined := stdout.String() |
||||
|
if stderr.Len() > 0 { |
||||
|
combined += stderr.String() |
||||
|
} |
||||
|
|
||||
|
output, nextDir := splitOutputAndDirectory(combined) |
||||
|
return output, nextDir, err |
||||
|
} |
||||
|
|
||||
|
func buildPowerShellScript(workingDir string, command string) string { |
||||
|
escapedDir := strings.ReplaceAll(workingDir, "'", "''") |
||||
|
return strings.Join([]string{ |
||||
|
"$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()", |
||||
|
"$ErrorActionPreference = 'Continue'", |
||||
|
fmt.Sprintf("Set-Location -LiteralPath '%s'", escapedDir), |
||||
|
command, |
||||
|
fmt.Sprintf("Write-Output ('%s' + (Get-Location).Path)", pathMarker), |
||||
|
}, "; ") |
||||
|
} |
||||
|
|
||||
|
func splitOutputAndDirectory(raw string) (string, string) { |
||||
|
normalized := strings.ReplaceAll(raw, "\r\n", "\n") |
||||
|
lines := strings.Split(normalized, "\n") |
||||
|
workingDir := "" |
||||
|
filtered := make([]string, 0, len(lines)) |
||||
|
|
||||
|
for _, line := range lines { |
||||
|
if strings.HasPrefix(line, pathMarker) { |
||||
|
workingDir = strings.TrimPrefix(line, pathMarker) |
||||
|
continue |
||||
|
} |
||||
|
filtered = append(filtered, line) |
||||
|
} |
||||
|
|
||||
|
output := strings.Join(filtered, "\n") |
||||
|
output = strings.TrimRight(output, "\n") |
||||
|
if output != "" { |
||||
|
output += "\r\n" |
||||
|
} |
||||
|
|
||||
|
return output, workingDir |
||||
|
} |
||||
|
|
||||
|
func normalizeOutput(output string) string { |
||||
|
output = strings.ReplaceAll(output, "\r\n", "\n") |
||||
|
output = strings.ReplaceAll(output, "\n", "\r\n") |
||||
|
return output |
||||
|
} |
||||
@ -0,0 +1,3 @@ |
|||||
|
package config |
||||
|
|
||||
|
const MockMode = true |
||||
@ -0,0 +1,144 @@ |
|||||
|
package handler |
||||
|
|
||||
|
import ( |
||||
|
"embed" |
||||
|
"encoding/json" |
||||
|
"io/fs" |
||||
|
"net/http" |
||||
|
|
||||
|
"teraclone/internal/service" |
||||
|
|
||||
|
"github.com/gorilla/websocket" |
||||
|
) |
||||
|
|
||||
|
//go:embed web/*
|
||||
|
var webFiles embed.FS |
||||
|
|
||||
|
type AppHandler struct { |
||||
|
appService *service.AppService |
||||
|
fileServer http.Handler |
||||
|
} |
||||
|
|
||||
|
type socketRequest struct { |
||||
|
Type string `json:"type"` |
||||
|
Data string `json:"data"` |
||||
|
} |
||||
|
|
||||
|
type socketResponse struct { |
||||
|
Type string `json:"type"` |
||||
|
Data string `json:"data,omitempty"` |
||||
|
Prompt string `json:"prompt,omitempty"` |
||||
|
WorkingDir string `json:"workingDir,omitempty"` |
||||
|
} |
||||
|
|
||||
|
var upgrader = websocket.Upgrader{ |
||||
|
CheckOrigin: func(r *http.Request) bool { |
||||
|
return true |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
func NewAppHandler(appService *service.AppService) *AppHandler { |
||||
|
webRoot, err := fs.Sub(webFiles, "web") |
||||
|
|
||||
|
if err != nil { |
||||
|
panic(err) |
||||
|
} |
||||
|
|
||||
|
return &AppHandler{ |
||||
|
appService: appService, |
||||
|
fileServer: http.FileServer(http.FS(webRoot)), |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (h *AppHandler) RegisterRoutes(mux *http.ServeMux) { |
||||
|
mux.HandleFunc("/", h.handleRoot) |
||||
|
mux.HandleFunc("/health", h.handleHealth) |
||||
|
mux.HandleFunc("/ws", h.handleWebSocket) |
||||
|
mux.Handle("/assets/", http.StripPrefix("/assets/", h.fileServer)) |
||||
|
} |
||||
|
|
||||
|
func (h *AppHandler) RegisterLegacyRoutes(mux *http.ServeMux) { |
||||
|
mux.HandleFunc("/legacy", h.handleLegacyRoot) |
||||
|
mux.HandleFunc("/legacy/", h.handleLegacyRoot) |
||||
|
mux.HandleFunc("/legacy/ws", h.handleLegacyWebSocket) |
||||
|
mux.Handle("/legacy/assets/", http.StripPrefix("/legacy/assets/", h.fileServer)) |
||||
|
} |
||||
|
|
||||
|
func (h *AppHandler) HandleHealth(w http.ResponseWriter, r *http.Request) { |
||||
|
h.handleHealth(w, r) |
||||
|
} |
||||
|
|
||||
|
func (h *AppHandler) handleRoot(w http.ResponseWriter, r *http.Request) { |
||||
|
if r.URL.Path != "/" { |
||||
|
http.NotFound(w, r) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
http.ServeFileFS(w, r, webFiles, "web/index.html") |
||||
|
} |
||||
|
|
||||
|
func (h *AppHandler) handleHealth(w http.ResponseWriter, r *http.Request) { |
||||
|
w.Header().Set("Content-Type", "application/json") |
||||
|
|
||||
|
response := map[string]string{ |
||||
|
"status": h.appService.HealthStatus(), |
||||
|
} |
||||
|
|
||||
|
_ = json.NewEncoder(w).Encode(response) |
||||
|
} |
||||
|
|
||||
|
func (h *AppHandler) handleLegacyRoot(w http.ResponseWriter, r *http.Request) { |
||||
|
if r.URL.Path != "/legacy" && r.URL.Path != "/legacy/" { |
||||
|
http.NotFound(w, r) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
http.ServeFileFS(w, r, webFiles, "web/index.html") |
||||
|
} |
||||
|
|
||||
|
func (h *AppHandler) handleLegacyWebSocket(w http.ResponseWriter, r *http.Request) { |
||||
|
h.handleWebSocket(w, r) |
||||
|
} |
||||
|
|
||||
|
func (h *AppHandler) handleWebSocket(w http.ResponseWriter, r *http.Request) { |
||||
|
conn, err := upgrader.Upgrade(w, r, nil) |
||||
|
if err != nil { |
||||
|
return |
||||
|
} |
||||
|
defer conn.Close() |
||||
|
|
||||
|
session, err := h.appService.NewTerminalSession() |
||||
|
if err != nil { |
||||
|
_ = conn.WriteJSON(socketResponse{ |
||||
|
Type: "error", |
||||
|
Data: err.Error(), |
||||
|
}) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
_ = conn.WriteJSON(socketResponse{ |
||||
|
Type: "welcome", |
||||
|
Data: session.WelcomeMessage(), |
||||
|
Prompt: session.Prompt(), |
||||
|
WorkingDir: session.Snapshot().WorkingDir, |
||||
|
}) |
||||
|
|
||||
|
for { |
||||
|
var request socketRequest |
||||
|
if err := conn.ReadJSON(&request); err != nil { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
if request.Type != "input" { |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
result := session.Execute(request.Data) |
||||
|
_ = conn.WriteJSON(socketResponse{ |
||||
|
Type: "output", |
||||
|
Data: result.Output, |
||||
|
Prompt: result.Prompt, |
||||
|
WorkingDir: result.WorkingDir, |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,118 @@ |
|||||
|
<!DOCTYPE html> |
||||
|
<html lang="ko"> |
||||
|
<head> |
||||
|
<meta charset="UTF-8"> |
||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||
|
<title>TeraClone Console Server</title> |
||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css"> |
||||
|
<link rel="stylesheet" href="/legacy/assets/styles.css"> |
||||
|
</head> |
||||
|
<body> |
||||
|
<main class="layout"> |
||||
|
<aside class="sidebar"> |
||||
|
<div class="brand"> |
||||
|
<div class="brand-mark"></div> |
||||
|
<div class="brand-copy"> |
||||
|
<div class="brand-kicker" data-i18n="brand.kicker">Console Server</div> |
||||
|
<div class="brand-title">TeraClone CS-48</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="nav-title" data-i18n="nav.title">Control</div> |
||||
|
<nav class="nav"> |
||||
|
<div class="nav-group has-flyout"> |
||||
|
<button class="nav-item nav-item-toggle" type="button" data-group-toggle> |
||||
|
<span> |
||||
|
<strong data-i18n="nav.overview.title">Overview</strong> |
||||
|
<small data-i18n="nav.overview.desc">Rack summary and health</small> |
||||
|
</span> |
||||
|
<div class="nav-pill nav-arrow">+</div> |
||||
|
</button> |
||||
|
<div class="nav-flyout"> |
||||
|
<button class="flyout-item nav-link active" type="button" data-route="overview" data-i18n="flyout.overview.dashboard">Overview Dashboard</button> |
||||
|
<button class="flyout-item nav-link" type="button" data-route="example" data-i18n="flyout.example">Example Page</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="nav-group has-flyout"> |
||||
|
<button class="nav-item nav-item-toggle" type="button" data-group-toggle> |
||||
|
<span> |
||||
|
<strong data-i18n="nav.ports.title">Ports</strong> |
||||
|
<small data-i18n="nav.ports.desc">48 serial channels</small> |
||||
|
</span> |
||||
|
<div class="nav-pill nav-arrow" data-i18n="pill.live">Live</div> |
||||
|
</button> |
||||
|
<div class="nav-flyout"> |
||||
|
<button class="flyout-item nav-link" type="button" data-route="ports" data-i18n="flyout.ports.matrix">Port Matrix</button> |
||||
|
<button class="flyout-item nav-link" type="button" data-route="cabling" data-i18n="flyout.ports.cabling">Cable Map</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<button class="nav-item nav-link" type="button" data-route="terminal"> |
||||
|
<span> |
||||
|
<strong data-i18n="nav.terminal.title">Terminal</strong> |
||||
|
<small data-i18n="nav.terminal.desc">Interactive shell</small> |
||||
|
</span> |
||||
|
<div class="nav-pill">PS</div> |
||||
|
</button> |
||||
|
|
||||
|
<div class="nav-group has-flyout"> |
||||
|
<button class="nav-item nav-item-toggle" type="button" data-group-toggle> |
||||
|
<span> |
||||
|
<strong data-i18n="nav.sessions.title">Sessions</strong> |
||||
|
<small data-i18n="nav.sessions.desc">User and device links</small> |
||||
|
</span> |
||||
|
<div class="nav-pill">12</div> |
||||
|
</button> |
||||
|
<div class="nav-flyout"> |
||||
|
<button class="flyout-item nav-link" type="button" data-route="sessions" data-i18n="flyout.sessions.active">Active Sessions</button> |
||||
|
<button class="flyout-item nav-link" type="button" data-route="example" data-i18n="flyout.example">Example Page</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="nav-group has-flyout"> |
||||
|
<button class="nav-item nav-item-toggle" type="button" data-group-toggle> |
||||
|
<span> |
||||
|
<strong data-i18n="nav.system.title">System</strong> |
||||
|
<small data-i18n="nav.system.desc">Network and security</small> |
||||
|
</span> |
||||
|
<div class="nav-pill" data-i18n="pill.safe">Safe</div> |
||||
|
</button> |
||||
|
<div class="nav-flyout"> |
||||
|
<button class="flyout-item nav-link" type="button" data-route="system" data-i18n="flyout.system.settings">System Settings</button> |
||||
|
<button class="flyout-item nav-link" type="button" data-route="example" data-i18n="flyout.example">Example Page</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</nav> |
||||
|
</aside> |
||||
|
|
||||
|
<section class="main"> |
||||
|
<header class="topbar"> |
||||
|
<button id="sidebarToggle" class="sidebar-toggle" type="button" aria-label="Open menu">☰</button> |
||||
|
<div class="topbar-copy"> |
||||
|
<div class="page-eyebrow" data-i18n="topbar.eyebrow">Integrated Console Server</div> |
||||
|
<div id="pageTitle" class="page-title">Overview</div> |
||||
|
</div> |
||||
|
<div class="topbar-meta"> |
||||
|
<div class="lang-switch" role="group" aria-label="Language selector"> |
||||
|
<button id="langKo" class="lang-button active" type="button">KO</button> |
||||
|
<button id="langEn" class="lang-button" type="button">EN</button> |
||||
|
</div> |
||||
|
<div class="chip" data-i18n="chip.ports">48 Serial Ports</div> |
||||
|
<div class="chip" data-i18n="chip.dualLan">Dual LAN</div> |
||||
|
<div class="chip" data-i18n="chip.remoteAccess">Remote Access</div> |
||||
|
<div id="connectionState" class="chip" data-i18n="socket.connecting">Connecting...</div> |
||||
|
</div> |
||||
|
</header> |
||||
|
|
||||
|
<section id="pageOutlet" class="content page-outlet"></section> |
||||
|
</section> |
||||
|
</main> |
||||
|
|
||||
|
<div id="sidebarBackdrop" class="sidebar-backdrop" hidden></div> |
||||
|
|
||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script> |
||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script> |
||||
|
<script type="module" src="/legacy/assets/js/app.js"></script> |
||||
|
</body> |
||||
|
</html> |
||||
@ -0,0 +1,158 @@ |
|||||
|
import { detectLanguage, translations } from "./i18n.js"; |
||||
|
import { getRouteConfig, getRouteFromHash, loadPartial, navigateTo } from "./router.js"; |
||||
|
import { TerminalConsole } from "./terminal-console.js"; |
||||
|
|
||||
|
const pageTitleEl = document.getElementById("pageTitle"); |
||||
|
const pageOutletEl = document.getElementById("pageOutlet"); |
||||
|
const navLinks = Array.from(document.querySelectorAll(".nav-link")); |
||||
|
const navGroups = Array.from(document.querySelectorAll(".nav-group.has-flyout")); |
||||
|
const navGroupToggles = Array.from(document.querySelectorAll("[data-group-toggle]")); |
||||
|
const sidebarToggleEl = document.getElementById("sidebarToggle"); |
||||
|
const sidebarBackdropEl = document.getElementById("sidebarBackdrop"); |
||||
|
const langKoEl = document.getElementById("langKo"); |
||||
|
const langEnEl = document.getElementById("langEn"); |
||||
|
|
||||
|
let currentLanguage = detectLanguage(); |
||||
|
let activeRoute = getRouteFromHash(); |
||||
|
let activeController = null; |
||||
|
|
||||
|
function t(key) { |
||||
|
return translations[currentLanguage]?.[key] ?? translations.en[key] ?? key; |
||||
|
} |
||||
|
|
||||
|
function applyTranslations(root = document) { |
||||
|
document.documentElement.lang = currentLanguage; |
||||
|
document.title = currentLanguage === "ko" ? "TeraClone 콘솔 서버" : "TeraClone Console Server"; |
||||
|
|
||||
|
root.querySelectorAll("[data-i18n]").forEach((element) => { |
||||
|
element.textContent = t(element.dataset.i18n); |
||||
|
}); |
||||
|
|
||||
|
updatePageTitle(); |
||||
|
updateLanguageButtons(); |
||||
|
activeController?.updateTranslations?.(t); |
||||
|
} |
||||
|
|
||||
|
function updateLanguageButtons() { |
||||
|
langKoEl?.classList.toggle("active", currentLanguage === "ko"); |
||||
|
langEnEl?.classList.toggle("active", currentLanguage === "en"); |
||||
|
} |
||||
|
|
||||
|
function updatePageTitle() { |
||||
|
pageTitleEl.textContent = t(getRouteConfig(activeRoute).titleKey); |
||||
|
} |
||||
|
|
||||
|
function setSidebarOpen(open) { |
||||
|
document.body.classList.toggle("sidebar-open", open); |
||||
|
if (sidebarBackdropEl) { |
||||
|
sidebarBackdropEl.hidden = !open; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function setActiveNav(routeName) { |
||||
|
navLinks.forEach((item) => { |
||||
|
item.classList.toggle("active", item.dataset.route === routeName); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function bindNav() { |
||||
|
navLinks.forEach((item) => { |
||||
|
item.addEventListener("click", () => { |
||||
|
navigateTo(item.dataset.route); |
||||
|
navGroups.forEach((group) => group.classList.remove("open")); |
||||
|
const parentGroup = item.closest(".nav-group"); |
||||
|
if (parentGroup && window.innerWidth > 920) { |
||||
|
parentGroup.classList.add("suppress-hover"); |
||||
|
} |
||||
|
item.blur(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
navGroups.forEach((group) => { |
||||
|
group.addEventListener("mouseleave", () => { |
||||
|
group.classList.remove("suppress-hover"); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
navGroupToggles.forEach((toggle) => { |
||||
|
toggle.addEventListener("click", () => { |
||||
|
if (window.innerWidth > 920) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const group = toggle.closest(".nav-group"); |
||||
|
if (!group) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const willOpen = !group.classList.contains("open"); |
||||
|
navGroups.forEach((item) => item.classList.remove("open")); |
||||
|
group.classList.toggle("open", willOpen); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function bindChrome() { |
||||
|
sidebarToggleEl?.addEventListener("click", () => { |
||||
|
setSidebarOpen(!document.body.classList.contains("sidebar-open")); |
||||
|
}); |
||||
|
|
||||
|
sidebarBackdropEl?.addEventListener("click", () => { |
||||
|
setSidebarOpen(false); |
||||
|
}); |
||||
|
|
||||
|
langKoEl?.addEventListener("click", () => { |
||||
|
currentLanguage = "ko"; |
||||
|
localStorage.setItem("teraclone-language", currentLanguage); |
||||
|
applyTranslations(document); |
||||
|
}); |
||||
|
|
||||
|
langEnEl?.addEventListener("click", () => { |
||||
|
currentLanguage = "en"; |
||||
|
localStorage.setItem("teraclone-language", currentLanguage); |
||||
|
applyTranslations(document); |
||||
|
}); |
||||
|
|
||||
|
window.addEventListener("resize", () => { |
||||
|
if (window.innerWidth > 920) { |
||||
|
setSidebarOpen(false); |
||||
|
navGroups.forEach((group) => group.classList.remove("open")); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
async function renderRoute(routeName) { |
||||
|
activeController?.unmount?.(); |
||||
|
activeController = null; |
||||
|
|
||||
|
const partial = await loadPartial(routeName); |
||||
|
pageOutletEl.innerHTML = partial; |
||||
|
activeRoute = routeName; |
||||
|
setActiveNav(routeName); |
||||
|
applyTranslations(pageOutletEl); |
||||
|
|
||||
|
if (routeName === "terminal") { |
||||
|
activeController = new TerminalConsole(t); |
||||
|
activeController.mount(); |
||||
|
} |
||||
|
|
||||
|
if (window.innerWidth <= 920) { |
||||
|
setSidebarOpen(false); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function syncRoute() { |
||||
|
const routeName = getRouteFromHash(); |
||||
|
await renderRoute(routeName); |
||||
|
} |
||||
|
|
||||
|
bindNav(); |
||||
|
bindChrome(); |
||||
|
applyTranslations(document); |
||||
|
window.addEventListener("hashchange", syncRoute); |
||||
|
|
||||
|
if (!window.location.hash) { |
||||
|
navigateTo("overview"); |
||||
|
} else { |
||||
|
syncRoute(); |
||||
|
} |
||||
@ -0,0 +1,167 @@ |
|||||
|
export const supportedLanguages = ["ko", "en"]; |
||||
|
|
||||
|
export const translations = { |
||||
|
ko: { |
||||
|
"brand.kicker": "콘솔 서버", |
||||
|
"nav.title": "제어 메뉴", |
||||
|
"nav.overview.title": "개요", |
||||
|
"nav.overview.desc": "랙 요약 및 상태", |
||||
|
"nav.ports.title": "포트", |
||||
|
"nav.ports.desc": "48개 시리얼 채널", |
||||
|
"nav.terminal.title": "터미널", |
||||
|
"nav.terminal.desc": "대화형 셸", |
||||
|
"nav.sessions.title": "세션", |
||||
|
"nav.sessions.desc": "사용자 및 장비 연결", |
||||
|
"nav.system.title": "시스템", |
||||
|
"nav.system.desc": "네트워크 및 보안", |
||||
|
"flyout.overview.dashboard": "개요 대시보드", |
||||
|
"flyout.example": "예시 페이지", |
||||
|
"flyout.ports.matrix": "포트 매트릭스", |
||||
|
"flyout.ports.cabling": "케이블 맵", |
||||
|
"flyout.sessions.active": "활성 세션", |
||||
|
"flyout.system.settings": "시스템 설정", |
||||
|
"pill.live": "실시간", |
||||
|
"pill.safe": "보안", |
||||
|
"topbar.eyebrow": "통합 콘솔 서버", |
||||
|
"chip.ports": "48 시리얼 포트", |
||||
|
"chip.dualLan": "듀얼 LAN", |
||||
|
"chip.remoteAccess": "원격 접속", |
||||
|
"terminal.title": "브라우저 터미널", |
||||
|
"terminal.body1": "진단, 포트 점검, 로컬 관리자 작업을 위해 소켓 세션을 사용합니다. 화면을 초기화하려면", |
||||
|
"terminal.body2": "또는", |
||||
|
"terminal.body3": "를 입력하세요.", |
||||
|
"terminal.consoleTitle": "유지보수 콘솔", |
||||
|
"terminal.consoleNote": "Windows PowerShell 세션", |
||||
|
"terminal.footer": "로컬 셸 스트림", |
||||
|
"cabling.title": "연결 케이블 목록", |
||||
|
"cabling.standard": "콘솔 규격", |
||||
|
"cabling.types": "케이블 종류", |
||||
|
"cabling.typesValue": "스트레이트 / 크로스오버", |
||||
|
"cabling.labeling": "라벨링", |
||||
|
"cabling.labelingValue": "랙 + 포트 ID", |
||||
|
"cabling.patchRule": "패치 규칙", |
||||
|
"cabling.patchRuleValue": "그룹별 색상 구분", |
||||
|
"cabling.notesTitle": "포트 배선 노트", |
||||
|
"cabling.blueTitle": "파란 번들", |
||||
|
"cabling.blueBody": "관리자 PC에서 이더넷 스위치 업링크로 연결", |
||||
|
"cabling.orangeTitle": "주황 번들", |
||||
|
"cabling.orangeBody": "CS-48에서 현장 장비와 랙 자산으로 연결", |
||||
|
"cabling.grayTitle": "회색 번들", |
||||
|
"cabling.grayBody": "시스로그 및 유지보수 백업 경로", |
||||
|
"events.title": "최근 이벤트", |
||||
|
"events.time": "시간", |
||||
|
"events.source": "소스", |
||||
|
"events.event": "이벤트", |
||||
|
"events.state": "상태", |
||||
|
"events.row1": "운영자에 의해 세션이 열림", |
||||
|
"events.row2": "독점 잠금이 활성화됨", |
||||
|
"events.row3": "시스로그 전달 정상", |
||||
|
"events.row4": "설정 스냅샷 저장 완료", |
||||
|
"events.info": "정보", |
||||
|
"events.warn": "경고", |
||||
|
"events.ok": "정상", |
||||
|
"example.title": "예시 페이지", |
||||
|
"example.body": "이 서브메뉴는 앞으로 들어올 화면 자리입니다. 실제 기능 흐름이 정해지면 이 예시 페이지를 교체하면 됩니다.", |
||||
|
"socket.connecting": "연결 중...", |
||||
|
"socket.connected": "소켓 연결됨", |
||||
|
"socket.disconnected": "소켓 연결 끊김", |
||||
|
"socket.error": "소켓 오류", |
||||
|
"socket.waiting": "소켓 대기 중...", |
||||
|
"socket.closed": "연결이 종료되었습니다", |
||||
|
"socket.errorPrefix": "오류", |
||||
|
"page.overview": "개요", |
||||
|
"page.ports": "포트", |
||||
|
"page.terminal": "터미널", |
||||
|
"page.sessions": "세션", |
||||
|
"page.cabling": "배선", |
||||
|
"page.events": "이벤트", |
||||
|
"page.system": "시스템", |
||||
|
"page.example": "예시 페이지", |
||||
|
}, |
||||
|
en: { |
||||
|
"brand.kicker": "Console Server", |
||||
|
"nav.title": "Control", |
||||
|
"nav.overview.title": "Overview", |
||||
|
"nav.overview.desc": "Rack summary and health", |
||||
|
"nav.ports.title": "Ports", |
||||
|
"nav.ports.desc": "48 serial channels", |
||||
|
"nav.terminal.title": "Terminal", |
||||
|
"nav.terminal.desc": "Interactive shell", |
||||
|
"nav.sessions.title": "Sessions", |
||||
|
"nav.sessions.desc": "User and device links", |
||||
|
"nav.system.title": "System", |
||||
|
"nav.system.desc": "Network and security", |
||||
|
"flyout.overview.dashboard": "Overview Dashboard", |
||||
|
"flyout.example": "Example Page", |
||||
|
"flyout.ports.matrix": "Port Matrix", |
||||
|
"flyout.ports.cabling": "Cable Map", |
||||
|
"flyout.sessions.active": "Active Sessions", |
||||
|
"flyout.system.settings": "System Settings", |
||||
|
"pill.live": "Live", |
||||
|
"pill.safe": "Safe", |
||||
|
"topbar.eyebrow": "Integrated Console Server", |
||||
|
"chip.ports": "48 Serial Ports", |
||||
|
"chip.dualLan": "Dual LAN", |
||||
|
"chip.remoteAccess": "Remote Access", |
||||
|
"terminal.title": "Browser Terminal", |
||||
|
"terminal.body1": "Use the socket session for diagnostics, port inspection, and local admin tasks. Type", |
||||
|
"terminal.body2": "or", |
||||
|
"terminal.body3": "to reset the view.", |
||||
|
"terminal.consoleTitle": "Maintenance Console", |
||||
|
"terminal.consoleNote": "Windows PowerShell Session", |
||||
|
"terminal.footer": "Local shell stream", |
||||
|
"cabling.title": "Connection Cable List", |
||||
|
"cabling.standard": "Console standard", |
||||
|
"cabling.types": "Cable types", |
||||
|
"cabling.typesValue": "Straight / Cross-over", |
||||
|
"cabling.labeling": "Labeling", |
||||
|
"cabling.labelingValue": "Rack + Port ID", |
||||
|
"cabling.patchRule": "Patch rule", |
||||
|
"cabling.patchRuleValue": "Color by group", |
||||
|
"cabling.notesTitle": "Port Wiring Notes", |
||||
|
"cabling.blueTitle": "Blue bundle", |
||||
|
"cabling.blueBody": "Manager PC to Ethernet switch uplink", |
||||
|
"cabling.orangeTitle": "Orange bundle", |
||||
|
"cabling.orangeBody": "CS-48 to field devices and rack assets", |
||||
|
"cabling.grayTitle": "Gray bundle", |
||||
|
"cabling.grayBody": "Syslog and maintenance backup path", |
||||
|
"events.title": "Recent Events", |
||||
|
"events.time": "Time", |
||||
|
"events.source": "Source", |
||||
|
"events.event": "Event", |
||||
|
"events.state": "State", |
||||
|
"events.row1": "Session opened by operator", |
||||
|
"events.row2": "Exclusive lock enabled", |
||||
|
"events.row3": "Syslog forwarding healthy", |
||||
|
"events.row4": "Config snapshot saved", |
||||
|
"events.info": "Info", |
||||
|
"events.warn": "Warn", |
||||
|
"events.ok": "OK", |
||||
|
"example.title": "Example Page", |
||||
|
"example.body": "This submenu is reserved for a future screen. Replace this placeholder with the real feature page when the flow is defined.", |
||||
|
"socket.connecting": "Connecting...", |
||||
|
"socket.connected": "Socket connected", |
||||
|
"socket.disconnected": "Socket disconnected", |
||||
|
"socket.error": "Socket error", |
||||
|
"socket.waiting": "Waiting for socket...", |
||||
|
"socket.closed": "connection closed", |
||||
|
"socket.errorPrefix": "error", |
||||
|
"page.overview": "Overview", |
||||
|
"page.ports": "Ports", |
||||
|
"page.terminal": "Terminal", |
||||
|
"page.sessions": "Sessions", |
||||
|
"page.cabling": "Cabling", |
||||
|
"page.events": "Events", |
||||
|
"page.system": "System", |
||||
|
"page.example": "Example Page", |
||||
|
}, |
||||
|
}; |
||||
|
|
||||
|
export function detectLanguage() { |
||||
|
const stored = localStorage.getItem("teraclone-language"); |
||||
|
if (supportedLanguages.includes(stored)) { |
||||
|
return stored; |
||||
|
} |
||||
|
|
||||
|
return navigator.language?.toLowerCase().startsWith("ko") ? "ko" : "en"; |
||||
|
} |
||||
@ -0,0 +1,40 @@ |
|||||
|
const routeConfigs = { |
||||
|
overview: { titleKey: "page.overview", partial: "/legacy/assets/partials/overview.html" }, |
||||
|
ports: { titleKey: "page.ports", partial: "/legacy/assets/partials/ports.html" }, |
||||
|
terminal: { titleKey: "page.terminal", partial: "/legacy/assets/partials/terminal.html" }, |
||||
|
sessions: { titleKey: "page.sessions", partial: "/legacy/assets/partials/sessions.html" }, |
||||
|
cabling: { titleKey: "page.cabling", partial: "/legacy/assets/partials/cabling.html" }, |
||||
|
events: { titleKey: "page.events", partial: "/legacy/assets/partials/events.html" }, |
||||
|
system: { titleKey: "page.system", partial: "/legacy/assets/partials/system.html" }, |
||||
|
example: { titleKey: "page.example", partial: "/legacy/assets/partials/example.html" }, |
||||
|
}; |
||||
|
|
||||
|
export function getRouteConfig(routeName) { |
||||
|
return routeConfigs[routeName] || routeConfigs.overview; |
||||
|
} |
||||
|
|
||||
|
export function getRouteFromHash() { |
||||
|
const hash = window.location.hash.replace(/^#\/?/, ""); |
||||
|
return routeConfigs[hash] ? hash : "overview"; |
||||
|
} |
||||
|
|
||||
|
export function navigateTo(routeName) { |
||||
|
const nextRoute = routeConfigs[routeName] ? routeName : "overview"; |
||||
|
if (getRouteFromHash() === nextRoute) { |
||||
|
window.dispatchEvent(new HashChangeEvent("hashchange")); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
window.location.hash = nextRoute; |
||||
|
} |
||||
|
|
||||
|
export async function loadPartial(routeName) { |
||||
|
const config = getRouteConfig(routeName); |
||||
|
const response = await fetch(config.partial, { cache: "no-cache" }); |
||||
|
|
||||
|
if (!response.ok) { |
||||
|
throw new Error(`failed to load partial: ${config.partial}`); |
||||
|
} |
||||
|
|
||||
|
return response.text(); |
||||
|
} |
||||
@ -0,0 +1,208 @@ |
|||||
|
export class TerminalConsole { |
||||
|
constructor(t) { |
||||
|
this.t = t; |
||||
|
this.term = null; |
||||
|
this.fitAddon = null; |
||||
|
this.socket = null; |
||||
|
this.currentPrompt = "PS>"; |
||||
|
this.commandBuffer = ""; |
||||
|
this.footerStateEl = null; |
||||
|
this.connectionStateEl = null; |
||||
|
this.resizeHandler = this.handleResize.bind(this); |
||||
|
} |
||||
|
|
||||
|
mount() { |
||||
|
const terminalRoot = document.getElementById("terminal"); |
||||
|
this.footerStateEl = document.getElementById("footerState"); |
||||
|
this.connectionStateEl = document.getElementById("connectionState"); |
||||
|
|
||||
|
if (!terminalRoot) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.term = new Terminal({ |
||||
|
cursorBlink: true, |
||||
|
fontFamily: 'Consolas, "IBM Plex Mono", monospace', |
||||
|
fontSize: 15, |
||||
|
lineHeight: 1.25, |
||||
|
letterSpacing: 0.2, |
||||
|
theme: { |
||||
|
background: "#081426", |
||||
|
foreground: "#eef5ff", |
||||
|
cursor: "#7af1d0", |
||||
|
cursorAccent: "#081426", |
||||
|
selectionBackground: "rgba(122, 241, 208, 0.18)", |
||||
|
black: "#081426", |
||||
|
red: "#ff8d8d", |
||||
|
green: "#7cf6d1", |
||||
|
yellow: "#ffd479", |
||||
|
blue: "#8dbaff", |
||||
|
magenta: "#d6a4ff", |
||||
|
cyan: "#73d5ff", |
||||
|
white: "#eef5ff", |
||||
|
brightBlack: "#5d718f", |
||||
|
brightRed: "#ffb0b0", |
||||
|
brightGreen: "#a2ffe3", |
||||
|
brightYellow: "#ffe4a6", |
||||
|
brightBlue: "#b3d1ff", |
||||
|
brightMagenta: "#e6c1ff", |
||||
|
brightCyan: "#9be6ff", |
||||
|
brightWhite: "#ffffff", |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
this.fitAddon = new FitAddon.FitAddon(); |
||||
|
this.term.loadAddon(this.fitAddon); |
||||
|
this.term.open(terminalRoot); |
||||
|
this.fitAddon.fit(); |
||||
|
this.bindTerminalInput(); |
||||
|
this.setConnectionState("socket.connecting", "status-live"); |
||||
|
this.connect(); |
||||
|
window.addEventListener("resize", this.resizeHandler); |
||||
|
} |
||||
|
|
||||
|
unmount() { |
||||
|
window.removeEventListener("resize", this.resizeHandler); |
||||
|
|
||||
|
if (this.socket) { |
||||
|
this.socket.close(); |
||||
|
this.socket = null; |
||||
|
} |
||||
|
|
||||
|
if (this.term) { |
||||
|
this.term.dispose(); |
||||
|
this.term = null; |
||||
|
} |
||||
|
|
||||
|
this.fitAddon = null; |
||||
|
this.commandBuffer = ""; |
||||
|
this.currentPrompt = "PS>"; |
||||
|
this.footerStateEl = null; |
||||
|
this.connectionStateEl = null; |
||||
|
} |
||||
|
|
||||
|
updateTranslations(t) { |
||||
|
this.t = t; |
||||
|
if (this.socket?.readyState === WebSocket.OPEN) { |
||||
|
this.setConnectionState("socket.connected", "status-ok"); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.setConnectionState("socket.connecting", "status-live"); |
||||
|
} |
||||
|
|
||||
|
handleResize() { |
||||
|
this.fitAddon?.fit(); |
||||
|
} |
||||
|
|
||||
|
bindTerminalInput() { |
||||
|
this.term.onData((data) => { |
||||
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (data === "\r") { |
||||
|
const command = this.commandBuffer.trim(); |
||||
|
this.term.write("\r\n"); |
||||
|
|
||||
|
if (command === "clear" || command === "cls") { |
||||
|
this.commandBuffer = ""; |
||||
|
this.term.clear(); |
||||
|
this.writePrompt(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.socket.send(JSON.stringify({ |
||||
|
type: "input", |
||||
|
data: this.commandBuffer, |
||||
|
})); |
||||
|
this.commandBuffer = ""; |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (data === "\u007f") { |
||||
|
if (this.commandBuffer.length > 0) { |
||||
|
this.commandBuffer = this.commandBuffer.slice(0, -1); |
||||
|
this.term.write("\b \b"); |
||||
|
} |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (data === "\u0003") { |
||||
|
this.commandBuffer = ""; |
||||
|
this.term.write("^C\r\n"); |
||||
|
this.writePrompt(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (data < " ") { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
this.commandBuffer += data; |
||||
|
this.term.write(data); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
writePrompt() { |
||||
|
this.term.write(this.currentPrompt + " "); |
||||
|
} |
||||
|
|
||||
|
setConnectionState(labelKey, className) { |
||||
|
const label = this.t(labelKey); |
||||
|
if (this.connectionStateEl) { |
||||
|
this.connectionStateEl.textContent = label; |
||||
|
} |
||||
|
if (this.footerStateEl) { |
||||
|
this.footerStateEl.textContent = label; |
||||
|
this.footerStateEl.className = className; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
connect() { |
||||
|
const protocol = window.location.protocol === "https:" ? "wss" : "ws"; |
||||
|
this.socket = new WebSocket(protocol + "://" + window.location.host + "/legacy/ws"); |
||||
|
|
||||
|
this.socket.addEventListener("open", () => { |
||||
|
this.setConnectionState("socket.connected", "status-ok"); |
||||
|
}); |
||||
|
|
||||
|
this.socket.addEventListener("message", (event) => { |
||||
|
const message = JSON.parse(event.data); |
||||
|
|
||||
|
if (message.prompt) { |
||||
|
this.currentPrompt = message.prompt; |
||||
|
} |
||||
|
|
||||
|
if (message.type === "welcome") { |
||||
|
this.term.write(message.data || ""); |
||||
|
this.writePrompt(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (message.type === "output") { |
||||
|
if (message.data) { |
||||
|
this.term.write(message.data); |
||||
|
} |
||||
|
this.writePrompt(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (message.type === "error") { |
||||
|
this.term.writeln(""); |
||||
|
this.term.writeln(`${this.t("socket.errorPrefix")}: ${message.data || "unknown websocket error"}`); |
||||
|
this.writePrompt(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
this.socket.addEventListener("close", () => { |
||||
|
this.setConnectionState("socket.disconnected", "status-bad"); |
||||
|
this.term?.writeln(""); |
||||
|
this.term?.writeln(this.t("socket.closed")); |
||||
|
}); |
||||
|
|
||||
|
this.socket.addEventListener("error", () => { |
||||
|
this.setConnectionState("socket.error", "status-bad"); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,30 @@ |
|||||
|
<div class="page active"> |
||||
|
<div class="split"> |
||||
|
<div class="card"> |
||||
|
<h2 data-i18n="cabling.title">Connection Cable List</h2> |
||||
|
<div class="info-list"> |
||||
|
<div class="info-row"><div class="info-key" data-i18n="cabling.standard">Console standard</div><div class="info-value">RJ45 Serial</div></div> |
||||
|
<div class="info-row"><div class="info-key" data-i18n="cabling.types">Cable types</div><div class="info-value" data-i18n="cabling.typesValue">Straight / Cross-over</div></div> |
||||
|
<div class="info-row"><div class="info-key" data-i18n="cabling.labeling">Labeling</div><div class="info-value" data-i18n="cabling.labelingValue">Rack + Port ID</div></div> |
||||
|
<div class="info-row"><div class="info-key" data-i18n="cabling.patchRule">Patch rule</div><div class="info-value" data-i18n="cabling.patchRuleValue">Color by group</div></div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="card"> |
||||
|
<h2 data-i18n="cabling.notesTitle">Port Wiring Notes</h2> |
||||
|
<div class="channel-groups"> |
||||
|
<div class="group-card"> |
||||
|
<div class="group-title" data-i18n="cabling.blueTitle">Blue bundle</div> |
||||
|
<div class="group-meta" data-i18n="cabling.blueBody">Manager PC to Ethernet switch uplink</div> |
||||
|
</div> |
||||
|
<div class="group-card"> |
||||
|
<div class="group-title" data-i18n="cabling.orangeTitle">Orange bundle</div> |
||||
|
<div class="group-meta" data-i18n="cabling.orangeBody">CS-48 to field devices and rack assets</div> |
||||
|
</div> |
||||
|
<div class="group-card"> |
||||
|
<div class="group-title" data-i18n="cabling.grayTitle">Gray bundle</div> |
||||
|
<div class="group-meta" data-i18n="cabling.grayBody">Syslog and maintenance backup path</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
@ -0,0 +1,21 @@ |
|||||
|
<div class="page active"> |
||||
|
<div class="card"> |
||||
|
<h2 data-i18n="events.title">Recent Events</h2> |
||||
|
<table class="device-table"> |
||||
|
<thead> |
||||
|
<tr> |
||||
|
<th data-i18n="events.time">Time</th> |
||||
|
<th data-i18n="events.source">Source</th> |
||||
|
<th data-i18n="events.event">Event</th> |
||||
|
<th data-i18n="events.state">State</th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody> |
||||
|
<tr><td>14:21</td><td>Port 05</td><td data-i18n="events.row1">Session opened by operator</td><td data-i18n="events.info">Info</td></tr> |
||||
|
<tr><td>14:18</td><td>Port 23</td><td data-i18n="events.row2">Exclusive lock enabled</td><td data-i18n="events.warn">Warn</td></tr> |
||||
|
<tr><td>14:07</td><td>LAN 1</td><td data-i18n="events.row3">Syslog forwarding healthy</td><td data-i18n="events.ok">OK</td></tr> |
||||
|
<tr><td>13:52</td><td>System</td><td data-i18n="events.row4">Config snapshot saved</td><td data-i18n="events.ok">OK</td></tr> |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</div> |
||||
|
</div> |
||||
@ -0,0 +1,6 @@ |
|||||
|
<div class="page active"> |
||||
|
<div class="card"> |
||||
|
<h2 data-i18n="example.title">Example Page</h2> |
||||
|
<p data-i18n="example.body">This submenu is reserved for a future screen. Replace this placeholder with the real feature page when the flow is defined.</p> |
||||
|
</div> |
||||
|
</div> |
||||
@ -0,0 +1 @@ |
|||||
|
|
||||
@ -0,0 +1 @@ |
|||||
|
|
||||
@ -0,0 +1 @@ |
|||||
|
|
||||
@ -0,0 +1 @@ |
|||||
|
|
||||
@ -0,0 +1,25 @@ |
|||||
|
<div class="page active"> |
||||
|
<div class="card"> |
||||
|
<h2 data-i18n="terminal.title">Browser Terminal</h2> |
||||
|
<p> |
||||
|
<span data-i18n="terminal.body1">Use the socket session for diagnostics, port inspection, and local admin tasks. Type</span> |
||||
|
<code>clear</code> |
||||
|
<span data-i18n="terminal.body2">or</span> |
||||
|
<code>cls</code> |
||||
|
<span data-i18n="terminal.body3">to reset the view.</span> |
||||
|
</p> |
||||
|
</div> |
||||
|
|
||||
|
<div class="terminal-shell"> |
||||
|
<div class="terminal-top"> |
||||
|
<div class="terminal-title" data-i18n="terminal.consoleTitle">Maintenance Console</div> |
||||
|
<div class="terminal-note" data-i18n="terminal.consoleNote">Windows PowerShell Session</div> |
||||
|
</div> |
||||
|
<div id="terminal"></div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="footer"> |
||||
|
<div data-i18n="terminal.footer">Local shell stream</div> |
||||
|
<div id="footerState" class="status-live" data-i18n="socket.waiting">Waiting for socket...</div> |
||||
|
</div> |
||||
|
</div> |
||||
@ -0,0 +1,946 @@ |
|||||
|
:root { |
||||
|
--bg: #edf3f9; |
||||
|
--bg-strong: #dbe7f4; |
||||
|
--sidebar: #11263e; |
||||
|
--sidebar-soft: #1b3b5d; |
||||
|
--panel: #ffffff; |
||||
|
--panel-soft: #f6f9fc; |
||||
|
--panel-tint: #f0f5fb; |
||||
|
--line: #d5e0ec; |
||||
|
--line-strong: #bfd0e2; |
||||
|
--text: #18324f; |
||||
|
--muted: #617994; |
||||
|
--accent: #2277d8; |
||||
|
--accent-soft: #e5f0ff; |
||||
|
--success: #1ca56a; |
||||
|
--warning: #d98a1f; |
||||
|
--danger: #d85f5f; |
||||
|
--terminal: #081426; |
||||
|
} |
||||
|
|
||||
|
* { |
||||
|
box-sizing: border-box; |
||||
|
} |
||||
|
|
||||
|
html, |
||||
|
body { |
||||
|
margin: 0; |
||||
|
min-height: 100vh; |
||||
|
font-family: "Segoe UI", Arial, sans-serif; |
||||
|
color: var(--text); |
||||
|
background: |
||||
|
radial-gradient(circle at top right, rgba(34, 119, 216, 0.08), transparent 28%), |
||||
|
linear-gradient(180deg, #f9fbfe 0%, var(--bg) 100%); |
||||
|
overflow-x: hidden; |
||||
|
} |
||||
|
|
||||
|
code { |
||||
|
font-family: Consolas, "IBM Plex Mono", monospace; |
||||
|
font-size: 0.95em; |
||||
|
} |
||||
|
|
||||
|
.sidebar-backdrop { |
||||
|
position: fixed; |
||||
|
inset: 0; |
||||
|
background: rgba(7, 18, 32, 0.46); |
||||
|
z-index: 20; |
||||
|
} |
||||
|
|
||||
|
.layout { |
||||
|
display: grid; |
||||
|
grid-template-columns: 280px 1fr; |
||||
|
width: 100%; |
||||
|
max-width: 100%; |
||||
|
height: 100vh; |
||||
|
} |
||||
|
|
||||
|
.sidebar { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
gap: 18px; |
||||
|
padding: 22px 16px; |
||||
|
background: |
||||
|
linear-gradient(180deg, rgba(120, 181, 255, 0.08), transparent 22%), |
||||
|
linear-gradient(180deg, #132a43 0%, #102238 100%); |
||||
|
color: #dce8f7; |
||||
|
border-right: 1px solid rgba(255, 255, 255, 0.08); |
||||
|
height: 100vh; |
||||
|
overflow: visible; |
||||
|
position: sticky; |
||||
|
top: 0; |
||||
|
z-index: 25; |
||||
|
} |
||||
|
|
||||
|
.brand { |
||||
|
display: flex; |
||||
|
gap: 12px; |
||||
|
align-items: center; |
||||
|
padding: 6px 10px 14px; |
||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08); |
||||
|
} |
||||
|
|
||||
|
.brand-mark { |
||||
|
width: 18px; |
||||
|
height: 18px; |
||||
|
border-radius: 6px; |
||||
|
background: linear-gradient(180deg, #59b4ff, #2b7ddd); |
||||
|
position: relative; |
||||
|
flex: 0 0 auto; |
||||
|
} |
||||
|
|
||||
|
.brand-mark::before, |
||||
|
.brand-mark::after { |
||||
|
content: ""; |
||||
|
position: absolute; |
||||
|
left: 50%; |
||||
|
transform: translateX(-50%); |
||||
|
border: 2px solid rgba(255, 255, 255, 0.92); |
||||
|
border-bottom: 0; |
||||
|
border-radius: 999px 999px 0 0; |
||||
|
} |
||||
|
|
||||
|
.brand-mark::before { |
||||
|
width: 12px; |
||||
|
height: 7px; |
||||
|
top: 4px; |
||||
|
} |
||||
|
|
||||
|
.brand-mark::after { |
||||
|
width: 7px; |
||||
|
height: 4px; |
||||
|
top: 7px; |
||||
|
} |
||||
|
|
||||
|
.brand-copy { |
||||
|
display: grid; |
||||
|
gap: 4px; |
||||
|
} |
||||
|
|
||||
|
.brand-kicker { |
||||
|
font-size: 11px; |
||||
|
letter-spacing: 0.14em; |
||||
|
text-transform: uppercase; |
||||
|
color: #8fc3ff; |
||||
|
font-weight: 700; |
||||
|
} |
||||
|
|
||||
|
.brand-title { |
||||
|
font-size: 20px; |
||||
|
font-weight: 700; |
||||
|
color: #fff; |
||||
|
} |
||||
|
|
||||
|
.nav-title { |
||||
|
padding: 0 10px; |
||||
|
font-size: 11px; |
||||
|
letter-spacing: 0.16em; |
||||
|
text-transform: uppercase; |
||||
|
color: #86a9ce; |
||||
|
} |
||||
|
|
||||
|
.nav { |
||||
|
display: grid; |
||||
|
gap: 6px; |
||||
|
} |
||||
|
|
||||
|
.nav-group { |
||||
|
position: relative; |
||||
|
} |
||||
|
|
||||
|
.nav-item { |
||||
|
width: 100%; |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
gap: 12px; |
||||
|
padding: 13px 12px; |
||||
|
border: 1px solid transparent; |
||||
|
border-radius: 14px; |
||||
|
background: transparent; |
||||
|
color: #dce8f7; |
||||
|
text-align: left; |
||||
|
font: inherit; |
||||
|
cursor: pointer; |
||||
|
transition: background 160ms ease, border-color 160ms ease, transform 160ms ease; |
||||
|
} |
||||
|
|
||||
|
.nav-item:hover { |
||||
|
background: rgba(255, 255, 255, 0.04); |
||||
|
transform: translateX(2px); |
||||
|
} |
||||
|
|
||||
|
.nav-item.active { |
||||
|
background: linear-gradient(180deg, rgba(69, 147, 242, 0.28), rgba(49, 108, 184, 0.18)); |
||||
|
border-color: rgba(144, 194, 255, 0.28); |
||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); |
||||
|
} |
||||
|
|
||||
|
.nav-group.open > .nav-item { |
||||
|
background: linear-gradient(180deg, rgba(69, 147, 242, 0.28), rgba(49, 108, 184, 0.18)); |
||||
|
border-color: rgba(144, 194, 255, 0.28); |
||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); |
||||
|
} |
||||
|
|
||||
|
.nav-item span { |
||||
|
display: grid; |
||||
|
gap: 4px; |
||||
|
} |
||||
|
|
||||
|
.nav-item strong { |
||||
|
font-size: 14px; |
||||
|
font-weight: 700; |
||||
|
color: #fff; |
||||
|
} |
||||
|
|
||||
|
.nav-item small { |
||||
|
font-size: 12px; |
||||
|
color: #a7c3df; |
||||
|
} |
||||
|
|
||||
|
.nav-pill { |
||||
|
padding: 5px 8px; |
||||
|
border-radius: 999px; |
||||
|
background: rgba(255, 255, 255, 0.08); |
||||
|
color: #dce8f7; |
||||
|
font-size: 11px; |
||||
|
white-space: nowrap; |
||||
|
} |
||||
|
|
||||
|
.nav-arrow { |
||||
|
min-width: 34px; |
||||
|
text-align: center; |
||||
|
} |
||||
|
|
||||
|
.nav-flyout { |
||||
|
position: absolute; |
||||
|
top: 0; |
||||
|
left: calc(100% + 2px); |
||||
|
min-width: 220px; |
||||
|
padding: 8px; |
||||
|
border: 1px solid var(--line); |
||||
|
border-radius: 16px; |
||||
|
background: rgba(255, 255, 255, 0.98); |
||||
|
box-shadow: 0 18px 40px rgba(12, 29, 51, 0.16); |
||||
|
opacity: 0; |
||||
|
visibility: hidden; |
||||
|
transform: translateX(-8px); |
||||
|
pointer-events: none; |
||||
|
transition: opacity 160ms ease, transform 160ms ease, visibility 160ms ease; |
||||
|
z-index: 40; |
||||
|
} |
||||
|
|
||||
|
.nav-flyout::before { |
||||
|
content: ""; |
||||
|
position: absolute; |
||||
|
top: 0; |
||||
|
bottom: 0; |
||||
|
left: -14px; |
||||
|
width: 14px; |
||||
|
} |
||||
|
|
||||
|
.nav-group:hover > .nav-flyout, |
||||
|
.nav-group.open > .nav-flyout { |
||||
|
opacity: 1; |
||||
|
visibility: visible; |
||||
|
transform: translateX(0); |
||||
|
pointer-events: auto; |
||||
|
} |
||||
|
|
||||
|
.nav-group.suppress-hover > .nav-flyout { |
||||
|
opacity: 0; |
||||
|
visibility: hidden; |
||||
|
transform: translateX(-8px); |
||||
|
pointer-events: none; |
||||
|
} |
||||
|
|
||||
|
.flyout-item { |
||||
|
width: 100%; |
||||
|
padding: 12px 14px; |
||||
|
border: 0; |
||||
|
border-radius: 12px; |
||||
|
background: transparent; |
||||
|
color: var(--text); |
||||
|
text-align: left; |
||||
|
font: inherit; |
||||
|
font-size: 14px; |
||||
|
font-weight: 600; |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
|
||||
|
.flyout-item:hover { |
||||
|
background: var(--panel-soft); |
||||
|
} |
||||
|
|
||||
|
.flyout-item.active { |
||||
|
background: var(--accent-soft); |
||||
|
color: var(--accent); |
||||
|
} |
||||
|
|
||||
|
.main { |
||||
|
display: grid; |
||||
|
grid-template-rows: auto 1fr; |
||||
|
width: 100%; |
||||
|
min-width: 0; |
||||
|
height: 100vh; |
||||
|
overflow-y: auto; |
||||
|
} |
||||
|
|
||||
|
.sidebar-toggle { |
||||
|
display: none; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
border: 1px solid var(--line); |
||||
|
background: var(--panel-soft); |
||||
|
color: var(--text); |
||||
|
border-radius: 10px; |
||||
|
padding: 8px 11px; |
||||
|
font: inherit; |
||||
|
font-size: 18px; |
||||
|
line-height: 1; |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
|
||||
|
.topbar { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
gap: 16px; |
||||
|
padding: 18px 24px; |
||||
|
border-bottom: 1px solid var(--line); |
||||
|
background: rgba(255, 255, 255, 0.88); |
||||
|
backdrop-filter: blur(10px); |
||||
|
position: sticky; |
||||
|
top: 0; |
||||
|
z-index: 10; |
||||
|
} |
||||
|
|
||||
|
.topbar-copy { |
||||
|
display: grid; |
||||
|
gap: 6px; |
||||
|
min-width: 0; |
||||
|
} |
||||
|
|
||||
|
.page-eyebrow { |
||||
|
font-size: 12px; |
||||
|
text-transform: uppercase; |
||||
|
letter-spacing: 0.14em; |
||||
|
color: var(--accent); |
||||
|
font-weight: 700; |
||||
|
} |
||||
|
|
||||
|
.page-title { |
||||
|
font-size: 28px; |
||||
|
font-weight: 700; |
||||
|
color: var(--text); |
||||
|
} |
||||
|
|
||||
|
.topbar-meta { |
||||
|
display: flex; |
||||
|
gap: 10px; |
||||
|
flex-wrap: wrap; |
||||
|
justify-content: flex-end; |
||||
|
} |
||||
|
|
||||
|
.lang-switch { |
||||
|
display: inline-flex; |
||||
|
padding: 3px; |
||||
|
border: 1px solid var(--line); |
||||
|
border-radius: 999px; |
||||
|
background: var(--panel-soft); |
||||
|
} |
||||
|
|
||||
|
.lang-button { |
||||
|
border: 0; |
||||
|
background: transparent; |
||||
|
color: var(--muted); |
||||
|
padding: 6px 10px; |
||||
|
border-radius: 999px; |
||||
|
font: inherit; |
||||
|
font-size: 12px; |
||||
|
font-weight: 700; |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
|
||||
|
.lang-button.active { |
||||
|
background: #fff; |
||||
|
color: var(--text); |
||||
|
box-shadow: 0 1px 3px rgba(19, 36, 58, 0.08); |
||||
|
} |
||||
|
|
||||
|
.chip { |
||||
|
padding: 8px 12px; |
||||
|
border-radius: 999px; |
||||
|
border: 1px solid var(--line); |
||||
|
background: var(--panel-soft); |
||||
|
color: var(--muted); |
||||
|
font-size: 12px; |
||||
|
} |
||||
|
|
||||
|
.content { |
||||
|
padding: 24px; |
||||
|
min-width: 0; |
||||
|
width: 100%; |
||||
|
overflow-x: hidden; |
||||
|
} |
||||
|
|
||||
|
.page-outlet { |
||||
|
min-height: calc(100vh - 98px); |
||||
|
} |
||||
|
|
||||
|
.page { |
||||
|
display: none; |
||||
|
gap: 18px; |
||||
|
min-width: 0; |
||||
|
} |
||||
|
|
||||
|
.page.active { |
||||
|
display: grid; |
||||
|
} |
||||
|
|
||||
|
.card, |
||||
|
.hero-card { |
||||
|
border: 1px solid var(--line); |
||||
|
border-radius: 20px; |
||||
|
background: rgba(255, 255, 255, 0.96); |
||||
|
min-width: 0; |
||||
|
} |
||||
|
|
||||
|
.card { |
||||
|
padding: 20px; |
||||
|
} |
||||
|
|
||||
|
.card h2, |
||||
|
.hero-card h2 { |
||||
|
margin: 0 0 10px; |
||||
|
color: var(--text); |
||||
|
} |
||||
|
|
||||
|
.card h2 { |
||||
|
font-size: 18px; |
||||
|
} |
||||
|
|
||||
|
.hero-card h2 { |
||||
|
font-size: 28px; |
||||
|
line-height: 1.2; |
||||
|
} |
||||
|
|
||||
|
.card p, |
||||
|
.hero-card p { |
||||
|
margin: 0; |
||||
|
color: var(--muted); |
||||
|
line-height: 1.6; |
||||
|
font-size: 14px; |
||||
|
} |
||||
|
|
||||
|
.hero-grid { |
||||
|
display: grid; |
||||
|
grid-template-columns: 1.3fr 0.7fr; |
||||
|
gap: 18px; |
||||
|
} |
||||
|
|
||||
|
.hero-card { |
||||
|
padding: 24px; |
||||
|
} |
||||
|
|
||||
|
.hero-card-accent { |
||||
|
background: |
||||
|
radial-gradient(circle at top right, rgba(48, 130, 238, 0.18), transparent 30%), |
||||
|
linear-gradient(180deg, #fff 0%, #f8fbff 100%); |
||||
|
} |
||||
|
|
||||
|
.hero-label { |
||||
|
display: inline-block; |
||||
|
margin-bottom: 12px; |
||||
|
padding: 6px 10px; |
||||
|
border-radius: 999px; |
||||
|
background: var(--accent-soft); |
||||
|
color: var(--accent); |
||||
|
font-size: 11px; |
||||
|
font-weight: 700; |
||||
|
letter-spacing: 0.08em; |
||||
|
text-transform: uppercase; |
||||
|
} |
||||
|
|
||||
|
.hero-stats { |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(3, minmax(0, 1fr)); |
||||
|
gap: 12px; |
||||
|
margin-top: 20px; |
||||
|
} |
||||
|
|
||||
|
.hero-stat { |
||||
|
padding: 14px; |
||||
|
border-radius: 16px; |
||||
|
border: 1px solid var(--line); |
||||
|
background: rgba(255, 255, 255, 0.84); |
||||
|
} |
||||
|
|
||||
|
.hero-stat-value { |
||||
|
display: block; |
||||
|
font-size: 28px; |
||||
|
font-weight: 700; |
||||
|
color: var(--text); |
||||
|
} |
||||
|
|
||||
|
.hero-stat-label { |
||||
|
display: block; |
||||
|
margin-top: 4px; |
||||
|
font-size: 12px; |
||||
|
color: var(--muted); |
||||
|
} |
||||
|
|
||||
|
.status-list { |
||||
|
display: grid; |
||||
|
gap: 12px; |
||||
|
} |
||||
|
|
||||
|
.status-row { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
gap: 12px; |
||||
|
padding: 12px 0; |
||||
|
border-bottom: 1px dashed var(--line); |
||||
|
} |
||||
|
|
||||
|
.status-row:last-child { |
||||
|
border-bottom: 0; |
||||
|
} |
||||
|
|
||||
|
.status-row span { |
||||
|
color: var(--muted); |
||||
|
} |
||||
|
|
||||
|
.status-row strong { |
||||
|
color: var(--text); |
||||
|
} |
||||
|
|
||||
|
.split { |
||||
|
display: grid; |
||||
|
grid-template-columns: 1.1fr 0.9fr; |
||||
|
gap: 18px; |
||||
|
min-width: 0; |
||||
|
} |
||||
|
|
||||
|
.split-wide { |
||||
|
grid-template-columns: 1fr 1fr; |
||||
|
} |
||||
|
|
||||
|
.channel-groups, |
||||
|
.settings-grid { |
||||
|
display: grid; |
||||
|
gap: 14px; |
||||
|
} |
||||
|
|
||||
|
.channel-groups { |
||||
|
grid-template-columns: repeat(2, minmax(0, 1fr)); |
||||
|
margin-top: 16px; |
||||
|
} |
||||
|
|
||||
|
.settings-grid { |
||||
|
grid-template-columns: repeat(2, minmax(0, 1fr)); |
||||
|
margin-top: 16px; |
||||
|
} |
||||
|
|
||||
|
.group-card, |
||||
|
.setting-box { |
||||
|
padding: 16px; |
||||
|
border: 1px solid var(--line); |
||||
|
border-radius: 16px; |
||||
|
background: var(--panel-soft); |
||||
|
} |
||||
|
|
||||
|
.group-title, |
||||
|
.setting-box h3 { |
||||
|
margin: 0 0 6px; |
||||
|
font-size: 15px; |
||||
|
color: var(--text); |
||||
|
font-weight: 700; |
||||
|
} |
||||
|
|
||||
|
.group-meta, |
||||
|
.setting-box p { |
||||
|
margin: 0; |
||||
|
font-size: 13px; |
||||
|
color: var(--muted); |
||||
|
line-height: 1.5; |
||||
|
} |
||||
|
|
||||
|
.diagram-card { |
||||
|
position: relative; |
||||
|
display: grid; |
||||
|
gap: 18px; |
||||
|
align-items: center; |
||||
|
justify-items: center; |
||||
|
min-height: 320px; |
||||
|
margin-top: 16px; |
||||
|
padding: 24px; |
||||
|
border-radius: 18px; |
||||
|
background: |
||||
|
linear-gradient(180deg, rgba(236, 244, 252, 0.8), rgba(245, 249, 253, 0.96)), |
||||
|
var(--panel-tint); |
||||
|
border: 1px dashed var(--line-strong); |
||||
|
} |
||||
|
|
||||
|
.diagram-node, |
||||
|
.diagram-device { |
||||
|
padding: 12px 16px; |
||||
|
border-radius: 14px; |
||||
|
border: 1px solid var(--line-strong); |
||||
|
background: #fff; |
||||
|
font-weight: 700; |
||||
|
} |
||||
|
|
||||
|
.diagram-node-center { |
||||
|
min-width: 220px; |
||||
|
text-align: center; |
||||
|
border-color: #f3b469; |
||||
|
box-shadow: 0 0 0 10px rgba(243, 180, 105, 0.14); |
||||
|
} |
||||
|
|
||||
|
.diagram-stack { |
||||
|
display: grid; |
||||
|
gap: 12px; |
||||
|
width: 100%; |
||||
|
max-width: 220px; |
||||
|
} |
||||
|
|
||||
|
.diagram-line { |
||||
|
background: linear-gradient(180deg, #84b8ff, #f1af62); |
||||
|
} |
||||
|
|
||||
|
.diagram-line-horizontal { |
||||
|
width: 160px; |
||||
|
height: 2px; |
||||
|
} |
||||
|
|
||||
|
.diagram-line-vertical { |
||||
|
width: 2px; |
||||
|
height: 28px; |
||||
|
} |
||||
|
|
||||
|
.port-grid { |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(4, minmax(0, 1fr)); |
||||
|
gap: 12px; |
||||
|
margin-top: 18px; |
||||
|
} |
||||
|
|
||||
|
.port-tile { |
||||
|
display: grid; |
||||
|
gap: 8px; |
||||
|
padding: 14px; |
||||
|
border-radius: 16px; |
||||
|
border: 1px solid var(--line); |
||||
|
background: #fff; |
||||
|
min-width: 0; |
||||
|
} |
||||
|
|
||||
|
.port-up { |
||||
|
background: linear-gradient(180deg, #fff 0%, #f6fbf8 100%); |
||||
|
} |
||||
|
|
||||
|
.port-warn { |
||||
|
background: linear-gradient(180deg, #fff 0%, #fff9f1 100%); |
||||
|
border-color: #edd2a7; |
||||
|
} |
||||
|
|
||||
|
.port-off { |
||||
|
background: linear-gradient(180deg, #fff 0%, #f7f9fc 100%); |
||||
|
opacity: 0.82; |
||||
|
} |
||||
|
|
||||
|
.port-num { |
||||
|
font-size: 12px; |
||||
|
font-weight: 700; |
||||
|
color: var(--accent); |
||||
|
letter-spacing: 0.08em; |
||||
|
} |
||||
|
|
||||
|
.port-name { |
||||
|
font-size: 15px; |
||||
|
font-weight: 700; |
||||
|
color: var(--text); |
||||
|
} |
||||
|
|
||||
|
.port-state { |
||||
|
font-size: 12px; |
||||
|
color: var(--muted); |
||||
|
} |
||||
|
|
||||
|
.info-list { |
||||
|
display: grid; |
||||
|
gap: 12px; |
||||
|
margin-top: 16px; |
||||
|
} |
||||
|
|
||||
|
.info-row { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
gap: 12px; |
||||
|
padding-bottom: 10px; |
||||
|
border-bottom: 1px dashed var(--line); |
||||
|
font-size: 14px; |
||||
|
} |
||||
|
|
||||
|
.info-row:last-child { |
||||
|
border-bottom: 0; |
||||
|
padding-bottom: 0; |
||||
|
} |
||||
|
|
||||
|
.info-key { |
||||
|
color: var(--muted); |
||||
|
} |
||||
|
|
||||
|
.info-value { |
||||
|
color: var(--text); |
||||
|
font-weight: 600; |
||||
|
text-align: right; |
||||
|
word-break: break-word; |
||||
|
min-width: 0; |
||||
|
} |
||||
|
|
||||
|
.device-table { |
||||
|
width: 100%; |
||||
|
border-collapse: collapse; |
||||
|
margin-top: 14px; |
||||
|
font-size: 14px; |
||||
|
} |
||||
|
|
||||
|
.device-table th, |
||||
|
.device-table td { |
||||
|
padding: 12px 10px; |
||||
|
border-bottom: 1px solid var(--line); |
||||
|
text-align: left; |
||||
|
} |
||||
|
|
||||
|
.device-table th { |
||||
|
color: var(--muted); |
||||
|
font-size: 12px; |
||||
|
text-transform: uppercase; |
||||
|
letter-spacing: 0.06em; |
||||
|
} |
||||
|
|
||||
|
.terminal-shell { |
||||
|
min-height: 620px; |
||||
|
border: 1px solid #9db6d2; |
||||
|
border-radius: 18px; |
||||
|
overflow: hidden; |
||||
|
background: var(--terminal); |
||||
|
box-shadow: 0 12px 28px rgba(17, 35, 60, 0.08); |
||||
|
min-width: 0; |
||||
|
} |
||||
|
|
||||
|
.terminal-top { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
gap: 16px; |
||||
|
padding: 14px 18px; |
||||
|
border-bottom: 1px solid rgba(197, 214, 235, 0.14); |
||||
|
background: linear-gradient(180deg, #10213a 0%, #0c1a2e 100%); |
||||
|
} |
||||
|
|
||||
|
.terminal-title { |
||||
|
color: #e1eeff; |
||||
|
font-size: 13px; |
||||
|
font-weight: 700; |
||||
|
letter-spacing: 0.06em; |
||||
|
text-transform: uppercase; |
||||
|
} |
||||
|
|
||||
|
.terminal-note { |
||||
|
color: #96b6d8; |
||||
|
font-size: 12px; |
||||
|
} |
||||
|
|
||||
|
#terminal { |
||||
|
height: calc(100% - 53px); |
||||
|
padding: 16px 14px 10px; |
||||
|
} |
||||
|
|
||||
|
#terminal .xterm-viewport { |
||||
|
scrollbar-width: none; |
||||
|
-ms-overflow-style: none; |
||||
|
} |
||||
|
|
||||
|
#terminal .xterm-viewport::-webkit-scrollbar { |
||||
|
width: 0; |
||||
|
height: 0; |
||||
|
} |
||||
|
|
||||
|
.footer { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
gap: 12px; |
||||
|
flex-wrap: wrap; |
||||
|
color: var(--muted); |
||||
|
font-size: 12px; |
||||
|
padding-top: 10px; |
||||
|
} |
||||
|
|
||||
|
.status-live { |
||||
|
color: var(--warning); |
||||
|
} |
||||
|
|
||||
|
.status-ok { |
||||
|
color: var(--success); |
||||
|
} |
||||
|
|
||||
|
.status-bad { |
||||
|
color: var(--danger); |
||||
|
} |
||||
|
|
||||
|
@media (max-width: 1260px) { |
||||
|
.hero-grid, |
||||
|
.split, |
||||
|
.split-wide { |
||||
|
grid-template-columns: 1fr; |
||||
|
} |
||||
|
|
||||
|
.port-grid { |
||||
|
grid-template-columns: repeat(3, minmax(0, 1fr)); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@media (max-width: 920px) { |
||||
|
.layout { |
||||
|
display: block; |
||||
|
height: auto; |
||||
|
} |
||||
|
|
||||
|
.sidebar { |
||||
|
position: fixed; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
bottom: 0; |
||||
|
width: min(320px, 82vw); |
||||
|
transform: translateX(-100%); |
||||
|
transition: transform 180ms ease; |
||||
|
z-index: 30; |
||||
|
height: 100vh; |
||||
|
overflow-y: auto; |
||||
|
overflow-x: hidden; |
||||
|
} |
||||
|
|
||||
|
body.sidebar-open .sidebar { |
||||
|
transform: translateX(0); |
||||
|
} |
||||
|
|
||||
|
body.sidebar-open { |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
.sidebar-toggle { |
||||
|
display: inline-flex; |
||||
|
} |
||||
|
|
||||
|
.main { |
||||
|
height: auto; |
||||
|
overflow-y: visible; |
||||
|
} |
||||
|
|
||||
|
.topbar { |
||||
|
padding: 16px 18px; |
||||
|
} |
||||
|
|
||||
|
.topbar-meta { |
||||
|
width: 100%; |
||||
|
justify-content: flex-start; |
||||
|
} |
||||
|
|
||||
|
.content { |
||||
|
padding: 18px; |
||||
|
} |
||||
|
|
||||
|
.hero-card, |
||||
|
.card { |
||||
|
padding: 18px; |
||||
|
} |
||||
|
|
||||
|
.nav-flyout { |
||||
|
position: static; |
||||
|
min-width: 0; |
||||
|
margin-top: 6px; |
||||
|
padding: 6px; |
||||
|
border-radius: 14px; |
||||
|
background: rgba(255, 255, 255, 0.06); |
||||
|
border-color: rgba(255, 255, 255, 0.08); |
||||
|
box-shadow: none; |
||||
|
opacity: 1; |
||||
|
visibility: visible; |
||||
|
transform: none; |
||||
|
pointer-events: auto; |
||||
|
display: none; |
||||
|
} |
||||
|
|
||||
|
.nav-group.open > .nav-flyout { |
||||
|
display: block; |
||||
|
} |
||||
|
|
||||
|
.flyout-item { |
||||
|
color: #dce8f7; |
||||
|
} |
||||
|
|
||||
|
.flyout-item:hover { |
||||
|
background: rgba(255, 255, 255, 0.08); |
||||
|
} |
||||
|
|
||||
|
.flyout-item.active { |
||||
|
background: rgba(143, 195, 255, 0.18); |
||||
|
color: #ffffff; |
||||
|
} |
||||
|
|
||||
|
.hero-stats, |
||||
|
.channel-groups, |
||||
|
.settings-grid, |
||||
|
.port-grid { |
||||
|
grid-template-columns: repeat(2, minmax(0, 1fr)); |
||||
|
} |
||||
|
|
||||
|
.page-title { |
||||
|
font-size: 24px; |
||||
|
} |
||||
|
|
||||
|
.terminal-shell { |
||||
|
min-height: 58vh; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@media (max-width: 640px) { |
||||
|
.topbar { |
||||
|
gap: 12px; |
||||
|
} |
||||
|
|
||||
|
.page-title { |
||||
|
font-size: 21px; |
||||
|
} |
||||
|
|
||||
|
.hero-card h2 { |
||||
|
font-size: 24px; |
||||
|
} |
||||
|
|
||||
|
.hero-stats, |
||||
|
.channel-groups, |
||||
|
.settings-grid, |
||||
|
.port-grid { |
||||
|
grid-template-columns: 1fr; |
||||
|
} |
||||
|
|
||||
|
.card, |
||||
|
.hero-card, |
||||
|
.group-card, |
||||
|
.setting-box, |
||||
|
.port-tile { |
||||
|
padding: 14px; |
||||
|
} |
||||
|
|
||||
|
.device-table { |
||||
|
display: block; |
||||
|
overflow-x: auto; |
||||
|
white-space: nowrap; |
||||
|
} |
||||
|
|
||||
|
.terminal-shell { |
||||
|
min-height: 52vh; |
||||
|
} |
||||
|
} |
||||
File diff suppressed because it is too large
File diff suppressed because it is too large
@ -0,0 +1,23 @@ |
|||||
|
package routes |
||||
|
|
||||
|
import ( |
||||
|
"net/http" |
||||
|
|
||||
|
"teraclone/internal/handler" |
||||
|
"teraclone/internal/handlers" |
||||
|
"teraclone/internal/service" |
||||
|
) |
||||
|
|
||||
|
func Register(mux *http.ServeMux, appService *service.AppService) error { |
||||
|
legacyHandler := handler.NewAppHandler(appService) |
||||
|
legacyHandler.RegisterLegacyRoutes(mux) |
||||
|
|
||||
|
deviceHandler, err := handlers.NewDeviceHandler(appService) |
||||
|
if err != nil { |
||||
|
return err |
||||
|
} |
||||
|
|
||||
|
deviceHandler.RegisterRoutes(mux) |
||||
|
mux.HandleFunc("/health", legacyHandler.HandleHealth) |
||||
|
return nil |
||||
|
} |
||||
@ -0,0 +1,329 @@ |
|||||
|
package service |
||||
|
|
||||
|
import ( |
||||
|
"errors" |
||||
|
"fmt" |
||||
|
"strings" |
||||
|
"teraclone/internal/cli" |
||||
|
"teraclone/internal/store" |
||||
|
) |
||||
|
|
||||
|
type AppService struct { |
||||
|
store *store.SQLiteStore |
||||
|
} |
||||
|
|
||||
|
type User struct { |
||||
|
ID int64 `json:"id"` |
||||
|
Username string `json:"username"` |
||||
|
Group string `json:"group"` |
||||
|
Enabled bool `json:"enabled"` |
||||
|
StatusLabel string `json:"statusLabel"` |
||||
|
AccessSummary string `json:"accessSummary"` |
||||
|
} |
||||
|
|
||||
|
type CreateUserInput struct { |
||||
|
Username string `json:"username"` |
||||
|
Password string `json:"password"` |
||||
|
Group string `json:"group"` |
||||
|
Enabled bool `json:"enabled"` |
||||
|
} |
||||
|
|
||||
|
type UpdateUserInput struct { |
||||
|
ID int64 `json:"id"` |
||||
|
Username string `json:"username"` |
||||
|
Password string `json:"password"` |
||||
|
Group string `json:"group"` |
||||
|
Enabled bool `json:"enabled"` |
||||
|
} |
||||
|
|
||||
|
type Group struct { |
||||
|
ID int64 `json:"id"` |
||||
|
Name string `json:"name"` |
||||
|
Description string `json:"description"` |
||||
|
Permissions []string `json:"permissions"` |
||||
|
PermissionSummary string `json:"permissionSummary"` |
||||
|
} |
||||
|
|
||||
|
type CreateGroupInput struct { |
||||
|
Name string `json:"name"` |
||||
|
Description string `json:"description"` |
||||
|
Permissions []string `json:"permissions"` |
||||
|
} |
||||
|
|
||||
|
type UpdateGroupInput struct { |
||||
|
ID int64 `json:"id"` |
||||
|
Name string `json:"name"` |
||||
|
Description string `json:"description"` |
||||
|
Permissions []string `json:"permissions"` |
||||
|
} |
||||
|
|
||||
|
func NewAppService(dbPath string) (*AppService, error) { |
||||
|
sqliteStore, err := store.NewSQLiteStore(dbPath) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
return &AppService{store: sqliteStore}, nil |
||||
|
} |
||||
|
|
||||
|
func (s *AppService) Greeting() string { |
||||
|
return "hello from go server" |
||||
|
} |
||||
|
|
||||
|
func (s *AppService) HealthStatus() string { |
||||
|
return "ok" |
||||
|
} |
||||
|
|
||||
|
func (s *AppService) NewTerminalSession() (*cli.TerminalSession, error) { |
||||
|
return cli.NewTerminalSession() |
||||
|
} |
||||
|
|
||||
|
func (s *AppService) Close() error { |
||||
|
if s == nil { |
||||
|
return nil |
||||
|
} |
||||
|
return s.store.Close() |
||||
|
} |
||||
|
|
||||
|
func (s *AppService) RecordPageVisit(path string, title string) error { |
||||
|
return s.store.RecordPageVisit(path, title) |
||||
|
} |
||||
|
|
||||
|
func (s *AppService) RecordMockAction(action string, path string) error { |
||||
|
return s.store.RecordMockAction(action, path) |
||||
|
} |
||||
|
|
||||
|
func (s *AppService) RecentActivity(limit int) ([]store.ActivityRecord, error) { |
||||
|
return s.store.RecentActivity(limit) |
||||
|
} |
||||
|
|
||||
|
func (s *AppService) ListUsers() ([]User, error) { |
||||
|
records, err := s.store.ListUsers() |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
users := make([]User, 0, len(records)) |
||||
|
for _, record := range records { |
||||
|
users = append(users, mapUser(record)) |
||||
|
} |
||||
|
return users, nil |
||||
|
} |
||||
|
|
||||
|
func (s *AppService) ListGroups() ([]Group, error) { |
||||
|
records, err := s.store.ListGroups() |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
groups := make([]Group, 0, len(records)) |
||||
|
for _, record := range records { |
||||
|
groups = append(groups, mapGroup(record)) |
||||
|
} |
||||
|
return groups, nil |
||||
|
} |
||||
|
|
||||
|
func (s *AppService) GetGroup(id int64) (Group, error) { |
||||
|
record, err := s.store.GetGroup(id) |
||||
|
if err != nil { |
||||
|
return Group{}, err |
||||
|
} |
||||
|
return mapGroup(record), nil |
||||
|
} |
||||
|
|
||||
|
func (s *AppService) CreateGroup(input CreateGroupInput) (Group, error) { |
||||
|
if err := validateGroupInput(input.Name, input.Permissions); err != nil { |
||||
|
return Group{}, err |
||||
|
} |
||||
|
|
||||
|
record, err := s.store.CreateGroup(store.CreateGroupParams{ |
||||
|
Name: input.Name, |
||||
|
Description: input.Description, |
||||
|
Permissions: input.Permissions, |
||||
|
}) |
||||
|
if err != nil { |
||||
|
return Group{}, err |
||||
|
} |
||||
|
return mapGroup(record), nil |
||||
|
} |
||||
|
|
||||
|
func (s *AppService) UpdateGroup(input UpdateGroupInput) (Group, error) { |
||||
|
if input.ID <= 0 { |
||||
|
return Group{}, errors.New("invalid group id") |
||||
|
} |
||||
|
if err := validateGroupInput(input.Name, input.Permissions); err != nil { |
||||
|
return Group{}, err |
||||
|
} |
||||
|
|
||||
|
record, err := s.store.UpdateGroup(store.UpdateGroupParams{ |
||||
|
ID: input.ID, |
||||
|
Name: input.Name, |
||||
|
Description: input.Description, |
||||
|
Permissions: input.Permissions, |
||||
|
}) |
||||
|
if err != nil { |
||||
|
return Group{}, err |
||||
|
} |
||||
|
return mapGroup(record), nil |
||||
|
} |
||||
|
|
||||
|
func (s *AppService) DeleteGroup(id int64) error { |
||||
|
if id <= 0 { |
||||
|
return errors.New("invalid group id") |
||||
|
} |
||||
|
return s.store.DeleteGroup(id) |
||||
|
} |
||||
|
|
||||
|
func (s *AppService) GetUser(id int64) (User, error) { |
||||
|
record, err := s.store.GetUser(id) |
||||
|
if err != nil { |
||||
|
return User{}, err |
||||
|
} |
||||
|
return mapUser(record), nil |
||||
|
} |
||||
|
|
||||
|
func (s *AppService) CreateUser(input CreateUserInput) (User, error) { |
||||
|
group, err := s.store.GetGroupByName(strings.TrimSpace(input.Group)) |
||||
|
if err != nil { |
||||
|
return User{}, errors.New("group not found") |
||||
|
} |
||||
|
if err := validateUserInput(input.Username, input.Password, input.Group, true); err != nil { |
||||
|
return User{}, err |
||||
|
} |
||||
|
|
||||
|
record, err := s.store.CreateUser(store.CreateUserParams{ |
||||
|
Username: input.Username, |
||||
|
Password: input.Password, |
||||
|
GroupName: group.Name, |
||||
|
Enabled: input.Enabled, |
||||
|
}) |
||||
|
if err != nil { |
||||
|
return User{}, err |
||||
|
} |
||||
|
return mapUser(record), nil |
||||
|
} |
||||
|
|
||||
|
func (s *AppService) UpdateUser(input UpdateUserInput) (User, error) { |
||||
|
if input.ID <= 0 { |
||||
|
return User{}, errors.New("invalid user id") |
||||
|
} |
||||
|
group, err := s.store.GetGroupByName(strings.TrimSpace(input.Group)) |
||||
|
if err != nil { |
||||
|
return User{}, errors.New("group not found") |
||||
|
} |
||||
|
if err := validateUserInput(input.Username, input.Password, input.Group, false); err != nil { |
||||
|
return User{}, err |
||||
|
} |
||||
|
|
||||
|
record, err := s.store.UpdateUser(store.UpdateUserParams{ |
||||
|
ID: input.ID, |
||||
|
Username: input.Username, |
||||
|
Password: input.Password, |
||||
|
GroupName: group.Name, |
||||
|
Enabled: input.Enabled, |
||||
|
}) |
||||
|
if err != nil { |
||||
|
return User{}, err |
||||
|
} |
||||
|
return mapUser(record), nil |
||||
|
} |
||||
|
|
||||
|
func (s *AppService) DeleteUser(id int64) error { |
||||
|
if id <= 0 { |
||||
|
return errors.New("invalid user id") |
||||
|
} |
||||
|
return s.store.DeleteUser(id) |
||||
|
} |
||||
|
|
||||
|
func (s *AppService) AuthenticateUser(username string, password string) (User, error) { |
||||
|
record, err := s.store.GetUserByUsername(strings.TrimSpace(username)) |
||||
|
if err != nil { |
||||
|
return User{}, err |
||||
|
} |
||||
|
if !record.Enabled { |
||||
|
return User{}, errors.New("user is disabled") |
||||
|
} |
||||
|
if strings.TrimSpace(password) == "" || !store.VerifyPassword(password, record.PasswordHash) { |
||||
|
return User{}, errors.New("invalid username or password") |
||||
|
} |
||||
|
return mapUser(record), nil |
||||
|
} |
||||
|
|
||||
|
func mapUser(record store.UserRecord) User { |
||||
|
return User{ |
||||
|
ID: record.ID, |
||||
|
Username: record.Username, |
||||
|
Group: record.GroupName, |
||||
|
Enabled: record.Enabled, |
||||
|
StatusLabel: statusLabel(record.Enabled), |
||||
|
AccessSummary: accessSummary(record.GroupName), |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func mapGroup(record store.GroupRecord) Group { |
||||
|
return Group{ |
||||
|
ID: record.ID, |
||||
|
Name: record.Name, |
||||
|
Description: record.Description, |
||||
|
Permissions: record.Permissions, |
||||
|
PermissionSummary: permissionSummary(record.Permissions), |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func validateUserInput(username string, password string, group string, passwordRequired bool) error { |
||||
|
if strings.TrimSpace(username) == "" { |
||||
|
return errors.New("username is required") |
||||
|
} |
||||
|
if passwordRequired && strings.TrimSpace(password) == "" { |
||||
|
return errors.New("password is required") |
||||
|
} |
||||
|
if strings.TrimSpace(group) == "" { |
||||
|
return errors.New("group is required") |
||||
|
} |
||||
|
|
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func statusLabel(enabled bool) string { |
||||
|
if enabled { |
||||
|
return "사용" |
||||
|
} |
||||
|
return "중지" |
||||
|
} |
||||
|
|
||||
|
func accessSummary(group string) string { |
||||
|
switch strings.TrimSpace(group) { |
||||
|
case "administrator": |
||||
|
return "전체 허용" |
||||
|
case "operator": |
||||
|
return "운영 허용" |
||||
|
case "guest": |
||||
|
return "조회 허용" |
||||
|
default: |
||||
|
return "제한됨" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func validateGroupInput(name string, permissions []string) error { |
||||
|
if strings.TrimSpace(name) == "" { |
||||
|
return errors.New("group name is required") |
||||
|
} |
||||
|
if len(permissions) == 0 { |
||||
|
return errors.New("at least one permission is required") |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func permissionSummary(permissions []string) string { |
||||
|
if len(permissions) == 0 { |
||||
|
return "권한 없음" |
||||
|
} |
||||
|
if len(permissions) == len(defaultPermissionOptions()) { |
||||
|
return "모든 메뉴" |
||||
|
} |
||||
|
return fmt.Sprintf("권한 %d개", len(permissions)) |
||||
|
} |
||||
|
|
||||
|
func defaultPermissionOptions() []string { |
||||
|
return []string{"login", "network", "ports", "tools", "admin", "common", "status", "password", "operate"} |
||||
|
} |
||||
@ -0,0 +1,572 @@ |
|||||
|
package store |
||||
|
|
||||
|
import ( |
||||
|
"crypto/sha256" |
||||
|
"database/sql" |
||||
|
"encoding/hex" |
||||
|
"encoding/json" |
||||
|
"errors" |
||||
|
"os" |
||||
|
"path/filepath" |
||||
|
"sort" |
||||
|
"strings" |
||||
|
"time" |
||||
|
|
||||
|
_ "modernc.org/sqlite" |
||||
|
) |
||||
|
|
||||
|
var ErrDependencyExists = errors.New("group is assigned to one or more users") |
||||
|
|
||||
|
type SQLiteStore struct { |
||||
|
db *sql.DB |
||||
|
} |
||||
|
|
||||
|
type ActivityRecord struct { |
||||
|
ID int64 |
||||
|
Kind string |
||||
|
Path string |
||||
|
Title string |
||||
|
Action string |
||||
|
CreatedAt time.Time |
||||
|
} |
||||
|
|
||||
|
type UserRecord struct { |
||||
|
ID int64 |
||||
|
Username string |
||||
|
GroupName string |
||||
|
Enabled bool |
||||
|
PasswordHash string |
||||
|
CreatedAt time.Time |
||||
|
UpdatedAt time.Time |
||||
|
} |
||||
|
|
||||
|
type CreateUserParams struct { |
||||
|
Username string |
||||
|
Password string |
||||
|
GroupName string |
||||
|
Enabled bool |
||||
|
} |
||||
|
|
||||
|
type UpdateUserParams struct { |
||||
|
ID int64 |
||||
|
Username string |
||||
|
Password string |
||||
|
GroupName string |
||||
|
Enabled bool |
||||
|
} |
||||
|
|
||||
|
type GroupRecord struct { |
||||
|
ID int64 |
||||
|
Name string |
||||
|
Description string |
||||
|
Permissions []string |
||||
|
CreatedAt time.Time |
||||
|
UpdatedAt time.Time |
||||
|
} |
||||
|
|
||||
|
type CreateGroupParams struct { |
||||
|
Name string |
||||
|
Description string |
||||
|
Permissions []string |
||||
|
} |
||||
|
|
||||
|
type UpdateGroupParams struct { |
||||
|
ID int64 |
||||
|
Name string |
||||
|
Description string |
||||
|
Permissions []string |
||||
|
} |
||||
|
|
||||
|
func NewSQLiteStore(dbPath string) (*SQLiteStore, error) { |
||||
|
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
db, err := sql.Open("sqlite", dbPath) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
store := &SQLiteStore{db: db} |
||||
|
if err := store.init(); err != nil { |
||||
|
_ = db.Close() |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
return store, nil |
||||
|
} |
||||
|
|
||||
|
func (s *SQLiteStore) Close() error { |
||||
|
if s == nil || s.db == nil { |
||||
|
return nil |
||||
|
} |
||||
|
return s.db.Close() |
||||
|
} |
||||
|
|
||||
|
func (s *SQLiteStore) RecordPageVisit(path string, title string) error { |
||||
|
_, err := s.db.Exec(` |
||||
|
INSERT INTO activity_logs (kind, path, title, action) |
||||
|
VALUES ('page_visit', ?, ?, '') |
||||
|
`, path, title) |
||||
|
return err |
||||
|
} |
||||
|
|
||||
|
func (s *SQLiteStore) RecordMockAction(action string, path string) error { |
||||
|
_, err := s.db.Exec(` |
||||
|
INSERT INTO activity_logs (kind, path, title, action) |
||||
|
VALUES ('mock_action', ?, '', ?) |
||||
|
`, path, action) |
||||
|
return err |
||||
|
} |
||||
|
|
||||
|
func (s *SQLiteStore) RecentActivity(limit int) ([]ActivityRecord, error) { |
||||
|
if limit <= 0 { |
||||
|
limit = 20 |
||||
|
} |
||||
|
|
||||
|
rows, err := s.db.Query(` |
||||
|
SELECT id, kind, path, title, action, created_at |
||||
|
FROM activity_logs |
||||
|
ORDER BY id DESC |
||||
|
LIMIT ? |
||||
|
`, limit) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
defer rows.Close() |
||||
|
|
||||
|
records := make([]ActivityRecord, 0, limit) |
||||
|
for rows.Next() { |
||||
|
var record ActivityRecord |
||||
|
if err := rows.Scan(&record.ID, &record.Kind, &record.Path, &record.Title, &record.Action, &record.CreatedAt); err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
records = append(records, record) |
||||
|
} |
||||
|
|
||||
|
return records, rows.Err() |
||||
|
} |
||||
|
|
||||
|
func (s *SQLiteStore) ListUsers() ([]UserRecord, error) { |
||||
|
rows, err := s.db.Query(` |
||||
|
SELECT id, username, group_name, enabled, created_at, updated_at |
||||
|
FROM users |
||||
|
ORDER BY username COLLATE NOCASE ASC |
||||
|
`) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
defer rows.Close() |
||||
|
|
||||
|
users := make([]UserRecord, 0) |
||||
|
for rows.Next() { |
||||
|
var user UserRecord |
||||
|
if err := rows.Scan(&user.ID, &user.Username, &user.GroupName, &user.Enabled, &user.CreatedAt, &user.UpdatedAt); err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
users = append(users, user) |
||||
|
} |
||||
|
|
||||
|
return users, rows.Err() |
||||
|
} |
||||
|
|
||||
|
func (s *SQLiteStore) GetUser(id int64) (UserRecord, error) { |
||||
|
var user UserRecord |
||||
|
err := s.db.QueryRow(` |
||||
|
SELECT id, username, group_name, enabled, created_at, updated_at |
||||
|
FROM users |
||||
|
WHERE id = ? |
||||
|
`, id).Scan(&user.ID, &user.Username, &user.GroupName, &user.Enabled, &user.CreatedAt, &user.UpdatedAt) |
||||
|
return user, err |
||||
|
} |
||||
|
|
||||
|
func (s *SQLiteStore) GetUserByUsername(username string) (UserRecord, error) { |
||||
|
var user UserRecord |
||||
|
err := s.db.QueryRow(` |
||||
|
SELECT id, username, group_name, enabled, password_hash, created_at, updated_at |
||||
|
FROM users |
||||
|
WHERE username = ? |
||||
|
`, strings.TrimSpace(username)).Scan( |
||||
|
&user.ID, |
||||
|
&user.Username, |
||||
|
&user.GroupName, |
||||
|
&user.Enabled, |
||||
|
&user.PasswordHash, |
||||
|
&user.CreatedAt, |
||||
|
&user.UpdatedAt, |
||||
|
) |
||||
|
return user, err |
||||
|
} |
||||
|
|
||||
|
func (s *SQLiteStore) ListGroups() ([]GroupRecord, error) { |
||||
|
rows, err := s.db.Query(` |
||||
|
SELECT id, name, description, permissions, created_at, updated_at |
||||
|
FROM groups |
||||
|
ORDER BY name COLLATE NOCASE ASC |
||||
|
`) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
defer rows.Close() |
||||
|
|
||||
|
groups := make([]GroupRecord, 0) |
||||
|
for rows.Next() { |
||||
|
var group GroupRecord |
||||
|
var permissions string |
||||
|
if err := rows.Scan(&group.ID, &group.Name, &group.Description, &permissions, &group.CreatedAt, &group.UpdatedAt); err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
group.Permissions = decodePermissions(permissions) |
||||
|
groups = append(groups, group) |
||||
|
} |
||||
|
|
||||
|
return groups, rows.Err() |
||||
|
} |
||||
|
|
||||
|
func (s *SQLiteStore) GetGroup(id int64) (GroupRecord, error) { |
||||
|
var group GroupRecord |
||||
|
var permissions string |
||||
|
err := s.db.QueryRow(` |
||||
|
SELECT id, name, description, permissions, created_at, updated_at |
||||
|
FROM groups |
||||
|
WHERE id = ? |
||||
|
`, id).Scan(&group.ID, &group.Name, &group.Description, &permissions, &group.CreatedAt, &group.UpdatedAt) |
||||
|
if err != nil { |
||||
|
return GroupRecord{}, err |
||||
|
} |
||||
|
group.Permissions = decodePermissions(permissions) |
||||
|
return group, nil |
||||
|
} |
||||
|
|
||||
|
func (s *SQLiteStore) GetGroupByName(name string) (GroupRecord, error) { |
||||
|
var group GroupRecord |
||||
|
var permissions string |
||||
|
err := s.db.QueryRow(` |
||||
|
SELECT id, name, description, permissions, created_at, updated_at |
||||
|
FROM groups |
||||
|
WHERE name = ? |
||||
|
`, strings.TrimSpace(name)).Scan(&group.ID, &group.Name, &group.Description, &permissions, &group.CreatedAt, &group.UpdatedAt) |
||||
|
if err != nil { |
||||
|
return GroupRecord{}, err |
||||
|
} |
||||
|
group.Permissions = decodePermissions(permissions) |
||||
|
return group, nil |
||||
|
} |
||||
|
|
||||
|
func (s *SQLiteStore) CreateGroup(params CreateGroupParams) (GroupRecord, error) { |
||||
|
result, err := s.db.Exec(` |
||||
|
INSERT INTO groups (name, description, permissions) |
||||
|
VALUES (?, ?, ?) |
||||
|
`, strings.TrimSpace(params.Name), strings.TrimSpace(params.Description), encodePermissions(params.Permissions)) |
||||
|
if err != nil { |
||||
|
return GroupRecord{}, err |
||||
|
} |
||||
|
|
||||
|
id, err := result.LastInsertId() |
||||
|
if err != nil { |
||||
|
return GroupRecord{}, err |
||||
|
} |
||||
|
|
||||
|
return s.GetGroup(id) |
||||
|
} |
||||
|
|
||||
|
func (s *SQLiteStore) UpdateGroup(params UpdateGroupParams) (GroupRecord, error) { |
||||
|
current, err := s.GetGroup(params.ID) |
||||
|
if err != nil { |
||||
|
return GroupRecord{}, err |
||||
|
} |
||||
|
|
||||
|
tx, err := s.db.Begin() |
||||
|
if err != nil { |
||||
|
return GroupRecord{}, err |
||||
|
} |
||||
|
|
||||
|
if _, err := tx.Exec(` |
||||
|
UPDATE groups |
||||
|
SET name = ?, description = ?, permissions = ?, updated_at = CURRENT_TIMESTAMP |
||||
|
WHERE id = ? |
||||
|
`, strings.TrimSpace(params.Name), strings.TrimSpace(params.Description), encodePermissions(params.Permissions), params.ID); err != nil { |
||||
|
_ = tx.Rollback() |
||||
|
return GroupRecord{}, err |
||||
|
} |
||||
|
|
||||
|
if current.Name != strings.TrimSpace(params.Name) { |
||||
|
if _, err := tx.Exec(` |
||||
|
UPDATE users |
||||
|
SET group_name = ?, updated_at = CURRENT_TIMESTAMP |
||||
|
WHERE group_name = ? |
||||
|
`, strings.TrimSpace(params.Name), current.Name); err != nil { |
||||
|
_ = tx.Rollback() |
||||
|
return GroupRecord{}, err |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if err := tx.Commit(); err != nil { |
||||
|
return GroupRecord{}, err |
||||
|
} |
||||
|
|
||||
|
return s.GetGroup(params.ID) |
||||
|
} |
||||
|
|
||||
|
func (s *SQLiteStore) DeleteGroup(id int64) error { |
||||
|
group, err := s.GetGroup(id) |
||||
|
if err != nil { |
||||
|
return err |
||||
|
} |
||||
|
|
||||
|
var userCount int |
||||
|
if err := s.db.QueryRow(` |
||||
|
SELECT COUNT(*) |
||||
|
FROM users |
||||
|
WHERE group_name = ? |
||||
|
`, group.Name).Scan(&userCount); err != nil { |
||||
|
return err |
||||
|
} |
||||
|
if userCount > 0 { |
||||
|
return ErrDependencyExists |
||||
|
} |
||||
|
|
||||
|
result, err := s.db.Exec(`DELETE FROM groups WHERE id = ?`, id) |
||||
|
if err != nil { |
||||
|
return err |
||||
|
} |
||||
|
rows, err := result.RowsAffected() |
||||
|
if err != nil { |
||||
|
return err |
||||
|
} |
||||
|
if rows == 0 { |
||||
|
return sql.ErrNoRows |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (s *SQLiteStore) CreateUser(params CreateUserParams) (UserRecord, error) { |
||||
|
result, err := s.db.Exec(` |
||||
|
INSERT INTO users (username, password_hash, group_name, enabled) |
||||
|
VALUES (?, ?, ?, ?) |
||||
|
`, strings.TrimSpace(params.Username), hashPassword(params.Password), strings.TrimSpace(params.GroupName), params.Enabled) |
||||
|
if err != nil { |
||||
|
return UserRecord{}, err |
||||
|
} |
||||
|
|
||||
|
id, err := result.LastInsertId() |
||||
|
if err != nil { |
||||
|
return UserRecord{}, err |
||||
|
} |
||||
|
|
||||
|
return s.GetUser(id) |
||||
|
} |
||||
|
|
||||
|
func (s *SQLiteStore) UpdateUser(params UpdateUserParams) (UserRecord, error) { |
||||
|
if strings.TrimSpace(params.Password) == "" { |
||||
|
result, err := s.db.Exec(` |
||||
|
UPDATE users |
||||
|
SET username = ?, group_name = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP |
||||
|
WHERE id = ? |
||||
|
`, strings.TrimSpace(params.Username), strings.TrimSpace(params.GroupName), params.Enabled, params.ID) |
||||
|
if err != nil { |
||||
|
return UserRecord{}, err |
||||
|
} |
||||
|
rows, err := result.RowsAffected() |
||||
|
if err != nil { |
||||
|
return UserRecord{}, err |
||||
|
} |
||||
|
if rows == 0 { |
||||
|
return UserRecord{}, sql.ErrNoRows |
||||
|
} |
||||
|
return s.GetUser(params.ID) |
||||
|
} |
||||
|
|
||||
|
result, err := s.db.Exec(` |
||||
|
UPDATE users |
||||
|
SET username = ?, password_hash = ?, group_name = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP |
||||
|
WHERE id = ? |
||||
|
`, strings.TrimSpace(params.Username), hashPassword(params.Password), strings.TrimSpace(params.GroupName), params.Enabled, params.ID) |
||||
|
if err != nil { |
||||
|
return UserRecord{}, err |
||||
|
} |
||||
|
rows, err := result.RowsAffected() |
||||
|
if err != nil { |
||||
|
return UserRecord{}, err |
||||
|
} |
||||
|
if rows == 0 { |
||||
|
return UserRecord{}, sql.ErrNoRows |
||||
|
} |
||||
|
return s.GetUser(params.ID) |
||||
|
} |
||||
|
|
||||
|
func (s *SQLiteStore) DeleteUser(id int64) error { |
||||
|
result, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, id) |
||||
|
if err != nil { |
||||
|
return err |
||||
|
} |
||||
|
|
||||
|
rows, err := result.RowsAffected() |
||||
|
if err != nil { |
||||
|
return err |
||||
|
} |
||||
|
if rows == 0 { |
||||
|
return sql.ErrNoRows |
||||
|
} |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
func (s *SQLiteStore) init() error { |
||||
|
_, err := s.db.Exec(` |
||||
|
PRAGMA journal_mode = WAL; |
||||
|
|
||||
|
CREATE TABLE IF NOT EXISTS activity_logs ( |
||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, |
||||
|
kind TEXT NOT NULL, |
||||
|
path TEXT NOT NULL DEFAULT '', |
||||
|
title TEXT NOT NULL DEFAULT '', |
||||
|
action TEXT NOT NULL DEFAULT '', |
||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP |
||||
|
); |
||||
|
|
||||
|
CREATE TABLE IF NOT EXISTS users ( |
||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, |
||||
|
username TEXT NOT NULL UNIQUE, |
||||
|
password_hash TEXT NOT NULL, |
||||
|
group_name TEXT NOT NULL, |
||||
|
enabled INTEGER NOT NULL DEFAULT 1, |
||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP |
||||
|
); |
||||
|
|
||||
|
CREATE TABLE IF NOT EXISTS groups ( |
||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT, |
||||
|
name TEXT NOT NULL UNIQUE, |
||||
|
description TEXT NOT NULL DEFAULT '', |
||||
|
permissions TEXT NOT NULL DEFAULT '[]', |
||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP |
||||
|
); |
||||
|
`) |
||||
|
if err != nil { |
||||
|
return err |
||||
|
} |
||||
|
|
||||
|
if err := s.ensureDefaultGroups(); err != nil { |
||||
|
return err |
||||
|
} |
||||
|
return s.ensureDefaultUsers() |
||||
|
} |
||||
|
|
||||
|
func (s *SQLiteStore) ensureDefaultGroups() error { |
||||
|
defaultGroups := []CreateGroupParams{ |
||||
|
{Name: "administrator", Description: "전체 접근 가능", Permissions: defaultPermissionKeys()}, |
||||
|
{Name: "operator", Description: "운영 담당 권한 그룹", Permissions: []string{"network", "ports", "admin", "common", "status", "password"}}, |
||||
|
{Name: "guest", Description: "조회 전용", Permissions: []string{"status"}}, |
||||
|
} |
||||
|
|
||||
|
tx, err := s.db.Begin() |
||||
|
if err != nil { |
||||
|
return err |
||||
|
} |
||||
|
|
||||
|
for _, group := range defaultGroups { |
||||
|
if _, err := tx.Exec(` |
||||
|
INSERT OR IGNORE INTO groups (name, description, permissions) |
||||
|
VALUES (?, ?, ?) |
||||
|
`, group.Name, group.Description, encodePermissions(group.Permissions)); err != nil { |
||||
|
_ = tx.Rollback() |
||||
|
return err |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return tx.Commit() |
||||
|
} |
||||
|
|
||||
|
func (s *SQLiteStore) ensureDefaultUsers() error { |
||||
|
defaultUsers := []CreateUserParams{ |
||||
|
{Username: "terauser", Password: "terauser", GroupName: "administrator", Enabled: true}, |
||||
|
{Username: "guest", Password: "guest", GroupName: "guest", Enabled: true}, |
||||
|
{Username: "admin", Password: "admin", GroupName: "administrator", Enabled: true}, |
||||
|
} |
||||
|
|
||||
|
tx, err := s.db.Begin() |
||||
|
if err != nil { |
||||
|
return err |
||||
|
} |
||||
|
|
||||
|
for _, user := range defaultUsers { |
||||
|
if _, err := tx.Exec(` |
||||
|
INSERT OR IGNORE INTO users (username, password_hash, group_name, enabled) |
||||
|
VALUES (?, ?, ?, ?) |
||||
|
`, user.Username, hashPassword(user.Password), user.GroupName, user.Enabled); err != nil { |
||||
|
_ = tx.Rollback() |
||||
|
return err |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return tx.Commit() |
||||
|
} |
||||
|
|
||||
|
func hashPassword(password string) string { |
||||
|
sum := sha256.Sum256([]byte(password)) |
||||
|
return hex.EncodeToString(sum[:]) |
||||
|
} |
||||
|
|
||||
|
func VerifyPassword(password string, encoded string) bool { |
||||
|
return hashPassword(password) == encoded |
||||
|
} |
||||
|
|
||||
|
func IsUniqueConstraintError(err error) bool { |
||||
|
return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique") |
||||
|
} |
||||
|
|
||||
|
func IsNotFoundError(err error) bool { |
||||
|
return errors.Is(err, sql.ErrNoRows) |
||||
|
} |
||||
|
|
||||
|
func IsDependencyError(err error) bool { |
||||
|
return errors.Is(err, ErrDependencyExists) |
||||
|
} |
||||
|
|
||||
|
func encodePermissions(permissions []string) string { |
||||
|
if len(permissions) == 0 { |
||||
|
return "[]" |
||||
|
} |
||||
|
|
||||
|
normalized := normalizePermissions(permissions) |
||||
|
payload, err := json.Marshal(normalized) |
||||
|
if err != nil { |
||||
|
return "[]" |
||||
|
} |
||||
|
return string(payload) |
||||
|
} |
||||
|
|
||||
|
func decodePermissions(raw string) []string { |
||||
|
if strings.TrimSpace(raw) == "" { |
||||
|
return nil |
||||
|
} |
||||
|
|
||||
|
var permissions []string |
||||
|
if err := json.Unmarshal([]byte(raw), &permissions); err != nil { |
||||
|
return nil |
||||
|
} |
||||
|
return normalizePermissions(permissions) |
||||
|
} |
||||
|
|
||||
|
func normalizePermissions(permissions []string) []string { |
||||
|
seen := make(map[string]struct{}) |
||||
|
result := make([]string, 0, len(permissions)) |
||||
|
for _, permission := range permissions { |
||||
|
permission = strings.TrimSpace(permission) |
||||
|
if permission == "" { |
||||
|
continue |
||||
|
} |
||||
|
if _, ok := seen[permission]; ok { |
||||
|
continue |
||||
|
} |
||||
|
seen[permission] = struct{}{} |
||||
|
result = append(result, permission) |
||||
|
} |
||||
|
sort.Strings(result) |
||||
|
return result |
||||
|
} |
||||
|
|
||||
|
func defaultPermissionKeys() []string { |
||||
|
return []string{"admin", "common", "login", "network", "operate", "password", "ports", "status", "tools"} |
||||
|
} |
||||
@ -0,0 +1,255 @@ |
|||||
|
param( |
||||
|
[ValidateSet("start", "stop", "restart", "status", "logs")] |
||||
|
[string]$Action = "status", |
||||
|
[int]$Port = 8080 |
||||
|
) |
||||
|
|
||||
|
$ScriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path |
||||
|
$ProjectRoot = Split-Path -Parent $ScriptRoot |
||||
|
$RuntimeRoot = Join-Path $ProjectRoot "runtime" |
||||
|
$LogRoot = Join-Path $RuntimeRoot "logs" |
||||
|
$RunRoot = Join-Path $RuntimeRoot "run" |
||||
|
$BinRoot = Join-Path $RuntimeRoot "bin" |
||||
|
$CacheRoot = Join-Path $RuntimeRoot "cache" |
||||
|
|
||||
|
$PidFile = Join-Path $RunRoot "teraclone.pid" |
||||
|
$StdOutLogFile = Join-Path $LogRoot "server.out.log" |
||||
|
$StdErrLogFile = Join-Path $LogRoot "server.err.log" |
||||
|
$DevExe = Join-Path $BinRoot "teraclone-dev.exe" |
||||
|
$GoCache = Join-Path $CacheRoot "go-build" |
||||
|
$GoTmp = Join-Path $CacheRoot "go-tmp" |
||||
|
$Port = [string]$Port |
||||
|
$StdOutEventSource = "teraclone.stdout.$Port" |
||||
|
$StdErrEventSource = "teraclone.stderr.$Port" |
||||
|
|
||||
|
function Ensure-RuntimeLayout { |
||||
|
New-Item -ItemType Directory -Force -Path $LogRoot | Out-Null |
||||
|
New-Item -ItemType Directory -Force -Path $RunRoot | Out-Null |
||||
|
New-Item -ItemType Directory -Force -Path $BinRoot | Out-Null |
||||
|
New-Item -ItemType Directory -Force -Path $GoCache | Out-Null |
||||
|
New-Item -ItemType Directory -Force -Path $GoTmp | Out-Null |
||||
|
} |
||||
|
|
||||
|
function Get-PortOwnerPid { |
||||
|
$netstatLines = netstat -ano -p TCP | Select-String ":$Port\s+.*LISTENING\s+(\d+)$" |
||||
|
if ($null -eq $netstatLines) { |
||||
|
return $null |
||||
|
} |
||||
|
|
||||
|
foreach ($line in $netstatLines) { |
||||
|
if ($line.Matches.Count -gt 0) { |
||||
|
return [int]$line.Matches[0].Groups[1].Value |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return $null |
||||
|
} |
||||
|
|
||||
|
function Get-PortOwnerProcess { |
||||
|
$ownerPid = Get-PortOwnerPid |
||||
|
if ($null -eq $ownerPid) { |
||||
|
return $null |
||||
|
} |
||||
|
|
||||
|
return Get-Process -Id $ownerPid -ErrorAction SilentlyContinue |
||||
|
} |
||||
|
|
||||
|
function Get-ServerProcess { |
||||
|
if (-not (Test-Path $PidFile)) { |
||||
|
return $null |
||||
|
} |
||||
|
|
||||
|
$pidLine = Get-Content $PidFile -ErrorAction SilentlyContinue | Select-Object -First 1 |
||||
|
if ($null -eq $pidLine) { |
||||
|
Remove-Item $PidFile -ErrorAction SilentlyContinue |
||||
|
return $null |
||||
|
} |
||||
|
|
||||
|
$pidValue = $pidLine.Trim() |
||||
|
if ([string]::IsNullOrWhiteSpace($pidValue)) { |
||||
|
Remove-Item $PidFile -ErrorAction SilentlyContinue |
||||
|
return $null |
||||
|
} |
||||
|
|
||||
|
$process = Get-Process -Id $pidValue -ErrorAction SilentlyContinue |
||||
|
if ($null -eq $process) { |
||||
|
Remove-Item $PidFile -ErrorAction SilentlyContinue |
||||
|
return $null |
||||
|
} |
||||
|
|
||||
|
return $process |
||||
|
} |
||||
|
|
||||
|
function Clear-LogEventSubscriptions { |
||||
|
foreach ($source in @($StdOutEventSource, $StdErrEventSource)) { |
||||
|
Unregister-Event -SourceIdentifier $source -ErrorAction SilentlyContinue |
||||
|
|
||||
|
Get-Job -ErrorAction SilentlyContinue | |
||||
|
Where-Object { $_.Name -eq $source } | |
||||
|
Remove-Job -Force -ErrorAction SilentlyContinue |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function Start-Server { |
||||
|
Ensure-RuntimeLayout |
||||
|
Clear-LogEventSubscriptions |
||||
|
|
||||
|
$existing = Get-ServerProcess |
||||
|
if ($null -ne $existing) { |
||||
|
Write-Host "Server already running. PID: $($existing.Id)" |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
$portOwner = Get-PortOwnerProcess |
||||
|
if ($null -ne $portOwner) { |
||||
|
Write-Host "Port $Port is already in use by PID $($portOwner.Id) ($($portOwner.ProcessName))." |
||||
|
Write-Host "Run '.\\scripts\\dev.ps1 stop' first, or free the port manually." |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
if (Test-Path $StdOutLogFile) { |
||||
|
Remove-Item $StdOutLogFile -ErrorAction SilentlyContinue |
||||
|
} |
||||
|
|
||||
|
if (Test-Path $StdErrLogFile) { |
||||
|
Remove-Item $StdErrLogFile -ErrorAction SilentlyContinue |
||||
|
} |
||||
|
|
||||
|
if (Test-Path $DevExe) { |
||||
|
Remove-Item $DevExe -ErrorAction SilentlyContinue |
||||
|
} |
||||
|
|
||||
|
$env:GOCACHE = $GoCache |
||||
|
$env:GOTMPDIR = $GoTmp |
||||
|
|
||||
|
& go build -buildvcs=false -o $DevExe ./cmd/server |
||||
|
if ($LASTEXITCODE -ne 0 -or -not (Test-Path $DevExe)) { |
||||
|
Write-Host "Build failed." |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
$startInfo = New-Object System.Diagnostics.ProcessStartInfo |
||||
|
$startInfo.FileName = $DevExe |
||||
|
$startInfo.WorkingDirectory = $ProjectRoot |
||||
|
$startInfo.UseShellExecute = $false |
||||
|
$startInfo.CreateNoWindow = $true |
||||
|
$startInfo.RedirectStandardOutput = $true |
||||
|
$startInfo.RedirectStandardError = $true |
||||
|
$startInfo.EnvironmentVariables["PORT"] = $Port |
||||
|
|
||||
|
$process = New-Object System.Diagnostics.Process |
||||
|
$process.StartInfo = $startInfo |
||||
|
|
||||
|
if (-not $process.Start()) { |
||||
|
Write-Host "Server failed to start." |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
Register-ObjectEvent -InputObject $process -EventName OutputDataReceived -SourceIdentifier $StdOutEventSource -Action { |
||||
|
if ($EventArgs.Data -ne $null) { |
||||
|
[System.IO.File]::AppendAllText($Event.MessageData.Path, $EventArgs.Data + [Environment]::NewLine) |
||||
|
} |
||||
|
} -MessageData @{ Path = $StdOutLogFile } | Out-Null |
||||
|
|
||||
|
Register-ObjectEvent -InputObject $process -EventName ErrorDataReceived -SourceIdentifier $StdErrEventSource -Action { |
||||
|
if ($EventArgs.Data -ne $null) { |
||||
|
[System.IO.File]::AppendAllText($Event.MessageData.Path, $EventArgs.Data + [Environment]::NewLine) |
||||
|
} |
||||
|
} -MessageData @{ Path = $StdErrLogFile } | Out-Null |
||||
|
|
||||
|
$process.BeginOutputReadLine() |
||||
|
$process.BeginErrorReadLine() |
||||
|
|
||||
|
Set-Content -Path $PidFile -Value $process.Id |
||||
|
Start-Sleep -Seconds 2 |
||||
|
|
||||
|
$running = Get-ServerProcess |
||||
|
$portOwnerAfterStart = Get-PortOwnerProcess |
||||
|
if ($null -eq $running -or $null -eq $portOwnerAfterStart) { |
||||
|
Write-Host "Server failed to start." |
||||
|
if (Test-Path $StdOutLogFile) { |
||||
|
Get-Content $StdOutLogFile |
||||
|
} |
||||
|
if (Test-Path $StdErrLogFile) { |
||||
|
Get-Content $StdErrLogFile |
||||
|
} |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
Write-Host "Server started." |
||||
|
Write-Host "PID: $($portOwnerAfterStart.Id)" |
||||
|
Write-Host "URL: http://localhost:$Port" |
||||
|
} |
||||
|
|
||||
|
function Stop-Server { |
||||
|
Clear-LogEventSubscriptions |
||||
|
|
||||
|
$existing = Get-ServerProcess |
||||
|
if ($null -ne $existing) { |
||||
|
Stop-Process -Id $existing.Id -Force -ErrorAction SilentlyContinue |
||||
|
Remove-Item $PidFile -ErrorAction SilentlyContinue |
||||
|
Start-Sleep -Milliseconds 500 |
||||
|
} |
||||
|
|
||||
|
$portOwner = Get-PortOwnerProcess |
||||
|
if ($null -ne $portOwner) { |
||||
|
Stop-Process -Id $portOwner.Id -Force -ErrorAction SilentlyContinue |
||||
|
if (Test-Path $DevExe) { |
||||
|
Remove-Item $DevExe -ErrorAction SilentlyContinue |
||||
|
} |
||||
|
Write-Host "Server stopped. PID: $($portOwner.Id)" |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
if (Test-Path $DevExe) { |
||||
|
Remove-Item $DevExe -ErrorAction SilentlyContinue |
||||
|
} |
||||
|
|
||||
|
Write-Host "Server is not running." |
||||
|
} |
||||
|
|
||||
|
function Restart-Server { |
||||
|
Stop-Server |
||||
|
Start-Server |
||||
|
} |
||||
|
|
||||
|
function Show-Status { |
||||
|
$portOwner = Get-PortOwnerProcess |
||||
|
if ($null -eq $portOwner) { |
||||
|
Write-Host "Server status: stopped" |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
Write-Host "Server status: running" |
||||
|
Write-Host "PID: $($portOwner.Id)" |
||||
|
Write-Host "Process: $($portOwner.ProcessName)" |
||||
|
Write-Host "URL: http://localhost:$Port" |
||||
|
} |
||||
|
|
||||
|
function Show-Logs { |
||||
|
$hasStdOut = Test-Path $StdOutLogFile |
||||
|
$hasStdErr = Test-Path $StdErrLogFile |
||||
|
|
||||
|
if (-not $hasStdOut -and -not $hasStdErr) { |
||||
|
Write-Host "No log file yet." |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
if ($hasStdOut) { |
||||
|
Write-Host "[stdout]" |
||||
|
Get-Content $StdOutLogFile |
||||
|
} |
||||
|
|
||||
|
if ($hasStdErr) { |
||||
|
Write-Host "[stderr]" |
||||
|
Get-Content $StdErrLogFile |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
switch ($Action) { |
||||
|
"start" { Start-Server } |
||||
|
"stop" { Stop-Server } |
||||
|
"restart" { Restart-Server } |
||||
|
"status" { Show-Status } |
||||
|
"logs" { Show-Logs } |
||||
|
} |
||||
@ -0,0 +1,89 @@ |
|||||
|
param( |
||||
|
[ValidateSet("loadtest", "find-max-users")] |
||||
|
[string]$Mode = "loadtest", |
||||
|
[string]$Target = "http://localhost:8080", |
||||
|
[int]$Users = 200, |
||||
|
[string]$Duration = "30s", |
||||
|
[string]$RampUp = "10s", |
||||
|
[string]$Timeout = "5s", |
||||
|
[string]$WebSocketTimeout = "15s", |
||||
|
[double]$WebSocketRatio = 0.05, |
||||
|
[int]$WebSocketMaxConnections = 20, |
||||
|
[string]$MonitorInterval = "1s", |
||||
|
[string]$WebSocketCommand = "Get-Location", |
||||
|
[int]$SearchStartUsers = 100, |
||||
|
[int]$SearchStepUsers = 100, |
||||
|
[int]$SearchMaxUsers = 2000, |
||||
|
[double]$MaxFailureRate = 1.0, |
||||
|
[string]$MaxP95 = "1s", |
||||
|
[switch]$Insecure |
||||
|
) |
||||
|
|
||||
|
$ScriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path |
||||
|
$ProjectRoot = Split-Path -Parent $ScriptRoot |
||||
|
$RuntimeRoot = Join-Path $ProjectRoot "runtime" |
||||
|
$BinRoot = Join-Path $RuntimeRoot "bin" |
||||
|
$CacheRoot = Join-Path $RuntimeRoot "cache" |
||||
|
$GoCache = Join-Path $CacheRoot "go-build" |
||||
|
$GoTmp = Join-Path $CacheRoot "go-tmp" |
||||
|
$LoadTestExe = Join-Path $BinRoot "teraclone-loadtest.exe" |
||||
|
|
||||
|
function Find-GoExecutable { |
||||
|
$candidates = @( |
||||
|
(Join-Path $env:ProgramFiles "Go\bin\go.exe"), |
||||
|
(Join-Path ${env:ProgramFiles(x86)} "Go\bin\go.exe"), |
||||
|
(Join-Path $env:USERPROFILE "go\bin\go.exe") |
||||
|
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } |
||||
|
|
||||
|
foreach ($candidate in $candidates) { |
||||
|
if (Test-Path $candidate) { |
||||
|
return $candidate |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
$fromPath = Get-Command go.exe -ErrorAction SilentlyContinue |
||||
|
if ($null -ne $fromPath) { |
||||
|
return $fromPath.Source |
||||
|
} |
||||
|
|
||||
|
throw "go.exe를 찾을 수 없습니다. Go를 설치하거나 PATH에 추가해주세요." |
||||
|
} |
||||
|
|
||||
|
New-Item -ItemType Directory -Force -Path $BinRoot | Out-Null |
||||
|
New-Item -ItemType Directory -Force -Path $GoCache | Out-Null |
||||
|
New-Item -ItemType Directory -Force -Path $GoTmp | Out-Null |
||||
|
|
||||
|
$env:GOCACHE = $GoCache |
||||
|
$env:GOTMPDIR = $GoTmp |
||||
|
$GoExe = Find-GoExecutable |
||||
|
|
||||
|
& $GoExe build -buildvcs=false -o $LoadTestExe ./cmd/loadtest |
||||
|
if ($LASTEXITCODE -ne 0 -or -not (Test-Path $LoadTestExe)) { |
||||
|
Write-Host "부하 테스트 도구 빌드에 실패했습니다." |
||||
|
exit 1 |
||||
|
} |
||||
|
|
||||
|
$arguments = @( |
||||
|
"-mode", $Mode, |
||||
|
"-target", $Target, |
||||
|
"-users", $Users, |
||||
|
"-duration", $Duration, |
||||
|
"-ramp-up", $RampUp, |
||||
|
"-timeout", $Timeout, |
||||
|
"-ws-timeout", $WebSocketTimeout, |
||||
|
"-ws-ratio", $WebSocketRatio, |
||||
|
"-ws-max-conns", $WebSocketMaxConnections, |
||||
|
"-monitor-interval", $MonitorInterval, |
||||
|
"-ws-command", $WebSocketCommand, |
||||
|
"-search-start-users", $SearchStartUsers, |
||||
|
"-search-step-users", $SearchStepUsers, |
||||
|
"-search-max-users", $SearchMaxUsers, |
||||
|
"-max-failure-rate", $MaxFailureRate, |
||||
|
"-max-p95", $MaxP95 |
||||
|
) |
||||
|
|
||||
|
if ($Insecure) { |
||||
|
$arguments += "-insecure" |
||||
|
} |
||||
|
|
||||
|
& $LoadTestExe @arguments |
||||
@ -0,0 +1,84 @@ |
|||||
|
#!/usr/bin/env bash |
||||
|
set -euo pipefail |
||||
|
|
||||
|
MODE="${MODE:-loadtest}" |
||||
|
TARGET="${TARGET:-http://localhost:8080}" |
||||
|
USERS="${USERS:-200}" |
||||
|
DURATION="${DURATION:-30s}" |
||||
|
RAMP_UP="${RAMP_UP:-10s}" |
||||
|
TIMEOUT="${TIMEOUT:-5s}" |
||||
|
WEBSOCKET_TIMEOUT="${WEBSOCKET_TIMEOUT:-15s}" |
||||
|
WEBSOCKET_RATIO="${WEBSOCKET_RATIO:-0.05}" |
||||
|
WEBSOCKET_MAX_CONNECTIONS="${WEBSOCKET_MAX_CONNECTIONS:-20}" |
||||
|
MONITOR_INTERVAL="${MONITOR_INTERVAL:-1s}" |
||||
|
WEBSOCKET_COMMAND="${WEBSOCKET_COMMAND:-Get-Location}" |
||||
|
SEARCH_START_USERS="${SEARCH_START_USERS:-100}" |
||||
|
SEARCH_STEP_USERS="${SEARCH_STEP_USERS:-100}" |
||||
|
SEARCH_MAX_USERS="${SEARCH_MAX_USERS:-2000}" |
||||
|
MAX_FAILURE_RATE="${MAX_FAILURE_RATE:-1.0}" |
||||
|
MAX_P95="${MAX_P95:-1s}" |
||||
|
INSECURE="${INSECURE:-false}" |
||||
|
|
||||
|
SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
||||
|
PROJECT_ROOT="$(cd "${SCRIPT_ROOT}/.." && pwd)" |
||||
|
RUNTIME_ROOT="${PROJECT_ROOT}/runtime" |
||||
|
BIN_ROOT="${RUNTIME_ROOT}/bin" |
||||
|
CACHE_ROOT="${RUNTIME_ROOT}/cache" |
||||
|
GO_CACHE="${CACHE_ROOT}/go-build" |
||||
|
GO_TMP="${CACHE_ROOT}/go-tmp" |
||||
|
LOADTEST_BIN="${BIN_ROOT}/teraclone-loadtest" |
||||
|
|
||||
|
mkdir -p "${BIN_ROOT}" "${GO_CACHE}" "${GO_TMP}" |
||||
|
|
||||
|
find_go_executable() { |
||||
|
if command -v go >/dev/null 2>&1; then |
||||
|
command -v go |
||||
|
return |
||||
|
fi |
||||
|
|
||||
|
local candidates=( |
||||
|
"/usr/local/go/bin/go" |
||||
|
"${HOME}/go/bin/go" |
||||
|
) |
||||
|
|
||||
|
for candidate in "${candidates[@]}"; do |
||||
|
if [[ -x "${candidate}" ]]; then |
||||
|
echo "${candidate}" |
||||
|
return |
||||
|
fi |
||||
|
done |
||||
|
|
||||
|
echo "go 실행 파일을 찾을 수 없습니다. Go를 설치하거나 PATH에 추가해주세요." >&2 |
||||
|
exit 1 |
||||
|
} |
||||
|
|
||||
|
GO_EXE="$(find_go_executable)" |
||||
|
export GOCACHE="${GO_CACHE}" |
||||
|
export GOTMPDIR="${GO_TMP}" |
||||
|
|
||||
|
"${GO_EXE}" build -o "${LOADTEST_BIN}" ./cmd/loadtest |
||||
|
|
||||
|
ARGS=( |
||||
|
"-mode" "${MODE}" |
||||
|
"-target" "${TARGET}" |
||||
|
"-users" "${USERS}" |
||||
|
"-duration" "${DURATION}" |
||||
|
"-ramp-up" "${RAMP_UP}" |
||||
|
"-timeout" "${TIMEOUT}" |
||||
|
"-ws-timeout" "${WEBSOCKET_TIMEOUT}" |
||||
|
"-ws-ratio" "${WEBSOCKET_RATIO}" |
||||
|
"-ws-max-conns" "${WEBSOCKET_MAX_CONNECTIONS}" |
||||
|
"-monitor-interval" "${MONITOR_INTERVAL}" |
||||
|
"-ws-command" "${WEBSOCKET_COMMAND}" |
||||
|
"-search-start-users" "${SEARCH_START_USERS}" |
||||
|
"-search-step-users" "${SEARCH_STEP_USERS}" |
||||
|
"-search-max-users" "${SEARCH_MAX_USERS}" |
||||
|
"-max-failure-rate" "${MAX_FAILURE_RATE}" |
||||
|
"-max-p95" "${MAX_P95}" |
||||
|
) |
||||
|
|
||||
|
if [[ "${INSECURE}" == "true" ]]; then |
||||
|
ARGS+=("-insecure") |
||||
|
fi |
||||
|
|
||||
|
"${LOADTEST_BIN}" "${ARGS[@]}" |
||||
@ -0,0 +1,585 @@ |
|||||
|
:root { |
||||
|
--bg: #eef1f6; |
||||
|
--surface: #ffffff; |
||||
|
--surface-muted: #f5f7fb; |
||||
|
--line: #dfe5ee; |
||||
|
--text: #5d6a7d; |
||||
|
--heading: #33445b; |
||||
|
--sidebar: #31435f; |
||||
|
--sidebar-deep: #25354c; |
||||
|
--sidebar-active: #1f90ff; |
||||
|
--accent: #4ea0ff; |
||||
|
--accent-strong: #3f97f7; |
||||
|
--danger: #ff5e5e; |
||||
|
--success: #3e9b61; |
||||
|
} |
||||
|
|
||||
|
* { box-sizing: border-box; } |
||||
|
html, body { margin: 0; padding: 0; font-family: "Segoe UI", "Malgun Gothic", sans-serif; background: var(--bg); color: var(--text); } |
||||
|
a { text-decoration: none; color: inherit; } |
||||
|
button, input, select, textarea { font: inherit; } |
||||
|
|
||||
|
.login-body { |
||||
|
min-height: 100vh; |
||||
|
background: |
||||
|
radial-gradient(circle at top left, rgba(78,160,255,0.22), transparent 30%), |
||||
|
linear-gradient(180deg, #1f2f45 0%, #2f4260 100%); |
||||
|
} |
||||
|
|
||||
|
.login-shell { |
||||
|
min-height: 100vh; |
||||
|
display: grid; |
||||
|
place-items: center; |
||||
|
padding: 24px; |
||||
|
} |
||||
|
|
||||
|
.login-panel { |
||||
|
width: min(440px, 100%); |
||||
|
background: rgba(255,255,255,0.97); |
||||
|
border: 1px solid rgba(223,229,238,0.9); |
||||
|
border-radius: 18px; |
||||
|
padding: 28px; |
||||
|
box-shadow: 0 26px 70px rgba(16, 28, 44, 0.35); |
||||
|
} |
||||
|
|
||||
|
.login-brand { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
gap: 12px; |
||||
|
margin-bottom: 22px; |
||||
|
} |
||||
|
|
||||
|
.login-copy h1 { |
||||
|
margin: 0; |
||||
|
font-size: 28px; |
||||
|
color: var(--heading); |
||||
|
} |
||||
|
|
||||
|
.login-copy p { |
||||
|
margin: 8px 0 0; |
||||
|
font-size: 14px; |
||||
|
color: #6e7d92; |
||||
|
} |
||||
|
|
||||
|
.login-form { |
||||
|
display: grid; |
||||
|
gap: 14px; |
||||
|
margin-top: 22px; |
||||
|
} |
||||
|
|
||||
|
.login-submit { |
||||
|
min-height: 40px; |
||||
|
} |
||||
|
|
||||
|
.login-error { |
||||
|
margin-top: 18px; |
||||
|
border: 1px solid #f1c6c6; |
||||
|
background: #fff1f1; |
||||
|
color: #b14e4e; |
||||
|
border-radius: 10px; |
||||
|
padding: 12px 14px; |
||||
|
font-size: 13px; |
||||
|
} |
||||
|
|
||||
|
.login-hint { |
||||
|
margin-top: 16px; |
||||
|
font-size: 13px; |
||||
|
color: #6f7d91; |
||||
|
} |
||||
|
|
||||
|
.app-shell { display: flex; min-height: 100vh; } |
||||
|
|
||||
|
.sidebar { |
||||
|
width: 200px; |
||||
|
background: linear-gradient(180deg, var(--sidebar-deep), var(--sidebar)); |
||||
|
color: #dbe4f0; |
||||
|
padding: 18px 0 24px; |
||||
|
flex: 0 0 200px; |
||||
|
} |
||||
|
|
||||
|
.brand { display: flex; align-items: center; gap: 10px; padding: 0 18px 16px; border-bottom: 1px solid rgba(255,255,255,0.08); } |
||||
|
.brand-mark { width: 34px; height: 34px; border-radius: 999px; background: rgba(255,255,255,0.12); display: flex; align-items: center; justify-content: center; font-weight: 700; color: #fff; } |
||||
|
.brand-title { color: #fff; font-size: 20px; font-weight: 700; line-height: 1; } |
||||
|
.brand-subtitle { margin-top: 4px; font-size: 11px; opacity: 0.7; } |
||||
|
|
||||
|
.nav { padding-top: 10px; } |
||||
|
.nav-group { margin-bottom: 2px; } |
||||
|
.nav-item, .nav-child { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
min-height: 42px; |
||||
|
padding: 0 18px; |
||||
|
color: #dbe4f0; |
||||
|
} |
||||
|
.nav-item { justify-content: space-between; } |
||||
|
.nav-item.active, .nav-child.active { background: rgba(78,160,255,0.18); color: #58a5ff; font-weight: 600; } |
||||
|
.nav-label { font-size: 14px; } |
||||
|
.nav-caret { |
||||
|
width: 8px; |
||||
|
height: 8px; |
||||
|
flex: 0 0 auto; |
||||
|
border-right: 1.5px solid currentColor; |
||||
|
border-bottom: 1.5px solid currentColor; |
||||
|
transform: rotate(45deg); |
||||
|
transition: transform 0.18s ease; |
||||
|
} |
||||
|
.nav-children { display: none; padding-bottom: 6px; } |
||||
|
.nav-group.expanded .nav-children { display: block; } |
||||
|
.nav-group.expanded .nav-caret { transform: rotate(225deg); } |
||||
|
.nav-child { padding-left: 32px; font-size: 13px; min-height: 40px; } |
||||
|
|
||||
|
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; } |
||||
|
|
||||
|
.topbar { |
||||
|
height: 56px; |
||||
|
background: #243349; |
||||
|
color: #fff; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
padding: 0 16px; |
||||
|
} |
||||
|
.topbar-left, .topbar-right { display: flex; align-items: center; gap: 12px; } |
||||
|
.menu-toggle { display: none; border: 0; background: transparent; color: #fff; font-size: 20px; } |
||||
|
.topbar-crumbs { font-size: 13px; opacity: 0.92; } |
||||
|
.crumb-sep { margin: 0 6px; opacity: 0.55; } |
||||
|
.legacy-link { font-size: 12px; padding: 6px 10px; border-radius: 4px; background: rgba(255,255,255,0.1); } |
||||
|
.user-pill { font-size: 14px; } |
||||
|
.theme-pill { border: 0; background: var(--accent-strong); color: #fff; border-radius: 3px; padding: 6px 12px; font-size: 12px; } |
||||
|
.logout-form { margin: 0; } |
||||
|
.logout-button { |
||||
|
border: 1px solid rgba(255,255,255,0.18); |
||||
|
background: rgba(255,255,255,0.06); |
||||
|
color: #fff; |
||||
|
border-radius: 4px; |
||||
|
padding: 6px 10px; |
||||
|
font-size: 12px; |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
|
||||
|
.workspace-tabs { |
||||
|
display: flex; |
||||
|
gap: 2px; |
||||
|
overflow-x: auto; |
||||
|
padding: 6px 8px 0; |
||||
|
background: #f5f6f8; |
||||
|
border-bottom: 1px solid #e0e4eb; |
||||
|
} |
||||
|
.workspace-tab { |
||||
|
display: inline-flex; |
||||
|
align-items: center; |
||||
|
gap: 8px; |
||||
|
padding: 6px 10px; |
||||
|
background: #fff; |
||||
|
border: 1px solid #dce3eb; |
||||
|
border-bottom: 0; |
||||
|
border-radius: 4px 4px 0 0; |
||||
|
font-size: 12px; |
||||
|
color: #6d7b90; |
||||
|
white-space: nowrap; |
||||
|
} |
||||
|
.workspace-tab-link { color: inherit; } |
||||
|
.workspace-tab.active { background: var(--accent-strong); color: #fff; border-color: var(--accent-strong); } |
||||
|
.workspace-close { |
||||
|
border: 0; |
||||
|
background: transparent; |
||||
|
color: inherit; |
||||
|
opacity: 0.7; |
||||
|
cursor: pointer; |
||||
|
padding: 0; |
||||
|
line-height: 1; |
||||
|
} |
||||
|
.workspace-close:hover { opacity: 1; } |
||||
|
|
||||
|
.content { padding: 14px 8px 20px; } |
||||
|
.page-header { display: flex; align-items: center; gap: 12px; padding: 8px 10px 10px; } |
||||
|
.page-kicker { font-size: 13px; color: #7b8798; } |
||||
|
.page-header h1 { margin: 0; font-size: 20px; color: var(--heading); } |
||||
|
.mock-badge { margin-left: auto; padding: 5px 8px; background: #fff2cc; color: #8c6b00; border: 1px solid #f0d889; border-radius: 999px; font-size: 11px; font-weight: 700; } |
||||
|
|
||||
|
.subtabs { |
||||
|
display: flex; |
||||
|
gap: 20px; |
||||
|
padding: 0 22px 0 22px; |
||||
|
border-bottom: 1px solid var(--line); |
||||
|
margin: 0 10px 0; |
||||
|
} |
||||
|
.subtabs.embedded { padding: 0 0 12px; margin: 0 0 12px; } |
||||
|
.subtab { |
||||
|
position: relative; |
||||
|
padding: 12px 0; |
||||
|
font-size: 13px; |
||||
|
font-weight: 600; |
||||
|
color: #5f6e84; |
||||
|
} |
||||
|
.subtab.active { color: var(--accent-strong); } |
||||
|
.subtab.active::after { |
||||
|
content: ""; |
||||
|
position: absolute; |
||||
|
left: 0; |
||||
|
right: 0; |
||||
|
bottom: -1px; |
||||
|
height: 2px; |
||||
|
background: var(--accent-strong); |
||||
|
} |
||||
|
|
||||
|
.blocks { display: grid; gap: 14px; padding: 0 10px; } |
||||
|
.panel { |
||||
|
background: var(--surface); |
||||
|
border: 1px solid var(--line); |
||||
|
border-radius: 4px; |
||||
|
padding: 14px 22px 20px; |
||||
|
} |
||||
|
.panel-header { padding-bottom: 12px; border-bottom: 1px solid #edf1f6; margin-bottom: 14px; } |
||||
|
.panel-header h2 { margin: 0; font-size: 14px; font-weight: 700; color: #5f6e84; } |
||||
|
.panel-header p { margin: 6px 0 0; font-size: 12px; color: #8996a8; } |
||||
|
.panel-header-actions { display: flex; justify-content: space-between; align-items: center; gap: 12px; } |
||||
|
|
||||
|
.port-link-grid { |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(12, minmax(0, 1fr)); |
||||
|
gap: 8px; |
||||
|
} |
||||
|
.port-link { |
||||
|
border: 1px solid #aeb7c4; |
||||
|
background: #f8fafc; |
||||
|
border-radius: 6px; |
||||
|
padding: 8px 4px; |
||||
|
text-align: center; |
||||
|
font-size: 11px; |
||||
|
} |
||||
|
.port-link.up { border-color: #6da884; background: #edf9f1; } |
||||
|
.port-link.down { border-color: #b7bec8; background: #f5f6f8; } |
||||
|
.port-link.blink { box-shadow: 0 0 0 2px rgba(78,160,255,0.18); } |
||||
|
|
||||
|
.port-link-card { |
||||
|
border: 1px solid #aeb7c4; |
||||
|
background: #f8fafc; |
||||
|
border-radius: 8px; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
.port-link-card.up { border-color: #6da884; background: #edf9f1; } |
||||
|
.port-link-card.down { border-color: #b7bec8; background: #f5f6f8; } |
||||
|
.port-link-card.blink { box-shadow: 0 0 0 2px rgba(78,160,255,0.18); } |
||||
|
.port-link-card.active { |
||||
|
box-shadow: 0 0 0 2px rgba(63,151,247,0.32); |
||||
|
} |
||||
|
.port-link-view { |
||||
|
border: 0; |
||||
|
background: transparent; |
||||
|
padding: 8px 4px; |
||||
|
text-align: center; |
||||
|
font-size: 11px; |
||||
|
width: 100%; |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
.port-link-view { min-height: 78px; display: grid; align-content: center; gap: 4px; } |
||||
|
.port-link-number { font-weight: 700; color: #44546a; } |
||||
|
.port-link-state { margin-top: 4px; color: #718197; } |
||||
|
.port-link-caption { font-size: 10px; color: #4f86d6; font-weight: 700; } |
||||
|
.port-link-apply { |
||||
|
width: 100%; |
||||
|
border: 0; |
||||
|
border-top: 1px solid rgba(94, 112, 135, 0.16); |
||||
|
background: rgba(255,255,255,0.7); |
||||
|
color: #4c627d; |
||||
|
font-size: 11px; |
||||
|
font-weight: 700; |
||||
|
padding: 7px 4px; |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
.port-link-apply.selected { |
||||
|
background: #3f97f7; |
||||
|
color: #fff; |
||||
|
} |
||||
|
|
||||
|
.kv-table { display: grid; } |
||||
|
.kv-row { |
||||
|
display: grid; |
||||
|
grid-template-columns: 240px 1fr; |
||||
|
min-height: 44px; |
||||
|
align-items: center; |
||||
|
border-bottom: 1px solid #eef2f6; |
||||
|
} |
||||
|
.kv-row:last-child { border-bottom: 0; } |
||||
|
.kv-key { color: #67778d; } |
||||
|
.kv-value { color: #4b5c73; } |
||||
|
|
||||
|
.mock-form { display: grid; gap: 14px; } |
||||
|
.form-grid { |
||||
|
display: grid; |
||||
|
grid-template-columns: 360px; |
||||
|
gap: 14px 24px; |
||||
|
} |
||||
|
.compact-grid { grid-template-columns: repeat(2, minmax(240px, 360px)); } |
||||
|
.form-row { display: grid; gap: 7px; } |
||||
|
.form-row label { font-size: 13px; color: #6b7b91; } |
||||
|
.required { color: #ef7e7e; } |
||||
|
.form-row input[type="text"], |
||||
|
.form-row input[type="password"], |
||||
|
.form-row select, |
||||
|
.form-row textarea { |
||||
|
width: 100%; |
||||
|
min-height: 32px; |
||||
|
border: 1px solid #d9e0ea; |
||||
|
border-radius: 4px; |
||||
|
background: #fff; |
||||
|
padding: 6px 10px; |
||||
|
color: #607188; |
||||
|
} |
||||
|
.form-row textarea { resize: vertical; min-height: 110px; } |
||||
|
.field-hint { font-size: 11px; color: #97a4b3; } |
||||
|
|
||||
|
.switch { position: relative; width: 34px; height: 18px; display: inline-flex; } |
||||
|
.switch input { display: none; } |
||||
|
.slider { |
||||
|
width: 34px; |
||||
|
height: 18px; |
||||
|
border-radius: 999px; |
||||
|
background: #d8dee8; |
||||
|
position: relative; |
||||
|
} |
||||
|
.slider::after { |
||||
|
content: ""; |
||||
|
position: absolute; |
||||
|
top: 2px; |
||||
|
left: 2px; |
||||
|
width: 14px; |
||||
|
height: 14px; |
||||
|
border-radius: 50%; |
||||
|
background: #fff; |
||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.14); |
||||
|
} |
||||
|
.switch input:checked + .slider { background: #72b1ff; } |
||||
|
.switch input:checked + .slider::after { transform: translateX(16px); } |
||||
|
|
||||
|
.target-grid-wrap { border-top: 1px solid #edf1f6; padding-top: 10px; } |
||||
|
.target-title { margin-bottom: 10px; font-size: 13px; color: #67778d; font-weight: 600; } |
||||
|
.target-grid { |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(6, minmax(0, 1fr)); |
||||
|
gap: 10px; |
||||
|
} |
||||
|
.target-chip { |
||||
|
position: relative; |
||||
|
display: grid; |
||||
|
gap: 4px; |
||||
|
min-height: 72px; |
||||
|
padding: 12px 8px; |
||||
|
border: 1px solid #b8c2cf; |
||||
|
border-radius: 10px; |
||||
|
background: #f8fafc; |
||||
|
color: #5f6e84; |
||||
|
text-align: center; |
||||
|
cursor: pointer; |
||||
|
transition: border-color 0.18s ease, background 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; |
||||
|
} |
||||
|
.target-chip:hover { |
||||
|
transform: translateY(-1px); |
||||
|
border-color: #7bb0f5; |
||||
|
} |
||||
|
.target-chip input { |
||||
|
position: absolute; |
||||
|
opacity: 0; |
||||
|
pointer-events: none; |
||||
|
} |
||||
|
.target-chip.selected { |
||||
|
border-color: #6da884; |
||||
|
background: #edf9f1; |
||||
|
box-shadow: 0 0 0 2px rgba(78,160,255,0.16); |
||||
|
} |
||||
|
.target-chip-number { |
||||
|
font-size: 13px; |
||||
|
font-weight: 700; |
||||
|
color: #44546a; |
||||
|
} |
||||
|
.target-chip-state { |
||||
|
font-size: 11px; |
||||
|
color: #718197; |
||||
|
} |
||||
|
|
||||
|
.panel-actions { display: flex; gap: 12px; align-items: center; } |
||||
|
.panel-actions.compact { margin-bottom: 0; } |
||||
|
.modal-form .panel-actions, |
||||
|
.group-modal .panel-actions { |
||||
|
margin-top: 18px; |
||||
|
} |
||||
|
.btn { |
||||
|
border: 1px solid transparent; |
||||
|
border-radius: 3px; |
||||
|
padding: 8px 18px; |
||||
|
font-size: 12px; |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
.btn.primary { background: var(--accent-strong); color: #fff; } |
||||
|
.btn.secondary { background: #fff; border-color: #d7dee9; color: #6b7c91; } |
||||
|
.btn.danger { background: var(--danger); color: #fff; } |
||||
|
|
||||
|
.note-list { display: grid; gap: 6px; } |
||||
|
.note-item { font-size: 12px; color: #8a97a6; } |
||||
|
|
||||
|
.table-wrap { overflow-x: auto; } |
||||
|
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; } |
||||
|
.data-table th, .data-table td { padding: 12px 14px; border-bottom: 1px solid #eef2f6; text-align: left; color: #5e7087; } |
||||
|
.data-table th { background: #f3f6fa; font-weight: 700; color: #6d7d94; } |
||||
|
.row-actions { white-space: nowrap; } |
||||
|
.link-action { border: 0; background: transparent; padding: 0 2px; font-size: 12px; cursor: pointer; } |
||||
|
.link-action.primary { color: var(--accent-strong); } |
||||
|
.link-action.danger { color: var(--danger); } |
||||
|
.empty-state { text-align: center; color: #98a5b3; } |
||||
|
|
||||
|
.action-panel .action-copy { margin-bottom: 16px; font-size: 13px; color: #6b7b90; } |
||||
|
.result-box { |
||||
|
min-height: 180px; |
||||
|
border: 1px solid #d9e0ea; |
||||
|
border-radius: 4px; |
||||
|
background: #fbfcfe; |
||||
|
padding: 10px; |
||||
|
white-space: pre-wrap; |
||||
|
color: #65758d; |
||||
|
} |
||||
|
.modal-layer { |
||||
|
position: fixed; |
||||
|
inset: 0; |
||||
|
display: grid; |
||||
|
place-items: center; |
||||
|
z-index: 1200; |
||||
|
} |
||||
|
.modal-layer[hidden] { |
||||
|
display: none; |
||||
|
} |
||||
|
.modal-backdrop { |
||||
|
position: absolute; |
||||
|
inset: 0; |
||||
|
background: rgba(27, 40, 58, 0.42); |
||||
|
backdrop-filter: blur(2px); |
||||
|
} |
||||
|
.modal-box { |
||||
|
position: relative; |
||||
|
z-index: 1; |
||||
|
width: min(720px, calc(100vw - 32px)); |
||||
|
border: 1px solid #dfe5ee; |
||||
|
background: #fbfcfe; |
||||
|
border-radius: 10px; |
||||
|
padding: 18px; |
||||
|
box-shadow: 0 24px 60px rgba(20, 33, 52, 0.24); |
||||
|
} |
||||
|
.modal-form { |
||||
|
display: grid; |
||||
|
gap: 18px; |
||||
|
} |
||||
|
.modal-form .form-grid { |
||||
|
gap: 18px 24px; |
||||
|
} |
||||
|
.group-modal { |
||||
|
width: min(920px, calc(100vw - 32px)); |
||||
|
} |
||||
|
.group-modal .form-grid { |
||||
|
gap: 18px 24px; |
||||
|
margin-bottom: 18px; |
||||
|
} |
||||
|
.modal-header { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: space-between; |
||||
|
gap: 16px; |
||||
|
} |
||||
|
.modal-close { |
||||
|
border: 0; |
||||
|
background: transparent; |
||||
|
color: #6b7c91; |
||||
|
cursor: pointer; |
||||
|
font-size: 18px; |
||||
|
line-height: 1; |
||||
|
padding: 4px; |
||||
|
} |
||||
|
.modal-message { margin: 0 0 18px; font-size: 13px; color: #6d7c91; } |
||||
|
.group-permission-grid, |
||||
|
.group-permission-checks { |
||||
|
display: grid; |
||||
|
grid-template-columns: repeat(9, minmax(0, 1fr)); |
||||
|
gap: 8px; |
||||
|
} |
||||
|
.group-permission-grid { |
||||
|
margin-top: 18px; |
||||
|
} |
||||
|
.group-permission-checks { |
||||
|
margin-top: 14px; |
||||
|
margin-bottom: 18px; |
||||
|
} |
||||
|
.group-permission-cell { |
||||
|
min-height: 64px; |
||||
|
border: 1px solid #dfe5ee; |
||||
|
border-radius: 8px; |
||||
|
background: #f8fbff; |
||||
|
padding: 12px 8px; |
||||
|
text-align: center; |
||||
|
font-size: 12px; |
||||
|
color: #6b7c91; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
line-height: 1.45; |
||||
|
} |
||||
|
.group-permission-cell.heading { |
||||
|
font-weight: 700; |
||||
|
color: #607188; |
||||
|
background: #f3f7fc; |
||||
|
} |
||||
|
.permission-chip { |
||||
|
min-height: 44px; |
||||
|
border: 1px solid #d9e3ef; |
||||
|
border-radius: 8px; |
||||
|
background: #fff; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
gap: 6px; |
||||
|
font-size: 12px; |
||||
|
color: #5f6e84; |
||||
|
} |
||||
|
.permission-chip input { |
||||
|
margin: 0; |
||||
|
} |
||||
|
.permission-chip.selected { |
||||
|
border-color: #5aa57a; |
||||
|
background: #edf8f1; |
||||
|
color: #24523a; |
||||
|
} |
||||
|
|
||||
|
.toast-root { |
||||
|
position: fixed; |
||||
|
right: 18px; |
||||
|
bottom: 18px; |
||||
|
display: grid; |
||||
|
gap: 10px; |
||||
|
z-index: 1000; |
||||
|
} |
||||
|
.toast { |
||||
|
min-width: 260px; |
||||
|
max-width: 360px; |
||||
|
background: #243349; |
||||
|
color: #fff; |
||||
|
border-radius: 6px; |
||||
|
padding: 12px 14px; |
||||
|
box-shadow: 0 12px 24px rgba(0,0,0,0.18); |
||||
|
font-size: 13px; |
||||
|
} |
||||
|
|
||||
|
@media (max-width: 1100px) { |
||||
|
.compact-grid { grid-template-columns: 1fr; } |
||||
|
.port-link-grid { grid-template-columns: repeat(8, minmax(0, 1fr)); } |
||||
|
.target-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); } |
||||
|
.group-permission-grid, |
||||
|
.group-permission-checks { grid-template-columns: repeat(3, minmax(0, 1fr)); } |
||||
|
} |
||||
|
|
||||
|
@media (max-width: 920px) { |
||||
|
.sidebar { position: fixed; inset: 0 auto 0 0; transform: translateX(-100%); z-index: 30; } |
||||
|
body.sidebar-open .sidebar { transform: translateX(0); } |
||||
|
.menu-toggle { display: inline-flex; } |
||||
|
.compact-grid, .form-grid { grid-template-columns: 1fr; } |
||||
|
.target-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } |
||||
|
.group-permission-grid, |
||||
|
.group-permission-checks { grid-template-columns: repeat(2, minmax(0, 1fr)); } |
||||
|
} |
||||
@ -0,0 +1,817 @@ |
|||||
|
(function () { |
||||
|
const body = document.body; |
||||
|
const sidebarToggle = document.querySelector("[data-sidebar-toggle]"); |
||||
|
const sidebar = document.querySelector(".sidebar"); |
||||
|
const toastRoot = document.getElementById("toastRoot"); |
||||
|
const workspaceTabsEl = document.querySelector(".workspace-tabs"); |
||||
|
const openTabsKey = "teraclone.openTabs"; |
||||
|
const collapsedNavGroupsKey = "teraclone.collapsedNavGroups"; |
||||
|
const legacyHiddenTabsKey = "teraclone.hiddenTabs"; |
||||
|
const portTargetsKey = "teraclone.portTargets"; |
||||
|
|
||||
|
function showToast(message) { |
||||
|
if (!toastRoot) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const toast = document.createElement("div"); |
||||
|
toast.className = "toast"; |
||||
|
toast.textContent = message; |
||||
|
toastRoot.appendChild(toast); |
||||
|
setTimeout(function () { |
||||
|
toast.remove(); |
||||
|
}, 2600); |
||||
|
} |
||||
|
|
||||
|
function parseJSONSafely(response) { |
||||
|
return response.json().catch(function () { |
||||
|
return {}; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
sidebarToggle?.addEventListener("click", function () { |
||||
|
body.classList.toggle("sidebar-open"); |
||||
|
}); |
||||
|
|
||||
|
function closeSidebar() { |
||||
|
body.classList.remove("sidebar-open"); |
||||
|
} |
||||
|
|
||||
|
function readStoredObject(storageKey) { |
||||
|
try { |
||||
|
const parsed = JSON.parse(window.localStorage.getItem(storageKey) || "{}"); |
||||
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; |
||||
|
} catch (error) { |
||||
|
console.error(error); |
||||
|
return {}; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function writeStoredObject(storageKey, value) { |
||||
|
window.localStorage.setItem(storageKey, JSON.stringify(value)); |
||||
|
} |
||||
|
|
||||
|
function readOpenTabs() { |
||||
|
try { |
||||
|
const parsed = JSON.parse(window.localStorage.getItem(openTabsKey) || "[]"); |
||||
|
if (!Array.isArray(parsed)) { |
||||
|
return []; |
||||
|
} |
||||
|
|
||||
|
return parsed.filter(function (tab) { |
||||
|
return tab && |
||||
|
typeof tab.key === "string" && tab.key && |
||||
|
typeof tab.path === "string" && tab.path && |
||||
|
typeof tab.label === "string" && tab.label; |
||||
|
}); |
||||
|
} catch (error) { |
||||
|
console.error(error); |
||||
|
return []; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function writeOpenTabs(tabs) { |
||||
|
window.localStorage.setItem(openTabsKey, JSON.stringify(tabs)); |
||||
|
} |
||||
|
|
||||
|
function getCurrentTab() { |
||||
|
const path = body.getAttribute("data-current-path") || window.location.pathname; |
||||
|
if (path.startsWith("/ports/")) { |
||||
|
return { key: "ports", path: path, label: "포트 설정" }; |
||||
|
} |
||||
|
|
||||
|
const key = body.getAttribute("data-current-tab-key") || path; |
||||
|
const label = body.getAttribute("data-current-tab-label") || document.title; |
||||
|
return { key: key, path: path, label: label }; |
||||
|
} |
||||
|
|
||||
|
function dedupeTabs(tabs) { |
||||
|
const seenKeys = new Set(); |
||||
|
return tabs.filter(function (tab) { |
||||
|
if (seenKeys.has(tab.key)) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
seenKeys.add(tab.key); |
||||
|
return true; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function syncOpenTabs() { |
||||
|
const currentTab = getCurrentTab(); |
||||
|
const openTabs = dedupeTabs(readOpenTabs()); |
||||
|
const existingTab = openTabs.find(function (tab) { |
||||
|
return tab.key === currentTab.key; |
||||
|
}); |
||||
|
|
||||
|
if (existingTab) { |
||||
|
existingTab.key = currentTab.key; |
||||
|
existingTab.path = currentTab.path; |
||||
|
existingTab.label = currentTab.label; |
||||
|
} else { |
||||
|
openTabs.push(currentTab); |
||||
|
} |
||||
|
|
||||
|
writeOpenTabs(openTabs); |
||||
|
return openTabs; |
||||
|
} |
||||
|
|
||||
|
function renderWorkspaceTabs() { |
||||
|
if (!workspaceTabsEl) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const currentTab = getCurrentTab(); |
||||
|
const openTabs = syncOpenTabs(); |
||||
|
workspaceTabsEl.replaceChildren(); |
||||
|
|
||||
|
openTabs.forEach(function (tab) { |
||||
|
const tabEl = document.createElement("div"); |
||||
|
tabEl.className = "workspace-tab"; |
||||
|
tabEl.setAttribute("data-tab-key", tab.key); |
||||
|
tabEl.setAttribute("data-tab-path", tab.path); |
||||
|
if (tab.key === currentTab.key) { |
||||
|
tabEl.classList.add("active"); |
||||
|
} |
||||
|
|
||||
|
const linkEl = document.createElement("a"); |
||||
|
linkEl.className = "workspace-tab-link"; |
||||
|
linkEl.href = tab.path; |
||||
|
linkEl.textContent = tab.label; |
||||
|
|
||||
|
const closeEl = document.createElement("button"); |
||||
|
closeEl.className = "workspace-close"; |
||||
|
closeEl.type = "button"; |
||||
|
closeEl.setAttribute("aria-label", tab.label + " 닫기"); |
||||
|
closeEl.textContent = "x"; |
||||
|
|
||||
|
closeEl.addEventListener("click", function (event) { |
||||
|
event.preventDefault(); |
||||
|
event.stopPropagation(); |
||||
|
closeWorkspaceTab(tab.key); |
||||
|
}); |
||||
|
|
||||
|
tabEl.append(linkEl, closeEl); |
||||
|
workspaceTabsEl.appendChild(tabEl); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function closeWorkspaceTab(tabKey) { |
||||
|
const openTabs = syncOpenTabs(); |
||||
|
const tabIndex = openTabs.findIndex(function (tab) { |
||||
|
return tab.key === tabKey; |
||||
|
}); |
||||
|
|
||||
|
if (tabIndex === -1) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const nextTabs = openTabs.filter(function (tab) { |
||||
|
return tab.key !== tabKey; |
||||
|
}); |
||||
|
writeOpenTabs(nextTabs); |
||||
|
|
||||
|
if (getCurrentTab().key === tabKey) { |
||||
|
const fallbackTab = nextTabs[tabIndex] || nextTabs[tabIndex - 1]; |
||||
|
window.location.assign(fallbackTab ? fallbackTab.path : "/"); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
renderWorkspaceTabs(); |
||||
|
} |
||||
|
|
||||
|
function applyNavGroupState() { |
||||
|
const storedGroupState = readStoredObject(collapsedNavGroupsKey); |
||||
|
|
||||
|
document.querySelectorAll("[data-nav-group]").forEach(function (group) { |
||||
|
const groupPath = group.getAttribute("data-path"); |
||||
|
const toggle = group.querySelector("[data-nav-toggle]"); |
||||
|
const defaultExpanded = group.getAttribute("data-default-expanded") === "true"; |
||||
|
if (!groupPath || !toggle) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const expanded = Object.prototype.hasOwnProperty.call(storedGroupState, groupPath) |
||||
|
? Boolean(storedGroupState[groupPath]) |
||||
|
: defaultExpanded; |
||||
|
group.classList.toggle("expanded", expanded); |
||||
|
toggle.setAttribute("aria-expanded", expanded ? "true" : "false"); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function bindNavGroups() { |
||||
|
applyNavGroupState(); |
||||
|
|
||||
|
document.querySelectorAll("[data-nav-toggle]").forEach(function (toggle) { |
||||
|
toggle.addEventListener("click", function (event) { |
||||
|
event.preventDefault(); |
||||
|
|
||||
|
const group = toggle.closest("[data-nav-group]"); |
||||
|
const groupPath = group?.getAttribute("data-path"); |
||||
|
if (!groupPath) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const storedGroupState = readStoredObject(collapsedNavGroupsKey); |
||||
|
storedGroupState[groupPath] = !group.classList.contains("expanded"); |
||||
|
writeStoredObject(collapsedNavGroupsKey, storedGroupState); |
||||
|
applyNavGroupState(); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function bindSidebarDismiss() { |
||||
|
document.addEventListener("click", function (event) { |
||||
|
if (!body.classList.contains("sidebar-open")) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const target = event.target; |
||||
|
if (!(target instanceof Element)) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (sidebar?.contains(target) || sidebarToggle?.contains(target)) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
closeSidebar(); |
||||
|
}); |
||||
|
|
||||
|
document.addEventListener("keydown", function (event) { |
||||
|
if (event.key === "Escape") { |
||||
|
closeSidebar(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
document.querySelectorAll(".nav a:not([data-nav-toggle])").forEach(function (link) { |
||||
|
link.addEventListener("click", function () { |
||||
|
closeSidebar(); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function bindModal() { |
||||
|
const modals = Array.from(document.querySelectorAll("[data-modal-root]")); |
||||
|
if (!modals.length) { |
||||
|
return { |
||||
|
openNamedModal: function () {}, |
||||
|
closeNamedModal: function () {}, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
function openModal(modal) { |
||||
|
modals.forEach(function (item) { |
||||
|
item.hidden = true; |
||||
|
}); |
||||
|
modal.hidden = false; |
||||
|
body.classList.add("modal-open"); |
||||
|
} |
||||
|
|
||||
|
function closeModal(modal) { |
||||
|
modal.hidden = true; |
||||
|
if (modals.every(function (item) { return item.hidden; })) { |
||||
|
body.classList.remove("modal-open"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function openNamedModal(name) { |
||||
|
const modal = document.querySelector('[data-modal-root="' + name + '"]'); |
||||
|
if (modal) { |
||||
|
openModal(modal); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function closeNamedModal(name) { |
||||
|
const modal = document.querySelector('[data-modal-root="' + name + '"]'); |
||||
|
if (modal) { |
||||
|
closeModal(modal); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
document.querySelectorAll("[data-open-modal]").forEach(function (button) { |
||||
|
button.addEventListener("click", function (event) { |
||||
|
event.preventDefault(); |
||||
|
const modalName = button.getAttribute("data-open-modal"); |
||||
|
if (modalName) { |
||||
|
openNamedModal(modalName); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
modals.forEach(function (modal) { |
||||
|
modal.querySelectorAll("[data-modal-close]").forEach(function (button) { |
||||
|
button.addEventListener("click", function () { |
||||
|
closeModal(modal); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
modal.addEventListener("click", function (event) { |
||||
|
const target = event.target; |
||||
|
if (target instanceof Element && target.hasAttribute("data-modal-close")) { |
||||
|
closeModal(modal); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
modal.querySelectorAll("[data-mock-action]").forEach(function (button) { |
||||
|
button.addEventListener("click", function () { |
||||
|
window.setTimeout(function () { |
||||
|
closeModal(modal); |
||||
|
}, 0); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
document.addEventListener("keydown", function (event) { |
||||
|
if (event.key !== "Escape") { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
modals.forEach(function (modal) { |
||||
|
if (!modal.hidden) { |
||||
|
closeModal(modal); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
return { |
||||
|
openNamedModal: openNamedModal, |
||||
|
closeNamedModal: closeNamedModal, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
function bindMockActions() { |
||||
|
document.querySelectorAll("[data-mock-action]").forEach(function (button) { |
||||
|
button.addEventListener("click", async function () { |
||||
|
const action = button.getAttribute("data-mock-action") || "mock-action"; |
||||
|
|
||||
|
try { |
||||
|
const response = await fetch("/api/mock/action", { |
||||
|
method: "POST", |
||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" }, |
||||
|
body: new URLSearchParams({ action: action }).toString(), |
||||
|
}); |
||||
|
const payload = await parseJSONSafely(response); |
||||
|
showToast(payload.message || "동작을 처리했습니다."); |
||||
|
} catch (error) { |
||||
|
console.error(error); |
||||
|
showToast("동작 처리 중 오류가 발생했습니다."); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
document.querySelectorAll("[data-mock-form]").forEach(function (form) { |
||||
|
form.addEventListener("submit", async function (event) { |
||||
|
event.preventDefault(); |
||||
|
|
||||
|
const formData = new FormData(form); |
||||
|
if (!formData.get("action")) { |
||||
|
formData.set("action", "mock-form-submit"); |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const response = await fetch(form.getAttribute("action") || "/api/mock/action", { |
||||
|
method: "POST", |
||||
|
body: new URLSearchParams(Array.from(formData.entries())), |
||||
|
}); |
||||
|
const payload = await parseJSONSafely(response); |
||||
|
showToast(payload.message || "적용이 완료되었습니다."); |
||||
|
} catch (error) { |
||||
|
console.error(error); |
||||
|
showToast("처리 중 오류가 발생했습니다."); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function bindPortSelection() { |
||||
|
const portCards = Array.from(document.querySelectorAll("[data-port-card]")); |
||||
|
if (!portCards.length) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const currentPath = body.getAttribute("data-current-path") || window.location.pathname; |
||||
|
const currentPortMatch = currentPath.match(/^\/ports\/(\d+)\//); |
||||
|
const currentPort = currentPortMatch ? currentPortMatch[1] : ""; |
||||
|
|
||||
|
function readStoredTargets() { |
||||
|
try { |
||||
|
const parsed = JSON.parse(window.localStorage.getItem(portTargetsKey) || "[]"); |
||||
|
if (!Array.isArray(parsed)) { |
||||
|
return []; |
||||
|
} |
||||
|
return parsed.map(function (value) { |
||||
|
return String(value); |
||||
|
}); |
||||
|
} catch (error) { |
||||
|
console.error(error); |
||||
|
return []; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function writeStoredTargets() { |
||||
|
const selected = portCards |
||||
|
.map(function (card) { |
||||
|
return card.classList.contains("selected") ? card.getAttribute("data-port-card") : null; |
||||
|
}) |
||||
|
.filter(Boolean); |
||||
|
window.localStorage.setItem(portTargetsKey, JSON.stringify(selected)); |
||||
|
} |
||||
|
|
||||
|
function syncCard(card) { |
||||
|
const checked = card.classList.contains("selected"); |
||||
|
const number = card.getAttribute("data-port-card"); |
||||
|
if (!number) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const shortcutButton = card.querySelector('[data-port-shortcut="' + number + '"]'); |
||||
|
if (shortcutButton) { |
||||
|
shortcutButton.classList.toggle("selected", checked); |
||||
|
shortcutButton.textContent = checked ? "적용 대상 제외" : "적용 대상 추가"; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const storedTargets = readStoredTargets(); |
||||
|
portCards.forEach(function (card) { |
||||
|
const number = card.getAttribute("data-port-card"); |
||||
|
card.classList.toggle("selected", Boolean(number && storedTargets.includes(number))); |
||||
|
if (number && number === currentPort) { |
||||
|
card.classList.add("active"); |
||||
|
} |
||||
|
syncCard(card); |
||||
|
}); |
||||
|
|
||||
|
function togglePort(number) { |
||||
|
const card = document.querySelector('[data-port-card="' + number + '"]'); |
||||
|
if (!card) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
card.classList.toggle("selected"); |
||||
|
syncCard(card); |
||||
|
writeStoredTargets(); |
||||
|
showToast(card.classList.contains("selected") ? ("Port " + number + " 추가됨") : ("Port " + number + " 제외됨")); |
||||
|
} |
||||
|
|
||||
|
document.querySelectorAll("[data-port-shortcut]").forEach(function (button) { |
||||
|
button.addEventListener("click", function (event) { |
||||
|
event.preventDefault(); |
||||
|
event.stopPropagation(); |
||||
|
const number = button.getAttribute("data-port-shortcut"); |
||||
|
if (number) { |
||||
|
togglePort(number); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
document.querySelectorAll("[data-port-view]").forEach(function (button) { |
||||
|
const number = button.getAttribute("data-port-view"); |
||||
|
button.addEventListener("click", function () { |
||||
|
if (!number) { |
||||
|
return; |
||||
|
} |
||||
|
writeStoredTargets(); |
||||
|
window.location.assign("/ports/" + number + "/parameters"); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
writeStoredTargets(); |
||||
|
} |
||||
|
|
||||
|
function bindGroupManagement(modalApi) { |
||||
|
const groupModal = document.querySelector('[data-modal-root="group-create"]'); |
||||
|
const groupForm = groupModal?.querySelector("[data-group-form]"); |
||||
|
if (!groupModal || !groupForm) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const titleEl = groupModal.querySelector(".modal-header h2"); |
||||
|
const messageEl = groupModal.querySelector(".modal-message"); |
||||
|
const idInput = groupForm.querySelector('input[name="id"]'); |
||||
|
const nameInput = groupForm.querySelector('input[name="name"]'); |
||||
|
const descriptionInput = groupForm.querySelector('input[name="description"]'); |
||||
|
const permissionInputs = Array.from(groupForm.querySelectorAll('input[name="permissions"]')); |
||||
|
const defaultState = { |
||||
|
title: titleEl?.textContent || "그룹 추가", |
||||
|
message: messageEl?.textContent || "", |
||||
|
}; |
||||
|
|
||||
|
function syncPermissionVisuals() { |
||||
|
permissionInputs.forEach(function (input) { |
||||
|
const chip = input.closest(".permission-chip"); |
||||
|
if (chip) { |
||||
|
chip.classList.toggle("selected", Boolean(input.checked)); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
function resetGroupForm() { |
||||
|
if (idInput) { |
||||
|
idInput.value = ""; |
||||
|
} |
||||
|
if (nameInput) { |
||||
|
nameInput.value = ""; |
||||
|
} |
||||
|
if (descriptionInput) { |
||||
|
descriptionInput.value = ""; |
||||
|
} |
||||
|
permissionInputs.forEach(function (input) { |
||||
|
input.checked = false; |
||||
|
}); |
||||
|
if (titleEl) { |
||||
|
titleEl.textContent = defaultState.title; |
||||
|
} |
||||
|
if (messageEl) { |
||||
|
messageEl.textContent = defaultState.message; |
||||
|
} |
||||
|
syncPermissionVisuals(); |
||||
|
} |
||||
|
|
||||
|
function applyPermissions(permissions) { |
||||
|
const selected = new Set(Array.isArray(permissions) ? permissions : []); |
||||
|
permissionInputs.forEach(function (input) { |
||||
|
input.checked = selected.has(input.value); |
||||
|
}); |
||||
|
syncPermissionVisuals(); |
||||
|
} |
||||
|
|
||||
|
permissionInputs.forEach(function (input) { |
||||
|
input.addEventListener("change", syncPermissionVisuals); |
||||
|
}); |
||||
|
|
||||
|
document.querySelectorAll('[data-open-modal="group-create"]').forEach(function (button) { |
||||
|
button.addEventListener("click", function () { |
||||
|
resetGroupForm(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
document.querySelectorAll("[data-group-edit]").forEach(function (button) { |
||||
|
button.addEventListener("click", async function () { |
||||
|
const id = button.getAttribute("data-group-edit"); |
||||
|
if (!id) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const response = await fetch("/api/groups/" + encodeURIComponent(id)); |
||||
|
const payload = await parseJSONSafely(response); |
||||
|
if (!response.ok || !payload.data) { |
||||
|
throw new Error(payload.message || "그룹 정보를 불러오지 못했습니다."); |
||||
|
} |
||||
|
|
||||
|
if (idInput) { |
||||
|
idInput.value = String(payload.data.id || ""); |
||||
|
} |
||||
|
if (nameInput) { |
||||
|
nameInput.value = payload.data.name || ""; |
||||
|
} |
||||
|
if (descriptionInput) { |
||||
|
descriptionInput.value = payload.data.description || ""; |
||||
|
} |
||||
|
applyPermissions(payload.data.permissions); |
||||
|
if (titleEl) { |
||||
|
titleEl.textContent = "그룹 수정"; |
||||
|
} |
||||
|
if (messageEl) { |
||||
|
messageEl.textContent = "사용자에게 연결된 그룹 권한을 수정합니다."; |
||||
|
} |
||||
|
|
||||
|
modalApi.openNamedModal("group-create"); |
||||
|
} catch (error) { |
||||
|
console.error(error); |
||||
|
showToast(error instanceof Error ? error.message : "그룹 정보를 불러오지 못했습니다."); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
document.querySelectorAll("[data-group-delete]").forEach(function (button) { |
||||
|
button.addEventListener("click", async function () { |
||||
|
const id = button.getAttribute("data-group-delete"); |
||||
|
if (!id || !window.confirm("이 그룹을 삭제하시겠습니까?")) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const response = await fetch("/api/groups/" + encodeURIComponent(id), { |
||||
|
method: "DELETE", |
||||
|
}); |
||||
|
const payload = await parseJSONSafely(response); |
||||
|
if (!response.ok) { |
||||
|
throw new Error(payload.message || "그룹을 삭제하지 못했습니다."); |
||||
|
} |
||||
|
|
||||
|
showToast(payload.message || "그룹을 삭제했습니다."); |
||||
|
window.location.reload(); |
||||
|
} catch (error) { |
||||
|
console.error(error); |
||||
|
showToast(error instanceof Error ? error.message : "그룹을 삭제하지 못했습니다."); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
groupForm.addEventListener("submit", async function (event) { |
||||
|
event.preventDefault(); |
||||
|
|
||||
|
const groupID = idInput?.value.trim() || ""; |
||||
|
const payload = { |
||||
|
name: nameInput?.value.trim() || "", |
||||
|
description: descriptionInput?.value.trim() || "", |
||||
|
permissions: permissionInputs.filter(function (input) { |
||||
|
return input.checked; |
||||
|
}).map(function (input) { |
||||
|
return input.value; |
||||
|
}), |
||||
|
}; |
||||
|
const isEdit = groupID !== ""; |
||||
|
const endpoint = isEdit ? "/api/groups/" + encodeURIComponent(groupID) : "/api/groups"; |
||||
|
const method = isEdit ? "PUT" : "POST"; |
||||
|
|
||||
|
try { |
||||
|
const response = await fetch(endpoint, { |
||||
|
method: method, |
||||
|
headers: { "Content-Type": "application/json" }, |
||||
|
body: JSON.stringify(payload), |
||||
|
}); |
||||
|
const result = await parseJSONSafely(response); |
||||
|
if (!response.ok) { |
||||
|
throw new Error(result.message || "그룹 저장에 실패했습니다."); |
||||
|
} |
||||
|
|
||||
|
modalApi.closeNamedModal("group-create"); |
||||
|
showToast(result.message || "그룹을 저장했습니다."); |
||||
|
window.location.reload(); |
||||
|
} catch (error) { |
||||
|
console.error(error); |
||||
|
showToast(error instanceof Error ? error.message : "그룹 저장에 실패했습니다."); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
syncPermissionVisuals(); |
||||
|
} |
||||
|
|
||||
|
function bindUserManagement(modalApi) { |
||||
|
const userModal = document.querySelector('[data-modal-root="user-create"]'); |
||||
|
const userForm = userModal?.querySelector("[data-user-form]"); |
||||
|
if (!userModal || !userForm) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const titleEl = userModal.querySelector(".modal-header h2"); |
||||
|
const messageEl = userModal.querySelector(".modal-message"); |
||||
|
const idInput = userForm.querySelector('input[name="id"]'); |
||||
|
const usernameInput = userForm.querySelector('input[name="username"]'); |
||||
|
const passwordInput = userForm.querySelector('input[name="password"]'); |
||||
|
const groupInput = userForm.querySelector('select[name="group"]'); |
||||
|
const enabledInput = userForm.querySelector('select[name="enabled"]'); |
||||
|
const defaultState = { |
||||
|
title: titleEl?.textContent || "사용자 추가", |
||||
|
message: messageEl?.textContent || "", |
||||
|
group: groupInput?.value || "administrator", |
||||
|
enabled: enabledInput?.value || "true", |
||||
|
}; |
||||
|
|
||||
|
function resetUserForm() { |
||||
|
if (idInput) { |
||||
|
idInput.value = ""; |
||||
|
} |
||||
|
if (usernameInput) { |
||||
|
usernameInput.value = ""; |
||||
|
} |
||||
|
if (passwordInput) { |
||||
|
passwordInput.value = ""; |
||||
|
} |
||||
|
if (groupInput) { |
||||
|
groupInput.value = defaultState.group; |
||||
|
} |
||||
|
if (enabledInput) { |
||||
|
enabledInput.value = defaultState.enabled; |
||||
|
} |
||||
|
if (titleEl) { |
||||
|
titleEl.textContent = defaultState.title; |
||||
|
} |
||||
|
if (messageEl) { |
||||
|
messageEl.textContent = defaultState.message; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
document.querySelectorAll('[data-open-modal="user-create"]').forEach(function (button) { |
||||
|
button.addEventListener("click", function () { |
||||
|
resetUserForm(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
document.querySelectorAll("[data-user-edit]").forEach(function (button) { |
||||
|
button.addEventListener("click", async function () { |
||||
|
const id = button.getAttribute("data-user-edit"); |
||||
|
if (!id) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const response = await fetch("/api/users/" + encodeURIComponent(id)); |
||||
|
const payload = await parseJSONSafely(response); |
||||
|
if (!response.ok || !payload.data) { |
||||
|
throw new Error(payload.message || "사용자 정보를 불러오지 못했습니다."); |
||||
|
} |
||||
|
|
||||
|
if (idInput) { |
||||
|
idInput.value = String(payload.data.id || ""); |
||||
|
} |
||||
|
if (usernameInput) { |
||||
|
usernameInput.value = payload.data.username || ""; |
||||
|
} |
||||
|
if (passwordInput) { |
||||
|
passwordInput.value = ""; |
||||
|
} |
||||
|
if (groupInput) { |
||||
|
groupInput.value = payload.data.group || defaultState.group; |
||||
|
} |
||||
|
if (enabledInput) { |
||||
|
enabledInput.value = String(payload.data.enabled !== false); |
||||
|
} |
||||
|
if (titleEl) { |
||||
|
titleEl.textContent = "사용자 수정"; |
||||
|
} |
||||
|
if (messageEl) { |
||||
|
messageEl.textContent = "비밀번호를 비워두면 기존 값이 유지됩니다."; |
||||
|
} |
||||
|
|
||||
|
modalApi.openNamedModal("user-create"); |
||||
|
} catch (error) { |
||||
|
console.error(error); |
||||
|
showToast(error instanceof Error ? error.message : "사용자 정보를 불러오지 못했습니다."); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
document.querySelectorAll("[data-user-delete]").forEach(function (button) { |
||||
|
button.addEventListener("click", async function () { |
||||
|
const id = button.getAttribute("data-user-delete"); |
||||
|
if (!id || !window.confirm("이 사용자를 삭제하시겠습니까?")) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
const response = await fetch("/api/users/" + encodeURIComponent(id), { |
||||
|
method: "DELETE", |
||||
|
}); |
||||
|
const payload = await parseJSONSafely(response); |
||||
|
if (!response.ok) { |
||||
|
throw new Error(payload.message || "사용자를 삭제하지 못했습니다."); |
||||
|
} |
||||
|
|
||||
|
showToast(payload.message || "사용자를 삭제했습니다."); |
||||
|
window.location.reload(); |
||||
|
} catch (error) { |
||||
|
console.error(error); |
||||
|
showToast(error instanceof Error ? error.message : "사용자를 삭제하지 못했습니다."); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
userForm.addEventListener("submit", async function (event) { |
||||
|
event.preventDefault(); |
||||
|
|
||||
|
const userID = idInput?.value.trim() || ""; |
||||
|
const payload = { |
||||
|
username: usernameInput?.value.trim() || "", |
||||
|
password: passwordInput?.value || "", |
||||
|
group: groupInput?.value || defaultState.group, |
||||
|
enabled: (enabledInput?.value || "true") === "true", |
||||
|
}; |
||||
|
const isEdit = userID !== ""; |
||||
|
const endpoint = isEdit ? "/api/users/" + encodeURIComponent(userID) : "/api/users"; |
||||
|
const method = isEdit ? "PUT" : "POST"; |
||||
|
|
||||
|
try { |
||||
|
const response = await fetch(endpoint, { |
||||
|
method: method, |
||||
|
headers: { "Content-Type": "application/json" }, |
||||
|
body: JSON.stringify(payload), |
||||
|
}); |
||||
|
const result = await parseJSONSafely(response); |
||||
|
if (!response.ok) { |
||||
|
throw new Error(result.message || "사용자 저장에 실패했습니다."); |
||||
|
} |
||||
|
|
||||
|
modalApi.closeNamedModal("user-create"); |
||||
|
showToast(result.message || "사용자를 저장했습니다."); |
||||
|
window.location.reload(); |
||||
|
} catch (error) { |
||||
|
console.error(error); |
||||
|
showToast(error instanceof Error ? error.message : "사용자 저장에 실패했습니다."); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
window.localStorage.removeItem(legacyHiddenTabsKey); |
||||
|
renderWorkspaceTabs(); |
||||
|
bindNavGroups(); |
||||
|
bindSidebarDismiss(); |
||||
|
const modalApi = bindModal(); |
||||
|
bindMockActions(); |
||||
|
bindPortSelection(); |
||||
|
bindGroupManagement(modalApi); |
||||
|
bindUserManagement(modalApi); |
||||
|
})(); |
||||
@ -0,0 +1,45 @@ |
|||||
|
{{define "login"}} |
||||
|
<!DOCTYPE html> |
||||
|
<html lang="ko"> |
||||
|
<head> |
||||
|
<meta charset="UTF-8"> |
||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||
|
<title>로그인 - {{.AppName}}</title> |
||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||
|
</head> |
||||
|
<body class="login-body"> |
||||
|
<main class="login-shell"> |
||||
|
<section class="login-panel"> |
||||
|
<div class="login-brand"> |
||||
|
<div class="brand-mark">TC</div> |
||||
|
<div> |
||||
|
<div class="brand-title">{{.AppName}}</div> |
||||
|
<div class="brand-subtitle">Device console login</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="login-copy"> |
||||
|
<h1>로그인</h1> |
||||
|
<p>장치 관리 화면에 접근하려면 계정으로 로그인하세요.</p> |
||||
|
</div> |
||||
|
|
||||
|
{{if .Error}} |
||||
|
<div class="login-error">{{.Error}}</div> |
||||
|
{{end}} |
||||
|
|
||||
|
<form class="login-form" action="/login" method="post" autocomplete="off"> |
||||
|
<div class="form-row"> |
||||
|
<label for="username">아이디</label> |
||||
|
<input id="username" type="text" name="username" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" required> |
||||
|
</div> |
||||
|
<div class="form-row"> |
||||
|
<label for="password">비밀번호</label> |
||||
|
<input id="password" type="password" name="password" autocomplete="new-password" required> |
||||
|
</div> |
||||
|
<button class="btn primary login-submit" type="submit">로그인</button> |
||||
|
</form> |
||||
|
</section> |
||||
|
</main> |
||||
|
</body> |
||||
|
</html> |
||||
|
{{end}} |
||||
@ -0,0 +1,165 @@ |
|||||
|
{{define "layout"}} |
||||
|
<!DOCTYPE html> |
||||
|
<html lang="ko"> |
||||
|
<head> |
||||
|
<meta charset="UTF-8"> |
||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||
|
<title>{{.Title}} - {{.AppName}}</title> |
||||
|
<link rel="stylesheet" href="/static/css/style.css"> |
||||
|
</head> |
||||
|
<body data-current-path="{{.CurrentPath}}" data-current-tab-key="{{(index .WorkspaceTabs 0).Key}}" data-current-tab-label="{{(index .WorkspaceTabs 0).Label}}"> |
||||
|
<div class="app-shell"> |
||||
|
<aside class="sidebar"> |
||||
|
<div class="brand"> |
||||
|
<div class="brand-mark">TC</div> |
||||
|
<div class="brand-copy"> |
||||
|
<div class="brand-title">{{.AppName}}</div> |
||||
|
<div class="brand-subtitle">Device console mock</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<nav class="nav"> |
||||
|
{{range .Menu}} |
||||
|
<div class="nav-group {{if .Expanded}}expanded{{end}}" data-nav-group data-path="{{.Path}}" data-default-expanded="{{if .Expanded}}true{{else}}false{{end}}"> |
||||
|
<a class="nav-item {{if .Active}}active{{end}}" href="{{.Path}}" {{if .Children}}data-nav-toggle aria-expanded="{{if .Expanded}}true{{else}}false{{end}}"{{end}}> |
||||
|
<span class="nav-label">{{.Label}}</span> |
||||
|
{{if .Children}}<span class="nav-caret" aria-hidden="true"></span>{{end}} |
||||
|
</a> |
||||
|
{{if .Children}} |
||||
|
<div class="nav-children"> |
||||
|
{{range .Children}} |
||||
|
<a class="nav-child {{if .Active}}active{{end}}" href="{{.Path}}">{{.Label}}</a> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
{{end}} |
||||
|
</nav> |
||||
|
</aside> |
||||
|
|
||||
|
<main class="main"> |
||||
|
<header class="topbar"> |
||||
|
<div class="topbar-left"> |
||||
|
<button class="menu-toggle" type="button" data-sidebar-toggle>Menu</button> |
||||
|
<div class="topbar-crumbs"> |
||||
|
{{range $idx, $crumb := .Breadcrumbs}} |
||||
|
{{if gt $idx 0}}<span class="crumb-sep">/</span>{{end}} |
||||
|
<span class="crumb">{{$crumb}}</span> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="topbar-right"> |
||||
|
<a class="legacy-link" href="/legacy">이전 화면</a> |
||||
|
<div class="user-pill">{{.CurrentUser}}</div> |
||||
|
<form class="logout-form" action="/logout" method="post"> |
||||
|
<button class="logout-button" type="submit">로그아웃</button> |
||||
|
</form> |
||||
|
<button class="theme-pill" type="button" data-mock-action="open-options">Quick Action</button> |
||||
|
</div> |
||||
|
</header> |
||||
|
|
||||
|
<div class="workspace-tabs"> |
||||
|
{{range .WorkspaceTabs}} |
||||
|
<div class="workspace-tab {{if .Active}}active{{end}}" data-tab-key="{{.Key}}" data-tab-path="{{.Path}}"> |
||||
|
<a class="workspace-tab-link" href="{{.Path}}">{{.Label}}</a> |
||||
|
<button class="workspace-close" type="button" aria-label="{{.Label}} 닫기">x</button> |
||||
|
</div> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
|
||||
|
<section class="content"> |
||||
|
<div class="page-header"> |
||||
|
<div class="page-kicker">{{.Section}}</div> |
||||
|
<h1>{{.Title}}</h1> |
||||
|
{{if .MockMode}} |
||||
|
<div class="mock-badge">MOCK MODE</div> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
|
||||
|
{{if .SubTabs}} |
||||
|
<div class="subtabs"> |
||||
|
{{range .SubTabs}} |
||||
|
<a class="subtab {{if .Active}}active{{end}}" href="{{.Path}}">{{.Label}}</a> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
{{end}} |
||||
|
|
||||
|
<div class="blocks"> |
||||
|
{{range .Blocks}} |
||||
|
{{if eq .Kind "port-links"}} |
||||
|
{{template "block_port-links" .}} |
||||
|
{{else if eq .Kind "port-selector"}} |
||||
|
{{template "block_port-selector" .}} |
||||
|
{{else if eq .Kind "key-values"}} |
||||
|
{{template "block_key-values" .}} |
||||
|
{{else if eq .Kind "form"}} |
||||
|
{{template "block_form" .}} |
||||
|
{{else if eq .Kind "table"}} |
||||
|
{{template "block_table" .}} |
||||
|
{{else if eq .Kind "action"}} |
||||
|
{{template "block_action" .}} |
||||
|
{{else if eq .Kind "tool"}} |
||||
|
{{template "block_tool" .}} |
||||
|
{{else if eq .Kind "modal"}} |
||||
|
{{template "block_modal" .}} |
||||
|
{{end}} |
||||
|
{{end}} |
||||
|
</div> |
||||
|
</section> |
||||
|
</main> |
||||
|
</div> |
||||
|
|
||||
|
<div id="toastRoot" class="toast-root"></div> |
||||
|
<section class="modal-layer" data-modal-root="group-create" hidden> |
||||
|
<div class="modal-backdrop" data-modal-close></div> |
||||
|
<div class="modal-box group-modal" role="dialog" aria-modal="true" aria-label="그룹 추가"> |
||||
|
<div class="panel-header modal-header"> |
||||
|
<h2>그룹 추가</h2> |
||||
|
<button type="button" class="modal-close" aria-label="닫기" data-modal-close>x</button> |
||||
|
</div> |
||||
|
<p class="modal-message">그룹 이름과 권한을 지정해 사용자 계정에 연결할 그룹을 관리합니다.</p> |
||||
|
<form class="modal-form" data-group-form> |
||||
|
<input type="hidden" name="id" value=""> |
||||
|
<div class="form-grid compact-grid"> |
||||
|
<div class="form-row"> |
||||
|
<label>그룹 이름</label> |
||||
|
<input type="text" name="name" value="" placeholder="예: operator"> |
||||
|
</div> |
||||
|
<div class="form-row"> |
||||
|
<label>설명</label> |
||||
|
<input type="text" name="description" value="" placeholder="그룹 설명을 입력하세요"> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="group-permission-grid"> |
||||
|
<div class="group-permission-cell heading">로그인</div> |
||||
|
<div class="group-permission-cell heading">네트워크 설정</div> |
||||
|
<div class="group-permission-cell heading">포트 설정</div> |
||||
|
<div class="group-permission-cell heading">포트 디버그</div> |
||||
|
<div class="group-permission-cell heading">Admin 설정</div> |
||||
|
<div class="group-permission-cell heading">공통 설정</div> |
||||
|
<div class="group-permission-cell heading">상태</div> |
||||
|
<div class="group-permission-cell heading">비밀번호 변경</div> |
||||
|
<div class="group-permission-cell heading">Operate</div> |
||||
|
</div> |
||||
|
<div class="group-permission-checks"> |
||||
|
<label class="permission-chip"><input type="checkbox" name="permissions" value="login"> <span>사용</span></label> |
||||
|
<label class="permission-chip"><input type="checkbox" name="permissions" value="network"> <span>사용</span></label> |
||||
|
<label class="permission-chip"><input type="checkbox" name="permissions" value="ports"> <span>사용</span></label> |
||||
|
<label class="permission-chip"><input type="checkbox" name="permissions" value="tools"> <span>사용</span></label> |
||||
|
<label class="permission-chip"><input type="checkbox" name="permissions" value="admin"> <span>사용</span></label> |
||||
|
<label class="permission-chip"><input type="checkbox" name="permissions" value="common"> <span>사용</span></label> |
||||
|
<label class="permission-chip"><input type="checkbox" name="permissions" value="status"> <span>사용</span></label> |
||||
|
<label class="permission-chip"><input type="checkbox" name="permissions" value="password"> <span>사용</span></label> |
||||
|
<label class="permission-chip"><input type="checkbox" name="permissions" value="operate"> <span>사용</span></label> |
||||
|
</div> |
||||
|
<div class="panel-actions"> |
||||
|
<button type="submit" class="btn primary" data-group-submit>확인</button> |
||||
|
<button type="button" class="btn secondary" data-modal-close>취소</button> |
||||
|
</div> |
||||
|
</form> |
||||
|
</div> |
||||
|
</section> |
||||
|
<script src="/static/js/app.js"></script> |
||||
|
</body> |
||||
|
</html> |
||||
|
{{end}} |
||||
@ -0,0 +1 @@ |
|||||
|
{{define "page"}}{{end}} |
||||
@ -0,0 +1,13 @@ |
|||||
|
{{define "block_action"}} |
||||
|
<section class="panel action-panel"> |
||||
|
<div class="panel-header"> |
||||
|
<h2>{{.Title}}</h2> |
||||
|
</div> |
||||
|
<div class="action-copy">{{.Action.Description}}</div> |
||||
|
<div class="panel-actions"> |
||||
|
{{range .Action.Buttons}} |
||||
|
<button type="button" class="btn {{.Variant}}" data-mock-action="{{.Action}}">{{.Label}}</button> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
</section> |
||||
|
{{end}} |
||||
@ -0,0 +1,48 @@ |
|||||
|
{{define "block_form"}} |
||||
|
<section class="panel"> |
||||
|
<div class="panel-header"> |
||||
|
<h2>{{.Title}}</h2> |
||||
|
{{if .Subtitle}}<p>{{.Subtitle}}</p>{{end}} |
||||
|
</div> |
||||
|
<form class="mock-form" action="{{.Form.Action}}" method="post" data-mock-form> |
||||
|
<input type="hidden" name="action" value="{{.Form.Action}}"> |
||||
|
<div class="form-grid"> |
||||
|
{{range .Form.Fields}} |
||||
|
<div class="form-row form-type-{{.Type}}"> |
||||
|
<label>{{.Label}}{{if .Required}} <span class="required">*</span>{{end}}</label> |
||||
|
{{if eq .Type "text"}} |
||||
|
<input type="text" name="{{.Name}}" value="{{.Value}}" placeholder="{{.Placeholder}}" {{if .Disabled}}disabled{{end}}> |
||||
|
{{else if eq .Type "password"}} |
||||
|
<input type="password" name="{{.Name}}" value="{{.Value}}" placeholder="{{.Placeholder}}"> |
||||
|
{{else if eq .Type "textarea"}} |
||||
|
<textarea name="{{.Name}}" rows="{{.Rows}}">{{.Value}}</textarea> |
||||
|
{{else if eq .Type "select"}} |
||||
|
<select name="{{.Name}}"> |
||||
|
{{range .Options}} |
||||
|
<option value="{{.Value}}" {{if .Selected}}selected{{end}}>{{.Label}}</option> |
||||
|
{{end}} |
||||
|
</select> |
||||
|
{{else if eq .Type "toggle"}} |
||||
|
<label class="switch"> |
||||
|
<input type="checkbox" name="{{.Name}}" {{if .Checked}}checked{{end}}> |
||||
|
<span class="slider"></span> |
||||
|
</label> |
||||
|
{{end}} |
||||
|
{{if .Hint}}<div class="field-hint">{{.Hint}}</div>{{end}} |
||||
|
</div> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
|
||||
|
{{if .Form.Notes}} |
||||
|
<div class="note-list"> |
||||
|
{{range .Form.Notes}}<div class="note-item">{{.}}</div>{{end}} |
||||
|
</div> |
||||
|
{{end}} |
||||
|
|
||||
|
<div class="panel-actions"> |
||||
|
{{if .Form.PrimaryLabel}}<button type="submit" class="btn primary" data-mock-action="apply">{{.Form.PrimaryLabel}}</button>{{end}} |
||||
|
{{if .Form.SecondaryLabel}}<button type="button" class="btn secondary" data-mock-action="secondary">{{.Form.SecondaryLabel}}</button>{{end}} |
||||
|
</div> |
||||
|
</form> |
||||
|
</section> |
||||
|
{{end}} |
||||
@ -0,0 +1,16 @@ |
|||||
|
{{define "block_key-values"}} |
||||
|
<section class="panel"> |
||||
|
<div class="panel-header"> |
||||
|
<h2>{{.Title}}</h2> |
||||
|
{{if .Subtitle}}<p>{{.Subtitle}}</p>{{end}} |
||||
|
</div> |
||||
|
<div class="kv-table"> |
||||
|
{{range .KeyValues}} |
||||
|
<div class="kv-row"> |
||||
|
<div class="kv-key">{{.Key}}</div> |
||||
|
<div class="kv-value">{{.Value}}</div> |
||||
|
</div> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
</section> |
||||
|
{{end}} |
||||
@ -0,0 +1,43 @@ |
|||||
|
{{define "block_modal"}} |
||||
|
<section class="modal-layer" data-modal-root="{{.Modal.Root}}" {{if eq .Modal.Root "user-create"}}data-user-modal="true"{{end}} hidden> |
||||
|
<div class="modal-backdrop" data-modal-close></div> |
||||
|
<div class="modal-box" role="dialog" aria-modal="true" aria-label="{{.Modal.Title}}"> |
||||
|
<div class="panel-header modal-header"> |
||||
|
<h2>{{.Modal.Title}}</h2> |
||||
|
<button type="button" class="modal-close" aria-label="닫기" data-modal-close>x</button> |
||||
|
</div> |
||||
|
<p class="modal-message">{{.Modal.Message}}</p> |
||||
|
<form class="modal-form" {{if eq .Modal.Root "user-create"}}data-user-form{{end}}> |
||||
|
<div class="form-grid compact-grid"> |
||||
|
{{range .Modal.Fields}} |
||||
|
{{if eq .Type "hidden"}} |
||||
|
<input type="hidden" name="{{.Name}}" value="{{.Value}}"> |
||||
|
{{else}} |
||||
|
<div class="form-row"> |
||||
|
<label>{{.Label}}</label> |
||||
|
{{if eq .Type "textarea"}} |
||||
|
<textarea name="{{.Name}}" rows="{{.Rows}}">{{.Value}}</textarea> |
||||
|
{{else if eq .Type "select"}} |
||||
|
<select name="{{.Name}}"> |
||||
|
{{range .Options}} |
||||
|
<option value="{{.Value}}" {{if .Selected}}selected{{end}}>{{.Label}}</option> |
||||
|
{{end}} |
||||
|
</select> |
||||
|
{{else if eq .Type "password"}} |
||||
|
<input type="password" name="{{.Name}}" value="{{.Value}}" placeholder="{{.Placeholder}}"> |
||||
|
{{else}} |
||||
|
<input type="text" name="{{.Name}}" value="{{.Value}}" placeholder="{{.Placeholder}}"> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
{{end}} |
||||
|
{{end}} |
||||
|
</div> |
||||
|
<div class="panel-actions"> |
||||
|
{{range .Modal.Buttons}} |
||||
|
<button type="{{if eq .Action "submit-user-form"}}submit{{else}}button{{end}}" class="btn {{.Variant}}" {{if eq .Action "cancel-modal"}}data-modal-close{{else if eq .Action "submit-user-form"}}data-user-submit{{else}}data-mock-action="{{.Action}}"{{end}}>{{.Label}}</button> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
</form> |
||||
|
</div> |
||||
|
</section> |
||||
|
{{end}} |
||||
@ -0,0 +1,15 @@ |
|||||
|
{{define "block_port-links"}} |
||||
|
<section class="panel"> |
||||
|
<div class="panel-header"> |
||||
|
<h2>{{.Title}}</h2> |
||||
|
</div> |
||||
|
<div class="port-link-grid"> |
||||
|
{{range .PortLinks}} |
||||
|
<div class="port-link {{if .Up}}up{{else}}down{{end}} {{if .Blink}}blink{{end}}"> |
||||
|
<div class="port-link-number">{{.Number}}</div> |
||||
|
<div class="port-link-state">{{if .Up}}링크 연결{{else}}링크 대기{{end}}</div> |
||||
|
</div> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
</section> |
||||
|
{{end}} |
||||
@ -0,0 +1,21 @@ |
|||||
|
{{define "block_port-selector"}} |
||||
|
<section class="panel"> |
||||
|
<div class="panel-header"> |
||||
|
<h2>{{.Title}}</h2> |
||||
|
</div> |
||||
|
<div class="port-link-grid"> |
||||
|
{{range .PortLinks}} |
||||
|
<div class="port-link-card {{if .Up}}up{{else}}down{{end}} {{if .Blink}}blink{{end}}" data-port-card="{{.Number}}"> |
||||
|
<button type="button" class="port-link port-link-view" data-port-view="{{.Number}}"> |
||||
|
<div class="port-link-number">{{.Number}}</div> |
||||
|
<div class="port-link-state">{{if .Up}}링크 연결{{else}}링크 대기{{end}}</div> |
||||
|
<div class="port-link-caption">개별 보기</div> |
||||
|
</button> |
||||
|
<button type="button" class="port-link-apply" data-port-shortcut="{{.Number}}"> |
||||
|
적용 대상 추가 |
||||
|
</button> |
||||
|
</div> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
</section> |
||||
|
{{end}} |
||||
@ -0,0 +1,56 @@ |
|||||
|
{{define "block_table"}} |
||||
|
<section class="panel"> |
||||
|
<div class="panel-header panel-header-actions"> |
||||
|
<div> |
||||
|
<h2>{{.Title}}</h2> |
||||
|
{{if .Subtitle}}<p>{{.Subtitle}}</p>{{end}} |
||||
|
</div> |
||||
|
<div class="panel-actions compact"> |
||||
|
{{if .Table.PrimaryLabel}} |
||||
|
<button |
||||
|
type="button" |
||||
|
class="btn primary" |
||||
|
{{if eq .Title "사용자 계정"}}data-open-modal="user-create"{{else if eq .Title "그룹"}}data-open-modal="group-create"{{else}}data-mock-action="primary-action"{{end}}> |
||||
|
{{.Table.PrimaryLabel}} |
||||
|
</button> |
||||
|
{{end}} |
||||
|
{{if .Table.SecondaryLabel}}<button type="button" class="btn secondary" data-mock-action="secondary-action">{{.Table.SecondaryLabel}}</button>{{end}} |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="table-wrap"> |
||||
|
<table class="data-table"> |
||||
|
<thead> |
||||
|
<tr> |
||||
|
{{range .Table.Columns}}<th>{{.}}</th>{{end}} |
||||
|
{{if .Table.Rows}} |
||||
|
{{if (index .Table.Rows 0).Actions}}<th>Operate</th>{{end}} |
||||
|
{{end}} |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody> |
||||
|
{{if .Table.Rows}} |
||||
|
{{range .Table.Rows}} |
||||
|
<tr> |
||||
|
{{range .Cells}}<td>{{.}}</td>{{end}} |
||||
|
{{if .Actions}} |
||||
|
<td class="row-actions"> |
||||
|
{{range .Actions}} |
||||
|
<button |
||||
|
type="button" |
||||
|
class="link-action {{.Variant}}" |
||||
|
{{if eq .Action "user-edit"}}data-user-edit="{{.Target}}"{{else if eq .Action "user-delete"}}data-user-delete="{{.Target}}"{{else if eq .Action "group-edit"}}data-group-edit="{{.Target}}"{{else if eq .Action "group-delete"}}data-group-delete="{{.Target}}"{{else}}data-mock-action="{{.Action}}"{{end}}> |
||||
|
{{.Label}} |
||||
|
</button> |
||||
|
{{end}} |
||||
|
</td> |
||||
|
{{end}} |
||||
|
</tr> |
||||
|
{{end}} |
||||
|
{{else}} |
||||
|
<tr><td colspan="{{len .Table.Columns}}" class="empty-state">{{.Table.EmptyMessage}}</td></tr> |
||||
|
{{end}} |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</div> |
||||
|
</section> |
||||
|
{{end}} |
||||
@ -0,0 +1,41 @@ |
|||||
|
{{define "block_tool"}} |
||||
|
<section class="panel"> |
||||
|
<div class="panel-header"> |
||||
|
<h2>{{.Title}}</h2> |
||||
|
</div> |
||||
|
{{if .Tool.Tabs}} |
||||
|
<div class="subtabs embedded"> |
||||
|
{{range .Tool.Tabs}} |
||||
|
<a class="subtab {{if .Active}}active{{end}}" href="{{.Path}}">{{.Label}}</a> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
{{end}} |
||||
|
<form class="mock-form" action="{{.Tool.Action}}" method="post" data-mock-form> |
||||
|
<input type="hidden" name="action" value="{{.Tool.Action}}"> |
||||
|
<div class="form-grid"> |
||||
|
{{range .Tool.Fields}} |
||||
|
<div class="form-row form-type-{{.Type}}"> |
||||
|
<label>{{.Label}}</label> |
||||
|
{{if eq .Type "textarea"}} |
||||
|
<textarea name="{{.Name}}" rows="{{.Rows}}">{{.Value}}</textarea> |
||||
|
{{else if eq .Type "select"}} |
||||
|
<select name="{{.Name}}"> |
||||
|
{{range .Options}} |
||||
|
<option value="{{.Value}}" {{if .Selected}}selected{{end}}>{{.Label}}</option> |
||||
|
{{end}} |
||||
|
</select> |
||||
|
{{else}} |
||||
|
<input type="text" name="{{.Name}}" value="{{.Value}}"> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
{{end}} |
||||
|
</div> |
||||
|
{{if .Tool.Result}} |
||||
|
<div class="result-box">{{.Tool.Result}}</div> |
||||
|
{{end}} |
||||
|
<div class="panel-actions"> |
||||
|
<button type="submit" class="btn primary" data-mock-action="run-tool">{{.Tool.PrimaryLabel}}</button> |
||||
|
</div> |
||||
|
</form> |
||||
|
</section> |
||||
|
{{end}} |
||||
@ -0,0 +1,13 @@ |
|||||
|
package teraclone |
||||
|
|
||||
|
import ( |
||||
|
"embed" |
||||
|
"io/fs" |
||||
|
) |
||||
|
|
||||
|
//go:embed web
|
||||
|
var embeddedWeb embed.FS |
||||
|
|
||||
|
func EmbeddedWebRoot() (fs.FS, error) { |
||||
|
return fs.Sub(embeddedWeb, "web") |
||||
|
} |
||||
Loading…
Reference in new issue