add send cmd page
This commit is contained in:
		
							parent
							
								
									328d499dca
								
							
						
					
					
						commit
						8f41579972
					
				
							
								
								
									
										90
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										90
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| 
						 | 
				
			
			@ -13,7 +13,7 @@
 | 
			
		|||
        "@radix-ui/react-slot": "^1.2.3",
 | 
			
		||||
        "@radix-ui/react-tooltip": "^1.2.7",
 | 
			
		||||
        "@tailwindcss/vite": "^4.1.11",
 | 
			
		||||
        "@tanstack/react-form": "^1.15.0",
 | 
			
		||||
        "@tanstack/react-form": "^1.23.0",
 | 
			
		||||
        "@tanstack/react-query": "^5.83.0",
 | 
			
		||||
        "@tanstack/react-router": "^1.121.2",
 | 
			
		||||
        "@tanstack/react-router-devtools": "^1.121.2",
 | 
			
		||||
| 
						 | 
				
			
			@ -31,6 +31,7 @@
 | 
			
		|||
        "sonner": "^2.0.7",
 | 
			
		||||
        "tailwind-merge": "^3.3.1",
 | 
			
		||||
        "tailwindcss": "^4.1.11",
 | 
			
		||||
        "zod": "^3.25.76",
 | 
			
		||||
        "zustand": "^5.0.6"
 | 
			
		||||
      },
 | 
			
		||||
      "devDependencies": {
 | 
			
		||||
| 
						 | 
				
			
			@ -2385,12 +2386,26 @@
 | 
			
		|||
        "vite": "^5.2.0 || ^6 || ^7"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@tanstack/devtools-event-client": {
 | 
			
		||||
      "version": "0.2.5",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.2.5.tgz",
 | 
			
		||||
      "integrity": "sha512-iVdqw879KETXyyPHc3gQR5Ld0GjlPLk7bKenBUhzr3+z1FiQZvsbfgYfRRokTSPcgwANAV7aA2Uv05nx5xWT8A==",
 | 
			
		||||
      "engines": {
 | 
			
		||||
        "node": ">=18"
 | 
			
		||||
      },
 | 
			
		||||
      "funding": {
 | 
			
		||||
        "type": "github",
 | 
			
		||||
        "url": "https://github.com/sponsors/tannerlinsley"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@tanstack/form-core": {
 | 
			
		||||
      "version": "1.15.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.15.0.tgz",
 | 
			
		||||
      "integrity": "sha512-zMNyxb/J/JnFmW4Gzb1TSxaXmwNhvsaF9p3dGRpE93TMGp2ojPKK7V5LZ43ZV7iFTYWTL8NOIU8ZXuf9qZVkmw==",
 | 
			
		||||
      "version": "1.23.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.23.0.tgz",
 | 
			
		||||
      "integrity": "sha512-aBJnO5iA1TtMK/SSpAvd/y6cVsBgbu5Br2FftvW6r7TkHCcZ/lB2DMx3G2dk6SG7sW0g8s6F7iaMjrvqBGooDw==",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@tanstack/store": "^0.7.2"
 | 
			
		||||
        "@tanstack/devtools-event-client": "^0.2.5",
 | 
			
		||||
        "@tanstack/store": "^0.7.5",
 | 
			
		||||
        "uuid": "^13.0.0"
 | 
			
		||||
      },
 | 
			
		||||
      "funding": {
 | 
			
		||||
        "type": "github",
 | 
			
		||||
| 
						 | 
				
			
			@ -2419,30 +2434,26 @@
 | 
			
		|||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@tanstack/react-form": {
 | 
			
		||||
      "version": "1.15.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.15.0.tgz",
 | 
			
		||||
      "integrity": "sha512-bAawFDxR1wLn+eXli6MSyS4Nw0vTyHuW3CybjZGtk7NIcYxyaAm9cW/jPUX2j/KMDtjVNg5RMpfmufxDrsNHyA==",
 | 
			
		||||
      "version": "1.23.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.23.0.tgz",
 | 
			
		||||
      "integrity": "sha512-wZ/cBjqj5dgWG79TQOsilbs8WzdouJj3iXw1DAZvQ4nejobsTGl0huU7SZZ0mnFtdNHbre1bFSdWyw+GqFcIhQ==",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@tanstack/form-core": "1.15.0",
 | 
			
		||||
        "@tanstack/react-store": "^0.7.3",
 | 
			
		||||
        "@tanstack/form-core": "1.23.0",
 | 
			
		||||
        "@tanstack/react-store": "^0.7.5",
 | 
			
		||||
        "decode-formdata": "^0.9.0",
 | 
			
		||||
        "devalue": "^5.1.1"
 | 
			
		||||
        "devalue": "^5.3.2"
 | 
			
		||||
      },
 | 
			
		||||
      "funding": {
 | 
			
		||||
        "type": "github",
 | 
			
		||||
        "url": "https://github.com/sponsors/tannerlinsley"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependencies": {
 | 
			
		||||
        "@tanstack/react-start": "^1.112.0",
 | 
			
		||||
        "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
 | 
			
		||||
        "vinxi": "^0.5.0"
 | 
			
		||||
        "@tanstack/react-start": "^1.130.10",
 | 
			
		||||
        "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
 | 
			
		||||
      },
 | 
			
		||||
      "peerDependenciesMeta": {
 | 
			
		||||
        "@tanstack/react-start": {
 | 
			
		||||
          "optional": true
 | 
			
		||||
        },
 | 
			
		||||
        "vinxi": {
 | 
			
		||||
          "optional": true
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
| 
						 | 
				
			
			@ -2506,11 +2517,11 @@
 | 
			
		|||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@tanstack/react-store": {
 | 
			
		||||
      "version": "0.7.3",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.7.3.tgz",
 | 
			
		||||
      "integrity": "sha512-3Dnqtbw9P2P0gw8uUM8WP2fFfg8XMDSZCTsywRPZe/XqqYW8PGkXKZTvP0AHkE4mpqP9Y43GpOg9vwO44azu6Q==",
 | 
			
		||||
      "version": "0.7.7",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.7.7.tgz",
 | 
			
		||||
      "integrity": "sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@tanstack/store": "0.7.2",
 | 
			
		||||
        "@tanstack/store": "0.7.7",
 | 
			
		||||
        "use-sync-external-store": "^1.5.0"
 | 
			
		||||
      },
 | 
			
		||||
      "funding": {
 | 
			
		||||
| 
						 | 
				
			
			@ -2685,9 +2696,9 @@
 | 
			
		|||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/@tanstack/store": {
 | 
			
		||||
      "version": "0.7.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.7.2.tgz",
 | 
			
		||||
      "integrity": "sha512-RP80Z30BYiPX2Pyo0Nyw4s1SJFH2jyM6f9i3HfX4pA+gm5jsnYryscdq2aIQLnL4TaGuQMO+zXmN9nh1Qck+Pg==",
 | 
			
		||||
      "version": "0.7.7",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.7.7.tgz",
 | 
			
		||||
      "integrity": "sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==",
 | 
			
		||||
      "funding": {
 | 
			
		||||
        "type": "github",
 | 
			
		||||
        "url": "https://github.com/sponsors/tannerlinsley"
 | 
			
		||||
| 
						 | 
				
			
			@ -3215,9 +3226,9 @@
 | 
			
		|||
      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/axios": {
 | 
			
		||||
      "version": "1.11.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
 | 
			
		||||
      "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
 | 
			
		||||
      "version": "1.12.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
 | 
			
		||||
      "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "follow-redirects": "^1.15.6",
 | 
			
		||||
        "form-data": "^4.0.4",
 | 
			
		||||
| 
						 | 
				
			
			@ -3897,9 +3908,9 @@
 | 
			
		|||
      "license": "MIT"
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/devalue": {
 | 
			
		||||
      "version": "5.1.1",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz",
 | 
			
		||||
      "integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw=="
 | 
			
		||||
      "version": "5.3.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.3.2.tgz",
 | 
			
		||||
      "integrity": "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw=="
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/diff": {
 | 
			
		||||
      "version": "8.0.2",
 | 
			
		||||
| 
						 | 
				
			
			@ -7232,6 +7243,18 @@
 | 
			
		|||
      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
 | 
			
		||||
      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/uuid": {
 | 
			
		||||
      "version": "13.0.0",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
 | 
			
		||||
      "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
 | 
			
		||||
      "funding": [
 | 
			
		||||
        "https://github.com/sponsors/broofa",
 | 
			
		||||
        "https://github.com/sponsors/ctavan"
 | 
			
		||||
      ],
 | 
			
		||||
      "bin": {
 | 
			
		||||
        "uuid": "dist-node/bin/uuid"
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/vary": {
 | 
			
		||||
      "version": "1.1.2",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
 | 
			
		||||
| 
						 | 
				
			
			@ -7241,10 +7264,9 @@
 | 
			
		|||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "node_modules/vite": {
 | 
			
		||||
      "version": "6.3.5",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
 | 
			
		||||
      "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
 | 
			
		||||
      "license": "MIT",
 | 
			
		||||
      "version": "6.3.6",
 | 
			
		||||
      "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
 | 
			
		||||
      "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "esbuild": "^0.25.0",
 | 
			
		||||
        "fdir": "^6.4.4",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -17,7 +17,7 @@
 | 
			
		|||
    "@radix-ui/react-slot": "^1.2.3",
 | 
			
		||||
    "@radix-ui/react-tooltip": "^1.2.7",
 | 
			
		||||
    "@tailwindcss/vite": "^4.1.11",
 | 
			
		||||
    "@tanstack/react-form": "^1.15.0",
 | 
			
		||||
    "@tanstack/react-form": "^1.23.0",
 | 
			
		||||
    "@tanstack/react-query": "^5.83.0",
 | 
			
		||||
    "@tanstack/react-router": "^1.121.2",
 | 
			
		||||
    "@tanstack/react-router-devtools": "^1.121.2",
 | 
			
		||||
| 
						 | 
				
			
			@ -35,6 +35,7 @@
 | 
			
		|||
    "sonner": "^2.0.7",
 | 
			
		||||
    "tailwind-merge": "^3.3.1",
 | 
			
		||||
    "tailwindcss": "^4.1.11",
 | 
			
		||||
    "zod": "^3.25.76",
 | 
			
		||||
    "zustand": "^5.0.6"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										90
									
								
								src/components/command-form.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								src/components/command-form.tsx
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,90 @@
 | 
			
		|||
"use client";
 | 
			
		||||
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
import { useForm } from "@tanstack/react-form";
 | 
			
		||||
import { z } from "zod";
 | 
			
		||||
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Textarea } from "@/components/ui/textarea";
 | 
			
		||||
import {
 | 
			
		||||
  Card,
 | 
			
		||||
  CardContent,
 | 
			
		||||
  CardDescription,
 | 
			
		||||
  CardHeader,
 | 
			
		||||
  CardTitle,
 | 
			
		||||
} from "@/components/ui/card";
 | 
			
		||||
import { Badge } from "@/components/ui/badge";
 | 
			
		||||
import { Alert, AlertDescription } from "@/components/ui/alert";
 | 
			
		||||
import { Loader2, Terminal, AlertTriangle, CheckCircle } from "lucide-react";
 | 
			
		||||
 | 
			
		||||
interface ShellCommandFormProps {
 | 
			
		||||
  onExecute: (command: string) => Promise<{ success: boolean; output: string }>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function ShellCommandForm({ onExecute }: ShellCommandFormProps) {
 | 
			
		||||
  const [isLoading, setIsLoading] = useState(false);
 | 
			
		||||
 | 
			
		||||
  // init form
 | 
			
		||||
  const form = useForm({
 | 
			
		||||
    defaultValues: {
 | 
			
		||||
      command: "",
 | 
			
		||||
    },
 | 
			
		||||
    onSubmit: async ({ value }) => {
 | 
			
		||||
      setIsLoading(true);
 | 
			
		||||
      try {
 | 
			
		||||
        const res = await onExecute(value.command);
 | 
			
		||||
        if (res.success) {
 | 
			
		||||
            form.reset();
 | 
			
		||||
        }
 | 
			
		||||
      } finally {
 | 
			
		||||
        setIsLoading(false);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="space-y-6">
 | 
			
		||||
      
 | 
			
		||||
          {/* Form */}
 | 
			
		||||
          <form
 | 
			
		||||
            onSubmit={(e) => {
 | 
			
		||||
              e.preventDefault();
 | 
			
		||||
              form.handleSubmit();
 | 
			
		||||
            }}
 | 
			
		||||
            className="space-y-5"
 | 
			
		||||
          >
 | 
			
		||||
            {/* Field: command */}
 | 
			
		||||
            <form.Field
 | 
			
		||||
              name="command"
 | 
			
		||||
              validators={{
 | 
			
		||||
                onChange: z
 | 
			
		||||
                  .string()
 | 
			
		||||
                  .min(1, "Nhập command để thực thi")
 | 
			
		||||
                  .max(500, "Command quá dài"),
 | 
			
		||||
              }}
 | 
			
		||||
              children={(field) => (
 | 
			
		||||
                <div className="w-full px-0">
 | 
			
		||||
                  <Textarea
 | 
			
		||||
                    className="w-full h-[25vh]"
 | 
			
		||||
                    placeholder="Nhập lệnh..."
 | 
			
		||||
                    value={field.state.value}
 | 
			
		||||
                    onChange={(e) => field.handleChange(e.target.value)}
 | 
			
		||||
                    disabled={isLoading}
 | 
			
		||||
                  />
 | 
			
		||||
                  {field.state.meta.errors?.length > 0 && (
 | 
			
		||||
                    <p className="text-sm text-red-500">
 | 
			
		||||
                      {field.state.meta.errors.join(", ")}
 | 
			
		||||
                    </p>
 | 
			
		||||
                  )}
 | 
			
		||||
                </div>
 | 
			
		||||
              )}
 | 
			
		||||
            />
 | 
			
		||||
 | 
			
		||||
            <Button type="submit" disabled={isLoading}>
 | 
			
		||||
              {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
 | 
			
		||||
              Yêu cầu thiết bị thực thi
 | 
			
		||||
            </Button>
 | 
			
		||||
          </form>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										66
									
								
								src/components/ui/alert.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								src/components/ui/alert.tsx
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,66 @@
 | 
			
		|||
import * as React from "react"
 | 
			
		||||
import { cva, type VariantProps } from "class-variance-authority"
 | 
			
		||||
 | 
			
		||||
import { cn } from "@/lib/utils"
 | 
			
		||||
 | 
			
		||||
const alertVariants = cva(
 | 
			
		||||
  "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
 | 
			
		||||
  {
 | 
			
		||||
    variants: {
 | 
			
		||||
      variant: {
 | 
			
		||||
        default: "bg-card text-card-foreground",
 | 
			
		||||
        destructive:
 | 
			
		||||
          "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    defaultVariants: {
 | 
			
		||||
      variant: "default",
 | 
			
		||||
    },
 | 
			
		||||
  }
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
function Alert({
 | 
			
		||||
  className,
 | 
			
		||||
  variant,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="alert"
 | 
			
		||||
      role="alert"
 | 
			
		||||
      className={cn(alertVariants({ variant }), className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="alert-title"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function AlertDescription({
 | 
			
		||||
  className,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<"div">) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div
 | 
			
		||||
      data-slot="alert-description"
 | 
			
		||||
      className={cn(
 | 
			
		||||
        "text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
 | 
			
		||||
        className
 | 
			
		||||
      )}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { Alert, AlertTitle, AlertDescription }
 | 
			
		||||
							
								
								
									
										46
									
								
								src/components/ui/badge.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/components/ui/badge.tsx
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,46 @@
 | 
			
		|||
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"
 | 
			
		||||
 | 
			
		||||
const badgeVariants = cva(
 | 
			
		||||
  "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
 | 
			
		||||
  {
 | 
			
		||||
    variants: {
 | 
			
		||||
      variant: {
 | 
			
		||||
        default:
 | 
			
		||||
          "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
 | 
			
		||||
        secondary:
 | 
			
		||||
          "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
 | 
			
		||||
        destructive:
 | 
			
		||||
          "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
 | 
			
		||||
        outline:
 | 
			
		||||
          "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
    defaultVariants: {
 | 
			
		||||
      variant: "default",
 | 
			
		||||
    },
 | 
			
		||||
  }
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
function Badge({
 | 
			
		||||
  className,
 | 
			
		||||
  variant,
 | 
			
		||||
  asChild = false,
 | 
			
		||||
  ...props
 | 
			
		||||
}: React.ComponentProps<"span"> &
 | 
			
		||||
  VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
 | 
			
		||||
  const Comp = asChild ? Slot : "span"
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Comp
 | 
			
		||||
      data-slot="badge"
 | 
			
		||||
      className={cn(badgeVariants({ variant }), className)}
 | 
			
		||||
      {...props}
 | 
			
		||||
    />
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export { Badge, badgeVariants }
 | 
			
		||||
| 
						 | 
				
			
			@ -1,36 +1,36 @@
 | 
			
		|||
import { useState } from "react";
 | 
			
		||||
import {
 | 
			
		||||
  Dialog, DialogContent, DialogDescription, DialogFooter,
 | 
			
		||||
  DialogHeader, DialogTitle, DialogTrigger,
 | 
			
		||||
  Dialog,
 | 
			
		||||
  DialogContent,
 | 
			
		||||
  DialogFooter,
 | 
			
		||||
  DialogHeader,
 | 
			
		||||
  DialogTitle,
 | 
			
		||||
  DialogTrigger,
 | 
			
		||||
} from "@/components/ui/dialog";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Input } from "@/components/ui/input";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import { Label } from "@/components/ui/label";
 | 
			
		||||
import { Progress } from "@/components/ui/progress";
 | 
			
		||||
import { Plus } from "lucide-react";
 | 
			
		||||
import { formOptions, useForm } from "@tanstack/react-form";
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
import { useForm, formOptions } from "@tanstack/react-form";
 | 
			
		||||
import { toast } from "sonner";
 | 
			
		||||
import type { AxiosProgressEvent } from "axios";
 | 
			
		||||
 | 
			
		||||
interface UploadDialogProps {
 | 
			
		||||
  onSubmit: (formData: FormData) => Promise<void>;
 | 
			
		||||
  accept?: string;
 | 
			
		||||
  onSubmit: (
 | 
			
		||||
    fd: FormData,
 | 
			
		||||
    config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
 | 
			
		||||
  ) => Promise<void>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface UploadFormProps {
 | 
			
		||||
  files: FileList;
 | 
			
		||||
  newVersion: string;
 | 
			
		||||
}
 | 
			
		||||
const formOpts = formOptions({
 | 
			
		||||
  defaultValues: { files: new DataTransfer().files, newVersion: "" },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const defaultInput: UploadFormProps = {
 | 
			
		||||
  files: new DataTransfer().files,
 | 
			
		||||
  newVersion: "",
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const formOpts = formOptions({ defaultValues: defaultInput });
 | 
			
		||||
 | 
			
		||||
export function UploadDialog({ onSubmit, accept = ".exe,.msi,.apk" }: UploadDialogProps) {
 | 
			
		||||
export function UploadDialog({ onSubmit }: UploadDialogProps) {
 | 
			
		||||
  const [isOpen, setIsOpen] = useState(false);
 | 
			
		||||
  const [uploadPercent, setUploadPercent] = useState(0);
 | 
			
		||||
  const [isUploading, setIsUploading] = useState(false);
 | 
			
		||||
  const [isDone, setIsDone] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const form = useForm({
 | 
			
		||||
    ...formOpts,
 | 
			
		||||
| 
						 | 
				
			
			@ -40,39 +40,71 @@ export function UploadDialog({ onSubmit, accept = ".exe,.msi,.apk" }: UploadDial
 | 
			
		|||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const fd = new FormData();
 | 
			
		||||
      Array.from(value.files).forEach((file) => fd.append("files", file));
 | 
			
		||||
      fd.append("newVersion", value.newVersion);
 | 
			
		||||
      try {
 | 
			
		||||
        setIsUploading(true);
 | 
			
		||||
        setUploadPercent(0);
 | 
			
		||||
        setIsDone(false);
 | 
			
		||||
 | 
			
		||||
      setUploadPercent(0);
 | 
			
		||||
      await onSubmit(fd);
 | 
			
		||||
      setIsOpen(false);
 | 
			
		||||
      form.reset();
 | 
			
		||||
        const fd = new FormData();
 | 
			
		||||
        Array.from(value.files).forEach((f) => fd.append("FileInput", f));
 | 
			
		||||
        fd.append("Version", value.newVersion);
 | 
			
		||||
 | 
			
		||||
        await onSubmit(fd, {
 | 
			
		||||
          onUploadProgress: (e: AxiosProgressEvent) => {
 | 
			
		||||
            if (e.total) {
 | 
			
		||||
              const progress = Math.round((e.loaded * 100) / e.total);
 | 
			
		||||
              setUploadPercent(progress);
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        setIsDone(true); 
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error("Upload error:", error);
 | 
			
		||||
        toast.error("Upload thất bại!");
 | 
			
		||||
      } finally {
 | 
			
		||||
        setIsUploading(false);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const handleDialogClose = (open: boolean) => {
 | 
			
		||||
    if (isUploading) return;
 | 
			
		||||
    setIsOpen(open);
 | 
			
		||||
    if (!open) {
 | 
			
		||||
      setUploadPercent(0);
 | 
			
		||||
      setIsDone(false);
 | 
			
		||||
      form.reset();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Dialog open={isOpen} onOpenChange={setIsOpen}>
 | 
			
		||||
    <Dialog open={isOpen} onOpenChange={handleDialogClose}>
 | 
			
		||||
      <DialogTrigger asChild>
 | 
			
		||||
        <Button className="gap-2">
 | 
			
		||||
          <Plus className="h-4 w-4" /> Cập nhật phiên bản mới
 | 
			
		||||
        </Button>
 | 
			
		||||
        <Button>Tải lên phiên bản mới</Button>
 | 
			
		||||
      </DialogTrigger>
 | 
			
		||||
      <DialogContent className="sm:max-w-md">
 | 
			
		||||
      <DialogContent>
 | 
			
		||||
        <DialogHeader>
 | 
			
		||||
          <DialogTitle>Cập nhật phiên bản mới</DialogTitle>
 | 
			
		||||
          <DialogDescription>Chọn tệp và nhập số phiên bản</DialogDescription>
 | 
			
		||||
          <DialogTitle>Cập nhật phiên bản</DialogTitle>
 | 
			
		||||
        </DialogHeader>
 | 
			
		||||
 | 
			
		||||
        <form className="space-y-4" onSubmit={form.handleSubmit}>
 | 
			
		||||
        <form
 | 
			
		||||
          className="space-y-4"
 | 
			
		||||
          onSubmit={(e) => {
 | 
			
		||||
            e.preventDefault();
 | 
			
		||||
            e.stopPropagation();
 | 
			
		||||
            form.handleSubmit();
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <form.Field name="newVersion">
 | 
			
		||||
            {(field) => (
 | 
			
		||||
              <div className="space-y-2">
 | 
			
		||||
              <div>
 | 
			
		||||
                <Label>Phiên bản</Label>
 | 
			
		||||
                <Input
 | 
			
		||||
                  value={field.state.value}
 | 
			
		||||
                  onChange={(e) => field.handleChange(e.target.value)}
 | 
			
		||||
                  placeholder="e.g., 1.0.0"
 | 
			
		||||
                  placeholder="1.0.0"
 | 
			
		||||
                  disabled={isUploading || isDone}
 | 
			
		||||
                />
 | 
			
		||||
              </div>
 | 
			
		||||
            )}
 | 
			
		||||
| 
						 | 
				
			
			@ -80,28 +112,50 @@ export function UploadDialog({ onSubmit, accept = ".exe,.msi,.apk" }: UploadDial
 | 
			
		|||
 | 
			
		||||
          <form.Field name="files">
 | 
			
		||||
            {(field) => (
 | 
			
		||||
              <div className="space-y-2">
 | 
			
		||||
                <Label>File ứng dụng</Label>
 | 
			
		||||
              <div>
 | 
			
		||||
                <Label>File</Label>
 | 
			
		||||
                <Input
 | 
			
		||||
                  type="file"
 | 
			
		||||
                  accept={accept}
 | 
			
		||||
                  onChange={(e) => e.target.files && field.handleChange(e.target.files)}
 | 
			
		||||
                  accept=".exe,.msi,.apk"
 | 
			
		||||
                  onChange={(e) =>
 | 
			
		||||
                    e.target.files && field.handleChange(e.target.files)
 | 
			
		||||
                  }
 | 
			
		||||
                  disabled={isUploading || isDone}
 | 
			
		||||
                />
 | 
			
		||||
              </div>
 | 
			
		||||
            )}
 | 
			
		||||
          </form.Field>
 | 
			
		||||
 | 
			
		||||
          {uploadPercent > 0 && (
 | 
			
		||||
          {(uploadPercent > 0 || isUploading || isDone) && (
 | 
			
		||||
            <div className="space-y-2">
 | 
			
		||||
              <Label>Tiến trình upload</Label>
 | 
			
		||||
              <div className="flex justify-between text-sm">
 | 
			
		||||
                <span>{isDone ? "Hoàn tất!" : "Đang tải lên..."}</span>
 | 
			
		||||
                <span>{uploadPercent}%</span>
 | 
			
		||||
              </div>
 | 
			
		||||
              <Progress value={uploadPercent} className="w-full" />
 | 
			
		||||
              <span>{uploadPercent}%</span>
 | 
			
		||||
            </div>
 | 
			
		||||
          )}
 | 
			
		||||
 | 
			
		||||
          <DialogFooter>
 | 
			
		||||
            <Button type="button" variant="outline" onClick={() => setIsOpen(false)}>Hủy</Button>
 | 
			
		||||
            <Button type="submit">Tải lên</Button>
 | 
			
		||||
            {!isDone ? (
 | 
			
		||||
              <>
 | 
			
		||||
                <Button
 | 
			
		||||
                  type="button"
 | 
			
		||||
                  variant="outline"
 | 
			
		||||
                  onClick={() => handleDialogClose(false)}
 | 
			
		||||
                  disabled={isUploading}
 | 
			
		||||
                >
 | 
			
		||||
                  Hủy
 | 
			
		||||
                </Button>
 | 
			
		||||
                <Button type="submit" disabled={isUploading}>
 | 
			
		||||
                  {isUploading ? "Đang tải..." : "Upload"}
 | 
			
		||||
                </Button>
 | 
			
		||||
              </>
 | 
			
		||||
            ) : (
 | 
			
		||||
              <Button type="button" onClick={() => handleDialogClose(false)}>
 | 
			
		||||
                Hoàn tất
 | 
			
		||||
              </Button>
 | 
			
		||||
            )}
 | 
			
		||||
          </DialogFooter>
 | 
			
		||||
        </form>
 | 
			
		||||
      </DialogContent>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,6 +16,8 @@ export const API_ENDPOINTS = {
 | 
			
		|||
    GET_DEVICE_FROM_ROOM: (roomName: string) =>
 | 
			
		||||
      `${BASE_URL}/DeviceComm/room/${roomName}`,
 | 
			
		||||
    UPDATE_AGENT: `${BASE_URL}/DeviceComm/updateagent`,
 | 
			
		||||
    SEND_COMMAND: `${BASE_URL}/DeviceComm/shellcommand`,
 | 
			
		||||
    CHANGE_DEVICE_ROOM: `${BASE_URL}/DeviceComm/changeroom`,
 | 
			
		||||
  },
 | 
			
		||||
  SSE_EVENTS: {
 | 
			
		||||
    DEVICE_ONLINE: `${BASE_URL}/Sse/events/onlineDevices`,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,16 +1,13 @@
 | 
			
		|||
import { useMutation, useQueryClient } from "@tanstack/react-query"
 | 
			
		||||
import axios, { type Method } from "axios"
 | 
			
		||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
 | 
			
		||||
import axios, { type Method } from "axios";
 | 
			
		||||
 | 
			
		||||
type MutationDataOptions<TInput, TOutput> = {
 | 
			
		||||
  url: string
 | 
			
		||||
  method?: Method // POST, PUT, PATCH, DELETE
 | 
			
		||||
  onSuccess?: (data: TOutput) => void
 | 
			
		||||
  onError?: (error: any) => void
 | 
			
		||||
  config?: {
 | 
			
		||||
    onUploadProgress?: (e: ProgressEvent) => void
 | 
			
		||||
  }
 | 
			
		||||
  invalidate?: string[][] // List of queryKeys to invalidate
 | 
			
		||||
}
 | 
			
		||||
  url: string;
 | 
			
		||||
  method?: Method;
 | 
			
		||||
  onSuccess?: (data: TOutput) => void;
 | 
			
		||||
  onError?: (error: any) => void;
 | 
			
		||||
  invalidate?: string[][];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function useMutationData<TInput = any, TOutput = any>({
 | 
			
		||||
  url,
 | 
			
		||||
| 
						 | 
				
			
			@ -19,24 +16,31 @@ export function useMutationData<TInput = any, TOutput = any>({
 | 
			
		|||
  onError,
 | 
			
		||||
  invalidate = [],
 | 
			
		||||
}: MutationDataOptions<TInput, TOutput>) {
 | 
			
		||||
  const queryClient = useQueryClient()
 | 
			
		||||
  const queryClient = useQueryClient();
 | 
			
		||||
 | 
			
		||||
  return useMutation<TOutput, any, { data: TInput; config?: any }>({
 | 
			
		||||
    mutationFn: async ({ data, config }) => {
 | 
			
		||||
       const isFormData = data instanceof FormData;
 | 
			
		||||
 | 
			
		||||
  return useMutation<TOutput, any, TInput>({
 | 
			
		||||
    mutationFn: async (data: TInput) => {
 | 
			
		||||
      const response = await axios.request({
 | 
			
		||||
        url,
 | 
			
		||||
        method,
 | 
			
		||||
        data,
 | 
			
		||||
      })
 | 
			
		||||
      return response.data
 | 
			
		||||
        headers: {
 | 
			
		||||
          ...(isFormData
 | 
			
		||||
            ? {} 
 | 
			
		||||
            : { "Content-Type": "application/json" }),
 | 
			
		||||
        },
 | 
			
		||||
        ...config,
 | 
			
		||||
      });
 | 
			
		||||
      return response.data;
 | 
			
		||||
    },
 | 
			
		||||
    onSuccess: (data) => {
 | 
			
		||||
      invalidate.forEach((key) =>
 | 
			
		||||
        queryClient.invalidateQueries({ queryKey: key })
 | 
			
		||||
      )
 | 
			
		||||
      onSuccess?.(data)
 | 
			
		||||
      );
 | 
			
		||||
      onSuccess?.(data);
 | 
			
		||||
    },
 | 
			
		||||
    onError,
 | 
			
		||||
  })
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,7 +5,7 @@ import {
 | 
			
		|||
  SidebarInset,
 | 
			
		||||
  SidebarTrigger,
 | 
			
		||||
} from "@/components/ui/sidebar";
 | 
			
		||||
import { Home, Building, AppWindow } from "lucide-react";
 | 
			
		||||
import { Home, Building, AppWindow, Terminal } from "lucide-react";
 | 
			
		||||
import { Toaster } from "@/components/ui/sonner";
 | 
			
		||||
import { useQueryClient } from "@tanstack/react-query";
 | 
			
		||||
import { API_ENDPOINTS, BASE_URL } from "@/config/api";
 | 
			
		||||
| 
						 | 
				
			
			@ -67,6 +67,7 @@ export default function AppLayout({ children }: AppLayoutProps) {
 | 
			
		|||
    { title: "Quản lý phần mềm", to: "/apps", icon: AppWindow,
 | 
			
		||||
      onPointerEnter: handlePrefetchSofware,
 | 
			
		||||
     },
 | 
			
		||||
    { title: "Gửi lệnh CMD", to: "/command", icon: Terminal },
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -13,6 +13,7 @@ import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
 | 
			
		|||
import { Route as AuthRouteImport } from './routes/_auth'
 | 
			
		||||
import { Route as IndexRouteImport } from './routes/index'
 | 
			
		||||
import { Route as AuthenticatedRoomIndexRouteImport } from './routes/_authenticated/room/index'
 | 
			
		||||
import { Route as AuthenticatedCommandIndexRouteImport } from './routes/_authenticated/command/index'
 | 
			
		||||
import { Route as AuthenticatedAppsIndexRouteImport } from './routes/_authenticated/apps/index'
 | 
			
		||||
import { Route as AuthenticatedAgentIndexRouteImport } from './routes/_authenticated/agent/index'
 | 
			
		||||
import { Route as AuthLoginIndexRouteImport } from './routes/_auth/login/index'
 | 
			
		||||
| 
						 | 
				
			
			@ -36,6 +37,12 @@ const AuthenticatedRoomIndexRoute = AuthenticatedRoomIndexRouteImport.update({
 | 
			
		|||
  path: '/room/',
 | 
			
		||||
  getParentRoute: () => AuthenticatedRoute,
 | 
			
		||||
} as any)
 | 
			
		||||
const AuthenticatedCommandIndexRoute =
 | 
			
		||||
  AuthenticatedCommandIndexRouteImport.update({
 | 
			
		||||
    id: '/command/',
 | 
			
		||||
    path: '/command/',
 | 
			
		||||
    getParentRoute: () => AuthenticatedRoute,
 | 
			
		||||
  } as any)
 | 
			
		||||
const AuthenticatedAppsIndexRoute = AuthenticatedAppsIndexRouteImport.update({
 | 
			
		||||
  id: '/apps/',
 | 
			
		||||
  path: '/apps/',
 | 
			
		||||
| 
						 | 
				
			
			@ -63,6 +70,7 @@ export interface FileRoutesByFullPath {
 | 
			
		|||
  '/login': typeof AuthLoginIndexRoute
 | 
			
		||||
  '/agent': typeof AuthenticatedAgentIndexRoute
 | 
			
		||||
  '/apps': typeof AuthenticatedAppsIndexRoute
 | 
			
		||||
  '/command': typeof AuthenticatedCommandIndexRoute
 | 
			
		||||
  '/room': typeof AuthenticatedRoomIndexRoute
 | 
			
		||||
  '/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -71,6 +79,7 @@ export interface FileRoutesByTo {
 | 
			
		|||
  '/login': typeof AuthLoginIndexRoute
 | 
			
		||||
  '/agent': typeof AuthenticatedAgentIndexRoute
 | 
			
		||||
  '/apps': typeof AuthenticatedAppsIndexRoute
 | 
			
		||||
  '/command': typeof AuthenticatedCommandIndexRoute
 | 
			
		||||
  '/room': typeof AuthenticatedRoomIndexRoute
 | 
			
		||||
  '/room/$roomName': typeof AuthenticatedRoomRoomNameIndexRoute
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -82,14 +91,29 @@ export interface FileRoutesById {
 | 
			
		|||
  '/_auth/login/': typeof AuthLoginIndexRoute
 | 
			
		||||
  '/_authenticated/agent/': typeof AuthenticatedAgentIndexRoute
 | 
			
		||||
  '/_authenticated/apps/': typeof AuthenticatedAppsIndexRoute
 | 
			
		||||
  '/_authenticated/command/': typeof AuthenticatedCommandIndexRoute
 | 
			
		||||
  '/_authenticated/room/': typeof AuthenticatedRoomIndexRoute
 | 
			
		||||
  '/_authenticated/room/$roomName/': typeof AuthenticatedRoomRoomNameIndexRoute
 | 
			
		||||
}
 | 
			
		||||
export interface FileRouteTypes {
 | 
			
		||||
  fileRoutesByFullPath: FileRoutesByFullPath
 | 
			
		||||
  fullPaths: '/' | '/login' | '/agent' | '/apps' | '/room' | '/room/$roomName'
 | 
			
		||||
  fullPaths:
 | 
			
		||||
    | '/'
 | 
			
		||||
    | '/login'
 | 
			
		||||
    | '/agent'
 | 
			
		||||
    | '/apps'
 | 
			
		||||
    | '/command'
 | 
			
		||||
    | '/room'
 | 
			
		||||
    | '/room/$roomName'
 | 
			
		||||
  fileRoutesByTo: FileRoutesByTo
 | 
			
		||||
  to: '/' | '/login' | '/agent' | '/apps' | '/room' | '/room/$roomName'
 | 
			
		||||
  to:
 | 
			
		||||
    | '/'
 | 
			
		||||
    | '/login'
 | 
			
		||||
    | '/agent'
 | 
			
		||||
    | '/apps'
 | 
			
		||||
    | '/command'
 | 
			
		||||
    | '/room'
 | 
			
		||||
    | '/room/$roomName'
 | 
			
		||||
  id:
 | 
			
		||||
    | '__root__'
 | 
			
		||||
    | '/'
 | 
			
		||||
| 
						 | 
				
			
			@ -98,6 +122,7 @@ export interface FileRouteTypes {
 | 
			
		|||
    | '/_auth/login/'
 | 
			
		||||
    | '/_authenticated/agent/'
 | 
			
		||||
    | '/_authenticated/apps/'
 | 
			
		||||
    | '/_authenticated/command/'
 | 
			
		||||
    | '/_authenticated/room/'
 | 
			
		||||
    | '/_authenticated/room/$roomName/'
 | 
			
		||||
  fileRoutesById: FileRoutesById
 | 
			
		||||
| 
						 | 
				
			
			@ -138,6 +163,13 @@ declare module '@tanstack/react-router' {
 | 
			
		|||
      preLoaderRoute: typeof AuthenticatedRoomIndexRouteImport
 | 
			
		||||
      parentRoute: typeof AuthenticatedRoute
 | 
			
		||||
    }
 | 
			
		||||
    '/_authenticated/command/': {
 | 
			
		||||
      id: '/_authenticated/command/'
 | 
			
		||||
      path: '/command'
 | 
			
		||||
      fullPath: '/command'
 | 
			
		||||
      preLoaderRoute: typeof AuthenticatedCommandIndexRouteImport
 | 
			
		||||
      parentRoute: typeof AuthenticatedRoute
 | 
			
		||||
    }
 | 
			
		||||
    '/_authenticated/apps/': {
 | 
			
		||||
      id: '/_authenticated/apps/'
 | 
			
		||||
      path: '/apps'
 | 
			
		||||
| 
						 | 
				
			
			@ -182,6 +214,7 @@ const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
 | 
			
		|||
interface AuthenticatedRouteChildren {
 | 
			
		||||
  AuthenticatedAgentIndexRoute: typeof AuthenticatedAgentIndexRoute
 | 
			
		||||
  AuthenticatedAppsIndexRoute: typeof AuthenticatedAppsIndexRoute
 | 
			
		||||
  AuthenticatedCommandIndexRoute: typeof AuthenticatedCommandIndexRoute
 | 
			
		||||
  AuthenticatedRoomIndexRoute: typeof AuthenticatedRoomIndexRoute
 | 
			
		||||
  AuthenticatedRoomRoomNameIndexRoute: typeof AuthenticatedRoomRoomNameIndexRoute
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -189,6 +222,7 @@ interface AuthenticatedRouteChildren {
 | 
			
		|||
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
 | 
			
		||||
  AuthenticatedAgentIndexRoute: AuthenticatedAgentIndexRoute,
 | 
			
		||||
  AuthenticatedAppsIndexRoute: AuthenticatedAppsIndexRoute,
 | 
			
		||||
  AuthenticatedCommandIndexRoute: AuthenticatedCommandIndexRoute,
 | 
			
		||||
  AuthenticatedRoomIndexRoute: AuthenticatedRoomIndexRoute,
 | 
			
		||||
  AuthenticatedRoomRoomNameIndexRoute: AuthenticatedRoomRoomNameIndexRoute,
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,6 +5,7 @@ import { useMutationData } from "@/hooks/useMutationData";
 | 
			
		|||
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
 | 
			
		||||
import { toast } from "sonner";
 | 
			
		||||
import type { ColumnDef } from "@tanstack/react-table";
 | 
			
		||||
import type { AxiosProgressEvent } from "axios";
 | 
			
		||||
 | 
			
		||||
type Version = {
 | 
			
		||||
  id?: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -12,6 +13,7 @@ type Version = {
 | 
			
		|||
  fileName: string;
 | 
			
		||||
  folderPath: string;
 | 
			
		||||
  updatedAt?: string;
 | 
			
		||||
  requestUpdateAt?: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const Route = createFileRoute("/_authenticated/agent/")({
 | 
			
		||||
| 
						 | 
				
			
			@ -26,16 +28,25 @@ function AgentsPage() {
 | 
			
		|||
    url: BASE_URL + API_ENDPOINTS.APP_VERSION.GET_VERSION,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const versionList: Version[] = Array.isArray(data) ? data : data ? [data] : [];
 | 
			
		||||
  const versionList: Version[] = Array.isArray(data)
 | 
			
		||||
    ? data
 | 
			
		||||
    : data
 | 
			
		||||
    ? [data]
 | 
			
		||||
    : [];
 | 
			
		||||
 | 
			
		||||
  // Mutation upload
 | 
			
		||||
  const uploadMutation = useMutationData<FormData>({
 | 
			
		||||
    url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD,
 | 
			
		||||
    method: "POST",
 | 
			
		||||
    invalidate: [["agent-version"]],
 | 
			
		||||
    onSuccess: () => toast.success("Upload thành công!"),
 | 
			
		||||
    onError: () => toast.error("Upload thất bại!"),
 | 
			
		||||
    onError: (error) => {
 | 
			
		||||
      console.error("Upload error:", error)
 | 
			
		||||
      toast.error("Upload thất bại!")
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // Mutation update
 | 
			
		||||
  const updateMutation = useMutationData<void>({
 | 
			
		||||
    url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.UPDATE_AGENT,
 | 
			
		||||
    method: "POST",
 | 
			
		||||
| 
						 | 
				
			
			@ -44,18 +55,9 @@ function AgentsPage() {
 | 
			
		|||
  });
 | 
			
		||||
 | 
			
		||||
  const columns: ColumnDef<Version>[] = [
 | 
			
		||||
    {
 | 
			
		||||
      accessorKey: "version",
 | 
			
		||||
      header: "Phiên bản",
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      accessorKey: "fileName",
 | 
			
		||||
      header: "Tên file",
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      accessorKey: "folderPath",
 | 
			
		||||
      header: "Đường dẫn",
 | 
			
		||||
    },
 | 
			
		||||
    { accessorKey: "version", header: "Phiên bản" },
 | 
			
		||||
    { accessorKey: "fileName", header: "Tên file" },
 | 
			
		||||
    { accessorKey: "folderPath", header: "Đường dẫn" },
 | 
			
		||||
    {
 | 
			
		||||
      accessorKey: "updatedAt",
 | 
			
		||||
      header: "Thời gian cập nhật",
 | 
			
		||||
| 
						 | 
				
			
			@ -64,8 +66,32 @@ function AgentsPage() {
 | 
			
		|||
          ? new Date(getValue() as string).toLocaleString("vi-VN")
 | 
			
		||||
          : "N/A",
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      accessorKey: "requestUpdateAt",
 | 
			
		||||
      header: "Thời gian yêu cầu cập nhật",
 | 
			
		||||
      cell: ({ getValue }) =>
 | 
			
		||||
        getValue()
 | 
			
		||||
          ? new Date(getValue() as string).toLocaleString("vi-VN")
 | 
			
		||||
          : "N/A",
 | 
			
		||||
    }
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  const handleUpload = async (
 | 
			
		||||
    fd: FormData, 
 | 
			
		||||
    config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
 | 
			
		||||
  ) => {
 | 
			
		||||
    return uploadMutation.mutateAsync({
 | 
			
		||||
      data: fd,
 | 
			
		||||
      config
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleUpdate = async () => {
 | 
			
		||||
    return updateMutation.mutateAsync({
 | 
			
		||||
      data: undefined,
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <AppManagerTemplate<Version>
 | 
			
		||||
      title="Quản lý Agent"
 | 
			
		||||
| 
						 | 
				
			
			@ -73,9 +99,9 @@ function AgentsPage() {
 | 
			
		|||
      data={versionList}
 | 
			
		||||
      isLoading={isLoading}
 | 
			
		||||
      columns={columns}
 | 
			
		||||
      onUpload={(fd) => uploadMutation.mutateAsync(fd)}
 | 
			
		||||
      onUpdate={() => updateMutation.mutateAsync()}
 | 
			
		||||
      onUpload={handleUpload}
 | 
			
		||||
      onUpdate={handleUpdate}
 | 
			
		||||
      updateLoading={updateMutation.isPending}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -6,6 +6,7 @@ import { BASE_URL, API_ENDPOINTS } from "@/config/api";
 | 
			
		|||
import { toast } from "sonner";
 | 
			
		||||
import type { ColumnDef } from "@tanstack/react-table";
 | 
			
		||||
import { useState } from "react";
 | 
			
		||||
import type { AxiosProgressEvent } from "axios";
 | 
			
		||||
 | 
			
		||||
export const Route = createFileRoute("/_authenticated/apps/")({
 | 
			
		||||
  head: () => ({ meta: [{ title: "Quản lý phần mềm" }] }),
 | 
			
		||||
| 
						 | 
				
			
			@ -18,6 +19,7 @@ type Version = {
 | 
			
		|||
  fileName: string;
 | 
			
		||||
  folderPath: string;
 | 
			
		||||
  updatedAt?: string;
 | 
			
		||||
  requestUpdateAt?: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function AppsComponent() {
 | 
			
		||||
| 
						 | 
				
			
			@ -29,36 +31,34 @@ function AppsComponent() {
 | 
			
		|||
  const versionList: Version[] = Array.isArray(data)
 | 
			
		||||
    ? data
 | 
			
		||||
    : data
 | 
			
		||||
      ? [data]
 | 
			
		||||
      : [];
 | 
			
		||||
    ? [data]
 | 
			
		||||
    : [];
 | 
			
		||||
 | 
			
		||||
  const [table, setTable] = useState<any>();
 | 
			
		||||
 | 
			
		||||
  const uploadMutation = useMutationData<FormData>({
 | 
			
		||||
    url: BASE_URL + API_ENDPOINTS.APP_VERSION.UPLOAD,
 | 
			
		||||
    method: "POST",
 | 
			
		||||
    invalidate: [["software-version"]], // Add this to refresh data after upload
 | 
			
		||||
    onSuccess: () => toast.success("Upload thành công!"),
 | 
			
		||||
    onError: () => toast.error("Upload thất bại!"),
 | 
			
		||||
    onError: (error) => {
 | 
			
		||||
      console.error("Upload error:", error);
 | 
			
		||||
      toast.error("Upload thất bại!");
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const installMutation = useMutationData<{ msiFileIds: number[] }>({
 | 
			
		||||
    url: BASE_URL+ API_ENDPOINTS.DEVICE_COMM.DOWNLOAD_MSI,
 | 
			
		||||
  const installMutation = useMutationData<{ MsiFileIds: number[] }>({
 | 
			
		||||
    url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.DOWNLOAD_MSI,
 | 
			
		||||
    method: "POST",
 | 
			
		||||
    onSuccess: () => toast.success("Đã gửi yêu cầu cài đặt MSI!"),
 | 
			
		||||
    onError: () => toast.error("Gửi yêu cầu thất bại!"),
 | 
			
		||||
    onError: (error) => {
 | 
			
		||||
      console.error("Install error:", error);
 | 
			
		||||
      toast.error("Gửi yêu cầu thất bại!");
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const columns: ColumnDef<Version>[] = [
 | 
			
		||||
    {
 | 
			
		||||
      id: "select",
 | 
			
		||||
      header: () => <span>Thêm vào danh sách yêu cầu</span>,
 | 
			
		||||
      cell: ({ row }) => (
 | 
			
		||||
        <input
 | 
			
		||||
          type="checkbox"
 | 
			
		||||
          checked={row.getIsSelected?.() ?? false}
 | 
			
		||||
          onChange={row.getToggleSelectedHandler?.()}
 | 
			
		||||
        />
 | 
			
		||||
      ),
 | 
			
		||||
      enableSorting: false,
 | 
			
		||||
      enableHiding: false,
 | 
			
		||||
    },
 | 
			
		||||
    
 | 
			
		||||
    { accessorKey: "version", header: "Phiên bản" },
 | 
			
		||||
    { accessorKey: "fileName", header: "Tên file" },
 | 
			
		||||
    { accessorKey: "folderPath", header: "Đường dẫn" },
 | 
			
		||||
| 
						 | 
				
			
			@ -70,8 +70,58 @@ function AppsComponent() {
 | 
			
		|||
          ? new Date(getValue() as string).toLocaleString("vi-VN")
 | 
			
		||||
          : "N/A",
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      accessorKey: "requestUpdateAt",
 | 
			
		||||
      header: "Thời gian yêu cầu cài đặt",
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      id: "select",
 | 
			
		||||
      header: () => <span>Thêm vào danh sách yêu cầu</span>,
 | 
			
		||||
      cell: ({ row }) => (
 | 
			
		||||
        <input
 | 
			
		||||
          type="checkbox"
 | 
			
		||||
          checked={row.getIsSelected?.() ?? false}
 | 
			
		||||
          onChange={row.getToggleSelectedHandler?.()}
 | 
			
		||||
          disabled={installMutation.isPending}
 | 
			
		||||
        />
 | 
			
		||||
      ),
 | 
			
		||||
      enableSorting: false,
 | 
			
		||||
      enableHiding: false,
 | 
			
		||||
    }
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  const handleUpload = async (
 | 
			
		||||
    fd: FormData,
 | 
			
		||||
    config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }
 | 
			
		||||
  ) => {
 | 
			
		||||
    return uploadMutation.mutateAsync({
 | 
			
		||||
      data: fd,
 | 
			
		||||
      config,
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleInstall = async () => {
 | 
			
		||||
    if (!table) {
 | 
			
		||||
      toast.error("Không thể lấy thông tin bảng!");
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const selectedRows = table.getSelectedRowModel().rows;
 | 
			
		||||
    
 | 
			
		||||
    if (selectedRows.length === 0) {
 | 
			
		||||
      toast.error("Vui lòng chọn ít nhất một file để cài đặt!");
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const MsiFileIds = selectedRows.map((row: any) => row.original.id);
 | 
			
		||||
    
 | 
			
		||||
    console.log("Selected MSI file IDs:", MsiFileIds);
 | 
			
		||||
 | 
			
		||||
    return installMutation.mutateAsync({
 | 
			
		||||
      data: { MsiFileIds },
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <AppManagerTemplate<Version>
 | 
			
		||||
      title="Quản lý phần mềm"
 | 
			
		||||
| 
						 | 
				
			
			@ -79,16 +129,10 @@ function AppsComponent() {
 | 
			
		|||
      data={versionList}
 | 
			
		||||
      isLoading={isLoading}
 | 
			
		||||
      columns={columns}
 | 
			
		||||
      onUpload={(fd) => uploadMutation.mutateAsync(fd)} 
 | 
			
		||||
      onTableInit={setTable}
 | 
			
		||||
      onUpdate={() => {
 | 
			
		||||
        const selectedIds = table
 | 
			
		||||
          ?.getSelectedRowModel()
 | 
			
		||||
          .rows.map((row: any) => (row.original as Version).id);
 | 
			
		||||
 | 
			
		||||
        installMutation.mutateAsync({ msiFileIds: selectedIds });
 | 
			
		||||
      }}
 | 
			
		||||
      updateLoading={installMutation.isPending}
 | 
			
		||||
      onUpload={handleUpload}
 | 
			
		||||
      onUpdate={handleInstall} 
 | 
			
		||||
      updateLoading={installMutation.isPending} 
 | 
			
		||||
      onTableInit={setTable} 
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										46
									
								
								src/routes/_authenticated/command/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/routes/_authenticated/command/index.tsx
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,46 @@
 | 
			
		|||
import { createFileRoute } from "@tanstack/react-router";
 | 
			
		||||
import { FormSubmitTemplate } from "@/template/form-submit-template";
 | 
			
		||||
import { ShellCommandForm } from "@/components/command-form";
 | 
			
		||||
import { useMutationData } from "@/hooks/useMutationData";
 | 
			
		||||
import { BASE_URL, API_ENDPOINTS } from "@/config/api";
 | 
			
		||||
import { toast } from "sonner";
 | 
			
		||||
 | 
			
		||||
export const Route = createFileRoute("/_authenticated/command/")({
 | 
			
		||||
  head: () => ({ meta: [{ title: "Gửi lệnh CMD" }] }),
 | 
			
		||||
  component: CommandPage,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
function CommandPage() {
 | 
			
		||||
  const sendCommandMutation = useMutationData<
 | 
			
		||||
    string,
 | 
			
		||||
    { success: boolean; output: string }
 | 
			
		||||
  >({
 | 
			
		||||
    url: BASE_URL + API_ENDPOINTS.DEVICE_COMM.SEND_COMMAND,
 | 
			
		||||
    method: "POST",
 | 
			
		||||
    onSuccess: (data) => {
 | 
			
		||||
      if (data.success) {
 | 
			
		||||
        toast.success("Lệnh đã được gửi thành công!");
 | 
			
		||||
      } else {
 | 
			
		||||
        toast.error("Lệnh không thể thực thi trên thiết bị!");
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    onError: (error) => {
 | 
			
		||||
      console.error("Send command error:", error);
 | 
			
		||||
      toast.error("Gửi lệnh thất bại!");
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <FormSubmitTemplate
 | 
			
		||||
      title="CMD Command"
 | 
			
		||||
      description="Gửi lệnh shell xuống thiết bị để thực thi"
 | 
			
		||||
      isLoading={sendCommandMutation.isPending}
 | 
			
		||||
    >
 | 
			
		||||
      <ShellCommandForm
 | 
			
		||||
        onExecute={async (cmd: string) => {
 | 
			
		||||
          return await sendCommandMutation.mutateAsync({ data: cmd });
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
    </FormSubmitTemplate>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,16 +1,9 @@
 | 
			
		|||
import {
 | 
			
		||||
  createFileRoute,
 | 
			
		||||
  Outlet,
 | 
			
		||||
  redirect,
 | 
			
		||||
} from "@tanstack/react-router";
 | 
			
		||||
import AppLayout from "@/layouts/app-layout";
 | 
			
		||||
export const Route = createFileRoute("/")({
 | 
			
		||||
  //  beforeLoad: async ({context}) => {
 | 
			
		||||
  //       const {authToken} = context.auth
 | 
			
		||||
  //       if (!authToken) {
 | 
			
		||||
  //         throw redirect({to: '/login'})
 | 
			
		||||
  //       }
 | 
			
		||||
  //     },
 | 
			
		||||
  head: () => ({
 | 
			
		||||
    meta: [
 | 
			
		||||
      {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,6 +11,7 @@ import { FileText } from "lucide-react";
 | 
			
		|||
import { UploadDialog } from "@/components/upload-dialog";
 | 
			
		||||
import { VersionTable } from "@/components/version-table";
 | 
			
		||||
import { UpdateButton } from "@/components/update-button";
 | 
			
		||||
import type { AxiosProgressEvent } from "axios";
 | 
			
		||||
 | 
			
		||||
interface AppManagerTemplateProps<TData> {
 | 
			
		||||
  title: string;
 | 
			
		||||
| 
						 | 
				
			
			@ -18,7 +19,7 @@ interface AppManagerTemplateProps<TData> {
 | 
			
		|||
  data: TData[];
 | 
			
		||||
  isLoading: boolean;
 | 
			
		||||
  columns: ColumnDef<TData, any>[];
 | 
			
		||||
  onUpload: (fd: FormData) => Promise<void>;
 | 
			
		||||
  onUpload: (fd: FormData, config?: { onUploadProgress?: (e: AxiosProgressEvent) => void }) => Promise<void>;
 | 
			
		||||
  onUpdate?: () => void;
 | 
			
		||||
  updateLoading?: boolean;
 | 
			
		||||
  onTableInit?: (table: any) => void;
 | 
			
		||||
| 
						 | 
				
			
			@ -68,4 +69,4 @@ export function AppManagerTemplate<TData>({
 | 
			
		|||
      </Card>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										53
									
								
								src/template/form-submit-template.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								src/template/form-submit-template.tsx
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,53 @@
 | 
			
		|||
import { ShellCommandForm } from "@/components/command-form";
 | 
			
		||||
import {
 | 
			
		||||
  Card,
 | 
			
		||||
  CardContent,
 | 
			
		||||
  CardDescription,
 | 
			
		||||
  CardFooter,
 | 
			
		||||
  CardHeader,
 | 
			
		||||
  CardTitle,
 | 
			
		||||
} from "@/components/ui/card";
 | 
			
		||||
import { UpdateButton } from "@/components/update-button";
 | 
			
		||||
import { FileText, Terminal } from "lucide-react";
 | 
			
		||||
 | 
			
		||||
interface FormSubmitTemplateProps {
 | 
			
		||||
  title: string;
 | 
			
		||||
  description: string;
 | 
			
		||||
  isLoading?: boolean;
 | 
			
		||||
  children: React.ReactNode;
 | 
			
		||||
  onSubmit?: () => void;
 | 
			
		||||
  submitLoading?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function FormSubmitTemplate({
 | 
			
		||||
  title,
 | 
			
		||||
  description,
 | 
			
		||||
  isLoading,
 | 
			
		||||
  children,
 | 
			
		||||
  onSubmit,
 | 
			
		||||
  submitLoading,
 | 
			
		||||
}: FormSubmitTemplateProps) {
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="w-full px-6 space-y-4">
 | 
			
		||||
      <div>
 | 
			
		||||
        <h1 className="text-3xl font-bold">{title}</h1>
 | 
			
		||||
        <p className="text-muted-foreground mt-2">{description}</p>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <Card className="w-full">
 | 
			
		||||
        <CardHeader>
 | 
			
		||||
          <CardTitle className="flex items-center gap-2">
 | 
			
		||||
            <Terminal className="h-5 w-5" /> Gửi lệnh CMD
 | 
			
		||||
          </CardTitle>
 | 
			
		||||
          <CardDescription>Nhập và gửi lệnh xuống thiết bị</CardDescription>
 | 
			
		||||
        </CardHeader>
 | 
			
		||||
        <CardContent>{children}</CardContent>
 | 
			
		||||
        {onSubmit && (
 | 
			
		||||
          <CardFooter>
 | 
			
		||||
            <UpdateButton onClick={onSubmit} loading={submitLoading} />
 | 
			
		||||
          </CardFooter>
 | 
			
		||||
        )}
 | 
			
		||||
      </Card>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user