c215d68c81
- 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
710 lines
17 KiB
Markdown
710 lines
17 KiB
Markdown
# 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
|
|
|
|
```go
|
|
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)
|
|
|
|
```go
|
|
// 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
|
|
|
|
```go
|
|
// 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
|
|
|
|
```go
|
|
// 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)
|
|
|
|
```typescript
|
|
// 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)
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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');
|
|
}
|
|
}
|
|
```
|
|
|
|
```go
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
useEffect(() => {
|
|
const ws = new WebSocket(url);
|
|
|
|
return () => {
|
|
ws.close(); // Close on unmount
|
|
};
|
|
}, [hostId]);
|
|
```
|
|
|
|
### 5.3 Image/Icon Lazy Loading
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```go
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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)
|
|
|
|
```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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```go
|
|
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)
|
|
|
|
```go
|
|
// 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)
|
|
|
|
```bash
|
|
# 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```go
|
|
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 |
|