Add distributed F# monitoring platform
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
**/bin
|
||||
**/obj
|
||||
.git
|
||||
.next
|
||||
.vinext
|
||||
dist
|
||||
node_modules
|
||||
.pnpm-store
|
||||
*.tar.gz
|
||||
51
.github/workflows/ci.yml
vendored
Normal file
51
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
- run: dotnet restore IpCheck.slnx
|
||||
- run: dotnet build IpCheck.slnx -c Release --no-restore
|
||||
- run: dotnet run --project tests/IpCheck.UnitTests/IpCheck.UnitTests.fsproj -c Release --no-build
|
||||
- run: docker compose -f deploy/docker-compose.yml config
|
||||
env:
|
||||
POSTGRES_PASSWORD: ci-only
|
||||
IPCHECK_LOCATION_ID: 11111111-1111-1111-1111-111111111111
|
||||
|
||||
containers:
|
||||
if: github.event_name == 'push'
|
||||
needs: build-test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.server
|
||||
push: true
|
||||
tags: ghcr.io/${{ github.repository_owner }}/ipcheck-server:${{ github.sha }}
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.agent
|
||||
push: true
|
||||
tags: ghcr.io/${{ github.repository_owner }}/ipcheck-agent:${{ github.sha }}
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -41,3 +41,8 @@ yarn-error.log*
|
||||
/.wrangler/
|
||||
/outputs/
|
||||
/work/
|
||||
**/bin/
|
||||
**/obj/
|
||||
.env
|
||||
*.tar.gz
|
||||
/.docker-build-config/
|
||||
|
||||
11
Dockerfile.agent
Normal file
11
Dockerfile.agent
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore src/IpCheck.Agent/IpCheck.Agent.fsproj
|
||||
RUN dotnet publish src/IpCheck.Agent/IpCheck.Agent.fsproj -c Release -o /app --no-restore
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS runtime
|
||||
WORKDIR /app
|
||||
COPY --from=build /app .
|
||||
USER 10001:10001
|
||||
ENTRYPOINT ["dotnet", "IpCheck.Agent.dll"]
|
||||
16
Dockerfile.server
Normal file
16
Dockerfile.server
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore src/IpCheck.Server/IpCheck.Server.fsproj
|
||||
RUN dotnet publish src/IpCheck.Server/IpCheck.Server.fsproj -c Release -o /app --no-restore
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=build /app .
|
||||
COPY src/IpCheck.Server/migrations ./migrations
|
||||
USER 10001:10001
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD curl --fail --silent http://localhost:8080/healthz || exit 1
|
||||
ENTRYPOINT ["dotnet", "IpCheck.Server.dll"]
|
||||
12
IpCheck.slnx
Normal file
12
IpCheck.slnx
Normal file
@@ -0,0 +1,12 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/IpCheck.Domain/IpCheck.Domain.fsproj" />
|
||||
<Project Path="src/IpCheck.Contracts/IpCheck.Contracts.fsproj" />
|
||||
<Project Path="src/IpCheck.Persistence/IpCheck.Persistence.fsproj" />
|
||||
<Project Path="src/IpCheck.Server/IpCheck.Server.fsproj" />
|
||||
<Project Path="src/IpCheck.Agent/IpCheck.Agent.fsproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/IpCheck.UnitTests/IpCheck.UnitTests.fsproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
110
README.md
110
README.md
@@ -1,100 +1,42 @@
|
||||
# vinext-starter
|
||||
# IpCheck
|
||||
|
||||
A clean full-stack starter running on
|
||||
[vinext](https://github.com/cloudflare/vinext), with optional Cloudflare D1 and
|
||||
Drizzle support.
|
||||
Распределённая система мониторинга IP-адресов на F#. Каждый агент представляет отдельную локацию, синхронизирует общий список IP с центральным сервером, выполняет Ping и TCP-проверки SSH/RDP и отправляет результаты обратно.
|
||||
|
||||
## Prerequisites
|
||||
## Состав первого среза
|
||||
|
||||
- Node.js `>=22.13.0`
|
||||
- F# ASP.NET Core API;
|
||||
- F# Worker Service для агентов локаций;
|
||||
- общие контракты и доменная модель;
|
||||
- схема PostgreSQL;
|
||||
- Docker-образы сервера и агента;
|
||||
- Docker Compose для локального окружения;
|
||||
- CI для сборки, тестов и публикации контейнеров;
|
||||
- существующий интерфейс в `app/` сохранён как визуальный прототип до переноса на Fable.
|
||||
|
||||
## Quick Start
|
||||
## Локальная проверка
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
npm run build
|
||||
dotnet restore IpCheck.slnx
|
||||
dotnet build IpCheck.slnx -c Release
|
||||
dotnet run --project tests/IpCheck.UnitTests/IpCheck.UnitTests.fsproj -c Release
|
||||
```
|
||||
|
||||
This starter does not use `wrangler.jsonc`.
|
||||
Запуск сервера:
|
||||
|
||||
## Included Shape
|
||||
|
||||
- edit site code under `app/`
|
||||
- `.openai/hosting.json` declares optional Sites D1 and R2 bindings
|
||||
- `vite.config.ts` simulates declared bindings for local development
|
||||
- `db/schema.ts` starts intentionally empty
|
||||
- `examples/d1/` contains an optional D1 example surface
|
||||
- `drizzle.config.ts` supports local migration generation when needed
|
||||
|
||||
## Workspace Auth Headers
|
||||
|
||||
Signed-in visitors receive both `oai-authenticated-user-id` and `oai-authenticated-user-email`. Private Sites require every visitor to sign in; public Sites may also have anonymous visitors, for whom neither header is present.
|
||||
|
||||
The user ID is stable for the same user on the same Site and different across Sites. Email and name are intended for display or contact purposes.
|
||||
|
||||
SIWC-authenticated workspace sites may also receive
|
||||
`oai-authenticated-user-full-name` when the user's SIWC profile has a non-empty
|
||||
`name` claim. The full-name value is percent-encoded UTF-8 and is accompanied by
|
||||
`oai-authenticated-user-full-name-encoding: percent-encoded-utf-8`.
|
||||
|
||||
Treat the full name as optional and fall back to email when it is absent:
|
||||
|
||||
```tsx
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export default async function Home() {
|
||||
const requestHeaders = await headers();
|
||||
const userId = requestHeaders.get("oai-authenticated-user-id");
|
||||
const email = requestHeaders.get("oai-authenticated-user-email");
|
||||
const encodedFullName = requestHeaders.get("oai-authenticated-user-full-name");
|
||||
const fullName =
|
||||
encodedFullName &&
|
||||
requestHeaders.get("oai-authenticated-user-full-name-encoding") ===
|
||||
"percent-encoded-utf-8"
|
||||
? decodeURIComponent(encodedFullName)
|
||||
: null;
|
||||
|
||||
const displayName = fullName ?? email;
|
||||
// ...
|
||||
}
|
||||
```bash
|
||||
dotnet run --project src/IpCheck.Server/IpCheck.Server.fsproj
|
||||
```
|
||||
|
||||
## Optional Dispatch-Owned ChatGPT Sign-In
|
||||
API будет доступен по адресу, указанному ASP.NET Core в консоли. Проверка состояния: `GET /healthz`.
|
||||
|
||||
Import the ready-to-use helpers from `app/chatgpt-auth.ts` when the site needs
|
||||
optional or required ChatGPT sign-in:
|
||||
## Docker Compose
|
||||
|
||||
- Use `getChatGPTUser()` for optional signed-in UI.
|
||||
- Use `requireChatGPTUser(returnTo)` for server-rendered pages that should send
|
||||
anonymous visitors through Sign in with ChatGPT.
|
||||
- Use `chatGPTSignInPath(returnTo)` and `chatGPTSignOutPath(returnTo)` for
|
||||
browser links or actions.
|
||||
- Pass a same-origin relative `returnTo` path for the destination after sign-in
|
||||
or sign-out. The helper validates and safely encodes it.
|
||||
- Mark protected pages with `export const dynamic = "force-dynamic"` because
|
||||
they depend on per-request identity headers.
|
||||
Скопируйте `deploy/.env.example` в `deploy/.env`, замените пароль и запустите:
|
||||
|
||||
Dispatch owns `/signin-with-chatgpt`, `/signout-with-chatgpt`, `/callback`, the
|
||||
OAuth cookies, and identity header injection. Do not implement app routes for
|
||||
those reserved paths. Routes that do not import and call the helper remain
|
||||
anonymous-compatible.
|
||||
```bash
|
||||
docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d --build
|
||||
```
|
||||
|
||||
SIWC establishes identity only; it does not prove workspace membership. Use the
|
||||
Sites hosting platform's access policy controls for workspace-wide restrictions,
|
||||
or enforce explicit server-side membership or allowlist checks.
|
||||
## Следующий этап
|
||||
|
||||
Use SIWC for account pages, user-specific dashboards, saved records, and write
|
||||
actions tied to the current ChatGPT user. Leave public content anonymous.
|
||||
|
||||
## Useful Commands
|
||||
|
||||
- `npm run dev`: start local development
|
||||
- `npm run build`: verify the vinext build output
|
||||
- `npm test`: build the starter and verify its rendered loading skeleton
|
||||
- `npm run db:generate`: generate Drizzle migrations after schema changes
|
||||
|
||||
## Learn More
|
||||
|
||||
- [vinext Documentation](https://github.com/cloudflare/vinext)
|
||||
- [Drizzle D1 Guide](https://orm.drizzle.team/docs/get-started/d1-new)
|
||||
Перевести репозитории IP, локаций и результатов с временного in-memory хранилища на PostgreSQL, добавить регистрацию агентов по одноразовому токену и начать Fable-админку.
|
||||
|
||||
48
deploy/docker-compose.yml
Normal file
48
deploy/docker-compose.yml
Normal file
@@ -0,0 +1,48 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-ipcheck}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-ipcheck}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-ipcheck} -d ${POSTGRES_DB:-ipcheck}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
server:
|
||||
image: ${IPCHECK_SERVER_IMAGE:-ipcheck/server:local}
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile.server
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${IPCHECK_HTTP_PORT:-8080}:8080"
|
||||
environment:
|
||||
ConnectionStrings__Postgres: Host=postgres;Database=${POSTGRES_DB:-ipcheck};Username=${POSTGRES_USER:-ipcheck};Password=${POSTGRES_PASSWORD}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
agent:
|
||||
image: ${IPCHECK_AGENT_IMAGE:-ipcheck/agent:local}
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile.agent
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- NET_RAW
|
||||
environment:
|
||||
IpCheck__ServerUrl: http://server:8080
|
||||
IpCheck__LocationId: ${IPCHECK_LOCATION_ID}
|
||||
IpCheck__SyncIntervalSeconds: ${IPCHECK_SYNC_INTERVAL_SECONDS:-30}
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
14
docs/architecture.md
Normal file
14
docs/architecture.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Архитектура IpCheck
|
||||
|
||||
Центральный F#-сервер хранит IP-адреса, локации, расписание и результаты. Агент в каждой локации синхронизирует общий список целей, выполняет ICMP Ping и TCP-проверки SSH/RDP и возвращает результаты.
|
||||
|
||||
## Первый вертикальный срез
|
||||
|
||||
- создание IP через `POST /api/targets`;
|
||||
- создание локации через `POST /api/locations`;
|
||||
- получение агентом полного списка через `GET /api/agent/sync/{locationId}`;
|
||||
- отправка результатов через `POST /api/agent/results`;
|
||||
- просмотр результатов через `GET /api/results`;
|
||||
- проверка работоспособности через `GET /healthz`.
|
||||
|
||||
Текущее оперативное хранилище — in-memory. PostgreSQL-схема создаётся при наличии строки подключения; следующим этапом репозитории переводятся на постоянное хранение.
|
||||
6
global.json
Normal file
6
global.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.302",
|
||||
"rollForward": "latestPatch"
|
||||
}
|
||||
}
|
||||
9
src/IpCheck.Agent/IpCheck.Agent.fsproj
Normal file
9
src/IpCheck.Agent/IpCheck.Agent.fsproj
Normal file
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||
<PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
|
||||
<ProjectReference Include="../IpCheck.Contracts/IpCheck.Contracts.fsproj" />
|
||||
<Compile Include="Worker.fs" />
|
||||
<Compile Include="Program.fs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
12
src/IpCheck.Agent/Program.fs
Normal file
12
src/IpCheck.Agent/Program.fs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace IpCheck.Agent
|
||||
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
open Microsoft.Extensions.Hosting
|
||||
|
||||
module Program =
|
||||
[<EntryPoint>]
|
||||
let main args =
|
||||
let builder = Host.CreateApplicationBuilder(args)
|
||||
builder.Services.AddHostedService<Worker>() |> ignore
|
||||
builder.Build().Run()
|
||||
0
|
||||
84
src/IpCheck.Agent/Worker.fs
Normal file
84
src/IpCheck.Agent/Worker.fs
Normal file
@@ -0,0 +1,84 @@
|
||||
namespace IpCheck.Agent
|
||||
|
||||
open System
|
||||
open System.Diagnostics
|
||||
open System.Net.NetworkInformation
|
||||
open System.Net.Sockets
|
||||
open System.Net.Http
|
||||
open System.Net.Http.Json
|
||||
open System.Threading
|
||||
open System.Threading.Tasks
|
||||
open IpCheck.Contracts
|
||||
open Microsoft.Extensions.Configuration
|
||||
open Microsoft.Extensions.Hosting
|
||||
open Microsoft.Extensions.Logging
|
||||
|
||||
module private Checks =
|
||||
let ping (address: string) (targetId: Guid) (locationId: Guid) (_cancellationToken: CancellationToken) = task {
|
||||
let stopwatch = Stopwatch.StartNew()
|
||||
try
|
||||
use ping = new Ping()
|
||||
let! (reply: PingReply) = ping.SendPingAsync(address, 3000)
|
||||
stopwatch.Stop()
|
||||
return {
|
||||
TargetId = targetId; LocationId = locationId; CheckType = "ping"; Port = Nullable()
|
||||
Status = if reply.Status = IPStatus.Success then "available" else "unavailable"
|
||||
LatencyMs = if reply.Status = IPStatus.Success then Nullable reply.RoundtripTime else Nullable()
|
||||
Error = if reply.Status = IPStatus.Success then "" else string reply.Status
|
||||
CheckedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
with ex ->
|
||||
return { TargetId = targetId; LocationId = locationId; CheckType = "ping"; Port = Nullable(); Status = "error"; LatencyMs = Nullable(); Error = ex.Message; CheckedAt = DateTimeOffset.UtcNow }
|
||||
}
|
||||
|
||||
let tcp (name: string) (address: string) (port: int) (targetId: Guid) (locationId: Guid) (cancellationToken: CancellationToken) = task {
|
||||
let stopwatch = Stopwatch.StartNew()
|
||||
try
|
||||
use client = new TcpClient()
|
||||
use timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds 5.0)
|
||||
do! client.ConnectAsync(address, port, timeout.Token)
|
||||
stopwatch.Stop()
|
||||
return { TargetId = targetId; LocationId = locationId; CheckType = name; Port = Nullable port; Status = "available"; LatencyMs = Nullable stopwatch.ElapsedMilliseconds; Error = ""; CheckedAt = DateTimeOffset.UtcNow }
|
||||
with
|
||||
| :? OperationCanceledException ->
|
||||
return { TargetId = targetId; LocationId = locationId; CheckType = name; Port = Nullable port; Status = "timeout"; LatencyMs = Nullable(); Error = "Тайм-аут подключения"; CheckedAt = DateTimeOffset.UtcNow }
|
||||
| ex ->
|
||||
return { TargetId = targetId; LocationId = locationId; CheckType = name; Port = Nullable port; Status = "unavailable"; LatencyMs = Nullable(); Error = ex.Message; CheckedAt = DateTimeOffset.UtcNow }
|
||||
}
|
||||
|
||||
type Worker(configuration: IConfiguration, logger: ILogger<Worker>) =
|
||||
inherit BackgroundService()
|
||||
|
||||
let serverUrl = configuration["IpCheck:ServerUrl"] |> Option.ofObj |> Option.defaultValue "http://localhost:8080"
|
||||
let locationId = configuration["IpCheck:LocationId"] |> Guid.Parse
|
||||
let pollInterval = configuration.GetValue<int>("IpCheck:SyncIntervalSeconds", 30) |> int64 |> TimeSpan.FromSeconds
|
||||
let http = new HttpClient(BaseAddress = Uri serverUrl)
|
||||
|
||||
override _.ExecuteAsync(stoppingToken: CancellationToken) = task {
|
||||
logger.LogInformation("Агент локации {LocationId} запущен", locationId)
|
||||
while not stoppingToken.IsCancellationRequested do
|
||||
try
|
||||
let! sync = http.GetFromJsonAsync<AgentSyncResponse>($"/api/agent/sync/{locationId}", stoppingToken)
|
||||
if not (isNull (box sync)) then
|
||||
let results = ResizeArray<CheckResultDto>()
|
||||
for target in sync.Targets do
|
||||
if target.PingEnabled then
|
||||
let! result = Checks.ping target.IpAddress target.Id locationId stoppingToken
|
||||
results.Add result
|
||||
if target.SshPort.HasValue then
|
||||
let! result = Checks.tcp "ssh" target.IpAddress target.SshPort.Value target.Id locationId stoppingToken
|
||||
results.Add result
|
||||
if target.RdpPort.HasValue then
|
||||
let! result = Checks.tcp "rdp" target.IpAddress target.RdpPort.Value target.Id locationId stoppingToken
|
||||
results.Add result
|
||||
if results.Count > 0 then
|
||||
use! response = http.PostAsJsonAsync("/api/agent/results", results.ToArray(), stoppingToken)
|
||||
response.EnsureSuccessStatusCode() |> ignore
|
||||
with ex -> logger.LogError(ex, "Ошибка синхронизации с сервером")
|
||||
do! Task.Delay(pollInterval, stoppingToken)
|
||||
}
|
||||
|
||||
override _.Dispose() =
|
||||
http.Dispose()
|
||||
base.Dispose()
|
||||
7
src/IpCheck.Agent/appsettings.json
Normal file
7
src/IpCheck.Agent/appsettings.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"IpCheck": {
|
||||
"ServerUrl": "http://localhost:8080",
|
||||
"LocationId": "11111111-1111-1111-1111-111111111111",
|
||||
"SyncIntervalSeconds": 30
|
||||
}
|
||||
}
|
||||
61
src/IpCheck.Contracts/Contracts.fs
Normal file
61
src/IpCheck.Contracts/Contracts.fs
Normal file
@@ -0,0 +1,61 @@
|
||||
namespace IpCheck.Contracts
|
||||
|
||||
open System
|
||||
|
||||
[<CLIMutable>]
|
||||
type TargetDto = {
|
||||
Id: Guid
|
||||
IpAddress: string
|
||||
Description: string
|
||||
PingEnabled: bool
|
||||
SshPort: Nullable<int>
|
||||
RdpPort: Nullable<int>
|
||||
IntervalSeconds: int
|
||||
Enabled: bool
|
||||
CreatedAt: DateTimeOffset
|
||||
}
|
||||
|
||||
[<CLIMutable>]
|
||||
type CreateTargetRequest = {
|
||||
IpAddress: string
|
||||
Description: string
|
||||
PingEnabled: bool
|
||||
SshPort: Nullable<int>
|
||||
RdpPort: Nullable<int>
|
||||
IntervalSeconds: int
|
||||
}
|
||||
|
||||
[<CLIMutable>]
|
||||
type LocationDto = {
|
||||
Id: Guid
|
||||
Name: string
|
||||
Description: string
|
||||
AgentStatus: string
|
||||
LastSeenAt: Nullable<DateTimeOffset>
|
||||
CreatedAt: DateTimeOffset
|
||||
}
|
||||
|
||||
[<CLIMutable>]
|
||||
type CreateLocationRequest = {
|
||||
Name: string
|
||||
Description: string
|
||||
}
|
||||
|
||||
[<CLIMutable>]
|
||||
type CheckResultDto = {
|
||||
TargetId: Guid
|
||||
LocationId: Guid
|
||||
CheckType: string
|
||||
Port: Nullable<int>
|
||||
Status: string
|
||||
LatencyMs: Nullable<int64>
|
||||
Error: string
|
||||
CheckedAt: DateTimeOffset
|
||||
}
|
||||
|
||||
[<CLIMutable>]
|
||||
type AgentSyncResponse = {
|
||||
LocationId: Guid
|
||||
ServerTime: DateTimeOffset
|
||||
Targets: TargetDto array
|
||||
}
|
||||
4
src/IpCheck.Contracts/IpCheck.Contracts.fsproj
Normal file
4
src/IpCheck.Contracts/IpCheck.Contracts.fsproj
Normal file
@@ -0,0 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup>
|
||||
<ItemGroup><Compile Include="Contracts.fs" /></ItemGroup>
|
||||
</Project>
|
||||
47
src/IpCheck.Domain/Domain.fs
Normal file
47
src/IpCheck.Domain/Domain.fs
Normal file
@@ -0,0 +1,47 @@
|
||||
namespace IpCheck.Domain
|
||||
|
||||
open System
|
||||
open System.Net
|
||||
|
||||
[<Struct>]
|
||||
type TargetId = TargetId of Guid
|
||||
|
||||
[<Struct>]
|
||||
type LocationId = LocationId of Guid
|
||||
|
||||
type ServiceCheck =
|
||||
| Ping
|
||||
| TcpPort of name: string * port: uint16
|
||||
|
||||
type CheckOutcome =
|
||||
| Available of latency: TimeSpan
|
||||
| Unavailable of reason: string
|
||||
| TimedOut
|
||||
|
||||
type Target = {
|
||||
Id: TargetId
|
||||
Address: IPAddress
|
||||
Description: string option
|
||||
Checks: ServiceCheck list
|
||||
Interval: TimeSpan
|
||||
Enabled: bool
|
||||
}
|
||||
|
||||
module Target =
|
||||
let create (address: string) (description: string option) (checks: ServiceCheck list) (interval: TimeSpan) =
|
||||
if interval < TimeSpan.FromSeconds 10.0 then
|
||||
Error "Интервал проверки не может быть меньше 10 секунд"
|
||||
elif List.isEmpty checks then
|
||||
Error "Необходимо выбрать хотя бы одну проверку"
|
||||
else
|
||||
match IPAddress.TryParse address with
|
||||
| true, ip ->
|
||||
Ok {
|
||||
Id = TargetId(Guid.NewGuid())
|
||||
Address = ip
|
||||
Description = description
|
||||
Checks = checks
|
||||
Interval = interval
|
||||
Enabled = true
|
||||
}
|
||||
| _ -> Error "Некорректный IP-адрес"
|
||||
9
src/IpCheck.Domain/IpCheck.Domain.fsproj
Normal file
9
src/IpCheck.Domain/IpCheck.Domain.fsproj
Normal file
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="Domain.fs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
7
src/IpCheck.Persistence/IpCheck.Persistence.fsproj
Normal file
7
src/IpCheck.Persistence/IpCheck.Persistence.fsproj
Normal file
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Npgsql" Version="10.0.3" />
|
||||
<Compile Include="Postgres.fs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
15
src/IpCheck.Persistence/Postgres.fs
Normal file
15
src/IpCheck.Persistence/Postgres.fs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace IpCheck.Persistence
|
||||
|
||||
open System.IO
|
||||
open System.Threading
|
||||
open System.Threading.Tasks
|
||||
open Npgsql
|
||||
|
||||
type PostgresSchema(connectionString: string, migrationPath: string) =
|
||||
member _.EnsureCreated(cancellationToken: CancellationToken) = task {
|
||||
let sql = File.ReadAllText migrationPath
|
||||
use dataSource = NpgsqlDataSource.Create connectionString
|
||||
use command = dataSource.CreateCommand(sql)
|
||||
let! _ = command.ExecuteNonQueryAsync(cancellationToken)
|
||||
return ()
|
||||
}
|
||||
10
src/IpCheck.Server/IpCheck.Server.fsproj
Normal file
10
src/IpCheck.Server/IpCheck.Server.fsproj
Normal file
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../IpCheck.Contracts/IpCheck.Contracts.fsproj" />
|
||||
<ProjectReference Include="../IpCheck.Domain/IpCheck.Domain.fsproj" />
|
||||
<ProjectReference Include="../IpCheck.Persistence/IpCheck.Persistence.fsproj" />
|
||||
<Compile Include="Store.fs" />
|
||||
<Compile Include="Program.fs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
47
src/IpCheck.Server/Program.fs
Normal file
47
src/IpCheck.Server/Program.fs
Normal file
@@ -0,0 +1,47 @@
|
||||
namespace IpCheck.Server
|
||||
|
||||
open System
|
||||
open System.IO
|
||||
open IpCheck.Contracts
|
||||
open IpCheck.Persistence
|
||||
open Microsoft.AspNetCore.Builder
|
||||
open Microsoft.AspNetCore.Http
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
open Microsoft.Extensions.Hosting
|
||||
|
||||
module Program =
|
||||
[<EntryPoint>]
|
||||
let main args =
|
||||
let builder = WebApplication.CreateBuilder(args)
|
||||
builder.Services.AddSingleton<MemoryStore>() |> ignore
|
||||
builder.Services.AddHealthChecks() |> ignore
|
||||
let app = builder.Build()
|
||||
let store = app.Services.GetRequiredService<MemoryStore>()
|
||||
store.Seed()
|
||||
|
||||
let connectionString = builder.Configuration["ConnectionStrings:Postgres"]
|
||||
if not (String.IsNullOrWhiteSpace connectionString) then
|
||||
let migration = Path.Combine(app.Environment.ContentRootPath, "migrations", "001_initial.sql")
|
||||
PostgresSchema(connectionString, migration).EnsureCreated(app.Lifetime.ApplicationStopping).GetAwaiter().GetResult()
|
||||
|
||||
app.MapHealthChecks("/healthz") |> ignore
|
||||
app.MapGet("/api/targets", Func<IResult>(fun () -> Results.Ok(store.ListTargets()))) |> ignore
|
||||
app.MapPost("/api/targets", Func<CreateTargetRequest, IResult>(fun request ->
|
||||
match store.AddTarget request with
|
||||
| Ok target -> Results.Created($"/api/targets/{target.Id}", target)
|
||||
| Error message -> Results.BadRequest {| error = message |})) |> ignore
|
||||
app.MapGet("/api/locations", Func<IResult>(fun () -> Results.Ok(store.ListLocations()))) |> ignore
|
||||
app.MapPost("/api/locations", Func<CreateLocationRequest, IResult>(fun request ->
|
||||
match store.AddLocation request with
|
||||
| Ok location -> Results.Created($"/api/locations/{location.Id}", location)
|
||||
| Error message -> Results.BadRequest {| error = message |})) |> ignore
|
||||
app.MapGet("/api/results", Func<int, IResult>(fun limit -> Results.Ok(store.ListResults(if limit <= 0 then 200 else min limit 2000)))) |> ignore
|
||||
app.MapGet("/api/agent/sync/{locationId:guid}", Func<Guid, IResult>(fun locationId ->
|
||||
match store.Sync locationId with
|
||||
| Some response -> Results.Ok response
|
||||
| None -> Results.NotFound {| error = "Локация не найдена" |})) |> ignore
|
||||
app.MapPost("/api/agent/results", Func<CheckResultDto array, IResult>(fun items ->
|
||||
Results.Accepted(value = {| accepted = store.AddResults items |}))) |> ignore
|
||||
|
||||
app.Run()
|
||||
0
|
||||
75
src/IpCheck.Server/Store.fs
Normal file
75
src/IpCheck.Server/Store.fs
Normal file
@@ -0,0 +1,75 @@
|
||||
namespace IpCheck.Server
|
||||
|
||||
open System
|
||||
open System.Collections.Concurrent
|
||||
open System.Net
|
||||
open IpCheck.Contracts
|
||||
|
||||
type MemoryStore() =
|
||||
let targets = ConcurrentDictionary<Guid, TargetDto>()
|
||||
let locations = ConcurrentDictionary<Guid, LocationDto>()
|
||||
let results = ConcurrentQueue<CheckResultDto>()
|
||||
|
||||
member _.ListTargets() = targets.Values |> Seq.sortBy _.IpAddress |> Seq.toArray
|
||||
|
||||
member _.AddTarget(request: CreateTargetRequest) =
|
||||
match IPAddress.TryParse request.IpAddress with
|
||||
| false, _ -> Error "Некорректный IP-адрес"
|
||||
| true, _ when not request.PingEnabled && not request.SshPort.HasValue && not request.RdpPort.HasValue ->
|
||||
Error "Выберите хотя бы одну проверку"
|
||||
| true, _ when request.IntervalSeconds < 10 -> Error "Минимальный интервал — 10 секунд"
|
||||
| true, _ ->
|
||||
let target = {
|
||||
Id = Guid.NewGuid()
|
||||
IpAddress = request.IpAddress
|
||||
Description = request.Description
|
||||
PingEnabled = request.PingEnabled
|
||||
SshPort = request.SshPort
|
||||
RdpPort = request.RdpPort
|
||||
IntervalSeconds = request.IntervalSeconds
|
||||
Enabled = true
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
targets[target.Id] <- target
|
||||
Ok target
|
||||
|
||||
member _.ListLocations() = locations.Values |> Seq.sortBy _.Name |> Seq.toArray
|
||||
|
||||
member _.AddLocation(request: CreateLocationRequest) =
|
||||
if String.IsNullOrWhiteSpace request.Name then Error "Название локации обязательно"
|
||||
else
|
||||
let location = {
|
||||
Id = Guid.NewGuid()
|
||||
Name = request.Name.Trim()
|
||||
Description = request.Description
|
||||
AgentStatus = "awaiting-agent"
|
||||
LastSeenAt = Nullable()
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
locations[location.Id] <- location
|
||||
Ok location
|
||||
|
||||
member _.Sync(locationId: Guid) =
|
||||
match locations.TryGetValue locationId with
|
||||
| true, location ->
|
||||
locations[locationId] <- { location with AgentStatus = "online"; LastSeenAt = Nullable DateTimeOffset.UtcNow }
|
||||
Some { LocationId = locationId; ServerTime = DateTimeOffset.UtcNow; Targets = targets.Values |> Seq.filter _.Enabled |> Seq.toArray }
|
||||
| _ -> None
|
||||
|
||||
member _.AddResults(items: CheckResultDto array) =
|
||||
items |> Array.iter results.Enqueue
|
||||
items.Length
|
||||
|
||||
member _.ListResults(limit: int) = results.ToArray() |> Array.sortByDescending _.CheckedAt |> Array.truncate limit
|
||||
|
||||
member this.Seed() =
|
||||
if locations.IsEmpty then
|
||||
let location = {
|
||||
Id = Guid.Parse "11111111-1111-1111-1111-111111111111"
|
||||
Name = "Локальная локация"
|
||||
Description = "Агент разработки"
|
||||
AgentStatus = "awaiting-agent"
|
||||
LastSeenAt = Nullable()
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
locations[location.Id] <- location
|
||||
5
src/IpCheck.Server/appsettings.json
Normal file
5
src/IpCheck.Server/appsettings.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } },
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": { "Postgres": "" }
|
||||
}
|
||||
35
src/IpCheck.Server/migrations/001_initial.sql
Normal file
35
src/IpCheck.Server/migrations/001_initial.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
CREATE TABLE IF NOT EXISTS locations (
|
||||
id uuid PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
agent_status text NOT NULL,
|
||||
last_seen_at timestamptz,
|
||||
created_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS targets (
|
||||
id uuid PRIMARY KEY,
|
||||
ip_address inet NOT NULL UNIQUE,
|
||||
description text NOT NULL DEFAULT '',
|
||||
ping_enabled boolean NOT NULL,
|
||||
ssh_port integer,
|
||||
rdp_port integer,
|
||||
interval_seconds integer NOT NULL CHECK (interval_seconds >= 10),
|
||||
enabled boolean NOT NULL,
|
||||
created_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS check_results (
|
||||
id bigserial PRIMARY KEY,
|
||||
target_id uuid NOT NULL REFERENCES targets(id),
|
||||
location_id uuid NOT NULL REFERENCES locations(id),
|
||||
check_type text NOT NULL,
|
||||
port integer,
|
||||
status text NOT NULL,
|
||||
latency_ms bigint,
|
||||
error text NOT NULL DEFAULT '',
|
||||
checked_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_check_results_target_location_time
|
||||
ON check_results(target_id, location_id, checked_at DESC);
|
||||
7
tests/IpCheck.UnitTests/IpCheck.UnitTests.fsproj
Normal file
7
tests/IpCheck.UnitTests/IpCheck.UnitTests.fsproj
Normal file
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><OutputType>Exe</OutputType><TargetFramework>net10.0</TargetFramework></PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/IpCheck.Domain/IpCheck.Domain.fsproj" />
|
||||
<Compile Include="Program.fs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
15
tests/IpCheck.UnitTests/Program.fs
Normal file
15
tests/IpCheck.UnitTests/Program.fs
Normal file
@@ -0,0 +1,15 @@
|
||||
open System
|
||||
open IpCheck.Domain
|
||||
|
||||
let assertTrue message condition = if not condition then failwith message
|
||||
|
||||
[<EntryPoint>]
|
||||
let main _ =
|
||||
let valid = Target.create "192.168.1.10" None [ Ping; TcpPort("ssh", 22us) ] (TimeSpan.FromSeconds 60.0)
|
||||
let invalidAddress = Target.create "not-an-ip" None [ Ping ] (TimeSpan.FromSeconds 60.0)
|
||||
let invalidInterval = Target.create "127.0.0.1" None [ Ping ] (TimeSpan.FromSeconds 5.0)
|
||||
assertTrue "Корректный адрес должен создавать цель" (Result.isOk valid)
|
||||
assertTrue "Некорректный адрес должен отклоняться" (Result.isError invalidAddress)
|
||||
assertTrue "Слишком короткий интервал должен отклоняться" (Result.isError invalidInterval)
|
||||
printfn "Все доменные тесты пройдены"
|
||||
0
|
||||
Reference in New Issue
Block a user