No activity recorded yet.
{activityEnabled && (
diff --git a/web/src/components/instances/preferences/TrackerRulesPanel.tsx b/web/src/components/instances/preferences/TrackerRulesPanel.tsx
new file mode 100644
index 000000000..d930bf4ce
--- /dev/null
+++ b/web/src/components/instances/preferences/TrackerRulesPanel.tsx
@@ -0,0 +1,530 @@
+/*
+ * Copyright (c) 2025, s0up and the autobrr contributors.
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle
+} from "@/components/ui/dialog"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { MultiSelect, type Option } from "@/components/ui/multi-select"
+import { Switch } from "@/components/ui/switch"
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
+import { useInstanceTrackers } from "@/hooks/useInstanceTrackers"
+import { api } from "@/lib/api"
+import { cn } from "@/lib/utils"
+import type { TrackerRule, TrackerRuleInput } from "@/types"
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import { ArrowDown, ArrowUp, Clock, Loader2, Pencil, Plus, RefreshCw, Scale, Trash2 } from "lucide-react"
+import { useMemo, useState } from "react"
+import { toast } from "sonner"
+
+interface TrackerRulesPanelProps {
+ instanceId: number
+}
+
+type FormState = TrackerRuleInput & { trackerDomains: string[] }
+
+const emptyFormState: FormState = {
+ name: "",
+ trackerPattern: "",
+ trackerDomains: [],
+ category: "",
+ tag: "",
+ uploadLimitKiB: undefined,
+ downloadLimitKiB: undefined,
+ ratioLimit: undefined,
+ seedingTimeLimitMinutes: undefined,
+ enabled: true,
+}
+
+export function TrackerRulesPanel({ instanceId }: TrackerRulesPanelProps) {
+ const queryClient = useQueryClient()
+ const [dialogOpen, setDialogOpen] = useState(false)
+ const [editingRule, setEditingRule] = useState(null)
+ const [formState, setFormState] = useState(emptyFormState)
+
+ const trackersQuery = useInstanceTrackers(instanceId, { enabled: true })
+ const trackerOptions: Option[] = useMemo(() => {
+ if (!trackersQuery.data) return []
+ return Object.keys(trackersQuery.data)
+ .map((domain) => ({ label: domain, value: domain }))
+ .sort((a, b) => a.label.localeCompare(b.label))
+ }, [trackersQuery.data])
+
+ const rulesQuery = useQuery({
+ queryKey: ["tracker-rules", instanceId],
+ queryFn: () => api.listTrackerRules(instanceId),
+ })
+
+ const createOrUpdate = useMutation({
+ mutationFn: async (input: FormState) => {
+ if (editingRule) {
+ return api.updateTrackerRule(instanceId, editingRule.id, input)
+ }
+ return api.createTrackerRule(instanceId, input)
+ },
+ onSuccess: () => {
+ toast.success(`Tracker rule ${editingRule ? "updated" : "created"}`)
+ setDialogOpen(false)
+ setEditingRule(null)
+ setFormState(emptyFormState)
+ void queryClient.invalidateQueries({ queryKey: ["tracker-rules", instanceId] })
+ },
+ onError: (error) => {
+ toast.error(error instanceof Error ? error.message : "Failed to save tracker rule")
+ },
+ })
+
+ const deleteRule = useMutation({
+ mutationFn: (ruleId: number) => api.deleteTrackerRule(instanceId, ruleId),
+ onSuccess: () => {
+ toast.success("Tracker rule deleted")
+ void queryClient.invalidateQueries({ queryKey: ["tracker-rules", instanceId] })
+ },
+ onError: (error) => {
+ toast.error(error instanceof Error ? error.message : "Failed to delete tracker rule")
+ },
+ })
+
+ const reorderRules = useMutation({
+ mutationFn: (orderedIds: number[]) => api.reorderTrackerRules(instanceId, orderedIds),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ["tracker-rules", instanceId] })
+ },
+ })
+
+ const toggleEnabled = useMutation({
+ mutationFn: (rule: TrackerRule) => api.updateTrackerRule(instanceId, rule.id, { ...rule, enabled: !rule.enabled }),
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ["tracker-rules", instanceId] })
+ },
+ onError: (error) => {
+ toast.error(error instanceof Error ? error.message : "Failed to toggle rule")
+ },
+ })
+
+ const applyRules = useMutation({
+ mutationFn: () => api.applyTrackerRules(instanceId),
+ onSuccess: () => {
+ toast.success("Tracker rules applied")
+ },
+ onError: (error) => {
+ toast.error(error instanceof Error ? error.message : "Failed to apply tracker rules")
+ },
+ })
+
+ const sortedRules = useMemo(() => {
+ const rules = rulesQuery.data ?? []
+ return [...rules].sort((a, b) => a.sortOrder - b.sortOrder || a.id - b.id)
+ }, [rulesQuery.data])
+
+ const openForCreate = () => {
+ setEditingRule(null)
+ setFormState(emptyFormState)
+ setDialogOpen(true)
+ }
+
+ const openForEdit = (rule: TrackerRule) => {
+ const domains = parseTrackerDomains(rule)
+ setEditingRule(rule)
+ setFormState({
+ name: rule.name,
+ trackerPattern: rule.trackerPattern,
+ trackerDomains: domains,
+ category: rule.category,
+ tag: rule.tag,
+ uploadLimitKiB: rule.uploadLimitKiB,
+ downloadLimitKiB: rule.downloadLimitKiB,
+ ratioLimit: rule.ratioLimit,
+ seedingTimeLimitMinutes: rule.seedingTimeLimitMinutes,
+ enabled: rule.enabled,
+ sortOrder: rule.sortOrder,
+ })
+ setDialogOpen(true)
+ }
+
+ const handleMove = (ruleId: number, direction: -1 | 1) => {
+ if (!sortedRules) return
+ const index = sortedRules.findIndex(r => r.id === ruleId)
+ const target = index + direction
+ if (index === -1 || target < 0 || target >= sortedRules.length) {
+ return
+ }
+ const nextOrder = sortedRules.map(r => r.id)
+ const [removed] = nextOrder.splice(index, 1)
+ nextOrder.splice(target, 0, removed)
+ reorderRules.mutate(nextOrder)
+ }
+
+ const handleSubmit = (event: React.FormEvent) => {
+ event.preventDefault()
+ if (!formState.name) {
+ toast.error("Name is required")
+ return
+ }
+ const selectedTrackers = formState.trackerDomains.filter(Boolean)
+ if (selectedTrackers.length === 0) {
+ toast.error("Select at least one tracker")
+ return
+ }
+ const payload: FormState = {
+ ...formState,
+ trackerDomains: selectedTrackers,
+ trackerPattern: selectedTrackers.join(","),
+ category: formState.category || undefined,
+ tag: formState.tag || undefined,
+ }
+ createOrUpdate.mutate(payload)
+ }
+
+ return (
+
+
+
+
+ Tracker Rules
+ Apply speed and ratio caps per tracker domain.
+
+
+
+
+
+
+
+ {rulesQuery.isLoading ? (
+
+
+ Loading rules...
+
+ ) : (sortedRules?.length ?? 0) === 0 ? (
+ No tracker rules yet. Add one to start enforcing per-tracker limits.
+ ) : (
+
+ {sortedRules.map((rule) => {
+ const actions = (
+ <>
+
+
+
+
+ >
+ )
+
+ return (
+
+
+
+
+ toggleEnabled.mutate(rule)}
+ disabled={toggleEnabled.isPending}
+ className="shrink-0"
+ />
+ {rule.name}
+ {!rule.enabled && (
+
+ Disabled
+
+ )}
+
+
+ {actions}
+
+
+
+
+
+
+ {actions}
+
+
+ )
+ })}
+
+ )}
+
+
+
+
+
+ )
+}
+
+function RuleSummary({ rule }: { rule: TrackerRule }) {
+ const trackers = parseTrackerDomains(rule)
+
+ const hasActions =
+ rule.downloadLimitKiB !== undefined ||
+ rule.uploadLimitKiB !== undefined ||
+ rule.ratioLimit !== undefined ||
+ rule.seedingTimeLimitMinutes !== undefined
+
+ if (!hasActions && trackers.length === 0 && !rule.category && !rule.tag) {
+ return No actions set
+ }
+
+ return (
+
+ {trackers.length > 0 && (
+
+
+
+ {trackers[0]}
+ {trackers.length > 1 && (
+
+ +{trackers.length - 1}
+
+ )}
+
+
+
+ {trackers.join(", ")}
+
+
+ )}
+
+ {rule.category && (
+
+ Cat: {rule.category}
+
+ )}
+
+ {rule.tag && (
+
+ Tag: {rule.tag}
+
+ )}
+
+ {rule.uploadLimitKiB !== undefined && (
+
+
+ UL {rule.uploadLimitKiB} KiB/s
+
+ )}
+
+ {rule.downloadLimitKiB !== undefined && (
+
+
+ DL {rule.downloadLimitKiB} KiB/s
+
+ )}
+
+ {rule.ratioLimit !== undefined && (
+
+
+ Ratio {rule.ratioLimit}
+
+ )}
+
+ {rule.seedingTimeLimitMinutes !== undefined && (
+
+
+ {rule.seedingTimeLimitMinutes}m
+
+ )}
+
+ )
+}
+
+function parseTrackerDomains(rule: TrackerRule): string[] {
+ if (rule.trackerDomains && rule.trackerDomains.length > 0) {
+ return rule.trackerDomains
+ }
+ if (!rule.trackerPattern) return []
+ return rule.trackerPattern
+ .split(/[|,;]/)
+ .map((item) => item.trim())
+ .filter(Boolean)
+}
diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx
index a873df082..6376017d0 100644
--- a/web/src/components/layout/Header.tsx
+++ b/web/src/components/layout/Header.tsx
@@ -36,7 +36,7 @@ import { cn } from "@/lib/utils"
import type { InstanceCapabilities } from "@/types"
import { useQuery } from "@tanstack/react-query"
import { Link, useNavigate, useSearch } from "@tanstack/react-router"
-import { Archive, ChevronsUpDown, Download, FileEdit, FunnelPlus, FunnelX, GitBranch, HardDrive, Home, Info, ListTodo, Loader2, LogOut, Menu, Plus, Rss, Search, SearchCode, Server, Settings, X } from "lucide-react"
+import { Archive, ChevronsUpDown, Download, FileEdit, FunnelPlus, FunnelX, GitBranch, HardDrive, Home, Info, ListTodo, Loader2, LogOut, Menu, Plus, Rss, Search, SearchCode, Server, Settings, Wrench, X } from "lucide-react"
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useHotkeys } from "react-hotkeys-hook"
@@ -508,6 +508,15 @@ export function Header({
Cross-Seed
+
+
+
+ Services
+
+
+
+
+
+ Services
+
+
("speed")
// Filter lifecycle state machine to replace fragile timing-based coordination
type FilterLifecycleState = 'idle' | 'clearing-all' | 'clearing-columns-only' | 'cleared'
@@ -877,6 +876,7 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({
// Debounce search to prevent excessive filtering (200ms delay for faster response)
const debouncedSearch = useDebounce(globalFilter, 200)
const routeSearch = useSearch({ strict: false }) as { q?: string }
+ const navigate = useNavigate()
const rawRouteSearch = typeof routeSearch?.q === "string" ? routeSearch.q : ""
const searchFromRoute = rawRouteSearch.trim()
@@ -2801,8 +2801,10 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
- setPreferencesDefaultTab("reannounce")
- setPreferencesOpen(true)
+ void navigate({
+ to: "/services",
+ search: { instanceId: String(instanceId) },
+ })
}}
className="h-6 w-6 text-muted-foreground hover:text-accent-foreground"
>
@@ -3073,7 +3075,6 @@ export const TorrentTableOptimized = memo(function TorrentTableOptimized({
onOpenChange={setPreferencesOpen}
instanceId={instanceId}
instanceName={instance.name}
- defaultTab={preferencesDefaultTab}
/>
)}
diff --git a/web/src/components/ui/multi-select.tsx b/web/src/components/ui/multi-select.tsx
index 33d27b6ee..856fb33ff 100644
--- a/web/src/components/ui/multi-select.tsx
+++ b/web/src/components/ui/multi-select.tsx
@@ -9,6 +9,7 @@ import * as React from "react"
export interface Option {
label: string
value: string
+ level?: number
}
interface MultiSelectProps {
@@ -152,7 +153,12 @@ export function MultiSelect({
selected.includes(option.value) ? "opacity-100" : "opacity-0"
)}
/>
- {option.label}
+
+ {option.label}
+
))}
diff --git a/web/src/hooks/useInstances.ts b/web/src/hooks/useInstances.ts
index c688d2e85..71af6027d 100644
--- a/web/src/hooks/useInstances.ts
+++ b/web/src/hooks/useInstances.ts
@@ -50,10 +50,13 @@ export function useInstances() {
data: Partial
}) => api.updateInstance(id, data),
onSuccess: async (updatedInstance) => {
- // Immediately update the instance in cache
+ // Immediately update the instance in cache, preserving only the connected
+ // flag to avoid UI flicker (testConnection will refresh it)
queryClient.setQueryData(["instances"], (old) => {
if (!old) return [updatedInstance]
- return old.map(i => i.id === updatedInstance.id ? updatedInstance : i)
+ return old.map(i => i.id === updatedInstance.id
+ ? { ...updatedInstance, connected: i.connected }
+ : i)
})
// Test connection immediately to get actual status
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index 46aeb141d..5e470bcd9 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -51,6 +51,8 @@ import type {
TorrentProperties,
TorrentResponse,
TorrentTracker,
+ TrackerRule,
+ TrackerRuleInput,
TorznabIndexer,
TorznabIndexerError,
TorznabIndexerFormData,
@@ -1217,6 +1219,43 @@ class ApiClient {
return this.request(`/instances/${instanceId}/trackers`)
}
+ async listTrackerRules(instanceId: number): Promise {
+ return this.request(`/instances/${instanceId}/tracker-rules`)
+ }
+
+ async createTrackerRule(instanceId: number, payload: TrackerRuleInput): Promise {
+ return this.request(`/instances/${instanceId}/tracker-rules`, {
+ method: "POST",
+ body: JSON.stringify(payload),
+ })
+ }
+
+ async updateTrackerRule(instanceId: number, ruleId: number, payload: TrackerRuleInput): Promise {
+ return this.request(`/instances/${instanceId}/tracker-rules/${ruleId}`, {
+ method: "PUT",
+ body: JSON.stringify(payload),
+ })
+ }
+
+ async deleteTrackerRule(instanceId: number, ruleId: number): Promise {
+ return this.request(`/instances/${instanceId}/tracker-rules/${ruleId}`, {
+ method: "DELETE",
+ })
+ }
+
+ async reorderTrackerRules(instanceId: number, orderedIds: number[]): Promise {
+ return this.request(`/instances/${instanceId}/tracker-rules/order`, {
+ method: "PUT",
+ body: JSON.stringify({ orderedIds }),
+ })
+ }
+
+ async applyTrackerRules(instanceId: number): Promise {
+ return this.request(`/instances/${instanceId}/tracker-rules/apply`, {
+ method: "POST",
+ })
+ }
+
// User endpoints
async changePassword(currentPassword: string, newPassword: string): Promise {
return this.request("/auth/change-password", {
diff --git a/web/src/pages/CrossSeedPage.tsx b/web/src/pages/CrossSeedPage.tsx
index a33a89eca..86bba22e3 100644
--- a/web/src/pages/CrossSeedPage.tsx
+++ b/web/src/pages/CrossSeedPage.tsx
@@ -3,6 +3,7 @@
* SPDX-License-Identifier: GPL-2.0-or-later
*/
+import { buildCategoryTree, type CategoryNode } from "@/components/torrents/CategoryTree"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
@@ -677,22 +678,37 @@ export function CrossSeedPage() {
[enabledIndexers]
)
- const searchCategoryNames = useMemo(() => {
- if (!searchMetadata?.categories) return [] as string[]
- return Object.keys(searchMetadata.categories).sort()
- }, [searchMetadata])
-
const searchTagNames = useMemo(() => searchMetadata?.tags ?? [], [searchMetadata])
const searchCategorySelectOptions = useMemo(
() => {
- const extras = searchCategories.filter(category => !searchCategoryNames.includes(category))
- return Array.from(new Set([...searchCategoryNames, ...extras])).map(category => ({
- label: category,
- value: category,
- }))
+ // Build tree from available categories for indentation
+ const categories = searchMetadata?.categories ?? {}
+ const tree = buildCategoryTree(categories, {})
+ const flattened: { label: string; value: string; level: number }[] = []
+
+ const visitNodes = (nodes: CategoryNode[]) => {
+ for (const node of nodes) {
+ flattened.push({
+ label: node.displayName,
+ value: node.name,
+ level: node.level,
+ })
+ visitNodes(node.children)
+ }
+ }
+
+ visitNodes(tree)
+
+ // Add any extra categories that were manually typed but not in the list
+ const extras = searchCategories.filter(category => !flattened.some(opt => opt.value === category))
+ for (const extra of extras) {
+ flattened.push({ label: extra, value: extra, level: 0 })
+ }
+
+ return flattened
},
- [searchCategories, searchCategoryNames]
+ [searchCategories, searchMetadata?.categories]
)
const searchTagSelectOptions = useMemo(
diff --git a/web/src/pages/Services.tsx b/web/src/pages/Services.tsx
new file mode 100644
index 000000000..ea8233794
--- /dev/null
+++ b/web/src/pages/Services.tsx
@@ -0,0 +1,112 @@
+/*
+ * Copyright (c) 2025, s0up and the autobrr contributors.
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+import { TrackerReannounceForm } from "@/components/instances/preferences/TrackerReannounceForm"
+import { TrackerRulesPanel } from "@/components/instances/preferences/TrackerRulesPanel"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { useInstances } from "@/hooks/useInstances"
+import { useNavigate, useSearch } from "@tanstack/react-router"
+import { HardDrive } from "lucide-react"
+import { useMemo } from "react"
+
+type ServicesSearch = {
+ instanceId?: string
+}
+
+export function Services() {
+ const { instances } = useInstances()
+ const navigate = useNavigate()
+ const search = useSearch({ from: "/_authenticated/services" }) as ServicesSearch
+
+ const activeInstances = useMemo(
+ () => (instances ?? []).filter((instance) => instance.isActive),
+ [instances]
+ )
+
+ const selectedInstanceId = useMemo(() => {
+ const fromSearch = search.instanceId ? Number(search.instanceId) : undefined
+ const allInstances = instances ?? []
+ if (fromSearch && allInstances.some((inst) => inst.id === fromSearch)) {
+ return fromSearch
+ }
+ if (allInstances.length > 0) {
+ return allInstances[0]?.id
+ }
+ return undefined
+ }, [instances, search.instanceId])
+
+ const handleInstanceChange = (value: string) => {
+ navigate({
+ to: "/services",
+ search: (prev: ServicesSearch) => ({
+ ...prev,
+ instanceId: value,
+ }) satisfies ServicesSearch,
+ replace: true,
+ })
+ }
+
+ const selectedInstance = activeInstances.find((inst) => inst.id === selectedInstanceId)
+
+ return (
+
+
+
+
Services
+
+ Instance-level automation and helper services managed by qui.
+
+
+
+
+ {instances && instances.length > 0 && (
+
+ )}
+
+
+
+ {instances && instances.length === 0 && (
+
+ No instances configured yet. Add one in Settings to use services.
+
+ )}
+
+ {!selectedInstance && instances && instances.length > 0 && (
+
+ Select an active instance to configure services.
+
+ )}
+
+ {selectedInstance && (
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts
index 62aaf3eab..2309b1e99 100644
--- a/web/src/routeTree.gen.ts
+++ b/web/src/routeTree.gen.ts
@@ -17,6 +17,7 @@ import { Route as AuthenticatedInstancesRouteImport } from './routes/_authentica
import { Route as AuthenticatedInstancesInstanceIdRouteImport } from './routes/_authenticated/instances.$instanceId'
import { Route as AuthenticatedInstancesIndexRouteImport } from './routes/_authenticated/instances.index'
import { Route as AuthenticatedSearchRouteImport } from './routes/_authenticated/search'
+import { Route as AuthenticatedServicesRouteImport } from './routes/_authenticated/services'
import { Route as AuthenticatedSettingsRouteImport } from './routes/_authenticated/settings'
import { Route as IndexRouteImport } from './routes/index'
import { Route as LoginRouteImport } from './routes/login'
@@ -46,6 +47,11 @@ const AuthenticatedSettingsRoute = AuthenticatedSettingsRouteImport.update({
path: '/settings',
getParentRoute: () => AuthenticatedRoute,
} as any)
+const AuthenticatedServicesRoute = AuthenticatedServicesRouteImport.update({
+ id: '/services',
+ path: '/services',
+ getParentRoute: () => AuthenticatedRoute,
+} as any)
const AuthenticatedSearchRoute = AuthenticatedSearchRouteImport.update({
id: '/search',
path: '/search',
@@ -93,6 +99,7 @@ export interface FileRoutesByFullPath {
'/dashboard': typeof AuthenticatedDashboardRoute
'/instances': typeof AuthenticatedInstancesRouteWithChildren
'/search': typeof AuthenticatedSearchRoute
+ '/services': typeof AuthenticatedServicesRoute
'/settings': typeof AuthenticatedSettingsRoute
'/instances/$instanceId': typeof AuthenticatedInstancesInstanceIdRoute
'/instances/': typeof AuthenticatedInstancesIndexRoute
@@ -105,6 +112,7 @@ export interface FileRoutesByTo {
'/cross-seed': typeof AuthenticatedCrossSeedRoute
'/dashboard': typeof AuthenticatedDashboardRoute
'/search': typeof AuthenticatedSearchRoute
+ '/services': typeof AuthenticatedServicesRoute
'/settings': typeof AuthenticatedSettingsRoute
'/instances/$instanceId': typeof AuthenticatedInstancesInstanceIdRoute
'/instances': typeof AuthenticatedInstancesIndexRoute
@@ -120,6 +128,7 @@ export interface FileRoutesById {
'/_authenticated/dashboard': typeof AuthenticatedDashboardRoute
'/_authenticated/instances': typeof AuthenticatedInstancesRouteWithChildren
'/_authenticated/search': typeof AuthenticatedSearchRoute
+ '/_authenticated/services': typeof AuthenticatedServicesRoute
'/_authenticated/settings': typeof AuthenticatedSettingsRoute
'/_authenticated/instances/$instanceId': typeof AuthenticatedInstancesInstanceIdRoute
'/_authenticated/instances/': typeof AuthenticatedInstancesIndexRoute
@@ -135,6 +144,7 @@ export interface FileRouteTypes {
| '/dashboard'
| '/instances'
| '/search'
+ | '/services'
| '/settings'
| '/instances/$instanceId'
| '/instances/'
@@ -147,6 +157,7 @@ export interface FileRouteTypes {
| '/cross-seed'
| '/dashboard'
| '/search'
+ | '/services'
| '/settings'
| '/instances/$instanceId'
| '/instances'
@@ -161,6 +172,7 @@ export interface FileRouteTypes {
| '/_authenticated/dashboard'
| '/_authenticated/instances'
| '/_authenticated/search'
+ | '/_authenticated/services'
| '/_authenticated/settings'
| '/_authenticated/instances/$instanceId'
| '/_authenticated/instances/'
@@ -210,6 +222,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedSettingsRouteImport
parentRoute: typeof AuthenticatedRoute
}
+ '/_authenticated/services': {
+ id: '/_authenticated/services'
+ path: '/services'
+ fullPath: '/services'
+ preLoaderRoute: typeof AuthenticatedServicesRouteImport
+ parentRoute: typeof AuthenticatedRoute
+ }
'/_authenticated/search': {
id: '/_authenticated/search'
path: '/search'
@@ -285,6 +304,7 @@ interface AuthenticatedRouteChildren {
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute
AuthenticatedInstancesRoute: typeof AuthenticatedInstancesRouteWithChildren
AuthenticatedSearchRoute: typeof AuthenticatedSearchRoute
+ AuthenticatedServicesRoute: typeof AuthenticatedServicesRoute
AuthenticatedSettingsRoute: typeof AuthenticatedSettingsRoute
}
@@ -294,6 +314,7 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
AuthenticatedInstancesRoute: AuthenticatedInstancesRouteWithChildren,
AuthenticatedSearchRoute: AuthenticatedSearchRoute,
+ AuthenticatedServicesRoute: AuthenticatedServicesRoute,
AuthenticatedSettingsRoute: AuthenticatedSettingsRoute,
}
diff --git a/web/src/routes/_authenticated/services.tsx b/web/src/routes/_authenticated/services.tsx
new file mode 100644
index 000000000..045793d48
--- /dev/null
+++ b/web/src/routes/_authenticated/services.tsx
@@ -0,0 +1,11 @@
+/*
+ * Copyright (c) 2025, s0up and the autobrr contributors.
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+import { Services } from "@/pages/Services"
+import { createFileRoute } from "@tanstack/react-router"
+
+export const Route = createFileRoute("/_authenticated/services")({
+ component: Services,
+})
diff --git a/web/src/types/index.ts b/web/src/types/index.ts
index ee587b651..f5ab4b796 100644
--- a/web/src/types/index.ts
+++ b/web/src/types/index.ts
@@ -85,6 +85,38 @@ export interface InstanceError {
occurredAt: string
}
+export interface TrackerRule {
+ id: number
+ instanceId: number
+ name: string
+ trackerPattern: string
+ trackerDomains?: string[]
+ category?: string
+ tag?: string
+ uploadLimitKiB?: number
+ downloadLimitKiB?: number
+ ratioLimit?: number
+ seedingTimeLimitMinutes?: number
+ enabled: boolean
+ sortOrder: number
+ createdAt?: string
+ updatedAt?: string
+}
+
+export interface TrackerRuleInput {
+ name: string
+ trackerPattern?: string
+ trackerDomains?: string[]
+ category?: string
+ tag?: string
+ uploadLimitKiB?: number
+ downloadLimitKiB?: number
+ ratioLimit?: number
+ seedingTimeLimitMinutes?: number
+ enabled?: boolean
+ sortOrder?: number
+}
+
export interface InstanceResponse extends Instance {
connected: boolean
hasDecryptionError: boolean