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