commit b08695cf658bfc2037f9b87aa21c1ec4146c65f4 Author: bless Date: Wed Jul 8 14:18:32 2026 +0900 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fca4716 --- /dev/null +++ b/.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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea2b2cf --- /dev/null +++ b/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` diff --git a/cmd/loadtest/main.go b/cmd/loadtest/main.go new file mode 100644 index 0000000..456205d --- /dev/null +++ b/cmd/loadtest/main.go @@ -0,0 +1,1237 @@ +package main + +import ( + "bufio" + "context" + "crypto/tls" + "encoding/json" + "flag" + "fmt" + "log" + "math" + "math/rand" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "regexp" + "runtime" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/gorilla/websocket" +) + +const ( + modeLoadTest = "loadtest" + modeFindMaxUsers = "find-max-users" + + labelRoot = "GET / 메인" + labelHealth = "GET /health 상태확인" + labelWebSocket = "WS /ws 터미널" +) + +var ( + netstatPIDPattern = regexp.MustCompile(`LISTENING\s+(\d+)\s*$`) + ssPIDPattern = regexp.MustCompile(`pid=(\d+)`) + globalAggregator atomic.Pointer[aggregator] +) + +type config struct { + mode string + targetURL string + users int + duration time.Duration + rampUp time.Duration + timeout time.Duration + wsTimeout time.Duration + pauseMin time.Duration + pauseMax time.Duration + wsRatio float64 + wsMaxConn int + wsCommand string + insecure bool + monitorInterval time.Duration + + searchStartUsers int + searchStepUsers int + searchMaxUsers int + maxFailureRate float64 + maxP95 time.Duration +} + +type scenario int + +const ( + scenarioRoot scenario = iota + scenarioHealth + scenarioWebSocket +) + +type result struct { + name string + statusCode int + duration time.Duration + err error +} + +type aggregator struct { + mu sync.Mutex + startedAt time.Time + finishedAt time.Time + total int64 + success int64 + failed int64 + statusCounts map[int]int64 + scenarioCounts map[string]int64 + errorCounts map[string]int64 + latencies []time.Duration + scenarioLatencies map[string][]time.Duration +} + +type aggregateSnapshot struct { + total int64 + success int64 + failed int64 +} + +type testReport struct { + Config config + StartedUsers int64 + Elapsed time.Duration + Total int64 + Success int64 + Failed int64 + SuccessRate float64 + RequestsPerSecond float64 + AverageLatency time.Duration + P50 time.Duration + P95 time.Duration + P99 time.Duration + MaxLatency time.Duration + StatusCounts map[int]int64 + ScenarioCounts map[string]int64 + ErrorCounts map[string]int64 + ScenarioStats map[string]scenarioStat + Memory serverMemoryStatsSnapshot +} + +type scenarioStat struct { + Count int64 + Average time.Duration + P95 time.Duration +} + +type socketResponse struct { + Type string `json:"type"` + Data string `json:"data"` +} + +type socketRequest struct { + Type string `json:"type"` + Data string `json:"data"` +} + +type serverMemorySample struct { + PID int + WorkingSetBytes uint64 + PrivateBytes uint64 +} + +type serverMemoryStats struct { + Enabled bool + LocalTarget bool + TargetPort string + ProcessName string + PID int + LastWorkingSet uint64 + LastPrivate uint64 + MaxWorkingSet uint64 + MaxPrivate uint64 + SampleCount int64 + LastError string + Verbose bool + mu sync.Mutex +} + +type serverMemoryStatsSnapshot struct { + Enabled bool + LocalTarget bool + ProcessName string + PID int + LastWorkingSet uint64 + LastPrivate uint64 + MaxWorkingSet uint64 + MaxPrivate uint64 + SampleCount int64 + LastError string +} + +func main() { + cfg := parseFlags() + if err := validateConfig(cfg); err != nil { + log.Fatalf("잘못된 설정입니다: %v", err) + } + + switch cfg.mode { + case modeLoadTest: + report := runLoadTest(cfg, true, true) + printSummary(report) + case modeFindMaxUsers: + runMaxUserSearch(cfg) + default: + log.Fatalf("지원하지 않는 실행 모드입니다: %s", cfg.mode) + } +} + +func parseFlags() config { + cfg := config{} + + flag.StringVar(&cfg.mode, "mode", modeLoadTest, "실행 모드(loadtest, find-max-users)") + flag.StringVar(&cfg.targetURL, "target", "http://localhost:8080", "테스트 대상 기본 URL") + flag.IntVar(&cfg.users, "users", 200, "동시 가상 사용자 수") + flag.DurationVar(&cfg.duration, "duration", 30*time.Second, "전체 테스트 지속 시간") + flag.DurationVar(&cfg.rampUp, "ramp-up", 10*time.Second, "전체 사용자를 점진적으로 올리는 시간") + flag.DurationVar(&cfg.timeout, "timeout", 5*time.Second, "HTTP 요청 타임아웃") + flag.DurationVar(&cfg.wsTimeout, "ws-timeout", 15*time.Second, "웹소켓 연결/읽기/쓰기 타임아웃") + flag.DurationVar(&cfg.pauseMin, "pause-min", 150*time.Millisecond, "사용자 액션 사이 최소 대기 시간") + flag.DurationVar(&cfg.pauseMax, "pause-max", 600*time.Millisecond, "사용자 액션 사이 최대 대기 시간") + flag.Float64Var(&cfg.wsRatio, "ws-ratio", 0.05, "전체 시나리오 중 웹소켓 비율(0~1)") + flag.IntVar(&cfg.wsMaxConn, "ws-max-conns", 20, "동시에 열 수 있는 최대 웹소켓 세션 수") + flag.StringVar(&cfg.wsCommand, "ws-command", "Get-Location", "웹소켓 연결 후 전송할 PowerShell 명령어") + flag.BoolVar(&cfg.insecure, "insecure", false, "wss 대상의 TLS 인증서 검증 건너뛰기") + flag.DurationVar(&cfg.monitorInterval, "monitor-interval", 1*time.Second, "로컬 서버 메모리 실시간 출력 주기") + + flag.IntVar(&cfg.searchStartUsers, "search-start-users", 100, "최대 동접 탐색 시작 사용자 수") + flag.IntVar(&cfg.searchStepUsers, "search-step-users", 100, "최대 동접 탐색 증가 폭") + flag.IntVar(&cfg.searchMaxUsers, "search-max-users", 2000, "최대 동접 탐색 상한 사용자 수") + flag.Float64Var(&cfg.maxFailureRate, "max-failure-rate", 1.0, "허용 실패율 퍼센트") + flag.DurationVar(&cfg.maxP95, "max-p95", 1*time.Second, "허용 P95 지연 시간") + + flag.Parse() + return cfg +} + +func validateConfig(cfg config) error { + if cfg.users <= 0 { + return fmt.Errorf("users must be greater than 0") + } + if cfg.duration <= 0 { + return fmt.Errorf("duration must be greater than 0") + } + if cfg.rampUp < 0 { + return fmt.Errorf("ramp-up cannot be negative") + } + if cfg.timeout <= 0 { + return fmt.Errorf("timeout must be greater than 0") + } + if cfg.wsTimeout <= 0 { + return fmt.Errorf("ws-timeout must be greater than 0") + } + if cfg.pauseMin < 0 || cfg.pauseMax < 0 { + return fmt.Errorf("pauses cannot be negative") + } + if cfg.pauseMax < cfg.pauseMin { + return fmt.Errorf("pause-max must be greater than or equal to pause-min") + } + if cfg.wsRatio < 0 || cfg.wsRatio > 1 { + return fmt.Errorf("ws-ratio must be between 0 and 1") + } + if cfg.wsMaxConn <= 0 { + return fmt.Errorf("ws-max-conns must be greater than 0") + } + if cfg.monitorInterval < 0 { + return fmt.Errorf("monitor-interval cannot be negative") + } + if cfg.searchStartUsers <= 0 { + return fmt.Errorf("search-start-users must be greater than 0") + } + if cfg.searchStepUsers <= 0 { + return fmt.Errorf("search-step-users must be greater than 0") + } + if cfg.searchMaxUsers < cfg.searchStartUsers { + return fmt.Errorf("search-max-users must be greater than or equal to search-start-users") + } + if cfg.maxFailureRate < 0 || cfg.maxFailureRate > 100 { + return fmt.Errorf("max-failure-rate must be between 0 and 100") + } + if cfg.maxP95 <= 0 { + return fmt.Errorf("max-p95 must be greater than 0") + } + return nil +} + +func runMaxUserSearch(cfg config) { + log.Printf( + "최대 동접 탐색 시작: 시작=%d 증가폭=%d 상한=%d 허용실패율=%.2f%% 허용P95=%s", + cfg.searchStartUsers, + cfg.searchStepUsers, + cfg.searchMaxUsers, + cfg.maxFailureRate, + cfg.maxP95, + ) + + var best *testReport + for users := cfg.searchStartUsers; users <= cfg.searchMaxUsers; users += cfg.searchStepUsers { + stageCfg := cfg + stageCfg.users = users + + fmt.Println() + fmt.Printf("=== 동접 %d명 탐색 단계 ===\n", users) + + report := runLoadTest(stageCfg, true, false) + printCompactStageResult(report, cfg.maxFailureRate, cfg.maxP95) + + if isStable(report, cfg.maxFailureRate, cfg.maxP95) { + reportCopy := report + best = &reportCopy + continue + } + + fmt.Println() + fmt.Println("최대 동접 탐색 결과") + if best == nil { + fmt.Printf("안정 구간을 찾지 못했습니다. 시작 사용자 수 %d명부터 기준을 넘었습니다.\n", cfg.searchStartUsers) + } else { + fmt.Printf("최대 안정 동시 사용자 수: %d명\n", best.Config.users) + fmt.Printf("기준: 실패율 %.2f%% 이하, P95 %s 이하\n", cfg.maxFailureRate, cfg.maxP95) + fmt.Printf("해당 단계 결과: 성공률 %.2f%%, P95 %s, 초당 요청 수 %.2f\n", best.SuccessRate, best.P95, best.RequestsPerSecond) + if best.Memory.SampleCount > 0 { + fmt.Printf("해당 단계 최대 서버 사용 메모리: %s\n", formatBytes(best.Memory.MaxPrivate)) + } + } + return + } + + fmt.Println() + fmt.Println("최대 동접 탐색 결과") + if best == nil { + fmt.Println("안정 구간을 찾지 못했습니다.") + return + } + + fmt.Printf("탐색 상한까지 모두 통과했습니다. 현재 최대 안정 동시 사용자 수는 최소 %d명입니다.\n", best.Config.users) + fmt.Printf("기준: 실패율 %.2f%% 이하, P95 %s 이하\n", cfg.maxFailureRate, cfg.maxP95) + fmt.Printf("마지막 단계 결과: 성공률 %.2f%%, P95 %s, 초당 요청 수 %.2f\n", best.SuccessRate, best.P95, best.RequestsPerSecond) + if best.Memory.SampleCount > 0 { + fmt.Printf("마지막 단계 최대 서버 사용 메모리: %s\n", formatBytes(best.Memory.MaxPrivate)) + } +} + +func printCompactStageResult(report testReport, maxFailureRate float64, maxP95 time.Duration) { + result := "실패" + if isStable(report, maxFailureRate, maxP95) { + result = "통과" + } + + fmt.Printf( + "결과=%s 사용자=%d 성공률=%.2f%% 실패=%d P95=%s RPS=%.2f", + result, + report.Config.users, + report.SuccessRate, + report.Failed, + report.P95, + report.RequestsPerSecond, + ) + if report.Memory.SampleCount > 0 { + fmt.Printf(" 최대서버메모리=%s", formatBytes(report.Memory.MaxPrivate)) + } + fmt.Println() +} + +func isStable(report testReport, maxFailureRate float64, maxP95 time.Duration) bool { + if report.Total == 0 { + return false + } + failureRate := 100 - report.SuccessRate + return failureRate <= maxFailureRate && report.P95 <= maxP95 +} + +func runLoadTest(cfg config, printStartLog bool, verboseMemory bool) testReport { + if printStartLog { + log.Printf( + "부하 테스트 시작: 대상=%s 사용자=%d 지속시간=%s 램프업=%s 웹소켓비율=%.2f", + cfg.targetURL, + cfg.users, + cfg.duration, + cfg.rampUp, + cfg.wsRatio, + ) + } + + agg := newAggregator() + globalAggregator.Store(agg) + agg.startedAt = time.Now() + + ctx, cancel := context.WithTimeout(context.Background(), cfg.duration) + defer cancel() + + httpClient := newHTTPClient(cfg.timeout) + wsURL, err := deriveWebSocketURL(cfg.targetURL) + if err != nil { + log.Fatalf("잘못된 대상 URL입니다: %v", err) + } + + memoryStats := startServerMemoryMonitor(ctx, cfg, verboseMemory) + wsSlots := make(chan struct{}, cfg.wsMaxConn) + var startedUsers atomic.Int64 + var wg sync.WaitGroup + + for i := 0; i < cfg.users; i++ { + wg.Add(1) + go func(userID int) { + defer wg.Done() + delay := rampDelay(cfg.rampUp, cfg.users, userID) + if !sleepWithContext(ctx, delay) { + return + } + + startedUsers.Add(1) + runVirtualUser(ctx, userID, cfg, httpClient, wsURL, wsSlots, agg) + }(i) + } + + wg.Wait() + agg.finishedAt = time.Now() + + return buildReport(cfg, agg, startedUsers.Load(), memoryStats.snapshot()) +} + +func newHTTPClient(timeout time.Duration) *http.Client { + transport := &http.Transport{ + MaxIdleConns: 512, + MaxIdleConnsPerHost: 512, + MaxConnsPerHost: 0, + IdleConnTimeout: 90 * time.Second, + } + + return &http.Client{ + Timeout: timeout, + Transport: transport, + } +} + +func deriveWebSocketURL(target string) (string, error) { + parsed, err := url.Parse(target) + if err != nil { + return "", err + } + + switch parsed.Scheme { + case "http": + parsed.Scheme = "ws" + case "https": + parsed.Scheme = "wss" + case "ws", "wss": + default: + return "", fmt.Errorf("지원하지 않는 스킴 %q", parsed.Scheme) + } + + parsed.Path = "/ws" + parsed.RawQuery = "" + parsed.Fragment = "" + return parsed.String(), nil +} + +func rampDelay(rampUp time.Duration, users int, userID int) time.Duration { + if rampUp <= 0 || users <= 1 { + return 0 + } + + step := float64(rampUp) / float64(users) + return time.Duration(step * float64(userID)) +} + +func sleepWithContext(ctx context.Context, d time.Duration) bool { + if d <= 0 { + return true + } + + timer := time.NewTimer(d) + defer timer.Stop() + + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} + +func runVirtualUser(ctx context.Context, userID int, cfg config, httpClient *http.Client, wsURL string, wsSlots chan struct{}, agg *aggregator) { + rng := rand.New(rand.NewSource(time.Now().UnixNano() + int64(userID)*7919)) + + for { + if ctx.Err() != nil { + return + } + + currentScenario := chooseScenario(rng, cfg.wsRatio) + var res result + + switch currentScenario { + case scenarioRoot: + res = hitEndpoint(ctx, httpClient, cfg.targetURL, labelRoot) + case scenarioHealth: + res = hitEndpoint(ctx, httpClient, strings.TrimRight(cfg.targetURL, "/")+"/health", labelHealth) + default: + res = hitWebSocketWithLimit(ctx, wsURL, cfg.wsCommand, cfg.wsTimeout, cfg.insecure, wsSlots) + } + + agg.add(res) + + pause := cfg.pauseMin + if cfg.pauseMax > cfg.pauseMin { + pause += time.Duration(rng.Int63n(int64(cfg.pauseMax - cfg.pauseMin))) + } + + if !sleepWithContext(ctx, pause) { + return + } + } +} + +func chooseScenario(rng *rand.Rand, wsRatio float64) scenario { + roll := rng.Float64() + if roll < wsRatio { + return scenarioWebSocket + } + if roll < wsRatio+0.30 { + return scenarioHealth + } + return scenarioRoot +} + +func hitEndpoint(ctx context.Context, client *http.Client, endpoint string, name string) result { + startedAt := time.Now() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return result{name: name, duration: time.Since(startedAt), err: err} + } + + resp, err := client.Do(req) + if err != nil { + return result{name: name, duration: time.Since(startedAt), err: err} + } + defer resp.Body.Close() + + return result{ + name: name, + statusCode: resp.StatusCode, + duration: time.Since(startedAt), + } +} + +func hitWebSocketWithLimit(ctx context.Context, wsURL string, command string, timeout time.Duration, insecure bool, wsSlots chan struct{}) result { + select { + case wsSlots <- struct{}{}: + defer func() { <-wsSlots }() + return hitWebSocket(ctx, wsURL, command, timeout, insecure) + case <-ctx.Done(): + return result{name: labelWebSocket, err: ctx.Err()} + } +} + +func hitWebSocket(ctx context.Context, wsURL string, command string, timeout time.Duration, insecure bool) result { + startedAt := time.Now() + + dialer := websocket.Dialer{ + HandshakeTimeout: timeout, + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: insecure, + }, + } + + conn, _, err := dialer.DialContext(ctx, wsURL, nil) + if err != nil { + return result{name: labelWebSocket, duration: time.Since(startedAt), err: err} + } + defer conn.Close() + + _ = conn.SetReadDeadline(time.Now().Add(timeout)) + _ = conn.SetWriteDeadline(time.Now().Add(timeout)) + + var welcome socketResponse + if err := conn.ReadJSON(&welcome); err != nil { + return result{name: labelWebSocket, duration: time.Since(startedAt), err: err} + } + + req := socketRequest{ + Type: "input", + Data: command, + } + + if err := conn.WriteJSON(req); err != nil { + return result{name: labelWebSocket, duration: time.Since(startedAt), err: err} + } + + var output socketResponse + if err := conn.ReadJSON(&output); err != nil { + return result{name: labelWebSocket, duration: time.Since(startedAt), err: err} + } + + if welcome.Type == "error" || output.Type == "error" { + return result{ + name: labelWebSocket, + duration: time.Since(startedAt), + err: fmt.Errorf("서버가 웹소켓 오류를 반환했습니다"), + } + } + + return result{ + name: labelWebSocket, + statusCode: http.StatusSwitchingProtocols, + duration: time.Since(startedAt), + } +} + +func newAggregator() *aggregator { + return &aggregator{ + statusCounts: make(map[int]int64), + scenarioCounts: make(map[string]int64), + errorCounts: make(map[string]int64), + scenarioLatencies: make(map[string][]time.Duration), + } +} + +func (a *aggregator) add(res result) { + a.mu.Lock() + defer a.mu.Unlock() + + a.total++ + a.scenarioCounts[res.name]++ + a.latencies = append(a.latencies, res.duration) + a.scenarioLatencies[res.name] = append(a.scenarioLatencies[res.name], res.duration) + + if res.err != nil { + a.failed++ + a.errorCounts[shortError(res.err)]++ + return + } + + a.success++ + a.statusCounts[res.statusCode]++ +} + +func (a *aggregator) snapshot() aggregateSnapshot { + a.mu.Lock() + defer a.mu.Unlock() + + return aggregateSnapshot{ + total: a.total, + success: a.success, + failed: a.failed, + } +} + +func buildReport(cfg config, agg *aggregator, startedUsers int64, memory serverMemoryStatsSnapshot) testReport { + agg.mu.Lock() + defer agg.mu.Unlock() + + elapsed := agg.finishedAt.Sub(agg.startedAt) + if elapsed <= 0 { + elapsed = time.Millisecond + } + + successRate := 0.0 + if agg.total > 0 { + successRate = float64(agg.success) / float64(agg.total) * 100 + } + + report := testReport{ + Config: cfg, + StartedUsers: startedUsers, + Elapsed: elapsed, + Total: agg.total, + Success: agg.success, + Failed: agg.failed, + SuccessRate: successRate, + RequestsPerSecond: float64(agg.total) / elapsed.Seconds(), + StatusCounts: copyIntMap(agg.statusCounts), + ScenarioCounts: copyStringMap(agg.scenarioCounts), + ErrorCounts: copyStringMap(agg.errorCounts), + ScenarioStats: make(map[string]scenarioStat), + Memory: memory, + } + + if len(agg.latencies) > 0 { + report.AverageLatency = averageDuration(agg.latencies) + report.P50 = percentile(agg.latencies, 50) + report.P95 = percentile(agg.latencies, 95) + report.P99 = percentile(agg.latencies, 99) + report.MaxLatency = maxDuration(agg.latencies) + } + + for name, latencies := range agg.scenarioLatencies { + report.ScenarioStats[name] = scenarioStat{ + Count: agg.scenarioCounts[name], + Average: averageDuration(latencies), + P95: percentile(latencies, 95), + } + } + + return report +} + +func shortError(err error) string { + if err == nil { + return "" + } + + msg := normalizeError(err.Error()) + if len(msg) > 120 { + return msg[:120] + } + return msg +} + +func normalizeError(msg string) string { + switch { + case strings.Contains(msg, "i/o timeout"): + return "입출력 시간 초과" + case strings.Contains(msg, "context deadline exceeded"): + return "컨텍스트 제한 시간 초과" + case strings.Contains(msg, "server returned websocket error"): + return "서버가 웹소켓 오류를 반환했습니다" + default: + return msg + } +} + +func printSummary(report testReport) { + fmt.Println() + fmt.Println("=== 부하 테스트 요약 ===") + fmt.Printf("대상: %s\n", report.Config.targetURL) + fmt.Printf("시작한 사용자: %d/%d\n", report.StartedUsers, report.Config.users) + fmt.Printf("지속 시간: %s\n", report.Elapsed.Truncate(time.Millisecond)) + fmt.Printf("총 요청 수: %d\n", report.Total) + fmt.Printf("성공: %d\n", report.Success) + fmt.Printf("실패: %d\n", report.Failed) + fmt.Printf("성공률: %.2f%%\n", report.SuccessRate) + fmt.Printf("초당 요청 수: %.2f\n", report.RequestsPerSecond) + + if report.Total > 0 { + fmt.Println() + fmt.Println("지연 시간") + fmt.Printf("평균: %s\n", report.AverageLatency) + fmt.Printf("P50: %s\n", report.P50) + fmt.Printf("P95: %s\n", report.P95) + fmt.Printf("P99: %s\n", report.P99) + fmt.Printf("최대: %s\n", report.MaxLatency) + } + + if len(report.ScenarioCounts) > 0 { + fmt.Println() + fmt.Println("시나리오별 통계") + for _, name := range sortedStringKeys(report.ScenarioCounts) { + stat := report.ScenarioStats[name] + fmt.Printf("%s: %d", name, stat.Count) + fmt.Printf(" (평균 %s, p95 %s)\n", stat.Average, stat.P95) + } + } + + if len(report.StatusCounts) > 0 { + fmt.Println() + fmt.Println("HTTP/웹소켓 상태 코드") + for _, code := range sortedIntKeys(report.StatusCounts) { + fmt.Printf("%d%s: %d\n", code, describeStatusCode(code), report.StatusCounts[code]) + } + } + + printMemorySummary(report.Memory) + + if len(report.ErrorCounts) > 0 { + fmt.Println() + fmt.Println("오류") + for _, msg := range sortedStringKeys(report.ErrorCounts) { + fmt.Printf("%s: %d\n", msg, report.ErrorCounts[msg]) + } + } +} + +func describeStatusCode(code int) string { + switch code { + case http.StatusOK: + return " (HTTP 정상 응답)" + case http.StatusSwitchingProtocols: + return " (웹소켓 연결 성공)" + default: + return "" + } +} + +func printMemorySummary(memory serverMemoryStatsSnapshot) { + if !memory.Enabled { + return + } + + fmt.Println() + fmt.Println("서버 메모리") + + if !memory.LocalTarget { + fmt.Println("로컬 서버가 아닌 대상이라 메모리 모니터링을 건너뛰었습니다.") + return + } + + if memory.SampleCount == 0 { + fmt.Printf("메모리 수집 실패") + if memory.LastError != "" { + fmt.Printf(": %s", memory.LastError) + } + fmt.Println() + return + } + + fmt.Printf("PID: %d\n", memory.PID) + if memory.ProcessName != "" { + fmt.Printf("프로세스: %s\n", memory.ProcessName) + } + fmt.Printf("현재 서버 사용 메모리: %s\n", formatBytes(memory.LastPrivate)) + fmt.Printf("테스트 중 최대 서버 사용 메모리: %s\n", formatBytes(memory.MaxPrivate)) + fmt.Printf("현재 작업 집합 메모리: %s\n", formatBytes(memory.LastWorkingSet)) + fmt.Printf("테스트 중 최대 작업 집합 메모리: %s\n", formatBytes(memory.MaxWorkingSet)) + fmt.Printf("수집 샘플 수: %d\n", memory.SampleCount) +} + +func averageDuration(values []time.Duration) time.Duration { + if len(values) == 0 { + return 0 + } + + var total time.Duration + for _, v := range values { + total += v + } + return total / time.Duration(len(values)) +} + +func percentile(values []time.Duration, p float64) time.Duration { + if len(values) == 0 { + return 0 + } + + sorted := append([]time.Duration(nil), values...) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i] < sorted[j] + }) + + rank := int(math.Ceil((p / 100) * float64(len(sorted)))) + if rank < 1 { + rank = 1 + } + if rank > len(sorted) { + rank = len(sorted) + } + return sorted[rank-1] +} + +func maxDuration(values []time.Duration) time.Duration { + if len(values) == 0 { + return 0 + } + + max := values[0] + for _, v := range values[1:] { + if v > max { + max = v + } + } + return max +} + +func copyStringMap(src map[string]int64) map[string]int64 { + dst := make(map[string]int64, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +func copyIntMap(src map[int]int64) map[int]int64 { + dst := make(map[int]int64, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +func sortedStringKeys[T any](m map[string]T) []string { + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func sortedIntKeys[T any](m map[int]T) []int { + keys := make([]int, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Ints(keys) + return keys +} + +func startServerMemoryMonitor(ctx context.Context, cfg config, verbose bool) *serverMemoryStats { + stats := &serverMemoryStats{ + Enabled: cfg.monitorInterval > 0, + Verbose: verbose, + } + if !stats.Enabled { + return stats + } + + port, local := extractTargetPortIfLocal(cfg.targetURL) + stats.LocalTarget = local + stats.TargetPort = port + if !local { + if verbose { + log.Printf("실시간 메모리 모니터링 생략: 로컬 대상이 아닙니다 (%s)", cfg.targetURL) + } + return stats + } + + if verbose { + log.Printf("실시간 메모리 모니터링 활성화: 포트=%s 주기=%s", port, cfg.monitorInterval) + } + + go func() { + ticker := time.NewTicker(cfg.monitorInterval) + defer ticker.Stop() + + startedAt := time.Now() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + sample, processName, err := captureServerMemorySample(port) + stats.mu.Lock() + if err != nil { + stats.LastError = err.Error() + stats.mu.Unlock() + continue + } + + stats.PID = sample.PID + stats.ProcessName = processName + stats.LastWorkingSet = sample.WorkingSetBytes + stats.LastPrivate = sample.PrivateBytes + if sample.WorkingSetBytes > stats.MaxWorkingSet { + stats.MaxWorkingSet = sample.WorkingSetBytes + } + if sample.PrivateBytes > stats.MaxPrivate { + stats.MaxPrivate = sample.PrivateBytes + } + stats.SampleCount++ + stats.LastError = "" + stats.mu.Unlock() + + if stats.Verbose { + snapshot := aggregateSnapshotIfAvailable() + log.Printf( + "[실시간] 경과=%s 요청=%d 성공=%d 실패=%d 서버사용메모리=%s 작업집합=%s PID=%d", + time.Since(startedAt).Truncate(time.Second), + snapshot.total, + snapshot.success, + snapshot.failed, + formatBytes(sample.PrivateBytes), + formatBytes(sample.WorkingSetBytes), + sample.PID, + ) + } + } + } + }() + + return stats +} + +func (s *serverMemoryStats) snapshot() serverMemoryStatsSnapshot { + if s == nil { + return serverMemoryStatsSnapshot{} + } + + s.mu.Lock() + defer s.mu.Unlock() + + return serverMemoryStatsSnapshot{ + Enabled: s.Enabled, + LocalTarget: s.LocalTarget, + ProcessName: s.ProcessName, + PID: s.PID, + LastWorkingSet: s.LastWorkingSet, + LastPrivate: s.LastPrivate, + MaxWorkingSet: s.MaxWorkingSet, + MaxPrivate: s.MaxPrivate, + SampleCount: s.SampleCount, + LastError: s.LastError, + } +} + +func aggregateSnapshotIfAvailable() aggregateSnapshot { + agg := globalAggregator.Load() + if agg == nil { + return aggregateSnapshot{} + } + return agg.snapshot() +} + +func captureServerMemorySample(port string) (serverMemorySample, string, error) { + pid, err := findPIDByPort(port) + if err != nil { + return serverMemorySample{}, "", err + } + + workingSet, privateBytes, processName, err := readProcessMemory(pid) + if err != nil { + return serverMemorySample{}, "", err + } + + return serverMemorySample{ + PID: pid, + WorkingSetBytes: workingSet, + PrivateBytes: privateBytes, + }, processName, nil +} + +func extractTargetPortIfLocal(raw string) (string, bool) { + parsed, err := url.Parse(raw) + if err != nil { + return "", false + } + + host := parsed.Hostname() + if host == "" { + return "", false + } + + if !isLocalHost(host) { + return "", false + } + + port := parsed.Port() + if port != "" { + return port, true + } + + switch parsed.Scheme { + case "http", "ws": + return "80", true + case "https", "wss": + return "443", true + default: + return "", false + } +} + +func isLocalHost(host string) bool { + switch strings.ToLower(host) { + case "localhost", "127.0.0.1", "::1": + return true + default: + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() + } +} + +func findPIDByPort(port string) (int, error) { + switch runtime.GOOS { + case "windows": + return findPIDByPortWindows(port) + case "linux": + return findPIDByPortLinux(port) + default: + return 0, fmt.Errorf("현재 환경에서는 서버 메모리 모니터링을 지원하지 않습니다") + } +} + +func findPIDByPortWindows(port string) (int, error) { + out, err := exec.Command("netstat", "-ano", "-p", "TCP").CombinedOutput() + if err != nil { + return 0, fmt.Errorf("netstat 실행 실패: %w", err) + } + + lines := strings.Split(string(out), "\n") + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if !strings.Contains(trimmed, "LISTENING") { + continue + } + if !strings.Contains(trimmed, ":"+port) { + continue + } + matches := netstatPIDPattern.FindStringSubmatch(trimmed) + if len(matches) != 2 { + continue + } + pid, convErr := strconv.Atoi(matches[1]) + if convErr != nil { + continue + } + return pid, nil + } + + return 0, fmt.Errorf("포트 %s에서 LISTENING 중인 프로세스를 찾지 못했습니다", port) +} + +func findPIDByPortLinux(port string) (int, error) { + out, err := exec.Command("ss", "-ltnp").CombinedOutput() + if err == nil { + lines := strings.Split(string(out), "\n") + for _, line := range lines { + if !strings.Contains(line, ":"+port) { + continue + } + matches := ssPIDPattern.FindStringSubmatch(line) + if len(matches) != 2 { + continue + } + pid, convErr := strconv.Atoi(matches[1]) + if convErr == nil { + return pid, nil + } + } + } + + out, lsofErr := exec.Command("lsof", "-nP", "-iTCP:"+port, "-sTCP:LISTEN", "-t").CombinedOutput() + if lsofErr != nil { + if err != nil { + return 0, fmt.Errorf("ss/lsof 실행 실패: %v / %w", err, lsofErr) + } + return 0, fmt.Errorf("lsof 실행 실패: %w", lsofErr) + } + + text := strings.TrimSpace(string(out)) + if text == "" { + return 0, fmt.Errorf("포트 %s에서 LISTEN 중인 프로세스를 찾지 못했습니다", port) + } + + pid, convErr := strconv.Atoi(strings.Split(text, "\n")[0]) + if convErr != nil { + return 0, fmt.Errorf("lsof PID 파싱 실패: %w", convErr) + } + return pid, nil +} + +func readProcessMemory(pid int) (uint64, uint64, string, error) { + switch runtime.GOOS { + case "windows": + return readProcessMemoryWindows(pid) + case "linux": + return readProcessMemoryLinux(pid) + default: + return 0, 0, "", fmt.Errorf("현재 환경에서는 프로세스 메모리 조회를 지원하지 않습니다") + } +} + +func readProcessMemoryWindows(pid int) (uint64, uint64, string, error) { + script := fmt.Sprintf(`$p = Get-Process -Id %d -ErrorAction Stop; Write-Output ($p.WorkingSet64.ToString() + "," + $p.PrivateMemorySize64.ToString() + "," + $p.ProcessName)`, pid) + out, err := exec.Command("powershell", "-NoLogo", "-NoProfile", "-Command", script).CombinedOutput() + if err != nil { + return 0, 0, "", fmt.Errorf("Get-Process 실행 실패: %w", err) + } + + text := strings.TrimSpace(string(out)) + parts := strings.SplitN(text, ",", 3) + if len(parts) != 3 { + return 0, 0, "", fmt.Errorf("메모리 정보 파싱 실패: %s", text) + } + + workingSet, err := strconv.ParseUint(strings.TrimSpace(parts[0]), 10, 64) + if err != nil { + return 0, 0, "", fmt.Errorf("작업 집합 메모리 파싱 실패: %w", err) + } + + privateBytes, err := strconv.ParseUint(strings.TrimSpace(parts[1]), 10, 64) + if err != nil { + return 0, 0, "", fmt.Errorf("서버 사용 메모리 파싱 실패: %w", err) + } + + return workingSet, privateBytes, strings.TrimSpace(parts[2]), nil +} + +func readProcessMemoryLinux(pid int) (uint64, uint64, string, error) { + statusPath := fmt.Sprintf("/proc/%d/status", pid) + file, err := os.Open(statusPath) + if err != nil { + return 0, 0, "", fmt.Errorf("/proc 상태 파일 열기 실패: %w", err) + } + defer file.Close() + + var ( + processName string + vmRSSKB uint64 + vmSizeKB uint64 + ) + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + switch { + case strings.HasPrefix(line, "Name:"): + processName = strings.TrimSpace(strings.TrimPrefix(line, "Name:")) + case strings.HasPrefix(line, "VmRSS:"): + value, parseErr := parseProcKBValue(line) + if parseErr == nil { + vmRSSKB = value + } + case strings.HasPrefix(line, "VmSize:"): + value, parseErr := parseProcKBValue(line) + if parseErr == nil { + vmSizeKB = value + } + } + } + + if err := scanner.Err(); err != nil { + return 0, 0, "", fmt.Errorf("/proc 상태 파일 읽기 실패: %w", err) + } + + if vmRSSKB == 0 && vmSizeKB == 0 { + return 0, 0, "", fmt.Errorf("리눅스 프로세스 메모리 정보를 찾지 못했습니다") + } + + return vmRSSKB * 1024, vmSizeKB * 1024, processName, nil +} + +func parseProcKBValue(line string) (uint64, error) { + fields := strings.Fields(line) + if len(fields) < 2 { + return 0, fmt.Errorf("메모리 값 형식이 올바르지 않습니다: %s", line) + } + return strconv.ParseUint(fields[1], 10, 64) +} + +func formatBytes(v uint64) string { + const unit = 1024 + if v < unit { + return fmt.Sprintf("%d B", v) + } + + div, exp := uint64(unit), 0 + for n := v / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + + suffixes := []string{"KB", "MB", "GB", "TB"} + return fmt.Sprintf("%.2f %s", float64(v)/float64(div), suffixes[exp]) +} + +func init() { + log.SetOutput(os.Stdout) + log.SetFlags(log.LstdFlags | log.Lmicroseconds) +} + +func (r socketResponse) String() string { + payload, _ := json.Marshal(r) + return string(payload) +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..7abf1f6 --- /dev/null +++ b/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) + } +} diff --git a/docs/TERACLONE_진행현황_2026-06-29.md b/docs/TERACLONE_진행현황_2026-06-29.md new file mode 100644 index 0000000..065626b --- /dev/null +++ b/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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2d177eb --- /dev/null +++ b/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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..53ada06 --- /dev/null +++ b/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= diff --git a/internal/cli/terminal_session.go b/internal/cli/terminal_session.go new file mode 100644 index 0000000..30841d2 --- /dev/null +++ b/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 +} diff --git a/internal/config/mock_mode.go b/internal/config/mock_mode.go new file mode 100644 index 0000000..6f594ae --- /dev/null +++ b/internal/config/mock_mode.go @@ -0,0 +1,3 @@ +package config + +const MockMode = true diff --git a/internal/handler/terminal_handler.go b/internal/handler/terminal_handler.go new file mode 100644 index 0000000..af7e389 --- /dev/null +++ b/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, + }) + } +} diff --git a/internal/handler/web/index.html b/internal/handler/web/index.html new file mode 100644 index 0000000..7a07690 --- /dev/null +++ b/internal/handler/web/index.html @@ -0,0 +1,118 @@ + + + + + + TeraClone Console Server + + + + +
+ + +
+
+ +
+
Integrated Console Server
+
Overview
+
+
+
+ + +
+
48 Serial Ports
+
Dual LAN
+
Remote Access
+
Connecting...
+
+
+ +
+
+
+ + + + + + + + diff --git a/internal/handler/web/js/app.js b/internal/handler/web/js/app.js new file mode 100644 index 0000000..57f1e1b --- /dev/null +++ b/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(); +} diff --git a/internal/handler/web/js/i18n.js b/internal/handler/web/js/i18n.js new file mode 100644 index 0000000..5672daa --- /dev/null +++ b/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"; +} diff --git a/internal/handler/web/js/router.js b/internal/handler/web/js/router.js new file mode 100644 index 0000000..dc0f3c0 --- /dev/null +++ b/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(); +} diff --git a/internal/handler/web/js/terminal-console.js b/internal/handler/web/js/terminal-console.js new file mode 100644 index 0000000..2f1ef52 --- /dev/null +++ b/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"); + }); + } +} diff --git a/internal/handler/web/partials/cabling.html b/internal/handler/web/partials/cabling.html new file mode 100644 index 0000000..21515f3 --- /dev/null +++ b/internal/handler/web/partials/cabling.html @@ -0,0 +1,30 @@ +
+
+
+

Connection Cable List

+
+
Console standard
RJ45 Serial
+
Cable types
Straight / Cross-over
+
Labeling
Rack + Port ID
+
Patch rule
Color by group
+
+
+
+

Port Wiring Notes

+
+
+
Blue bundle
+
Manager PC to Ethernet switch uplink
+
+
+
Orange bundle
+
CS-48 to field devices and rack assets
+
+
+
Gray bundle
+
Syslog and maintenance backup path
+
+
+
+
+
diff --git a/internal/handler/web/partials/events.html b/internal/handler/web/partials/events.html new file mode 100644 index 0000000..2b45adc --- /dev/null +++ b/internal/handler/web/partials/events.html @@ -0,0 +1,21 @@ +
+
+

Recent Events

+ + + + + + + + + + + + + + + +
TimeSourceEventState
14:21Port 05Session opened by operatorInfo
14:18Port 23Exclusive lock enabledWarn
14:07LAN 1Syslog forwarding healthyOK
13:52SystemConfig snapshot savedOK
+
+
diff --git a/internal/handler/web/partials/example.html b/internal/handler/web/partials/example.html new file mode 100644 index 0000000..c109b71 --- /dev/null +++ b/internal/handler/web/partials/example.html @@ -0,0 +1,6 @@ +
+
+

Example Page

+

This submenu is reserved for a future screen. Replace this placeholder with the real feature page when the flow is defined.

+
+
diff --git a/internal/handler/web/partials/overview.html b/internal/handler/web/partials/overview.html new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/internal/handler/web/partials/overview.html @@ -0,0 +1 @@ + diff --git a/internal/handler/web/partials/ports.html b/internal/handler/web/partials/ports.html new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/internal/handler/web/partials/ports.html @@ -0,0 +1 @@ + diff --git a/internal/handler/web/partials/sessions.html b/internal/handler/web/partials/sessions.html new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/internal/handler/web/partials/sessions.html @@ -0,0 +1 @@ + diff --git a/internal/handler/web/partials/system.html b/internal/handler/web/partials/system.html new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/internal/handler/web/partials/system.html @@ -0,0 +1 @@ + diff --git a/internal/handler/web/partials/terminal.html b/internal/handler/web/partials/terminal.html new file mode 100644 index 0000000..8d29596 --- /dev/null +++ b/internal/handler/web/partials/terminal.html @@ -0,0 +1,25 @@ +
+
+

Browser Terminal

+

+ Use the socket session for diagnostics, port inspection, and local admin tasks. Type + clear + or + cls + to reset the view. +

+
+ +
+
+
Maintenance Console
+
Windows PowerShell Session
+
+
+
+ + +
diff --git a/internal/handler/web/styles.css b/internal/handler/web/styles.css new file mode 100644 index 0000000..084c94b --- /dev/null +++ b/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; + } +} diff --git a/internal/handlers/device_handler.go b/internal/handlers/device_handler.go new file mode 100644 index 0000000..c09bc55 --- /dev/null +++ b/internal/handlers/device_handler.go @@ -0,0 +1,1054 @@ +package handlers + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "html/template" + "io/fs" + "log" + "net/http" + "strconv" + "strings" + "sync" + + "teraclone" + "teraclone/internal/config" + "teraclone/internal/mock" + "teraclone/internal/service" + "teraclone/internal/store" +) + +type DeviceHandler struct { + templates *template.Template + static http.Handler + app *service.AppService + sessions map[string]string + mu sync.RWMutex +} + +type apiResponse struct { + OK bool `json:"ok"` + Message string `json:"message"` + Mock bool `json:"mock"` + Data interface{} `json:"data,omitempty"` +} + +type loginPageData struct { + AppName string + Error string +} + +const sessionCookieName = "teraclone_session" + +func NewDeviceHandler(appService *service.AppService) (*DeviceHandler, error) { + webRoot, err := teraclone.EmbeddedWebRoot() + if err != nil { + return nil, err + } + + tmpl, err := template.New("").Funcs(template.FuncMap{ + "eq": func(a, b string) bool { return a == b }, + "contains": func(items []string, target string) bool { + for _, item := range items { + if item == target { + return true + } + } + return false + }, + }).ParseFS(webRoot, "templates/*.html") + if err != nil { + return nil, err + } + + if _, err := tmpl.ParseFS(webRoot, "templates/partials/*.html"); err != nil { + return nil, err + } + + if _, err := tmpl.ParseFS(webRoot, "templates/auth/*.html"); err != nil { + return nil, err + } + + staticRoot, err := fs.Sub(webRoot, "static") + if err != nil { + return nil, err + } + + return &DeviceHandler{ + templates: tmpl, + static: http.FileServer(http.FS(staticRoot)), + app: appService, + sessions: make(map[string]string), + }, nil +} + +func (h *DeviceHandler) RegisterRoutes(mux *http.ServeMux) { + mux.Handle("/static/", http.StripPrefix("/static/", h.static)) + mux.HandleFunc("/login", h.handleLogin) + mux.HandleFunc("/logout", h.handleLogout) + mux.HandleFunc("/api/status", h.handleAPIStatus) + mux.HandleFunc("/api/network", h.handleAPINetwork) + mux.HandleFunc("/api/network/apply", h.handleMockPost("네트워크 설정 모의 적용이 완료되었습니다.")) + mux.HandleFunc("/api/system", h.handleAPISystem) + mux.HandleFunc("/api/system/reboot", h.handleMockPost("장치 재시작 요청을 모의 처리했습니다.")) + mux.HandleFunc("/api/logs/system", h.handleAPISystemLogs) + mux.HandleFunc("/api/logs/ports", h.handleAPIPortLogs) + mux.HandleFunc("/api/activity/recent", h.handleRecentActivity) + mux.HandleFunc("/api/users", h.handleUsers) + mux.HandleFunc("/api/users/", h.handleUserByID) + mux.HandleFunc("/api/groups", h.handleGroups) + mux.HandleFunc("/api/groups/", h.handleGroupByID) + mux.HandleFunc("/api/mock/action", h.handleMockAction) + mux.HandleFunc("/", h.handlePage) +} + +func (h *DeviceHandler) handlePage(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + + username, ok := h.requirePageAuth(w, r) + if !ok { + return + } + + if r.URL.Path == "/admin/users" { + h.handleUsersPage(w, r, username) + return + } + if r.URL.Path == "/admin/groups" { + h.handleGroupsPage(w, r, username) + return + } + + page, ok := mock.ResolvePage(r.URL.Path) + if !ok { + http.NotFound(w, r) + return + } + page.CurrentUser = username + + if err := h.app.RecordPageVisit(r.URL.Path, page.Title); err != nil { + log.Printf("sqlite page visit error: %v", err) + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = h.templates.ExecuteTemplate(w, "layout", page) +} + +func (h *DeviceHandler) handleUsersPage(w http.ResponseWriter, r *http.Request, username string) { + page, ok := mock.ResolvePage(r.URL.Path) + if !ok { + http.NotFound(w, r) + return + } + + users, err := h.app.ListUsers() + if err != nil { + http.Error(w, "failed to load users", http.StatusInternalServerError) + return + } + + groups, err := h.app.ListGroups() + if err != nil { + http.Error(w, "failed to load groups", http.StatusInternalServerError) + return + } + + page.Blocks = buildUserBlocksWithGroups(users, groups) + page.CurrentUser = username + + if err := h.app.RecordPageVisit(r.URL.Path, page.Title); err != nil { + log.Printf("sqlite page visit error: %v", err) + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := h.templates.ExecuteTemplate(w, "layout", page); err != nil { + log.Printf("template execute error: %v", err) + } +} + +func (h *DeviceHandler) handleGroupsPage(w http.ResponseWriter, r *http.Request, username string) { + page, ok := mock.ResolvePage(r.URL.Path) + if !ok { + http.NotFound(w, r) + return + } + + groups, err := h.app.ListGroups() + if err != nil { + http.Error(w, "failed to load groups", http.StatusInternalServerError) + return + } + + page.Blocks = buildGroupBlocks(groups) + page.CurrentUser = username + + if err := h.app.RecordPageVisit(r.URL.Path, page.Title); err != nil { + log.Printf("sqlite page visit error: %v", err) + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := h.templates.ExecuteTemplate(w, "layout", page); err != nil { + log.Printf("template execute error: %v", err) + } +} + +func (h *DeviceHandler) handleLogin(w http.ResponseWriter, r *http.Request) { + if currentUser, ok := h.authenticatedUser(r); ok { + http.Redirect(w, r, redirectTarget(r, "/"), http.StatusSeeOther) + _ = currentUser + return + } + + switch r.Method { + case http.MethodGet: + h.renderLogin(w, "") + case http.MethodPost: + username := strings.TrimSpace(r.FormValue("username")) + password := r.FormValue("password") + + user, err := h.app.AuthenticateUser(username, password) + if err != nil { + h.renderLogin(w, "아이디 또는 비밀번호가 올바르지 않습니다.") + return + } + + token, err := generateSessionToken() + if err != nil { + http.Error(w, "failed to create session", http.StatusInternalServerError) + return + } + + h.mu.Lock() + h.sessions[token] = user.Username + h.mu.Unlock() + + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + + http.Redirect(w, r, "/", http.StatusSeeOther) + default: + http.NotFound(w, r) + } +} + +func (h *DeviceHandler) handleLogout(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + + if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" { + h.mu.Lock() + delete(h.sessions, cookie.Value) + h.mu.Unlock() + } + + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +func (h *DeviceHandler) renderLogin(w http.ResponseWriter, errorMessage string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := h.templates.ExecuteTemplate(w, "login", loginPageData{ + AppName: "TERACLONE", + Error: errorMessage, + }); err != nil { + http.Error(w, "failed to render login", http.StatusInternalServerError) + } +} + +func (h *DeviceHandler) requirePageAuth(w http.ResponseWriter, r *http.Request) (string, bool) { + username, ok := h.authenticatedUser(r) + if ok { + return username, true + } + + if r.URL.Path == "/login" { + return "", true + } + + http.Redirect(w, r, redirectTarget(r, "/login"), http.StatusSeeOther) + return "", false +} + +func (h *DeviceHandler) requireAPIAuth(w http.ResponseWriter, r *http.Request) (string, bool) { + username, ok := h.authenticatedUser(r) + if ok { + return username, true + } + + writeJSON(w, http.StatusUnauthorized, apiResponse{ + OK: false, + Message: "로그인이 필요합니다.", + Mock: false, + }) + return "", false +} + +func (h *DeviceHandler) authenticatedUser(r *http.Request) (string, bool) { + cookie, err := r.Cookie(sessionCookieName) + if err != nil || cookie.Value == "" { + return "", false + } + + h.mu.RLock() + username, ok := h.sessions[cookie.Value] + h.mu.RUnlock() + return username, ok +} + +func (h *DeviceHandler) handleAPIStatus(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireAPIAuth(w, r); !ok { + return + } + + writeJSON(w, http.StatusOK, apiResponse{ + OK: true, + Message: "상태 더미 데이터를 반환했습니다.", + Mock: config.MockMode, + Data: mock.StatusAPIData(), + }) +} + +func (h *DeviceHandler) handleAPINetwork(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireAPIAuth(w, r); !ok { + return + } + + writeJSON(w, http.StatusOK, apiResponse{ + OK: true, + Message: "네트워크 더미 데이터를 반환했습니다.", + Mock: config.MockMode, + Data: mock.NetworkAPIData(), + }) +} + +func (h *DeviceHandler) handleAPISystem(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireAPIAuth(w, r); !ok { + return + } + + writeJSON(w, http.StatusOK, apiResponse{ + OK: true, + Message: "시스템 더미 데이터를 반환했습니다.", + Mock: config.MockMode, + Data: mock.SystemAPIData(), + }) +} + +func (h *DeviceHandler) handleAPISystemLogs(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireAPIAuth(w, r); !ok { + return + } + + writeJSON(w, http.StatusOK, apiResponse{ + OK: true, + Message: "시스템 로그 더미 데이터를 반환했습니다.", + Mock: config.MockMode, + Data: mock.SystemLogsAPIData(), + }) +} + +func (h *DeviceHandler) handleAPIPortLogs(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireAPIAuth(w, r); !ok { + return + } + + writeJSON(w, http.StatusOK, apiResponse{ + OK: true, + Message: "포트 로그 더미 데이터를 반환했습니다.", + Mock: config.MockMode, + Data: mock.PortLogsAPIData(), + }) +} + +func (h *DeviceHandler) handleRecentActivity(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireAPIAuth(w, r); !ok { + return + } + + records, err := h.app.RecentActivity(30) + if err != nil { + writeJSON(w, http.StatusInternalServerError, apiResponse{ + OK: false, + Message: "최근 활동 조회에 실패했습니다.", + Mock: config.MockMode, + }) + return + } + + writeJSON(w, http.StatusOK, apiResponse{ + OK: true, + Message: "최근 활동을 조회했습니다.", + Mock: config.MockMode, + Data: records, + }) +} + +func (h *DeviceHandler) handleMockAction(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireAPIAuth(w, r); !ok { + return + } + + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + + action := r.FormValue("action") + if action == "" { + action = "mock-action" + } + + if err := h.app.RecordMockAction(action, r.Referer()); err != nil { + log.Printf("sqlite mock action error: %v", err) + } + + message := "모의 동작을 처리했습니다." + switch { + case strings.Contains(action, "save"): + message = "모의 저장이 완료되었습니다." + case strings.Contains(action, "apply"): + message = "모의 적용이 완료되었습니다." + case strings.Contains(action, "reboot"): + message = "모의 재시작 요청이 접수되었습니다." + case strings.Contains(action, "download"): + message = "모의 다운로드 요청을 처리했습니다." + case strings.Contains(action, "refresh"): + message = "모의 새로고침이 완료되었습니다." + case strings.Contains(action, "delete"): + message = "모의 삭제가 완료되었습니다." + case strings.Contains(action, "add"): + message = "모의 추가가 완료되었습니다." + } + + writeJSON(w, http.StatusOK, apiResponse{ + OK: true, + Message: message, + Mock: config.MockMode, + Data: map[string]string{ + "action": action, + }, + }) +} + +func (h *DeviceHandler) handleMockPost(message string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireAPIAuth(w, r); !ok { + return + } + + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + + writeJSON(w, http.StatusOK, apiResponse{ + OK: true, + Message: message, + Mock: config.MockMode, + }) + } +} + +func (h *DeviceHandler) handleUsers(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireAPIAuth(w, r); !ok { + return + } + + switch r.Method { + case http.MethodGet: + users, err := h.app.ListUsers() + if err != nil { + writeJSON(w, http.StatusInternalServerError, apiResponse{ + OK: false, + Message: "사용자 목록을 불러오지 못했습니다.", + Mock: false, + }) + return + } + + writeJSON(w, http.StatusOK, apiResponse{ + OK: true, + Message: "사용자 목록을 조회했습니다.", + Mock: false, + Data: users, + }) + case http.MethodPost: + var input service.CreateUserInput + if err := decodeUserPayload(r, &input); err != nil { + writeJSON(w, http.StatusBadRequest, apiResponse{ + OK: false, + Message: err.Error(), + Mock: false, + }) + return + } + + user, err := h.app.CreateUser(input) + if err != nil { + writeUserError(w, err) + return + } + + _ = h.app.RecordMockAction("create-user", "/admin/users") + writeJSON(w, http.StatusCreated, apiResponse{ + OK: true, + Message: "사용자를 추가했습니다.", + Mock: false, + Data: user, + }) + default: + http.NotFound(w, r) + } +} + +func (h *DeviceHandler) handleUserByID(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireAPIAuth(w, r); !ok { + return + } + + id, err := parseUserID(r.URL.Path) + if err != nil { + http.NotFound(w, r) + return + } + + switch r.Method { + case http.MethodGet: + user, err := h.app.GetUser(id) + if err != nil { + writeUserError(w, err) + return + } + + writeJSON(w, http.StatusOK, apiResponse{ + OK: true, + Message: "사용자 정보를 조회했습니다.", + Mock: false, + Data: user, + }) + case http.MethodPut: + var input service.UpdateUserInput + if err := decodeUserPayload(r, &input); err != nil { + writeJSON(w, http.StatusBadRequest, apiResponse{ + OK: false, + Message: err.Error(), + Mock: false, + }) + return + } + input.ID = id + + user, err := h.app.UpdateUser(input) + if err != nil { + writeUserError(w, err) + return + } + + _ = h.app.RecordMockAction("update-user", "/admin/users") + writeJSON(w, http.StatusOK, apiResponse{ + OK: true, + Message: "사용자 정보를 수정했습니다.", + Mock: false, + Data: user, + }) + case http.MethodDelete: + if err := h.app.DeleteUser(id); err != nil { + writeUserError(w, err) + return + } + + _ = h.app.RecordMockAction("delete-user", "/admin/users") + writeJSON(w, http.StatusOK, apiResponse{ + OK: true, + Message: "사용자를 삭제했습니다.", + Mock: false, + }) + default: + http.NotFound(w, r) + } +} + +func (h *DeviceHandler) handleGroups(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireAPIAuth(w, r); !ok { + return + } + + switch r.Method { + case http.MethodGet: + groups, err := h.app.ListGroups() + if err != nil { + writeJSON(w, http.StatusInternalServerError, apiResponse{ + OK: false, + Message: "그룹 목록을 불러오지 못했습니다.", + Mock: false, + }) + return + } + + writeJSON(w, http.StatusOK, apiResponse{ + OK: true, + Message: "그룹 목록을 조회했습니다.", + Mock: false, + Data: groups, + }) + case http.MethodPost: + var input service.CreateGroupInput + if err := decodeGroupPayload(r, &input); err != nil { + writeJSON(w, http.StatusBadRequest, apiResponse{ + OK: false, + Message: err.Error(), + Mock: false, + }) + return + } + + group, err := h.app.CreateGroup(input) + if err != nil { + writeGroupError(w, err) + return + } + + _ = h.app.RecordMockAction("create-group", "/admin/groups") + writeJSON(w, http.StatusCreated, apiResponse{ + OK: true, + Message: "그룹을 추가했습니다.", + Mock: false, + Data: group, + }) + default: + http.NotFound(w, r) + } +} + +func (h *DeviceHandler) handleGroupByID(w http.ResponseWriter, r *http.Request) { + if _, ok := h.requireAPIAuth(w, r); !ok { + return + } + + id, err := parseGroupID(r.URL.Path) + if err != nil { + http.NotFound(w, r) + return + } + + switch r.Method { + case http.MethodGet: + group, err := h.app.GetGroup(id) + if err != nil { + writeGroupError(w, err) + return + } + + writeJSON(w, http.StatusOK, apiResponse{ + OK: true, + Message: "그룹 정보를 조회했습니다.", + Mock: false, + Data: group, + }) + case http.MethodPut: + var input service.UpdateGroupInput + if err := decodeGroupPayload(r, &input); err != nil { + writeJSON(w, http.StatusBadRequest, apiResponse{ + OK: false, + Message: err.Error(), + Mock: false, + }) + return + } + input.ID = id + + group, err := h.app.UpdateGroup(input) + if err != nil { + writeGroupError(w, err) + return + } + + _ = h.app.RecordMockAction("update-group", "/admin/groups") + writeJSON(w, http.StatusOK, apiResponse{ + OK: true, + Message: "그룹 정보를 수정했습니다.", + Mock: false, + Data: group, + }) + case http.MethodDelete: + if err := h.app.DeleteGroup(id); err != nil { + writeGroupError(w, err) + return + } + + _ = h.app.RecordMockAction("delete-group", "/admin/groups") + writeJSON(w, http.StatusOK, apiResponse{ + OK: true, + Message: "그룹을 삭제했습니다.", + Mock: false, + }) + default: + http.NotFound(w, r) + } +} + +func buildUserBlocks(users []service.User) []mock.ContentBlock { + rows := make([]mock.TableRow, 0, len(users)) + for _, user := range users { + rows = append(rows, mock.TableRow{ + Cells: []string{ + user.StatusLabel, + user.Username, + user.Group, + user.AccessSummary, + }, + Actions: []mock.RowAction{ + {Label: "수정", Action: "user-edit", Variant: "primary", Target: strconv.FormatInt(user.ID, 10)}, + {Label: "삭제", Action: "user-delete", Variant: "danger", Target: strconv.FormatInt(user.ID, 10)}, + }, + }) + } + + return []mock.ContentBlock{ + { + Kind: "table", + Title: "사용자 계정", + Table: &mock.TableData{ + Columns: []string{"상태", "사용자 이름", "그룹", "포트 권한"}, + Rows: rows, + EmptyMessage: "등록된 사용자가 없습니다.", + PrimaryLabel: "사용자 추가", + }, + }, + { + Kind: "modal", + Title: "사용자 추가", + Modal: &mock.ModalData{ + Root: "user-create", + Title: "사용자 추가", + Message: "사용자 생성과 수정을 같은 팝업에서 처리합니다.", + Fields: []mock.FormField{ + {Type: "hidden", Name: "id", Value: ""}, + {Label: "사용자 이름", Name: "username", Type: "text", Value: "", Required: true}, + {Label: "비밀번호", Name: "password", Type: "password", Value: "", Placeholder: "수정 시 비우면 유지됩니다."}, + { + Label: "그룹", + Name: "group", + Type: "select", + Value: "administrator", + Options: []mock.Option{ + {Label: "administrator", Value: "administrator", Selected: true}, + {Label: "operator", Value: "operator"}, + {Label: "guest", Value: "guest"}, + }, + }, + { + Label: "상태", + Name: "enabled", + Type: "select", + Value: "true", + Options: []mock.Option{ + {Label: "사용", Value: "true", Selected: true}, + {Label: "중지", Value: "false"}, + }, + }, + }, + Buttons: []mock.ActionButton{ + {Label: "확인", Action: "submit-user-form", Variant: "primary"}, + {Label: "취소", Action: "cancel-modal", Variant: "secondary"}, + }, + }, + }, + } +} + +func buildUserBlocksWithGroups(users []service.User, groups []service.Group) []mock.ContentBlock { + groupOptions := make([]mock.Option, 0, len(groups)) + accessByGroup := make(map[string]string, len(groups)) + defaultGroup := "" + + for index, group := range groups { + if defaultGroup == "" || group.Name == "administrator" { + defaultGroup = group.Name + } + + groupOptions = append(groupOptions, mock.Option{ + Label: group.Name, + Value: group.Name, + Selected: index == 0 || group.Name == "administrator", + }) + accessByGroup[group.Name] = group.PermissionSummary + } + + rows := make([]mock.TableRow, 0, len(users)) + for _, user := range users { + accessSummary := user.AccessSummary + if summary, ok := accessByGroup[user.Group]; ok { + accessSummary = summary + } + + rows = append(rows, mock.TableRow{ + Cells: []string{ + user.StatusLabel, + user.Username, + user.Group, + accessSummary, + }, + Actions: []mock.RowAction{ + {Label: "수정", Action: "user-edit", Variant: "primary", Target: strconv.FormatInt(user.ID, 10)}, + {Label: "삭제", Action: "user-delete", Variant: "danger", Target: strconv.FormatInt(user.ID, 10)}, + }, + }) + } + + return []mock.ContentBlock{ + { + Kind: "table", + Title: "사용자 계정", + Table: &mock.TableData{ + Columns: []string{"상태", "사용자 이름", "그룹", "포트 권한"}, + Rows: rows, + EmptyMessage: "등록된 사용자가 없습니다.", + PrimaryLabel: "사용자 추가", + }, + }, + { + Kind: "modal", + Title: "사용자 추가", + Modal: &mock.ModalData{ + Root: "user-create", + Title: "사용자 추가", + Message: "사용자 생성과 수정은 같은 팝업에서 처리합니다.", + Fields: []mock.FormField{ + {Type: "hidden", Name: "id", Value: ""}, + {Label: "사용자 이름", Name: "username", Type: "text", Value: "", Required: true}, + {Label: "비밀번호", Name: "password", Type: "password", Value: "", Placeholder: "수정 시 비우면 유지됩니다."}, + { + Label: "그룹", + Name: "group", + Type: "select", + Value: defaultGroup, + Options: groupOptions, + }, + { + Label: "상태", + Name: "enabled", + Type: "select", + Value: "true", + Options: []mock.Option{ + {Label: "사용", Value: "true", Selected: true}, + {Label: "중지", Value: "false"}, + }, + }, + }, + Buttons: []mock.ActionButton{ + {Label: "확인", Action: "submit-user-form", Variant: "primary"}, + {Label: "취소", Action: "cancel-modal", Variant: "secondary"}, + }, + }, + }, + } +} + +func buildGroupBlocks(groups []service.Group) []mock.ContentBlock { + rows := make([]mock.TableRow, 0, len(groups)) + for _, group := range groups { + rows = append(rows, mock.TableRow{ + Cells: []string{ + group.Name, + group.Description, + group.PermissionSummary, + }, + Actions: []mock.RowAction{ + {Label: "수정", Action: "group-edit", Variant: "primary", Target: strconv.FormatInt(group.ID, 10)}, + {Label: "삭제", Action: "group-delete", Variant: "danger", Target: strconv.FormatInt(group.ID, 10)}, + }, + }) + } + + return []mock.ContentBlock{ + { + Kind: "table", + Title: "그룹", + Table: &mock.TableData{ + Columns: []string{"그룹 이름", "설명", "권한"}, + Rows: rows, + EmptyMessage: "등록된 그룹이 없습니다.", + PrimaryLabel: "그룹 추가", + }, + }, + } +} + +func parseUserID(path string) (int64, error) { + idText := strings.TrimPrefix(path, "/api/users/") + if idText == "" || strings.Contains(idText, "/") { + return 0, errors.New("invalid user id") + } + return strconv.ParseInt(idText, 10, 64) +} + +func parseGroupID(path string) (int64, error) { + idText := strings.TrimPrefix(path, "/api/groups/") + if idText == "" || strings.Contains(idText, "/") { + return 0, errors.New("invalid group id") + } + return strconv.ParseInt(idText, 10, 64) +} + +func decodeUserPayload(r *http.Request, dest interface{}) error { + contentType := r.Header.Get("Content-Type") + if strings.Contains(contentType, "application/json") { + return json.NewDecoder(r.Body).Decode(dest) + } + + if err := r.ParseForm(); err != nil { + return err + } + + switch payload := dest.(type) { + case *service.CreateUserInput: + payload.Username = strings.TrimSpace(r.FormValue("username")) + payload.Password = r.FormValue("password") + payload.Group = strings.TrimSpace(r.FormValue("group")) + payload.Enabled = parseEnabledValue(r.FormValue("enabled")) + case *service.UpdateUserInput: + payload.Username = strings.TrimSpace(r.FormValue("username")) + payload.Password = r.FormValue("password") + payload.Group = strings.TrimSpace(r.FormValue("group")) + payload.Enabled = parseEnabledValue(r.FormValue("enabled")) + default: + return errors.New("unsupported payload") + } + + return nil +} + +func decodeGroupPayload(r *http.Request, dest interface{}) error { + contentType := r.Header.Get("Content-Type") + if strings.Contains(contentType, "application/json") { + return json.NewDecoder(r.Body).Decode(dest) + } + + if err := r.ParseForm(); err != nil { + return err + } + + permissions := append([]string(nil), r.Form["permissions"]...) + + switch payload := dest.(type) { + case *service.CreateGroupInput: + payload.Name = strings.TrimSpace(r.FormValue("name")) + payload.Description = strings.TrimSpace(r.FormValue("description")) + payload.Permissions = permissions + case *service.UpdateGroupInput: + payload.Name = strings.TrimSpace(r.FormValue("name")) + payload.Description = strings.TrimSpace(r.FormValue("description")) + payload.Permissions = permissions + default: + return errors.New("unsupported payload") + } + + return nil +} + +func parseEnabledValue(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "false", "0", "off", "disabled": + return false + default: + return true + } +} + +func writeUserError(w http.ResponseWriter, err error) { + switch { + case err == nil: + return + case store.IsNotFoundError(err): + writeJSON(w, http.StatusNotFound, apiResponse{ + OK: false, + Message: "사용자를 찾을 수 없습니다.", + Mock: false, + }) + case store.IsUniqueConstraintError(err): + writeJSON(w, http.StatusConflict, apiResponse{ + OK: false, + Message: "같은 사용자 이름이 이미 존재합니다.", + Mock: false, + }) + default: + writeJSON(w, http.StatusBadRequest, apiResponse{ + OK: false, + Message: err.Error(), + Mock: false, + }) + } +} + +func writeGroupError(w http.ResponseWriter, err error) { + switch { + case err == nil: + return + case store.IsNotFoundError(err): + writeJSON(w, http.StatusNotFound, apiResponse{ + OK: false, + Message: "그룹을 찾을 수 없습니다.", + Mock: false, + }) + case store.IsUniqueConstraintError(err): + writeJSON(w, http.StatusConflict, apiResponse{ + OK: false, + Message: "같은 그룹 이름이 이미 존재합니다.", + Mock: false, + }) + case store.IsDependencyError(err): + writeJSON(w, http.StatusConflict, apiResponse{ + OK: false, + Message: err.Error(), + Mock: false, + }) + default: + writeJSON(w, http.StatusBadRequest, apiResponse{ + OK: false, + Message: err.Error(), + Mock: false, + }) + } +} + +func generateSessionToken() (string, error) { + buffer := make([]byte, 32) + if _, err := rand.Read(buffer); err != nil { + return "", err + } + return hex.EncodeToString(buffer), nil +} + +func redirectTarget(r *http.Request, fallback string) string { + if next := strings.TrimSpace(r.URL.Query().Get("next")); next != "" && strings.HasPrefix(next, "/") { + return next + } + return fallback +} + +func writeJSON(w http.ResponseWriter, status int, payload apiResponse) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} diff --git a/internal/mock/mock_data.go b/internal/mock/mock_data.go new file mode 100644 index 0000000..09c4969 --- /dev/null +++ b/internal/mock/mock_data.go @@ -0,0 +1,1128 @@ +package mock + +import ( + "fmt" + "strconv" + "strings" + + "teraclone/internal/config" +) + +type PageData struct { + AppName string + MockMode bool + CurrentUser string + CurrentPath string + Title string + Section string + Breadcrumbs []string + WorkspaceTabs []WorkspaceTab + Menu []MenuGroup + SubTabs []SubTab + Blocks []ContentBlock +} + +type WorkspaceTab struct { + Key string + Label string + Path string + Active bool +} + +type MenuGroup struct { + Label string + Icon string + Path string + Active bool + Expanded bool + Children []MenuChild +} + +type MenuChild struct { + Label string + Path string + Active bool +} + +type SubTab struct { + Label string + Path string + Active bool +} + +type ContentBlock struct { + Kind string + Title string + Subtitle string + PortLinks []PortLink + KeyValues []KeyValue + Form *FormData + Table *TableData + Action *ActionPanel + Tool *ToolData + Modal *ModalData + Message string +} + +type PortLink struct { + Number int + Up bool + Blink bool +} + +type KeyValue struct { + Key string + Value string +} + +type FormData struct { + Action string + PrimaryLabel string + SecondaryLabel string + Fields []FormField + PortTargets []PortTarget + Notes []string +} + +type FormField struct { + Label string + Name string + Type string + Value string + Placeholder string + Required bool + Disabled bool + Checked bool + Hint string + Rows int + Options []Option +} + +type Option struct { + Label string + Value string + Selected bool +} + +type PortTarget struct { + Number int + Checked bool +} + +type TableData struct { + Columns []string + Rows []TableRow + EmptyMessage string + PrimaryLabel string + SecondaryLabel string +} + +type TableRow struct { + Cells []string + Actions []RowAction +} + +type RowAction struct { + Label string + Action string + Variant string + Target string +} + +type ActionPanel struct { + Description string + Buttons []ActionButton +} + +type ActionButton struct { + Label string + Action string + Variant string +} + +type ToolData struct { + Action string + PrimaryLabel string + Tabs []SubTab + Fields []FormField + Result string +} + +type ModalData struct { + Root string + Title string + Message string + Fields []FormField + Buttons []ActionButton +} + +func ResolvePage(path string) (PageData, bool) { + switch { + case path == "/": + return dashboardPage(), true + case path == "/config/save": + return actionPage(path, "설정 저장", "현재 화면의 설정을 장비 구성 파일로 저장하는 목업 화면입니다.", "save-config"), true + case path == "/ports/restart": + return actionPage(path, "포트 재시작", "선택한 포트를 재시작하는 동작을 흉내 내는 목업 화면입니다.", "restart-ports"), true + case path == "/device/restart": + return actionPage(path, "장치 재시작", "장치 전체 재시작을 시뮬레이션하는 목업 화면입니다.", "restart-device"), true + case strings.HasPrefix(path, "/network/"): + return networkPage(path) + case strings.HasPrefix(path, "/ports/"): + return portPage(path) + case strings.HasPrefix(path, "/port-debug"): + return portDebugPage(path) + case strings.HasPrefix(path, "/admin/"): + return adminPage(path) + case strings.HasPrefix(path, "/settings/"): + return settingsPage(path) + case strings.HasPrefix(path, "/tools/"): + return toolsPage(path) + case strings.HasPrefix(path, "/logs/"): + return logsPage(path) + case path == "/port-status": + return portStatusPage(), true + case path == "/password": + return passwordPage(), true + default: + return PageData{}, false + } +} + +func StatusAPIData() map[string]interface{} { + return map[string]interface{}{ + "portsUp": 32, + "portsDown": 16, + "model": "TERACLONE-X48PRO", + "uptime": "2일 17시간 24분", + "temperature": "53.8C", + "mock": true, + } +} + +func NetworkAPIData() map[string]interface{} { + return map[string]interface{}{ + "interface1": map[string]string{ + "address": "192.168.0.233", + "netmask": "255.255.255.0", + "gateway": "192.168.0.1", + }, + "interface2": map[string]string{ + "address": "192.168.200.233", + "netmask": "255.255.255.0", + "gateway": "192.168.200.1", + }, + "mock": true, + } +} + +func SystemAPIData() map[string]interface{} { + return map[string]interface{}{ + "hostname": "teraclone-x48", + "firmware": "6.4.6004", + "time": "2026-06-24 14:03:22 KST", + "mock": true, + } +} + +func SystemLogsAPIData() []map[string]string { + return []map[string]string{ + {"time": "2026-06-24 13:58:10", "event": "관리자 로그인", "level": "info"}, + {"time": "2026-06-24 13:59:45", "event": "구성 저장 요청", "level": "warn"}, + } +} + +func PortLogsAPIData() []map[string]string { + return []map[string]string{ + {"time": "2026-06-24 13:57:31", "port": "Port 1", "event": "데이터 수신 128 bytes"}, + {"time": "2026-06-24 13:58:02", "port": "Port 24", "event": "링크 상태 변경"}, + } +} + +func dashboardPage() PageData { + return newPage("/", "대시보드", "대시보드", []string{"대시보드"}, nil, []ContentBlock{ + {Kind: "port-links", Title: "포트 링크 상태", PortLinks: portLinks()}, + { + Kind: "key-values", + Title: "시스템 정보", + KeyValues: []KeyValue{ + {"모델", "TERACLONE-X48PRO"}, + {"빌드번호", "N010-0020-2609-0062"}, + {"시리얼번호", "RMM4027N850000026090062"}, + {"펌웨어", "6.4.6004"}, + {"가동 시간", "2일 17시간 24분 50초"}, + {"시스템 시간", "2026년 6월 24일 수요일 오후 2시 03분 22초"}, + {"CPU 평균", "0.46 / 0.45 / 0.41"}, + }, + }, + { + Kind: "key-values", + Title: "네트워크 정보", + KeyValues: []KeyValue{ + {"인터페이스 1", "eth0 / 192.168.0.233 / 1000Mbps / 링크 연결"}, + {"인터페이스 2", "eth1 / 192.168.200.233 / 1000Mbps / 링크 대기"}, + {"MAC 주소", "6C:B3:50:0D:36:12"}, + {"다운로드 속도", "0 B/s"}, + {"업로드 속도", "0 B/s"}, + }, + }, + { + Kind: "key-values", + Title: "리소스 상태", + KeyValues: []KeyValue{ + {"CPU 사용률", "14%"}, + {"메모리 사용률", "38%"}, + {"할당 메모리", "182 MB"}, + {"최대 RSS", "264 MB"}, + }, + }, + }) +} + +func networkPage(path string) (PageData, bool) { + subtabs := []SubTab{ + {"인터페이스 1", "/network/interface-1", path == "/network/interface-1"}, + {"인터페이스 2", "/network/interface-2", path == "/network/interface-2"}, + {"호스트 설정", "/network/host", path == "/network/host"}, + {"고급 설정", "/network/advanced", path == "/network/advanced"}, + {"DDNS 설정", "/network/ddns", path == "/network/ddns"}, + } + + switch path { + case "/network/interface-1", "/network/interface-2": + label := "인터페이스 1" + addr := "192.168.0.233" + gateway := "192.168.0.1" + if path == "/network/interface-2" { + label = "인터페이스 2" + addr = "192.168.200.233" + gateway = "192.168.200.1" + } + return newPage(path, "네트워크 설정", "네트워크 설정", []string{"네트워크 설정", label}, subtabs, []ContentBlock{ + formBlock(label+" IPv4 / IPv6 설정", "network-apply", []FormField{ + toggleField("DHCP 사용", "dhcp", false), + textField("IPv4 주소", "address", addr, true), + textField("서브넷 마스크", "netmask", "255.255.255.0", true), + textField("게이트웨이", "gateway", gateway, false), + textField("DNS 서버 1", "dns1", "8.8.8.8", false), + textField("DNS 서버 2", "dns2", "1.1.1.1", false), + selectField("IPv6 모드", "ipv6mode", "Auto", []string{"Auto", "Disable", "Static"}), + textField("IPv6 주소", "ipv6address", "", false), + textField("Prefix Length", "prefix", "64", false), + textField("IPv6 게이트웨이", "ipv6gateway", "", false), + }, "변경", "적용"), + }), true + case "/network/host": + return newPage(path, "네트워크 설정", "네트워크 설정", []string{"네트워크 설정", "호스트 설정"}, subtabs, []ContentBlock{ + formBlock("호스트 설정", "network-host", []FormField{ + textField("호스트 이름", "hostname", "teraclone-x48", true), + textField("도메인 이름", "domain", "local.lan", false), + textField("기본 URL", "url", "http://192.168.0.233", false), + textAreaField("메모", "memo", "장비 목업 UI를 위한 더미 호스트 설정입니다.", 4), + }, "변경", "적용"), + }), true + case "/network/advanced": + return newPage(path, "네트워크 설정", "네트워크 설정", []string{"네트워크 설정", "고급 설정"}, subtabs, []ContentBlock{ + formBlock("고급 네트워크 설정", "network-advanced", []FormField{ + toggleField("HTTP 활성화", "http", true), + toggleField("HTTPS 활성화", "https", true), + textField("HTTP 포트", "httpPort", "80", true), + textField("HTTPS 포트", "httpsPort", "443", true), + toggleField("ICMP 응답 허용", "icmp", true), + toggleField("SSH 활성화", "ssh", false), + }, "변경", "적용"), + }), true + case "/network/ddns": + return newPage(path, "네트워크 설정", "네트워크 설정", []string{"네트워크 설정", "DDNS 설정"}, subtabs, []ContentBlock{ + formBlock("DDNS 설정", "network-ddns", []FormField{ + toggleField("DDNS 사용", "ddns", false), + selectField("서비스 제공자", "provider", "DynDNS", []string{"DynDNS", "No-IP", "Custom"}), + textField("도메인", "domain", "teraclone-demo.example.com", false), + textField("사용자 이름", "username", "demo-user", false), + passwordField("비밀번호", "password", "********"), + textField("업데이트 주기(초)", "interval", "300", false), + }, "변경", "적용"), + }), true + default: + return PageData{}, false + } +} + +func portPage(path string) (PageData, bool) { + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) != 3 || parts[0] != "ports" { + return PageData{}, false + } + + portID, err := strconv.Atoi(parts[1]) + if err != nil || portID < 1 || portID > 48 { + return PageData{}, false + } + + subPath := parts[2] + subtabs := []SubTab{ + {"매개변수", fmt.Sprintf("/ports/%d/parameters", portID), subPath == "parameters"}, + {"데이터 패킷", fmt.Sprintf("/ports/%d/data-packet", portID), subPath == "data-packet"}, + {"동작 설정 1", fmt.Sprintf("/ports/%d/operation-1", portID), subPath == "operation-1"}, + {"동작 설정 2", fmt.Sprintf("/ports/%d/operation-2", portID), subPath == "operation-2"}, + {"데이터 로그", fmt.Sprintf("/ports/%d/data-log", portID), subPath == "data-log"}, + {"고급 설정", fmt.Sprintf("/ports/%d/advanced", portID), subPath == "advanced"}, + } + + switch subPath { + case "parameters": + return newPortPage(path, portID, subtabs, []ContentBlock{ + { + Kind: "port-selector", + Title: "포트 선택", + PortLinks: portLinks(), + }, + { + Kind: "form", + Title: fmt.Sprintf("포트 설정 / Port %d", portID), + Form: &FormData{ + Action: "/api/mock/action", + PrimaryLabel: "변경", + SecondaryLabel: "적용", + Fields: []FormField{ + textField("별칭 이름", "alias", fmt.Sprintf("Port-%02d", portID), false), + selectField("시리얼 타입", "serialType", "RS232", []string{"RS232", "RS422", "RS485"}), + selectField("Baudrate", "baudrate", "115200", []string{"9600", "19200", "38400", "57600", "115200"}), + selectField("Databit", "databit", "8", []string{"5", "6", "7", "8"}), + selectField("Parity", "parity", "None", []string{"None", "Odd", "Even"}), + selectField("Stopbit", "stopbit", "1", []string{"1", "1.5", "2"}), + selectField("Flow control", "flow", "None", []string{"None", "RTS/CTS", "XON/XOFF"}), + selectField("RTS control", "rts", "Auto", []string{"Auto", "Enable", "Disable"}), + selectField("DTR control", "dtr", "Auto", []string{"Auto", "Enable", "Disable"}), + toggleField("Ignore jammed", "ignore", false), + }, + PortTargets: portTargets(portID), + Notes: []string{ + "실제 장비 저장 없이 화면 상태만 갱신됩니다.", + "동일 패턴으로 Port 1~48 설정 화면을 재사용합니다.", + }, + }, + }, + }), true + case "data-packet": + return newPortPage(path, portID, subtabs, []ContentBlock{ + formBlock("데이터 패킷", "port-packet", []FormField{ + selectField("패킷 모드", "packetMode", "Delimiter", []string{"Delimiter", "Timeout", "Fixed Length"}), + textField("패킷 길이", "packetLen", "256", false), + textField("종료 문자", "delimiter", "\\r\\n", false), + textField("타임아웃(ms)", "timeout", "50", false), + }, "변경", "적용"), + }), true + case "operation-1": + return newPortPage(path, portID, subtabs, []ContentBlock{ + formBlock("동작 설정 1", "port-operation-1", []FormField{ + selectField("동작 모드", "mode", "TCP Server", []string{"TCP Server", "TCP Client", "UDP"}), + textField("로컬 포트", "localPort", fmt.Sprintf("40%02d", portID), false), + textField("원격 IP", "remoteIP", "192.168.0.100", false), + textField("원격 포트", "remotePort", "4001", false), + toggleField("자동 연결", "autoConnect", true), + }, "변경", "적용"), + }), true + case "operation-2": + return newPortPage(path, portID, subtabs, []ContentBlock{ + formBlock("동작 설정 2", "port-operation-2", []FormField{ + toggleField("TLS 사용", "tls", false), + toggleField("Keepalive 사용", "keepalive", true), + textField("Keepalive 시간", "keepaliveSeconds", "30", false), + toggleField("Nagle 비활성화", "nodelay", true), + textAreaField("비고", "remarks", "추후 실제 연결 시 포트별 통신 정책을 붙일 수 있도록 남겨둔 더미 영역입니다.", 4), + }, "변경", "적용"), + }), true + case "data-log": + return newPortPage(path, portID, subtabs, []ContentBlock{ + { + Kind: "table", + Title: "데이터 로그", + Table: &TableData{ + Columns: []string{"시간", "방향", "길이", "데이터"}, + PrimaryLabel: "다운로드", + SecondaryLabel: "새로고침", + EmptyMessage: "데이터가 없습니다.", + Rows: []TableRow{ + {Cells: []string{"2026-06-24 14:05:12", "RX", "128", "01 03 00 00 00 02 C4 0B"}}, + {Cells: []string{"2026-06-24 14:05:13", "TX", "128", "01 03 04 00 01 00 02 2A 32"}}, + }, + }, + }, + }), true + case "advanced": + return newPortPage(path, portID, subtabs, []ContentBlock{ + formBlock("고급 설정", "port-advanced", []FormField{ + toggleField("이벤트 로그 기록", "eventlog", true), + toggleField("라인 상태 감시", "linestate", true), + toggleField("포트 보호", "protect", false), + textField("세션 제한", "sessionLimit", "4", false), + textField("Idle Timeout", "idle", "600", false), + }, "변경", "적용"), + }), true + default: + return PageData{}, false + } +} + +func portDebugPage(path string) (PageData, bool) { + subtabs := []SubTab{ + {"포트 디버그", "/port-debug", path == "/port-debug"}, + {"포트 1", "/port-debug/1", path == "/port-debug/1"}, + } + if path == "/port-debug" || path == "/port-debug/1" { + return newPage(path, "포트 디버그", "포트 디버그", []string{"포트 디버그"}, subtabs, []ContentBlock{ + { + Kind: "tool", + Title: "포트 디버그 콘솔", + Tool: &ToolData{ + Action: "/api/mock/action", + PrimaryLabel: "전송", + Fields: []FormField{ + textField("대상 포트", "port", "Port 1", false), + selectField("인코딩", "encoding", "HEX", []string{"HEX", "ASCII", "UTF-8"}), + textAreaField("전송 데이터", "payload", "01 03 00 00 00 02 C4 0B", 8), + }, + Result: "RX: 01 03 04 00 01 00 02 2A 32\nTX: 01 03 00 00 00 02 C4 0B", + }, + }, + }), true + } + return PageData{}, false +} + +func adminPage(path string) (PageData, bool) { + switch path { + case "/admin/web-console": + return simpleFormPage(path, "관리자 운영", []string{"관리자 운영", "웹 콘솔"}, "웹 콘솔 접속 설정", []FormField{ + toggleField("웹 콘솔 사용", "console", true), + textField("세션 타임아웃", "timeout", "600", false), + toggleField("동시 로그인 차단", "multi", false), + }), true + case "/admin/users": + return newPage(path, "관리자 운영", "사용자 계정", []string{"관리자 운영", "사용자 계정"}, userTabs(path), []ContentBlock{ + tableBlock("사용자 계정", []string{"활성", "사용자 이름", "그룹", "포트 권한"}, []TableRow{ + {Cells: []string{"사용", "terauser", "administrator", "전체 허용"}, Actions: []RowAction{{Label: "수정", Action: "edit-user", Variant: "primary"}, {Label: "삭제", Action: "delete-user", Variant: "danger"}}}, + {Cells: []string{"사용", "guest", "guest", "조회 전용"}, Actions: []RowAction{{Label: "수정", Action: "edit-user", Variant: "primary"}, {Label: "삭제", Action: "delete-user", Variant: "danger"}}}, + }, "사용자 추가", "변경"), + modalBlock("사용자 추가", "사용자 생성 팝업을 흉내 낸 목업입니다.", []FormField{ + textField("사용자 이름", "username", "", true), + passwordField("비밀번호", "password", ""), + selectField("그룹", "group", "administrator", []string{"administrator", "guest", "operator"}), + }), + }), true + case "/admin/groups": + return newPage(path, "관리자 운영", "그룹 관리", []string{"관리자 운영", "그룹 관리"}, userTabs(path), []ContentBlock{ + tableBlock("그룹", []string{"그룹 이름", "권한", "설명"}, []TableRow{ + {Cells: []string{"administrator", "모든 메뉴", "전체 접근 가능"}, Actions: []RowAction{{Label: "수정", Action: "edit-group", Variant: "primary"}}}, + {Cells: []string{"guest", "조회 전용", "구성 변경 금지"}, Actions: []RowAction{{Label: "수정", Action: "edit-group", Variant: "primary"}}}, + }, "그룹 추가", "변경"), + }), true + case "/admin/auth/radius": + return simpleFormPage(path, "관리자 운영", []string{"관리자 운영", "인증 서버", "RADIUS"}, "RADIUS", []FormField{ + toggleField("RADIUS 사용", "radius", false), + textField("서버 주소", "host", "192.168.0.10", false), + textField("포트", "port", "1812", false), + passwordField("Secret", "secret", "radius-secret"), + textField("타임아웃", "timeout", "5", false), + }), true + case "/admin/auth/tacacs": + return simpleFormPage(path, "관리자 운영", []string{"관리자 운영", "인증 서버", "TACACS+"}, "TACACS+", []FormField{ + toggleField("TACACS+ 사용", "tacacs", false), + textField("서버 주소", "host", "192.168.0.20", false), + textField("포트", "port", "49", false), + passwordField("Secret", "secret", "tacacs-secret"), + textField("타임아웃", "timeout", "5", false), + }), true + case "/admin/address-filter": + return tablePage(path, "주소 필터", []string{"관리자 운영", "주소 필터"}, []string{"유형", "규칙 수", "설명"}, []TableRow{ + {Cells: []string{"IPv4 필터", "4", "기본 IPv4 접근 제어"}}, + {Cells: []string{"IPv6 필터", "2", "기본 IPv6 접근 제어"}}, + {Cells: []string{"MAC 필터", "3", "등록 MAC 제한"}}, + }, []SubTab{ + {"기본", "/admin/address-filter", path == "/admin/address-filter"}, + {"IPv4 필터", "/admin/address-filter/ipv4", false}, + {"IPv6 필터", "/admin/address-filter/ipv6", false}, + {"MAC 필터", "/admin/address-filter/mac", false}, + }), true + case "/admin/address-filter/ipv4", "/admin/address-filter/ipv6", "/admin/address-filter/mac": + return addressFilterListPage(path), true + case "/admin/address-filter/ipv4/new", "/admin/address-filter/ipv6/new", "/admin/address-filter/mac/new": + return addressFilterNewPage(path), true + case "/admin/snmp": + return simpleFormPage(path, "관리자 운영", []string{"관리자 운영", "SNMP"}, "SNMP", []FormField{ + toggleField("SNMP 사용", "snmp", true), + selectField("SNMP 버전", "version", "v3", []string{"v1", "v2c", "v3"}), + textField("Read Community", "roCommunity", "public", false), + textField("Write Community", "rwCommunity", "private", false), + textField("Trap Host", "trapHost", "192.168.0.50", false), + textField("Trap Port", "trapPort", "162", false), + }), true + case "/admin/ssl", "/admin/ssl/new", "/admin/ssl/import", "/admin/ssl/import-csr": + title := map[string]string{ + "/admin/ssl": "SSL 인증서", + "/admin/ssl/new": "SSL 인증서 - 새로 만들기", + "/admin/ssl/import": "SSL 인증서 - 가져오기", + "/admin/ssl/import-csr": "SSL 인증서 - CSR 가져오기", + }[path] + return simpleFormPage(path, "관리자 운영", []string{"관리자 운영", "SSL 인증서"}, title, []FormField{ + textField("인증서 이름", "name", "device-cert", false), + textField("공통 이름(CN)", "cn", "192.168.0.233", false), + textAreaField("인증서 내용", "certificate", "-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----", 6), + textAreaField("개인 키", "privateKey", "-----BEGIN PRIVATE KEY-----\nMOCK\n-----END PRIVATE KEY-----", 6), + }), true + case "/admin/backup/export", "/admin/backup/import", "/admin/backup/restore": + title := map[string]string{ + "/admin/backup/export": "구성 내보내기", + "/admin/backup/import": "구성 가져오기", + "/admin/backup/restore": "구성 복원", + }[path] + return actionPage(path, title, "백업 및 복원 관련 액션을 시뮬레이션하는 목업 화면입니다.", "backup-action"), true + case "/admin/firmware": + return actionPage(path, "펌웨어 업그레이드", "펌웨어 파일 업로드와 적용 절차를 재현하는 목업 화면입니다.", "firmware-upgrade"), true + default: + return PageData{}, false + } +} + +func settingsPage(path string) (PageData, bool) { + switch path { + case "/settings/alert/event": + return simpleFormPage(path, "기타 설정", []string{"기타 설정", "알림 설정", "이벤트"}, "알림 설정 - 이벤트", []FormField{ + toggleField("이벤트 알림 사용", "event", true), + toggleField("링크 다운", "linkDown", true), + toggleField("인증 실패", "authFail", true), + toggleField("설정 변경", "configChange", true), + }), true + case "/settings/alert/serial-event": + return simpleFormPage(path, "기타 설정", []string{"기타 설정", "알림 설정", "시리얼 이벤트"}, "알림 설정 - 시리얼 이벤트", []FormField{ + toggleField("시리얼 이벤트 사용", "serialEvent", false), + toggleField("포트 에러", "portError", true), + toggleField("수신 타임아웃", "rxTimeout", false), + }), true + case "/settings/alert/mail": + return simpleFormPage(path, "기타 설정", []string{"기타 설정", "알림 설정", "메일"}, "알림 설정 - 메일", []FormField{ + textField("SMTP 서버", "smtp", "smtp.example.com", false), + textField("포트", "port", "587", false), + textField("발신자", "from", "alert@example.com", false), + textField("수신자", "to", "admin@example.com", false), + toggleField("TLS 사용", "tls", true), + }), true + case "/settings/alert/snmp-trap-filter": + return simpleFormPage(path, "기타 설정", []string{"기타 설정", "알림 설정", "SNMP Trap 필터"}, "SNMP Trap 필터", []FormField{ + textField("대상 주소", "address", "192.168.0.50", false), + textField("커뮤니티", "community", "public", false), + selectField("버전", "version", "v2c", []string{"v1", "v2c", "v3"}), + }), true + case "/settings/system-log/remote": + return simpleFormPage(path, "기타 설정", []string{"기타 설정", "시스템 로그", "원격 로그"}, "시스템 로그 - 원격 로그", []FormField{ + toggleField("원격 로그 사용", "remote", false), + textField("서버 주소", "host", "192.168.0.60", false), + textField("포트", "port", "514", false), + selectField("프로토콜", "protocol", "UDP", []string{"UDP", "TCP"}), + }), true + case "/settings/system-log/status-event": + return simpleFormPage(path, "기타 설정", []string{"기타 설정", "시스템 로그", "상태 이벤트"}, "시스템 로그 - 상태 이벤트", []FormField{ + toggleField("링크 업/다운", "link", true), + toggleField("사용자 로그인", "login", true), + toggleField("환경 알림", "env", true), + }), true + case "/settings/system-log/events": + return simpleFormPage(path, "기타 설정", []string{"기타 설정", "시스템 로그", "이벤트"}, "시스템 로그 - 이벤트", []FormField{ + toggleField("정보 로그", "info", true), + toggleField("경고 로그", "warn", true), + toggleField("오류 로그", "error", true), + }), true + case "/settings/datetime": + return simpleFormPage(path, "기타 설정", []string{"기타 설정", "날짜와 시간"}, "날짜와 시간", []FormField{ + selectField("시간대", "timezone", "Asia/Seoul", []string{"Asia/Seoul", "UTC", "America/Los_Angeles"}), + toggleField("NTP 사용", "ntp", true), + textField("NTP 서버 1", "ntp1", "time.google.com", false), + textField("NTP 서버 2", "ntp2", "pool.ntp.org", false), + textField("수동 날짜", "date", "2026-06-24", false), + textField("수동 시간", "time", "14:03:22", false), + }), true + default: + return PageData{}, false + } +} + +func toolsPage(path string) (PageData, bool) { + switch path { + case "/tools/nettest/ping", "/tools/nettest/traceroute", "/tools/nettest/tcp": + active := path + title := map[string]string{ + "/tools/nettest/ping": "핑 테스트", + "/tools/nettest/traceroute": "경로 추적 테스트", + "/tools/nettest/tcp": "TCP 연결 테스트", + }[path] + result := map[string]string{ + "/tools/nettest/ping": "PING 1.1.1.1: 56 data bytes\n64 bytes from 1.1.1.1: icmp_seq=0 ttl=58 time=7.2 ms", + "/tools/nettest/traceroute": "1 192.168.0.1 0.7 ms\n2 10.10.0.1 2.8 ms\n3 1.1.1.1 8.4 ms", + "/tools/nettest/tcp": "Connecting to 192.168.0.100:4001 ... success\nSession established in mock mode.", + }[path] + return newPage(path, "진단 도구", "네트워크 테스트", []string{"진단 도구", "네트워크 테스트"}, []SubTab{ + {"핑", "/tools/nettest/ping", active == "/tools/nettest/ping"}, + {"경로 추적", "/tools/nettest/traceroute", active == "/tools/nettest/traceroute"}, + {"TCP 연결", "/tools/nettest/tcp", active == "/tools/nettest/tcp"}, + }, []ContentBlock{ + { + Kind: "tool", + Title: title, + Tool: &ToolData{ + Action: "/api/mock/action", + PrimaryLabel: "테스트", + Tabs: []SubTab{ + {"핑", "/tools/nettest/ping", active == "/tools/nettest/ping"}, + {"경로 추적", "/tools/nettest/traceroute", active == "/tools/nettest/traceroute"}, + {"TCP 연결", "/tools/nettest/tcp", active == "/tools/nettest/tcp"}, + }, + Fields: []FormField{ + textField("주소", "address", "1.1.1.1", false), + textField("횟수", "count", "4", false), + textField("패킷 크기", "size", "56", false), + textField("타임아웃", "timeout", "1000", false), + }, + Result: result, + }, + }, + }), true + case "/tools/alias": + return simpleFormPage(path, "진단 도구", []string{"진단 도구", "포트 별칭"}, "포트 별칭", []FormField{ + textField("포트 1", "port1", "PLC-A", false), + textField("포트 2", "port2", "센서-라인1", false), + textField("포트 3", "port3", "RTU-3", false), + textField("포트 4", "port4", "예비 포트", false), + }), true + default: + return PageData{}, false + } +} + +func logsPage(path string) (PageData, bool) { + switch path { + case "/logs/system": + return tablePage(path, "시스템 로그", []string{"로그", "시스템 로그"}, []string{"No.", "이벤트"}, []TableRow{ + {Cells: []string{"1", "관리자 로그인"}}, + {Cells: []string{"2", "구성 저장 요청"}}, + {Cells: []string{"3", "포트 1 링크 상태 변경"}}, + }, nil), true + case "/logs/ports": + return tablePage(path, "포트 로그", []string{"로그", "포트 로그"}, []string{"No.", "포트", "이벤트"}, []TableRow{ + {Cells: []string{"1", "Port 1", "데이터 수신 128 bytes"}}, + {Cells: []string{"2", "Port 24", "링크 복구"}}, + {Cells: []string{"3", "Port 12", "세션 종료"}}, + }, nil), true + default: + return PageData{}, false + } +} + +func portStatusPage() PageData { + return newPage("/port-status", "포트 상태", "포트 상태", []string{"포트 상태"}, nil, []ContentBlock{ + {Kind: "port-links", Title: "포트 상태 맵", PortLinks: portLinks()}, + { + Kind: "table", + Title: "포트 상태 요약", + Table: &TableData{ + Columns: []string{"포트", "상태", "프로토콜", "세션", "RX/TX"}, + EmptyMessage: "데이터가 없습니다.", + Rows: []TableRow{ + {Cells: []string{"Port 1", "연결", "TCP Server", "3", "12.3KB / 9.8KB"}}, + {Cells: []string{"Port 2", "대기", "TCP Client", "0", "0 / 0"}}, + {Cells: []string{"Port 24", "연결", "UDP", "1", "1.2KB / 0.8KB"}}, + }, + }, + }, + }) +} + +func passwordPage() PageData { + return newPage("/password", "비밀번호 변경", "비밀번호 변경", []string{"비밀번호 변경"}, nil, []ContentBlock{ + formBlock("비밀번호 변경", "change-password", []FormField{ + {Label: "사용자 이름", Name: "username", Type: "text", Value: "terauser", Disabled: true}, + passwordField("기존 비밀번호", "oldPassword", ""), + passwordField("새 비밀번호", "newPassword", ""), + passwordField("비밀번호 확인", "confirmPassword", ""), + }, "변경", "적용"), + }) +} + +func actionPage(path string, title string, description string, action string) PageData { + return newPage(path, title, title, []string{title}, nil, []ContentBlock{ + { + Kind: "action", + Title: title, + Action: &ActionPanel{ + Description: description, + Buttons: []ActionButton{ + {Label: "실행", Action: action, Variant: "primary"}, + {Label: "취소", Action: "cancel", Variant: "secondary"}, + }, + }, + }, + modalBlock(title+" 확인", "실제 장비에는 반영하지 않고 화면 알림만 표시합니다.", []FormField{ + textAreaField("안내", "note", "MOCK_MODE에서는 실제 저장, 재시작, 복원 작업을 수행하지 않습니다.", 4), + }), + }) +} + +func simpleFormPage(path string, section string, breadcrumbs []string, title string, fields []FormField) PageData { + return newPage(path, section, title, breadcrumbs, nil, []ContentBlock{ + formBlock(title, "apply-"+sanitizePath(path), fields, "변경", "적용"), + }) +} + +func tablePage(path string, title string, breadcrumbs []string, columns []string, rows []TableRow, subtabs []SubTab) PageData { + return newPage(path, title, title, breadcrumbs, subtabs, []ContentBlock{ + tableBlock(title, columns, rows, "추가", "변경"), + }) +} + +func newPortPage(path string, portID int, subtabs []SubTab, blocks []ContentBlock) PageData { + return newPage(path, "포트 설정", fmt.Sprintf("포트 설정 / Port %d", portID), []string{"포트 설정", fmt.Sprintf("Port %d", portID)}, subtabs, blocks) +} + +func newPage(path string, section string, title string, breadcrumbs []string, subtabs []SubTab, blocks []ContentBlock) PageData { + return PageData{ + AppName: "TERACLONE", + MockMode: config.MockMode, + CurrentPath: path, + Title: title, + Section: section, + Breadcrumbs: breadcrumbs, + WorkspaceTabs: workspaceTabs(path, title), + Menu: menu(path), + SubTabs: subtabs, + Blocks: blocks, + } +} + +func formBlock(title string, action string, fields []FormField, primary string, secondary string) ContentBlock { + return ContentBlock{ + Kind: "form", + Title: title, + Form: &FormData{ + Action: "/api/mock/action", + PrimaryLabel: primary, + SecondaryLabel: secondary, + Fields: fields, + Notes: []string{ + fmt.Sprintf("Mock action: %s", action), + "실제 장비 설정 저장이나 통신은 수행하지 않습니다.", + }, + }, + } +} + +func tableBlock(title string, columns []string, rows []TableRow, primary string, secondary string) ContentBlock { + return ContentBlock{ + Kind: "table", + Title: title, + Table: &TableData{ + Columns: columns, + Rows: rows, + EmptyMessage: "데이터가 없습니다.", + PrimaryLabel: primary, + SecondaryLabel: secondary, + }, + } +} + +func modalBlock(title string, message string, fields []FormField) ContentBlock { + return ContentBlock{ + Kind: "modal", + Title: title, + Modal: &ModalData{ + Root: sanitizePath(title), + Title: title, + Message: message, + Fields: fields, + Buttons: []ActionButton{ + {Label: "확인", Action: "confirm-modal", Variant: "primary"}, + {Label: "취소", Action: "cancel-modal", Variant: "secondary"}, + }, + }, + } +} + +func addressFilterListPage(path string) PageData { + filterName := "IPv4 필터" + if strings.Contains(path, "ipv6") { + filterName = "IPv6 필터" + } + if strings.Contains(path, "mac") { + filterName = "MAC 필터" + } + + return tablePage(path, filterName, []string{"관리자 운영", "주소 필터", filterName}, []string{"규칙 이름", "조건", "설명"}, []TableRow{ + {Cells: []string{"규칙 1", "허용", "내부 운영망"}}, + {Cells: []string{"규칙 2", "차단", "임시 테스트 장비"}}, + }, []SubTab{ + {"기본", "/admin/address-filter", false}, + {"IPv4 필터", "/admin/address-filter/ipv4", strings.Contains(path, "ipv4")}, + {"IPv6 필터", "/admin/address-filter/ipv6", strings.Contains(path, "ipv6")}, + {"MAC 필터", "/admin/address-filter/mac", strings.Contains(path, "mac")}, + }) +} + +func addressFilterNewPage(path string) PageData { + title := "IPv4 필터 추가" + fields := []FormField{ + textField("규칙 이름", "name", "allow-office", false), + selectField("동작", "policy", "허용", []string{"허용", "차단"}), + textField("주소", "address", "192.168.0.0", false), + textField("마스크", "mask", "255.255.255.0", false), + } + if strings.Contains(path, "ipv6") { + title = "IPv6 필터 추가" + fields = []FormField{ + textField("규칙 이름", "name", "allow-v6", false), + selectField("동작", "policy", "허용", []string{"허용", "차단"}), + textField("주소", "address", "2001:db8::", false), + textField("Prefix Length", "prefix", "64", false), + } + } + if strings.Contains(path, "mac") { + title = "MAC 필터 추가" + fields = []FormField{ + textField("규칙 이름", "name", "allow-maintenance", false), + selectField("동작", "policy", "허용", []string{"허용", "차단"}), + textField("MAC 주소", "address", "00:11:22:33:44:55", false), + textField("설명", "desc", "점검 장비", false), + } + } + + return simpleFormPage(path, "관리자 운영", []string{"관리자 운영", "주소 필터", title}, title, fields) +} + +func userTabs(path string) []SubTab { + return []SubTab{ + {"사용자", "/admin/users", path == "/admin/users"}, + {"그룹", "/admin/groups", path == "/admin/groups"}, + } +} + +func workspaceTabs(path string, title string) []WorkspaceTab { + return []WorkspaceTab{ + {Key: tabKeyForPath(path), Label: tabLabelForPath(path, title), Path: path, Active: true}, + } +} + +func menu(path string) []MenuGroup { + groups := []MenuGroup{ + {Label: "대시보드", Icon: "DB", Path: "/"}, + {Label: "네트워크 설정", Icon: "NW", Path: "/network/interface-1"}, + {Label: "포트 설정", Icon: "PT", Path: "/ports/1/parameters"}, + {Label: "포트 디버그", Icon: "DG", Path: "/port-debug"}, + {Label: "관리자 운영", Icon: "AD", Path: "/admin/users"}, + {Label: "기타 설정", Icon: "ST", Path: "/settings/alert/event"}, + {Label: "진단 도구", Icon: "TL", Path: "/tools/nettest/ping"}, + {Label: "로그", Icon: "LG", Path: "/logs/system"}, + {Label: "포트 상태", Icon: "PS", Path: "/port-status"}, + {Label: "비밀번호 변경", Icon: "PW", Path: "/password"}, + {Label: "설정 저장", Icon: "SV", Path: "/config/save"}, + {Label: "포트 재시작", Icon: "PR", Path: "/ports/restart"}, + {Label: "장치 재시작", Icon: "DR", Path: "/device/restart"}, + } + + for i := range groups { + if groups[i].Path == "/" { + groups[i].Active = path == "/" + continue + } + groups[i].Active = path == groups[i].Path || strings.HasPrefix(path, trimMenuRoot(groups[i].Path)) + } + + for i := range groups { + switch groups[i].Label { + case "포트 설정": + groups[i].Expanded = false + case "관리자 운영": + groups[i].Expanded = strings.HasPrefix(path, "/admin/") + groups[i].Children = append(groups[i].Children, + MenuChild{"웹 콘솔", "/admin/web-console", path == "/admin/web-console"}, + MenuChild{"사용자 계정", "/admin/users", strings.HasPrefix(path, "/admin/users") || path == "/admin/groups"}, + MenuChild{"인증 서버", "/admin/auth/radius", strings.HasPrefix(path, "/admin/auth/")}, + MenuChild{"주소 필터", "/admin/address-filter", strings.HasPrefix(path, "/admin/address-filter")}, + MenuChild{"SNMP", "/admin/snmp", path == "/admin/snmp"}, + MenuChild{"SSL 인증서", "/admin/ssl", strings.HasPrefix(path, "/admin/ssl")}, + MenuChild{"백업/복원", "/admin/backup/export", strings.HasPrefix(path, "/admin/backup/")}, + MenuChild{"펌웨어", "/admin/firmware", path == "/admin/firmware"}, + ) + case "기타 설정": + groups[i].Expanded = strings.HasPrefix(path, "/settings/") + groups[i].Children = append(groups[i].Children, + MenuChild{"알림 설정", "/settings/alert/event", strings.HasPrefix(path, "/settings/alert/")}, + MenuChild{"시스템 로그", "/settings/system-log/remote", strings.HasPrefix(path, "/settings/system-log/")}, + MenuChild{"날짜와 시간", "/settings/datetime", path == "/settings/datetime"}, + ) + case "진단 도구": + groups[i].Expanded = strings.HasPrefix(path, "/tools/") + groups[i].Children = append(groups[i].Children, + MenuChild{"네트워크 테스트", "/tools/nettest/ping", strings.HasPrefix(path, "/tools/nettest/")}, + MenuChild{"포트 별칭", "/tools/alias", path == "/tools/alias"}, + ) + case "로그": + groups[i].Expanded = strings.HasPrefix(path, "/logs/") + groups[i].Children = append(groups[i].Children, + MenuChild{"시스템 로그", "/logs/system", path == "/logs/system"}, + MenuChild{"포트 로그", "/logs/ports", path == "/logs/ports"}, + ) + } + } + + return groups +} + +func portLinks() []PortLink { + links := make([]PortLink, 0, 48) + for i := 1; i <= 48; i++ { + links = append(links, PortLink{ + Number: i, + Up: i%3 != 0, + Blink: i == 1 || i == 24, + }) + } + return links +} + +func portTargets(selected int) []PortTarget { + targets := make([]PortTarget, 0, 48) + for i := 1; i <= 48; i++ { + targets = append(targets, PortTarget{ + Number: i, + Checked: i == selected, + }) + } + return targets +} + +func textField(label string, name string, value string, required bool) FormField { + return FormField{Label: label, Name: name, Type: "text", Value: value, Required: required} +} + +func passwordField(label string, name string, value string) FormField { + return FormField{Label: label, Name: name, Type: "password", Value: value} +} + +func textAreaField(label string, name string, value string, rows int) FormField { + return FormField{Label: label, Name: name, Type: "textarea", Value: value, Rows: rows} +} + +func toggleField(label string, name string, checked bool) FormField { + return FormField{Label: label, Name: name, Type: "toggle", Checked: checked} +} + +func selectField(label string, name string, value string, options []string) FormField { + field := FormField{Label: label, Name: name, Type: "select", Value: value} + for _, option := range options { + field.Options = append(field.Options, Option{ + Label: option, + Value: option, + Selected: option == value, + }) + } + return field +} + +func sanitizePath(path string) string { + return strings.NewReplacer("/", "-", ":", "-", "_", "-").Replace(strings.Trim(path, "/")) +} + +func trimMenuRoot(path string) string { + if path == "/" { + return "/" + } + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) == 0 { + return "/" + } + return "/" + parts[0] + "/" +} + +func shortLabelForPath(path string) string { + switch { + case strings.HasPrefix(path, "/network/"): + return "네트워크 설정" + case strings.HasPrefix(path, "/ports/"): + return "포트 설정" + case strings.HasPrefix(path, "/admin/"): + return "관리자 운영" + case strings.HasPrefix(path, "/settings/"): + return "기타 설정" + case strings.HasPrefix(path, "/tools/"): + return "진단 도구" + case strings.HasPrefix(path, "/logs/"): + return "로그" + default: + return "장비 화면" + } +} + +func tabLabelForPath(path string, title string) string { + switch { + case path == "/": + return "대시보드" + case strings.HasPrefix(path, "/ports/"): + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) >= 2 { + if portID, err := strconv.Atoi(parts[1]); err == nil { + return fmt.Sprintf("포트 %d 설정", portID) + } + } + case strings.HasPrefix(path, "/admin/snmp"): + return "SNMP" + case strings.HasPrefix(path, "/tools/nettest/"): + return "네트워크 테스트" + } + + if title != "" { + return title + } + + return shortLabelForPath(path) +} + +func tabKeyForPath(path string) string { + switch { + case path == "/": + return "dashboard" + case strings.HasPrefix(path, "/network/"): + return "network" + case strings.HasPrefix(path, "/ports/"): + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) >= 2 { + if portID, err := strconv.Atoi(parts[1]); err == nil { + return fmt.Sprintf("port-%d", portID) + } + } + return "ports" + case strings.HasPrefix(path, "/port-debug"): + return "port-debug" + case strings.HasPrefix(path, "/admin/snmp"): + return "admin-snmp" + case strings.HasPrefix(path, "/admin/"): + return "admin" + case strings.HasPrefix(path, "/settings/"): + return "settings" + case strings.HasPrefix(path, "/tools/nettest/"): + return "network-test" + case strings.HasPrefix(path, "/tools/"): + return "tools" + case strings.HasPrefix(path, "/logs/"): + return "logs" + case path == "/port-status": + return "port-status" + case path == "/password": + return "password" + case path == "/config/save": + return "config-save" + case path == "/ports/restart": + return "ports-restart" + case path == "/device/restart": + return "device-restart" + default: + return strings.Trim(strings.ReplaceAll(path, "/", "-"), "-") + } +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go new file mode 100644 index 0000000..4e4f4f5 --- /dev/null +++ b/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 +} diff --git a/internal/service/app_service.go b/internal/service/app_service.go new file mode 100644 index 0000000..c99352a --- /dev/null +++ b/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"} +} diff --git a/internal/store/sqlite_store.go b/internal/store/sqlite_store.go new file mode 100644 index 0000000..566d79d --- /dev/null +++ b/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"} +} diff --git a/scripts/dev.ps1 b/scripts/dev.ps1 new file mode 100644 index 0000000..843c39a --- /dev/null +++ b/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 } +} diff --git a/scripts/loadtest.ps1 b/scripts/loadtest.ps1 new file mode 100644 index 0000000..a41fbd3 --- /dev/null +++ b/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 diff --git a/scripts/loadtest.sh b/scripts/loadtest.sh new file mode 100644 index 0000000..9b0d129 --- /dev/null +++ b/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[@]}" diff --git a/web/static/css/style.css b/web/static/css/style.css new file mode 100644 index 0000000..fe6b3fa --- /dev/null +++ b/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)); } +} diff --git a/web/static/js/app.js b/web/static/js/app.js new file mode 100644 index 0000000..1090e62 --- /dev/null +++ b/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); +})(); diff --git a/web/templates/auth/login.html b/web/templates/auth/login.html new file mode 100644 index 0000000..a1f2b33 --- /dev/null +++ b/web/templates/auth/login.html @@ -0,0 +1,45 @@ +{{define "login"}} + + + + + + 로그인 - {{.AppName}} + + + +
+ +
+ + +{{end}} diff --git a/web/templates/layout.html b/web/templates/layout.html new file mode 100644 index 0000000..c484dee --- /dev/null +++ b/web/templates/layout.html @@ -0,0 +1,165 @@ +{{define "layout"}} + + + + + + {{.Title}} - {{.AppName}} + + + +
+ + +
+
+
+ +
+ {{range $idx, $crumb := .Breadcrumbs}} + {{if gt $idx 0}}/{{end}} + {{$crumb}} + {{end}} +
+
+
+ 이전 화면 +
{{.CurrentUser}}
+
+ +
+ +
+
+ +
+ {{range .WorkspaceTabs}} +
+ {{.Label}} + +
+ {{end}} +
+ +
+ + + {{if .SubTabs}} +
+ {{range .SubTabs}} + {{.Label}} + {{end}} +
+ {{end}} + +
+ {{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}} +
+
+
+
+ +
+ + + + +{{end}} diff --git a/web/templates/page.html b/web/templates/page.html new file mode 100644 index 0000000..6691e6a --- /dev/null +++ b/web/templates/page.html @@ -0,0 +1 @@ +{{define "page"}}{{end}} diff --git a/web/templates/partials/block_action.html b/web/templates/partials/block_action.html new file mode 100644 index 0000000..66e1a2e --- /dev/null +++ b/web/templates/partials/block_action.html @@ -0,0 +1,13 @@ +{{define "block_action"}} +
+
+

{{.Title}}

+
+
{{.Action.Description}}
+
+ {{range .Action.Buttons}} + + {{end}} +
+
+{{end}} diff --git a/web/templates/partials/block_form.html b/web/templates/partials/block_form.html new file mode 100644 index 0000000..b6a8dd4 --- /dev/null +++ b/web/templates/partials/block_form.html @@ -0,0 +1,48 @@ +{{define "block_form"}} +
+
+

{{.Title}}

+ {{if .Subtitle}}

{{.Subtitle}}

{{end}} +
+
+ +
+ {{range .Form.Fields}} +
+ + {{if eq .Type "text"}} + + {{else if eq .Type "password"}} + + {{else if eq .Type "textarea"}} + + {{else if eq .Type "select"}} + + {{else if eq .Type "toggle"}} + + {{end}} + {{if .Hint}}
{{.Hint}}
{{end}} +
+ {{end}} +
+ + {{if .Form.Notes}} +
+ {{range .Form.Notes}}
{{.}}
{{end}} +
+ {{end}} + +
+ {{if .Form.PrimaryLabel}}{{end}} + {{if .Form.SecondaryLabel}}{{end}} +
+
+
+{{end}} diff --git a/web/templates/partials/block_key-values.html b/web/templates/partials/block_key-values.html new file mode 100644 index 0000000..1155c2d --- /dev/null +++ b/web/templates/partials/block_key-values.html @@ -0,0 +1,16 @@ +{{define "block_key-values"}} +
+
+

{{.Title}}

+ {{if .Subtitle}}

{{.Subtitle}}

{{end}} +
+
+ {{range .KeyValues}} +
+
{{.Key}}
+
{{.Value}}
+
+ {{end}} +
+
+{{end}} diff --git a/web/templates/partials/block_modal.html b/web/templates/partials/block_modal.html new file mode 100644 index 0000000..c5eb431 --- /dev/null +++ b/web/templates/partials/block_modal.html @@ -0,0 +1,43 @@ +{{define "block_modal"}} + +{{end}} diff --git a/web/templates/partials/block_port-links.html b/web/templates/partials/block_port-links.html new file mode 100644 index 0000000..f553819 --- /dev/null +++ b/web/templates/partials/block_port-links.html @@ -0,0 +1,15 @@ +{{define "block_port-links"}} +
+
+

{{.Title}}

+
+ +
+{{end}} diff --git a/web/templates/partials/block_port-selector.html b/web/templates/partials/block_port-selector.html new file mode 100644 index 0000000..1f87015 --- /dev/null +++ b/web/templates/partials/block_port-selector.html @@ -0,0 +1,21 @@ +{{define "block_port-selector"}} +
+
+

{{.Title}}

+
+ +
+{{end}} diff --git a/web/templates/partials/block_table.html b/web/templates/partials/block_table.html new file mode 100644 index 0000000..5d3683f --- /dev/null +++ b/web/templates/partials/block_table.html @@ -0,0 +1,56 @@ +{{define "block_table"}} +
+
+
+

{{.Title}}

+ {{if .Subtitle}}

{{.Subtitle}}

{{end}} +
+
+ {{if .Table.PrimaryLabel}} + + {{end}} + {{if .Table.SecondaryLabel}}{{end}} +
+
+
+ + + + {{range .Table.Columns}}{{end}} + {{if .Table.Rows}} + {{if (index .Table.Rows 0).Actions}}{{end}} + {{end}} + + + + {{if .Table.Rows}} + {{range .Table.Rows}} + + {{range .Cells}}{{end}} + {{if .Actions}} + + {{end}} + + {{end}} + {{else}} + + {{end}} + +
{{.}}Operate
{{.}} + {{range .Actions}} + + {{end}} +
{{.Table.EmptyMessage}}
+
+
+{{end}} diff --git a/web/templates/partials/block_tool.html b/web/templates/partials/block_tool.html new file mode 100644 index 0000000..83410e2 --- /dev/null +++ b/web/templates/partials/block_tool.html @@ -0,0 +1,41 @@ +{{define "block_tool"}} +
+
+

{{.Title}}

+
+ {{if .Tool.Tabs}} +
+ {{range .Tool.Tabs}} + {{.Label}} + {{end}} +
+ {{end}} +
+ +
+ {{range .Tool.Fields}} +
+ + {{if eq .Type "textarea"}} + + {{else if eq .Type "select"}} + + {{else}} + + {{end}} +
+ {{end}} +
+ {{if .Tool.Result}} +
{{.Tool.Result}}
+ {{end}} +
+ +
+
+
+{{end}} diff --git a/web_embed.go b/web_embed.go new file mode 100644 index 0000000..8612076 --- /dev/null +++ b/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") +}