Merge pull request #420 from Dokploy/feat/requests

Feat(Monitoring Logs)
This commit is contained in:
Mauricio Siu
2024-09-05 11:31:31 -06:00
committed by GitHub
38 changed files with 5427 additions and 40 deletions

2
apps/api/.env.example Normal file
View File

@@ -0,0 +1,2 @@
LEMON_SQUEEZY_API_KEY=""
LEMON_SQUEEZY_STORE_ID=""

28
apps/api/.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# dev
.yarn/
!.yarn/releases
.vscode/*
!.vscode/launch.json
!.vscode/*.code-snippets
.idea/workspace.xml
.idea/usage.statistics.xml
.idea/shelf
# deps
node_modules/
# env
.env
.env.production
# logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# misc
.DS_Store

8
apps/api/README.md Normal file
View File

@@ -0,0 +1,8 @@
```
npm install
npm run dev
```
```
open http://localhost:3000
```

15
apps/api/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "my-app",
"scripts": {
"dev": "tsx watch src/index.ts"
},
"dependencies": {
"@hono/node-server": "^1.12.1",
"hono": "^4.5.8",
"dotenv": "^16.3.1"
},
"devDependencies": {
"@types/node": "^20.11.17",
"tsx": "^4.7.1"
}
}

66
apps/api/src/index.ts Normal file
View File

@@ -0,0 +1,66 @@
import { serve } from "@hono/node-server";
import { config } from "dotenv";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { validateLemonSqueezyLicense } from "./utils";
config();
const app = new Hono();
app.use(
"/*",
cors({
origin: ["http://localhost:3000", "http://localhost:3001"], // Ajusta esto a los orígenes de tu aplicación Next.js
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowHeaders: ["Content-Type", "Authorization"],
exposeHeaders: ["Content-Length", "X-Kuma-Revision"],
maxAge: 600,
credentials: true,
}),
);
export const LEMON_SQUEEZY_API_KEY = process.env.LEMON_SQUEEZY_API_KEY;
export const LEMON_SQUEEZY_STORE_ID = process.env.LEMON_SQUEEZY_STORE_ID;
app.get("/v1/health", (c) => {
return c.text("Hello Hono!");
});
app.post("/v1/validate-license", async (c) => {
const { licenseKey } = await c.req.json();
if (!licenseKey) {
return c.json({ error: "License key is required" }, 400);
}
try {
const licenseValidation = await validateLemonSqueezyLicense(licenseKey);
if (licenseValidation.valid) {
return c.json({
valid: true,
message: "License is valid",
metadata: licenseValidation.meta,
});
}
return c.json(
{
valid: false,
message: licenseValidation.error || "Invalid license",
},
400,
);
} catch (error) {
console.error("Error during license validation:", error);
return c.json({ error: "Internal server error" }, 500);
}
});
const port = 4000;
console.log(`Server is running on port ${port}`);
serve({
fetch: app.fetch,
port,
});

16
apps/api/src/types.ts Normal file
View File

@@ -0,0 +1,16 @@
export interface LemonSqueezyLicenseResponse {
valid: boolean;
error?: string;
meta?: {
store_id: string;
order_id: number;
order_item_id: number;
product_id: number;
product_name: string;
variant_id: number;
variant_name: string;
customer_id: number;
customer_name: string;
customer_email: string;
};
}

28
apps/api/src/utils.ts Normal file
View File

@@ -0,0 +1,28 @@
import { LEMON_SQUEEZY_API_KEY, LEMON_SQUEEZY_STORE_ID } from ".";
import type { LemonSqueezyLicenseResponse } from "./types";
export const validateLemonSqueezyLicense = async (
licenseKey: string,
): Promise<LemonSqueezyLicenseResponse> => {
try {
const response = await fetch(
"https://api.lemonsqueezy.com/v1/licenses/validate",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": LEMON_SQUEEZY_API_KEY as string,
},
body: JSON.stringify({
license_key: licenseKey,
store_id: LEMON_SQUEEZY_STORE_ID as string,
}),
},
);
return response.json();
} catch (error) {
console.error("Error validating license:", error);
return { valid: false, error: "Error validating license" };
}
};

12
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"types": ["node"],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx"
}
}

View File

@@ -330,6 +330,5 @@ test("Expect don't add suffix to dokploy-network in compose file with multiple s
const suffix = "testhash";
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
console.log(updatedComposeData);
expect(updatedComposeData).toEqual(expectedComposeFile4);
});

View File

@@ -0,0 +1,56 @@
import { parseRawConfig, processLogs } from "@/server/utils/access-log/utils";
import { describe, expect, it } from "vitest";
const sampleLogEntry = `{"ClientAddr":"172.19.0.1:56732","ClientHost":"172.19.0.1","ClientPort":"56732","ClientUsername":"-","DownstreamContentSize":0,"DownstreamStatus":304,"Duration":14729375,"OriginContentSize":0,"OriginDuration":14051833,"OriginStatus":304,"Overhead":677542,"RequestAddr":"s222-umami-c381af.traefik.me","RequestContentSize":0,"RequestCount":122,"RequestHost":"s222-umami-c381af.traefik.me","RequestMethod":"GET","RequestPath":"/dashboard?_rsc=1rugv","RequestPort":"-","RequestProtocol":"HTTP/1.1","RequestScheme":"http","RetryAttempts":0,"RouterName":"s222-umami-60e104-47-web@docker","ServiceAddr":"10.0.1.15:3000","ServiceName":"s222-umami-60e104-47-web@docker","ServiceURL":{"Scheme":"http","Opaque":"","User":null,"Host":"10.0.1.15:3000","Path":"","RawPath":"","ForceQuery":false,"RawQuery":"","Fragment":"","RawFragment":""},"StartLocal":"2024-08-25T04:34:37.306691884Z","StartUTC":"2024-08-25T04:34:37.306691884Z","entryPointName":"web","level":"info","msg":"","time":"2024-08-25T04:34:37Z"}`;
describe("processLogs", () => {
it("should process a single log entry correctly", () => {
const result = processLogs(sampleLogEntry);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
hour: "2024-08-25T04:00:00Z",
count: 1,
});
});
it("should process multiple log entries and group by hour", () => {
const sampleLogEntry = `{"ClientAddr":"172.19.0.1:58094","ClientHost":"172.19.0.1","ClientPort":"58094","ClientUsername":"-","DownstreamContentSize":50,"DownstreamStatus":200,"Duration":35914250,"OriginContentSize":50,"OriginDuration":35817959,"OriginStatus":200,"Overhead":96291,"RequestAddr":"s222-pocketbase-f4a6e5.traefik.me","RequestContentSize":0,"RequestCount":991,"RequestHost":"s222-pocketbase-f4a6e5.traefik.me","RequestMethod":"GET","RequestPath":"/api/logs/stats?filter=","RequestPort":"-","RequestProtocol":"HTTP/1.1","RequestScheme":"http","RetryAttempts":0,"RouterName":"s222-pocketbase-e94e25-44-web@docker","ServiceAddr":"10.0.1.12:80","ServiceName":"s222-pocketbase-e94e25-44-web@docker","ServiceURL":{"Scheme":"http","Opaque":"","User":null,"Host":"10.0.1.12:80","Path":"","RawPath":"","ForceQuery":false,"RawQuery":"","Fragment":"","RawFragment":""},"StartLocal":"2024-08-25T17:44:29.274072471Z","StartUTC":"2024-08-25T17:44:29.274072471Z","entryPointName":"web","level":"info","msg":"","time":"2024-08-25T17:44:29Z"}
{"ClientAddr":"172.19.0.1:58108","ClientHost":"172.19.0.1","ClientPort":"58108","ClientUsername":"-","DownstreamContentSize":30975,"DownstreamStatus":200,"Duration":31406458,"OriginContentSize":30975,"OriginDuration":31046791,"OriginStatus":200,"Overhead":359667,"RequestAddr":"s222-pocketbase-f4a6e5.traefik.me","RequestContentSize":0,"RequestCount":992,"RequestHost":"s222-pocketbase-f4a6e5.traefik.me","RequestMethod":"GET","RequestPath":"/api/logs?page=1\u0026perPage=50\u0026sort=-rowid\u0026skipTotal=1\u0026filter=","RequestPort":"-","RequestProtocol":"HTTP/1.1","RequestScheme":"http","RetryAttempts":0,"RouterName":"s222-pocketbase-e94e25-44-web@docker","ServiceAddr":"10.0.1.12:80","ServiceName":"s222-pocketbase-e94e25-44-web@docker","ServiceURL":{"Scheme":"http","Opaque":"","User":null,"Host":"10.0.1.12:80","Path":"","RawPath":"","ForceQuery":false,"RawQuery":"","Fragment":"","RawFragment":""},"StartLocal":"2024-08-25T17:44:29.278990221Z","StartUTC":"2024-08-25T17:44:29.278990221Z","entryPointName":"web","level":"info","msg":"","time":"2024-08-25T17:44:29Z"}
`;
const result = processLogs(sampleLogEntry);
expect(result).toHaveLength(1);
expect(result).toEqual([{ hour: "2024-08-25T17:00:00Z", count: 2 }]);
});
it("should return an empty array for empty input", () => {
expect(processLogs("")).toEqual([]);
expect(processLogs(null as any)).toEqual([]);
expect(processLogs(undefined as any)).toEqual([]);
});
// it("should parse a single log entry correctly", () => {
// const result = parseRawConfig(sampleLogEntry);
// expect(result).toHaveLength(1);
// expect(result.data[0]).toHaveProperty("ClientAddr", "172.19.0.1:56732");
// expect(result.data[0]).toHaveProperty(
// "StartUTC",
// "2024-08-25T04:34:37.306691884Z",
// );
// });
it("should parse multiple log entries", () => {
const multipleEntries = `${sampleLogEntry}\n${sampleLogEntry}`;
const result = parseRawConfig(multipleEntries);
expect(result.data).toHaveLength(2);
for (const entry of result.data) {
expect(entry).toHaveProperty("ClientAddr", "172.19.0.1:56732");
}
});
it("should handle whitespace and empty lines", () => {
const entryWithWhitespace = `\n${sampleLogEntry}\n\n${sampleLogEntry}\n`;
const result = parseRawConfig(entryWithWhitespace);
expect(result.data).toHaveLength(2);
});
});

View File

@@ -22,6 +22,7 @@ const baseAdmin: Admin = {
letsEncryptEmail: null,
sshPrivateKey: null,
enableDockerCleanup: false,
enableLogRotation: false,
};
beforeEach(() => {

View File

@@ -0,0 +1,92 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { ColumnDef } from "@tanstack/react-table";
import { format } from "date-fns";
import { ArrowUpDown } from "lucide-react";
import * as React from "react";
import type { LogEntry } from "./show-requests";
export const getStatusColor = (status: number) => {
if (status >= 100 && status < 200) {
return "outline";
}
if (status >= 200 && status < 300) {
return "default";
}
if (status >= 300 && status < 400) {
return "outline";
}
if (status >= 400 && status < 500) {
return "destructive";
}
return "destructive";
};
export const columns: ColumnDef<LogEntry>[] = [
{
accessorKey: "level",
header: ({ column }) => {
return <Button variant="ghost">Level</Button>;
},
cell: ({ row }) => {
return <div>{row.original.level}</div>;
},
},
{
accessorKey: "RequestPath",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Message
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const log = row.original;
return (
<div className=" flex flex-col gap-2">
<div>
{log.RequestMethod} {log.RequestPath}
</div>
<div className="flex flex-row gap-3 w-full">
<Badge variant={getStatusColor(log.OriginStatus)}>
Status: {log.OriginStatus}
</Badge>
<Badge variant={"secondary"}>
Exec Time: {`${log.Duration / 1000000000}s`}
</Badge>
<Badge variant={"secondary"}>IP: {log.ClientAddr}</Badge>
</div>
</div>
);
},
},
{
accessorKey: "time",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Time
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const log = row.original;
return (
<div className=" flex flex-col gap-2">
<div className="flex flex-row gap-3 w-full">
{format(new Date(log.StartUTC), "yyyy-MM-dd HH:mm:ss")}
</div>
</div>
);
},
},
];

View File

@@ -0,0 +1,80 @@
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { api } from "@/utils/api";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
XAxis,
YAxis,
} from "recharts";
const chartConfig = {
views: {
label: "Page Views",
},
count: {
label: "Count",
color: "hsl(var(--chart-1))",
},
} satisfies ChartConfig;
export const RequestDistributionChart = () => {
const { data: stats } = api.settings.readStats.useQuery(undefined, {
refetchInterval: 1333,
});
return (
<ResponsiveContainer width="100%" height={200}>
<ChartContainer config={chartConfig}>
<AreaChart
accessibilityLayer
data={stats || []}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="hour"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) =>
new Date(value).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
}
/>
<YAxis tickLine={false} axisLine={false} tickMargin={8} />
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="line" />}
labelFormatter={(value) =>
new Date(value).toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
/>
<Area
dataKey="count"
type="natural"
fill="hsl(var(--chart-1))"
fillOpacity={0.4}
stroke="hsl(var(--chart-1))"
/>
</AreaChart>
</ChartContainer>
</ResponsiveContainer>
);
};

View File

@@ -0,0 +1,370 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
import {
type ColumnFiltersState,
type PaginationState,
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import copy from "copy-to-clipboard";
import {
CheckCircle2Icon,
ChevronDown,
Copy,
Download,
Globe,
InfoIcon,
Server,
TrendingUpIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { columns, getStatusColor } from "./columns";
import type { LogEntry } from "./show-requests";
import { DataTableFacetedFilter } from "./status-request-filter";
export const priorities = [
{
label: "100 - 199",
value: "info",
icon: InfoIcon,
},
{
label: "200 - 299",
value: "success",
icon: CheckCircle2Icon,
},
{
label: "300 - 399",
value: "redirect",
icon: TrendingUpIcon,
},
{
label: "400 - 499",
value: "client",
icon: Globe,
},
{
label: "500 - 599",
value: "server",
icon: Server,
},
];
export const RequestsTable = () => {
const [statusFilter, setStatusFilter] = useState<string[]>([]);
const [search, setSearch] = useState("");
const [selectedRow, setSelectedRow] = useState<LogEntry>();
const [sorting, setSorting] = useState<SortingState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({});
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { data: statsLogs, isLoading } = api.settings.readStatsLogs.useQuery(
{
sort: sorting[0],
page: pagination,
search,
status: statusFilter,
},
{
refetchInterval: 1333,
},
);
const pageCount = useMemo(() => {
if (statsLogs?.totalCount) {
return Math.ceil(statsLogs.totalCount / pagination.pageSize);
}
return -1;
}, [statsLogs?.totalCount, pagination.pageSize]);
const table = useReactTable({
data: statsLogs?.data ?? [],
columns,
onPaginationChange: setPagination,
onSortingChange: setSorting,
pageCount: pageCount,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
manualPagination: true,
state: {
pagination,
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
});
const formatValue = (key: string, value: any) => {
if (typeof value === "object" && value !== null) {
return JSON.stringify(value, null, 2);
}
if (key === "Duration" || key === "OriginDuration" || key === "Overhead") {
return `${value / 1000000000} s`;
}
if (key === "level") {
return <Badge variant="secondary">{value}</Badge>;
}
if (key === "RequestMethod") {
return <Badge variant="outline">{value}</Badge>;
}
if (key === "DownstreamStatus" || key === "OriginStatus") {
return <Badge variant={getStatusColor(value)}>{value}</Badge>;
}
return value;
};
return (
<>
<div className="flex flex-col gap-6 w-full ">
<div className="mt-6 grid gap-4 pb-20 w-full">
<div className="flex flex-col gap-4 w-full overflow-auto">
<div className="flex items-center gap-2 max-sm:flex-wrap">
<Input
placeholder="Filter by name..."
value={search}
onChange={(event) => setSearch(event.target.value)}
className="md:max-w-sm"
/>
<DataTableFacetedFilter
value={statusFilter}
setValue={setStatusFilter}
title="Status"
options={priorities}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="sm:ml-auto max-sm:w-full"
>
Columns <ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="rounded-md border ">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
className="cursor-pointer"
onClick={() => {
setSelectedRow(row.original);
}}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
{statsLogs?.data.length === 0 && (
<div className="w-full flex-col gap-2 flex items-center justify-center h-[55vh]">
<span className="text-muted-foreground text-lg font-medium">
No results.
</span>
</div>
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
{statsLogs?.totalCount && (
<span className="text-muted-foreground text-sm">
Showing{" "}
{Math.min(
pagination.pageIndex * pagination.pageSize + 1,
statsLogs.totalCount,
)}{" "}
to{" "}
{Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
statsLogs.totalCount,
)}{" "}
of {statsLogs.totalCount} entries
</span>
)}
<div className="space-x-2 flex flex-wrap">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
</div>
</div>
<Sheet
open={!!selectedRow}
onOpenChange={(open) => setSelectedRow(undefined)}
>
<SheetContent className="sm:max-w-[740px] flex flex-col">
<SheetHeader>
<SheetTitle>Request log</SheetTitle>
<SheetDescription>
Details of the request log entry.
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-grow mt-4 pr-4">
<div className="border rounded-md">
<Table>
<TableBody>
{Object.entries(selectedRow || {}).map(([key, value]) => (
<TableRow key={key}>
<TableCell className="font-medium">{key}</TableCell>
<TableCell className="truncate break-words break-before-all whitespace-pre-wrap">
{key === "RequestAddr" ? (
<div className="flex items-center gap-2 bg-muted p-1 rounded">
<span>{value}</span>
<Copy
onClick={() => {
copy(value);
toast.success("Copied to clipboard");
}}
className="h-4 w-4 text-muted-foreground cursor-pointer"
/>
</div>
) : (
formatValue(key, value)
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</ScrollArea>
<div className="mt-4 pt-4 border-t">
<Button
variant="outline"
className="w-full gap-2"
onClick={() => {
const logs = JSON.stringify(selectedRow, null, 2);
const element = document.createElement("a");
element.setAttribute(
"href",
`data:text/plain;charset=utf-8,${encodeURIComponent(logs)}`,
);
element.setAttribute("download", "logs.json");
element.style.display = "none";
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}}
>
<Download className="h-4 w-4" />
Download as JSON
</Button>
</div>
</SheetContent>
</Sheet>
</>
);
};

View File

@@ -0,0 +1,78 @@
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { type RouterOutputs, api } from "@/utils/api";
import * as React from "react";
import { toast } from "sonner";
import { RequestDistributionChart } from "./request-distribution-chart";
import { RequestsTable } from "./requests-table";
export type LogEntry = NonNullable<
RouterOutputs["settings"]["readStatsLogs"]["data"]
>[0];
export const ShowRequests = () => {
const { data: isLogRotateActive, refetch: refetchLogRotate } =
api.settings.getLogRotateStatus.useQuery();
const { mutateAsync } = api.settings.activateLogRotate.useMutation();
const { mutateAsync: deactivateLogRotate } =
api.settings.deactivateLogRotate.useMutation();
return (
<>
<Card className="bg-transparent mt-10">
<CardHeader>
<CardTitle>Request Distribution</CardTitle>
<div className="flex justify-between gap-2">
<CardDescription>
<span>Showing web and API requests over time</span>
</CardDescription>
{!isLogRotateActive && (
<Button
onClick={() => {
mutateAsync()
.then(() => {
toast.success("Log rotate activated");
refetchLogRotate();
})
.catch((err) => {
toast.error(err.message);
});
}}
>
Activate Log Rotate
</Button>
)}
{isLogRotateActive && (
<Button
onClick={() => {
deactivateLogRotate()
.then(() => {
toast.success("Log rotate deactivated");
refetchLogRotate();
})
.catch((err) => {
toast.error(err.message);
});
}}
>
Deactivate Log Rotate
</Button>
)}
</div>
</CardHeader>
<CardContent>
<RequestDistributionChart />
</CardContent>
</Card>
<RequestsTable />
</>
);
};

View File

@@ -0,0 +1,138 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { CheckIcon, PlusCircle } from "lucide-react";
interface DataTableFacetedFilterProps {
value?: string[];
setValue?: (value: string[]) => void;
title?: string;
options: {
label: string;
value: string;
icon?: React.ComponentType<{ className?: string }>;
}[];
}
export function DataTableFacetedFilter({
value = [],
setValue,
title,
options,
}: DataTableFacetedFilterProps) {
const selectedValues = new Set(value as string[]);
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 border-dashed">
<PlusCircle className="mr-2 h-4 w-4" />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal lg:hidden"
>
{selectedValues.size}
</Badge>
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal"
>
{selectedValues.size} selected
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant="secondary"
key={option.value}
className="rounded-sm px-1 font-normal"
>
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder={title} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = selectedValues.has(option.value);
return (
<CommandItem
key={option.value}
onSelect={() => {
if (isSelected) {
selectedValues.delete(option.value);
} else {
selectedValues.add(option.value);
}
const filterValues = Array.from(selectedValues);
setValue?.(filterValues.length ? filterValues : []);
}}
>
<div
className={cn(
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible",
)}
>
<CheckIcon className={cn("h-4 w-4")} />
</div>
{option.icon && (
<option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
)}
<span>{option.label}</span>
</CommandItem>
);
})}
</CommandGroup>
{selectedValues.size > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => setValue?.([])}
className="justify-center text-center"
>
Clear filters
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -19,6 +19,7 @@ export type TabState =
| "monitoring"
| "settings"
| "traefik"
| "requests"
| "docker";
const tabMap: Record<TabState, TabInfo> = {
@@ -49,6 +50,14 @@ const tabMap: Record<TabState, TabInfo> = {
return Boolean(rol === "admin" || user?.canAccessToDocker);
},
},
requests: {
label: "Requests",
description: "Manage your requests",
index: "/dashboard/requests",
isShow: ({ rol, user }) => {
return Boolean(rol === "admin" || user?.canAccessToDocker);
},
},
settings: {
label: "Settings",
description: "Manage your settings",

View File

@@ -119,6 +119,7 @@ import {
Bell,
Database,
GitBranch,
KeyIcon,
KeyRound,
type LucideIcon,
Route,

View File

@@ -0,0 +1,363 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "Chart";
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([_, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref,
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
},
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref,
) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
},
);
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@@ -29,7 +29,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-300",
{
variants: {
side: {

View File

@@ -0,0 +1 @@
ALTER TABLE "admin" ADD COLUMN "enableLogRotation" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -253,6 +253,13 @@
"when": 1725429324584,
"tag": "0035_cool_gravity",
"breakpoints": true
},
{
"idx": 36,
"version": "6",
"when": 1725519351871,
"tag": "0036_tired_ronan",
"breakpoints": true
}
]
}

View File

@@ -34,6 +34,7 @@
"test": "vitest --config __test__/vitest.config.ts"
},
"dependencies": {
"rotating-file-stream": "3.2.3",
"@aws-sdk/client-s3": "3.515.0",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-yaml": "^6.1.1",
@@ -114,7 +115,7 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.49.3",
"recharts": "^2.12.3",
"recharts": "^2.12.7",
"slugify": "^1.6.6",
"sonner": "^1.4.0",
"superjson": "^2.2.1",
@@ -127,7 +128,9 @@
"ws": "8.16.0",
"xterm-addon-fit": "^0.8.0",
"zod": "^3.23.4",
"zod-form-data": "^2.0.2"
"zod-form-data": "^2.0.2",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-controllable-state": "1.1.0"
},
"devDependencies": {
"@biomejs/biome": "1.8.3",

View File

@@ -0,0 +1,30 @@
import { ShowRequests } from "@/components/dashboard/requests/show-requests";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { validateRequest } from "@/server/auth/auth";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import * as React from "react";
export default function Requests() {
return <ShowRequests />;
}
Requests.getLayout = (page: ReactElement) => {
return <DashboardLayout tab={"requests"}>{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { user } = await validateRequest(ctx.req, ctx.res);
if (!user) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
return {
props: {},
};
}

View File

@@ -1,14 +1,17 @@
import { MAIN_TRAEFIK_PATH, MONITORING_PATH, docker } from "@/server/constants";
import { MAIN_TRAEFIK_PATH, MONITORING_PATH } from "@/server/constants";
import {
apiAssignDomain,
apiEnableDashboard,
apiModifyTraefikConfig,
apiReadStatsLogs,
apiReadTraefikConfig,
apiSaveSSHKey,
apiTraefikConfig,
apiUpdateDockerCleanup,
} from "@/server/db/schema";
import { initializeTraefik } from "@/server/setup/traefik-setup";
import { logRotationManager } from "@/server/utils/access-log/handler";
import { parseRawConfig, processLogs } from "@/server/utils/access-log/utils";
import {
cleanStoppedContainers,
cleanUpDockerBuilder,
@@ -26,6 +29,7 @@ import { spawnAsync } from "@/server/utils/process/spawnAsync";
import {
readConfig,
readConfigInPath,
readMonitoringConfig,
writeConfig,
writeTraefikConfigInPath,
} from "@/server/utils/traefik/application";
@@ -350,4 +354,32 @@ export const settingsRouter = createTRPCRouter({
return false;
}),
readStatsLogs: adminProcedure.input(apiReadStatsLogs).query(({ input }) => {
const rawConfig = readMonitoringConfig();
const parsedConfig = parseRawConfig(
rawConfig as string,
input.page,
input.sort,
input.search,
input.status,
);
return parsedConfig;
}),
readStats: adminProcedure.query(() => {
const rawConfig = readMonitoringConfig();
const processedLogs = processLogs(rawConfig as string);
return processedLogs || [];
}),
activateLogRotate: adminProcedure.mutation(async () => {
await logRotationManager.activate();
return true;
}),
deactivateLogRotate: adminProcedure.mutation(async () => {
return await logRotationManager.deactivate();
}),
getLogRotateStatus: adminProcedure.query(async () => {
return await logRotationManager.getStatus();
}),
});

View File

@@ -19,6 +19,7 @@ export const admins = pgTable("admin", {
letsEncryptEmail: text("letsEncryptEmail"),
sshPrivateKey: text("sshPrivateKey"),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
enableLogRotation: boolean("enableLogRotation").notNull().default(false),
authId: text("authId")
.notNull()
.references(() => auth.id, { onDelete: "cascade" }),
@@ -79,3 +80,15 @@ export const apiReadTraefikConfig = z.object({
export const apiEnableDashboard = z.object({
enableDashboard: z.boolean().optional(),
});
export const apiReadStatsLogs = z.object({
page: z
.object({
pageIndex: z.number(),
pageSize: z.number(),
})
.optional(),
status: z.string().array().optional(),
search: z.string().optional(),
sort: z.object({ id: z.string(), desc: z.boolean() }).optional(),
});

View File

@@ -0,0 +1,114 @@
import { findAdmin, updateAdmin } from "@/server/api/services/admin";
import { DYNAMIC_TRAEFIK_PATH } from "@/server/constants";
import { type RotatingFileStream, createStream } from "rotating-file-stream";
import { execAsync } from "../process/execAsync";
class LogRotationManager {
private static instance: LogRotationManager;
private stream: RotatingFileStream | null = null;
private constructor() {
this.initialize().catch(console.error);
}
public static getInstance(): LogRotationManager {
if (!LogRotationManager.instance) {
LogRotationManager.instance = new LogRotationManager();
}
return LogRotationManager.instance;
}
private async initialize(): Promise<void> {
const isActive = await this.getStateFromDB();
if (isActive) {
await this.activateStream();
}
console.log(`Log rotation initialized. Active: ${isActive}`);
}
private async getStateFromDB(): Promise<boolean> {
const setting = await findAdmin();
return setting?.enableLogRotation ?? false;
}
private async setStateInDB(active: boolean): Promise<void> {
const admin = await findAdmin();
await updateAdmin(admin.authId, {
enableLogRotation: active,
});
}
private async activateStream(): Promise<void> {
if (this.stream) {
await this.deactivateStream();
}
this.stream = createStream("access.log", {
size: "100M",
interval: "1d",
path: DYNAMIC_TRAEFIK_PATH,
rotate: 6,
compress: "gzip",
});
this.stream.on("rotation", this.handleRotation.bind(this));
}
private async deactivateStream(): Promise<void> {
return new Promise<void>((resolve) => {
if (this.stream) {
this.stream.end(() => {
this.stream = null;
resolve();
});
} else {
resolve();
}
});
}
public async activate(): Promise<boolean> {
const currentState = await this.getStateFromDB();
if (currentState) {
return true;
}
await this.setStateInDB(true);
await this.activateStream();
return true;
}
public async deactivate(): Promise<boolean> {
console.log("Deactivating log rotation...");
const currentState = await this.getStateFromDB();
if (!currentState) {
console.log("Log rotation is already inactive in DB");
return true;
}
await this.setStateInDB(false);
await this.deactivateStream();
console.log("Log rotation deactivated successfully");
return true;
}
private async handleRotation() {
try {
const status = await this.getStatus();
if (!status) {
await this.deactivateStream();
}
await execAsync(
"docker kill -s USR1 $(docker ps -q --filter name=traefik)",
);
console.log("USR1 Signal send to Traefik");
} catch (error) {
console.error("Error to send USR1 Signal to Traefik:", error);
}
}
public async getStatus(): Promise<boolean> {
const dbState = await this.getStateFromDB();
return dbState;
}
}
export const logRotationManager = LogRotationManager.getInstance();

View File

@@ -0,0 +1,48 @@
export interface LogEntry {
ClientAddr: string;
ClientHost: string;
ClientPort: string;
ClientUsername: string;
DownstreamContentSize: number;
DownstreamStatus: number;
Duration: number;
OriginContentSize: number;
OriginDuration: number;
OriginStatus: number;
Overhead: number;
RequestAddr: string;
RequestContentSize: number;
RequestCount: number;
RequestHost: string;
RequestMethod: string;
RequestPath: string;
RequestPort: string;
RequestProtocol: string;
RequestScheme: string;
RetryAttempts: number;
RouterName: string;
ServiceAddr: string;
ServiceName: string;
ServiceURL: {
Scheme: string;
Opaque: string;
User: null;
Host: string;
Path: string;
RawPath: string;
ForceQuery: boolean;
RawQuery: string;
Fragment: string;
RawFragment: string;
};
StartLocal: string;
StartUTC: string;
downstream_Content_Type: string;
entryPointName: string;
level: string;
msg: string;
origin_Content_Type: string;
request_Content_Type: string;
request_User_Agent: string;
time: string;
}

View File

@@ -0,0 +1,113 @@
import _ from "lodash";
import type { LogEntry } from "./types";
interface HourlyData {
hour: string;
count: number;
}
export function processLogs(logString: string): HourlyData[] {
if (_.isEmpty(logString)) {
return [];
}
const hourlyData = _(logString)
.split("\n")
.compact()
.map((entry) => {
try {
const log: LogEntry = JSON.parse(entry);
const date = new Date(log.StartUTC);
return `${date.toISOString().slice(0, 13)}:00:00Z`;
} catch (error) {
console.error("Error parsing log entry:", error);
return null;
}
})
.compact()
.countBy()
.map((count, hour) => ({ hour, count }))
.value();
return _.sortBy(hourlyData, (entry) => new Date(entry.hour).getTime());
}
interface PageInfo {
pageIndex: number;
pageSize: number;
}
interface SortInfo {
id: string;
desc: boolean;
}
export function parseRawConfig(
rawConfig: string,
page?: PageInfo,
sort?: SortInfo,
search?: string,
status?: string[],
): { data: LogEntry[]; totalCount: number } {
try {
if (_.isEmpty(rawConfig)) {
return { data: [], totalCount: 0 };
}
let parsedLogs = _(rawConfig)
.split("\n")
.compact()
.map((line) => JSON.parse(line) as LogEntry)
.value();
if (search) {
parsedLogs = parsedLogs.filter((log) =>
log.RequestPath.toLowerCase().includes(search.toLowerCase()),
);
}
if (status && status.length > 0) {
parsedLogs = parsedLogs.filter((log) =>
status.some((range) => isStatusInRange(log.DownstreamStatus, range)),
);
}
const totalCount = parsedLogs.length;
if (sort) {
parsedLogs = _.orderBy(
parsedLogs,
[sort.id],
[sort.desc ? "desc" : "asc"],
);
} else {
parsedLogs = _.orderBy(parsedLogs, ["time"], ["desc"]);
}
if (page) {
const startIndex = page.pageIndex * page.pageSize;
parsedLogs = parsedLogs.slice(startIndex, startIndex + page.pageSize);
}
return { data: parsedLogs, totalCount };
} catch (error) {
console.error("Error parsing rawConfig:", error);
throw new Error("Failed to parse rawConfig");
}
}
const isStatusInRange = (status: number, range: string) => {
switch (range) {
case "info":
return status >= 100 && status <= 199;
case "success":
return status >= 200 && status <= 299;
case "redirect":
return status >= 300 && status <= 399;
case "client":
return status >= 400 && status <= 499;
case "server":
return status >= 500 && status <= 599;
default:
return false;
}
};

View File

@@ -76,6 +76,15 @@ export const readConfig = (appName: string) => {
return null;
};
export const readMonitoringConfig = () => {
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, "access.log");
if (fs.existsSync(configPath)) {
const yamlStr = fs.readFileSync(configPath, "utf8");
return yamlStr;
}
return null;
};
export const readConfigInPath = (pathFile: string) => {
const configPath = path.join(pathFile);
if (fs.existsSync(configPath)) {

View File

@@ -34,6 +34,12 @@
--radius: 0.5rem;
--overlay: rgba(0, 0, 0, 0.2);
--chart-1: 173 58% 39%;
--chart-2: 12 76% 61%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
@@ -65,7 +71,13 @@
--input: 240 4% 10%;
--ring: 240 4.9% 83.9%;
--overlay: rgba(0, 0, 0, 0.5);
--overlay: rgba(0, 0, 0, 0.5);
--chart-1: 220 70% 50%;
--chart-5: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-2: 340 75% 55%;
}
}
@@ -125,7 +137,6 @@
@apply min-h-[25rem];
}
@keyframes heartbeat {
0% {
transform: scale(1);
@@ -157,9 +168,8 @@
background-color: white;
}
.swagger-ui .info{
.swagger-ui .info {
margin: 0px !important;
padding-top: 1rem !important;
}
}
}

View File

@@ -15,7 +15,13 @@ services:
- dokploy-network
restart: unless-stopped
healthcheck:
test: [ "CMD", "node", "-e", "require('http').get('http://localhost:3000/api/profile', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})" ]
test:
[
"CMD",
"node",
"-e",
"require('http').get('http://localhost:3000/api/profile', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})",
]
timeout: 5s
interval: 5s
retries: 3
@@ -57,11 +63,6 @@ services:
expose:
- 8000
- 8443
labels:
- traefik.enable=true
- traefik.http.routers.frontend-app.rule=Host(`${SUPABASE_HOST}`)
- traefik.http.routers.frontend-app.entrypoints=web
- traefik.http.services.frontend-app.loadbalancer.server.port=${KONG_HTTP_PORT}
depends_on:
analytics:
condition: service_healthy
@@ -93,7 +94,15 @@ services:
analytics:
condition: service_healthy
healthcheck:
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9999/health" ]
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://localhost:9999/health",
]
timeout: 5s
interval: 5s
retries: 3
@@ -145,9 +154,6 @@ services:
# GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_ENABLED="true"
# GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI="pg-functions://postgres/public/password_verification_attempt"
rest:
container_name: supabase-rest
image: postgrest/postgrest:v12.2.0
@@ -183,7 +189,18 @@ services:
analytics:
condition: service_healthy
healthcheck:
test: [ "CMD", "curl", "-sSfL", "--head", "-o", "/dev/null", "-H", "Authorization: Bearer ${ANON_KEY}", "http://localhost:4000/api/tenants/realtime-dev/health" ]
test:
[
"CMD",
"curl",
"-sSfL",
"--head",
"-o",
"/dev/null",
"-H",
"Authorization: Bearer ${ANON_KEY}",
"http://localhost:4000/api/tenants/realtime-dev/health",
]
timeout: 5s
interval: 5s
retries: 3
@@ -195,7 +212,7 @@ services:
DB_USER: supabase_admin
DB_PASSWORD: ${POSTGRES_PASSWORD}
DB_NAME: ${POSTGRES_DB}
DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime"
DB_ENC_KEY: supabaserealtime
API_JWT_SECRET: ${JWT_SECRET}
SECRET_KEY_BASE: UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq
@@ -220,7 +237,15 @@ services:
imgproxy:
condition: service_started
healthcheck:
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:5000/status" ]
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://localhost:5000/status",
]
timeout: 5s
interval: 5s
retries: 3
@@ -249,7 +274,7 @@ services:
networks:
- dokploy-network
healthcheck:
test: [ "CMD", "imgproxy", "health" ]
test: ["CMD", "imgproxy", "health"]
timeout: 5s
interval: 5s
retries: 3
@@ -311,7 +336,7 @@ services:
networks:
- dokploy-network
healthcheck:
test: [ "CMD", "curl", "http://localhost:4000/health" ]
test: ["CMD", "curl", "http://localhost:4000/health"]
timeout: 5s
interval: 5s
retries: 10
@@ -410,13 +435,12 @@ services:
healthcheck:
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://vector:9001/health"
"http://vector:9001/health",
]
timeout: 5s
interval: 5s
@@ -426,12 +450,11 @@ services:
- ${DOCKER_SOCKET_LOCATION}:/var/run/docker.sock:ro
environment:
LOGFLARE_API_KEY: ${LOGFLARE_API_KEY}
command: [ "--config", "etc/vector/vector.yml" ]
command: ["--config", "etc/vector/vector.yml"]
volumes:
db-config:
networks:
dokploy-network:
external: true

View File

@@ -1,9 +1,9 @@
import { createHmac, randomBytes } from "node:crypto";
import { createHmac } from "node:crypto";
import {
type DomainSchema,
type Schema,
type Template,
generateBase64,
generateHash,
generatePassword,
generateRandomDomain,
} from "../utils";
@@ -61,8 +61,7 @@ export function generateSupabaseServiceJWT(secret: string): string {
}
export function generate(schema: Schema): Template {
const mainServiceHash = generateHash(schema.projectName);
const randomDomain = generateRandomDomain(schema);
const mainDomain = generateRandomDomain(schema);
const postgresPassword = generatePassword(32);
const jwtSecret = generateBase64(32);
@@ -71,9 +70,16 @@ export function generate(schema: Schema): Template {
const annonKey = generateSupabaseAnonJWT(jwtSecret);
const serviceRoleKey = generateSupabaseServiceJWT(jwtSecret);
const domains: DomainSchema[] = [
{
serviceName: "kong",
host: mainDomain,
port: 8000,
},
];
const envs = [
`SUPABASE_HOST=${randomDomain}`,
`SUPABASE_HOST=${mainDomain}`,
`POSTGRES_PASSWORD=${postgresPassword}`,
`JWT_SECRET=${jwtSecret}`,
`ANON_KEY=${annonKey}`,
@@ -114,7 +120,6 @@ export function generate(schema: Schema): Template {
"DOCKER_SOCKET_LOCATION=/var/run/docker.sock",
"GOOGLE_PROJECT_ID=GOOGLE_PROJECT_ID",
"GOOGLE_PROJECT_NUMBER=GOOGLE_PROJECT_NUMBER",
`HASH=${mainServiceHash}`,
];
const mounts: Template["mounts"] = [
@@ -983,6 +988,7 @@ sinks:
];
return {
domains,
envs,
mounts,
};

View File

@@ -28,7 +28,7 @@ export const generateRandomDomain = ({
}: Schema): string => {
const hash = randomBytes(3).toString("hex");
const slugIp = serverIp.replaceAll(".", "-");
return `${projectName}-${hash}-${slugIp}.traefik.me`;
return `${projectName}-${hash}${process.env.NODE_ENV === "production" ? `-${slugIp}` : ""}.traefik.me`;
};
export const generateHash = (projectName: string, quantity = 3): string => {

View File

@@ -8,6 +8,9 @@
},
"linter": {
"rules": {
"security": {
"noDangerouslySetInnerHtml": "off"
},
"complexity": {
"noUselessCatch": "off",
"noBannedTypes": "off"

54
pnpm-lock.yaml generated
View File

@@ -36,6 +36,25 @@ importers:
specifier: 4.16.2
version: 4.16.2
apps/api:
dependencies:
'@hono/node-server':
specifier: ^1.12.1
version: 1.12.1
dotenv:
specifier: ^16.3.1
version: 16.4.5
hono:
specifier: ^4.5.8
version: 4.5.8
devDependencies:
'@types/node':
specifier: ^20.11.17
version: 20.14.10
tsx:
specifier: ^4.7.1
version: 4.16.2
apps/docs:
dependencies:
fumadocs-core:
@@ -159,6 +178,9 @@ importers:
'@radix-ui/react-popover':
specifier: ^1.0.7
version: 1.1.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-primitive':
specifier: 2.0.0
version: 2.0.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-progress':
specifier: ^1.0.3
version: 1.1.0(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -189,6 +211,9 @@ importers:
'@radix-ui/react-tooltip':
specifier: ^1.0.7
version: 1.1.2(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@radix-ui/react-use-controllable-state':
specifier: 1.1.0
version: 1.1.0(@types/react@18.3.3)(react@18.2.0)
'@react-email/components':
specifier: ^0.0.21
version: 0.0.21(@types/react@18.3.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -340,8 +365,11 @@ importers:
specifier: ^7.49.3
version: 7.52.1(react@18.2.0)
recharts:
specifier: ^2.12.3
specifier: ^2.12.7
version: 2.12.7(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
rotating-file-stream:
specifier: 3.2.3
version: 3.2.3
slugify:
specifier: ^1.6.6
version: 1.6.6
@@ -1653,6 +1681,10 @@ packages:
peerDependencies:
tailwindcss: ^3.0
'@hono/node-server@1.12.1':
resolution: {integrity: sha512-C9l+08O8xtXB7Ppmy8DjBFH1hYji7JKzsU32Yt1poIIbdPp6S7aOI8IldDHD9YFJ55lv2c21ovNrmxatlHfhAg==}
engines: {node: '>=18.14.1'}
'@hookform/resolvers@3.9.0':
resolution: {integrity: sha512-bU0Gr4EepJ/EQsH/IwEzYLsT/PEj5C0ynLQ4m+GSHS+xKH4TfSelhluTgOaoc4kA5s7eCsQbM4wvZLzELmWzUg==}
peerDependencies:
@@ -5655,6 +5687,10 @@ packages:
highlight.js@10.7.3:
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
hono@4.5.8:
resolution: {integrity: sha512-pqpSlcdqGkpTTRpLYU1PnCz52gVr0zVR9H5GzMyJWuKQLLEBQxh96q45QizJ2PPX8NATtz2mu31/PKW/Jt+90Q==}
engines: {node: '>=16.0.0'}
html-to-text@9.0.5:
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
engines: {node: '>=14'}
@@ -7488,6 +7524,10 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
rotating-file-stream@3.2.3:
resolution: {integrity: sha512-cfmm3tqdnbuYw2FBmRTPBDaohYEbMJ3211T35o6eZdr4d7v69+ZeK1Av84Br7FLj2dlzyeZSbN6qTuXXE6dawQ==}
engines: {node: '>=14.0'}
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -9657,6 +9697,8 @@ snapshots:
dependencies:
tailwindcss: 3.4.7
'@hono/node-server@1.12.1': {}
'@hookform/resolvers@3.9.0(react-hook-form@7.52.1(react@18.2.0))':
dependencies:
react-hook-form: 7.52.1(react@18.2.0)
@@ -14040,7 +14082,7 @@ snapshots:
eslint: 8.45.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.45.0))(eslint@8.45.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.45.0))(eslint@8.45.0))(eslint@8.45.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1)(eslint@8.45.0)
eslint-plugin-jsx-a11y: 6.9.0(eslint@8.45.0)
eslint-plugin-react: 7.35.0(eslint@8.45.0)
eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.45.0)
@@ -14064,7 +14106,7 @@ snapshots:
enhanced-resolve: 5.17.1
eslint: 8.45.0
eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.45.0))(eslint@8.45.0))(eslint@8.45.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.45.0))(eslint@8.45.0))(eslint@8.45.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1)(eslint@8.45.0)
fast-glob: 3.3.2
get-tsconfig: 4.7.5
is-core-module: 2.15.0
@@ -14086,7 +14128,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.45.0))(eslint@8.45.0))(eslint@8.45.0):
eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1)(eslint@8.45.0):
dependencies:
array-includes: 3.1.8
array.prototype.findlastindex: 1.2.5
@@ -14793,6 +14835,8 @@ snapshots:
highlight.js@10.7.3: {}
hono@4.5.8: {}
html-to-text@9.0.5:
dependencies:
'@selderee/plugin-htmlparser2': 0.11.0
@@ -16947,6 +16991,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.19.1
fsevents: 2.3.3
rotating-file-stream@3.2.3: {}
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3

View File

@@ -2,3 +2,4 @@ packages:
- "apps/dokploy"
- "apps/docs"
- "apps/website"
- "apps/api"