Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
594 changes: 594 additions & 0 deletions frontend/package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"oxfmt": "^0.44.0",
"oxlint": "^1.59.0",
"oxlint-tsgolint": "^0.20.0",
"shiki": "^4.0.2",
"typescript": "^6.0.2",
"vite": "^8.0.8"
}
Expand Down
144 changes: 144 additions & 0 deletions frontend/src/components/dialogs/upsert-application.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// oxlint-disable react/no-children-prop
import { Pencil, Plus } from "lucide-react";
import { Button } from "../ui/button";
import z from "zod";
import type { Application } from "@/lib/applications";
import { useState } from "react";
import { toast } from "sonner";
import { useForm } from "@tanstack/react-form";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../ui/dialog";
import { DropdownMenuItem } from "../ui/dropdown-menu";
import { Field, FieldError, FieldGroup } from "../ui/field";
import { Label } from "../ui/label";
import { Input } from "../ui/input";

const applicationSchema = z.object({
name: z
.string()
.trim()
.min(1, "Name is required")
.max(128, "Name must be at most 128 characters"),
});

export default function UpsertApplicationDialog({
application,
asDropdownItem = false,
}: {
application: Application | null;
asDropdownItem?: boolean;
}) {
const isEditing = !!application;
const [isSubmitting, setIsSubmitting] = useState(false);
const [open, setOpen] = useState(false);

const form = useForm({
defaultValues: {
name: application?.name ?? "",
},
validators: {
onSubmit: applicationSchema,
},
onSubmit: () => {
setIsSubmitting(true);
try {
if (isEditing && application) {
toast.success("Application updated");
} else {
toast.success("Application created");
}
setOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to save application");
} finally {
setIsSubmitting(false);
}
},
});
return (
<Dialog open={open} onOpenChange={(open) => setOpen(open)}>
<DialogTrigger asChild>
{asDropdownItem ? (
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Pencil className="h-4 w-4" />
Edit
</DropdownMenuItem>
) : isEditing ? (
<Button variant="ghost" size="icon">
<Pencil className="h-4 w-4" />
</Button>
) : (
<Button>
<Plus className="h-4 w-4" />
New Application
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-106.25">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{isEditing ? "Edit Application" : "Add Application"}
</DialogTitle>
<DialogDescription className="py-2">
{isEditing
? "Update the application configuration."
: "Add a new application to monitor and manage."}
</DialogDescription>
</DialogHeader>

<form
onSubmit={async (e) => {
e.preventDefault();
await form.handleSubmit();
}}
>
<FieldGroup>
<form.Field
name="name"
children={(field) => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<Label htmlFor={field.name}>Name</Label>
<Input
id={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
placeholder='e.g. "notifications-service"'
autoFocus
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
/>

<div className="flex gap-2 pt-2">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : isEditing ? "Update Application" : "Add Application"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setOpen(false);
form.reset();
}}
disabled={isSubmitting}
>
Cancel
</Button>
</div>
</FieldGroup>
</form>
</DialogContent>
</Dialog>
);
}
71 changes: 71 additions & 0 deletions frontend/src/components/status-badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { HealthStatus, SyncStatus } from "@/lib/applications";
import { cn } from "@/lib/utils";

type StatusBadgeProps =
| { type: "sync"; status: SyncStatus }
| { type: "health"; status: HealthStatus };

const syncConfig: Record<SyncStatus, { label: string; className: string; dotClass: string }> = {
Synced: {
label: "Synced",
className: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30",
dotClass: "bg-emerald-400",
},
OutOfSync: {
label: "Out of Sync",
className: "bg-amber-500/20 text-amber-400 border-amber-500/30",
dotClass: "bg-amber-400",
},
Progressing: {
label: "Syncing",
className: "bg-blue-500/20 text-blue-400 border-blue-500/30",
dotClass: "bg-blue-400 animate-pulse",
},
Unknown: {
label: "Unknown",
className: "bg-zinc-500/20 text-zinc-400 border-zinc-500/30",
dotClass: "bg-zinc-400",
},
};

const healthConfig: Record<HealthStatus, { label: string; className: string; dotClass: string }> = {
Healthy: {
label: "Healthy",
className: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30",
dotClass: "bg-emerald-400",
},
Degraded: {
label: "Degraded",
className: "bg-amber-500/20 text-amber-400 border-amber-500/30",
dotClass: "bg-amber-400",
},
Progressing: {
label: "Unhealthy",
className: "bg-red-500/20 text-red-400 border-red-500/30",
dotClass: "bg-red-400",
},
Unknown: {
label: "Unknown",
className: "bg-zinc-500/20 text-zinc-400 border-zinc-500/30",
dotClass: "bg-zinc-400",
},
};

export function StatusBadge(props: StatusBadgeProps) {
let config =
props.type === "sync"
? syncConfig[props.status as SyncStatus]
: healthConfig[props.status as HealthStatus];

return (
<span
className={cn(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border",
config.className,
)}
>
Comment thread
alex289 marked this conversation as resolved.
Outdated
<span className={cn("h-1.5 w-1.5 rounded-full", config.dotClass)} />
{config.label}
</span>
);
}
108 changes: 108 additions & 0 deletions frontend/src/components/tables/applications/columns.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type { ColumnDef } from "@tanstack/react-table";
import { MoreHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DataTableColumnHeader } from "../data-table-column-header";
import type { Application } from "@/lib/applications";
import { Link } from "@tanstack/react-router";
import { StatusBadge } from "@/components/status-badge";

export const columns: ColumnDef<Application>[] = [
{
accessorKey: "name",
header: ({ column }) => {
return <DataTableColumnHeader column={column} title="Name" />;
},
cell: ({ row }) => {
const app = row.original;
return (
<Link
to="/applications/$id"
params={{ id: app.id }}
className="font-medium hover:text-primary"
>
{app.name}
</Link>
);
},
},
{
id: "syncStatus",
accessorFn: (row) => row.syncStatus,
header: ({ column }) => {
return <DataTableColumnHeader column={column} title="Sync Status" />;
},
cell: ({ row }) => {
const app = row.original;
return <StatusBadge status={app.syncStatus} type="sync" />;
},
},
{
id: "healthStatus",
accessorFn: (row) => row.healthStatus,
header: ({ column }) => {
return <DataTableColumnHeader column={column} title="Health Status" />;
},
cell: ({ row }) => {
const app = row.original;
return <StatusBadge status={app.healthStatus} type="health" />;
},
},
{
accessorKey: "repo",
header: ({ column }) => {
return <DataTableColumnHeader column={column} title="Repository" />;
},
},
{
accessorKey: "agent",
header: ({ column }) => {
return <DataTableColumnHeader column={column} title="Agent" />;
},
},
{
accessorKey: "branch",
header: ({ column }) => {
return <DataTableColumnHeader column={column} title="Branch" />;
},
},
{
accessorKey: "path",
header: ({ column }) => {
return <DataTableColumnHeader column={column} title="Path" />;
},
},
{
accessorKey: "lastSync",
header: ({ column }) => {
return <DataTableColumnHeader column={column} title="Last Sync" />;
},
},
{
id: "actions",
cell: () => {
return (
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>Sync</DropdownMenuItem>
<DropdownMenuItem>Refresh</DropdownMenuItem>
Comment thread
alex289 marked this conversation as resolved.
Outdated
Comment thread
alex289 marked this conversation as resolved.
Outdated
<DropdownMenuItem>Settings</DropdownMenuItem>
<DropdownMenuItem className="text-destructive">Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
Loading
Loading