Visual redesign: design system, refined ops console aesthetic

- Full component library (Button, Input, Label, Select, Modal, Table, Badge, Card, etc.)
- Tailwind design tokens: IBM Plex Sans + JetBrains Mono, teal accent, semantic status colors
- NavBar with logo, responsive hamburger menu, real logout
- All pages redesigned: Login, Dashboard (KPI cards), Machines, SyncPairs, JobHistory, JobDetail, SSHKeys, Settings
- Fixed: hover:bg-gray-750 dead class, window.location.href navigation bug
- Replaced alert()/confirm() with sonner toasts and accessible modals
- Added ErrorBoundary, skip link, accessible modal dialogs (Radix)
- Icons: lucide-react throughout, copy/download buttons
- 1.0.5 → 1.0.6
This commit is contained in:
2026-07-07 23:12:34 -04:00
parent 93c22e844e
commit 9cef7173cb
36 changed files with 4069 additions and 652 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
BINARY=syncserver
VERSION?=1.0.5
VERSION?=1.0.6
GO?=go
LDFLAGS=-s -w -X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
BUILD_FLAGS=CGO_ENABLED=0
+1 -1
View File
@@ -20,7 +20,7 @@ import (
"github.com/syncserver/internal/syncengine"
)
var version = "1.0.5"
var version = "1.0.6"
func main() {
cfgPath := flag.String("config", "", "Path to config.yaml")
+793 -5
View File
@@ -8,9 +8,19 @@
"name": "syncserver-web",
"version": "1.0.0",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.19",
"@radix-ui/react-select": "^2.3.3",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.3.3",
"@radix-ui/react-toggle": "^1.1.14",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.23.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
"react-router-dom": "^6.28.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
@@ -714,6 +724,40 @@
"node": ">=18"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
"integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
"dependencies": {
"@floating-ui/dom": "^1.7.6"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -794,6 +838,569 @@
"node": ">= 8"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz",
"integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig=="
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.5.tgz",
"integrity": "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg=="
},
"node_modules/@radix-ui/react-arrow": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.11.tgz",
"integrity": "sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A==",
"dependencies": {
"@radix-ui/react-primitive": "2.1.7"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-collection": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.12.tgz",
"integrity": "sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.2.0",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-slot": "1.3.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz",
"integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-context": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.0.tgz",
"integrity": "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dialog": {
"version": "1.1.19",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.19.tgz",
"integrity": "sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg==",
"dependencies": {
"@radix-ui/primitive": "1.1.5",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.2.0",
"@radix-ui/react-dismissable-layer": "1.1.15",
"@radix-ui/react-focus-guards": "1.1.4",
"@radix-ui/react-focus-scope": "1.1.12",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-portal": "1.1.13",
"@radix-ui/react-presence": "1.1.7",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-slot": "1.3.0",
"@radix-ui/react-use-controllable-state": "1.2.3",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.7.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz",
"integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.15.tgz",
"integrity": "sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA==",
"dependencies": {
"@radix-ui/primitive": "1.1.5",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-effect-event": "0.0.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz",
"integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.12.tgz",
"integrity": "sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-callback-ref": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-id": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz",
"integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.3.tgz",
"integrity": "sha512-mS7dGpyjv6b+gsDjLF7e0ia1W4Im1B1hSCy2yuXlHuvnZxHKagfDaobt/KAKt27EpZMit2pss8eJBVyVjEWM+g==",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.11",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.2.0",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.2",
"@radix-ui/react-use-rect": "1.1.2",
"@radix-ui/react-use-size": "1.1.2",
"@radix-ui/rect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-portal": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.13.tgz",
"integrity": "sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==",
"dependencies": {
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-presence": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.7.tgz",
"integrity": "sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-primitive": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz",
"integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==",
"dependencies": {
"@radix-ui/react-slot": "1.3.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.3.tgz",
"integrity": "sha512-L5RQTXz6Anxsf9CCv+pTgiAsUpyVj7rJxsGtmhFaEOJ++cVfXucv4qWfsIO0AIB4NAhi3yovWGVMKKS1Xf1Wrg==",
"dependencies": {
"@radix-ui/number": "1.1.2",
"@radix-ui/primitive": "1.1.5",
"@radix-ui/react-collection": "1.1.12",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.2.0",
"@radix-ui/react-direction": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.15",
"@radix-ui/react-focus-guards": "1.1.4",
"@radix-ui/react-focus-scope": "1.1.12",
"@radix-ui/react-id": "1.1.2",
"@radix-ui/react-popper": "1.3.3",
"@radix-ui/react-portal": "1.1.13",
"@radix-ui/react-presence": "1.1.7",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-slot": "1.3.0",
"@radix-ui/react-use-callback-ref": "1.1.2",
"@radix-ui/react-use-controllable-state": "1.2.3",
"@radix-ui/react-use-layout-effect": "1.1.2",
"@radix-ui/react-use-previous": "1.1.2",
"@radix-ui/react-visually-hidden": "1.2.7",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.7.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz",
"integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.3"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-switch": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.3.tgz",
"integrity": "sha512-1+mlB4/lxJfk5tgJ4g+R5mUCbRpPE1T9+UsEyeLYbGgMtwiMgmuTnfKz4Mw1nHALHjuwyxw4MLd4cSHn6pNSlQ==",
"dependencies": {
"@radix-ui/primitive": "1.1.5",
"@radix-ui/react-compose-refs": "1.1.3",
"@radix-ui/react-context": "1.2.0",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-controllable-state": "1.2.3",
"@radix-ui/react-use-previous": "1.1.2",
"@radix-ui/react-use-size": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-toggle": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.14.tgz",
"integrity": "sha512-QI/hB65XKWACA66P64A+aHxtLUgHJeJLkaQa+awUNXT6T3swndtY5DojeHA+vldrTspMTtFBd7HfZ9QGbM1Qrw==",
"dependencies": {
"@radix-ui/primitive": "1.1.5",
"@radix-ui/react-primitive": "2.1.7",
"@radix-ui/react-use-controllable-state": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz",
"integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz",
"integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==",
"dependencies": {
"@radix-ui/react-use-effect-event": "0.0.3",
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-effect-event": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz",
"integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz",
"integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-previous": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz",
"integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz",
"integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==",
"dependencies": {
"@radix-ui/rect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-size": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz",
"integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.7.tgz",
"integrity": "sha512-1wNZBggTDK3GRuuQ6nP4k2yi7a6l7I5qbMPbZcRsrGsGVead/f/d5FhEzUvqFs0bcrDLx7n1zKQ3JvLR6whaaw==",
"dependencies": {
"@radix-ui/react-primitive": "2.1.7"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/rect": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz",
"integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA=="
},
"node_modules/@remix-run/router": {
"version": "1.23.3",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
@@ -1184,13 +1791,13 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true
"devOptional": true
},
"node_modules/@types/react": {
"version": "18.3.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"dev": true,
"devOptional": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -1200,7 +1807,7 @@
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"devOptional": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
@@ -1250,6 +1857,17 @@
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"dev": true
},
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
"integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/autoprefixer": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.2.tgz",
@@ -1420,6 +2038,25 @@
"node": ">= 6"
}
},
"node_modules/class-variance-authority": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
"dependencies": {
"clsx": "^2.1.1"
},
"funding": {
"url": "https://polar.sh/cva"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"engines": {
"node": ">=6"
}
},
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@@ -1451,7 +2088,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true
"devOptional": true
},
"node_modules/debug": {
"version": "4.4.3",
@@ -1470,6 +2107,11 @@
}
}
},
"node_modules/detect-node-es": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -1641,6 +2283,14 @@
"node": ">=6.9.0"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
"integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==",
"engines": {
"node": ">=6"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -1798,6 +2448,14 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "1.23.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz",
"integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -2141,6 +2799,51 @@
"node": ">=0.10.0"
}
},
"node_modules/react-remove-scroll": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
"integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
"react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
"use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-remove-scroll-bar": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz",
"integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==",
"dependencies": {
"react-style-singleton": "^2.2.2",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-router": {
"version": "6.30.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
@@ -2171,6 +2874,27 @@
"react-dom": ">=16.8"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
"integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==",
"dependencies": {
"get-nonce": "^1.0.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -2307,6 +3031,15 @@
"semver": "bin/semver.js"
}
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2350,6 +3083,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tailwind-merge": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
"integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@@ -2471,6 +3213,11 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2514,6 +3261,47 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",
"integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==",
"dependencies": {
"detect-node-es": "^1.1.0",
"tslib": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+11 -1
View File
@@ -9,9 +9,19 @@
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.19",
"@radix-ui/react-select": "^2.3.3",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.3.3",
"@radix-ui/react-toggle": "^1.1.14",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.23.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
"react-router-dom": "^6.28.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
+169 -26
View File
@@ -1,14 +1,48 @@
import { BrowserRouter, Routes, Route, Navigate, NavLink, Outlet } from 'react-router-dom';
import {
BrowserRouter,
Routes,
Route,
Navigate,
NavLink,
Outlet,
useNavigate,
} from 'react-router-dom';
import { useState, useEffect } from 'react';
import { Toaster } from 'sonner';
import {
Database,
LayoutDashboard,
Server,
GitCompare,
History,
Key,
Settings as SettingsIcon,
LogOut,
Menu,
X,
} from 'lucide-react';
import { ErrorBoundary } from './components/ErrorBoundary';
import { Spinner } from './components/ui/Spinner';
import { Button } from './components/ui/Button';
import { cn } from './lib/utils';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Machines from './pages/Machines';
import SyncPairs from './pages/SyncPairs';
import JobHistory from './pages/JobHistory';
import JobDetail from './pages/JobDetail';
import Settings from './pages/Settings';
import SettingsPage from './pages/Settings';
import SSHKeys from './pages/SSHKeys';
const navItems = [
{ to: '/', label: 'Dashboard', icon: LayoutDashboard },
{ to: '/machines', label: 'Machines', icon: Server },
{ to: '/sync-pairs', label: 'Sync Pairs', icon: GitCompare },
{ to: '/jobs', label: 'Jobs', icon: History },
{ to: '/ssh-keys', label: 'SSH Keys', icon: Key },
{ to: '/settings', label: 'Settings', icon: SettingsIcon },
];
function ProtectedRoute({ children }: { children: JSX.Element }) {
const [authed, setAuthed] = useState<boolean | null>(null);
useEffect(() => {
@@ -16,54 +50,155 @@ function ProtectedRoute({ children }: { children: JSX.Element }) {
.then(r => setAuthed(r.ok))
.catch(() => setAuthed(false));
}, []);
if (authed === null) return <div className="p-4">Loading...</div>;
if (authed === null) {
return (
<div className="flex h-screen items-center justify-center bg-canvas">
<Spinner size="lg" />
</div>
);
}
return authed ? children : <Navigate to="/login" />;
}
function NavBar() {
const navItems = [
{ to: '/', label: 'Dashboard' },
{ to: '/machines', label: 'Machines' },
{ to: '/sync-pairs', label: 'Sync Pairs' },
{ to: '/jobs', label: 'Jobs' },
{ to: '/ssh-keys', label: 'SSH Keys' },
{ to: '/settings', label: 'Settings' },
];
const [mobileOpen, setMobileOpen] = useState(false);
const navigate = useNavigate();
const handleLogout = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
} catch {}
localStorage.removeItem('auth');
navigate('/login');
};
return (
<nav className="sticky top-0 z-50 bg-gray-900 border-b border-gray-700">
<header className="sticky top-0 z-50 bg-surface border-b border-border">
<div className="max-w-7xl mx-auto px-4">
<div className="flex items-center h-14 gap-1">
<span className="text-white font-bold text-lg mr-4">SyncServer</span>
{navItems.map(item => (
<div className="flex items-center h-14 gap-6">
<a
href="/"
className="flex items-center gap-2.5 text-fg font-bold text-base hover:text-accent transition-colors shrink-0"
>
<div className="rounded-card bg-accent/10 p-1">
<Database className="h-4 w-4 text-accent" />
</div>
<span className="hidden sm:inline">SyncServer</span>
</a>
<nav className="hidden md:flex items-center gap-0.5 flex-1">
{navItems.map(item => {
const Icon = item.icon;
return (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-card text-sm font-medium transition-all duration-150',
isActive
? 'text-white bg-gray-800'
: 'text-gray-400 hover:text-white hover:bg-gray-800'
}`
? 'bg-surface-raised text-fg'
: 'text-fg-muted hover:text-fg hover:bg-surface-raised'
)
}
>
<Icon className="h-3.5 w-3.5" />
{item.label}
</NavLink>
))}
</div>
</div>
);
})}
</nav>
<div className="flex items-center gap-2 ml-auto">
<Button
variant="ghost"
size="icon-sm"
onClick={handleLogout}
className="text-fg-muted hover:text-fg hidden sm:flex"
title="Logout"
>
<LogOut className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setMobileOpen(v => !v)}
className="md:hidden text-fg-muted"
>
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</Button>
</div>
</div>
{mobileOpen && (
<nav className="md:hidden pb-3 flex flex-col gap-0.5 animate-slide-up">
{navItems.map(item => {
const Icon = item.icon;
return (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
onClick={() => setMobileOpen(false)}
className={({ isActive }) =>
cn(
'flex items-center gap-2 px-3 py-2 rounded-card text-sm font-medium transition-all',
isActive
? 'bg-surface-raised text-fg'
: 'text-fg-muted hover:text-fg hover:bg-surface-raised'
)
}
>
<Icon className="h-4 w-4" />
{item.label}
</NavLink>
);
})}
<button
onClick={() => {
setMobileOpen(false);
handleLogout();
}}
className="flex items-center gap-2 px-3 py-2 rounded-card text-sm font-medium text-fg-muted hover:text-fg hover:bg-surface-raised mt-1 border-t border-border pt-3"
>
<LogOut className="h-4 w-4" />
Logout
</button>
</nav>
)}
</div>
</header>
);
}
function Layout() {
return (
<div className="min-h-screen bg-gray-900">
<div className="min-h-screen bg-canvas">
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-4 focus:py-2 focus:rounded-card focus:bg-surface-raised focus:text-fg focus:outline-none focus:ring-2 focus:ring-accent/40"
>
Skip to content
</a>
<NavBar />
<main>
<main id="main-content" className="max-w-7xl mx-auto px-4 py-6">
<Outlet />
</main>
<Toaster
position="bottom-right"
toastOptions={{
classNames: {
error: 'bg-surface-raised border border-rose-500/30 text-fg',
success: 'bg-surface-raised border border-emerald-500/30 text-fg',
warning: 'bg-surface-raised border border-amber-500/30 text-fg',
info: 'bg-surface-raised border border-sky-500/30 text-fg',
},
}}
theme="dark"
richColors
closeButton
/>
</div>
);
}
@@ -71,19 +206,27 @@ function Layout() {
export default function App() {
return (
<BrowserRouter>
<ErrorBoundary>
<Routes>
<Route path="/login" element={<Login />} />
<Route element={<ProtectedRoute><Layout /></ProtectedRoute>}>
<Route
element={
<ProtectedRoute>
<Layout />
</ProtectedRoute>
}
>
<Route path="/" element={<Dashboard />} />
<Route path="/machines" element={<Machines />} />
<Route path="/sync-pairs" element={<SyncPairs />} />
<Route path="/jobs" element={<JobHistory />} />
<Route path="/jobs/:id" element={<JobDetail />} />
<Route path="/ssh-keys" element={<SSHKeys />} />
<Route path="/settings" element={<Settings />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</ErrorBoundary>
</BrowserRouter>
);
}
+62
View File
@@ -0,0 +1,62 @@
import * as React from 'react'
import { AlertTriangle, RefreshCw } from 'lucide-react'
import { Button } from './ui/Button'
interface ErrorBoundaryState {
hasError: boolean
error: Error | null
}
interface ErrorBoundaryProps {
children: React.ReactNode
fallback?: React.ReactNode
}
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught:', error, errorInfo)
}
handleReload = () => {
this.setState({ hasError: false, error: null })
window.location.reload()
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback
}
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-4 p-8 text-center">
<div className="rounded-full bg-rose-500/10 p-4 text-rose-400">
<AlertTriangle className="h-8 w-8" />
</div>
<div className="space-y-2">
<h2 className="text-lg font-semibold text-fg">Something went wrong</h2>
<p className="text-sm text-fg-muted max-w-md">
{this.state.error?.message ?? 'An unexpected error occurred'}
</p>
</div>
<Button variant="secondary" onClick={this.handleReload} className="gap-2">
<RefreshCw className="h-4 w-4" />
Reload page
</Button>
</div>
)
}
return this.props.children
}
}
export { ErrorBoundary }
+30
View File
@@ -0,0 +1,30 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { badgeVariants, type BadgeVariant } from '@/lib/status'
import { statusLabel } from '@/lib/status'
interface BadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {
label?: string
}
const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
({ className, variant, label, children, ...props }, ref) => {
const displayLabel = label ?? (typeof variant === 'string' ? statusLabel(variant) : children)
return (
<span
ref={ref}
className={cn(badgeVariants({ variant }), className)}
{...props}
>
{displayLabel}
</span>
)
}
)
Badge.displayName = 'Badge'
export { Badge, badgeVariants }
export type { BadgeVariant }
+66
View File
@@ -0,0 +1,66 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { Loader2 } from 'lucide-react'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 rounded-card font-medium transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-canvas disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]',
{
variants: {
variant: {
primary:
'bg-accent text-accent-foreground hover:bg-accent-hover shadow-sm hover:shadow-glow',
secondary:
'bg-surface-raised text-fg border border-border hover:bg-surface-hover hover:border-border/80',
ghost:
'text-fg-muted hover:text-fg hover:bg-surface-raised',
danger:
'bg-rose-500/15 text-rose-400 border border-rose-500/30 hover:bg-rose-500/25 hover:border-rose-500/50 hover:shadow-glow-error',
'danger-solid':
'bg-rose-500 text-white hover:bg-rose-600 shadow-sm',
link:
'text-accent underline-offset-4 hover:underline hover:text-accent-hover',
},
size: {
sm: 'h-8 px-3 text-xs',
md: 'h-9 px-4 text-sm',
lg: 'h-11 px-6 text-base',
icon: 'h-9 w-9',
'icon-sm': 'h-8 w-8',
'icon-lg': 'h-10 w-10',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
loading?: boolean
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, loading, disabled, asChild = false, children, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={asChild ? undefined : (disabled || loading)}
{...props}
>
{loading && !asChild && <Loader2 className="h-4 w-4 animate-spin" />}
{children}
</Comp>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }
+75
View File
@@ -0,0 +1,75 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-card border border-border bg-surface shadow-card',
className
)}
{...props}
/>
))
Card.displayName = 'Card'
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col gap-1 p-5', className)}
{...props}
/>
))
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-sm font-semibold text-fg', className)}
{...props}
/>
))
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-xs text-fg-muted', className)}
{...props}
/>
))
CardDescription.displayName = 'CardDescription'
const CardBody = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-5 pt-0', className)} {...props} />
))
CardBody.displayName = 'CardBody'
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center gap-3 p-5 pt-0', className)}
{...props}
/>
))
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardTitle, CardDescription, CardBody, CardFooter }
+55
View File
@@ -0,0 +1,55 @@
import * as React from 'react'
import { Check, Copy } from 'lucide-react'
import { Button } from './Button'
import { cn } from '@/lib/utils'
interface CopyButtonProps
extends Omit<React.ComponentProps<typeof Button>, 'variant' | 'size' | 'children'> {
text: string
displayText?: string
successDuration?: number
}
const CopyButton = React.forwardRef<HTMLButtonElement, CopyButtonProps>(
(
{ className, text, displayText, successDuration = 2000, ...props },
ref
) => {
const [copied, setCopied] = React.useState(false)
const handleCopy = React.useCallback(async () => {
try {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), successDuration)
} catch {
}
}, [text, successDuration])
return (
<Button
ref={ref}
variant="ghost"
size="sm"
onClick={handleCopy}
className={cn('gap-1.5 text-fg-muted hover:text-fg', className)}
{...props}
>
{copied ? (
<>
<Check className="h-3.5 w-3.5 text-emerald-400" />
<span className="text-emerald-400">Copied</span>
</>
) : (
<>
<Copy className="h-3.5 w-3.5" />
<span>{displayText ?? 'Copy'}</span>
</>
)}
</Button>
)
}
)
CopyButton.displayName = 'CopyButton'
export { CopyButton }
+38
View File
@@ -0,0 +1,38 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
interface EmptyStateProps extends React.HTMLAttributes<HTMLDivElement> {
icon?: React.ReactNode
title: string
description?: string
action?: React.ReactNode
}
const EmptyState = React.forwardRef<HTMLDivElement, EmptyStateProps>(
({ className, icon, title, description, action, ...props }, ref) => (
<div
ref={ref}
className={cn(
'flex flex-col items-center justify-center gap-3 py-12 text-center',
className
)}
{...props}
>
{icon && (
<div className="rounded-full bg-surface-raised p-3 text-fg-subtle">
{icon}
</div>
)}
<div className="space-y-1">
<p className="text-sm font-medium text-fg">{title}</p>
{description && (
<p className="text-xs text-fg-muted max-w-xs">{description}</p>
)}
</div>
{action && <div className="mt-1">{action}</div>}
</div>
)
)
EmptyState.displayName = 'EmptyState'
export { EmptyState }
+29
View File
@@ -0,0 +1,29 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
error?: boolean
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, error, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-card border bg-surface-raised px-3 py-2 text-sm text-fg placeholder:text-fg-subtle transition-colors',
'border-border hover:border-border/80',
'focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent/50',
'disabled:cursor-not-allowed disabled:opacity-50',
error && 'border-rose-500/50 focus:ring-rose-500/30 focus:border-rose-500/50',
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export { Input }
+25
View File
@@ -0,0 +1,25 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
required?: boolean
}
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
({ className, required, children, ...props }, ref) => (
<label
ref={ref}
className={cn(
'text-sm font-medium text-fg-muted leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className
)}
{...props}
>
{children}
{required && <span className="ml-1 text-rose-400">*</span>}
</label>
)
)
Label.displayName = 'Label'
export { Label }
+132
View File
@@ -0,0 +1,132 @@
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const Modal = DialogPrimitive.Root
const ModalTrigger = DialogPrimitive.Trigger
const ModalPortal = DialogPrimitive.Portal
const ModalClose = DialogPrimitive.Close
const ModalOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
'data-[state=open]:animate-fade-in',
className
)}
{...props}
/>
))
ModalOverlay.displayName = DialogPrimitive.Overlay.displayName
const ModalContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
size?: 'sm' | 'md' | 'lg' | 'xl'
}
>(({ className, children, size = 'md', ...props }, ref) => {
const sizes = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
}
return (
<ModalPortal>
<ModalOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
'w-full max-h-[90vh] overflow-y-auto rounded-card border border-border bg-surface shadow-card animate-scale-in',
'focus:outline-none',
sizes[size],
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-accent/40 focus:ring-offset-2 focus:ring-offset-surface disabled:pointer-events-none">
<X className="h-4 w-4 text-fg-muted" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</ModalPortal>
)
})
ModalContent.displayName = DialogPrimitive.Content.displayName
const ModalHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col gap-1.5 p-5 pb-0', className)}
{...props}
/>
))
ModalHeader.displayName = 'ModalHeader'
const ModalTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-base font-semibold text-fg', className)}
{...props}
/>
))
ModalTitle.displayName = DialogPrimitive.Title.displayName
const ModalDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-fg-muted', className)}
{...props}
/>
))
ModalDescription.displayName = DialogPrimitive.Description.displayName
const ModalBody = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-5', className)} {...props} />
))
ModalBody.displayName = 'ModalBody'
const ModalFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center justify-end gap-3 p-5 pt-0', className)}
{...props}
/>
))
ModalFooter.displayName = 'ModalFooter'
export {
Modal,
ModalPortal,
ModalOverlay,
ModalTrigger,
ModalClose,
ModalContent,
ModalHeader,
ModalTitle,
ModalDescription,
ModalBody,
ModalFooter,
}
+36
View File
@@ -0,0 +1,36 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
interface PageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
title: string
description?: string
actions?: React.ReactNode
}
const PageHeader = React.forwardRef<HTMLDivElement, PageHeaderProps>(
({ className, title, description, actions, ...props }, ref) => (
<div
ref={ref}
className={cn(
'flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between',
className
)}
{...props}
>
<div className="flex-1 min-w-0">
<h1 className="text-2xl font-bold text-fg tracking-tight">{title}</h1>
{description && (
<p className="mt-1 text-sm text-fg-muted">{description}</p>
)}
</div>
{actions && (
<div className="flex items-center gap-2 mt-3 sm:mt-0 shrink-0">
{actions}
</div>
)}
</div>
)
)
PageHeader.displayName = 'PageHeader'
export { PageHeader }
+95
View File
@@ -0,0 +1,95 @@
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { ChevronDown, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between rounded-card border border-border bg-surface-raised px-3 py-2 text-sm text-fg placeholder:text-fg-subtle transition-colors',
'hover:border-border/80 focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent/50',
'disabled:cursor-not-allowed disabled:opacity-50',
' [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 text-fg-subtle" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-card border border-border bg-surface shadow-card animate-scale-in',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-pointer select-none items-center rounded px-2 py-1.5 text-sm text-fg-muted outline-none',
'hover:bg-surface-raised hover:text-fg',
'focus:bg-surface-raised focus:text-fg',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4 text-accent" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectItem,
}
+34
View File
@@ -0,0 +1,34 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: 'text' | 'circular' | 'rectangular'
width?: string | number
height?: string | number
}
const Skeleton = React.forwardRef<HTMLDivElement, SkeletonProps>(
({ className, variant = 'rectangular', width, height, style, ...props }, ref) => {
const baseClass =
variant === 'circular'
? 'rounded-full'
: variant === 'text'
? 'rounded'
: 'rounded-card'
return (
<div
ref={ref}
className={cn(
'animate-pulse bg-surface-raised',
baseClass,
className
)}
style={{ width, height, ...style }}
{...props}
/>
)
}
)
Skeleton.displayName = 'Skeleton'
export { Skeleton }
+46
View File
@@ -0,0 +1,46 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Spinner = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { size?: 'sm' | 'md' | 'lg' }
>(({ className, size = 'md', ...props }, ref) => {
const sizes = {
sm: 'h-4 w-4',
md: 'h-6 w-6',
lg: 'h-8 w-8',
}
return (
<div
ref={ref}
role="status"
aria-label="Loading"
className={cn('animate-spin text-accent', sizes[size], className)}
{...props}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="h-full w-full"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
</div>
)
})
Spinner.displayName = 'Spinner'
export { Spinner }
+30
View File
@@ -0,0 +1,30 @@
import * as React from 'react'
import * as SwitchPrimitive from '@radix-ui/react-switch'
import { cn } from '@/lib/utils'
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitive.Root
ref={ref}
className={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-canvas',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[state=checked]:bg-accent data-[state=unchecked]:bg-surface-raised data-[state=unchecked]:border-border',
className
)}
{...props}
>
<SwitchPrimitive.Thumb
className={cn(
'pointer-events-none block h-4 w-4 rounded-full bg-fg-subtle shadow-lg ring-0 transition-transform',
'translate-x-0 data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
)}
/>
</SwitchPrimitive.Root>
))
Switch.displayName = SwitchPrimitive.Root.displayName
export { Switch }
+118
View File
@@ -0,0 +1,118 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
))
Table.displayName = 'Table'
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
))
TableHeader.displayName = 'TableHeader'
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
))
TableBody.displayName = 'TableBody'
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
'border-t bg-surface-raised font-medium [&>tr]:last:border-b-0',
className
)}
{...props}
/>
))
TableFooter.displayName = 'TableFooter'
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b border-border/50 transition-colors',
'hover:bg-surface-raised/50',
className
)}
{...props}
/>
))
TableRow.displayName = 'TableRow'
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-10 px-4 text-left align-middle text-xs font-semibold text-fg-muted uppercase tracking-wider',
'[&:has([role=checkbox])]:pr-0',
className
)}
{...props}
/>
))
TableHead.displayName = 'TableHead'
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn('px-4 py-3 align-middle text-sm text-fg', '[&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
))
TableCell.displayName = 'TableCell'
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn('mt-4 text-sm text-fg-muted', className)}
{...props}
/>
))
TableCaption.displayName = 'TableCaption'
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+29
View File
@@ -0,0 +1,29 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
error?: boolean
}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, error, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-card border bg-surface-raised px-3 py-2 text-sm text-fg placeholder:text-fg-subtle transition-colors resize-none',
'border-border hover:border-border/80',
'focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent/50',
'disabled:cursor-not-allowed disabled:opacity-50',
error && 'border-rose-500/50 focus:ring-rose-500/30 focus:border-rose-500/50',
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = 'Textarea'
export { Textarea }
+36
View File
@@ -0,0 +1,36 @@
export { Button, buttonVariants } from './Button'
export { Badge, badgeVariants } from './Badge'
export { Spinner } from './Spinner'
export { Skeleton } from './Skeleton'
export { Card, CardHeader, CardTitle, CardDescription, CardBody, CardFooter } from './Card'
export { Input } from './Input'
export { Label } from './Label'
export { Textarea } from './Textarea'
export { Select, SelectGroup, SelectValue, SelectTrigger, SelectContent, SelectItem } from './Select'
export { Switch } from './Switch'
export {
Modal,
ModalPortal,
ModalOverlay,
ModalTrigger,
ModalClose,
ModalContent,
ModalHeader,
ModalTitle,
ModalDescription,
ModalBody,
ModalFooter,
} from './Modal'
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
} from './Table'
export { EmptyState } from './EmptyState'
export { PageHeader } from './PageHeader'
export { CopyButton } from './CopyButton'
+61
View File
@@ -1,3 +1,64 @@
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
scroll-behavior: smooth;
}
body {
@apply bg-canvas text-fg font-sans;
min-height: 100vh;
}
::selection {
@apply bg-accent/30 text-fg;
}
:focus-visible {
@apply outline-none ring-2 ring-accent/40 ring-offset-2 ring-offset-canvas;
}
input[type='search']::-webkit-search-decoration,
input[type='search']::-webkit-search-cancel-button,
input[type='search']::-webkit-search-results-button,
input[type='search']::-webkit-search-results-decoration {
display: none;
}
}
@layer utilities {
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: #3f3f46 transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: #3f3f46;
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: #52525b;
}
}
+56
View File
@@ -0,0 +1,56 @@
import { cva, type VariantProps } from 'class-variance-authority'
export const badgeVariants = cva(
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors',
{
variants: {
variant: {
running: 'bg-emerald-500/15 text-emerald-400 border border-emerald-500/30',
pending: 'bg-amber-500/15 text-amber-400 border border-amber-500/30',
waking: 'bg-amber-500/15 text-amber-400 border border-amber-500/30',
success: 'bg-emerald-500/15 text-emerald-400 border border-emerald-500/30',
error: 'bg-rose-500/15 text-rose-400 border border-rose-500/30',
info: 'bg-sky-500/15 text-sky-400 border border-sky-500/30',
neutral: 'bg-zinc-500/15 text-zinc-400 border border-zinc-500/30',
},
},
defaultVariants: {
variant: 'neutral',
},
}
)
export type BadgeVariant = VariantProps<typeof badgeVariants>['variant']
const STATUS_TO_VARIANT: Record<string, BadgeVariant> = {
running: 'running',
pending: 'pending',
waking: 'waking',
success: 'success',
error: 'error',
failed: 'error',
completed: 'success',
cancelled: 'neutral',
info: 'info',
unknown: 'neutral',
}
export function statusVariant(status: string): BadgeVariant {
return STATUS_TO_VARIANT[status.toLowerCase()] ?? 'neutral'
}
export function statusLabel(status: string): string {
const labels: Record<string, string> = {
running: 'Running',
pending: 'Pending',
waking: 'Waking',
success: 'Success',
error: 'Error',
failed: 'Failed',
completed: 'Completed',
cancelled: 'Cancelled',
info: 'Info',
unknown: 'Unknown',
}
return labels[status.toLowerCase()] ?? status
}
+33
View File
@@ -0,0 +1,33 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`
const seconds = Math.floor(ms / 1000)
if (seconds < 60) return `${seconds}s`
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`
const hours = Math.floor(minutes / 60)
const remainingMinutes = minutes % 60
return `${hours}h ${remainingMinutes}m`
}
export function formatRelativeTime(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date
const now = new Date()
const diff = now.getTime() - d.getTime()
const seconds = Math.floor(diff / 1000)
if (seconds < 60) return 'just now'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 7) return `${days}d ago`
return d.toLocaleDateString()
}
+142 -56
View File
@@ -1,18 +1,33 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Server, Activity, HardDrive, Clock, Plus } from 'lucide-react';
import { api, Machine, Job } from '../api/client';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Card, CardBody } from '@/components/ui/Card';
import { PageHeader } from '@/components/ui/PageHeader';
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/Table';
import { EmptyState } from '@/components/ui/EmptyState';
import { Skeleton } from '@/components/ui/Skeleton';
import { statusVariant, statusLabel } from '@/lib/status';
import { formatRelativeTime } from '@/lib/utils';
export default function Dashboard() {
const [machines, setMachines] = useState<Machine[]>([]);
const [jobs, setJobs] = useState<Job[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
api<Machine[]>('/api/machines'),
api<Job[]>('/api/jobs?limit=5'),
]).then(([m, j]) => {
])
.then(([m, j]) => {
setMachines(m);
setJobs(j);
}).catch(() => {});
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const online = machines.filter(m => m.status.startsWith('online')).length;
@@ -20,67 +35,138 @@ export default function Dashboard() {
if (!j.started_at) return false;
return j.started_at.startsWith(new Date().toISOString().split('T')[0]);
}).length;
const runningJobs = jobs.filter(j =>
['running', 'queued', 'waking_up'].includes(j.status)
).length;
const kpis = [
{
label: 'Total Machines',
value: machines.length,
icon: Server,
className: 'text-sky-400',
bgClass: 'bg-sky-500/10',
},
{
label: 'Online',
value: online,
icon: Activity,
className: 'text-emerald-400',
bgClass: 'bg-emerald-500/10',
accent: online > 0,
},
{
label: 'Jobs Today',
value: todayJobs,
icon: Clock,
className: 'text-amber-400',
bgClass: 'bg-amber-500/10',
},
{
label: 'Running',
value: runningJobs,
icon: HardDrive,
className: 'text-accent',
bgClass: 'bg-accent/10',
},
];
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
<div className="grid grid-cols-3 gap-4 mb-8">
<div className="bg-gray-800 rounded-lg p-4">
<div className="text-gray-400 text-sm">Machines</div>
<div className="text-3xl font-bold">{machines.length}</div>
</div>
<div className="bg-gray-800 rounded-lg p-4">
<div className="text-gray-400 text-sm">Online</div>
<div className="text-3xl font-bold text-green-500">{online}</div>
</div>
<div className="bg-gray-800 rounded-lg p-4">
<div className="text-gray-400 text-sm">Jobs Today</div>
<div className="text-3xl font-bold text-blue-500">{todayJobs}</div>
<div className="space-y-6">
<PageHeader
title="Dashboard"
description="Overview of your sync infrastructure"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{loading
? Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardBody>
<Skeleton className="h-4 w-20 mb-3" />
<Skeleton className="h-8 w-12" />
</CardBody>
</Card>
))
: kpis.map(kpi => {
const Icon = kpi.icon;
return (
<Card key={kpi.label} className="transition-shadow hover:shadow-card-hover">
<CardBody>
<div className="flex items-start justify-between mb-3">
<span className="text-xs font-medium text-fg-muted uppercase tracking-wider">
{kpi.label}
</span>
<div className={`rounded-card p-1.5 ${kpi.bgClass}`}>
<Icon className={`h-3.5 w-3.5 ${kpi.className}`} />
</div>
</div>
<div className="bg-gray-800 rounded-lg p-4">
<h2 className="text-lg font-semibold mb-3">Recent Jobs</h2>
{jobs.length === 0 ? <p className="text-gray-500">No jobs yet</p> : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-400 border-b border-gray-700">
<th className="pb-2">ID</th>
<th className="pb-2">Sync Pair</th>
<th className="pb-2">Status</th>
<th className="pb-2">Started</th>
</tr>
</thead>
<tbody>
{jobs.map(j => (
<tr key={j.id} className="border-b border-gray-700/50">
<td className="py-2">{j.id}</td>
<td className="py-2">{j.sync_pair_id}</td>
<td className="py-2">
<StatusBadge status={j.status} />
</td>
<td className="py-2">{j.started_at ? new Date(j.started_at).toLocaleString() : '-'}</td>
</tr>
<p className={`text-3xl font-bold ${kpi.className}`}>
{kpi.value}
</p>
</CardBody>
</Card>
);
})}
</div>
<Card>
<div className="p-5 pb-0 flex items-center justify-between">
<h2 className="text-base font-semibold text-fg">Recent Jobs</h2>
<Button variant="ghost" size="sm" asChild>
<Link to="/jobs">View all</Link>
</Button>
</div>
<div className="p-5">
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</tbody>
</table>
</div>
) : jobs.length === 0 ? (
<EmptyState
icon={<HardDrive className="h-5 w-5" />}
title="No jobs yet"
description="Sync pairs will appear here once jobs are executed"
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Sync Pair</TableHead>
<TableHead>Status</TableHead>
<TableHead>Started</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.map(j => (
<TableRow key={j.id}>
<TableCell>
<Link
to={`/jobs/${j.id}`}
className="text-accent hover:text-accent-hover font-mono text-xs"
>
#{j.id}
</Link>
</TableCell>
<TableCell className="text-fg-muted">
Pair {j.sync_pair_id}
</TableCell>
<TableCell>
<Badge variant={statusVariant(j.status)} label={statusLabel(j.status)} />
</TableCell>
<TableCell className="text-fg-muted text-xs">
{j.started_at ? formatRelativeTime(j.started_at) : '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</Card>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
queued: 'bg-gray-600',
waking_up: 'bg-yellow-600',
running: 'bg-blue-600',
success: 'bg-green-600',
failed: 'bg-red-600',
cancelled: 'bg-gray-600',
};
return (
<span className={`${colors[status] || 'bg-gray-600'} text-white text-xs px-2 py-0.5 rounded`}>
{status}
</span>
);
}
+248 -72
View File
@@ -1,6 +1,32 @@
import { useEffect, useState, useRef } from 'react';
import { useEffect, useState, useRef, useCallback } from 'react';
import { useParams, Link } from 'react-router-dom';
import { api, Job, LogLine, SyncPair } from '../api/client';
import { api } from '../api/client';
import type { Job, LogLine, SyncPair } from '../api/client';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Switch } from '@/components/ui/Switch';
import { Card } from '@/components/ui/Card';
import { Spinner } from '@/components/ui/Spinner';
import { CopyButton } from '@/components/ui/CopyButton';
import {
Modal,
ModalContent,
ModalHeader,
ModalTitle,
ModalDescription,
ModalFooter,
} from '@/components/ui/Modal';
import {
ArrowLeft,
Download,
XCircle,
ScrollText,
Terminal,
} from 'lucide-react';
import { toast } from 'sonner';
import { statusVariant, statusLabel } from '@/lib/status';
import { formatDuration } from '@/lib/utils';
import { cn } from '@/lib/utils';
interface SSEEvent {
type: string;
@@ -15,22 +41,26 @@ export default function JobDetail() {
const [job, setJob] = useState<Job | null>(null);
const [pair, setPair] = useState<SyncPair | null>(null);
const [logs, setLogs] = useState<LogLine[]>([]);
const [lines, setLines] = useState<{ stream: string; text: string }[]>([]);
const [liveLines, setLiveLines] = useState<{ stream: string; text: string }[]>([]);
const [autoScroll, setAutoScroll] = useState(true);
const logEndRef = useRef<HTMLDivElement>(null);
const esRef = useRef<EventSource | null>(null);
const jobId = Number(id);
const [loading, setLoading] = useState(true);
const [cancelModal, setCancelModal] = useState(false);
useEffect(() => {
loadJob();
if (jobId) {
loadLogs(0);
const es = new EventSource(`/api/jobs/${jobId}/log/stream?job_id=${jobId}`);
const es = new EventSource(
`/api/jobs/${jobId}/log/stream?job_id=${jobId}`
);
esRef.current = es;
es.onmessage = (e) => {
const evt: SSEEvent = JSON.parse(e.data);
if (evt.type === 'log') {
setLines(prev => [...prev, { stream: evt.stream!, text: evt.line! }]);
setLiveLines(prev => [...prev, { stream: evt.stream!, text: evt.line! }]);
}
if (evt.type === 'status') {
setJob(prev => prev ? { ...prev, status: evt.status! } : prev);
@@ -44,7 +74,7 @@ export default function JobDetail() {
if (autoScroll && logEndRef.current) {
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [lines, autoScroll]);
}, [liveLines, autoScroll]);
async function loadJob() {
try {
@@ -53,12 +83,17 @@ export default function JobDetail() {
const pairs = await api<SyncPair[]>('/api/sync-pairs');
const p = pairs.find((sp: SyncPair) => sp.id === j.sync_pair_id);
setPair(p || null);
} catch {}
} catch {
} finally {
setLoading(false);
}
}
async function loadLogs(offset: number) {
try {
const ls = await api<LogLine[]>(`/api/jobs/${id}/log?offset=${offset}&limit=1000`);
const ls = await api<LogLine[]>(
`/api/jobs/${id}/log?offset=${offset}&limit=1000`
);
if (offset === 0) {
setLogs(ls);
} else {
@@ -68,100 +103,241 @@ export default function JobDetail() {
}
async function cancel() {
if (!confirm('Cancel this job?')) return;
try {
await api(`/api/jobs/${id}/cancel`, { method: 'POST' });
toast.success('Job cancelled');
setCancelModal(false);
loadJob();
} catch { alert('Cancel failed'); }
} catch (e: unknown) {
toast.error((e as Error).message);
}
}
function statusColor(s: string) {
const map: Record<string, string> = {
queued: 'bg-gray-600', waking_up: 'bg-yellow-600', running: 'bg-blue-600',
success: 'bg-green-600', failed: 'bg-red-600', cancelled: 'bg-gray-600',
};
return map[s] || 'bg-gray-600';
function downloadLog() {
const allLines = [
...logs.map(l => `[${l.timestamp}] [${l.stream}] ${l.content}`),
...liveLines.map(l => `[LIVE] [${l.stream}] ${l.text}`),
];
const blob = new Blob([allLines.join('\n')], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `job-${id}.log`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function duration(j: Job) {
if (!j.started_at) return '-';
const start = new Date(j.started_at).getTime();
const end = j.finished_at ? new Date(j.finished_at).getTime() : Date.now();
const secs = Math.round((end - start) / 1000);
if (secs < 60) return `${secs}s`;
const mins = Math.floor(secs / 60);
const rem = secs % 60;
if (mins < 60) return `${mins}m ${rem}s`;
return `${Math.floor(mins / 60)}h ${mins % 60}m`;
const copyLog = useCallback(() => {
const text = [
...logs.map(l => `[${l.timestamp}] [${l.stream}] ${l.content}`),
...liveLines.map(l => `[LIVE] [${l.stream}] ${l.text}`),
].join('\n');
navigator.clipboard.writeText(text);
toast.success('Log copied to clipboard');
}, [logs, liveLines]);
if (loading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<Spinner size="lg" />
</div>
);
}
if (!job) return <div className="p-6 text-gray-400">Loading...</div>;
if (!job) {
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] gap-3">
<p className="text-fg-muted">Job not found</p>
<Button variant="secondary" asChild>
<Link to="/jobs">
<ArrowLeft className="h-4 w-4" />
Back to Jobs
</Link>
</Button>
</div>
);
}
const totalLines = logs.length + liveLines.length;
return (
<div className="p-6 h-screen flex flex-col">
<div className="flex items-center gap-3 mb-4">
<Link to="/jobs" className="text-gray-400 hover:text-white text-sm"> Job History</Link>
<h1 className="text-2xl font-bold">Job #{job.id}</h1>
<span className={`${statusColor(job.status)} text-white text-xs px-2 py-0.5 rounded`}>
{job.status}
</span>
<div className="space-y-4">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" asChild>
<Link to="/jobs">
<ArrowLeft className="h-4 w-4" />
Job History
</Link>
</Button>
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold text-fg font-mono">#{job.id}</h1>
<Badge variant={statusVariant(job.status)} label={statusLabel(job.status)} />
</div>
</div>
<div className="bg-gray-800 rounded-lg p-4 mb-4 grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<div className="text-gray-400 text-xs">Sync Pair</div>
<div className="text-white font-medium">{pair?.name || `Pair ${job.sync_pair_id}`}</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: 'Sync Pair', value: pair?.name || `Pair ${job.sync_pair_id}` },
{ label: 'Trigger', value: job.trigger_type },
{
label: 'Duration',
value: job.started_at
? formatDuration(
(job.finished_at
? new Date(job.finished_at).getTime()
: Date.now()) -
new Date(job.started_at).getTime()
)
: '-',
},
{
label: 'Started',
value: job.started_at
? new Date(job.started_at).toLocaleString()
: '-',
},
].map(item => (
<Card key={item.label}>
<div className="p-4">
<div className="text-xs font-medium text-fg-muted uppercase tracking-wider mb-1">
{item.label}
</div>
<div>
<div className="text-gray-400 text-xs">Trigger</div>
<div className="text-white">{job.trigger_type}</div>
<div className="text-sm font-semibold text-fg truncate">
{item.value}
</div>
<div>
<div className="text-gray-400 text-xs">Duration</div>
<div className="text-white">{duration(job)}</div>
</div>
<div>
<div className="text-gray-400 text-xs">Started</div>
<div className="text-white text-xs">{job.started_at ? new Date(job.started_at).toLocaleString() : '-'}</div>
</div>
</Card>
))}
</div>
{['queued', 'waking_up', 'running'].includes(job.status) && (
<div className="flex gap-2 mb-4">
<button onClick={cancel} className="bg-red-600 hover:bg-red-700 text-white px-4 py-1.5 rounded text-sm">
Cancel
</button>
<label className="flex items-center gap-2 text-gray-400 text-sm cursor-pointer">
<input type="checkbox" checked={autoScroll} onChange={e => setAutoScroll(e.target.checked)} />
<div className="flex items-center gap-3">
<Button
variant="danger"
size="sm"
onClick={() => setCancelModal(true)}
>
<XCircle className="h-4 w-4" />
Cancel Job
</Button>
<div className="flex items-center gap-2 text-sm text-fg-muted">
<Switch
id="auto-scroll"
checked={autoScroll}
onCheckedChange={setAutoScroll}
/>
<label htmlFor="auto-scroll" className="cursor-pointer">
Auto-scroll
</label>
</div>
</div>
)}
<div className="flex-1 bg-gray-900 rounded-lg overflow-hidden flex flex-col min-h-0">
<div className="bg-gray-800 px-4 py-2 flex items-center justify-between">
<span className="text-gray-400 text-xs font-mono">Output</span>
<span className="text-gray-500 text-xs">{lines.length + logs.length} lines</span>
<Card className="flex flex-col min-h-0">
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-fg-subtle" />
<span className="text-xs font-semibold text-fg-muted uppercase tracking-wider">
Output
</span>
<span className="text-xs text-fg-subtle">
{totalLines} line{totalLines !== 1 ? 's' : ''}
</span>
</div>
<div className="flex-1 overflow-y-auto p-4 font-mono text-xs space-y-0.5">
<div className="flex items-center gap-1">
<CopyButton
text={[
...logs.map(l => `[${l.timestamp}] [${l.stream}] ${l.content}`),
...liveLines.map(l => `[LIVE] [${l.stream}] ${l.text}`),
].join('\n')}
displayText="Copy log"
/>
<Button variant="ghost" size="icon-sm" onClick={downloadLog} title="Download log">
<Download className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 font-mono text-xs space-y-0.5 scrollbar-thin min-h-[300px] max-h-[60vh]">
{logs.length === 0 && liveLines.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-fg-subtle gap-2">
<ScrollText className="h-6 w-6" />
<p>No log output yet</p>
</div>
) : (
<>
{logs.map(l => (
<div key={l.id} className={l.stream === 'stderr' ? 'text-red-400' : 'text-gray-300'}>
<span className="text-gray-600 mr-2">{((): string => {
const d = new Date(l.timestamp);
return `${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}:${d.getSeconds().toString().padStart(2,'0')}`;
})()}</span>
{l.content}
</div>
))}
{lines.map((l, i) => (
<div key={`live-${i}`} className={l.stream === 'stderr' ? 'text-red-400' : 'text-gray-300'}>
<span className="text-gray-600 mr-2">LIVE</span>
{l.text}
<LogLine
key={l.id}
stream={l.stream}
content={l.content}
timestamp={l.timestamp}
/>
))}
{liveLines.map((l, i) => (
<div
key={`live-${i}`}
className={cn(
'flex gap-2',
l.stream === 'stderr'
? 'text-rose-400'
: 'text-fg-muted'
)}
>
<span className="text-accent shrink-0">LIVE</span>
<span className="break-all">{l.text}</span>
</div>
))}
</>
)}
<div ref={logEndRef} />
</div>
</div>
</Card>
<Modal open={cancelModal} onOpenChange={setCancelModal}>
<ModalContent size="sm">
<ModalHeader>
<ModalTitle>Cancel Job</ModalTitle>
<ModalDescription>
Are you sure you want to cancel job #{job.id}? This action cannot
be undone.
</ModalDescription>
</ModalHeader>
<ModalFooter>
<Button variant="secondary" onClick={() => setCancelModal(false)}>
Keep Running
</Button>
<Button variant="danger-solid" onClick={cancel}>
Cancel Job
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
);
}
function LogLine({
stream,
content,
timestamp,
}: {
stream: string;
content: string;
timestamp: string;
}) {
const d = new Date(timestamp);
const timeStr = `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}:${d.getSeconds().toString().padStart(2, '0')}`;
return (
<div
className={cn(
'flex gap-2',
stream === 'stderr' ? 'text-rose-400' : 'text-fg-muted'
)}
>
<span className="text-fg-subtle shrink-0">{timeStr}</span>
<span className="break-all">{content}</span>
</div>
);
}
+237 -98
View File
@@ -1,6 +1,25 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api, Job, SyncPair } from '../api/client';
import { Button } from '@/components/ui/Button';
import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/Select';
import { Badge } from '@/components/ui/Badge';
import { PageHeader } from '@/components/ui/PageHeader';
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from '@/components/ui/Table';
import { EmptyState } from '@/components/ui/EmptyState';
import { Skeleton } from '@/components/ui/Skeleton';
import { History, XCircle, ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { statusVariant, statusLabel } from '@/lib/status';
import { formatDuration } from '@/lib/utils';
export default function JobHistory() {
const [jobs, setJobs] = useState<Job[]>([]);
@@ -10,6 +29,7 @@ export default function JobHistory() {
const [filterRange, setFilterRange] = useState('7d');
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [loading, setLoading] = useState(true);
const limit = 50;
useEffect(() => {
@@ -21,6 +41,7 @@ export default function JobHistory() {
}, [filterStatus, filterPair, filterRange, page]);
async function load() {
setLoading(true);
try {
let url = `/api/jobs?limit=${limit}&offset=${page * limit}`;
if (filterStatus) url += `&status=${filterStatus}`;
@@ -40,19 +61,26 @@ export default function JobHistory() {
if (totalCount) setTotal(Number(totalCount));
const data = await res.json();
setJobs(data);
} catch {}
} catch {
} finally {
setLoading(false);
}
}
async function loadPairs() {
try { setPairs(await api<SyncPair[]>('/api/sync-pairs')); } catch {}
try {
setPairs(await api<SyncPair[]>('/api/sync-pairs'));
} catch {}
}
async function cancel(id: number) {
if (!confirm('Cancel this job?')) return;
try {
await api(`/api/jobs/${id}/cancel`, { method: 'POST' });
toast.success('Job cancelled');
load();
} catch { alert('Cancel failed'); }
} catch (e: unknown) {
toast.error((e as Error).message);
}
}
function pairName(id: number) {
@@ -60,110 +88,221 @@ export default function JobHistory() {
return p ? p.name : `Pair ${id}`;
}
function statusColor(s: string) {
const map: Record<string, string> = {
queued: 'bg-gray-600', waking_up: 'bg-yellow-600', running: 'bg-blue-600',
success: 'bg-green-600', failed: 'bg-red-600', cancelled: 'bg-gray-600',
};
return map[s] || 'bg-gray-600';
}
function duration(j: Job) {
if (!j.started_at) return '-';
const start = new Date(j.started_at).getTime();
const end = j.finished_at ? new Date(j.finished_at).getTime() : Date.now();
const secs = Math.round((end - start) / 1000);
if (secs < 60) return `${secs}s`;
const mins = Math.floor(secs / 60);
const rem = secs % 60;
if (mins < 60) return `${mins}m ${rem}s`;
return `${Math.floor(mins / 60)}h ${mins % 60}m`;
}
const totalPages = Math.ceil(total / limit);
const FilterChip = ({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (v: string) => void;
}) => (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="w-auto min-w-[140px]">
<SelectValue placeholder={label} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">{label}</SelectItem>
{label === 'All Pairs' &&
pairs.map(p => (
<SelectItem key={p.id} value={p.id.toString()}>
{p.name}
</SelectItem>
))}
{label === 'All Statuses' &&
[
{ value: 'queued', label: 'Queued' },
{ value: 'waking_up', label: 'Waking Up' },
{ value: 'running', label: 'Running' },
{ value: 'success', label: 'Success' },
{ value: 'failed', label: 'Failed' },
{ value: 'cancelled', label: 'Cancelled' },
].map(s => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
{label === 'Time Range' &&
[
{ value: '24h', label: 'Last 24h' },
{ value: '7d', label: 'Last 7 days' },
{ value: '30d', label: 'Last 30 days' },
{ value: 'all', label: 'All time' },
].map(r => (
<SelectItem key={r.value} value={r.value}>
{r.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">Job History</h1>
<div className="flex items-center gap-3 text-sm">
<select value={filterPair} onChange={e => { setFilterPair(e.target.value); setPage(0); }}
className="bg-gray-700 text-white rounded px-2 py-1.5">
<option value="">All Pairs</option>
{pairs.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<select value={filterStatus} onChange={e => { setFilterStatus(e.target.value); setPage(0); }}
className="bg-gray-700 text-white rounded px-2 py-1.5">
<option value="">All Statuses</option>
<option value="queued">Queued</option>
<option value="waking_up">Waking Up</option>
<option value="running">Running</option>
<option value="success">Success</option>
<option value="failed">Failed</option>
<option value="cancelled">Cancelled</option>
</select>
<select value={filterRange} onChange={e => { setFilterRange(e.target.value); setPage(0); }}
className="bg-gray-700 text-white rounded px-2 py-1.5">
<option value="24h">Last 24h</option>
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="all">All time</option>
</select>
</div>
<div className="space-y-6">
<PageHeader
title="Job History"
description={`${total} job${total !== 1 ? 's' : ''} found`}
actions={
<Button
variant="secondary"
size="sm"
onClick={() => load()}
disabled={loading}
>
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
Refresh
</Button>
}
/>
<div className="flex flex-wrap items-center gap-3">
<FilterChip label="All Pairs" value={filterPair} onChange={v => { setFilterPair(v); setPage(0); }} />
<FilterChip label="All Statuses" value={filterStatus} onChange={v => { setFilterStatus(v); setPage(0); }} />
<FilterChip label="Time Range" value={filterRange} onChange={v => { setFilterRange(v); setPage(0); }} />
{(filterStatus || filterPair || filterRange !== '7d') && (
<Button
variant="ghost"
size="sm"
onClick={() => { setFilterStatus(''); setFilterPair(''); setFilterRange('7d'); setPage(0); }}
className="text-fg-subtle"
>
<XCircle className="h-3.5 w-3.5" />
Clear filters
</Button>
)}
</div>
<div className="bg-gray-800 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-700">
<tr className="text-left text-gray-400">
<th className="p-3">ID</th>
<th className="p-3">Sync Pair</th>
<th className="p-3">Trigger</th>
<th className="p-3">Status</th>
<th className="p-3">Duration</th>
<th className="p-3">Started</th>
<th className="p-3">Finished</th>
<th className="p-3">Actions</th>
</tr>
</thead>
<tbody>
{jobs.map(j => (
<tr key={j.id} className="border-t border-gray-700 hover:bg-gray-750 cursor-pointer"
onClick={() => window.location.href = `/jobs/${j.id}`}>
<td className="p-3 text-blue-400 hover:text-blue-300">
<Link to={`/jobs/${j.id}`}>#{j.id}</Link>
</td>
<td className="p-3">{pairName(j.sync_pair_id)}</td>
<td className="p-3">{j.trigger_type}</td>
<td className="p-3">
<span className={`${statusColor(j.status)} text-white text-xs px-2 py-0.5 rounded`}>
{j.status}
</span>
</td>
<td className="p-3 text-gray-400 text-xs">{duration(j)}</td>
<td className="p-3 text-xs">{j.started_at ? new Date(j.started_at).toLocaleString() : '-'}</td>
<td className="p-3 text-xs">{j.finished_at ? new Date(j.finished_at).toLocaleString() : '-'}</td>
<td className="p-3" onClick={e => e.stopPropagation()}>
{['queued', 'waking_up', 'running'].includes(j.status) && (
<button onClick={() => cancel(j.id)} className="text-red-400 hover:text-red-300 text-xs">Cancel</button>
)}
</td>
</tr>
<Card className="p-0">
{loading ? (
<div className="p-5 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
{jobs.length === 0 && <tr><td colSpan={8} className="p-4 text-center text-gray-500">No jobs</td></tr>}
</tbody>
</table>
</div>
) : jobs.length === 0 ? (
<EmptyState
icon={<History className="h-5 w-5" />}
title="No jobs found"
description={
filterStatus || filterPair || filterRange !== '7d'
? 'Try adjusting your filters'
: 'Sync pairs will appear here once jobs are executed'
}
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Sync Pair</TableHead>
<TableHead>Trigger</TableHead>
<TableHead>Status</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Started</TableHead>
<TableHead>Finished</TableHead>
<TableHead className="w-20">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.map(j => (
<TableRow key={j.id}>
<TableCell>
<Link
to={`/jobs/${j.id}`}
className="text-accent hover:text-accent-hover font-mono text-xs"
>
#{j.id}
</Link>
</TableCell>
<TableCell className="text-fg-muted">
{pairName(j.sync_pair_id)}
</TableCell>
<TableCell>
<span className="text-xs text-fg-muted capitalize">
{j.trigger_type}
</span>
</TableCell>
<TableCell>
<Badge
variant={statusVariant(j.status)}
label={statusLabel(j.status)}
/>
</TableCell>
<TableCell className="text-fg-muted font-mono text-xs">
{j.started_at
? formatDuration(
(j.finished_at
? new Date(j.finished_at).getTime()
: Date.now()) -
new Date(j.started_at).getTime()
)
: '-'}
</TableCell>
<TableCell className="text-fg-muted text-xs">
{j.started_at
? new Date(j.started_at).toLocaleString()
: '-'}
</TableCell>
<TableCell className="text-fg-muted text-xs">
{j.finished_at
? new Date(j.finished_at).toLocaleString()
: '-'}
</TableCell>
<TableCell>
{['queued', 'waking_up', 'running'].includes(j.status) && (
<Button
variant="ghost"
size="icon-sm"
onClick={() => cancel(j.id)}
className="text-rose-400 hover:text-rose-300 hover:bg-rose-500/10"
title="Cancel job"
>
<XCircle className="h-3.5 w-3.5" />
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{totalPages > 1 && (
<div className="bg-gray-800 px-4 py-3 flex items-center justify-between border-t border-gray-700">
<button onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0}
className="text-sm text-gray-400 hover:text-white disabled:opacity-50"> Previous</button>
<span className="text-gray-400 text-sm">{page + 1} / {totalPages} ({total} total)</span>
<button onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} disabled={page >= totalPages - 1}
className="text-sm text-gray-400 hover:text-white disabled:opacity-50">Next </button>
<div className="flex items-center justify-between px-5 py-3 border-t border-border">
<div className="text-xs text-fg-muted">
Page {page + 1} of {totalPages} ({total} total)
</div>
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="icon-sm"
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="secondary"
size="icon-sm"
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
</Card>
</div>
);
}
function Card({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<div className={cn('rounded-card border border-border bg-surface shadow-card', className)}>
{children}
</div>
)
}
+78 -20
View File
@@ -1,15 +1,26 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Database, Eye, EyeOff } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
import { Card, CardBody } from '@/components/ui/Card';
import { toast } from 'sonner';
export default function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
if (!username.trim() || !password.trim()) {
toast.error('Username and password are required');
return;
}
setLoading(true);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
@@ -21,40 +32,87 @@ export default function Login() {
navigate('/');
} else {
const data = await res.json();
setError(data.error || 'Login failed');
toast.error(data.error || 'Login failed');
}
} catch {
setError('Network error');
toast.error('Network error');
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-950">
<form onSubmit={handleSubmit} className="bg-gray-900 p-8 rounded-lg w-80 shadow-xl">
<h1 className="text-2xl font-bold mb-6 text-white">SyncServer</h1>
{error && <div className="bg-red-900 text-red-200 p-2 rounded mb-4 text-sm">{error}</div>}
<div className="mb-4">
<label className="block text-gray-400 text-sm mb-1">Username</label>
<input
<div className="min-h-screen flex items-center justify-center bg-canvas relative overflow-hidden">
<div
className="absolute inset-0 opacity-[0.03]"
style={{
backgroundImage: `radial-gradient(circle at 30% 40%, #2dd4bf 0%, transparent 50%),
radial-gradient(circle at 80% 60%, #2dd4bf 0%, transparent 40%)`,
}}
/>
<div className="relative w-full max-w-sm mx-4">
<div className="flex flex-col items-center mb-8 animate-fade-in">
<div className="rounded-card bg-accent/10 p-3 mb-4">
<Database className="h-8 w-8 text-accent" />
</div>
<h1 className="text-2xl font-bold text-fg tracking-tight">SyncServer</h1>
<p className="text-fg-muted text-sm mt-1">Sign in to your account</p>
</div>
<Card className="animate-scale-in" style={{ animationDelay: '50ms' }}>
<CardBody>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="username" required>
Username
</Label>
<Input
id="username"
type="text"
placeholder="admin"
value={username}
onChange={e => setUsername(e.target.value)}
className="w-full bg-gray-800 text-white rounded px-3 py-2 border border-gray-700 focus:border-blue-500 outline-none"
autoComplete="username"
autoFocus
/>
</div>
<div className="mb-6">
<label className="block text-gray-400 text-sm mb-1">Password</label>
<input
type="password"
<div className="space-y-1.5">
<Label htmlFor="password" required>
Password
</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="••••••••"
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full bg-gray-800 text-white rounded px-3 py-2 border border-gray-700 focus:border-blue-500 outline-none"
autoComplete="current-password"
className="pr-10"
/>
</div>
<button type="submit" className="w-full bg-blue-600 hover:bg-blue-700 text-white rounded py-2 font-medium">
Sign In
<button
type="button"
onClick={() => setShowPassword(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-fg-subtle hover:text-fg-muted transition-colors"
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
<Button type="submit" className="w-full" loading={loading}>
Sign In
</Button>
</form>
</CardBody>
</Card>
</div>
</div>
);
}
+370 -96
View File
@@ -1,18 +1,72 @@
import { useEffect, useState } from 'react';
import { api, Machine, SSHKey } from '../api/client';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/Select';
import { Badge } from '@/components/ui/Badge';
import {
Modal,
ModalContent,
ModalHeader,
ModalTitle,
ModalDescription,
ModalBody,
ModalFooter,
} from '@/components/ui/Modal';
import { PageHeader } from '@/components/ui/PageHeader';
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from '@/components/ui/Table';
import { EmptyState } from '@/components/ui/EmptyState';
import { CopyButton } from '@/components/ui/CopyButton';
import { Card } from '@/components/ui/Card';
import { Pencil, Trash2, Plus, Server } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
type MachineForm = {
id: number | undefined;
name: string;
host: string;
port: number;
ssh_user: string;
ssh_key_id: number | null;
mac_address: string;
wol_enabled: boolean;
wake_timeout_seconds: number;
wake_check_interval_seconds: number;
};
const defaultForm: MachineForm = {
id: undefined,
name: '',
host: '',
port: 22,
ssh_user: 'root',
ssh_key_id: null,
mac_address: '',
wol_enabled: false,
wake_timeout_seconds: 120,
wake_check_interval_seconds: 5,
};
export default function Machines() {
const [machines, setMachines] = useState<Machine[]>([]);
const [sshKeys, setSSHKeys] = useState<SSHKey[]>([]);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({
id: undefined as number | undefined, name: '', host: '', port: 22, ssh_user: 'root',
ssh_key_id: null as number | null,
mac_address: '', wol_enabled: false,
wake_timeout_seconds: 120, wake_check_interval_seconds: 5,
});
const [modalOpen, setModalOpen] = useState(false);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [form, setForm] = useState<MachineForm>(defaultForm);
const [loading, setLoading] = useState(false);
useEffect(() => { load(); }, []);
useEffect(() => {
load();
}, []);
async function load() {
try {
@@ -25,46 +79,78 @@ export default function Machines() {
} catch {}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
try {
const payload: Record<string, unknown> = {
id: form.id || null, name: form.name, host: form.host, port: Number(form.port),
ssh_user: form.ssh_user, ssh_key_id: form.ssh_key_id,
mac_address: form.mac_address || null,
wol_enabled: Boolean(form.wol_enabled),
wake_timeout_seconds: Number(form.wake_timeout_seconds),
wake_check_interval_seconds: Number(form.wake_check_interval_seconds),
};
if (form.mac_address && !/^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$/.test(form.mac_address)) {
alert('Invalid MAC address format');
return;
}
await api(form.id ? `/api/machines/${form.id}` : '/api/machines', {
method: form.id ? 'PUT' : 'POST',
body: payload,
});
setShowForm(false);
setForm({ id: undefined, name: '', host: '', port: 22, ssh_user: 'root', ssh_key_id: null, mac_address: '', wol_enabled: false, wake_timeout_seconds: 120, wake_check_interval_seconds: 5 });
load();
} catch (e: unknown) { alert((e as Error).message); }
function openCreate() {
setForm(defaultForm);
setModalOpen(true);
}
function edit(m: Machine) {
function openEdit(m: Machine) {
setForm({
id: m.id, name: m.name, host: m.host, port: m.port,
ssh_user: m.ssh_user, ssh_key_id: m.ssh_key_id,
id: m.id,
name: m.name,
host: m.host,
port: m.port,
ssh_user: m.ssh_user,
ssh_key_id: m.ssh_key_id,
mac_address: m.mac_address || '',
wol_enabled: m.wol_enabled,
wake_timeout_seconds: m.wake_timeout_seconds,
wake_check_interval_seconds: m.wake_check_interval_seconds,
});
setShowForm(true);
setModalOpen(true);
}
async function remove(id: number) {
if (!confirm('Delete machine?')) return;
try { await api(`/api/machines/${id}`, { method: 'DELETE' }); load(); } catch { alert('Delete failed'); }
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.name.trim() || !form.host.trim()) {
toast.error('Name and host are required');
return;
}
if (
form.mac_address &&
!/^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$/.test(form.mac_address)
) {
toast.error('Invalid MAC address format (AA:BB:CC:DD:EE:FF)');
return;
}
setLoading(true);
try {
const payload: Record<string, unknown> = {
id: form.id || null,
name: form.name,
host: form.host,
port: Number(form.port),
ssh_user: form.ssh_user,
ssh_key_id: form.ssh_key_id,
mac_address: form.mac_address || null,
wol_enabled: Boolean(form.wol_enabled),
wake_timeout_seconds: Number(form.wake_timeout_seconds),
wake_check_interval_seconds: Number(form.wake_check_interval_seconds),
};
await api(form.id ? `/api/machines/${form.id}` : '/api/machines', {
method: form.id ? 'PUT' : 'POST',
body: payload,
});
setModalOpen(false);
toast.success(form.id ? 'Machine updated' : 'Machine created');
load();
} catch (e: unknown) {
toast.error((e as Error).message);
} finally {
setLoading(false);
}
}
async function handleDelete() {
if (deleteId === null) return;
try {
await api(`/api/machines/${deleteId}`, { method: 'DELETE' });
toast.success('Machine deleted');
setDeleteId(null);
load();
} catch (e: unknown) {
toast.error((e as Error).message);
}
}
function keyLabel(id: number | null) {
@@ -74,68 +160,256 @@ export default function Machines() {
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Machines</h1>
<button onClick={() => setShowForm(true)} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">
<div className="space-y-6">
<PageHeader
title="Machines"
description="Remote machines reachable via SSH"
actions={
<Button onClick={openCreate} size="sm">
<Plus className="h-4 w-4" />
Add Machine
</button>
</div>
</Button>
}
/>
{showForm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<form onSubmit={handleSubmit} className="bg-gray-800 p-6 rounded-lg w-[480px] space-y-3">
<h2 className="text-lg font-bold">Machine</h2>
<input placeholder="Name" value={form.name} onChange={e => setForm({...form, name: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required />
<input placeholder="Host / IP" value={form.host} onChange={e => setForm({...form, host: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required />
<input placeholder="SSH Port" type="number" value={form.port} onChange={e => setForm({...form, port: +e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
<input placeholder="SSH User" value={form.ssh_user} onChange={e => setForm({...form, ssh_user: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
<select value={form.ssh_key_id ?? ''} onChange={e => setForm({...form, ssh_key_id: e.target.value ? Number(e.target.value) : null})}
className="w-full bg-gray-700 rounded px-3 py-2 text-white">
<option value="">Server Key (default)</option>
{sshKeys.map(k => <option key={k.id} value={k.id}>{k.label} {k.in_use ? '(in use)' : ''}</option>)}
</select>
<input placeholder="MAC Address (AA:BB:CC:DD:EE:FF)" value={form.mac_address} onChange={e => setForm({...form, mac_address: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
<label className="flex items-center gap-2 text-gray-300">
<input type="checkbox" checked={form.wol_enabled} onChange={e => setForm({...form, wol_enabled: e.target.checked})} />
Enable Wake-on-LAN
</label>
<div className="flex gap-2">
<button type="submit" className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded flex-1">Save</button>
<button type="button" onClick={() => setShowForm(false)} className="bg-gray-600 hover:bg-gray-500 text-white px-4 py-2 rounded">Cancel</button>
</div>
</form>
</div>
)}
<table className="w-full text-sm bg-gray-800 rounded-lg overflow-hidden">
<thead className="bg-gray-700">
<tr className="text-left text-gray-400">
<th className="p-3">Name</th>
<th className="p-3">Host</th>
<th className="p-3">SSH Key</th>
<th className="p-3">WoL</th>
<th className="p-3">Status</th>
<th className="p-3">Actions</th>
</tr>
</thead>
<tbody>
<Card>
<div className="p-0">
{machines.length === 0 ? (
<EmptyState
icon={<Server className="h-5 w-5" />}
title="No machines"
description="Add a remote machine to start syncing data"
action={
<Button onClick={openCreate} size="sm">
<Plus className="h-4 w-4" />
Add Machine
</Button>
}
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Host</TableHead>
<TableHead>SSH Key</TableHead>
<TableHead>WoL</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{machines.map(m => (
<tr key={m.id} className="border-t border-gray-700">
<td className="p-3 font-medium">{m.name}</td>
<td className="p-3">{m.host}:{m.port}</td>
<td className="p-3 text-gray-400 text-xs">{keyLabel(m.ssh_key_id)}</td>
<td className="p-3">{m.wol_enabled ? 'Yes' : 'No'}</td>
<td className="p-3 text-gray-400">{m.status}</td>
<td className="p-3">
<button onClick={() => edit(m)} className="text-blue-400 hover:text-blue-300 mr-3">Edit</button>
<button onClick={() => remove(m.id)} className="text-red-400 hover:text-red-300">Delete</button>
</td>
</tr>
<TableRow key={m.id}>
<TableCell className="font-medium">{m.name}</TableCell>
<TableCell>
<span className="font-mono text-xs text-fg-muted">
{m.host}:{m.port}
</span>
</TableCell>
<TableCell className="text-fg-muted text-xs">
{keyLabel(m.ssh_key_id)}
</TableCell>
<TableCell>
{m.wol_enabled ? (
<Badge variant="info" label="Yes" />
) : (
<span className="text-fg-subtle text-xs">No</span>
)}
</TableCell>
<TableCell>
<StatusBadge status={m.status} />
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
onClick={() => openEdit(m)}
title="Edit"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setDeleteId(m.id)}
className="text-rose-400 hover:text-rose-300 hover:bg-rose-500/10"
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{machines.length === 0 && <tr><td colSpan={6} className="p-4 text-center text-gray-500">No machines</td></tr>}
</tbody>
</table>
</TableBody>
</Table>
)}
</div>
</Card>
<Modal open={modalOpen} onOpenChange={setModalOpen}>
<ModalContent size="md">
<ModalHeader>
<ModalTitle>{form.id ? 'Edit Machine' : 'Add Machine'}</ModalTitle>
<ModalDescription>
{form.id
? 'Update the configuration for this machine'
: 'Configure a new remote machine for syncing'}
</ModalDescription>
</ModalHeader>
<form onSubmit={handleSubmit}>
<ModalBody className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="name" required>
Name
</Label>
<Input
id="name"
placeholder="backup-nas"
value={form.name}
onChange={e => setForm({ ...form, name: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="host">Host / IP</Label>
<Input
id="host"
placeholder="192.168.1.100"
value={form.host}
onChange={e => setForm({ ...form, host: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="port">SSH Port</Label>
<Input
id="port"
type="number"
placeholder="22"
value={form.port}
onChange={e =>
setForm({ ...form, port: Number(e.target.value) })
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="ssh_user">SSH User</Label>
<Input
id="ssh_user"
placeholder="root"
value={form.ssh_user}
onChange={e =>
setForm({ ...form, ssh_user: e.target.value })
}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ssh_key_id">SSH Key</Label>
<Select
value={form.ssh_key_id?.toString() ?? ''}
onValueChange={v =>
setForm({ ...form, ssh_key_id: v ? Number(v) : null })
}
>
<SelectTrigger id="ssh_key_id">
<SelectValue placeholder="Server Key" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Server Key (default)</SelectItem>
{sshKeys.map(k => (
<SelectItem key={k.id} value={k.id.toString()}>
{k.label} {k.in_use ? '(in use)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="mac_address">MAC Address</Label>
<Input
id="mac_address"
placeholder="AA:BB:CC:DD:EE:FF"
value={form.mac_address}
onChange={e =>
setForm({ ...form, mac_address: e.target.value })
}
/>
<p className="text-xs text-fg-subtle">
Required for Wake-on-LAN
</p>
</div>
<div
className={cn(
'flex items-center gap-2 rounded-card p-3 transition-colors',
form.wol_enabled
? 'bg-accent/5 border border-accent/20'
: 'bg-surface-raised border border-border'
)}
>
<input
type="checkbox"
id="wol_enabled"
checked={form.wol_enabled}
onChange={e =>
setForm({ ...form, wol_enabled: e.target.checked })
}
className="h-4 w-4 rounded border-border accent-accent"
/>
<Label htmlFor="wol_enabled" className="cursor-pointer mb-0">
Enable Wake-on-LAN
</Label>
</div>
</ModalBody>
<ModalFooter>
<Button
type="button"
variant="secondary"
onClick={() => setModalOpen(false)}
>
Cancel
</Button>
<Button type="submit" loading={loading}>
{form.id ? 'Save Changes' : 'Add Machine'}
</Button>
</ModalFooter>
</form>
</ModalContent>
</Modal>
<Modal open={deleteId !== null} onOpenChange={v => !v && setDeleteId(null)}>
<ModalContent size="sm">
<ModalHeader>
<ModalTitle>Delete Machine</ModalTitle>
<ModalDescription>
Are you sure you want to delete this machine? This action cannot
be undone.
</ModalDescription>
</ModalHeader>
<ModalFooter>
<Button variant="secondary" onClick={() => setDeleteId(null)}>
Cancel
</Button>
<Button variant="danger-solid" onClick={handleDelete}>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const variant =
status === 'online'
? 'success'
: status === 'offline'
? 'neutral'
: 'info';
return <Badge variant={variant} label={status} />;
}
+277 -84
View File
@@ -1,69 +1,130 @@
import { useEffect, useState, useRef } from 'react';
import { useEffect, useState } from 'react';
import { api, SSHKey } from '../api/client';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
import { Textarea } from '@/components/ui/Textarea';
import { Badge } from '@/components/ui/Badge';
import {
Modal,
ModalContent,
ModalHeader,
ModalTitle,
ModalDescription,
ModalBody,
ModalFooter,
} from '@/components/ui/Modal';
import { PageHeader } from '@/components/ui/PageHeader';
import { Card, CardBody } from '@/components/ui/Card';
import { EmptyState } from '@/components/ui/EmptyState';
import { CopyButton } from '@/components/ui/CopyButton';
import { Spinner } from '@/components/ui/Spinner';
import {
Key,
Plus,
Upload,
Download,
Trash2,
Fingerprint,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
type ModalType = 'generate' | 'import' | 'delete' | null;
export default function SSHKeys() {
const [keys, setKeys] = useState<SSHKey[]>([]);
const [showGen, setShowGen] = useState(false);
const [showImport, setShowImport] = useState(false);
const [modal, setModal] = useState<ModalType>(null);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [genLabel, setGenLabel] = useState('');
const [importLabel, setImportLabel] = useState('');
const [importPubKey, setImportPubKey] = useState('');
const [downloading, setDownloading] = useState<number | null>(null);
const [copied, setCopied] = useState<number | null>(null);
const [loading, setLoading] = useState(false);
const [downloading, setDownloading] = useState<number | null>(null);
useEffect(() => { load(); }, []);
useEffect(() => {
load();
}, []);
async function load() {
try { setKeys(await api<SSHKey[]>('/api/ssh-keys')); } catch {}
try {
setKeys(await api<SSHKey[]>('/api/ssh-keys'));
} catch {}
}
async function generate() {
if (!genLabel.trim()) { alert('Label is required'); return; }
if (!genLabel.trim()) {
toast.error('Label is required');
return;
}
setLoading(true);
try {
await api('/api/ssh-keys', {
method: 'POST',
body: { label: genLabel.trim(), generate: true },
});
setShowGen(false);
setModal(null);
setGenLabel('');
toast.success('SSH key pair generated');
load();
} catch (e: unknown) { alert((e as Error).message); }
finally { setLoading(false); }
} catch (e: unknown) {
toast.error((e as Error).message);
} finally {
setLoading(false);
}
}
async function importKey() {
if (!importLabel.trim()) { alert('Label is required'); return; }
if (!importPubKey.trim()) { alert('Public key is required'); return; }
if (!importLabel.trim()) {
toast.error('Label is required');
return;
}
if (!importPubKey.trim()) {
toast.error('Public key is required');
return;
}
setLoading(true);
try {
await api('/api/ssh-keys', {
method: 'POST',
body: { label: importLabel.trim(), generate: false, public_key: importPubKey.trim() },
body: {
label: importLabel.trim(),
generate: false,
public_key: importPubKey.trim(),
},
});
setShowImport(false);
setModal(null);
setImportLabel('');
setImportPubKey('');
toast.success('SSH key imported');
load();
} catch (e: unknown) { alert((e as Error).message); }
finally { setLoading(false); }
} catch (e: unknown) {
toast.error((e as Error).message);
} finally {
setLoading(false);
}
}
async function remove(id: number) {
if (!confirm('Delete this SSH key? Machines using it will fall back to the server key.')) return;
async function handleDelete() {
if (deleteId === null) return;
try {
await api(`/api/ssh-keys/${id}`, { method: 'DELETE' });
await api(`/api/ssh-keys/${deleteId}`, { method: 'DELETE' });
toast.success('SSH key deleted');
setDeleteId(null);
load();
} catch (e: unknown) { alert((e as Error).message); }
} catch (e: unknown) {
toast.error((e as Error).message);
}
}
async function downloadPrivate(id: number) {
try {
const res = await fetch(`/api/ssh-keys/${id}/private`, { credentials: 'include' });
const res = await fetch(`/api/ssh-keys/${id}/private`, {
credentials: 'include',
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Failed' }));
alert((err as { error: string }).error);
toast.error((err as { error: string }).error);
return;
}
const blob = await res.blob();
@@ -76,88 +137,220 @@ export default function SSHKeys() {
document.body.removeChild(a);
URL.revokeObjectURL(url);
setDownloading(id);
toast.success('Private key downloaded');
setTimeout(() => setDownloading(null), 3000);
} catch (e: unknown) { alert((e as Error).message); }
} catch (e: unknown) {
toast.error((e as Error).message);
}
async function copyPubKey(key: SSHKey) {
await navigator.clipboard.writeText(key.public_key);
setCopied(key.id);
setTimeout(() => setCopied(null), 2000);
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">SSH Keys</h1>
<div className="flex gap-2">
<button onClick={() => setShowGen(true)} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm">
Generate New
</button>
<button onClick={() => setShowImport(true)} className="bg-gray-600 hover:bg-gray-500 text-white px-4 py-2 rounded text-sm">
<div className="space-y-6">
<PageHeader
title="SSH Keys"
description="Manage SSH key pairs for authenticating with remote machines"
actions={
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => setModal('import')}
>
<Upload className="h-4 w-4" />
Import Public Key
</button>
</div>
</Button>
<Button size="sm" onClick={() => setModal('generate')}>
<Plus className="h-4 w-4" />
Generate New
</Button>
</div>
}
/>
{(showGen || showImport) && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-gray-800 p-6 rounded-lg w-[500px] space-y-3">
<h2 className="text-lg font-bold">{showGen ? 'Generate SSH Key Pair' : 'Import Public Key'}</h2>
<input placeholder="Label (e.g. backup-nas)" value={genLabel} onChange={e => setGenLabel(e.target.value)}
className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
{showImport && (
<textarea placeholder="ssh-ed25519 AAAA..." value={importPubKey} onChange={e => setImportPubKey(e.target.value)}
className="w-full bg-gray-700 rounded px-3 py-2 text-white font-mono text-xs h-32" />
)}
<div className="flex gap-2">
<button onClick={showGen ? generate : importKey} disabled={loading}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded flex-1 disabled:opacity-50">
{loading ? 'Working...' : showGen ? 'Generate' : 'Import'}
</button>
<button onClick={() => { setShowGen(false); setShowImport(false); }}
className="bg-gray-600 hover:bg-gray-500 text-white px-4 py-2 rounded">Cancel</button>
{keys.length === 0 ? (
<Card>
<CardBody className="p-0">
<EmptyState
icon={<Key className="h-5 w-5" />}
title="No SSH keys"
description="Generate a key pair or import a public key to authenticate with remote machines"
action={
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => setModal('import')}
>
<Upload className="h-4 w-4" />
Import
</Button>
<Button size="sm" onClick={() => setModal('generate')}>
<Plus className="h-4 w-4" />
Generate
</Button>
</div>
</div>
</div>
)}
}
/>
</CardBody>
</Card>
) : (
<div className="space-y-3">
{keys.map(k => (
<div key={k.id} className="bg-gray-800 rounded-lg p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-white">{k.label}</span>
{k.in_use && <span className="text-xs bg-green-900 text-green-400 px-2 py-0.5 rounded">In Use</span>}
{!k.has_private_key && <span className="text-xs bg-gray-700 text-gray-400 px-2 py-0.5 rounded">Imported Only</span>}
<Card key={k.id}>
<CardBody>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<h3 className="text-sm font-semibold text-fg truncate">
{k.label}
</h3>
{k.in_use && (
<Badge variant="success" label="In Use" />
)}
{!k.has_private_key && (
<Badge variant="neutral" label="Imported Only" />
)}
</div>
<div className="text-xs text-gray-400 mb-2">Fingerprint: {k.fingerprint}</div>
<div className="bg-gray-900 p-2 rounded font-mono text-xs text-green-400 break-all max-w-2xl">
<div className="flex items-center gap-1.5 mb-3 text-xs text-fg-subtle">
<Fingerprint className="h-3.5 w-3.5" />
<span className="font-mono truncate">
{k.fingerprint}
</span>
</div>
<div className="bg-canvas-raised rounded-card p-3 font-mono text-xs text-emerald-400/80 break-all max-w-2xl">
{k.public_key}
</div>
</div>
<div className="flex gap-2 ml-4">
<button onClick={() => copyPubKey(k)}
className="text-gray-400 hover:text-white text-xs px-3 py-1.5 rounded bg-gray-700 hover:bg-gray-600">
{copied === k.id ? 'Copied!' : 'Copy Public'}
</button>
<div className="flex items-center gap-2 shrink-0">
<CopyButton
text={k.public_key}
displayText="Copy pub"
/>
{k.has_private_key && (
<button onClick={() => downloadPrivate(k.id)}
className="text-yellow-400 hover:text-yellow-300 text-xs px-3 py-1.5 rounded bg-gray-700 hover:bg-gray-600">
{downloading === k.id ? 'Downloaded!' : 'Download Private Key'}
</button>
<Button
variant="secondary"
size="sm"
onClick={() => downloadPrivate(k.id)}
loading={downloading === k.id}
>
<Download className="h-3.5 w-3.5" />
{downloading === k.id ? 'Done' : 'Private'}
</Button>
)}
<button onClick={() => remove(k.id)}
className="text-red-400 hover:text-red-300 text-xs px-3 py-1.5 rounded bg-gray-700 hover:bg-gray-600">
Delete
</button>
</div>
<Button
variant="danger"
size="icon-sm"
onClick={() => setDeleteId(k.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</CardBody>
</Card>
))}
{keys.length === 0 && <div className="text-gray-500 text-center py-12">No SSH keys. Generate one or import a public key above.</div>}
</div>
)}
<Modal open={modal === 'generate'} onOpenChange={v => !v && setModal(null)}>
<ModalContent size="md">
<ModalHeader>
<ModalTitle>Generate SSH Key Pair</ModalTitle>
<ModalDescription>
Generate a new Ed25519 key pair. The private key will be
downloaded immediately and the public key stored on the server.
</ModalDescription>
</ModalHeader>
<ModalBody className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="gen-label" required>
Label
</Label>
<Input
id="gen-label"
placeholder="backup-nas"
value={genLabel}
onChange={e => setGenLabel(e.target.value)}
/>
</div>
</ModalBody>
<ModalFooter>
<Button variant="secondary" onClick={() => setModal(null)}>
Cancel
</Button>
<Button onClick={generate} loading={loading}>
<Key className="h-4 w-4" />
Generate
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal open={modal === 'import'} onOpenChange={v => !v && setModal(null)}>
<ModalContent size="md">
<ModalHeader>
<ModalTitle>Import Public Key</ModalTitle>
<ModalDescription>
Import an existing public key. Only the public key will be stored
you must have the corresponding private key on this server.
</ModalDescription>
</ModalHeader>
<ModalBody className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="import-label" required>
Label
</Label>
<Input
id="import-label"
placeholder="work-server"
value={importLabel}
onChange={e => setImportLabel(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="import-pubkey" required>
Public Key
</Label>
<Textarea
id="import-pubkey"
placeholder="ssh-ed25519 AAAA..."
value={importPubKey}
onChange={e => setImportPubKey(e.target.value)}
className="font-mono text-xs h-24"
/>
</div>
</ModalBody>
<ModalFooter>
<Button variant="secondary" onClick={() => setModal(null)}>
Cancel
</Button>
<Button onClick={importKey} loading={loading}>
<Upload className="h-4 w-4" />
Import
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal open={deleteId !== null} onOpenChange={v => !v && setDeleteId(null)}>
<ModalContent size="sm">
<ModalHeader>
<ModalTitle>Delete SSH Key</ModalTitle>
<ModalDescription>
Are you sure you want to delete this SSH key? Machines using it
will fall back to the server key.
</ModalDescription>
</ModalHeader>
<ModalFooter>
<Button variant="secondary" onClick={() => setDeleteId(null)}>
Cancel
</Button>
<Button variant="danger-solid" onClick={handleDelete}>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
);
}
+89 -27
View File
@@ -1,50 +1,112 @@
import { useState, useEffect } from 'react';
import { CopyButton } from '@/components/ui/CopyButton';
import { Button } from '@/components/ui/Button';
import { PageHeader } from '@/components/ui/PageHeader';
import { Card, CardHeader, CardTitle, CardBody } from '@/components/ui/Card';
import { Key, Download, Terminal } from 'lucide-react';
export default function Settings() {
const [pubKey, setPubKey] = useState('');
const [copied, setCopied] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/settings/pubkey', { credentials: 'include' })
.then(r => r.ok ? r.text() : '')
.then(r => (r.ok ? r.text() : ''))
.then(t => setPubKey(t))
.catch(() => {});
.catch(() => {})
.finally(() => setLoading(false));
}, []);
function copyKey() {
navigator.clipboard.writeText(pubKey).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
function downloadPubKey() {
if (!pubKey) return;
const blob = new Blob([pubKey], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'syncserver.pub';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
return (
<div className="p-6 max-w-2xl">
<h1 className="text-2xl font-bold mb-6">Settings</h1>
<div className="space-y-6 max-w-2xl">
<PageHeader
title="Settings"
description="Server configuration and SSH key management"
/>
<div className="bg-gray-800 rounded-lg p-4 mb-6">
<h2 className="text-lg font-semibold mb-3">Server SSH Public Key</h2>
<p className="text-gray-400 text-sm mb-3">
Add this key to the <code className="bg-gray-700 px-1 rounded">~/.ssh/authorized_keys</code> file on your remote machines to allow SyncServer to connect.
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<div className="rounded-card bg-accent/10 p-1">
<Key className="h-4 w-4 text-accent" />
</div>
<CardTitle>Server SSH Public Key</CardTitle>
</div>
</CardHeader>
<CardBody className="space-y-4">
<p className="text-sm text-fg-muted">
Add this key to the{' '}
<code className="bg-surface-raised px-1.5 py-0.5 rounded text-xs text-fg font-mono">
~/.ssh/authorized_keys
</code>{' '}
file on your remote machines to allow SyncServer to connect.
</p>
<div className="bg-gray-900 p-3 rounded font-mono text-xs text-green-400 break-all mb-3">
{pubKey || 'Loading...'}
<div className="bg-canvas-raised rounded-card p-4 font-mono text-xs text-emerald-400/80 break-all min-h-[4rem]">
{loading ? (
<span className="text-fg-subtle">Loading...</span>
) : pubKey ? (
pubKey
) : (
<span className="text-fg-subtle">No public key available</span>
)}
</div>
<div className="flex items-center gap-2">
<CopyButton text={pubKey} displayText="Copy public key" />
{pubKey && (
<Button variant="secondary" size="sm" onClick={downloadPubKey}>
<Download className="h-3.5 w-3.5" />
Download .pub
</Button>
)}
</div>
</CardBody>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<div className="rounded-card bg-surface-raised p-1">
<Terminal className="h-4 w-4 text-fg-muted" />
</div>
<CardTitle>Quick Reference</CardTitle>
</div>
</CardHeader>
<CardBody className="space-y-4">
<div className="space-y-3">
<div>
<p className="text-sm text-fg-muted mb-2">
<span className="font-semibold text-fg">ssh-copy-id:</span> Copy
the public key above to a remote machine:
</p>
<div className="bg-canvas-raised rounded-card p-3 font-mono text-xs text-fg-muted">
cat ~/.ssh/id_ed25519.pub | ssh user@host 'cat &gt;&gt;
~/.ssh/authorized_keys'
</div>
<button onClick={copyKey} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm">
{copied ? 'Copied!' : 'Copy to clipboard'}
</button>
</div>
<div className="bg-gray-800 rounded-lg p-4">
<h2 className="text-lg font-semibold mb-3">Quick Reference</h2>
<div className="text-gray-400 text-sm space-y-2">
<p><strong className="text-white">ssh-copy-id:</strong> Copy the public key above to a remote machine:</p>
<code className="block bg-gray-900 p-2 rounded text-xs">
cat ~/.ssh/id_ed25519.pub | ssh user@host 'cat &gt;&gt; ~/.ssh/authorized_keys'
</code>
<p className="mt-4"><strong className="text-white">Wake-on-LAN:</strong> Make sure your target machine BIOS/UEFI has WoL enabled and is connected to the same network layer (L2) as this server.</p>
<div>
<p className="text-sm text-fg-muted">
<span className="font-semibold text-fg">Wake-on-LAN:</span>{' '}
Make sure your target machine BIOS/UEFI has WoL enabled and is
connected to the same network layer (L2) as this server.
</p>
</div>
</div>
</CardBody>
</Card>
</div>
);
}
+420 -98
View File
@@ -1,18 +1,73 @@
import { useEffect, useState } from 'react';
import { api, SyncPair, Machine } from '../api/client';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
import { Textarea } from '@/components/ui/Textarea';
import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/Select';
import {
Modal,
ModalContent,
ModalHeader,
ModalTitle,
ModalDescription,
ModalBody,
ModalFooter,
} from '@/components/ui/Modal';
import { PageHeader } from '@/components/ui/PageHeader';
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from '@/components/ui/Table';
import { EmptyState } from '@/components/ui/EmptyState';
import { Badge } from '@/components/ui/Badge';
import { Card } from '@/components/ui/Card';
import { Play, Trash2, Plus, GitCompare, ArrowRight, ArrowLeft } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
type SyncPairForm = {
id: number | undefined;
name: string;
source_machine_id: number | null;
source_path: string;
dest_machine_id: number | null;
dest_path: string;
direction: 'push' | 'pull' | 'mirror';
rsync_flags: string;
exclude_patterns: string;
enabled: boolean;
};
const defaultForm: SyncPairForm = {
id: undefined,
name: '',
source_machine_id: null,
source_path: '',
dest_machine_id: null,
dest_path: '',
direction: 'push',
rsync_flags: '-aP',
exclude_patterns: '',
enabled: true,
};
export default function SyncPairs() {
const [pairs, setPairs] = useState<SyncPair[]>([]);
const [machines, setMachines] = useState<Machine[]>([]);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({
id: undefined as number | undefined, name: '', source_machine_id: null as number | null, source_path: '',
dest_machine_id: null as number | null, dest_path: '',
direction: 'push', rsync_flags: '-aP', exclude_patterns: '', enabled: true,
});
const [modalOpen, setModalOpen] = useState(false);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [form, setForm] = useState<SyncPairForm>(defaultForm);
const [running, setRunning] = useState<Record<number, boolean>>({});
const [loading, setLoading] = useState(false);
useEffect(() => { load(); }, []);
useEffect(() => {
load();
}, []);
async function load() {
try {
@@ -25,41 +80,86 @@ export default function SyncPairs() {
} catch {}
}
function openCreate() {
setForm(defaultForm);
setModalOpen(true);
}
function openEdit(p: SyncPair) {
setForm({
id: p.id,
name: p.name,
source_machine_id: p.source_machine_id,
source_path: p.source_path,
dest_machine_id: p.dest_machine_id,
dest_path: p.dest_path,
direction: p.direction as 'push' | 'pull' | 'mirror',
rsync_flags: p.rsync_flags,
exclude_patterns: p.exclude_patterns || '',
enabled: p.enabled,
});
setModalOpen(true);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!form.name.trim()) {
toast.error('Name is required');
return;
}
if (!form.source_path.trim() || !form.dest_path.trim()) {
toast.error('Source and destination paths are required');
return;
}
setLoading(true);
try {
const payload = {
name: form.name, source_machine_id: form.source_machine_id, source_path: form.source_path,
dest_machine_id: form.dest_machine_id, dest_path: form.dest_path,
direction: form.direction, rsync_flags: form.rsync_flags,
exclude_patterns: form.exclude_patterns, enabled: form.enabled,
};
await api(form.id ? `/api/sync-pairs/${form.id}` : '/api/sync-pairs', {
method: form.id ? 'PUT' : 'POST',
body: payload,
body: {
name: form.name,
source_machine_id: form.source_machine_id,
source_path: form.source_path,
dest_machine_id: form.dest_machine_id,
dest_path: form.dest_path,
direction: form.direction,
rsync_flags: form.rsync_flags,
exclude_patterns: form.exclude_patterns,
enabled: form.enabled,
},
});
setShowForm(false);
resetForm();
setModalOpen(false);
toast.success(form.id ? 'Sync pair updated' : 'Sync pair created');
load();
} catch (e: unknown) { alert((e as Error).message); }
} catch (e: unknown) {
toast.error((e as Error).message);
} finally {
setLoading(false);
}
}
async function trigger(pairId: number) {
setRunning(r => ({ ...r, [pairId]: true }));
try {
await api(`/api/sync-pairs/${pairId}/run`, { method: 'POST' });
toast.success('Job triggered');
load();
} catch (e: unknown) { alert((e as Error).message); }
} catch (e: unknown) {
toast.error((e as Error).message);
} finally {
setRunning(r => ({ ...r, [pairId]: false }));
}
async function remove(id: number) {
if (!confirm('Delete sync pair?')) return;
try { await api(`/api/sync-pairs/${id}`, { method: 'DELETE' }); load(); } catch { alert('Delete failed'); }
}
function resetForm() {
setForm({ id: undefined, name: '', source_machine_id: null, source_path: '', dest_machine_id: null, dest_path: '', direction: 'push', rsync_flags: '-aP', exclude_patterns: '', enabled: true });
async function handleDelete() {
if (deleteId === null) return;
try {
await api(`/api/sync-pairs/${deleteId}`, { method: 'DELETE' });
toast.success('Sync pair deleted');
setDeleteId(null);
load();
} catch (e: unknown) {
toast.error((e as Error).message);
}
}
function machineName(id: number | null) {
@@ -68,85 +168,307 @@ export default function SyncPairs() {
return m ? m.name : `Machine ${id}`;
}
const directionIcon = (dir: string) => {
if (dir === 'push') return <ArrowRight className="h-3 w-3" />
if (dir === 'pull') return <ArrowLeft className="h-3 w-3" />
return <GitCompare className="h-3 w-3" />
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Sync Pairs</h1>
<button onClick={() => setShowForm(true)} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">
<div className="space-y-6">
<PageHeader
title="Sync Pairs"
description="Define source and destination for rsync operations"
actions={
<Button onClick={openCreate} size="sm">
<Plus className="h-4 w-4" />
Add Sync Pair
</button>
</div>
</Button>
}
/>
{showForm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<form onSubmit={handleSubmit} className="bg-gray-800 p-6 rounded-lg w-[500px] space-y-3 max-h-[90vh] overflow-y-auto">
<h2 className="text-lg font-bold">Sync Pair</h2>
<input placeholder="Name" value={form.name} onChange={e => setForm({...form, name: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required />
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-gray-400 text-xs">Source Machine</label>
<select value={form.source_machine_id ?? ''} onChange={e => setForm({...form, source_machine_id: e.target.value ? +e.target.value : null })} className="w-full bg-gray-700 rounded px-3 py-2 text-white">
<option value="">Local server</option>
{machines.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
</div>
<div>
<label className="text-gray-400 text-xs">Dest Machine</label>
<select value={form.dest_machine_id ?? ''} onChange={e => setForm({...form, dest_machine_id: e.target.value ? +e.target.value : null })} className="w-full bg-gray-700 rounded px-3 py-2 text-white">
<option value="">Local server</option>
{machines.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<input placeholder="Source Path" value={form.source_path} onChange={e => setForm({...form, source_path: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required />
<input placeholder="Dest Path" value={form.dest_path} onChange={e => setForm({...form, dest_path: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" required />
</div>
<div className="grid grid-cols-2 gap-3">
<select value={form.direction} onChange={e => setForm({...form, direction: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white">
<option value="push">Push</option>
<option value="pull">Pull</option>
<option value="mirror">Mirror</option>
</select>
<input placeholder="Rsync Flags" value={form.rsync_flags} onChange={e => setForm({...form, rsync_flags: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white" />
</div>
<textarea placeholder="Exclude Patterns (one per line)" value={form.exclude_patterns} onChange={e => setForm({...form, exclude_patterns: e.target.value})} className="w-full bg-gray-700 rounded px-3 py-2 text-white font-mono text-sm" rows={3} />
<div className="flex gap-2">
<button type="submit" className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded flex-1">Save</button>
<button type="button" onClick={() => { setShowForm(false); resetForm(); }} className="bg-gray-600 hover:bg-gray-500 text-white px-4 py-2 rounded">Cancel</button>
</div>
</form>
</div>
)}
<table className="w-full text-sm bg-gray-800 rounded-lg overflow-hidden">
<thead className="bg-gray-700">
<tr className="text-left text-gray-400">
<th className="p-3">Name</th>
<th className="p-3">Source</th>
<th className="p-3">Dest</th>
<th className="p-3">Direction</th>
<th className="p-3">Actions</th>
</tr>
</thead>
<tbody>
<Card>
<div className="p-0">
{pairs.length === 0 ? (
<EmptyState
icon={<GitCompare className="h-5 w-5" />}
title="No sync pairs"
description="Create a sync pair to define data transfer between machines"
action={
<Button onClick={openCreate} size="sm">
<Plus className="h-4 w-4" />
Add Sync Pair
</Button>
}
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Source</TableHead>
<TableHead>Destination</TableHead>
<TableHead>Direction</TableHead>
<TableHead>Enabled</TableHead>
<TableHead className="w-28">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pairs.map(p => (
<tr key={p.id} className="border-t border-gray-700">
<td className="p-3 font-medium">{p.name}</td>
<td className="p-3 font-mono text-xs">{machineName(p.source_machine_id)}:{p.source_path}</td>
<td className="p-3 font-mono text-xs">{machineName(p.dest_machine_id)}:{p.dest_path}</td>
<td className="p-3">{p.direction}</td>
<td className="p-3">
<button onClick={() => trigger(p.id)} disabled={running[p.id]} className="text-green-400 hover:text-green-300 mr-3 disabled:opacity-50">
{running[p.id] ? 'Running...' : 'Run'}
</button>
<button onClick={() => remove(p.id)} className="text-red-400 hover:text-red-300">Delete</button>
</td>
</tr>
<TableRow key={p.id}>
<TableCell className="font-medium">{p.name}</TableCell>
<TableCell>
<span className="font-mono text-xs text-fg-muted">
{machineName(p.source_machine_id)}:{p.source_path}
</span>
</TableCell>
<TableCell>
<span className="font-mono text-xs text-fg-muted">
{machineName(p.dest_machine_id)}:{p.dest_path}
</span>
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
{directionIcon(p.direction)}
<span className="text-xs capitalize">{p.direction}</span>
</div>
</TableCell>
<TableCell>
{p.enabled ? (
<Badge variant="success" label="Active" />
) : (
<Badge variant="neutral" label="Disabled" />
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
onClick={() => trigger(p.id)}
disabled={running[p.id]}
loading={running[p.id]}
className="text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/10"
title="Run now"
>
<Play className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setDeleteId(p.id)}
className="text-rose-400 hover:text-rose-300 hover:bg-rose-500/10"
title="Delete"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{pairs.length === 0 && <tr><td colSpan={5} className="p-4 text-center text-gray-500">No sync pairs</td></tr>}
</tbody>
</table>
</TableBody>
</Table>
)}
</div>
</Card>
<Modal open={modalOpen} onOpenChange={setModalOpen}>
<ModalContent size="lg">
<ModalHeader>
<ModalTitle>{form.id ? 'Edit Sync Pair' : 'Add Sync Pair'}</ModalTitle>
<ModalDescription>
{form.id
? 'Update the configuration for this sync pair'
: 'Define a new source and destination for data syncing'}
</ModalDescription>
</ModalHeader>
<form onSubmit={handleSubmit}>
<ModalBody className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="sp-name" required>
Name
</Label>
<Input
id="sp-name"
placeholder="backup-photos"
value={form.name}
onChange={e => setForm({ ...form, name: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="sp-source-machine">Source Machine</Label>
<Select
value={form.source_machine_id?.toString() ?? ''}
onValueChange={v =>
setForm({ ...form, source_machine_id: v ? Number(v) : null })
}
>
<SelectTrigger id="sp-source-machine">
<SelectValue placeholder="Local server" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Local server</SelectItem>
{machines.map(m => (
<SelectItem key={m.id} value={m.id.toString()}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="sp-dest-machine">Dest Machine</Label>
<Select
value={form.dest_machine_id?.toString() ?? ''}
onValueChange={v =>
setForm({ ...form, dest_machine_id: v ? Number(v) : null })
}
>
<SelectTrigger id="sp-dest-machine">
<SelectValue placeholder="Local server" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Local server</SelectItem>
{machines.map(m => (
<SelectItem key={m.id} value={m.id.toString()}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="sp-source-path" required>
Source Path
</Label>
<Input
id="sp-source-path"
placeholder="/data/photos"
value={form.source_path}
onChange={e =>
setForm({ ...form, source_path: e.target.value })
}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="sp-dest-path" required>
Dest Path
</Label>
<Input
id="sp-dest-path"
placeholder="/backup/photos"
value={form.dest_path}
onChange={e =>
setForm({ ...form, dest_path: e.target.value })
}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="sp-direction">Direction</Label>
<Select
value={form.direction}
onValueChange={(v: 'push' | 'pull' | 'mirror') =>
setForm({ ...form, direction: v })
}
>
<SelectTrigger id="sp-direction">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="push">Push</SelectItem>
<SelectItem value="pull">Pull</SelectItem>
<SelectItem value="mirror">Mirror</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="sp-rsync-flags">Rsync Flags</Label>
<Input
id="sp-rsync-flags"
placeholder="-aP"
value={form.rsync_flags}
onChange={e =>
setForm({ ...form, rsync_flags: e.target.value })
}
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="sp-exclude">Exclude Patterns</Label>
<Textarea
id="sp-exclude"
placeholder="node_modules&#10;.git&#10;*.tmp"
value={form.exclude_patterns}
onChange={e =>
setForm({ ...form, exclude_patterns: e.target.value })
}
className="font-mono text-xs"
rows={3}
/>
<p className="text-xs text-fg-subtle">
One pattern per line
</p>
</div>
<div
className={cn(
'flex items-center gap-2 rounded-card p-3 transition-colors',
form.enabled
? 'bg-accent/5 border border-accent/20'
: 'bg-surface-raised border border-border'
)}
>
<input
type="checkbox"
id="sp-enabled"
checked={form.enabled}
onChange={e => setForm({ ...form, enabled: e.target.checked })}
className="h-4 w-4 rounded border-border accent-accent"
/>
<Label htmlFor="sp-enabled" className="cursor-pointer mb-0">
Enable this sync pair
</Label>
</div>
</ModalBody>
<ModalFooter>
<Button
type="button"
variant="secondary"
onClick={() => setModalOpen(false)}
>
Cancel
</Button>
<Button type="submit" loading={loading}>
{form.id ? 'Save Changes' : 'Add Sync Pair'}
</Button>
</ModalFooter>
</form>
</ModalContent>
</Modal>
<Modal open={deleteId !== null} onOpenChange={v => !v && setDeleteId(null)}>
<ModalContent size="sm">
<ModalHeader>
<ModalTitle>Delete Sync Pair</ModalTitle>
<ModalDescription>
Are you sure you want to delete this sync pair? All associated
job history will remain.
</ModalDescription>
</ModalHeader>
<ModalFooter>
<Button variant="secondary" onClick={() => setDeleteId(null)}>
Cancel
</Button>
<Button variant="danger-solid" onClick={handleDelete}>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
);
}
+71 -1
View File
@@ -1,8 +1,78 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
extend: {},
extend: {
colors: {
canvas: {
DEFAULT: '#080808',
raised: '#121212',
},
surface: {
DEFAULT: '#0f0f0f',
hover: '#1a1a1a',
raised: '#1a1a1a',
border: '#262626',
},
accent: {
DEFAULT: '#2dd4bf',
hover: '#5eead4',
muted: '#0d9488',
foreground: '#0f0f0f',
},
fg: {
DEFAULT: '#f5f5f5',
muted: '#a3a3a3',
subtle: '#737373',
},
border: {
DEFAULT: '#262626',
subtle: '#1f1f1f',
},
status: {
running: '#10b981',
pending: '#fbbf24',
waking: '#fbbf24',
success: '#10b981',
error: '#f43f5e',
info: '#38bdf8',
neutral: '#71717a',
},
},
fontFamily: {
sans: ['"IBM Plex Sans"', 'ui-sans-serif', 'system-ui', 'sans-serif'],
mono: ['"JetBrains Mono"', 'ui-monospace', 'SFMono-Regular', 'monospace'],
},
boxShadow: {
card: '0 0 0 1px rgba(38, 38, 38, 1), 0 2px 8px rgba(0, 0, 0, 0.4)',
'card-hover': '0 0 0 1px rgba(45, 212, 191, 0.3), 0 4px 16px rgba(0, 0, 0, 0.5)',
glow: '0 0 20px rgba(45, 212, 191, 0.15)',
'glow-error': '0 0 20px rgba(244, 63, 94, 0.15)',
},
borderRadius: {
card: '0.5rem',
},
animation: {
'fade-in': 'fadeIn 200ms ease-out',
'scale-in': 'scaleIn 200ms ease-out',
'slide-up': 'slideUp 200ms ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
scaleIn: {
'0%': { opacity: '0', transform: 'scale(0.95)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
slideUp: {
'0%': { opacity: '0', transform: 'translateY(8px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
},
},
},
plugins: [],
}
+5 -1
View File
@@ -14,7 +14,11 @@
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
+6
View File
@@ -1,9 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
base: './',
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: {
outDir: 'dist',
emptyOutDir: true,