Add distributed F# monitoring platform
Some checks failed
CI / build-test (push) Has been cancelled
CI / containers (push) Has been cancelled

This commit is contained in:
Codex
2026-08-09 18:22:24 +03:00
parent cf8c51e3e8
commit 8ec7ee2ec9
27 changed files with 647 additions and 84 deletions

9
.dockerignore Normal file
View 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
View 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
View File

@@ -41,3 +41,8 @@ yarn-error.log*
/.wrangler/ /.wrangler/
/outputs/ /outputs/
/work/ /work/
**/bin/
**/obj/
.env
*.tar.gz
/.docker-build-config/

11
Dockerfile.agent Normal file
View 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
View 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
View 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
View File

@@ -1,100 +1,42 @@
# vinext-starter # IpCheck
A clean full-stack starter running on Распределённая система мониторинга IP-адресов на F#. Каждый агент представляет отдельную локацию, синхронизирует общий список IP с центральным сервером, выполняет Ping и TCP-проверки SSH/RDP и отправляет результаты обратно.
[vinext](https://github.com/cloudflare/vinext), with optional Cloudflare D1 and
Drizzle support.
## Prerequisites ## Состав первого среза
- Node.js `>=22.13.0` - F# ASP.NET Core API;
- F# Worker Service для агентов локаций;
- общие контракты и доменная модель;
- схема PostgreSQL;
- Docker-образы сервера и агента;
- Docker Compose для локального окружения;
- CI для сборки, тестов и публикации контейнеров;
- существующий интерфейс в `app/` сохранён как визуальный прототип до переноса на Fable.
## Quick Start ## Локальная проверка
```bash ```bash
npm install dotnet restore IpCheck.slnx
npm run dev dotnet build IpCheck.slnx -c Release
npm run build dotnet run --project tests/IpCheck.UnitTests/IpCheck.UnitTests.fsproj -c Release
``` ```
This starter does not use `wrangler.jsonc`. Запуск сервера:
## Included Shape ```bash
dotnet run --project src/IpCheck.Server/IpCheck.Server.fsproj
- 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;
// ...
}
``` ```
## 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 ## Docker Compose
optional or required ChatGPT sign-in:
- Use `getChatGPTUser()` for optional signed-in UI. Скопируйте `deploy/.env.example` в `deploy/.env`, замените пароль и запустите:
- 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.
Dispatch owns `/signin-with-chatgpt`, `/signout-with-chatgpt`, `/callback`, the ```bash
OAuth cookies, and identity header injection. Do not implement app routes for docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d --build
those reserved paths. Routes that do not import and call the helper remain ```
anonymous-compatible.
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 Перевести репозитории IP, локаций и результатов с временного in-memory хранилища на PostgreSQL, добавить регистрацию агентов по одноразовому токену и начать Fable-админку.
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)

48
deploy/docker-compose.yml Normal file
View 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
View 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
View File

@@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.302",
"rollForward": "latestPatch"
}
}

View 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>

View 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

View 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()

View File

@@ -0,0 +1,7 @@
{
"IpCheck": {
"ServerUrl": "http://localhost:8080",
"LocationId": "11111111-1111-1111-1111-111111111111",
"SyncIntervalSeconds": 30
}
}

View 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
}

View File

@@ -0,0 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup>
<ItemGroup><Compile Include="Contracts.fs" /></ItemGroup>
</Project>

View 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-адрес"

View 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>

View 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>

View 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 ()
}

View 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>

View 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

View 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

View File

@@ -0,0 +1,5 @@
{
"Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } },
"AllowedHosts": "*",
"ConnectionStrings": { "Postgres": "" }
}

View 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);

View 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>

View 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