Browse Source

Initial commit

main
최원형 4 weeks ago
commit
b08695cf65
  1. 19
      .gitignore
  2. 56
      README.md
  3. 1237
      cmd/loadtest/main.go
  4. 49
      cmd/server/main.go
  5. 178
      docs/TERACLONE_진행현황_2026-06-29.md
  6. 17
      go.mod
  7. 23
      go.sum
  8. 191
      internal/cli/terminal_session.go
  9. 3
      internal/config/mock_mode.go
  10. 144
      internal/handler/terminal_handler.go
  11. 118
      internal/handler/web/index.html
  12. 158
      internal/handler/web/js/app.js
  13. 167
      internal/handler/web/js/i18n.js
  14. 40
      internal/handler/web/js/router.js
  15. 208
      internal/handler/web/js/terminal-console.js
  16. 30
      internal/handler/web/partials/cabling.html
  17. 21
      internal/handler/web/partials/events.html
  18. 6
      internal/handler/web/partials/example.html
  19. 1
      internal/handler/web/partials/overview.html
  20. 1
      internal/handler/web/partials/ports.html
  21. 1
      internal/handler/web/partials/sessions.html
  22. 1
      internal/handler/web/partials/system.html
  23. 25
      internal/handler/web/partials/terminal.html
  24. 946
      internal/handler/web/styles.css
  25. 1054
      internal/handlers/device_handler.go
  26. 1128
      internal/mock/mock_data.go
  27. 23
      internal/routes/routes.go
  28. 329
      internal/service/app_service.go
  29. 572
      internal/store/sqlite_store.go
  30. 255
      scripts/dev.ps1
  31. 89
      scripts/loadtest.ps1
  32. 84
      scripts/loadtest.sh
  33. 585
      web/static/css/style.css
  34. 817
      web/static/js/app.js
  35. 45
      web/templates/auth/login.html
  36. 165
      web/templates/layout.html
  37. 1
      web/templates/page.html
  38. 13
      web/templates/partials/block_action.html
  39. 48
      web/templates/partials/block_form.html
  40. 16
      web/templates/partials/block_key-values.html
  41. 43
      web/templates/partials/block_modal.html
  42. 15
      web/templates/partials/block_port-links.html
  43. 21
      web/templates/partials/block_port-selector.html
  44. 56
      web/templates/partials/block_table.html
  45. 41
      web/templates/partials/block_tool.html
  46. 13
      web_embed.go

19
.gitignore

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

56
README.md

@ -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`

1237
cmd/loadtest/main.go

File diff suppressed because it is too large

49
cmd/server/main.go

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

178
docs/TERACLONE_진행현황_2026-06-29.md

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

17
go.mod

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

23
go.sum

@ -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=

191
internal/cli/terminal_session.go

@ -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
}

3
internal/config/mock_mode.go

@ -0,0 +1,3 @@
package config
const MockMode = true

144
internal/handler/terminal_handler.go

@ -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,
})
}
}

118
internal/handler/web/index.html

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

158
internal/handler/web/js/app.js

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

167
internal/handler/web/js/i18n.js

@ -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";
}

40
internal/handler/web/js/router.js

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

208
internal/handler/web/js/terminal-console.js

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

30
internal/handler/web/partials/cabling.html

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

21
internal/handler/web/partials/events.html

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

6
internal/handler/web/partials/example.html

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

1
internal/handler/web/partials/overview.html

@ -0,0 +1 @@

1
internal/handler/web/partials/ports.html

@ -0,0 +1 @@

1
internal/handler/web/partials/sessions.html

@ -0,0 +1 @@

1
internal/handler/web/partials/system.html

@ -0,0 +1 @@

25
internal/handler/web/partials/terminal.html

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

946
internal/handler/web/styles.css

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

1054
internal/handlers/device_handler.go

File diff suppressed because it is too large

1128
internal/mock/mock_data.go

File diff suppressed because it is too large

23
internal/routes/routes.go

@ -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
}

329
internal/service/app_service.go

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

572
internal/store/sqlite_store.go

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

255
scripts/dev.ps1

@ -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 }
}

89
scripts/loadtest.ps1

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

84
scripts/loadtest.sh

@ -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[@]}"

585
web/static/css/style.css

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

817
web/static/js/app.js

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

45
web/templates/auth/login.html

@ -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}}

165
web/templates/layout.html

@ -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}}

1
web/templates/page.html

@ -0,0 +1 @@
{{define "page"}}{{end}}

13
web/templates/partials/block_action.html

@ -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}}

48
web/templates/partials/block_form.html

@ -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}}

16
web/templates/partials/block_key-values.html

@ -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}}

43
web/templates/partials/block_modal.html

@ -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}}

15
web/templates/partials/block_port-links.html

@ -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}}

21
web/templates/partials/block_port-selector.html

@ -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}}

56
web/templates/partials/block_table.html

@ -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}}

41
web/templates/partials/block_tool.html

@ -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}}

13
web_embed.go

@ -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…
Cancel
Save