Cron (view, pause, remove, resume tasks)
This commit is contained in:
@@ -3,6 +3,7 @@ import { shared } from 'use-broadcast-ts'
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { type StateStorage, createJSONStorage, persist } from 'zustand/middleware'
|
import { type StateStorage, createJSONStorage, persist } from 'zustand/middleware'
|
||||||
import type { ConfigFile } from '../types/config'
|
import type { ConfigFile } from '../types/config'
|
||||||
|
import type { ScheduledTask } from '../types/task'
|
||||||
|
|
||||||
// const { LazyStore } = window.__TAURI__.store
|
// const { LazyStore } = window.__TAURI__.store
|
||||||
const store = new LazyStore('store.json')
|
const store = new LazyStore('store.json')
|
||||||
@@ -61,6 +62,16 @@ interface PersistedState {
|
|||||||
isFirstOpen: boolean
|
isFirstOpen: boolean
|
||||||
setIsFirstOpen: (isFirstOpen: boolean) => void
|
setIsFirstOpen: (isFirstOpen: boolean) => void
|
||||||
|
|
||||||
|
scheduledTasks: ScheduledTask[]
|
||||||
|
addScheduledTask: (
|
||||||
|
task: Omit<
|
||||||
|
ScheduledTask,
|
||||||
|
'id' | 'isRunning' | 'currentRunId' | 'lastRun' | 'configId' | 'isEnabled'
|
||||||
|
>
|
||||||
|
) => void
|
||||||
|
removeScheduledTask: (id: string) => void
|
||||||
|
updateScheduledTask: (id: string, task: Partial<ScheduledTask>) => void
|
||||||
|
|
||||||
configFiles: ConfigFile[]
|
configFiles: ConfigFile[]
|
||||||
addConfigFile: (configFile: ConfigFile) => void
|
addConfigFile: (configFile: ConfigFile) => void
|
||||||
removeConfigFile: (id: string) => void
|
removeConfigFile: (id: string) => void
|
||||||
@@ -146,6 +157,44 @@ export const usePersistedStore = create<PersistedState>()(
|
|||||||
isFirstOpen: true,
|
isFirstOpen: true,
|
||||||
setIsFirstOpen: (isFirstOpen: boolean) => set((_) => ({ isFirstOpen })),
|
setIsFirstOpen: (isFirstOpen: boolean) => set((_) => ({ isFirstOpen })),
|
||||||
|
|
||||||
|
scheduledTasks: [],
|
||||||
|
addScheduledTask: (
|
||||||
|
task: Omit<
|
||||||
|
ScheduledTask,
|
||||||
|
'id' | 'isRunning' | 'currentRunId' | 'lastRun' | 'configId' | 'isEnabled'
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
const state = usePersistedStore.getState()
|
||||||
|
const configId = state.activeConfigFile?.id
|
||||||
|
|
||||||
|
if (!configId) {
|
||||||
|
throw new Error('No active config file')
|
||||||
|
}
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
scheduledTasks: [
|
||||||
|
...state.scheduledTasks,
|
||||||
|
{
|
||||||
|
...task,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
isRunning: false,
|
||||||
|
isEnabled: true,
|
||||||
|
configId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
removeScheduledTask: (id: string) =>
|
||||||
|
set((state) => ({
|
||||||
|
scheduledTasks: state.scheduledTasks.filter((t) => t.id !== id),
|
||||||
|
})),
|
||||||
|
updateScheduledTask: (id: string, task: Partial<ScheduledTask>) =>
|
||||||
|
set((state) => ({
|
||||||
|
scheduledTasks: state.scheduledTasks.map((t) =>
|
||||||
|
t.id === id ? { ...t, ...task } : t
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
|
||||||
configFiles: [],
|
configFiles: [],
|
||||||
addConfigFile: (configFile: ConfigFile) =>
|
addConfigFile: (configFile: ConfigFile) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { ask, message } from '@tauri-apps/plugin-dialog'
|
|||||||
import { debug, error, info, trace, warn } from '@tauri-apps/plugin-log'
|
import { debug, error, info, trace, warn } from '@tauri-apps/plugin-log'
|
||||||
import { platform } from '@tauri-apps/plugin-os'
|
import { platform } from '@tauri-apps/plugin-os'
|
||||||
import { exit } from '@tauri-apps/plugin-process'
|
import { exit } from '@tauri-apps/plugin-process'
|
||||||
import type { Command } from '@tauri-apps/plugin-shell'
|
import { CronExpressionParser } from 'cron-parser'
|
||||||
import { validateLicense } from './lib/license'
|
import { validateLicense } from './lib/license'
|
||||||
import { listRemotes, mountRemote, unmountAllRemotes } from './lib/rclone/api'
|
import { listRemotes, mountRemote, startCopy, startSync, unmountAllRemotes } from './lib/rclone/api'
|
||||||
import { initRclone } from './lib/rclone/init'
|
import { initRclone } from './lib/rclone/init'
|
||||||
import { usePersistedStore, useStore } from './lib/store'
|
import { usePersistedStore, useStore } from './lib/store'
|
||||||
import { initLoadingTray, initTray, rebuildTrayMenu } from './lib/tray'
|
import { initLoadingTray, initTray, rebuildTrayMenu } from './lib/tray'
|
||||||
|
import type { ScheduledTask } from './types/task'
|
||||||
|
|
||||||
// forward console logs in webviews to the tauri logger, so they show up in terminal
|
// forward console logs in webviews to the tauri logger, so they show up in terminal
|
||||||
function forwardConsole(
|
function forwardConsole(
|
||||||
@@ -226,6 +227,121 @@ async function onboardUser() {
|
|||||||
usePersistedStore.setState({ isFirstOpen: false })
|
usePersistedStore.setState({ isFirstOpen: false })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_INT_MS = 2_147_483_647
|
||||||
|
let hasScheduledTasks = false
|
||||||
|
async function resumeTasks() {
|
||||||
|
console.log('resuming tasks')
|
||||||
|
|
||||||
|
if (hasScheduledTasks) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduledTasks = usePersistedStore.getState().scheduledTasks
|
||||||
|
const activeConfigId = usePersistedStore.getState().activeConfigFile?.id
|
||||||
|
|
||||||
|
if (!activeConfigId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const task of scheduledTasks) {
|
||||||
|
if (task.isRunning) {
|
||||||
|
usePersistedStore.getState().updateScheduledTask(task.id, {
|
||||||
|
isRunning: false,
|
||||||
|
currentRunId: undefined,
|
||||||
|
error: 'Task closed prematurely',
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (task.configId !== activeConfigId) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cronInterval = CronExpressionParser.parse(task.cron)
|
||||||
|
const nextRun = cronInterval.next().toDate()
|
||||||
|
const difference = nextRun.getTime() - Date.now()
|
||||||
|
|
||||||
|
if (difference <= MAX_INT_MS && difference > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('running task', task)
|
||||||
|
handleTask(task)
|
||||||
|
}, difference)
|
||||||
|
console.log('scheduled task', task.type, task.id, nextRun)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error scheduling task:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasScheduledTasks = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTask(task: ScheduledTask) {
|
||||||
|
const currentTask = usePersistedStore.getState().scheduledTasks.find((t) => t.id === task.id)
|
||||||
|
|
||||||
|
if (!currentTask) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentTask.isRunning) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const freshRunId = crypto.randomUUID()
|
||||||
|
|
||||||
|
usePersistedStore.getState().updateScheduledTask(task.id, {
|
||||||
|
isRunning: true,
|
||||||
|
currentRunId: freshRunId,
|
||||||
|
lastRun: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('running task', task.type, task.id)
|
||||||
|
|
||||||
|
const currentRunId = usePersistedStore
|
||||||
|
.getState()
|
||||||
|
.scheduledTasks.find((t) => t.id === task.id)?.currentRunId
|
||||||
|
|
||||||
|
if (currentRunId !== freshRunId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { srcFs, dstFs, _config, _filter } = task.args
|
||||||
|
|
||||||
|
switch (task.type) {
|
||||||
|
case 'delete':
|
||||||
|
break
|
||||||
|
case 'copy':
|
||||||
|
await startCopy({
|
||||||
|
srcFs,
|
||||||
|
dstFs,
|
||||||
|
_config,
|
||||||
|
_filter,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case 'sync':
|
||||||
|
await startSync({
|
||||||
|
srcFs,
|
||||||
|
dstFs,
|
||||||
|
_config,
|
||||||
|
_filter,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to start task:', err)
|
||||||
|
usePersistedStore.getState().updateScheduledTask(task.id, {
|
||||||
|
isRunning: false,
|
||||||
|
currentRunId: undefined,
|
||||||
|
error: err instanceof Error ? err.message : 'Unknown error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getCurrentWindow().listen('tauri://close-requested', async (e) => {
|
getCurrentWindow().listen('tauri://close-requested', async (e) => {
|
||||||
console.log('(main) window close requested')
|
console.log('(main) window close requested')
|
||||||
})
|
})
|
||||||
@@ -253,5 +369,6 @@ initLoadingTray()
|
|||||||
.then(() => startRclone())
|
.then(() => startRclone())
|
||||||
.then(() => onboardUser())
|
.then(() => onboardUser())
|
||||||
.then(() => startupMounts())
|
.then(() => startupMounts())
|
||||||
|
.then(() => resumeTasks())
|
||||||
.then(() => initTray())
|
.then(() => initTray())
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"Mount",
|
"Mount",
|
||||||
"Settings",
|
"Settings",
|
||||||
"Jobs",
|
"Jobs",
|
||||||
|
"Cron",
|
||||||
"Browse",
|
"Browse",
|
||||||
"Test",
|
"Test",
|
||||||
"Test2",
|
"Test2",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import './global.css'
|
|||||||
import { HeroUIProvider } from '@heroui/react'
|
import { HeroUIProvider } from '@heroui/react'
|
||||||
import { debug, error, info, trace, warn } from '@tauri-apps/plugin-log'
|
import { debug, error, info, trace, warn } from '@tauri-apps/plugin-log'
|
||||||
import Copy from './pages/Copy'
|
import Copy from './pages/Copy'
|
||||||
|
import Cron from './pages/Cron'
|
||||||
import Jobs from './pages/Jobs'
|
import Jobs from './pages/Jobs'
|
||||||
import Mount from './pages/Mount'
|
import Mount from './pages/Mount'
|
||||||
import Settings from './pages/Settings'
|
import Settings from './pages/Settings'
|
||||||
@@ -56,6 +57,10 @@ const router = createBrowserRouter([
|
|||||||
path: '/jobs',
|
path: '/jobs',
|
||||||
element: <Jobs />,
|
element: <Jobs />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/cron',
|
||||||
|
element: <Cron />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/test',
|
path: '/test',
|
||||||
element: <Test />,
|
element: <Test />,
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { Card, CardBody, CardHeader, Tooltip } from '@heroui/react'
|
||||||
|
import { Button, Chip } from '@heroui/react'
|
||||||
|
import { ask } from '@tauri-apps/plugin-dialog'
|
||||||
|
import CronExpressionParser from 'cron-parser'
|
||||||
|
import cronstrue from 'cronstrue'
|
||||||
|
import { formatDistance } from 'date-fns'
|
||||||
|
import { AlertCircleIcon, Clock7Icon, PauseIcon, PlayIcon, Trash2Icon } from 'lucide-react'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { buildReadablePath } from '../../lib/format'
|
||||||
|
import { usePersistedStore } from '../../lib/store'
|
||||||
|
import { openWindow } from '../../lib/window'
|
||||||
|
import type { ScheduledTask } from '../../types/task'
|
||||||
|
|
||||||
|
export default function Cron() {
|
||||||
|
const scheduledTasks = usePersistedStore((state) => state.scheduledTasks)
|
||||||
|
|
||||||
|
if (scheduledTasks.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-screen gap-10 pb-24">
|
||||||
|
<h1 className="text-2xl font-bold text-center">No scheduled tasks found</h1>
|
||||||
|
<div className="flex flex-row items-center justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
size="lg"
|
||||||
|
onPress={() => {
|
||||||
|
openWindow({
|
||||||
|
name: 'Copy',
|
||||||
|
url: '/copy',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create copy task
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="success"
|
||||||
|
size="lg"
|
||||||
|
onPress={() => {
|
||||||
|
openWindow({
|
||||||
|
name: 'Sync',
|
||||||
|
url: '/sync',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create sync task
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen overflow-scroll">
|
||||||
|
{scheduledTasks.map((task) => (
|
||||||
|
<TaskCard key={task.id} task={task} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskCard({ task }: { task: ScheduledTask }) {
|
||||||
|
const [isBusy, setIsBusy] = useState(false)
|
||||||
|
|
||||||
|
const removeScheduledTask = usePersistedStore((state) => state.removeScheduledTask)
|
||||||
|
const updateScheduledTask = usePersistedStore((state) => state.updateScheduledTask)
|
||||||
|
|
||||||
|
const nextRun = useMemo(() => {
|
||||||
|
const parsed = CronExpressionParser.parse(task.cron)
|
||||||
|
if (!parsed.hasNext()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return parsed.next().toDate()
|
||||||
|
}, [task.cron])
|
||||||
|
|
||||||
|
const lastRunLabel = useMemo(() => {
|
||||||
|
if (task.isRunning) {
|
||||||
|
return 'Running now'
|
||||||
|
}
|
||||||
|
if (task.lastRun) {
|
||||||
|
const distance = formatDistance(new Date(task.lastRun), new Date(), {
|
||||||
|
addSuffix: true,
|
||||||
|
})
|
||||||
|
return distance.charAt(0).toUpperCase() + distance.slice(1)
|
||||||
|
}
|
||||||
|
return 'Never'
|
||||||
|
}, [task.isRunning, task.lastRun])
|
||||||
|
|
||||||
|
const nextRunLabel = useMemo(() => {
|
||||||
|
if (!nextRun) {
|
||||||
|
return 'Never'
|
||||||
|
}
|
||||||
|
|
||||||
|
const distance = formatDistance(nextRun, new Date(), {
|
||||||
|
addSuffix: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
return distance.charAt(0).toUpperCase() + distance.slice(1)
|
||||||
|
}, [nextRun])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={task.id}
|
||||||
|
radius="none"
|
||||||
|
shadow="none"
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
border: '1px solid #e0e0e070',
|
||||||
|
padding: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-row items-start justify-between w-full h-10 gap-4">
|
||||||
|
<div className="flex flex-row justify-start flex-1 gap-2">
|
||||||
|
<Chip
|
||||||
|
isCloseable={false}
|
||||||
|
size="lg"
|
||||||
|
variant="flat"
|
||||||
|
radius="sm"
|
||||||
|
color={
|
||||||
|
task.type === 'delete'
|
||||||
|
? 'danger'
|
||||||
|
: task.type === 'copy'
|
||||||
|
? 'success'
|
||||||
|
: 'primary'
|
||||||
|
}
|
||||||
|
className="h-10"
|
||||||
|
>
|
||||||
|
{task.type.toUpperCase()}
|
||||||
|
</Chip>
|
||||||
|
<div className="flex flex-col gap-0">
|
||||||
|
<div className="text-sm font-bold">
|
||||||
|
{buildReadablePath(task.args.srcFs, 'short')}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{buildReadablePath(task.args.dstFs)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row justify-center w-1/2 gap-2">
|
||||||
|
<div className="flex flex-col items-center justify-center gap-0.5">
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
task.isRunning
|
||||||
|
? undefined
|
||||||
|
: task.lastRun
|
||||||
|
? new Date(task.lastRun).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
weekday: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
: "This task hasn't run yet"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
isCloseable={false}
|
||||||
|
size="lg"
|
||||||
|
variant="flat"
|
||||||
|
radius="sm"
|
||||||
|
color={
|
||||||
|
task.isRunning
|
||||||
|
? 'success'
|
||||||
|
: task.error
|
||||||
|
? 'danger'
|
||||||
|
: 'default'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{lastRunLabel}
|
||||||
|
</Chip>
|
||||||
|
</Tooltip>
|
||||||
|
<p className="text-xs text-gray-500">Last run</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center justify-center gap-0.5">
|
||||||
|
<Tooltip
|
||||||
|
content={nextRun?.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
weekday: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
isCloseable={false}
|
||||||
|
size="lg"
|
||||||
|
variant="flat"
|
||||||
|
radius="sm"
|
||||||
|
color={'primary'}
|
||||||
|
>
|
||||||
|
{nextRunLabel}
|
||||||
|
</Chip>
|
||||||
|
</Tooltip>
|
||||||
|
<p className="text-xs text-gray-500">Next run</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
isIconOnly={true}
|
||||||
|
color={task.isEnabled ? 'primary' : 'warning'}
|
||||||
|
isDisabled={isBusy}
|
||||||
|
size="sm"
|
||||||
|
onPress={async () => {
|
||||||
|
setIsBusy(true)
|
||||||
|
if (task.isEnabled) {
|
||||||
|
const answer = await ask(
|
||||||
|
'Are you sure you want to disable this task? This will not stop the current run.'
|
||||||
|
)
|
||||||
|
if (answer) {
|
||||||
|
updateScheduledTask(task.id, { isEnabled: false })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateScheduledTask(task.id, { isEnabled: true })
|
||||||
|
}
|
||||||
|
setIsBusy(false)
|
||||||
|
}}
|
||||||
|
data-focus-visible="false"
|
||||||
|
>
|
||||||
|
{task.isEnabled ? (
|
||||||
|
<PauseIcon className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<PlayIcon className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isIconOnly={true}
|
||||||
|
color="danger"
|
||||||
|
isDisabled={isBusy}
|
||||||
|
size="sm"
|
||||||
|
onPress={async () => {
|
||||||
|
setIsBusy(true)
|
||||||
|
const answer = await ask(
|
||||||
|
'Are you sure you want to remove this task?'
|
||||||
|
)
|
||||||
|
if (answer) {
|
||||||
|
removeScheduledTask(task.id)
|
||||||
|
}
|
||||||
|
setIsBusy(false)
|
||||||
|
}}
|
||||||
|
data-focus-visible="false"
|
||||||
|
>
|
||||||
|
<Trash2Icon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<div className="flex flex-row items-center justify-start gap-1 text-sm font-bold">
|
||||||
|
{task.error ? (
|
||||||
|
<>
|
||||||
|
<AlertCircleIcon className="w-4 h-4 text-danger-600" />
|
||||||
|
<p className="text-sm font-bold text-danger-600">{task.error}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Clock7Icon className="w-4 h-4" />
|
||||||
|
<p className="text-sm font-bold truncate">
|
||||||
|
{cronstrue.toString(task.cron)}.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user