chore: V2 planning docs + template discovery

This commit is contained in:
swanadiva
2026-07-07 11:53:16 +07:00
parent 4a8b4a2bc4
commit 8ebdebedc8
31 changed files with 9304 additions and 0 deletions
+709
View File
@@ -0,0 +1,709 @@
# 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 |