SERVE command

Signed-off-by: FTCHD <144691102+FTCHD@users.noreply.github.com>
This commit is contained in:
FTCHD
2025-09-20 00:16:07 +02:00
parent 40f2d89e15
commit 78d2675905
8 changed files with 605 additions and 13 deletions
+53 -1
View File
@@ -8,7 +8,15 @@ import { openPath } from '@tauri-apps/plugin-opener'
import { platform } from '@tauri-apps/plugin-os'
import { isDirectoryEmpty } from './fs'
import notify from './notify'
import { cleanupRemote, deleteRemote, listMounts, mountRemote, unmountRemote } from './rclone/api'
import {
cleanupRemote,
deleteRemote,
listMounts,
listServes,
mountRemote,
stopServe,
unmountRemote,
} from './rclone/api'
import { dialogGetMountPlugin, needsMountPlugin } from './rclone/mount'
import { usePersistedStore, useStore } from './store'
import { showDefaultTray, showLoadingTray } from './tray'
@@ -22,6 +30,8 @@ async function parseRemotes(remotes: string[]) {
console.log('[parseRemotes] listing mounts')
const currentMounts = await listMounts()
console.log('[parseRemotes] currentMounts', currentMounts)
const currentServes = await listServes()
console.log('[parseRemotes] currentServes', currentServes)
const parsedRemotes: Record<string, (MenuItem | Submenu | PredefinedMenuItem)[]> = {}
@@ -320,6 +330,34 @@ async function parseRemotes(remotes: string[]) {
submenuItems.push(showLocationItem)
}
for (const currentServe of currentServes) {
// console.log('[parseRemotes] Adding Stop Serve (' + currentServe.Addr.split(':')[0] + ') for ', remote)
const stopServeMenuItem = await MenuItem.new({
id: `stop-serve-${currentServe.id}`,
text: 'Stop Serve',
action: async () => {
try {
await showLoadingTray()
await stopServe(currentServe.id)
await message(`Successfully stopped serve ${currentServe.id}`, {
title: 'Success',
})
} catch (error) {
Sentry.captureException(error)
console.error('Stop serve operation failed:', error)
await message(`Failed to stop serve ${currentServe.id}: ${error}`, {
kind: 'error',
title: 'Stop Serve Error',
})
} finally {
await showDefaultTray()
}
},
})
submenuItems.push(stopServeMenuItem)
}
parsedRemotes[remote] = submenuItems
}
@@ -436,6 +474,20 @@ export async function buildMenu() {
menuItems.push(syncMenuItem)
}
if (!persistedStoreState.disabledActions?.includes('tray-serve')) {
const serveToMenuItem = await MenuItem.new({
id: 'serve',
text: 'Serve',
action: async () => {
await openWindow({
name: 'Serve',
url: '/serve',
})
},
})
menuItems.push(serveToMenuItem)
}
if (!persistedStoreState.disabledActions?.includes('tray-purge')) {
const purgeMenuItem = await MenuItem.new({
id: 'purge',
+172 -2
View File
@@ -1,6 +1,7 @@
import * as Sentry from '@sentry/browser'
import { fetch } from '@tauri-apps/plugin-http'
import { platform } from '@tauri-apps/plugin-os'
import type { FlagValue } from '../../types/rclone'
import { useStore } from '../store'
import { parseRcloneOptions } from './common'
@@ -859,7 +860,10 @@ export async function getSftpFlags() {
headers: getAuthHeader(),
}).then((res) => res.json() as Promise<any>)
const sftpFlags = r.sftp
const sftpFlags = r.sftp.map((flag: any) => ({
...flag,
FieldName: flag.Name,
}))
return sftpFlags.sort((a: any, b: any) => a.Name.localeCompare(b.Name))
}
@@ -872,7 +876,173 @@ export async function getFtpFlags() {
headers: getAuthHeader(),
}).then((res) => res.json() as Promise<any>)
const ftpFlags = r.ftp
const ftpFlags = r.ftp.map((flag: any) => ({
...flag,
FieldName: flag.Name,
}))
return ftpFlags.sort((a: any, b: any) => a.Name.localeCompare(b.Name))
}
export async function getDlnaFlags() {
console.log('[getDlnaFlags]')
const r = await fetch('http://localhost:5572/options/info', {
method: 'POST',
headers: getAuthHeader(),
}).then((res) => res.json() as Promise<any>)
const dlnaFlags = r.dlna.map((flag: any) => ({
...flag,
FieldName: flag.Name,
}))
return dlnaFlags.sort((a: any, b: any) => a.Name.localeCompare(b.Name))
}
/* SERVE */
export interface ServeListItem {
id: string
addr: string
params: {
fs: string
type: string
opt?: Record<string, unknown>
vfsOpt?: Record<string, unknown>
}
}
export async function listServes() {
console.log('[listServes]')
const r = await fetch('http://localhost:5572/serve/list', {
method: 'POST',
headers: getAuthHeader(),
})
.then(
(res) =>
res.json() as Promise<{
list: ServeListItem[]
}>
)
.catch((e) => {
Sentry.captureException(e)
console.log('error', e)
throw e
})
if (!Array.isArray(r?.list)) {
throw new Error('[listServes] Failed to get running serves')
}
return r.list
}
export async function startServe({
// type,
// fs,
// addr,
_filter,
_config,
...props
}: {
// type: string
// fs: string
// addr: string
// options?: Record<string, string | number | boolean | string[]>
_filter?: Record<string, FlagValue>
_config?: Record<string, FlagValue>
} & Record<string, FlagValue>) {
console.log('[startServe] ', props)
const params = new URLSearchParams()
// params.set('type', type)
// params.set('fs', fs)
// params.set('user', 'usr')
// params.set('pass', 'pas')
for (const [key, value] of Object.entries(props)) {
if (Array.isArray(value)) {
for (const v of value) params.append(key, String(v))
} else if (value !== undefined) {
params.set(key, String(value))
}
}
// if (options && Object.keys(options).length > 0) {
// const parsed = parseRcloneOptions(options)
// for (const [key, value] of Object.entries(parsed)) {
// if (Array.isArray(value)) {
// for (const v of value) params.append(key, String(v))
// } else if (value !== undefined) {
// params.set(key, String(value))
// }
// }
// }
if (_filter && Object.keys(_filter).length > 0) {
params.set('_filter', JSON.stringify(parseRcloneOptions(_filter)))
}
if (_config && Object.keys(_config).length > 0) {
params.set('_config', JSON.stringify(parseRcloneOptions(_config)))
}
const r = await fetch(`http://localhost:5572/serve/start?${params.toString()}`, {
method: 'POST',
headers: getAuthHeader(),
})
.then((res) => res.json() as Promise<{ addr: string; id: string } | { error: string }>)
.catch((e) => {
Sentry.captureException(e)
console.log('error', e)
throw e
})
if ('error' in r) {
throw new Error(r.error)
}
return r
}
export async function stopServe(id: string) {
console.log('[stopServe] ', id)
const params = new URLSearchParams()
params.set('id', id)
const r = await fetch(`http://localhost:5572/serve/stop?${params.toString()}`, {
method: 'POST',
headers: getAuthHeader(),
})
.then((res) => res.json())
.catch((e) => {
Sentry.captureException(e)
console.log('error', e)
throw e
})
if ('error' in r) {
throw new Error(r.error)
}
}
export async function stopAllServes() {
console.log('[stopAllServes]')
const r = await fetch('http://localhost:5572/serve/stopall', {
method: 'POST',
headers: getAuthHeader(),
})
.then((res) => res.json())
.catch((e) => {
Sentry.captureException(e)
console.log('error', e)
throw e
})
if ('error' in r) {
throw new Error(r.error)
}
}
-10
View File
@@ -33,10 +33,6 @@ interface State {
rcloneAuth: string
rcloneAuthHeader: string
serveList: { pid: number; protocol: string; remote: string }[]
setServeList: (serve: { pid: number; protocol: string; remote: string }) => void
removeServeList: (pid: number) => void
remotes: string[]
setRemotes: (remotes: string[]) => void
addRemote: (remote: string) => void
@@ -147,12 +143,6 @@ export const useStore = create<State>()(
rcloneAuth: '',
rcloneAuthHeader: '',
serveList: [],
setServeList: (serve: { pid: number; protocol: string; remote: string }) =>
set((state) => ({ serveList: [...state.serveList, serve] })),
removeServeList: (pid: number) =>
set((state) => ({ serveList: state.serveList.filter((s) => s.pid !== pid) })),
remotes: [],
setRemotes: (remotes: string[]) => set((_) => ({ remotes })),
addRemote: (remote: string) =>
+1
View File
@@ -13,6 +13,7 @@
"Settings",
"Jobs",
"Cron",
"Serve",
"Browse",
"Startup",
"Test"
+5
View File
@@ -12,6 +12,7 @@ import Jobs from './pages/Jobs'
import Mount from './pages/Mount'
import Move from './pages/Move'
import Purge from './pages/Purge'
import Serve from './pages/Serve'
import Settings from './pages/Settings'
import Startup from './pages/Startup'
import Sync from './pages/Sync'
@@ -69,6 +70,10 @@ const router = createBrowserRouter([
path: '/purge',
element: <Purge />,
},
{
path: '/serve',
element: <Serve />,
},
{
path: '/mount',
element: <Mount />,
+357
View File
@@ -0,0 +1,357 @@
import { Accordion, AccordionItem, Avatar, Button, Select, SelectItem } from '@heroui/react'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { message } from '@tauri-apps/plugin-dialog'
import {
AlertOctagonIcon,
FilterIcon,
FoldersIcon,
PlayIcon,
ServerCrashIcon,
WavesLadderIcon,
XIcon,
} from 'lucide-react'
import { useEffect, useState } from 'react'
import {
getCurrentGlobalFlags,
getDlnaFlags,
getFilterFlags,
getFtpFlags,
getSftpFlags,
getVfsFlags,
startServe,
} from '../../lib/rclone/api'
import { RCLONE_VFS_DEFAULTS } from '../../lib/rclone/constants'
import { usePersistedStore, useStore } from '../../lib/store'
import { triggerTrayRebuild } from '../../lib/tray'
import type { FlagValue } from '../../types/rclone'
import OptionsSection from '../components/OptionsSection'
const SERVE_TYPES = ['dlna', 'ftp', 'sftp'] as const
export default function Serve() {
const remotes = useStore((state) => state.remotes)
const [remote, setRemote] = useState<string | undefined>(undefined)
const [type, setType] = useState<(typeof SERVE_TYPES)[number] | undefined>(undefined)
const [isServing, setIsServing] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [jsonError, setJsonError] = useState<'mount' | 'vfs' | 'filter' | 'config' | null>(null)
const [serveOptionsLocked, setServeOptionsLocked] = useState(false)
const [serveOptions, setServeOptions] = useState<Record<string, FlagValue>>({})
const [serveOptionsJson, setServeOptionsJson] = useState<string>('{}')
const [vfsOptionsLocked, setVfsOptionsLocked] = useState(false)
const [_, setVfsOptions] = useState<Record<string, FlagValue>>({})
const [vfsOptionsJson, setVfsOptionsJson] = useState<string>('{}')
const [filterOptionsLocked, setFilterOptionsLocked] = useState(false)
const [filterOptions, setFilterOptions] = useState<Record<string, FlagValue>>({})
const [filterOptionsJson, setFilterOptionsJson] = useState<string>('{}')
// const [configOptionsLocked, setConfigOptionsLocked] = useState(false)
// const [configOptions, setConfigOptions] = useState<Record<string, string>>({})
// const [configOptionsJson, setConfigOptionsJson] = useState<string>('{}')
const [currentGlobalOptions, setCurrentGlobalOptions] = useState<any[]>([])
const getAvailableOptions =
type === 'dlna'
? getDlnaFlags
: type === 'ftp'
? getFtpFlags
: type === 'sftp'
? getSftpFlags
: () => Promise.resolve([])
// biome-ignore lint/correctness/useExhaustiveDependencies: when unlocking, we don't want to re-run the effect
useEffect(() => {
const storeData = usePersistedStore.getState()
if (!remote) return
const remoteConfig = storeData.remoteConfigList?.[remote]
if (!vfsOptionsLocked) {
if (remoteConfig?.vfsDefaults && Object.keys(remoteConfig.vfsDefaults).length > 0) {
setVfsOptionsJson(JSON.stringify(remoteConfig.vfsDefaults, null, 2))
} else {
setVfsOptionsJson(JSON.stringify(RCLONE_VFS_DEFAULTS, null, 2))
}
}
if (
remoteConfig?.filterDefaults &&
Object.keys(remoteConfig.filterDefaults).length > 0 &&
!filterOptionsLocked
) {
setFilterOptionsJson(JSON.stringify(remoteConfig.filterDefaults, null, 2))
}
// if (!configOptionsLocked) {
// if (
// storeData.remoteConfigList?.[remote]?.configDefaults &&
// Object.keys(storeData.remoteConfigList?.[remote]?.configDefaults).length > 0
// ) {
// setConfigOptionsJson(
// JSON.stringify(storeData.remoteConfigList[remote].configDefaults, null, 2)
// )
// } else {
// setConfigOptionsJson(JSON.stringify(RCLONE_CONFIG_DEFAULTS, null, 2))
// }
// }
}, [remote])
useEffect(() => {
getCurrentGlobalFlags().then((flags) => setCurrentGlobalOptions(flags))
}, [])
useEffect(() => {
let step: 'mount' | 'vfs' | 'filter' | 'config' = 'mount'
try {
setServeOptions(JSON.parse(serveOptionsJson))
step = 'vfs'
setVfsOptions(JSON.parse(vfsOptionsJson))
step = 'filter'
setFilterOptions(JSON.parse(filterOptionsJson))
// step = 'config'
// setConfigOptions(JSON.parse(configOptionsJson))
setJsonError(null)
} catch (error) {
setJsonError(step)
console.error(`[Mount] Error parsing ${step} options:`, error)
}
}, [serveOptionsJson, vfsOptionsJson, filterOptionsJson])
async function handleStartServe() {
if (!remote || !type) return
setIsLoading(true)
try {
await startServe({
type,
fs: `${remote}:/`,
_filter: filterOptions as any,
...serveOptions,
})
setIsServing(true)
await triggerTrayRebuild()
setIsLoading(false)
} catch (err) {
console.error('[Mount] Failed to start mount:', err)
const errorMessage =
err instanceof Error ? err.message : 'Failed to start mount operation'
await message(errorMessage, {
title: 'Error',
kind: 'error',
})
setIsLoading(false)
}
}
const buttonText = (() => {
if (isLoading) return 'STARTING...'
if (isServing) return 'STARTED'
if (!remote) return 'Please select a remote'
if (!type) return 'Please select a serve type'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
if (!('addr' in serveOptions)) return 'Specify a listen address in the Serve options'
return 'START SERVE'
})()
console.log('serveOptions', serveOptions)
const buttonIcon = (() => {
if (isLoading || isServing) return
if (!remote || !type) return <FoldersIcon className="w-5 h-5" />
if (jsonError) return <AlertOctagonIcon className="w-4 h-4 mt-0.5" />
if (!('addr' in serveOptions)) return <AlertOctagonIcon className="w-4 h-4 mt-0.5" />
return <PlayIcon className="w-4 h-4 fill-current" />
})()
console.log('type', type)
return (
<div className="flex flex-col h-screen gap-10 pt-10">
<div className="flex flex-col flex-1 w-full max-w-3xl gap-6 mx-auto">
<Select
selectedKeys={remote ? [remote] : []}
onSelectionChange={(keys) => {
setRemote(keys.currentKey as string)
}}
size="lg"
placeholder="Select a remote"
label="Remote"
labelPlacement="outside"
classNames={{}}
>
{remotes.map((remote) => (
<SelectItem key={remote} textValue={remote}>
{remote}
</SelectItem>
))}
</Select>
<Select
selectedKeys={type ? [type] : []}
onSelectionChange={(keys) => {
setType(keys.currentKey as (typeof SERVE_TYPES)[number])
}}
size="lg"
placeholder="Select a serve type"
label="Serve Type"
labelPlacement="outside"
>
{SERVE_TYPES.map((type) => (
<SelectItem key={type} textValue={type.toUpperCase()}>
{type.toUpperCase()}
</SelectItem>
))}
</Select>
<Accordion>
{type ? (
<AccordionItem
key="serve"
startContent={
<Avatar
color="success"
radius="lg"
fallback={<ServerCrashIcon />}
/>
}
indicator={<ServerCrashIcon />}
subtitle="Tap to see Serve options for the current operation"
title="Serve"
>
<OptionsSection
optionsJson={serveOptionsJson}
setOptionsJson={setServeOptionsJson}
globalOptions={
currentGlobalOptions[type as keyof typeof currentGlobalOptions]
}
getAvailableOptions={getAvailableOptions}
isLocked={serveOptionsLocked}
setIsLocked={setServeOptionsLocked}
/>
</AccordionItem>
) : null}
<AccordionItem
key="vfs"
startContent={
<Avatar color="warning" radius="lg" fallback={<WavesLadderIcon />} />
}
indicator={<WavesLadderIcon />}
subtitle="Tap to see VFS options for the current operation"
title="VFS"
>
<OptionsSection
optionsJson={vfsOptionsJson}
setOptionsJson={setVfsOptionsJson}
globalOptions={
currentGlobalOptions['vfs' as keyof typeof currentGlobalOptions]
}
getAvailableOptions={getVfsFlags}
isLocked={vfsOptionsLocked}
setIsLocked={setVfsOptionsLocked}
/>
</AccordionItem>
<AccordionItem
key="filters"
startContent={
<Avatar color="danger" radius="lg" fallback={<FilterIcon />} />
}
indicator={<FilterIcon />}
subtitle="Tap to toggle filtering options for this operation"
title="Filters"
>
<OptionsSection
globalOptions={
currentGlobalOptions['filter' as keyof typeof currentGlobalOptions]
}
optionsJson={filterOptionsJson}
setOptionsJson={setFilterOptionsJson}
getAvailableOptions={getFilterFlags}
isLocked={filterOptionsLocked}
setIsLocked={setFilterOptionsLocked}
/>
</AccordionItem>
{/* <AccordionItem
key="config"
startContent={
<Avatar color="default" radius="lg" fallback={<WrenchIcon />} />
}
indicator={<WrenchIcon />}
subtitle="Tap to toggle config options for this operation"
title="Config"
>
<OptionsSection
globalOptions={
currentGlobalOptions['main' as keyof typeof currentGlobalOptions]
}
optionsJson={configOptionsJson}
setOptionsJson={setConfigOptionsJson}
getAvailableOptions={getConfigFlags}
rows={10}
isLocked={configOptionsLocked}
setIsLocked={setConfigOptionsLocked}
/>
</AccordionItem> */}
</Accordion>
</div>
<div className="sticky bottom-0 z-50 flex items-center justify-center flex-none gap-5 p-4 border-t border-neutral-500/20 bg-neutral-900/50 backdrop-blur-lg">
{isServing ? (
<>
<Button
fullWidth={true}
size="lg"
color="primary"
onPress={() => {
setRemote(undefined)
setType(undefined)
setIsServing(false)
}}
data-focus-visible="false"
>
RESET
</Button>
<Button
size="lg"
isIconOnly={true}
onPress={async () => {
await getCurrentWindow().hide()
await getCurrentWindow().destroy()
}}
data-focus-visible="false"
>
<XIcon />
</Button>
</>
) : (
<Button
onPress={handleStartServe}
size="lg"
fullWidth={true}
color="primary"
isDisabled={isLoading || !!jsonError || !remote || !type || isServing}
isLoading={isLoading}
endContent={buttonIcon}
className="max-w-2xl gap-2"
data-focus-visible="false"
>
{buttonText}
</Button>
)}
</div>
</div>
)
}
+15
View File
@@ -555,6 +555,21 @@ function GeneralSection() {
>
Show <span className="font-mono text-blue-300">Move</span> option
</Checkbox>
<Checkbox
isSelected={!disabledActions?.includes('tray-serve')}
onValueChange={(value) => {
if (value) {
setDisabledActions(
disabledActions?.filter((action) => action !== 'tray-serve') ||
[]
)
} else {
setDisabledActions([...(disabledActions || []), 'tray-serve'])
}
}}
>
Show <span className="font-mono text-blue-300">Serve</span> option
</Checkbox>
<Checkbox
isSelected={!disabledActions?.includes('tray-purge')}
onValueChange={(value) => {
+2
View File
@@ -24,3 +24,5 @@ export interface Backend {
Options: BackendOption[]
Prefix: string
}
export type FlagValue = string | number | boolean | string[]