Files
HostKeeper/docs/PERFORMANCE.md
T
swanadiva c215d68c81 refactor: flatten docs/ — move V2 docs from docs/v2/ to docs/
- git mv docs/v2/*.md docs/ (11 planning documents)
- Remove empty docs/v2/ directory
- Update all references: AGENTS.md, CHANGELOG.md, README.md, ARCHITECTURE.md
- Fix outdated repo tree in ARCHITECTURE.md to match actual structure
2026-07-07 12:04:59 +07:00

17 KiB

Hostkeeper V2 — Performance & Stability

Status: V2 Planning Complete Last Updated: 2026-06-29 Goal: "Tidak lemot dan bisa stabil digunakan" — fast, responsive, zero crashes.


1. Performance Targets

Metric Target How to Measure
App startup < 2 seconds Electron ready → UI visible
API response < 50ms Time from request to response
Terminal latency < 10ms Keystroke → character appears
SFTP listing < 500ms Directory load time
Memory usage < 200 MB Steady state (10 tabs open)
CPU usage < 5% Idle state
Scroll (1000 hosts) 60 fps No jank during scroll
SSH reconnect < 3 seconds Auto-reconnect after disconnect

2. Go Backend Performance

2.1 GoFiber Configuration

app := fiber.New(fiber.Config{
    // Timeouts (prevent hung connections)
    ReadTimeout:  30 * time.Second,
    WriteTimeout: 30 * time.Second,
    IdleTimeout:  120 * time.Second,
    
    // Concurrency (limit goroutines)
    Concurrency: 256,
    
    // Body limit (prevent large payload attacks)
    BodyLimit: 10 * 1024 * 1024, // 10MB
    
    // Buffer sizes (optimize for typical requests)
    ReadBufferSize:  4096,
    WriteBufferSize: 4096,
    
    // Disable startup banner
    DisableStartupMessage: false,
    
    // JSON encoder (fastest)
    JSONEncoder: json.Marshal,
    JSONDecoder: json.Unmarshal,
})

2.2 Connection Pooling (SSH)

// app/backend/ws/pool.go
type SessionPool struct {
    mu       sync.RWMutex
    sessions map[string]*pooledSession // key: hostID
    maxIdle  time.Duration
    maxSize  int
}

type pooledSession struct {
    client    *ssh.Client
    lastUsed  time.Time
    refCount  int
}

func NewSessionPool(maxSize int, maxIdle time.Duration) *SessionPool {
    pool := &SessionPool{
        sessions: make(map[string]*pooledSession),
        maxIdle:  maxIdle,
        maxSize:  maxSize,
    }
    
    // Cleanup goroutine: close idle connections
    go func() {
        ticker := time.NewTicker(30 * time.Second)
        for range ticker.C {
            pool.cleanup()
        }
    }()
    
    return pool
}

func (p *SessionPool) Get(hostID string) (*pooledSession, error) {
    p.mu.RLock()
    sess, exists := p.sessions[hostID]
    p.mu.RUnlock()
    
    if exists {
        sess.lastUsed = time.Now()
        sess.refCount++
        return sess, nil
    }
    
    // Create new connection
    p.mu.Lock()
    defer p.mu.Unlock()
    
    // Double-check after acquiring write lock
    if sess, exists := p.sessions[hostID]; exists {
        sess.lastUsed = time.Now()
        sess.refCount++
        return sess, nil
    }
    
    // Evict if at capacity
    if len(p.sessions) >= p.maxSize {
        p.evictOldest()
    }
    
    // Create new session
    host, err := storage.GetHost(context.Background(), hostID)
    if err != nil {
        return nil, err
    }
    
    client := ssh.NewClient(host, 30*time.Second)
    if err := client.Connect(context.Background()); err != nil {
        return nil, err
    }
    
    sess = &pooledSession{
        client:   client,
        lastUsed: time.Now(),
        refCount: 1,
    }
    p.sessions[hostID] = sess
    
    return sess, nil
}

func (p *SessionPool) Put(hostID string) {
    p.mu.Lock()
    defer p.mu.Unlock()
    
    if sess, exists := p.sessions[hostID]; exists {
        sess.refCount--
        if sess.refCount <= 0 {
            // Don't close immediately — keep for reuse
            sess.refCount = 0
        }
    }
}

func (p *SessionPool) cleanup() {
    p.mu.Lock()
    defer p.mu.Unlock()
    
    for key, sess := range p.sessions {
        if sess.refCount == 0 && time.Since(sess.lastUsed) > p.maxIdle {
            sess.client.Close()
            delete(p.sessions, key)
        }
    }
}

2.3 Buffer Pooling

// app/backend/ws/buffers.go
var bufferPool = sync.Pool{
    New: func() interface{} {
        buf := make([]byte, 4096)
        return &buf
    },
}

func GetBuffer() *[]byte {
    return bufferPool.Get().(*[]byte)
}

func PutBuffer(buf *[]byte) {
    // Reset buffer
    (*buf) = (*buf)[:0]
    bufferPool.Put(buf)
}

2.4 Goroutine Management

// Limit concurrent SSH sessions
var sshSemaphore = make(chan struct{}, 50) // max 50 concurrent sessions

func handleTerminal(c *websocket.Conn) {
    // Acquire semaphore
    select {
    case sshSemaphore <- struct{}{}:
        defer func() { <-sshSemaphore }()
    default:
        c.WriteJSON(fiber.Map{
            "type":    "error",
            "message": "Too many connections",
        })
        return
    }
    
    // ... handle session
}

3. Frontend Performance

3.1 Code Splitting (Lazy Loading)

// app/frontend/src/router.tsx
import { lazy, Suspense } from 'react';

const HostListScreen = lazy(() => import('./screens/HostListScreen'));
const TerminalScreen = lazy(() => import('./screens/TerminalScreen'));
const SFTPScreen = lazy(() => import('./screens/SFTPScreen'));
const SettingsScreen = lazy(() => import('./screens/SettingsScreen'));

const router = createBrowserRouter([
    {
        path: '/',
        element: <MainLayout />,
        children: [
            {
                index: true,
                element: (
                    <Suspense fallback={<LoadingSpinner />}>
                        <HostListScreen />
                    </Suspense>
                ),
            },
            // ... other routes
        ],
    },
]);

3.2 Virtual Scrolling (Host List)

// app/frontend/src/components/VirtualList.tsx
import { useVirtualizer } from '@tanstack/react-virtual';

interface VirtualListProps<T> {
    items: T[];
    renderItem: (item: T) => React.ReactNode;
    estimateSize?: number;
}

function VirtualList<T>({ items, renderItem, estimateSize = 48 }: VirtualListProps<T>) {
    const parentRef = useRef<HTMLDivElement>(null);
    
    const virtualizer = useVirtualizer({
        count: items.length,
        getScrollElement: () => parentRef.current,
        estimateSize: () => estimateSize,
        overscan: 10, // Render 10 items outside viewport
    });
    
    return (
        <div ref={parentRef} className="h-full overflow-auto">
            <div style={{ height: virtualizer.getTotalSize() }}>
                {virtualizer.getVirtualItems().map(virtualRow => (
                    <div
                        key={virtualRow.key}
                        style={{
                            position: 'absolute',
                            top: virtualRow.start,
                            height: virtualRow.size,
                            width: '100%',
                        }}
                    >
                        {renderItem(items[virtualRow.index])}
                    </div>
                ))}
            </div>
        </div>
    );
}

3.3 React.memo for Expensive Components

// Prevent re-render when props haven't changed
const HostItem = React.memo(function HostItem({ host, isActive, onClick }: HostItemProps) {
    return (
        <div onClick={onClick}>
            {/* ... */}
        </div>
    );
}, (prev, next) => {
    return prev.host.id === next.host.id
        && prev.isActive === next.isActive;
});

3.4 useMemo for Expensive Computations

function HostList({ hosts, filter }: { hosts: Host[]; filter: string }) {
    const filteredHosts = useMemo(() => {
        if (!filter) return hosts;
        return hosts.filter(h =>
            h.name.toLowerCase().includes(filter.toLowerCase()) ||
            h.hostname.toLowerCase().includes(filter.toLowerCase())
        );
    }, [hosts, filter]);
    
    return <VirtualList items={filteredHosts} renderItem={renderHostItem} />;
}

3.5 useCallback for Event Handlers

function HostItem({ host, onSelect }: { host: Host; onSelect: (id: string) => void }) {
    const handleClick = useCallback(() => {
        onSelect(host.id);
    }, [host.id, onSelect]);
    
    return <div onClick={handleClick}>{/* ... */}</div>;
}

3.6 Zustand Selector Optimization

// Bad: re-renders on ANY store change
const hosts = useHostStore(state => state.hosts);

// Good: only re-renders when hosts change
const hosts = useHostStore(state => state.hosts);

// Good: only re-renders when selectedHost changes
const selectedId = useHostStore(state => state.selectedHost);

// Good: granular selector
const hostName = useHostStore(state => {
    const host = state.hosts.find(h => h.id === state.selectedHost);
    return host?.name;
});

4. WebSocket Stability

4.1 Keepalive

// Client side (React)
class TerminalWebSocket {
    private ws: WebSocket;
    private pingInterval: NodeJS.Timeout;
    
    connect(url: string) {
        this.ws = new WebSocket(url);
        
        // Send ping every 30 seconds
        this.pingInterval = setInterval(() => {
            if (this.ws.readyState === WebSocket.OPEN) {
                this.ws.send(new Uint8Array([0])); // ping frame
            }
        }, 30000);
    }
    
    disconnect() {
        clearInterval(this.pingInterval);
        this.ws.close(1000, 'Client closing');
    }
}
// Server side (Go)
func handleTerminal(c *websocket.Conn) {
    // Set pong handler
    c.SetPongHandler(func(string) error {
        c.SetReadDeadline(time.Now().Add(60 * time.Second))
        return nil
    })
    
    // Read loop (detects disconnect)
    for {
        _, _, err := c.ReadMessage()
        if err != nil {
            break
        }
    }
}

4.2 Auto-Reconnect

// app/frontend/src/hooks/useTerminalReconnect.ts
function useTerminalReconnect(ws: TerminalWebSocket) {
    const [reconnectAttempts, setReconnectAttempts] = useState(0);
    const maxReconnectAttempts = 5;
    
    useEffect(() => {
        if (ws.status === 'disconnected' && reconnectAttempts < maxReconnectAttempts) {
            const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
            
            const timer = setTimeout(() => {
                setReconnectAttempts(prev => prev + 1);
                ws.reconnect();
            }, delay);
            
            return () => clearTimeout(timer);
        }
    }, [ws.status, reconnectAttempts]);
    
    return { reconnectAttempts, maxReconnectAttempts };
}

4.3 Error Boundaries

// app/frontend/src/components/ErrorBoundary.tsx
class ErrorBoundary extends React.Component {
    state = { hasError: false, error: null };
    
    static getDerivedStateFromError(error: Error) {
        return { hasError: true, error };
    }
    
    componentDidCatch(error: Error, info: React.ErrorInfo) {
        console.error('Terminal error:', error, info);
    }
    
    render() {
        if (this.state.hasError) {
            return (
                <div className="flex flex-col items-center justify-center h-full">
                    <AlertTriangle className="h-12 w-12 text-yellow-500 mb-4" />
                    <h2 className="text-lg font-semibold mb-2">Something went wrong</h2>
                    <Button onClick={() => window.location.reload()}>
                        Reload
                    </Button>
                </div>
            );
        }
        return this.props.children;
    }
}

5. Memory Management

5.1 Terminal Cleanup

function XTermWrapper({ tabId }: { tabId: string }) {
    useEffect(() => {
        const term = new Terminal({
            scrollback: 10000, // Limit scrollback buffer
        });
        
        return () => {
            term.dispose(); // Free memory on unmount
        };
    }, []);
}

5.2 WebSocket Cleanup

useEffect(() => {
    const ws = new WebSocket(url);
    
    return () => {
        ws.close(); // Close on unmount
    };
}, [hostId]);

5.3 Image/Icon Lazy Loading

// Only load icons when needed
const Icon = lazy(() => import('./Icon'));

// Or use intersection observer
function LazyIcon({ name }: { name: string }) {
    const [loaded, setLoaded] = useState(false);
    const ref = useRef<HTMLDivElement>(null);
    
    useEffect(() => {
        const observer = new IntersectionObserver(([entry]) => {
            if (entry.isIntersecting) {
                setLoaded(true);
                observer.disconnect();
            }
        });
        
        if (ref.current) observer.observe(ref.current);
        return () => observer.disconnect();
    }, []);
    
    return <div ref={ref}>{loaded && <Icon name={name} />}</div>;
}

6. Crash Recovery

6.1 Go Panic Recovery

// Already built into GoFiber
app.Use(recover.New())

// Custom panic handler
app.Use(recover.New(recover.Config{
    Handler: func(c *fiber.Ctx, err error) {
        log.Printf("PANIC: %v\n%s", err, debug.Stack())
        c.Status(500).JSON(fiber.Map{
            "error": "Internal server error",
            "code":  "PANIC",
        })
    },
}))

6.2 Electron Crash Reporter

// app/electron/main.ts
import { crashReporter } from 'electron';

crashReporter.start({
    productName: 'Hostkeeper',
    submitURL: '', // No remote reporting (privacy)
    uploadToServer: false,
    compress: true,
});

6.3 State Persistence

// Save critical state to localStorage
window.addEventListener('beforeunload', () => {
    const state = {
        sidebarWidth: useUIStore.getState().sidebarWidth,
        activeTab: useTabStore.getState().activeTab,
        theme: useUIStore.getState().theme,
    };
    localStorage.setItem('hostkeeper-ui-state', JSON.stringify(state));
});

// Restore on load
const savedState = localStorage.getItem('hostkeeper-ui-state');
if (savedState) {
    const state = JSON.parse(savedState);
    useUIStore.getState().setSidebarWidth(state.sidebarWidth);
    useUIStore.getState().setTheme(state.theme);
}

7. Logging

7.1 Structured Logging (Go)

import "github.com/gofiber/fiber/v2/log"

// Request logging (built into GoFiber)
app.Use(logger.New(logger.Config{
    Format: "${time} ${method} ${path} ${status} ${latency}\n",
    Done: func(c *fiber.Ctx, log string) {
        // Custom log processing
        if c.Response().StatusCode() >= 500 {
            log.Error(log)
        }
    },
}))

// Application logging
log.Info("Server started on port", port)
log.Warn("Connection pool full")
log.Error("SSH connection failed", err)

7.2 Frontend Logging

// Only log in development
const logger = {
    info: (...args: any[]) => {
        if (import.meta.env.DEV) console.log('[INFO]', ...args);
    },
    warn: (...args: any[]) => {
        if (import.meta.env.DEV) console.warn('[WARN]', ...args);
    },
    error: (...args: any[]) => {
        console.error('[ERROR]', ...args); // Always log errors
    },
};

8. Monitoring

8.1 Health Check Endpoint

app.Get("/api/health", func(c *fiber.Ctx) error {
    return c.JSON(fiber.Map{
        "status":       "ok",
        "version":      version,
        "uptime":       time.Since(startTime).Seconds(),
        "connections":  sessionPool.ActiveCount(),
        "memory":       getMemoryUsage(),
    })
})

8.2 Metrics (Optional)

// Simple metrics middleware
var (
    requestCount   = prometheus.NewCounter("requests_total")
    requestLatency = prometheus.NewHistogram("request_latency_seconds")
)

app.Use(func(c *fiber.Ctx) error {
    start := time.Now()
    err := c.Next()
    duration := time.Since(start).Seconds()
    
    requestCount.Inc()
    requestLatency.Observe(duration)
    
    return err
})

9. Testing for Performance

9.1 Load Testing (Go)

# Install hey (HTTP load testing)
go install github.com/rakyll/hey@latest

# Test API endpoints
hey -n 1000 -c 50 http://localhost:8080/api/hosts
hey -n 1000 -c 50 http://localhost:8080/api/keys
hey -n 1000 -c 50 http://localhost:8080/api/snippets

9.2 Terminal Latency Test

// Measure keystroke to display latency
function measureTerminalLatency(term: Terminal): number {
    const start = performance.now();
    term.write('x'); // Write character
    // Measure time until character is rendered
    return performance.now() - start;
}

9.3 Memory Profiling

import "runtime"

// Log memory usage
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
log.Printf("Memory: %d MB allocated, %d MB sys",
    memStats.Alloc/1024/1024,
    memStats.Sys/1024/1024,
)

10. Common Performance Pitfalls

Issue Symptom Fix
Memory leak in terminals Memory grows over time Dispose terminals on unmount
WebSocket not closing Connections pile up Close on component unmount
Large JSON responses Slow API Pagination, field filtering
No connection pooling Slow SSH reconnect Use SessionPool
Synchronous SFTP UI freezes Use goroutines + channels
Re-render storms Jank, low FPS React.memo, useMemo, Zustand selectors
No code splitting Slow initial load Lazy load screens
Oversized bundles Slow startup Tree shaking, purging