refactor: update dependencies and remove unused code
- Added new dependencies: `adler2`, `crc32fast`, `flate2`, `miniz_oxide`, and `libredox`. - Updated existing dependencies: `tokio-rustls` to version 0.26.4 and `filetime` to version 0.2.27. - Removed the `backup.rs` file as it is no longer needed. - Introduced tests for configuration and credential management. - Enhanced the `identity` module to generate W3C compliant DID documents. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
178
neode-ui/src/api/__tests__/container-client.test.ts
Normal file
178
neode-ui/src/api/__tests__/container-client.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock the rpc-client module
|
||||
vi.mock('@/api/rpc-client', () => ({
|
||||
rpcClient: {
|
||||
call: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { containerClient } from '../container-client'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const mockedRpc = vi.mocked(rpcClient)
|
||||
|
||||
describe('containerClient', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('installApp calls container-install with manifest path', async () => {
|
||||
mockedRpc.call.mockResolvedValue('container-abc123')
|
||||
|
||||
const result = await containerClient.installApp('/apps/bitcoin/manifest.yml')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-install',
|
||||
params: { manifest_path: '/apps/bitcoin/manifest.yml' },
|
||||
})
|
||||
expect(result).toBe('container-abc123')
|
||||
})
|
||||
|
||||
it('startContainer calls container-start with app_id', async () => {
|
||||
mockedRpc.call.mockResolvedValue(undefined)
|
||||
|
||||
await containerClient.startContainer('bitcoin-knots')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-start',
|
||||
params: { app_id: 'bitcoin-knots' },
|
||||
})
|
||||
})
|
||||
|
||||
it('stopContainer calls container-stop with app_id', async () => {
|
||||
mockedRpc.call.mockResolvedValue(undefined)
|
||||
|
||||
await containerClient.stopContainer('lnd')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-stop',
|
||||
params: { app_id: 'lnd' },
|
||||
})
|
||||
})
|
||||
|
||||
it('removeContainer calls container-remove with app_id', async () => {
|
||||
mockedRpc.call.mockResolvedValue(undefined)
|
||||
|
||||
await containerClient.removeContainer('mempool')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-remove',
|
||||
params: { app_id: 'mempool' },
|
||||
})
|
||||
})
|
||||
|
||||
it('getContainerStatus returns status for a container', async () => {
|
||||
const mockStatus = {
|
||||
id: '1',
|
||||
name: 'bitcoin-knots',
|
||||
state: 'running' as const,
|
||||
image: 'bitcoinknots:29',
|
||||
created: '2026-01-01',
|
||||
ports: ['8332'],
|
||||
lan_address: 'http://localhost:8332',
|
||||
}
|
||||
mockedRpc.call.mockResolvedValue(mockStatus)
|
||||
|
||||
const result = await containerClient.getContainerStatus('bitcoin-knots')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-status',
|
||||
params: { app_id: 'bitcoin-knots' },
|
||||
})
|
||||
expect(result).toEqual(mockStatus)
|
||||
})
|
||||
|
||||
it('getContainerLogs returns log lines with default line count', async () => {
|
||||
const mockLogs = ['Starting bitcoin...', 'Block 850000 synced', 'Peer connected']
|
||||
mockedRpc.call.mockResolvedValue(mockLogs)
|
||||
|
||||
const result = await containerClient.getContainerLogs('bitcoin-knots')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-logs',
|
||||
params: { app_id: 'bitcoin-knots', lines: 100 },
|
||||
})
|
||||
expect(result).toEqual(mockLogs)
|
||||
})
|
||||
|
||||
it('getContainerLogs respects custom line count', async () => {
|
||||
mockedRpc.call.mockResolvedValue([])
|
||||
|
||||
await containerClient.getContainerLogs('lnd', 50)
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-logs',
|
||||
params: { app_id: 'lnd', lines: 50 },
|
||||
})
|
||||
})
|
||||
|
||||
it('listContainers returns all containers', async () => {
|
||||
const mockContainers = [
|
||||
{ id: '1', name: 'bitcoin-knots', state: 'running', image: 'bitcoinknots:29', created: '2026-01-01', ports: ['8332'] },
|
||||
{ id: '2', name: 'lnd', state: 'stopped', image: 'lnd:v0.18', created: '2026-01-01', ports: ['9735'] },
|
||||
]
|
||||
mockedRpc.call.mockResolvedValue(mockContainers)
|
||||
|
||||
const result = await containerClient.listContainers()
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-list',
|
||||
params: {},
|
||||
})
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('getHealthStatus returns health map', async () => {
|
||||
const mockHealth = { 'bitcoin-knots': 'healthy', lnd: 'unhealthy' }
|
||||
mockedRpc.call.mockResolvedValue(mockHealth)
|
||||
|
||||
const result = await containerClient.getHealthStatus()
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'container-health',
|
||||
params: {},
|
||||
})
|
||||
expect(result).toEqual(mockHealth)
|
||||
})
|
||||
|
||||
it('startBundledApp sends full app config', async () => {
|
||||
mockedRpc.call.mockResolvedValue(undefined)
|
||||
const app = {
|
||||
id: 'filebrowser',
|
||||
name: 'FileBrowser',
|
||||
image: 'filebrowser/filebrowser:v2',
|
||||
ports: [{ host: 8083, container: 80 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/filebrowser', container: '/srv' }],
|
||||
}
|
||||
|
||||
await containerClient.startBundledApp(app)
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'bundled-app-start',
|
||||
params: {
|
||||
app_id: 'filebrowser',
|
||||
image: 'filebrowser/filebrowser:v2',
|
||||
ports: [{ host: 8083, container: 80 }],
|
||||
volumes: [{ host: '/var/lib/archipelago/filebrowser', container: '/srv' }],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('stopBundledApp calls bundled-app-stop', async () => {
|
||||
mockedRpc.call.mockResolvedValue(undefined)
|
||||
|
||||
await containerClient.stopBundledApp('filebrowser')
|
||||
|
||||
expect(mockedRpc.call).toHaveBeenCalledWith({
|
||||
method: 'bundled-app-stop',
|
||||
params: { app_id: 'filebrowser' },
|
||||
})
|
||||
})
|
||||
|
||||
it('propagates RPC errors from the client', async () => {
|
||||
mockedRpc.call.mockRejectedValue(new Error('Connection refused'))
|
||||
|
||||
await expect(containerClient.startContainer('bitcoin-knots')).rejects.toThrow('Connection refused')
|
||||
})
|
||||
})
|
||||
330
neode-ui/src/api/__tests__/filebrowser-client.test.ts
Normal file
330
neode-ui/src/api/__tests__/filebrowser-client.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { sanitizePath } from '../filebrowser-client'
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
// FileBrowserClient reads window.location.origin in constructor, so stub it
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { origin: 'http://localhost', protocol: 'http:', hostname: 'localhost' },
|
||||
writable: true,
|
||||
})
|
||||
|
||||
// Import after stubs
|
||||
const { fileBrowserClient } = await import('../filebrowser-client')
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: status === 200 ? 'OK' : 'Error',
|
||||
json: () => Promise.resolve(body),
|
||||
text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
|
||||
blob: () => Promise.resolve(new Blob([JSON.stringify(body)])),
|
||||
headers: new Headers(),
|
||||
redirected: false,
|
||||
type: 'basic' as ResponseType,
|
||||
url: '',
|
||||
clone: () => jsonResponse(body, status),
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
|
||||
formData: () => Promise.resolve(new FormData()),
|
||||
bytes: () => Promise.resolve(new Uint8Array()),
|
||||
}
|
||||
}
|
||||
|
||||
describe('FileBrowserClient', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
})
|
||||
|
||||
describe('login', () => {
|
||||
it('authenticates and stores token', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"jwt-token-123"'))
|
||||
|
||||
// We need a fresh instance to test login — use the exported singleton
|
||||
const result = await fileBrowserClient.login('admin', 'admin')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(fileBrowserClient.isAuthenticated).toBe(true)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/app/filebrowser/api/login'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username: 'admin', password: 'admin' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns false on failed login', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 403))
|
||||
|
||||
const result = await fileBrowserClient.login('admin', 'wrong')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false on network error', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const result = await fileBrowserClient.login()
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('listDirectory', () => {
|
||||
it('lists items in a directory', async () => {
|
||||
// Ensure authenticated first
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
const mockItems = {
|
||||
items: [
|
||||
{ name: 'photos', path: '/photos', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
|
||||
{ name: 'readme.txt', path: '/readme.txt', size: 1024, modified: '2026-01-01', isDir: false, type: '', extension: 'txt' },
|
||||
],
|
||||
numDirs: 1,
|
||||
numFiles: 1,
|
||||
sorting: { by: 'name', asc: true },
|
||||
}
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(mockItems))
|
||||
|
||||
const items = await fileBrowserClient.listDirectory('/')
|
||||
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items[0]!.name).toBe('photos')
|
||||
expect(items[1]!.extension).toBe('txt')
|
||||
})
|
||||
|
||||
it('adds leading slash if missing', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ items: [], numDirs: 0, numFiles: 0, sorting: { by: 'name', asc: true } }))
|
||||
|
||||
await fileBrowserClient.listDirectory('photos')
|
||||
|
||||
const [url] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
|
||||
expect(url).toContain('/api/resources/photos')
|
||||
})
|
||||
|
||||
it('throws on non-OK response', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 404))
|
||||
|
||||
await expect(fileBrowserClient.listDirectory('/missing')).rejects.toThrow('Failed to list directory: 404')
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadUrl', () => {
|
||||
it('constructs download URL for file path', async () => {
|
||||
const url = fileBrowserClient.downloadUrl('/photos/sunset.jpg')
|
||||
|
||||
expect(url).toContain('/api/raw/photos/sunset.jpg')
|
||||
})
|
||||
|
||||
it('adds leading slash if missing', async () => {
|
||||
const url = fileBrowserClient.downloadUrl('file.txt')
|
||||
|
||||
expect(url).toContain('/api/raw/file.txt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('upload', () => {
|
||||
it('uploads a file to the correct path', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
||||
const file = new File(['hello'], 'test.txt', { type: 'text/plain' })
|
||||
|
||||
await fileBrowserClient.upload('/documents', file)
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
|
||||
expect(url).toContain('/api/resources/documents/test.txt')
|
||||
expect(url).toContain('override=true')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.body).toBe(file)
|
||||
})
|
||||
|
||||
it('throws on upload failure', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('Disk full', 507))
|
||||
const file = new File(['data'], 'big.bin')
|
||||
|
||||
await expect(fileBrowserClient.upload('/', file)).rejects.toThrow('Upload failed (507)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createFolder', () => {
|
||||
it('creates a folder at the correct path', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
||||
|
||||
await fileBrowserClient.createFolder('/documents', 'photos')
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
|
||||
expect(url).toContain('/api/resources/documents/photos/')
|
||||
expect(init.method).toBe('POST')
|
||||
})
|
||||
|
||||
it('throws on failure', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 500))
|
||||
|
||||
await expect(fileBrowserClient.createFolder('/', 'test')).rejects.toThrow('Create folder failed: 500')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteItem', () => {
|
||||
it('sends DELETE request for the item', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
||||
|
||||
await fileBrowserClient.deleteItem('/photos/old.jpg')
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
|
||||
expect(url).toContain('/api/resources/photos/old.jpg')
|
||||
expect(init.method).toBe('DELETE')
|
||||
})
|
||||
|
||||
it('throws on failure', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 403))
|
||||
|
||||
await expect(fileBrowserClient.deleteItem('/protected')).rejects.toThrow('Delete failed: 403')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUsage', () => {
|
||||
it('returns usage summary for root directory', async () => {
|
||||
// Login first
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
const mockData = {
|
||||
items: [
|
||||
{ name: 'photos', path: '/photos', size: 0, modified: '2026-01-01', isDir: true, type: '', extension: '' },
|
||||
{ name: 'file1.txt', path: '/file1.txt', size: 500, modified: '2026-01-01', isDir: false, type: '', extension: 'txt' },
|
||||
{ name: 'file2.jpg', path: '/file2.jpg', size: 1500, modified: '2026-01-01', isDir: false, type: '', extension: 'jpg' },
|
||||
],
|
||||
numDirs: 1,
|
||||
numFiles: 2,
|
||||
sorting: { by: 'name', asc: true },
|
||||
}
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(mockData))
|
||||
|
||||
const usage = await fileBrowserClient.getUsage()
|
||||
|
||||
expect(usage.totalSize).toBe(2000)
|
||||
expect(usage.folderCount).toBe(1)
|
||||
expect(usage.fileCount).toBe(2)
|
||||
})
|
||||
|
||||
it('returns zeros on failed request', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 500))
|
||||
|
||||
const usage = await fileBrowserClient.getUsage()
|
||||
|
||||
expect(usage).toEqual({ totalSize: 0, folderCount: 0, fileCount: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTextFile', () => {
|
||||
it('identifies text file extensions', () => {
|
||||
expect(fileBrowserClient.isTextFile('readme.md')).toBe(true)
|
||||
expect(fileBrowserClient.isTextFile('config.json')).toBe(true)
|
||||
expect(fileBrowserClient.isTextFile('script.py')).toBe(true)
|
||||
expect(fileBrowserClient.isTextFile('main.rs')).toBe(true)
|
||||
expect(fileBrowserClient.isTextFile('style.css')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for binary files', () => {
|
||||
expect(fileBrowserClient.isTextFile('photo.jpg')).toBe(false)
|
||||
expect(fileBrowserClient.isTextFile('video.mp4')).toBe(false)
|
||||
expect(fileBrowserClient.isTextFile('archive.zip')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rename', () => {
|
||||
it('sends PATCH request with new destination', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 200))
|
||||
|
||||
await fileBrowserClient.rename('/photos/old.jpg', 'new.jpg')
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[mockFetch.mock.calls.length - 1]!
|
||||
expect(url).toContain('/api/resources/photos/old.jpg')
|
||||
expect(init.method).toBe('PATCH')
|
||||
expect(JSON.parse(init.body)).toEqual({ destination: '/photos/new.jpg' })
|
||||
})
|
||||
|
||||
it('throws on rename failure', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse('"token"'))
|
||||
await fileBrowserClient.login()
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(null, 409))
|
||||
|
||||
await expect(fileBrowserClient.rename('/a.txt', 'b.txt')).rejects.toThrow('Rename failed: 409')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizePath', () => {
|
||||
it('returns / for empty path', () => {
|
||||
expect(sanitizePath('')).toBe('/')
|
||||
})
|
||||
|
||||
it('preserves simple paths', () => {
|
||||
expect(sanitizePath('/photos')).toBe('/photos')
|
||||
expect(sanitizePath('/docs/readme.md')).toBe('/docs/readme.md')
|
||||
})
|
||||
|
||||
it('adds leading slash', () => {
|
||||
expect(sanitizePath('photos/image.jpg')).toBe('/photos/image.jpg')
|
||||
})
|
||||
|
||||
it('resolves . segments', () => {
|
||||
expect(sanitizePath('/photos/./image.jpg')).toBe('/photos/image.jpg')
|
||||
})
|
||||
|
||||
it('resolves .. segments', () => {
|
||||
expect(sanitizePath('/photos/../etc/passwd')).toBe('/etc/passwd')
|
||||
})
|
||||
|
||||
it('prevents traversal past root', () => {
|
||||
expect(sanitizePath('/../../../etc/passwd')).toBe('/etc/passwd')
|
||||
expect(sanitizePath('/../../..')).toBe('/')
|
||||
})
|
||||
|
||||
it('handles multiple consecutive .. at root', () => {
|
||||
expect(sanitizePath('/../../../etc/shadow')).toBe('/etc/shadow')
|
||||
})
|
||||
|
||||
it('handles mixed . and .. segments', () => {
|
||||
expect(sanitizePath('/a/./b/../c')).toBe('/a/c')
|
||||
})
|
||||
|
||||
it('removes trailing slashes in segments', () => {
|
||||
expect(sanitizePath('/photos//image.jpg')).toBe('/photos/image.jpg')
|
||||
})
|
||||
})
|
||||
211
neode-ui/src/api/__tests__/rpc-marketplace.test.ts
Normal file
211
neode-ui/src/api/__tests__/rpc-marketplace.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
|
||||
// Import after stubbing fetch
|
||||
const { rpcClient } = await import('../rpc-client')
|
||||
|
||||
function jsonResponse(body: unknown, status = 200, statusText = 'OK'): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText,
|
||||
json: () => Promise.resolve(body),
|
||||
headers: new Headers(),
|
||||
redirected: false,
|
||||
type: 'basic' as ResponseType,
|
||||
url: '',
|
||||
clone: () => jsonResponse(body, status, statusText),
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
|
||||
blob: () => Promise.resolve(new Blob()),
|
||||
formData: () => Promise.resolve(new FormData()),
|
||||
text: () => Promise.resolve(JSON.stringify(body)),
|
||||
bytes: () => Promise.resolve(new Uint8Array()),
|
||||
}
|
||||
}
|
||||
|
||||
describe('marketplaceDiscover', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('returns apps array and relay_count on success', async () => {
|
||||
const payload = {
|
||||
apps: [
|
||||
{
|
||||
manifest: {
|
||||
app_id: 'bitcoin',
|
||||
name: 'Bitcoin Core',
|
||||
version: '27.0',
|
||||
description: { short: 'Full node', long: 'Bitcoin Core full node' },
|
||||
author: { name: 'Bitcoin', did: 'did:key:z111', nostr_pubkey: 'npub1abc' },
|
||||
container: { image: 'bitcoin:27.0', ports: [{ container: 8333, host: 8333 }] },
|
||||
category: 'bitcoin',
|
||||
icon_url: '/icons/bitcoin.png',
|
||||
repo_url: 'https://github.com/bitcoin/bitcoin',
|
||||
license: 'MIT',
|
||||
},
|
||||
trust_score: 95,
|
||||
trust_tier: 'verified',
|
||||
relay_count: 8,
|
||||
first_seen: '2025-01-15T00:00:00Z',
|
||||
nostr_pubkey: 'npub1abc',
|
||||
},
|
||||
],
|
||||
relay_count: 12,
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
|
||||
|
||||
const result = await rpcClient.marketplaceDiscover()
|
||||
|
||||
expect(result.apps).toHaveLength(1)
|
||||
expect(result.apps[0]!.manifest.app_id).toBe('bitcoin')
|
||||
expect(result.apps[0]!.manifest.name).toBe('Bitcoin Core')
|
||||
expect(result.apps[0]!.trust_score).toBe(95)
|
||||
expect(result.relay_count).toBe(12)
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
|
||||
expect(body.method).toBe('marketplace.discover')
|
||||
expect(body.params).toEqual({})
|
||||
})
|
||||
|
||||
it('handles empty results', async () => {
|
||||
const payload = {
|
||||
apps: [],
|
||||
relay_count: 0,
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
|
||||
|
||||
const result = await rpcClient.marketplaceDiscover()
|
||||
|
||||
expect(result.apps).toEqual([])
|
||||
expect(result.apps).toHaveLength(0)
|
||||
expect(result.relay_count).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('diskStatus', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('returns expected fields', async () => {
|
||||
const payload = {
|
||||
used_bytes: 500_000_000_000,
|
||||
total_bytes: 1_000_000_000_000,
|
||||
free_bytes: 500_000_000_000,
|
||||
used_percent: 50,
|
||||
level: 'ok' as const,
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
|
||||
|
||||
const result = await rpcClient.diskStatus()
|
||||
|
||||
expect(result.used_bytes).toBe(500_000_000_000)
|
||||
expect(result.total_bytes).toBe(1_000_000_000_000)
|
||||
expect(result.free_bytes).toBe(500_000_000_000)
|
||||
expect(result.used_percent).toBe(50)
|
||||
expect(result.level).toBe('ok')
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
|
||||
expect(body.method).toBe('system.disk-status')
|
||||
})
|
||||
|
||||
it('level is warning when percent >= 85', async () => {
|
||||
const payload = {
|
||||
used_bytes: 850_000_000_000,
|
||||
total_bytes: 1_000_000_000_000,
|
||||
free_bytes: 150_000_000_000,
|
||||
used_percent: 85,
|
||||
level: 'warning' as const,
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
|
||||
|
||||
const result = await rpcClient.diskStatus()
|
||||
|
||||
expect(result.level).toBe('warning')
|
||||
expect(result.used_percent).toBe(85)
|
||||
})
|
||||
|
||||
it('level is critical when percent >= 90', async () => {
|
||||
const payload = {
|
||||
used_bytes: 950_000_000_000,
|
||||
total_bytes: 1_000_000_000_000,
|
||||
free_bytes: 50_000_000_000,
|
||||
used_percent: 95,
|
||||
level: 'critical' as const,
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
|
||||
|
||||
const result = await rpcClient.diskStatus()
|
||||
|
||||
expect(result.level).toBe('critical')
|
||||
expect(result.used_percent).toBe(95)
|
||||
})
|
||||
})
|
||||
|
||||
describe('diskCleanup', () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('returns freed_bytes and actions array', async () => {
|
||||
const payload = {
|
||||
freed_bytes: 2_000_000_000,
|
||||
freed_human: '2 GB',
|
||||
actions: ['Removed 5 dangling images', 'Cleared build cache'],
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({ result: payload }))
|
||||
|
||||
const result = await rpcClient.diskCleanup()
|
||||
|
||||
expect(result.freed_bytes).toBe(2_000_000_000)
|
||||
expect(result.freed_human).toBe('2 GB')
|
||||
expect(result.actions).toHaveLength(2)
|
||||
expect(result.actions[0]).toBe('Removed 5 dangling images')
|
||||
expect(result.actions[1]).toBe('Cleared build cache')
|
||||
|
||||
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
|
||||
expect(body.method).toBe('system.disk-cleanup')
|
||||
})
|
||||
|
||||
it('uses 60s timeout', async () => {
|
||||
const abortError = Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
|
||||
mockFetch.mockRejectedValue(abortError)
|
||||
|
||||
const promise = rpcClient.diskCleanup()
|
||||
|
||||
// The call should eventually reject with timeout after retries
|
||||
await expect(promise).rejects.toThrow('Request timeout')
|
||||
|
||||
// Verify all 3 attempts used the signal (timeout is set via AbortController)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3)
|
||||
for (const call of mockFetch.mock.calls) {
|
||||
expect(call[1].signal).toBeInstanceOf(AbortSignal)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,26 @@ interface FileBrowserListResponse {
|
||||
sorting: { by: string; asc: boolean }
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a path: resolve `.` and `..`, reject traversal outside root.
|
||||
* Always returns a path starting with `/` and never containing `..`.
|
||||
*/
|
||||
export function sanitizePath(path: string): string {
|
||||
const segments = path.split('/').filter(Boolean)
|
||||
const resolved: string[] = []
|
||||
|
||||
for (const seg of segments) {
|
||||
if (seg === '.') continue
|
||||
if (seg === '..') {
|
||||
resolved.pop() // go up one level, but never past root
|
||||
} else {
|
||||
resolved.push(seg)
|
||||
}
|
||||
}
|
||||
|
||||
return '/' + resolved.join('/')
|
||||
}
|
||||
|
||||
class FileBrowserClient {
|
||||
private token: string | null = null
|
||||
private baseUrl: string
|
||||
@@ -38,6 +58,8 @@ class FileBrowserClient {
|
||||
const text = await res.text()
|
||||
// FileBrowser returns the JWT as a plain string (possibly quoted)
|
||||
this.token = text.replace(/^"|"$/g, '')
|
||||
// Store token as cookie for img/video/audio src requests (avoids token in URL)
|
||||
document.cookie = `auth=${this.token}; path=/app/filebrowser; SameSite=Strict`
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
@@ -51,7 +73,7 @@ class FileBrowserClient {
|
||||
}
|
||||
|
||||
async listDirectory(path: string): Promise<FileBrowserItem[]> {
|
||||
const safePath = path.startsWith('/') ? path : `/${path}`
|
||||
const safePath = sanitizePath(path)
|
||||
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
|
||||
headers: this.headers(),
|
||||
})
|
||||
@@ -63,14 +85,47 @@ class FileBrowserClient {
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use fetchBlobUrl() instead to avoid exposing tokens in URLs.
|
||||
* Returns a plain URL (no token in query string).
|
||||
*/
|
||||
downloadUrl(path: string): string {
|
||||
const safePath = path.startsWith('/') ? path : `/${path}`
|
||||
// Token is passed as query param for direct downloads (img src, audio src, etc.)
|
||||
return `${this.baseUrl}/api/raw${safePath}?auth=${this.token}`
|
||||
const safePath = sanitizePath(path)
|
||||
return `${this.baseUrl}/api/raw${safePath}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a file as a blob URL using header-based auth (no token in URL).
|
||||
* Use this for img/video/audio src attributes and download links.
|
||||
*/
|
||||
async fetchBlobUrl(path: string): Promise<string> {
|
||||
const safePath = sanitizePath(path)
|
||||
const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, {
|
||||
headers: this.headers(),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`)
|
||||
const blob = await res.blob()
|
||||
return URL.createObjectURL(blob)
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a file download using header-based auth (no token in URL).
|
||||
*/
|
||||
async downloadFile(path: string): Promise<void> {
|
||||
const blobUrl = await this.fetchBlobUrl(path)
|
||||
const filename = path.split('/').pop() || 'download'
|
||||
const a = document.createElement('a')
|
||||
a.href = blobUrl
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
|
||||
async upload(dirPath: string, file: File): Promise<void> {
|
||||
const safePath = dirPath.endsWith('/') ? dirPath : `${dirPath}/`
|
||||
const sanitized = sanitizePath(dirPath)
|
||||
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
|
||||
const encodedName = encodeURIComponent(file.name)
|
||||
const res = await fetch(
|
||||
`${this.baseUrl}/api/resources${safePath}${encodedName}?override=true`,
|
||||
@@ -87,8 +142,10 @@ class FileBrowserClient {
|
||||
}
|
||||
|
||||
async createFolder(parentPath: string, name: string): Promise<void> {
|
||||
const safePath = parentPath.endsWith('/') ? parentPath : `${parentPath}/`
|
||||
const res = await fetch(`${this.baseUrl}/api/resources${safePath}${name}/`, {
|
||||
const sanitized = sanitizePath(parentPath)
|
||||
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
|
||||
const sanitizedName = name.replace(/\.\./g, '').replace(/\//g, '')
|
||||
const res = await fetch(`${this.baseUrl}/api/resources${safePath}${sanitizedName}/`, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
})
|
||||
@@ -96,7 +153,7 @@ class FileBrowserClient {
|
||||
}
|
||||
|
||||
async deleteItem(path: string): Promise<void> {
|
||||
const safePath = path.startsWith('/') ? path : `/${path}`
|
||||
const safePath = sanitizePath(path)
|
||||
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.headers(),
|
||||
@@ -142,7 +199,7 @@ class FileBrowserClient {
|
||||
if (!this.isTextFile(path)) {
|
||||
throw new Error(`Cannot read binary file: ${path}`)
|
||||
}
|
||||
const safePath = path.startsWith('/') ? path : `/${path}`
|
||||
const safePath = sanitizePath(path)
|
||||
const res = await fetch(`${this.baseUrl}/api/raw${safePath}`, {
|
||||
headers: this.headers(),
|
||||
})
|
||||
@@ -156,15 +213,16 @@ class FileBrowserClient {
|
||||
}
|
||||
|
||||
async rename(oldPath: string, newName: string): Promise<void> {
|
||||
const safePath = oldPath.startsWith('/') ? oldPath : `/${oldPath}`
|
||||
const safePath = sanitizePath(oldPath)
|
||||
const dir = safePath.substring(0, safePath.lastIndexOf('/') + 1)
|
||||
const sanitizedName = newName.replace(/\.\./g, '').replace(/\//g, '')
|
||||
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...this.headers(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ destination: `${dir}${newName}` }),
|
||||
body: JSON.stringify({ destination: `${dir}${sanitizedName}` }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Rename failed: ${res.status}`)
|
||||
}
|
||||
|
||||
@@ -211,6 +211,62 @@ class RPCClient {
|
||||
})
|
||||
}
|
||||
|
||||
async resolveDid(did?: string): Promise<Record<string, unknown>> {
|
||||
return this.call({
|
||||
method: 'identity.resolve-did',
|
||||
params: did ? { did } : {},
|
||||
})
|
||||
}
|
||||
|
||||
async createPresentation(params: {
|
||||
holderId: string
|
||||
credentialIds: string[]
|
||||
}): Promise<Record<string, unknown>> {
|
||||
return this.call({
|
||||
method: 'identity.create-presentation',
|
||||
params: { holder_id: params.holderId, credential_ids: params.credentialIds },
|
||||
})
|
||||
}
|
||||
|
||||
async verifyPresentation(presentation: Record<string, unknown>): Promise<{
|
||||
valid: boolean
|
||||
holder_valid: boolean
|
||||
credentials: Array<{ id: string; valid: boolean; revoked: boolean }>
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'identity.verify-presentation',
|
||||
params: { presentation },
|
||||
})
|
||||
}
|
||||
|
||||
async createPsbt(params: {
|
||||
outputs: Array<{ address: string; amount_sats: number }>
|
||||
feeRateSatPerVbyte?: number
|
||||
}): Promise<{
|
||||
psbt_base64: string
|
||||
change_output_index: number
|
||||
total_amount_sats: number
|
||||
fee_rate_sat_per_vbyte: number
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'lnd.create-psbt',
|
||||
params: {
|
||||
outputs: params.outputs,
|
||||
fee_rate_sat_per_vbyte: params.feeRateSatPerVbyte ?? 10,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async finalizePsbt(signedPsbtBase64: string): Promise<{
|
||||
raw_final_tx: string
|
||||
broadcast: boolean
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'lnd.finalize-psbt',
|
||||
params: { signed_psbt_base64: signedPsbtBase64 },
|
||||
})
|
||||
}
|
||||
|
||||
async publishNostrIdentity(): Promise<{ event_id: string; success: number; failed: number }> {
|
||||
return this.call({
|
||||
method: 'node.nostr-publish',
|
||||
@@ -325,6 +381,21 @@ class RPCClient {
|
||||
})
|
||||
}
|
||||
|
||||
async detectUsbDevices(): Promise<{
|
||||
devices: Array<{
|
||||
type: string
|
||||
vendor_id: string
|
||||
product_id: string
|
||||
manufacturer: string
|
||||
product: string
|
||||
}>
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'system.detect-usb-devices',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async restartServer(): Promise<void> {
|
||||
return this.call({
|
||||
method: 'server.restart',
|
||||
@@ -381,6 +452,226 @@ class RPCClient {
|
||||
})
|
||||
}
|
||||
|
||||
// Federation
|
||||
async federationInvite(): Promise<{ code: string; did: string; onion: string }> {
|
||||
return this.call({
|
||||
method: 'federation.invite',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async federationJoin(code: string): Promise<{
|
||||
joined: boolean
|
||||
node: { did: string; onion: string; pubkey: string; trust_level: string }
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'federation.join',
|
||||
params: { code },
|
||||
})
|
||||
}
|
||||
|
||||
async federationListNodes(): Promise<{
|
||||
nodes: Array<{
|
||||
did: string
|
||||
pubkey: string
|
||||
onion: string
|
||||
trust_level: string
|
||||
added_at: string
|
||||
name?: string
|
||||
last_seen?: string
|
||||
last_state?: {
|
||||
timestamp: string
|
||||
apps: Array<{ id: string; status: string; version?: string }>
|
||||
cpu_usage_percent?: number
|
||||
mem_used_bytes?: number
|
||||
mem_total_bytes?: number
|
||||
disk_used_bytes?: number
|
||||
disk_total_bytes?: number
|
||||
uptime_secs?: number
|
||||
tor_active?: boolean
|
||||
}
|
||||
}>
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'federation.list-nodes',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async federationRemoveNode(did: string): Promise<{ removed: boolean; nodes_remaining: number }> {
|
||||
return this.call({
|
||||
method: 'federation.remove-node',
|
||||
params: { did },
|
||||
})
|
||||
}
|
||||
|
||||
async federationSetTrust(
|
||||
did: string,
|
||||
trustLevel: 'trusted' | 'observer' | 'untrusted',
|
||||
): Promise<{ updated: boolean; did: string; trust_level: string }> {
|
||||
return this.call({
|
||||
method: 'federation.set-trust',
|
||||
params: { did, trust_level: trustLevel },
|
||||
})
|
||||
}
|
||||
|
||||
async federationSyncState(): Promise<{
|
||||
synced: number
|
||||
failed: number
|
||||
results: Array<{ did: string; status: string; apps?: number; error?: string }>
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'federation.sync-state',
|
||||
params: {},
|
||||
timeout: 120000,
|
||||
})
|
||||
}
|
||||
|
||||
async federationDeployApp(params: {
|
||||
did: string
|
||||
appId: string
|
||||
version?: string
|
||||
marketplaceUrl?: string
|
||||
}): Promise<{ deployed: boolean; app_id: string; peer_did: string; peer_onion: string }> {
|
||||
return this.call({
|
||||
method: 'federation.deploy-app',
|
||||
params: {
|
||||
did: params.did,
|
||||
app_id: params.appId,
|
||||
version: params.version ?? 'latest',
|
||||
marketplace_url: params.marketplaceUrl ?? '',
|
||||
},
|
||||
timeout: 180000,
|
||||
})
|
||||
}
|
||||
|
||||
// VPN
|
||||
async vpnStatus(): Promise<{
|
||||
connected: boolean
|
||||
provider?: string
|
||||
interface?: string
|
||||
ip_address?: string
|
||||
hostname?: string
|
||||
peers_connected: number
|
||||
bytes_in: number
|
||||
bytes_out: number
|
||||
configured: boolean
|
||||
configured_provider: string
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'vpn.status',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async vpnConfigure(params: {
|
||||
provider: 'tailscale' | 'wireguard'
|
||||
auth_key?: string
|
||||
address?: string
|
||||
dns?: string
|
||||
peer?: {
|
||||
public_key: string
|
||||
endpoint: string
|
||||
allowed_ips?: string
|
||||
persistent_keepalive?: number
|
||||
}
|
||||
}): Promise<{ configured: boolean; provider: string; public_key?: string; address?: string }> {
|
||||
return this.call({
|
||||
method: 'vpn.configure',
|
||||
params,
|
||||
timeout: 60000,
|
||||
})
|
||||
}
|
||||
|
||||
async vpnDisconnect(): Promise<{ disconnected: boolean }> {
|
||||
return this.call({
|
||||
method: 'vpn.disconnect',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
// Marketplace
|
||||
async marketplaceDiscover(): Promise<{
|
||||
apps: Array<{
|
||||
manifest: {
|
||||
app_id: string
|
||||
name: string
|
||||
version: string
|
||||
description: { short: string; long: string } | string
|
||||
author: { name: string; did: string; nostr_pubkey: string }
|
||||
container: { image: string; ports: Array<{ container: number; host: number }>; }
|
||||
category: string
|
||||
icon_url: string
|
||||
repo_url: string
|
||||
license: string
|
||||
}
|
||||
trust_score: number
|
||||
trust_tier: string
|
||||
relay_count: number
|
||||
first_seen: string
|
||||
nostr_pubkey: string
|
||||
}>
|
||||
relay_count: number
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'marketplace.discover',
|
||||
params: {},
|
||||
timeout: 30000,
|
||||
})
|
||||
}
|
||||
|
||||
// DNS
|
||||
async dnsStatus(): Promise<{
|
||||
provider: string
|
||||
servers: string[]
|
||||
doh_enabled: boolean
|
||||
doh_url: string | null
|
||||
resolv_conf_servers: string[]
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'network.dns-status',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async configureDns(params: {
|
||||
provider: 'system' | 'cloudflare' | 'google' | 'quad9' | 'mullvad' | 'custom'
|
||||
servers?: string[]
|
||||
}): Promise<{
|
||||
ok: boolean
|
||||
provider: string
|
||||
servers: string[]
|
||||
doh_enabled: boolean
|
||||
doh_url: string | null
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'network.configure-dns',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
// Disk management
|
||||
async diskStatus(): Promise<{
|
||||
used_bytes: number
|
||||
total_bytes: number
|
||||
free_bytes: number
|
||||
used_percent: number
|
||||
level: 'ok' | 'warning' | 'critical'
|
||||
}> {
|
||||
return this.call({ method: 'system.disk-status' })
|
||||
}
|
||||
|
||||
async diskCleanup(): Promise<{
|
||||
freed_bytes: number
|
||||
freed_human: string
|
||||
actions: string[]
|
||||
}> {
|
||||
return this.call({
|
||||
method: 'system.disk-cleanup',
|
||||
timeout: 60000,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const rpcClient = new RPCClient()
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
import type { Update, PatchOperation } from '../types/api'
|
||||
import { applyPatch, type Operation } from 'fast-json-patch'
|
||||
|
||||
export type ConnectionState = 'connecting' | 'connected' | 'disconnecting' | 'disconnected'
|
||||
|
||||
type WebSocketCallback = (update: Update) => void
|
||||
type ConnectionStateCallback = (connected: boolean) => void
|
||||
type ConnectionStateCallback = (state: ConnectionState) => void
|
||||
|
||||
export class WebSocketClient {
|
||||
private ws: WebSocket | null = null
|
||||
@@ -13,14 +15,18 @@ export class WebSocketClient {
|
||||
private reconnectAttempts = 0
|
||||
private maxReconnectAttempts = 10
|
||||
private reconnectDelay = 1000
|
||||
private maxReconnectDelay = 30000
|
||||
private shouldReconnect = true
|
||||
private url: string
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private visibilityChangeHandler: (() => void) | null = null
|
||||
private onlineHandler: (() => void) | null = null
|
||||
private heartbeatTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null
|
||||
private lastMessageTime: number = Date.now()
|
||||
private heartbeatInterval = 10000 // Check connection every 10 seconds
|
||||
private pingInterval = 30000 // Send ping every 30 seconds
|
||||
private _state: ConnectionState = 'disconnected'
|
||||
|
||||
constructor(url: string = '/ws/db') {
|
||||
this.url = url
|
||||
@@ -33,13 +39,13 @@ export class WebSocketClient {
|
||||
// Handle page visibility changes (tab switching, browser minimizing)
|
||||
this.visibilityChangeHandler = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
console.log('[WebSocket] Page became visible, checking connection...')
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Page became visible, checking connection...')
|
||||
// Only reconnect if we haven't been explicitly disconnected
|
||||
if (this.shouldReconnect && (!this.ws || this.ws.readyState !== WebSocket.OPEN)) {
|
||||
console.log('[WebSocket] Connection lost while hidden, reconnecting...')
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Connection lost while hidden, reconnecting...')
|
||||
this.reconnectAttempts = 0
|
||||
this.connect().catch(err => {
|
||||
console.error('[WebSocket] Failed to reconnect on visibility change:', err)
|
||||
if (import.meta.env.DEV) console.error('[WebSocket] Failed to reconnect on visibility change:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -50,11 +56,11 @@ export class WebSocketClient {
|
||||
this.onlineHandler = () => {
|
||||
// Only reconnect if we haven't been explicitly disconnected
|
||||
if (!this.shouldReconnect) return
|
||||
console.log('[WebSocket] Network came online, reconnecting...')
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Network came online, reconnecting...')
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
this.reconnectAttempts = 0
|
||||
this.connect().catch(err => {
|
||||
console.error('[WebSocket] Failed to reconnect when network came online:', err)
|
||||
if (import.meta.env.DEV) console.error('[WebSocket] Failed to reconnect when network came online:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -65,14 +71,14 @@ export class WebSocketClient {
|
||||
return new Promise((resolve, reject) => {
|
||||
// If already connected, resolve immediately
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
console.log('[WebSocket] Already connected, skipping')
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Already connected, skipping')
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
// If connecting, wait for it
|
||||
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
|
||||
console.log('[WebSocket] Already connecting, waiting...')
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Already connecting, waiting...')
|
||||
const checkInterval = setInterval(() => {
|
||||
if (this.ws) {
|
||||
if (this.ws.readyState === WebSocket.OPEN) {
|
||||
@@ -107,7 +113,7 @@ export class WebSocketClient {
|
||||
|
||||
// If we have an active WebSocket, don't create a new one
|
||||
if (this.ws) {
|
||||
console.log('[WebSocket] Connection exists, reusing it')
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Connection exists, reusing it')
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
@@ -124,14 +130,15 @@ export class WebSocketClient {
|
||||
const host = window.location.host
|
||||
const wsUrl = `${protocol}//${host}${this.url}`
|
||||
|
||||
console.log('[WebSocket] Connecting to:', wsUrl)
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Connecting to:', wsUrl)
|
||||
|
||||
this.setConnectionState('connecting')
|
||||
this.ws = new WebSocket(wsUrl)
|
||||
|
||||
// Timeout handler in case connection hangs
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.CONNECTING) {
|
||||
console.warn('WebSocket connection timeout, retrying...')
|
||||
if (import.meta.env.DEV) console.warn('WebSocket connection timeout, retrying...')
|
||||
this.ws.close()
|
||||
reject(new Error('Connection timeout'))
|
||||
}
|
||||
@@ -141,15 +148,15 @@ export class WebSocketClient {
|
||||
clearTimeout(connectionTimeout)
|
||||
this.reconnectAttempts = 0
|
||||
this.lastMessageTime = Date.now()
|
||||
console.log('[WebSocket] Connected successfully')
|
||||
this.notifyConnectionState(true)
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Connected successfully')
|
||||
this.setConnectionState('connected')
|
||||
this.startHeartbeat()
|
||||
resolve()
|
||||
}
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
clearTimeout(connectionTimeout)
|
||||
console.error('[WebSocket] Connection error:', error)
|
||||
if (import.meta.env.DEV) console.error('[WebSocket] Connection error:', error)
|
||||
// Don't reject immediately - let onclose handle reconnection
|
||||
// This prevents errors from blocking reconnection
|
||||
}
|
||||
@@ -160,24 +167,24 @@ export class WebSocketClient {
|
||||
const update: Update = JSON.parse(event.data)
|
||||
this.callbacks.forEach((callback) => callback(update))
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error)
|
||||
if (import.meta.env.DEV) console.error('Failed to parse WebSocket message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
clearTimeout(connectionTimeout)
|
||||
this.stopHeartbeat()
|
||||
console.log('[WebSocket] Closed', { code: event.code, reason: event.reason, wasClean: event.wasClean })
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Closed', { code: event.code, reason: event.reason, wasClean: event.wasClean })
|
||||
|
||||
// Notify connection state changed
|
||||
this.notifyConnectionState(false)
|
||||
this.setConnectionState('disconnected')
|
||||
|
||||
// Clear the WebSocket reference
|
||||
this.ws = null
|
||||
|
||||
// Don't reconnect if we explicitly disconnected
|
||||
if (!this.shouldReconnect) {
|
||||
console.log('[WebSocket] Reconnection disabled')
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Reconnection disabled')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -190,11 +197,11 @@ export class WebSocketClient {
|
||||
// Immediate reconnection for HMR, service restarts, and first attempt after abnormal closure
|
||||
const needsImmediateReconnect = isHMR || isServiceRestart || (event.code === 1006 && this.reconnectAttempts === 0)
|
||||
|
||||
const delay = needsImmediateReconnect ? 0 :
|
||||
(this.reconnectAttempts === 0 ? 100 :
|
||||
Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), 5000))
|
||||
const delay = needsImmediateReconnect ? 0 :
|
||||
(this.reconnectAttempts === 0 ? 100 :
|
||||
Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts), this.maxReconnectDelay))
|
||||
|
||||
console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.maxReconnectAttempts}, code: ${event.code})`)
|
||||
if (import.meta.env.DEV) console.log(`[WebSocket] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/${this.maxReconnectAttempts}, code: ${event.code})`)
|
||||
|
||||
// Clear any existing reconnect timer
|
||||
if (this.reconnectTimer) {
|
||||
@@ -213,9 +220,9 @@ export class WebSocketClient {
|
||||
this.reconnectAttempts++
|
||||
}
|
||||
|
||||
console.log('[WebSocket] Attempting reconnection...')
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Attempting reconnection...')
|
||||
this.connect().catch((err) => {
|
||||
console.error('[WebSocket] Reconnection failed:', err)
|
||||
if (import.meta.env.DEV) console.error('[WebSocket] Reconnection failed:', err)
|
||||
// onclose will be called again and will retry
|
||||
})
|
||||
}
|
||||
@@ -230,7 +237,7 @@ export class WebSocketClient {
|
||||
}, delay)
|
||||
}
|
||||
} else {
|
||||
console.warn('[WebSocket] Max reconnection attempts reached')
|
||||
if (import.meta.env.DEV) console.warn('[WebSocket] Max reconnection attempts reached')
|
||||
this.shouldReconnect = false
|
||||
}
|
||||
}
|
||||
@@ -244,6 +251,10 @@ export class WebSocketClient {
|
||||
}
|
||||
}
|
||||
|
||||
get state(): ConnectionState {
|
||||
return this._state
|
||||
}
|
||||
|
||||
onConnectionStateChange(callback: ConnectionStateCallback): () => void {
|
||||
this.connectionStateCallbacks.add(callback)
|
||||
return () => {
|
||||
@@ -251,26 +262,38 @@ export class WebSocketClient {
|
||||
}
|
||||
}
|
||||
|
||||
private notifyConnectionState(connected: boolean): void {
|
||||
this.connectionStateCallbacks.forEach((callback) => callback(connected))
|
||||
private setConnectionState(state: ConnectionState): void {
|
||||
this._state = state
|
||||
this.connectionStateCallbacks.forEach((callback) => callback(state))
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat()
|
||||
|
||||
|
||||
// Send ping messages every 30s
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this.ws.send(JSON.stringify({ type: 'ping' }))
|
||||
} catch {
|
||||
// Send failed, connection likely broken
|
||||
}
|
||||
}
|
||||
}, this.pingInterval)
|
||||
|
||||
// Check connection health every 10s
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn('[WebSocket] Heartbeat detected closed connection')
|
||||
if (import.meta.env.DEV) console.warn('[WebSocket] Heartbeat detected closed connection')
|
||||
this.stopHeartbeat()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we've received a message recently
|
||||
|
||||
const timeSinceLastMessage = Date.now() - this.lastMessageTime
|
||||
|
||||
// If no message for more than 5 minutes, assume connection is stale
|
||||
if (timeSinceLastMessage > 300000) {
|
||||
console.warn('[WebSocket] No messages for 5m, reconnecting...')
|
||||
if (import.meta.env.DEV) console.warn('[WebSocket] No messages for 5m, reconnecting...')
|
||||
this.ws.close()
|
||||
return
|
||||
}
|
||||
@@ -282,11 +305,16 @@ export class WebSocketClient {
|
||||
clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
if (this.pingTimer) {
|
||||
clearInterval(this.pingTimer)
|
||||
this.pingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.shouldReconnect = false
|
||||
this.reconnectAttempts = 0
|
||||
this.setConnectionState('disconnecting')
|
||||
this.stopHeartbeat()
|
||||
|
||||
// Clear reconnect timer
|
||||
@@ -345,7 +373,7 @@ function getWebSocketClient(): WebSocketClient {
|
||||
if (existing && existing instanceof WebSocketClient) {
|
||||
// Check if the WebSocket is still valid
|
||||
if (existing.isConnected()) {
|
||||
console.log('[WebSocket] Using existing connected client from HMR')
|
||||
if (import.meta.env.DEV) console.log('[WebSocket] Using existing connected client from HMR')
|
||||
wsClientInstance = existing
|
||||
return existing
|
||||
}
|
||||
@@ -374,7 +402,7 @@ export const wsClient: WebSocketClient = (() => {
|
||||
_wsClient = getWebSocketClient()
|
||||
return _wsClient
|
||||
} catch (error) {
|
||||
console.error('[WebSocket] Error initializing client:', error)
|
||||
if (import.meta.env.DEV) console.error('[WebSocket] Error initializing client:', error)
|
||||
// Fallback to new instance
|
||||
_wsClient = new WebSocketClient()
|
||||
return _wsClient
|
||||
@@ -385,7 +413,7 @@ export const wsClient: WebSocketClient = (() => {
|
||||
export function applyDataPatch<T>(data: T, patch: PatchOperation[]): T {
|
||||
// Validate patch is an array before applying
|
||||
if (!Array.isArray(patch) || patch.length === 0) {
|
||||
console.warn('Invalid or empty patch received, returning original data')
|
||||
if (import.meta.env.DEV) console.warn('Invalid or empty patch received, returning original data')
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -393,7 +421,7 @@ export function applyDataPatch<T>(data: T, patch: PatchOperation[]): T {
|
||||
const result = applyPatch(data, patch as Operation[], false, false)
|
||||
return result.newDocument as T
|
||||
} catch (error) {
|
||||
console.error('Failed to apply patch:', error, 'Patch:', patch)
|
||||
if (import.meta.env.DEV) console.error('Failed to apply patch:', error, 'Patch:', patch)
|
||||
return data // Return original data on error
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user