Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d63c86065c | ||
|
|
4224db8eb3 | ||
|
|
4ca948292d | ||
|
|
50c5c21c18 | ||
|
|
ab4ccbd7a1 |
@@ -36,3 +36,10 @@ RUST_LOG=info
|
|||||||
|
|
||||||
# Certificate scheduler (cron)
|
# Certificate scheduler (cron)
|
||||||
CERT_CHECK_CRON=0 0 */6 * * *
|
CERT_CHECK_CRON=0 0 */6 * * *
|
||||||
|
|
||||||
|
# Local health-check engine (override in UI: Настройки → Health-check)
|
||||||
|
# HEALTH_CHECK_CRON=0 */2 * * * *
|
||||||
|
# HEALTH_DEGRADED_FAILURES=1
|
||||||
|
# HEALTH_DOWN_FAILURES=2
|
||||||
|
# HEALTH_SUCCESS_RECOVERIES=2
|
||||||
|
# HEALTH_LATENCY_WARN_MS=1000
|
||||||
|
|||||||
+9
-68
@@ -7,7 +7,6 @@ import {
|
|||||||
} from "@fastify/type-provider-zod";
|
} from "@fastify/type-provider-zod";
|
||||||
import type { AppConfig } from "./config.js";
|
import type { AppConfig } from "./config.js";
|
||||||
import { loadConfig } from "./config.js";
|
import { loadConfig } from "./config.js";
|
||||||
import { repos } from "@cfdm/db";
|
|
||||||
import authPlugin from "./plugins/auth.js";
|
import authPlugin from "./plugins/auth.js";
|
||||||
import cfClientPlugin from "./plugins/cf-client.js";
|
import cfClientPlugin from "./plugins/cf-client.js";
|
||||||
import { requireAuth } from "./plugins/auth.js";
|
import { requireAuth } from "./plugins/auth.js";
|
||||||
@@ -34,8 +33,10 @@ import { settingsRoutes } from "./routes/settings.js";
|
|||||||
import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js";
|
import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js";
|
||||||
import { auditRoutes } from "./routes/audit.js";
|
import { auditRoutes } from "./routes/audit.js";
|
||||||
import * as certificateService from "./services/certificate-service.js";
|
import * as certificateService from "./services/certificate-service.js";
|
||||||
import * as healthCheckService from "./services/health-check-service.js";
|
import {
|
||||||
import * as serviceConfigService from "./services/service-config-service.js";
|
createHealthCheckTask,
|
||||||
|
scheduleHealthCheckJob,
|
||||||
|
} from "./services/health-check-scheduler.js";
|
||||||
import { AsyncTask, CronJob } from "toad-scheduler";
|
import { AsyncTask, CronJob } from "toad-scheduler";
|
||||||
|
|
||||||
export interface BuildAppOptions {
|
export interface BuildAppOptions {
|
||||||
@@ -125,71 +126,11 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const healthTask = new AsyncTask(
|
const healthTask = createHealthCheckTask(app, config);
|
||||||
"health-check",
|
scheduleHealthCheckJob(app, config, healthTask);
|
||||||
async () => {
|
app.decorate("reloadHealthCheckJob", () => {
|
||||||
const thresholds = {
|
scheduleHealthCheckJob(app, config, healthTask);
|
||||||
degradedFailures: config.healthDegradedFailures,
|
});
|
||||||
downFailures: config.healthDownFailures,
|
|
||||||
latencyWarnMs: config.healthLatencyWarnMs,
|
|
||||||
successRecoveries: config.healthSuccessRecoveries,
|
|
||||||
};
|
|
||||||
const n = await healthCheckService.runAllChecks(app.db, {
|
|
||||||
thresholds,
|
|
||||||
probeGapMs: config.healthProbeGapMs,
|
|
||||||
onStatusChange: async (target, prev, next) => {
|
|
||||||
try {
|
|
||||||
const label =
|
|
||||||
next === "up"
|
|
||||||
? "OK"
|
|
||||||
: next === "degraded"
|
|
||||||
? "Slow"
|
|
||||||
: next === "down"
|
|
||||||
? "Down"
|
|
||||||
: "—";
|
|
||||||
repos.insertNotificationLog(
|
|
||||||
app.db,
|
|
||||||
"ip_health",
|
|
||||||
target.scope,
|
|
||||||
target.ref_id,
|
|
||||||
`${target.hostname || target.ip}: ${label}`,
|
|
||||||
`IP ${target.ip}: ${prev ?? "—"} → ${label}`,
|
|
||||||
);
|
|
||||||
await serviceConfigService.reconcileDnsForTarget(
|
|
||||||
app.db,
|
|
||||||
app.cf,
|
|
||||||
target.scope,
|
|
||||||
target.ref_id,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
app.log.warn(
|
|
||||||
{ err, scope: target.scope, refId: target.ref_id },
|
|
||||||
"health-check reconcile failed",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const monitors = await healthCheckService.runDomainMonitors(
|
|
||||||
app.db,
|
|
||||||
thresholds,
|
|
||||||
);
|
|
||||||
app.log.info(
|
|
||||||
{ checked: n, monitors },
|
|
||||||
"health check completed",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
(err) => {
|
|
||||||
app.log.warn({ err }, "health check failed");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
app.scheduler.addCronJob(
|
|
||||||
new CronJob(
|
|
||||||
{ cronExpression: config.healthCheckCron },
|
|
||||||
healthTask,
|
|
||||||
{ preventOverrun: true },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
changeDomainSchema,
|
changeDomainSchema,
|
||||||
createServiceNodeSchema,
|
createServiceNodeSchema,
|
||||||
reorderServicesSchema,
|
reorderServicesSchema,
|
||||||
|
toggleServiceIpSchema,
|
||||||
updateServiceConfigSchema,
|
updateServiceConfigSchema,
|
||||||
updateServiceNodeSchema,
|
updateServiceNodeSchema,
|
||||||
} from "@cfdm/shared";
|
} from "@cfdm/shared";
|
||||||
@@ -191,4 +192,26 @@ export async function serviceRoutes(app: FastifyInstance) {
|
|||||||
body.enabled,
|
body.enabled,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.patch("/services/:id/ips/toggle", async (request) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const body = toggleServiceIpSchema.parse(request.body);
|
||||||
|
const view = await serviceConfig.toggleServiceIp(
|
||||||
|
request.server.db,
|
||||||
|
request.server.cf,
|
||||||
|
Number(id),
|
||||||
|
body.ip,
|
||||||
|
body.enabled,
|
||||||
|
);
|
||||||
|
recordAudit(request.server, request, {
|
||||||
|
action: "service.ip.toggle",
|
||||||
|
targetType: "app_resource",
|
||||||
|
targetId: String(id),
|
||||||
|
summary: body.enabled
|
||||||
|
? `Включён IP ${body.ip} сервиса «${view.name}»`
|
||||||
|
: `Выключен IP ${body.ip} сервиса «${view.name}»`,
|
||||||
|
details: { ip: body.ip, enabled: body.enabled },
|
||||||
|
});
|
||||||
|
return view;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,46 @@ import {
|
|||||||
updateAppSettings,
|
updateAppSettings,
|
||||||
} from "@cfdm/db";
|
} from "@cfdm/db";
|
||||||
import { pingVpsTracker } from "../services/vps-tracker-sync.js";
|
import { pingVpsTracker } from "../services/vps-tracker-sync.js";
|
||||||
|
import { AppError } from "../errors.js";
|
||||||
|
import {
|
||||||
|
assertValidHealthCron,
|
||||||
|
healthEngineFallbacksFromConfig,
|
||||||
|
} from "../services/health-check-scheduler.js";
|
||||||
|
|
||||||
export async function settingsRoutes(app: FastifyInstance) {
|
export async function settingsRoutes(app: FastifyInstance) {
|
||||||
app.get("/settings", async (request) => {
|
app.get("/settings", async (request) => {
|
||||||
return getAppSettings(request.server.db);
|
return getAppSettings(
|
||||||
|
request.server.db,
|
||||||
|
healthEngineFallbacksFromConfig(request.server.config),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch("/settings", async (request) => {
|
app.patch("/settings", async (request) => {
|
||||||
const body = appSettingsPatchSchema.parse(request.body);
|
const parsed = appSettingsPatchSchema.safeParse(request.body);
|
||||||
return updateAppSettings(request.server.db, body);
|
if (!parsed.success) {
|
||||||
|
throw AppError.validation(
|
||||||
|
parsed.error.issues[0]?.message ?? "некорректные настройки",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const body = parsed.data;
|
||||||
|
if (body.healthCheckCron) {
|
||||||
|
assertValidHealthCron(body.healthCheckCron);
|
||||||
|
}
|
||||||
|
const fallbacks = healthEngineFallbacksFromConfig(request.server.config);
|
||||||
|
const current = getAppSettings(request.server.db, fallbacks);
|
||||||
|
const nextDegraded =
|
||||||
|
body.healthDegradedFailures ?? current.healthDegradedFailures;
|
||||||
|
const nextDown = body.healthDownFailures ?? current.healthDownFailures;
|
||||||
|
if (nextDown < nextDegraded) {
|
||||||
|
throw AppError.validation(
|
||||||
|
"ошибок до down не меньше, чем до degraded",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const next = updateAppSettings(request.server.db, body, fallbacks);
|
||||||
|
if (body.healthCheckCron !== undefined) {
|
||||||
|
request.server.reloadHealthCheckJob?.();
|
||||||
|
}
|
||||||
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/settings/vps-tracker/test", async (request) => {
|
app.post("/settings/vps-tracker/test", async (request) => {
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { AsyncTask, CronJob } from "toad-scheduler";
|
||||||
|
import {
|
||||||
|
getAppSettings,
|
||||||
|
type HealthEngineFallbacks,
|
||||||
|
} from "@cfdm/db";
|
||||||
|
import { repos } from "@cfdm/db";
|
||||||
|
import type { AppConfig } from "../config.js";
|
||||||
|
import { AppError } from "../errors.js";
|
||||||
|
import * as healthCheckService from "./health-check-service.js";
|
||||||
|
import * as serviceConfigService from "./service-config-service.js";
|
||||||
|
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyInstance {
|
||||||
|
reloadHealthCheckJob?: () => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HEALTH_CHECK_JOB_ID = "health-check";
|
||||||
|
|
||||||
|
export function healthEngineFallbacksFromConfig(
|
||||||
|
config: AppConfig,
|
||||||
|
): HealthEngineFallbacks {
|
||||||
|
return {
|
||||||
|
healthCheckCron: config.healthCheckCron,
|
||||||
|
healthDegradedFailures: config.healthDegradedFailures,
|
||||||
|
healthDownFailures: config.healthDownFailures,
|
||||||
|
healthLatencyWarnMs: config.healthLatencyWarnMs,
|
||||||
|
healthSuccessRecoveries: config.healthSuccessRecoveries,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertValidHealthCron(expr: string): void {
|
||||||
|
const cronExpression = expr.trim();
|
||||||
|
const parts = cronExpression.split(/\s+/).filter(Boolean);
|
||||||
|
if (parts.length < 5 || parts.length > 6) {
|
||||||
|
throw AppError.validation("некорректное cron-выражение");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const job = new CronJob(
|
||||||
|
{ cronExpression },
|
||||||
|
new AsyncTask("validate-cron", async () => undefined),
|
||||||
|
{ id: "validate-cron" },
|
||||||
|
);
|
||||||
|
job.stop();
|
||||||
|
} catch {
|
||||||
|
throw AppError.validation("некорректное cron-выражение");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHealthCheckTask(
|
||||||
|
app: FastifyInstance,
|
||||||
|
config: AppConfig,
|
||||||
|
): AsyncTask {
|
||||||
|
const fallbacks = healthEngineFallbacksFromConfig(config);
|
||||||
|
return new AsyncTask(
|
||||||
|
HEALTH_CHECK_JOB_ID,
|
||||||
|
async () => {
|
||||||
|
const settings = getAppSettings(app.db, fallbacks);
|
||||||
|
const thresholds = {
|
||||||
|
degradedFailures: settings.healthDegradedFailures,
|
||||||
|
downFailures: settings.healthDownFailures,
|
||||||
|
latencyWarnMs: settings.healthLatencyWarnMs,
|
||||||
|
successRecoveries: settings.healthSuccessRecoveries,
|
||||||
|
};
|
||||||
|
const n = await healthCheckService.runAllChecks(app.db, {
|
||||||
|
thresholds,
|
||||||
|
probeGapMs: config.healthProbeGapMs,
|
||||||
|
onStatusChange: async (target, prev, next) => {
|
||||||
|
try {
|
||||||
|
const label =
|
||||||
|
next === "up"
|
||||||
|
? "OK"
|
||||||
|
: next === "degraded"
|
||||||
|
? "Slow"
|
||||||
|
: next === "down"
|
||||||
|
? "Down"
|
||||||
|
: "—";
|
||||||
|
repos.insertNotificationLog(
|
||||||
|
app.db,
|
||||||
|
"ip_health",
|
||||||
|
target.scope,
|
||||||
|
target.ref_id,
|
||||||
|
`${target.hostname || target.ip}: ${label}`,
|
||||||
|
`IP ${target.ip}: ${prev ?? "—"} → ${label}`,
|
||||||
|
);
|
||||||
|
await serviceConfigService.reconcileDnsForTarget(
|
||||||
|
app.db,
|
||||||
|
app.cf,
|
||||||
|
target.scope,
|
||||||
|
target.ref_id,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
app.log.warn(
|
||||||
|
{ err, scope: target.scope, refId: target.ref_id },
|
||||||
|
"health-check reconcile failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const monitors = await healthCheckService.runDomainMonitors(
|
||||||
|
app.db,
|
||||||
|
thresholds,
|
||||||
|
);
|
||||||
|
app.log.info({ checked: n, monitors }, "health check completed");
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
app.log.warn({ err }, "health check failed");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scheduleHealthCheckJob(
|
||||||
|
app: FastifyInstance,
|
||||||
|
config: AppConfig,
|
||||||
|
task: AsyncTask,
|
||||||
|
): void {
|
||||||
|
const scheduler = app.scheduler;
|
||||||
|
if (!scheduler) return;
|
||||||
|
if (scheduler.existsById(HEALTH_CHECK_JOB_ID)) {
|
||||||
|
scheduler.removeById(HEALTH_CHECK_JOB_ID);
|
||||||
|
}
|
||||||
|
const settings = getAppSettings(
|
||||||
|
app.db,
|
||||||
|
healthEngineFallbacksFromConfig(config),
|
||||||
|
);
|
||||||
|
scheduler.addCronJob(
|
||||||
|
new CronJob(
|
||||||
|
{ cronExpression: settings.healthCheckCron },
|
||||||
|
task,
|
||||||
|
{ preventOverrun: true, id: HEALTH_CHECK_JOB_ID },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -242,7 +242,11 @@ async function collectKnownZones(
|
|||||||
|
|
||||||
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
||||||
const service = repos.getService(db, serviceId);
|
const service = repos.getService(db, serviceId);
|
||||||
const ips = repos.listServiceIps(db, serviceId);
|
const ipRows = repos.listServiceIpRows(db, serviceId);
|
||||||
|
const ips = ipRows.map((row) => row.ip);
|
||||||
|
const ip_enabled = Object.fromEntries(
|
||||||
|
ipRows.map((row) => [row.ip, row.enabled]),
|
||||||
|
);
|
||||||
const bindings = repos.listBindingsByService(db, serviceId);
|
const bindings = repos.listBindingsByService(db, serviceId);
|
||||||
|
|
||||||
const domainViews = bindings.map((binding) => {
|
const domainViews = bindings.map((binding) => {
|
||||||
@@ -304,9 +308,11 @@ async function buildView(db: Db, serviceId: number): Promise<ServiceView> {
|
|||||||
created_at: service.created_at,
|
created_at: service.created_at,
|
||||||
updated_at: service.updated_at,
|
updated_at: service.updated_at,
|
||||||
ips,
|
ips,
|
||||||
|
ip_enabled,
|
||||||
domains: domainViews,
|
domains: domainViews,
|
||||||
health_status: "unknown",
|
health_status: "unknown",
|
||||||
health_latency_ms: null,
|
health_latency_ms: null,
|
||||||
|
ip_health: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,16 +320,27 @@ function attachServiceHealth(
|
|||||||
db: Db,
|
db: Db,
|
||||||
views: ServiceView[],
|
views: ServiceView[],
|
||||||
): ServiceView[] {
|
): ServiceView[] {
|
||||||
const healthByService = repos.aggregateIpHealthByServiceIds(
|
const ids = views.map((v) => v.id);
|
||||||
db,
|
const healthByService = repos.aggregateIpHealthByServiceIds(db, ids);
|
||||||
views.map((v) => v.id),
|
const ipHealthByService = repos.listIpHealthByServiceIds(db, ids);
|
||||||
);
|
|
||||||
return views.map((view) => {
|
return views.map((view) => {
|
||||||
const health = healthByService.get(view.id);
|
const health = healthByService.get(view.id);
|
||||||
|
const byIp = new Map(
|
||||||
|
(ipHealthByService.get(view.id) ?? []).map((row) => [row.ip, row]),
|
||||||
|
);
|
||||||
|
const ip_health = (view.ips ?? []).map((ip) => {
|
||||||
|
const row = byIp.get(ip);
|
||||||
|
return {
|
||||||
|
ip,
|
||||||
|
status: row?.status ?? ("unknown" as const),
|
||||||
|
latency_ms: row?.latency_ms ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
...view,
|
...view,
|
||||||
health_status: health?.health_status ?? "unknown",
|
health_status: health?.health_status ?? "unknown",
|
||||||
health_latency_ms: health?.health_latency_ms ?? null,
|
health_latency_ms: health?.health_latency_ms ?? null,
|
||||||
|
ip_health,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -372,7 +389,7 @@ export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
|
|||||||
|
|
||||||
const groupViews = groupViewsRaw.map((group) => {
|
const groupViews = groupViewsRaw.map((group) => {
|
||||||
const services = group.services.map(
|
const services = group.services.map(
|
||||||
(s) => healthById.get(s.id) ?? { ...s, health_status: "unknown" as const, health_latency_ms: null },
|
(s) => healthById.get(s.id) ?? { ...s, health_status: "unknown" as const, health_latency_ms: null, ip_health: [], ip_enabled: {} },
|
||||||
);
|
);
|
||||||
const groupScopeHealth = groupHealthById.get(group.id);
|
const groupScopeHealth = groupHealthById.get(group.id);
|
||||||
// Only enabled services feed the group badge — a disabled service with a
|
// Only enabled services feed the group badge — a disabled service with a
|
||||||
@@ -401,6 +418,8 @@ export async function listGroupViews(db: Db): Promise<ServiceGroupsResponse> {
|
|||||||
...s,
|
...s,
|
||||||
health_status: "unknown" as const,
|
health_status: "unknown" as const,
|
||||||
health_latency_ms: null,
|
health_latency_ms: null,
|
||||||
|
ip_health: [],
|
||||||
|
ip_enabled: {},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1332,6 +1351,59 @@ export async function toggleService(
|
|||||||
return enabledView!;
|
return enabledView!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function toggleServiceIp(
|
||||||
|
db: Db,
|
||||||
|
cf: CloudflareClient,
|
||||||
|
serviceId: number,
|
||||||
|
ip: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<ServiceView> {
|
||||||
|
repos.getService(db, serviceId);
|
||||||
|
const pool = repos.listServiceIps(db, serviceId);
|
||||||
|
if (!pool.includes(ip)) {
|
||||||
|
throw AppError.validation(`IP ${ip} не входит в пул адресов сервиса`);
|
||||||
|
}
|
||||||
|
|
||||||
|
repos.setServiceIpEnabled(db, serviceId, ip, enabled);
|
||||||
|
const node = repos
|
||||||
|
.listNodes(db, serviceId)
|
||||||
|
.find((entry) => entry.address === ip);
|
||||||
|
if (node) {
|
||||||
|
repos.updateNode(db, node.id, { enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindings = repos.listBindingsByService(db, serviceId);
|
||||||
|
for (const binding of bindings) {
|
||||||
|
if (binding.cname_target?.trim()) continue;
|
||||||
|
const current = repos.listBindingIpsWithMeta(db, binding.id);
|
||||||
|
const hasIp = current.some((entry) => entry.ip === ip);
|
||||||
|
if (enabled && !hasIp) {
|
||||||
|
repos.replaceBindingIpsWithMeta(db, binding.id, [
|
||||||
|
...current,
|
||||||
|
{ ip, weight: 1, priority: 1 },
|
||||||
|
]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!enabled && hasIp) {
|
||||||
|
repos.replaceBindingIpsWithMeta(
|
||||||
|
db,
|
||||||
|
binding.id,
|
||||||
|
current.filter((entry) => entry.ip !== ip),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = repos.getService(db, serviceId);
|
||||||
|
if (shouldPushDns(db, service)) {
|
||||||
|
await syncServiceBindingsToDns(db, cf, serviceId);
|
||||||
|
await syncGroupDomainForService(db, cf, serviceId);
|
||||||
|
}
|
||||||
|
void syncServiceToVpsTracker(db, serviceId);
|
||||||
|
|
||||||
|
const [view] = attachServiceHealth(db, [await buildView(db, serviceId)]);
|
||||||
|
return view!;
|
||||||
|
}
|
||||||
|
|
||||||
export async function toggleGroup(
|
export async function toggleGroup(
|
||||||
db: Db,
|
db: Db,
|
||||||
cf: CloudflareClient,
|
cf: CloudflareClient,
|
||||||
|
|||||||
@@ -43,8 +43,9 @@ function resolveIpsLocally(
|
|||||||
const binding = index.byFqdn.get(key);
|
const binding = index.byFqdn.get(key);
|
||||||
if (!binding) return [];
|
if (!binding) return [];
|
||||||
|
|
||||||
if (binding.target_ips.some(isIpLiteral)) {
|
const ips = (binding.target_ips ?? []).filter(isIpLiteral);
|
||||||
return binding.target_ips.filter(isIpLiteral);
|
if (ips.length > 0) {
|
||||||
|
return ips;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cname = binding.cname_target?.trim();
|
const cname = binding.cname_target?.trim();
|
||||||
@@ -79,7 +80,7 @@ export async function resolveBindingIpsForSync(
|
|||||||
index: BindingIpIndex,
|
index: BindingIpIndex,
|
||||||
db?: Db,
|
db?: Db,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const directIps = binding.target_ips.filter(isIpLiteral);
|
const directIps = (binding.target_ips ?? []).filter(isIpLiteral);
|
||||||
if (directIps.length > 0) {
|
if (directIps.length > 0) {
|
||||||
return [...directIps];
|
return [...directIps];
|
||||||
}
|
}
|
||||||
@@ -124,7 +125,7 @@ export async function buildServiceSyncBindingsAsync(
|
|||||||
const serviceIps = repos.listServiceIps(db, serviceId);
|
const serviceIps = repos.listServiceIps(db, serviceId);
|
||||||
const allBindings = repos.listAllBindings(db);
|
const allBindings = repos.listAllBindings(db);
|
||||||
const index = buildBindingIndex(allBindings);
|
const index = buildBindingIndex(allBindings);
|
||||||
const bindings = repos.listBindingsByService(db, serviceId);
|
const bindings = allBindings.filter((row) => row.service_id === serviceId);
|
||||||
|
|
||||||
const items: CfdmBindingSyncItem[] = [];
|
const items: CfdmBindingSyncItem[] = [];
|
||||||
for (const binding of bindings) {
|
for (const binding of bindings) {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ describe("service groups health enrichment", () => {
|
|||||||
const service = repos.createService(app.db, "Panel", "panel");
|
const service = repos.createService(app.db, "Panel", "panel");
|
||||||
repos.setServiceGroup(app.db, service.id, group.id);
|
repos.setServiceGroup(app.db, service.id, group.id);
|
||||||
repos.setServiceEnabled(app.db, service.id, true);
|
repos.setServiceEnabled(app.db, service.id, true);
|
||||||
|
repos.replaceServiceIps(app.db, service.id, ["1.2.3.4"]);
|
||||||
const binding = repos.insertBinding(
|
const binding = repos.insertBinding(
|
||||||
app.db,
|
app.db,
|
||||||
domain.id,
|
domain.id,
|
||||||
@@ -85,6 +86,11 @@ describe("service groups health enrichment", () => {
|
|||||||
id: number;
|
id: number;
|
||||||
health_status: string;
|
health_status: string;
|
||||||
health_latency_ms: number | null;
|
health_latency_ms: number | null;
|
||||||
|
ip_health: Array<{
|
||||||
|
ip: string;
|
||||||
|
status: string;
|
||||||
|
latency_ms: number | null;
|
||||||
|
}>;
|
||||||
}>;
|
}>;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
@@ -92,6 +98,9 @@ describe("service groups health enrichment", () => {
|
|||||||
expect(groupView).toBeDefined();
|
expect(groupView).toBeDefined();
|
||||||
expect(groupView!.services[0]?.health_status).toBe("degraded");
|
expect(groupView!.services[0]?.health_status).toBe("degraded");
|
||||||
expect(groupView!.services[0]?.health_latency_ms).toBe(120);
|
expect(groupView!.services[0]?.health_latency_ms).toBe(120);
|
||||||
|
expect(groupView!.services[0]?.ip_health).toEqual([
|
||||||
|
{ ip: "1.2.3.4", status: "degraded", latency_ms: 120 },
|
||||||
|
]);
|
||||||
// group worst = degraded (from service) over up (group scope)
|
// group worst = degraded (from service) over up (group scope)
|
||||||
expect(groupView!.health_status).toBe("degraded");
|
expect(groupView!.health_status).toBe("degraded");
|
||||||
|
|
||||||
|
|||||||
@@ -165,4 +165,94 @@ describe("create service then list groups", () => {
|
|||||||
|
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("PATCH /services/:id/ips/toggle keeps IP in pool and removes it from A-binding", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const cf = mockCf();
|
||||||
|
|
||||||
|
repos.createDomain(app.db, null, "example.com", "zone-1");
|
||||||
|
const group = repos.createServiceGroup(
|
||||||
|
app.db,
|
||||||
|
"VPN",
|
||||||
|
"vpn",
|
||||||
|
null,
|
||||||
|
"vpn.example.com",
|
||||||
|
);
|
||||||
|
|
||||||
|
const createRes = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/services",
|
||||||
|
headers,
|
||||||
|
payload: {
|
||||||
|
name: "Panel",
|
||||||
|
slug: "panel-ip-toggle",
|
||||||
|
service_group_id: group.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createRes.statusCode).toBe(200);
|
||||||
|
const created = createRes.json() as { id: number };
|
||||||
|
|
||||||
|
await updateConfig(app.db, cf, created.id, {
|
||||||
|
ips: ["1.2.3.4", "5.6.7.8"],
|
||||||
|
service_group_id: group.id,
|
||||||
|
domains: [
|
||||||
|
{
|
||||||
|
fqdn: "panel.example.com",
|
||||||
|
target_ips: ["1.2.3.4", "5.6.7.8"],
|
||||||
|
target_ip_weights: { "1.2.3.4": 1, "5.6.7.8": 1 },
|
||||||
|
target_ip_priorities: { "1.2.3.4": 1, "5.6.7.8": 1 },
|
||||||
|
lb_mode: "round_robin",
|
||||||
|
health_check_enabled: false,
|
||||||
|
health_check_type: "tcp",
|
||||||
|
health_check_port: 443,
|
||||||
|
health_check_path: null,
|
||||||
|
health_check_expected_status: null,
|
||||||
|
health_check_interval_sec: 30,
|
||||||
|
health_check_timeout_ms: 3000,
|
||||||
|
health_check_verify_tls: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// HTTP toggle uses request.server.cf; disable DNS push so the test
|
||||||
|
// does not call the real Cloudflare client.
|
||||||
|
repos.setServiceEnabled(app.db, created.id, false);
|
||||||
|
|
||||||
|
const offRes = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/v1/services/${created.id}/ips/toggle`,
|
||||||
|
headers,
|
||||||
|
payload: { ip: "1.2.3.4", enabled: false },
|
||||||
|
});
|
||||||
|
expect(offRes.statusCode, JSON.stringify(offRes.json())).toBe(200);
|
||||||
|
const offView = offRes.json() as {
|
||||||
|
ips: string[];
|
||||||
|
ip_enabled: Record<string, boolean>;
|
||||||
|
};
|
||||||
|
expect(offView.ips).toEqual(expect.arrayContaining(["1.2.3.4", "5.6.7.8"]));
|
||||||
|
expect(offView.ip_enabled["1.2.3.4"]).toBe(false);
|
||||||
|
expect(offView.ip_enabled["5.6.7.8"]).toBe(true);
|
||||||
|
|
||||||
|
const binding = repos.listBindingsByService(app.db, created.id)[0]!;
|
||||||
|
expect(repos.listBindingIps(app.db, binding.id)).toEqual(["5.6.7.8"]);
|
||||||
|
|
||||||
|
const onRes = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/v1/services/${created.id}/ips/toggle`,
|
||||||
|
headers,
|
||||||
|
payload: { ip: "1.2.3.4", enabled: true },
|
||||||
|
});
|
||||||
|
expect(onRes.statusCode).toBe(200);
|
||||||
|
const onView = onRes.json() as { ip_enabled: Record<string, boolean> };
|
||||||
|
expect(onView.ip_enabled["1.2.3.4"]).toBe(true);
|
||||||
|
expect(repos.listBindingIps(app.db, binding.id)).toEqual(
|
||||||
|
expect.arrayContaining(["1.2.3.4", "5.6.7.8"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildApp } from "../src/app.js";
|
||||||
|
import { loadConfig } from "../src/config.js";
|
||||||
|
|
||||||
|
async function authHeaders(app: Awaited<ReturnType<typeof buildApp>>) {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/auth/login",
|
||||||
|
payload: { username: "admin", password: "admin" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const { token } = res.json() as { token: string };
|
||||||
|
return { authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("settings health engine", () => {
|
||||||
|
it("GET /api/v1/settings returns env fallbacks for health fields", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: {
|
||||||
|
...loadConfig(),
|
||||||
|
staticDir: null,
|
||||||
|
healthCheckCron: "*/30 * * * * *",
|
||||||
|
healthDegradedFailures: 3,
|
||||||
|
healthDownFailures: 4,
|
||||||
|
healthLatencyWarnMs: 1500,
|
||||||
|
healthSuccessRecoveries: 5,
|
||||||
|
},
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/settings",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json() as {
|
||||||
|
healthCheckCron: string;
|
||||||
|
healthDegradedFailures: number;
|
||||||
|
healthDownFailures: number;
|
||||||
|
healthLatencyWarnMs: number;
|
||||||
|
healthSuccessRecoveries: number;
|
||||||
|
};
|
||||||
|
expect(body.healthCheckCron).toBe("*/30 * * * * *");
|
||||||
|
expect(body.healthDegradedFailures).toBe(3);
|
||||||
|
expect(body.healthDownFailures).toBe(4);
|
||||||
|
expect(body.healthLatencyWarnMs).toBe(1500);
|
||||||
|
expect(body.healthSuccessRecoveries).toBe(5);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCH persists health engine settings", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: "/api/v1/settings",
|
||||||
|
headers,
|
||||||
|
payload: {
|
||||||
|
healthCheckCron: "0 */5 * * * *",
|
||||||
|
healthDegradedFailures: 2,
|
||||||
|
healthDownFailures: 4,
|
||||||
|
healthLatencyWarnMs: 800,
|
||||||
|
healthSuccessRecoveries: 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json() as {
|
||||||
|
healthCheckCron: string;
|
||||||
|
healthDegradedFailures: number;
|
||||||
|
healthDownFailures: number;
|
||||||
|
healthLatencyWarnMs: number;
|
||||||
|
healthSuccessRecoveries: number;
|
||||||
|
};
|
||||||
|
expect(body.healthCheckCron).toBe("0 */5 * * * *");
|
||||||
|
expect(body.healthDegradedFailures).toBe(2);
|
||||||
|
expect(body.healthDownFailures).toBe(4);
|
||||||
|
expect(body.healthLatencyWarnMs).toBe(800);
|
||||||
|
expect(body.healthSuccessRecoveries).toBe(3);
|
||||||
|
|
||||||
|
const again = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/v1/settings",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
expect(again.json()).toMatchObject({
|
||||||
|
healthCheckCron: "0 */5 * * * *",
|
||||||
|
healthDegradedFailures: 2,
|
||||||
|
healthDownFailures: 4,
|
||||||
|
healthLatencyWarnMs: 800,
|
||||||
|
healthSuccessRecoveries: 3,
|
||||||
|
});
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCH rejects invalid cron", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: "/api/v1/settings",
|
||||||
|
headers,
|
||||||
|
payload: { healthCheckCron: "not-a-cron" },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(res.json()).toMatchObject({
|
||||||
|
error: { code: "VALIDATION_ERROR" },
|
||||||
|
});
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCH rejects down < degraded", async () => {
|
||||||
|
const app = await buildApp({
|
||||||
|
config: { ...loadConfig(), staticDir: null },
|
||||||
|
memory: true,
|
||||||
|
});
|
||||||
|
const headers = await authHeaders(app);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "PATCH",
|
||||||
|
url: "/api/v1/settings",
|
||||||
|
headers,
|
||||||
|
payload: {
|
||||||
|
healthDegradedFailures: 5,
|
||||||
|
healthDownFailures: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -134,6 +134,19 @@ describe("resolveBindingIpsForSync", () => {
|
|||||||
expect(ips).toEqual(["203.0.113.10"]);
|
expect(ips).toEqual(["203.0.113.10"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats missing target_ips as empty instead of throwing", async () => {
|
||||||
|
const cname = binding({
|
||||||
|
id: 2,
|
||||||
|
hostname: "imsk",
|
||||||
|
zone_name: "rkns.top",
|
||||||
|
cname_target: "ihome.rkns.top",
|
||||||
|
});
|
||||||
|
delete (cname as { target_ips?: string[] }).target_ips;
|
||||||
|
const index = { byFqdn: new Map([["imsk.rkns.top", cname]]) };
|
||||||
|
const ips = await resolveBindingIpsForSync(cname, ["198.51.100.9"], index);
|
||||||
|
expect(ips).toEqual(["198.51.100.9"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("prefers service IPs over empty CNAME resolution chain", async () => {
|
it("prefers service IPs over empty CNAME resolution chain", async () => {
|
||||||
const cname = binding({
|
const cname = binding({
|
||||||
id: 2,
|
id: 2,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Switch } from '@cfdm/ui/components/switch'
|
|||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import { ButtonGroup } from '@cfdm/ui/components/button-group'
|
import { ButtonGroup } from '@cfdm/ui/components/button-group'
|
||||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
|
||||||
@@ -213,7 +214,18 @@ export function HealthCheckConfigFields({
|
|||||||
Checks, API вернёт ошибку — останется Local. Workers не используются.
|
Checks, API вернёт ошибку — останется Local. Workers не используются.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : (
|
||||||
|
<Alert>
|
||||||
|
<AlertTitle>Local health-check</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Интервал и таймаут пробы — ниже. Cron и пороги Slow/Down задаются в{' '}
|
||||||
|
<Link to="/settings/health" className="text-foreground underline">
|
||||||
|
Настройках → Health-check
|
||||||
|
</Link>
|
||||||
|
, как параметры Cloudflare Health Checks в этой форме.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<SettingRow
|
<SettingRow
|
||||||
title="Health-check"
|
title="Health-check"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { MoreHorizontalIcon, PencilIcon, Trash2Icon } from 'lucide-react'
|
|||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { ServiceFqdnList } from '@/components/services/service-fqdn-list'
|
import { ServiceFqdnList, ServiceIpList } from '@/components/services/service-fqdn-list'
|
||||||
import type { ServiceView } from '@/lib/schemas'
|
import type { ServiceView } from '@/lib/schemas'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
import {
|
import {
|
||||||
@@ -78,7 +78,22 @@ export function ServiceKanbanCard({
|
|||||||
</ItemHeader>
|
</ItemHeader>
|
||||||
|
|
||||||
<ItemContent className="min-w-0 gap-2">
|
<ItemContent className="min-w-0 gap-2">
|
||||||
<ServiceFqdnList service={service} />
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span className="text-muted-foreground text-xs">Общий домен</span>
|
||||||
|
<ServiceFqdnList
|
||||||
|
copyable
|
||||||
|
service={service}
|
||||||
|
emptyLabel="Не задан"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span className="text-muted-foreground text-xs">IP</span>
|
||||||
|
<ServiceIpList
|
||||||
|
copyable
|
||||||
|
ips={service.ips ?? []}
|
||||||
|
ipHealth={service.ip_health ?? []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
|
|
||||||
<ItemFooter className="min-w-0 justify-between gap-2">
|
<ItemFooter className="min-w-0 justify-between gap-2">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Link, useNavigate } from '@tanstack/react-router'
|
|||||||
import {
|
import {
|
||||||
FolderTreeIcon,
|
FolderTreeIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
|
HeartPulseIcon,
|
||||||
LayoutDashboardIcon,
|
LayoutDashboardIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
@@ -57,6 +58,12 @@ const NAV_ITEMS = [
|
|||||||
keywords: ['certificates', 'ssl', 'tls'],
|
keywords: ['certificates', 'ssl', 'tls'],
|
||||||
icon: ShieldCheckIcon,
|
icon: ShieldCheckIcon,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/settings/health',
|
||||||
|
label: 'Health-check',
|
||||||
|
keywords: ['health', 'health-check', 'cron', 'пороги', 'настройки'],
|
||||||
|
icon: HeartPulseIcon,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/integrations',
|
to: '/settings/integrations',
|
||||||
label: 'Настройки',
|
label: 'Настройки',
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const routeTitles: Record<string, string> = {
|
|||||||
'/services': 'Сервисы',
|
'/services': 'Сервисы',
|
||||||
'/certificates': 'Сертификаты',
|
'/certificates': 'Сертификаты',
|
||||||
'/settings/appearance': 'Внешний вид',
|
'/settings/appearance': 'Внешний вид',
|
||||||
|
'/settings/health': 'Health-check',
|
||||||
'/settings/integrations': 'Интеграции',
|
'/settings/integrations': 'Интеграции',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +65,8 @@ function getBreadcrumbs(
|
|||||||
{ label: 'Настройки', href: '/settings/appearance' },
|
{ label: 'Настройки', href: '/settings/appearance' },
|
||||||
...(pathname === '/settings/integrations'
|
...(pathname === '/settings/integrations'
|
||||||
? [{ label: 'Интеграции', href: pathname }]
|
? [{ label: 'Интеграции', href: pathname }]
|
||||||
|
: pathname === '/settings/health'
|
||||||
|
? [{ label: 'Health-check', href: pathname }]
|
||||||
: pathname === '/settings/appearance'
|
: pathname === '/settings/appearance'
|
||||||
? [{ label: 'Внешний вид', href: pathname }]
|
? [{ label: 'Внешний вид', href: pathname }]
|
||||||
: []),
|
: []),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
|
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
|
||||||
import { PaletteIcon, SettingsIcon } from 'lucide-react'
|
import { HeartPulseIcon, PaletteIcon, SettingsIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
|
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
@@ -21,6 +21,12 @@ const DEFAULT_TABS: SettingsTabConfig[] = [
|
|||||||
label: 'Внешний вид',
|
label: 'Внешний вид',
|
||||||
icon: <PaletteIcon className="size-4" aria-hidden="true" />,
|
icon: <PaletteIcon className="size-4" aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'health',
|
||||||
|
to: '/settings/health',
|
||||||
|
label: 'Health-check',
|
||||||
|
icon: <HeartPulseIcon className="size-4" aria-hidden="true" />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'integrations',
|
id: 'integrations',
|
||||||
to: '/settings/integrations',
|
to: '/settings/integrations',
|
||||||
@@ -37,7 +43,7 @@ interface SettingsShellProps {
|
|||||||
|
|
||||||
export function SettingsShell({
|
export function SettingsShell({
|
||||||
title = 'Настройки',
|
title = 'Настройки',
|
||||||
description = 'Внешний вид и интеграции',
|
description = 'Внешний вид, health-check и интеграции',
|
||||||
tabs = DEFAULT_TABS,
|
tabs = DEFAULT_TABS,
|
||||||
}: SettingsShellProps) {
|
}: SettingsShellProps) {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link2Icon, PlusIcon, Trash2Icon } from 'lucide-react'
|
import { PlusIcon, Trash2Icon } from 'lucide-react'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
|
||||||
import { EmptyState } from '@/components/empty-state'
|
|
||||||
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
|
import { TaggedInput, isValidIpv4 } from '@/components/tagged-input'
|
||||||
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
|
import { ServiceBindingIpInput } from '@/components/service-binding-ip-input'
|
||||||
import {
|
import {
|
||||||
@@ -38,7 +36,6 @@ import {
|
|||||||
ItemGroup,
|
ItemGroup,
|
||||||
} from '@cfdm/ui/components/item'
|
} from '@cfdm/ui/components/item'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { TabsContent } from '@cfdm/ui/components/tabs'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -46,7 +43,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@cfdm/ui/components/select'
|
} from '@cfdm/ui/components/select'
|
||||||
import { Separator } from '@cfdm/ui/components/separator'
|
|
||||||
|
|
||||||
interface BindingHealthConfig {
|
interface BindingHealthConfig {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
@@ -161,6 +157,33 @@ function buildDomainsPayload(bindings: ServiceBindingDraft[]) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptyBindingDraft(fqdn = ''): ServiceBindingDraft {
|
||||||
|
return {
|
||||||
|
fqdn,
|
||||||
|
record_type: 'A',
|
||||||
|
target_ips: [],
|
||||||
|
target_cname: '',
|
||||||
|
lb_mode: 'round_robin',
|
||||||
|
health: { ...defaultHealth },
|
||||||
|
target_ip_weights: {},
|
||||||
|
target_ip_priorities: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withPoolIps(draft: ServiceBindingDraft, pool: string[]): ServiceBindingDraft {
|
||||||
|
if (draft.record_type !== 'A' || draft.target_ips.length > 0 || pool.length === 0) {
|
||||||
|
return draft
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...draft,
|
||||||
|
target_ips: pool,
|
||||||
|
target_ip_weights: Object.fromEntries(pool.map((ip) => [ip, draft.target_ip_weights[ip] ?? 1])),
|
||||||
|
target_ip_priorities: Object.fromEntries(
|
||||||
|
pool.map((ip) => [ip, draft.target_ip_priorities[ip] ?? 1]),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function ServiceEditSheet({
|
export function ServiceEditSheet({
|
||||||
mode,
|
mode,
|
||||||
service,
|
service,
|
||||||
@@ -179,10 +202,10 @@ export function ServiceEditSheet({
|
|||||||
const [slug, setSlug] = useState('')
|
const [slug, setSlug] = useState('')
|
||||||
const [serviceGroupId, setServiceGroupId] = useState('none')
|
const [serviceGroupId, setServiceGroupId] = useState('none')
|
||||||
const [ips, setIps] = useState<string[]>([])
|
const [ips, setIps] = useState<string[]>([])
|
||||||
|
const [commonFqdn, setCommonFqdn] = useState('')
|
||||||
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
|
const [bindings, setBindings] = useState<ServiceBindingDraft[]>([])
|
||||||
const [lbWeight, setLbWeight] = useState(1)
|
const [lbWeight, setLbWeight] = useState(1)
|
||||||
const [lbPriority, setLbPriority] = useState(1)
|
const [lbPriority, setLbPriority] = useState(1)
|
||||||
const [activeTab, setActiveTab] = useState('general')
|
|
||||||
|
|
||||||
const groupItems = useMemo(
|
const groupItems = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -192,16 +215,8 @@ export function ServiceEditSheet({
|
|||||||
[groups],
|
[groups],
|
||||||
)
|
)
|
||||||
|
|
||||||
const selectedGroup = useMemo(() => {
|
|
||||||
if (serviceGroupId === 'none') return null
|
|
||||||
return groups.find((g) => String(g.id) === serviceGroupId) ?? null
|
|
||||||
}, [groups, serviceGroupId])
|
|
||||||
|
|
||||||
const groupHasDomain = Boolean(selectedGroup?.domain?.trim())
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
setActiveTab('general')
|
|
||||||
if (mode === 'edit' && service) {
|
if (mode === 'edit' && service) {
|
||||||
setName(service.name)
|
setName(service.name)
|
||||||
setSlug(service.slug)
|
setSlug(service.slug)
|
||||||
@@ -209,7 +224,9 @@ export function ServiceEditSheet({
|
|||||||
service.service_group_id != null ? String(service.service_group_id) : 'none',
|
service.service_group_id != null ? String(service.service_group_id) : 'none',
|
||||||
)
|
)
|
||||||
setIps(service.ips ?? [])
|
setIps(service.ips ?? [])
|
||||||
setBindings(toBindingDrafts(service))
|
const drafts = toBindingDrafts(service)
|
||||||
|
setBindings(drafts)
|
||||||
|
setCommonFqdn(drafts[0]?.fqdn ?? '')
|
||||||
setLbWeight(service.lb_weight ?? 1)
|
setLbWeight(service.lb_weight ?? 1)
|
||||||
setLbPriority(service.lb_priority ?? 1)
|
setLbPriority(service.lb_priority ?? 1)
|
||||||
return
|
return
|
||||||
@@ -221,6 +238,7 @@ export function ServiceEditSheet({
|
|||||||
defaultGroupId != null ? String(defaultGroupId) : 'none',
|
defaultGroupId != null ? String(defaultGroupId) : 'none',
|
||||||
)
|
)
|
||||||
setIps([])
|
setIps([])
|
||||||
|
setCommonFqdn('')
|
||||||
setBindings([])
|
setBindings([])
|
||||||
setLbWeight(1)
|
setLbWeight(1)
|
||||||
setLbPriority(1)
|
setLbPriority(1)
|
||||||
@@ -232,23 +250,28 @@ export function ServiceEditSheet({
|
|||||||
[knownDomains],
|
[knownDomains],
|
||||||
)
|
)
|
||||||
|
|
||||||
function handleAddBinding() {
|
const extraBindings = bindings.slice(1)
|
||||||
setBindings((current) => [
|
|
||||||
...current,
|
function handleCommonFqdnChange(value: string) {
|
||||||
{
|
setCommonFqdn(value)
|
||||||
fqdn: '',
|
setBindings((current) => {
|
||||||
record_type: 'A',
|
if (current.length === 0) return current
|
||||||
target_ips: [],
|
return current.map((item, i) => (i === 0 ? { ...item, fqdn: value } : item))
|
||||||
target_cname: '',
|
})
|
||||||
lb_mode: 'round_robin',
|
|
||||||
health: { ...defaultHealth },
|
|
||||||
target_ip_weights: {},
|
|
||||||
target_ip_priorities: {},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRemoveBinding(index: number) {
|
function handleAddExtraBinding() {
|
||||||
|
setBindings((current) => {
|
||||||
|
const extra = withPoolIps(emptyBindingDraft(), ips)
|
||||||
|
if (current.length === 0) {
|
||||||
|
return [emptyBindingDraft(commonFqdn), extra]
|
||||||
|
}
|
||||||
|
return [...current, extra]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveExtraBinding(extraIndex: number) {
|
||||||
|
const index = extraIndex + 1
|
||||||
setBindings((current) => current.filter((_, i) => i !== index))
|
setBindings((current) => current.filter((_, i) => i !== index))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,69 +321,75 @@ export function ServiceEditSheet({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBindingMetaChange(
|
function healthFromConfig(next: LbAndHealthConfig): BindingHealthConfig {
|
||||||
index: number,
|
return {
|
||||||
ip: string,
|
enabled: next.enabled,
|
||||||
meta: { weight?: number; priority?: number },
|
type: next.type,
|
||||||
) {
|
port: next.port,
|
||||||
setBindings((current) =>
|
path: next.path,
|
||||||
current.map((item, i) => {
|
expected_status: next.expected_status,
|
||||||
if (i !== index) return item
|
interval_sec: next.interval_sec,
|
||||||
const weights = { ...item.target_ip_weights }
|
timeout_ms: next.timeout_ms,
|
||||||
const priorities = { ...item.target_ip_priorities }
|
verify_tls: next.verify_tls,
|
||||||
if (meta.weight !== undefined) weights[ip] = meta.weight
|
provider: next.provider,
|
||||||
if (meta.priority !== undefined) priorities[ip] = meta.priority
|
}
|
||||||
return { ...item, target_ip_weights: weights, target_ip_priorities: priorities }
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBindingHealthChange(index: number, next: LbAndHealthConfig) {
|
function handlePrimaryHealthChange(next: LbAndHealthConfig) {
|
||||||
setBindings((current) =>
|
const health = healthFromConfig(next)
|
||||||
current.map((item, i) =>
|
setBindings((current) => {
|
||||||
i === index
|
if (current.length === 0) {
|
||||||
? {
|
return [
|
||||||
...item,
|
{
|
||||||
lb_mode: next.lb_mode,
|
...withPoolIps(emptyBindingDraft(commonFqdn), ips),
|
||||||
health: {
|
lb_mode: next.lb_mode,
|
||||||
enabled: next.enabled,
|
health,
|
||||||
type: next.type,
|
},
|
||||||
port: next.port,
|
]
|
||||||
path: next.path,
|
}
|
||||||
expected_status: next.expected_status,
|
return current.map((item, index) =>
|
||||||
interval_sec: next.interval_sec,
|
index === 0 ? { ...item, lb_mode: next.lb_mode, health } : item,
|
||||||
timeout_ms: next.timeout_ms,
|
)
|
||||||
verify_tls: next.verify_tls,
|
})
|
||||||
provider: next.provider ?? 'local',
|
}
|
||||||
},
|
|
||||||
}
|
const primaryHealthValue: LbAndHealthConfig = {
|
||||||
: item,
|
lb_mode: bindings[0]?.lb_mode ?? 'round_robin',
|
||||||
),
|
...(bindings[0]?.health ?? defaultHealth),
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveServiceGroupId(): number | null {
|
function resolveServiceGroupId(): number | null {
|
||||||
return serviceGroupId === 'none' ? null : Number(serviceGroupId)
|
return serviceGroupId === 'none' ? null : Number(serviceGroupId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncCommonDomain(current: ServiceBindingDraft[]): ServiceBindingDraft[] {
|
||||||
|
const trimmed = commonFqdn.trim()
|
||||||
|
if (!trimmed) return current
|
||||||
|
if (current.length === 0) {
|
||||||
|
return [withPoolIps(emptyBindingDraft(trimmed), ips)]
|
||||||
|
}
|
||||||
|
return current.map((item, index) => {
|
||||||
|
if (index !== 0) return item
|
||||||
|
return withPoolIps({ ...item, fqdn: trimmed }, ips)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
const domains = buildDomainsPayload(bindings)
|
const syncedBindings = syncCommonDomain(bindings)
|
||||||
|
const domains = buildDomainsPayload(syncedBindings)
|
||||||
const normalizedFqdns = domains.map((d) => d.fqdn.trim().toLowerCase())
|
const normalizedFqdns = domains.map((d) => d.fqdn.trim().toLowerCase())
|
||||||
const hasDuplicateFqdn =
|
const hasDuplicateFqdn =
|
||||||
new Set(normalizedFqdns).size !== normalizedFqdns.length
|
new Set(normalizedFqdns).size !== normalizedFqdns.length
|
||||||
if (hasDuplicateFqdn) {
|
if (hasDuplicateFqdn) {
|
||||||
toast.error('Укажите уникальные FQDN — дубликаты привязок недопустимы')
|
toast.error('Укажите уникальные FQDN — дубликаты привязок недопустимы')
|
||||||
setActiveTab('bindings')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const groupId = resolveServiceGroupId()
|
const groupId = resolveServiceGroupId()
|
||||||
const lbFields = groupHasDomain
|
|
||||||
? { lb_weight: lbWeight, lb_priority: lbPriority }
|
|
||||||
: {}
|
|
||||||
const configPayload = {
|
const configPayload = {
|
||||||
ips,
|
ips,
|
||||||
domains,
|
domains,
|
||||||
...lbFields,
|
lb_weight: lbWeight,
|
||||||
|
lb_priority: lbPriority,
|
||||||
}
|
}
|
||||||
if (mode === 'create') {
|
if (mode === 'create') {
|
||||||
onCreate?.({
|
onCreate?.({
|
||||||
@@ -368,7 +397,8 @@ export function ServiceEditSheet({
|
|||||||
slug: slug.trim(),
|
slug: slug.trim(),
|
||||||
service_group_id: groupId,
|
service_group_id: groupId,
|
||||||
ips,
|
ips,
|
||||||
...lbFields,
|
lb_weight: lbWeight,
|
||||||
|
lb_priority: lbPriority,
|
||||||
domains,
|
domains,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -398,29 +428,16 @@ export function ServiceEditSheet({
|
|||||||
<SheetHeader className="shrink-0 border-b pb-4">
|
<SheetHeader className="shrink-0 border-b pb-4">
|
||||||
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
<SheetTitle>{isCreate ? 'Новый сервис' : 'Редактирование сервиса'}</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
Настройте параметры сервиса и привязки FQDN → IP или CNAME. Один
|
Общий домен и IP задаются у сервиса. Дополнительные FQDN — ниже, зона
|
||||||
сервис может иметь несколько FQDN в разных зонах; зона определяется
|
определяется автоматически.
|
||||||
из FQDN автоматически.
|
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
|
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-4 py-4">
|
||||||
<CountedLineTabs
|
<section className="flex flex-col gap-3">
|
||||||
tabs={[
|
<h3 className="text-sm font-medium">Сервис</h3>
|
||||||
{ id: 'general', label: 'Основное' },
|
<FieldGroup className="flex flex-col gap-3">
|
||||||
{
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
id: 'bindings',
|
|
||||||
label: 'Привязки',
|
|
||||||
count: bindings.length > 0 ? bindings.length : undefined,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
value={activeTab}
|
|
||||||
onValueChange={setActiveTab}
|
|
||||||
className="flex w-full flex-col gap-4"
|
|
||||||
listClassName="mb-0 w-full"
|
|
||||||
>
|
|
||||||
<TabsContent value="general" className="flex flex-col gap-4">
|
|
||||||
<FieldGroup className="flex flex-col gap-4">
|
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor="edit-service-name">Название</FieldLabel>
|
<FieldLabel htmlFor="edit-service-name">Название</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
@@ -439,239 +456,182 @@ export function ServiceEditSheet({
|
|||||||
onChange={(e) => setSlug(e.target.value)}
|
onChange={(e) => setSlug(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="edit-service-group">Группа сервисов</FieldLabel>
|
|
||||||
<Select
|
|
||||||
items={groupItems}
|
|
||||||
value={serviceGroupId}
|
|
||||||
onValueChange={(value) => setServiceGroupId(value ?? 'none')}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="edit-service-group" className="w-full">
|
|
||||||
<SelectValue placeholder="Без группы" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{groupItems.map((item) => (
|
|
||||||
<SelectItem key={item.value} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
|
|
||||||
<TaggedInput
|
|
||||||
id="edit-service-ips"
|
|
||||||
value={ips}
|
|
||||||
onChange={setIps}
|
|
||||||
placeholder="192.168.1.1"
|
|
||||||
validate={isValidIpv4}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</FieldGroup>
|
|
||||||
|
|
||||||
{groupHasDomain && (
|
|
||||||
<>
|
|
||||||
<Separator />
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Балансировка внутри группы «{selectedGroup?.name}»: вес и приоритет
|
|
||||||
сервиса для общего домена группы.
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="service-lb-weight">Вес</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="service-lb-weight"
|
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
min={1}
|
|
||||||
max={100}
|
|
||||||
value={lbWeight}
|
|
||||||
onChange={(e) =>
|
|
||||||
setLbWeight(Math.max(1, Number(e.target.value) || 1))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="service-lb-priority">Приоритет</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="service-lb-priority"
|
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
min={1}
|
|
||||||
max={100}
|
|
||||||
value={lbPriority}
|
|
||||||
onChange={(e) =>
|
|
||||||
setLbPriority(Math.max(1, Number(e.target.value) || 1))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="bindings" className="flex flex-col gap-4">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Несколько FQDN в разных зонах → IP или CNAME для DNS Cloudflare
|
|
||||||
</p>
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
|
|
||||||
<PlusIcon data-icon="inline-start" />
|
|
||||||
Добавить
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Field>
|
||||||
{bindings.length === 0 ? (
|
<FieldLabel htmlFor="edit-service-group">Группа сервисов</FieldLabel>
|
||||||
<EmptyState
|
<Select
|
||||||
icon={Link2Icon}
|
items={groupItems}
|
||||||
title="Нет привязок"
|
value={serviceGroupId}
|
||||||
description="Необязательно. Можно добавить несколько FQDN: api.ivx.su и www.other.su — зоны определятся автоматически."
|
onValueChange={(value) => setServiceGroupId(value ?? 'none')}
|
||||||
centered={false}
|
>
|
||||||
action={
|
<SelectTrigger id="edit-service-group" className="w-full">
|
||||||
<Button type="button" variant="outline" size="sm" onClick={handleAddBinding}>
|
<SelectValue placeholder="Без группы" />
|
||||||
<PlusIcon data-icon="inline-start" />
|
</SelectTrigger>
|
||||||
Добавить привязку
|
<SelectContent>
|
||||||
</Button>
|
{groupItems.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="edit-service-common-domain">
|
||||||
|
Общий домен (FQDN)
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="edit-service-common-domain"
|
||||||
|
className="font-mono"
|
||||||
|
value={commonFqdn}
|
||||||
|
placeholder={
|
||||||
|
zoneHints[0] ? `gw.${zoneHints[0]}` : 'gw.ivx.su'
|
||||||
}
|
}
|
||||||
|
onChange={(e) => handleCommonFqdnChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
) : (
|
</Field>
|
||||||
<ItemGroup className="gap-2">
|
<Field>
|
||||||
{bindings.map((binding, index) => {
|
<FieldLabel htmlFor="edit-service-ips">IP-адреса сервиса</FieldLabel>
|
||||||
const showLbBlock =
|
<TaggedInput
|
||||||
(binding.record_type === 'A' && binding.target_ips.length > 0) ||
|
id="edit-service-ips"
|
||||||
(binding.record_type === 'CNAME' && binding.target_cname.trim().length > 0)
|
value={ips}
|
||||||
const showMeta =
|
onChange={setIps}
|
||||||
binding.record_type === 'A' &&
|
placeholder="192.168.1.1"
|
||||||
binding.target_ips.length > 1 &&
|
validate={isValidIpv4}
|
||||||
binding.lb_mode !== 'round_robin'
|
/>
|
||||||
const parsedZone = parseFqdn(binding.fqdn, zoneHints)
|
</Field>
|
||||||
return (
|
</FieldGroup>
|
||||||
<Item key={`binding-${index}`} variant="outline" className="items-stretch">
|
</section>
|
||||||
<ItemContent className="w-full flex flex-col gap-3">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
Привязка {index + 1}
|
|
||||||
</span>
|
|
||||||
{parsedZone ? (
|
|
||||||
<Badge variant="outline" size="xs" className="font-mono">
|
|
||||||
{parsedZone.zoneName}
|
|
||||||
</Badge>
|
|
||||||
) : binding.fqdn.trim() ? (
|
|
||||||
<Badge variant="warning-light" size="xs">
|
|
||||||
зона не найдена
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="shrink-0"
|
|
||||||
aria-label="Удалить привязку"
|
|
||||||
onClick={() => handleRemoveBinding(index)}
|
|
||||||
>
|
|
||||||
<Trash2Icon />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Field className="min-w-0">
|
|
||||||
<FieldLabel htmlFor={`binding-fqdn-${index}`}>FQDN</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id={`binding-fqdn-${index}`}
|
|
||||||
className="font-mono"
|
|
||||||
value={binding.fqdn}
|
|
||||||
onChange={(event) =>
|
|
||||||
handleFqdnChange(index, event.target.value)
|
|
||||||
}
|
|
||||||
placeholder={
|
|
||||||
zoneHints[0] ? `newdom.${zoneHints[0]}` : 'newdom.ivx.su'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor={`binding-type-${index}`}>Тип записи</FieldLabel>
|
|
||||||
<Select
|
|
||||||
items={[
|
|
||||||
{ label: 'A (IP)', value: 'A' },
|
|
||||||
{ label: 'CNAME', value: 'CNAME' },
|
|
||||||
]}
|
|
||||||
value={binding.record_type}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
handleRecordTypeChange(index, (value ?? 'A') as 'A' | 'CNAME')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger id={`binding-type-${index}`} className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="A">A (IP)</SelectItem>
|
|
||||||
<SelectItem value="CNAME">CNAME</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
{binding.record_type === 'CNAME' ? (
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor={`binding-cname-${index}`}>
|
|
||||||
CNAME-цель
|
|
||||||
</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id={`binding-cname-${index}`}
|
|
||||||
value={binding.target_cname}
|
|
||||||
placeholder="mmsk.rkns.top"
|
|
||||||
onChange={(event) => handleCnameChange(index, event.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
) : (
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor={`binding-ip-${index}`}>IP</FieldLabel>
|
|
||||||
<ServiceBindingIpInput
|
|
||||||
id={`binding-ip-${index}`}
|
|
||||||
value={binding.target_ips}
|
|
||||||
pool={ips}
|
|
||||||
onChange={(targetIps) => handleIpsChange(index, targetIps)}
|
|
||||||
showMeta={showLbBlock && showMeta}
|
|
||||||
weights={binding.target_ip_weights}
|
|
||||||
priorities={binding.target_ip_priorities}
|
|
||||||
onMetaChange={(ip, meta) =>
|
|
||||||
handleBindingMetaChange(index, ip, meta)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showLbBlock ? (
|
<section className="flex flex-col gap-3">
|
||||||
<HealthCheckConfigFields
|
<h3 className="text-sm font-medium">Health check</h3>
|
||||||
value={{
|
<HealthCheckConfigFields
|
||||||
lb_mode: binding.lb_mode,
|
idPrefix="service-health"
|
||||||
enabled: binding.health.enabled,
|
value={primaryHealthValue}
|
||||||
type: binding.health.type,
|
onChange={handlePrimaryHealthChange}
|
||||||
port: binding.health.port,
|
/>
|
||||||
path: binding.health.path,
|
</section>
|
||||||
expected_status: binding.health.expected_status,
|
|
||||||
interval_sec: binding.health.interval_sec,
|
<section className="flex flex-col gap-3">
|
||||||
timeout_ms: binding.health.timeout_ms,
|
<div className="flex items-center justify-between gap-2">
|
||||||
verify_tls: binding.health.verify_tls,
|
<h3 className="text-sm font-medium">Доп. FQDN</h3>
|
||||||
provider: binding.health.provider ?? 'local',
|
<Button
|
||||||
}}
|
type="button"
|
||||||
onChange={(next) => handleBindingHealthChange(index, next)}
|
variant="outline"
|
||||||
lbModeLabel="Режим балансировки"
|
size="sm"
|
||||||
showLbMode={
|
onClick={handleAddExtraBinding}
|
||||||
binding.record_type === 'A' && binding.target_ips.length > 1
|
>
|
||||||
}
|
<PlusIcon data-icon="inline-start" />
|
||||||
idPrefix={`binding-${index}-health`}
|
Добавить
|
||||||
/>
|
</Button>
|
||||||
) : null}
|
</div>
|
||||||
</ItemContent>
|
{extraBindings.length === 0 ? (
|
||||||
</Item>
|
<p className="text-muted-foreground text-sm">
|
||||||
)
|
Нет дополнительных FQDN
|
||||||
})}
|
</p>
|
||||||
</ItemGroup>
|
) : (
|
||||||
)}
|
<ItemGroup className="gap-2">
|
||||||
</TabsContent>
|
{extraBindings.map((binding, extraIndex) => {
|
||||||
</CountedLineTabs>
|
const index = extraIndex + 1
|
||||||
|
const parsedZone = parseFqdn(binding.fqdn, zoneHints)
|
||||||
|
return (
|
||||||
|
<Item
|
||||||
|
key={`extra-binding-${index}`}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="items-stretch"
|
||||||
|
>
|
||||||
|
<ItemContent className="flex w-full min-w-0 flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{parsedZone ? (
|
||||||
|
<Badge variant="outline" size="xs" className="font-mono">
|
||||||
|
{parsedZone.zoneName}
|
||||||
|
</Badge>
|
||||||
|
) : binding.fqdn.trim() ? (
|
||||||
|
<Badge variant="warning-light" size="xs">
|
||||||
|
зона не найдена
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
FQDN
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="ml-auto shrink-0"
|
||||||
|
aria-label="Удалить FQDN"
|
||||||
|
onClick={() => handleRemoveExtraBinding(extraIndex)}
|
||||||
|
>
|
||||||
|
<Trash2Icon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_7.5rem]">
|
||||||
|
<Input
|
||||||
|
id={`extra-fqdn-${index}`}
|
||||||
|
className="font-mono"
|
||||||
|
value={binding.fqdn}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleFqdnChange(index, event.target.value)
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
zoneHints[0]
|
||||||
|
? `api.${zoneHints[0]}`
|
||||||
|
: 'api.ivx.su'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
items={[
|
||||||
|
{ label: 'A (IP)', value: 'A' },
|
||||||
|
{ label: 'CNAME', value: 'CNAME' },
|
||||||
|
]}
|
||||||
|
value={binding.record_type}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleRecordTypeChange(
|
||||||
|
index,
|
||||||
|
(value ?? 'A') as 'A' | 'CNAME',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id={`extra-type-${index}`}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="A">A (IP)</SelectItem>
|
||||||
|
<SelectItem value="CNAME">CNAME</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{binding.record_type === 'CNAME' ? (
|
||||||
|
<Input
|
||||||
|
id={`extra-cname-${index}`}
|
||||||
|
value={binding.target_cname}
|
||||||
|
placeholder="mmsk.rkns.top"
|
||||||
|
onChange={(event) =>
|
||||||
|
handleCnameChange(index, event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ServiceBindingIpInput
|
||||||
|
id={`extra-ip-${index}`}
|
||||||
|
value={binding.target_ips}
|
||||||
|
pool={ips}
|
||||||
|
onChange={(targetIps) =>
|
||||||
|
handleIpsChange(index, targetIps)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ItemGroup>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SheetFooter className="shrink-0 flex flex-row flex-wrap gap-2 border-t pt-4">
|
<SheetFooter className="shrink-0 flex flex-row flex-wrap gap-2 border-t pt-4">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useForm, Controller } from 'react-hook-form'
|
import { useForm, Controller } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import {
|
import {
|
||||||
@@ -12,10 +12,6 @@ import { FormFieldSimple } from '@/components/form-field'
|
|||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { AppFieldGroup } from '@/components/app-field'
|
import { AppFieldGroup } from '@/components/app-field'
|
||||||
import { AppInput } from '@/components/app-input'
|
import { AppInput } from '@/components/app-input'
|
||||||
import {
|
|
||||||
HealthCheckConfigFields,
|
|
||||||
type LbAndHealthConfig,
|
|
||||||
} from '@/components/health-check-config-fields'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -44,19 +40,6 @@ interface ServiceGroupEditSheetProps {
|
|||||||
onSave?: (id: number, body: CreateServiceGroupInput) => void
|
onSave?: (id: number, body: CreateServiceGroupInput) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultLbHealth: LbAndHealthConfig = {
|
|
||||||
lb_mode: 'round_robin',
|
|
||||||
enabled: false,
|
|
||||||
type: 'tcp',
|
|
||||||
port: null,
|
|
||||||
path: null,
|
|
||||||
expected_status: null,
|
|
||||||
interval_sec: 30,
|
|
||||||
timeout_ms: 3000,
|
|
||||||
verify_tls: false,
|
|
||||||
provider: 'local',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ServiceGroupEditSheet({
|
export function ServiceGroupEditSheet({
|
||||||
mode,
|
mode,
|
||||||
group,
|
group,
|
||||||
@@ -74,7 +57,6 @@ export function ServiceGroupEditSheet({
|
|||||||
domain: null,
|
domain: null,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const [lbHealth, setLbHealth] = useState<LbAndHealthConfig>(defaultLbHealth)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
@@ -82,44 +64,19 @@ export function ServiceGroupEditSheet({
|
|||||||
form.reset({
|
form.reset({
|
||||||
name: group.name,
|
name: group.name,
|
||||||
type: group.type,
|
type: group.type,
|
||||||
domain: group.domain ?? null,
|
domain: null,
|
||||||
})
|
|
||||||
setLbHealth({
|
|
||||||
lb_mode: group.lb_mode,
|
|
||||||
enabled: group.health_check_enabled,
|
|
||||||
type: group.health_check_type === 'http' ? 'http' : 'tcp',
|
|
||||||
port: group.health_check_port,
|
|
||||||
path: group.health_check_path,
|
|
||||||
expected_status: group.health_check_expected_status,
|
|
||||||
interval_sec: group.health_check_interval_sec,
|
|
||||||
timeout_ms: group.health_check_timeout_ms,
|
|
||||||
verify_tls: group.health_check_verify_tls,
|
|
||||||
provider: 'local',
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
form.reset({ name: '', type: 'custom', domain: null })
|
form.reset({ name: '', type: 'custom', domain: null })
|
||||||
setLbHealth(defaultLbHealth)
|
|
||||||
}
|
}
|
||||||
}, [open, mode, group, form])
|
}, [open, mode, group, form])
|
||||||
|
|
||||||
const domainValue = form.watch('domain')
|
|
||||||
const hasDomain = Boolean(domainValue?.trim())
|
|
||||||
|
|
||||||
function handleSubmit(values: ServiceGroupFormValues) {
|
function handleSubmit(values: ServiceGroupFormValues) {
|
||||||
const body: CreateServiceGroupInput = {
|
const body: CreateServiceGroupInput = {
|
||||||
name: values.name,
|
name: values.name,
|
||||||
type: values.type ?? 'custom',
|
type: values.type ?? 'custom',
|
||||||
icon: values.icon,
|
icon: values.icon,
|
||||||
domain: values.domain?.trim() || null,
|
domain: null,
|
||||||
lb_mode: lbHealth.lb_mode,
|
|
||||||
health_check_enabled: lbHealth.enabled,
|
|
||||||
health_check_type: lbHealth.type,
|
|
||||||
health_check_port: lbHealth.port,
|
|
||||||
health_check_path: lbHealth.path,
|
|
||||||
health_check_expected_status: lbHealth.expected_status,
|
|
||||||
health_check_interval_sec: lbHealth.interval_sec,
|
|
||||||
health_check_timeout_ms: lbHealth.timeout_ms,
|
|
||||||
health_check_verify_tls: lbHealth.verify_tls,
|
|
||||||
}
|
}
|
||||||
if (mode === 'create') {
|
if (mode === 'create') {
|
||||||
onCreate?.(body)
|
onCreate?.(body)
|
||||||
@@ -133,7 +90,7 @@ export function ServiceGroupEditSheet({
|
|||||||
open={open}
|
open={open}
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
title={mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'}
|
title={mode === 'create' ? 'Новая группа сервисов' : 'Редактировать группу'}
|
||||||
description="Домен группы необязателен. Если указан FQDN (gr.ivx.su, domain.new.ivx.su), он публикуется в Cloudflare отдельно от привязок сервисов."
|
description="Группа нужна только для сортировки каталога. Общий домен и IP задаются у каждого сервиса."
|
||||||
form={form}
|
form={form}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
contentClassName="gap-6"
|
contentClassName="gap-6"
|
||||||
@@ -186,26 +143,7 @@ export function ServiceGroupEditSheet({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</FormFieldSimple>
|
</FormFieldSimple>
|
||||||
<FormFieldSimple
|
|
||||||
label="Домен группы (FQDN, необязательно)"
|
|
||||||
htmlFor="group-domain"
|
|
||||||
>
|
|
||||||
<AppInput
|
|
||||||
id="group-domain"
|
|
||||||
placeholder="domain.new.ivx.su"
|
|
||||||
{...form.register('domain')}
|
|
||||||
/>
|
|
||||||
</FormFieldSimple>
|
|
||||||
</AppFieldGroup>
|
</AppFieldGroup>
|
||||||
|
|
||||||
{hasDomain ? (
|
|
||||||
<HealthCheckConfigFields
|
|
||||||
value={lbHealth}
|
|
||||||
onChange={setLbHealth}
|
|
||||||
lbModeLabel="Режим балансировки общего домена"
|
|
||||||
idPrefix="group-lb-health"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</FormSheet>
|
</FormSheet>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { FolderOpenIcon, MoreHorizontalIcon, PlusIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
|
import { ServiceGroupIcon } from '@/components/service-group-icon'
|
||||||
|
import { ServiceUnitCard } from '@/components/services/service-unit-card'
|
||||||
|
import type { ServiceGroup, ServiceGroupView, ServiceView } from '@/lib/schemas'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@cfdm/ui/components/dropdown-menu'
|
||||||
|
|
||||||
|
const GROUP_TYPE_LABELS: Record<ServiceGroup['type'], string> = {
|
||||||
|
vpn: 'VPN',
|
||||||
|
network: 'Сеть',
|
||||||
|
internet: 'Интернет',
|
||||||
|
bgp: 'BGP',
|
||||||
|
custom: 'Другое',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServiceCatalogSectionProps {
|
||||||
|
group: ServiceGroupView | null
|
||||||
|
services: ServiceView[]
|
||||||
|
togglingId: number | null
|
||||||
|
togglingIp: { serviceId: number; ip: string } | null
|
||||||
|
onEditService: (service: ServiceView) => void
|
||||||
|
onDeleteService: (service: ServiceView) => void
|
||||||
|
onToggleService: (serviceId: number, enabled: boolean) => void
|
||||||
|
onToggleServiceIp: (serviceId: number, ip: string, enabled: boolean) => void
|
||||||
|
onEditGroup: (group: ServiceGroupView) => void
|
||||||
|
onDeleteGroup: (group: ServiceGroupView) => void
|
||||||
|
onAddServiceToGroup: (groupId: number | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceCatalogSection({
|
||||||
|
group,
|
||||||
|
services,
|
||||||
|
togglingId,
|
||||||
|
togglingIp,
|
||||||
|
onEditService,
|
||||||
|
onDeleteService,
|
||||||
|
onToggleService,
|
||||||
|
onToggleServiceIp,
|
||||||
|
onEditGroup,
|
||||||
|
onDeleteGroup,
|
||||||
|
onAddServiceToGroup,
|
||||||
|
}: ServiceCatalogSectionProps) {
|
||||||
|
const title = group?.name ?? 'Без группы'
|
||||||
|
const typeLabel = group ? GROUP_TYPE_LABELS[group.type] : null
|
||||||
|
const groupId = group?.id ?? null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="@container flex w-full flex-col gap-2"
|
||||||
|
aria-labelledby={`group-${groupId ?? 'none'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
|
<IconTile
|
||||||
|
variant="elevated"
|
||||||
|
size="sm"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{group ? (
|
||||||
|
<ServiceGroupIcon type={group.type} />
|
||||||
|
) : (
|
||||||
|
<FolderOpenIcon />
|
||||||
|
)}
|
||||||
|
</IconTile>
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||||
|
<h2
|
||||||
|
id={`group-${groupId ?? 'none'}`}
|
||||||
|
className="truncate text-sm font-medium"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{typeLabel ? (
|
||||||
|
<span className="text-muted-foreground text-xs">{typeLabel}</span>
|
||||||
|
) : null}
|
||||||
|
<Badge variant="outline" size="xs" className="shrink-0 tabular-nums">
|
||||||
|
{services.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={`Добавить сервис в ${title}`}
|
||||||
|
onClick={() => onAddServiceToGroup(groupId)}
|
||||||
|
>
|
||||||
|
<PlusIcon aria-hidden />
|
||||||
|
</Button>
|
||||||
|
{group ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Действия группы ${group.name}`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon aria-hidden />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => onAddServiceToGroup(group.id)}>
|
||||||
|
<PlusIcon aria-hidden />
|
||||||
|
Добавить сервис
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onEditGroup(group)}>
|
||||||
|
Изменить группу
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => onDeleteGroup(group)}
|
||||||
|
>
|
||||||
|
Удалить группу
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{services.length === 0 ? (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 py-1">
|
||||||
|
<span className="text-muted-foreground text-sm">Нет сервисов</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onAddServiceToGroup(groupId)}
|
||||||
|
>
|
||||||
|
<PlusIcon data-icon="inline-start" aria-hidden />
|
||||||
|
Добавить сервис
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-2 @xl:grid-cols-2 @4xl:grid-cols-3">
|
||||||
|
{services.map((service) => (
|
||||||
|
<ServiceUnitCard
|
||||||
|
key={service.id}
|
||||||
|
service={service}
|
||||||
|
togglingId={togglingId}
|
||||||
|
togglingIp={
|
||||||
|
togglingIp?.serviceId === service.id ? togglingIp.ip : null
|
||||||
|
}
|
||||||
|
onEditService={onEditService}
|
||||||
|
onDeleteService={onDeleteService}
|
||||||
|
onToggleService={onToggleService}
|
||||||
|
onToggleServiceIp={onToggleServiceIp}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,14 @@
|
|||||||
|
import { CheckIcon, CopyIcon } from 'lucide-react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { HealthCheckBadge } from '@/components/health-check-badge'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { TruncatedText } from '@/components/truncated-text'
|
import { TruncatedText } from '@/components/truncated-text'
|
||||||
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||||
import { serviceDisplayFqdns } from '@/lib/service-utils'
|
import { serviceDisplayFqdns } from '@/lib/service-utils'
|
||||||
import type { ServiceView } from '@/lib/schemas'
|
import type { ServiceView } from '@/lib/schemas'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import { Switch } from '@cfdm/ui/components/switch'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -10,16 +17,53 @@ import {
|
|||||||
} from '@cfdm/ui/components/tooltip'
|
} from '@cfdm/ui/components/tooltip'
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
import { cn } from '@cfdm/ui/lib/utils'
|
||||||
|
|
||||||
|
export function CopyFqdnButton({
|
||||||
|
value,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const { isCopied, copyToClipboard } = useCopyToClipboard({
|
||||||
|
onCopy: () => toast.success('Скопировано'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
className={className}
|
||||||
|
aria-label={isCopied ? 'Скопировано' : `Скопировать ${value}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
copyToClipboard(value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<CheckIcon className="text-success" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<CopyIcon aria-hidden />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface ServiceFqdnListProps {
|
interface ServiceFqdnListProps {
|
||||||
service: ServiceView
|
service: ServiceView
|
||||||
className?: string
|
className?: string
|
||||||
emptyLabel?: string
|
emptyLabel?: string
|
||||||
|
copyable?: boolean
|
||||||
|
textClassName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServiceFqdnList({
|
export function ServiceFqdnList({
|
||||||
service,
|
service,
|
||||||
className,
|
className,
|
||||||
emptyLabel = 'FQDN не задан',
|
emptyLabel = 'Нет FQDN',
|
||||||
|
copyable = false,
|
||||||
|
textClassName,
|
||||||
}: ServiceFqdnListProps) {
|
}: ServiceFqdnListProps) {
|
||||||
const fqdns = serviceDisplayFqdns(service)
|
const fqdns = serviceDisplayFqdns(service)
|
||||||
if (fqdns.length === 0) {
|
if (fqdns.length === 0) {
|
||||||
@@ -32,10 +76,16 @@ export function ServiceFqdnList({
|
|||||||
|
|
||||||
const [first, ...rest] = fqdns
|
const [first, ...rest] = fqdns
|
||||||
const extraCount = rest.length
|
const extraCount = rest.length
|
||||||
|
const copyValue = fqdns.join('\n')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex min-w-0 items-center gap-1.5', className)}>
|
<div className={cn('flex min-w-0 items-center gap-1.5', className)}>
|
||||||
<TruncatedText className="text-muted-foreground min-w-0 font-mono text-xs">
|
<TruncatedText
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground min-w-0 font-mono text-xs',
|
||||||
|
textClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{first}
|
{first}
|
||||||
</TruncatedText>
|
</TruncatedText>
|
||||||
{extraCount > 0 ? (
|
{extraCount > 0 ? (
|
||||||
@@ -62,6 +112,114 @@ export function ServiceFqdnList({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
) : null}
|
) : null}
|
||||||
|
{copyable ? <CopyFqdnButton value={copyValue} /> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const VISIBLE_IP_LIMIT = 6
|
||||||
|
|
||||||
|
interface ServiceIpListProps {
|
||||||
|
ips: string[]
|
||||||
|
ipHealth?: ServiceView['ip_health']
|
||||||
|
ipEnabled?: Record<string, boolean>
|
||||||
|
togglingIp?: string | null
|
||||||
|
ipToggleDisabled?: boolean
|
||||||
|
onToggleIp?: (ip: string, enabled: boolean) => void
|
||||||
|
className?: string
|
||||||
|
emptyLabel?: string
|
||||||
|
copyable?: boolean
|
||||||
|
textClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceIpList({
|
||||||
|
ips,
|
||||||
|
ipHealth = [],
|
||||||
|
ipEnabled = {},
|
||||||
|
togglingIp = null,
|
||||||
|
ipToggleDisabled = false,
|
||||||
|
onToggleIp,
|
||||||
|
className,
|
||||||
|
emptyLabel = 'Нет IP',
|
||||||
|
copyable = false,
|
||||||
|
textClassName,
|
||||||
|
}: ServiceIpListProps) {
|
||||||
|
if (ips.length === 0) {
|
||||||
|
return (
|
||||||
|
<span className={cn('text-muted-foreground text-xs', className)}>
|
||||||
|
{emptyLabel}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const healthByIp = new Map(ipHealth.map((row) => [row.ip, row]))
|
||||||
|
const visible = onToggleIp ? ips : ips.slice(0, VISIBLE_IP_LIMIT)
|
||||||
|
const extraCount = ips.length - visible.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex min-w-0 flex-col gap-1', className)}>
|
||||||
|
{visible.map((ip) => {
|
||||||
|
const health = healthByIp.get(ip)
|
||||||
|
const enabled = ipEnabled[ip] !== false
|
||||||
|
return (
|
||||||
|
<div key={ip} className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<HealthCheckBadge
|
||||||
|
status={health?.status ?? 'unknown'}
|
||||||
|
latencyMs={health?.latency_ms}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
<TruncatedText
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 font-mono text-xs',
|
||||||
|
enabled ? 'text-muted-foreground' : 'text-muted-foreground/60',
|
||||||
|
textClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ip}
|
||||||
|
</TruncatedText>
|
||||||
|
{copyable ? <CopyFqdnButton value={ip} /> : null}
|
||||||
|
{onToggleIp ? (
|
||||||
|
<Switch
|
||||||
|
size="sm"
|
||||||
|
className="ml-auto shrink-0"
|
||||||
|
checked={enabled}
|
||||||
|
disabled={ipToggleDisabled || togglingIp === ip}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onCheckedChange={(checked) => onToggleIp(ip, Boolean(checked))}
|
||||||
|
aria-label={
|
||||||
|
enabled ? `Выключить IP ${ip}` : `Включить IP ${ip}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{extraCount > 0 ? (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
className="w-fit shrink-0 tabular-nums"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
ещё {extraCount}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
<ul className="flex flex-col gap-0.5 font-mono text-xs">
|
||||||
|
{ips.slice(VISIBLE_IP_LIMIT).map((ip) => (
|
||||||
|
<li key={ip}>{ip}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { MoreHorizontalIcon, ServerIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
|
import {
|
||||||
|
CopyFqdnButton,
|
||||||
|
ServiceIpList,
|
||||||
|
} from '@/components/services/service-fqdn-list'
|
||||||
|
import { serviceDisplayFqdn, serviceDisplayFqdns } from '@/lib/service-utils'
|
||||||
|
import type { ServiceView } from '@/lib/schemas'
|
||||||
|
import { Button } from '@cfdm/ui/components/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@cfdm/ui/components/dropdown-menu'
|
||||||
|
import { Switch } from '@cfdm/ui/components/switch'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@cfdm/ui/components/tooltip'
|
||||||
|
|
||||||
|
interface ServiceUnitCardProps {
|
||||||
|
service: ServiceView
|
||||||
|
togglingId: number | null
|
||||||
|
togglingIp: string | null
|
||||||
|
onEditService: (service: ServiceView) => void
|
||||||
|
onDeleteService: (service: ServiceView) => void
|
||||||
|
onToggleService: (serviceId: number, enabled: boolean) => void
|
||||||
|
onToggleServiceIp: (serviceId: number, ip: string, enabled: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServiceUnitCard({
|
||||||
|
service,
|
||||||
|
togglingId,
|
||||||
|
togglingIp,
|
||||||
|
onEditService,
|
||||||
|
onDeleteService,
|
||||||
|
onToggleService,
|
||||||
|
onToggleServiceIp,
|
||||||
|
}: ServiceUnitCardProps) {
|
||||||
|
const fqdns = serviceDisplayFqdns(service)
|
||||||
|
const primaryDomain = serviceDisplayFqdn(service)
|
||||||
|
const extraCount = Math.max(0, fqdns.length - 1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className="h-full min-w-0 overflow-hidden">
|
||||||
|
<FrameHeader className="flex-row items-center justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<IconTile
|
||||||
|
variant="elevated"
|
||||||
|
size="sm"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<ServerIcon />
|
||||||
|
</IconTile>
|
||||||
|
<div className="flex min-w-0 flex-col gap-px">
|
||||||
|
<FrameTitle className="min-w-0 truncate text-sm">
|
||||||
|
<Link
|
||||||
|
to="/services/$serviceId"
|
||||||
|
params={{ serviceId: String(service.id) }}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{service.name}
|
||||||
|
</Link>
|
||||||
|
</FrameTitle>
|
||||||
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
|
<FrameDescription className="min-w-0 truncate font-mono text-xs">
|
||||||
|
{primaryDomain}
|
||||||
|
</FrameDescription>
|
||||||
|
{extraCount > 0 ? (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
className="shrink-0 tabular-nums"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+{extraCount}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
<ul className="flex flex-col gap-0.5 font-mono text-xs">
|
||||||
|
{fqdns.map((fqdn) => (
|
||||||
|
<li key={fqdn}>{fqdn}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : null}
|
||||||
|
{primaryDomain !== '—' ? (
|
||||||
|
<CopyFqdnButton value={fqdns.join('\n')} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<Switch
|
||||||
|
size="sm"
|
||||||
|
checked={service.enabled}
|
||||||
|
disabled={togglingId === service.id}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onToggleService(service.id, Boolean(checked))
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
service.enabled ? 'Выключить сервис' : 'Включить сервис'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Действия ${service.name}`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon aria-hidden />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
render={
|
||||||
|
<Link
|
||||||
|
to="/services/$serviceId"
|
||||||
|
params={{ serviceId: String(service.id) }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Обзор
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => onEditService(service)}>
|
||||||
|
Изменить
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => onDeleteService(service)}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</FrameHeader>
|
||||||
|
|
||||||
|
<FramePanel className="flex min-w-0 flex-col gap-1 pt-0 shadow-none!">
|
||||||
|
<ServiceIpList
|
||||||
|
copyable
|
||||||
|
ips={service.ips ?? []}
|
||||||
|
ipHealth={service.ip_health ?? []}
|
||||||
|
ipEnabled={service.ip_enabled ?? {}}
|
||||||
|
ipToggleDisabled={togglingId === service.id}
|
||||||
|
togglingIp={togglingIp}
|
||||||
|
onToggleIp={(ip, enabled) =>
|
||||||
|
onToggleServiceIp(service.id, ip, enabled)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,45 +1,26 @@
|
|||||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
import { useMemo, useState, type ReactNode } from 'react'
|
||||||
import {
|
|
||||||
getCoreRowModel,
|
|
||||||
getExpandedRowModel,
|
|
||||||
useReactTable,
|
|
||||||
type ExpandedState,
|
|
||||||
} from '@tanstack/react-table'
|
|
||||||
import {
|
import {
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
FilterIcon,
|
|
||||||
FolderPlusIcon,
|
FolderPlusIcon,
|
||||||
FunnelXIcon,
|
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
|
SearchIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
|
||||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
|
||||||
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
|
||||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
|
||||||
import { Filters, type Filter } from '@/components/reui/filters'
|
|
||||||
import {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
FrameDescription,
|
FrameDescription,
|
||||||
FrameFooter,
|
|
||||||
FrameHeader,
|
FrameHeader,
|
||||||
FramePanel,
|
FramePanel,
|
||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
|
||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import { applyFiltersToData } from '@/components/reui-kit/filter-utils'
|
import { ServiceCatalogSection } from '@/components/services/service-catalog-section'
|
||||||
import { createServicesGroupedColumns } from '@/components/services/services-grouped-columns'
|
|
||||||
import type { ServiceCatalogTreeRow } from '@/components/services/services-grouped-columns'
|
|
||||||
import {
|
import {
|
||||||
SERVICE_TABS,
|
|
||||||
createDefaultServiceFilters,
|
|
||||||
serviceFilterFieldValue,
|
|
||||||
serviceTabFilter,
|
serviceTabFilter,
|
||||||
useServiceFilterFields,
|
|
||||||
type ServiceCatalogRow,
|
type ServiceCatalogRow,
|
||||||
} from '@/components/columns/services-columns'
|
} from '@/components/columns/services-columns'
|
||||||
|
import { serviceDisplayFqdns } from '@/lib/service-utils'
|
||||||
import type {
|
import type {
|
||||||
ServiceGroupView,
|
ServiceGroupView,
|
||||||
ServiceGroupsResponse,
|
ServiceGroupsResponse,
|
||||||
@@ -52,23 +33,29 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@cfdm/ui/components/dropdown-menu'
|
} from '@cfdm/ui/components/dropdown-menu'
|
||||||
import { Separator } from '@cfdm/ui/components/separator'
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupText,
|
||||||
|
} from '@cfdm/ui/components/input-group'
|
||||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||||
|
|
||||||
const HEALTH_TABS = [
|
|
||||||
{ id: 'health-ok', label: 'OK' },
|
|
||||||
{ id: 'health-slow', label: 'Slow' },
|
|
||||||
{ id: 'health-down', label: 'Down' },
|
|
||||||
{ id: 'health-unknown', label: '—' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const ALL_TABS = [...SERVICE_TABS, ...HEALTH_TABS] as const
|
|
||||||
|
|
||||||
function serviceMatchesDomain(service: ServiceView, domainId?: number) {
|
function serviceMatchesDomain(service: ServiceView, domainId?: number) {
|
||||||
if (domainId == null) return true
|
if (domainId == null) return true
|
||||||
return service.domains.some((d) => d.domain_id === domainId)
|
return service.domains.some((d) => d.domain_id === domainId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serviceMatchesQuery(service: ServiceView, query: string) {
|
||||||
|
const needle = query.trim().toLowerCase()
|
||||||
|
if (!needle) return true
|
||||||
|
if (service.name.toLowerCase().includes(needle)) return true
|
||||||
|
if (service.slug.toLowerCase().includes(needle)) return true
|
||||||
|
return serviceDisplayFqdns(service).some((fqdn) =>
|
||||||
|
fqdn.toLowerCase().includes(needle),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function toCatalogRow(
|
function toCatalogRow(
|
||||||
service: ServiceView,
|
service: ServiceView,
|
||||||
groupId: number | null,
|
groupId: number | null,
|
||||||
@@ -86,72 +73,51 @@ function toCatalogRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function catalogTabFilter(row: ServiceCatalogRow, tabId: string) {
|
interface GroupUnitData {
|
||||||
if (tabId.startsWith('health-')) {
|
id: string
|
||||||
const status = row.service.health_status ?? 'unknown'
|
group: ServiceGroupView | null
|
||||||
if (tabId === 'health-ok') return status === 'up'
|
services: ServiceView[]
|
||||||
if (tabId === 'health-slow') return status === 'degraded'
|
|
||||||
if (tabId === 'health-down') return status === 'down'
|
|
||||||
if (tabId === 'health-unknown') return status === 'unknown'
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return serviceTabFilter(row, tabId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTreeRows(
|
function buildGroupUnits(
|
||||||
data: ServiceGroupsResponse,
|
data: ServiceGroupsResponse,
|
||||||
filteredServiceIds: Set<number>,
|
filteredServiceIds: Set<number>,
|
||||||
domainId?: number,
|
domainId: number | undefined,
|
||||||
): ServiceCatalogTreeRow[] {
|
showEmptyGroups: boolean,
|
||||||
const rows: ServiceCatalogTreeRow[] = []
|
): GroupUnitData[] {
|
||||||
|
const units: GroupUnitData[] = []
|
||||||
|
|
||||||
for (const group of data.groups) {
|
for (const group of data.groups) {
|
||||||
const services = group.services
|
const matchingDomain = group.services.filter((service) =>
|
||||||
.filter((s) => serviceMatchesDomain(s, domainId))
|
serviceMatchesDomain(service, domainId),
|
||||||
.filter((s) => filteredServiceIds.has(s.id))
|
)
|
||||||
if (services.length === 0) continue
|
const services = matchingDomain.filter((service) =>
|
||||||
|
filteredServiceIds.has(service.id),
|
||||||
|
)
|
||||||
|
|
||||||
rows.push({
|
if (services.length === 0) {
|
||||||
kind: 'group',
|
if (showEmptyGroups && matchingDomain.length === 0) {
|
||||||
id: `group-${group.id}`,
|
units.push({ id: `group-${group.id}`, group, services: [] })
|
||||||
group,
|
}
|
||||||
subRows: services.map((service) => ({
|
continue
|
||||||
kind: 'service' as const,
|
}
|
||||||
id: `service-${service.id}`,
|
|
||||||
service,
|
units.push({ id: `group-${group.id}`, group, services })
|
||||||
groupId: group.id,
|
|
||||||
groupName: group.name,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ungrouped = data.ungrouped
|
const ungrouped = data.ungrouped
|
||||||
.filter((s) => serviceMatchesDomain(s, domainId))
|
.filter((service) => serviceMatchesDomain(service, domainId))
|
||||||
.filter((s) => filteredServiceIds.has(s.id))
|
.filter((service) => filteredServiceIds.has(service.id))
|
||||||
|
|
||||||
if (ungrouped.length > 0) {
|
if (ungrouped.length > 0) {
|
||||||
rows.push({
|
units.push({
|
||||||
kind: 'group',
|
|
||||||
id: 'group-ungrouped',
|
id: 'group-ungrouped',
|
||||||
group: null,
|
group: null,
|
||||||
subRows: ungrouped.map((service) => ({
|
services: ungrouped,
|
||||||
kind: 'service' as const,
|
|
||||||
id: `service-${service.id}`,
|
|
||||||
service,
|
|
||||||
groupId: null,
|
|
||||||
groupName: null,
|
|
||||||
})),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows
|
return units
|
||||||
}
|
|
||||||
|
|
||||||
function defaultExpanded(rows: ServiceCatalogTreeRow[]): ExpandedState {
|
|
||||||
return rows.reduce<Record<string, boolean>>((acc, row) => {
|
|
||||||
acc[row.id] = true
|
|
||||||
return acc
|
|
||||||
}, {})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServicesAddMenu({
|
export function ServicesAddMenu({
|
||||||
@@ -194,11 +160,13 @@ interface ServicesGroupedCatalogProps {
|
|||||||
primaryAction?: ReactNode
|
primaryAction?: ReactNode
|
||||||
hideHeader?: boolean
|
hideHeader?: boolean
|
||||||
togglingId: number | null
|
togglingId: number | null
|
||||||
|
togglingIp: { serviceId: number; ip: string } | null
|
||||||
activeTab?: string
|
activeTab?: string
|
||||||
onTabChange?: (tabId: string) => void
|
onTabChange?: (tabId: string) => void
|
||||||
onEditService: (service: ServiceView) => void
|
onEditService: (service: ServiceView) => void
|
||||||
onDeleteService: (service: ServiceView) => void
|
onDeleteService: (service: ServiceView) => void
|
||||||
onToggleService: (serviceId: number, enabled: boolean) => void
|
onToggleService: (serviceId: number, enabled: boolean) => void
|
||||||
|
onToggleServiceIp: (serviceId: number, ip: string, enabled: boolean) => void
|
||||||
onEditGroup: (group: ServiceGroupView) => void
|
onEditGroup: (group: ServiceGroupView) => void
|
||||||
onDeleteGroup: (group: ServiceGroupView) => void
|
onDeleteGroup: (group: ServiceGroupView) => void
|
||||||
onAddServiceToGroup: (groupId: number | null) => void
|
onAddServiceToGroup: (groupId: number | null) => void
|
||||||
@@ -213,24 +181,18 @@ export function ServicesGroupedCatalog({
|
|||||||
primaryAction,
|
primaryAction,
|
||||||
hideHeader = false,
|
hideHeader = false,
|
||||||
togglingId,
|
togglingId,
|
||||||
activeTab: controlledTab,
|
togglingIp,
|
||||||
onTabChange,
|
activeTab = 'all',
|
||||||
onEditService,
|
onEditService,
|
||||||
onDeleteService,
|
onDeleteService,
|
||||||
onToggleService,
|
onToggleService,
|
||||||
|
onToggleServiceIp,
|
||||||
onEditGroup,
|
onEditGroup,
|
||||||
onDeleteGroup,
|
onDeleteGroup,
|
||||||
onAddServiceToGroup,
|
onAddServiceToGroup,
|
||||||
emptyAction,
|
emptyAction,
|
||||||
}: ServicesGroupedCatalogProps) {
|
}: ServicesGroupedCatalogProps) {
|
||||||
const [internalTab, setInternalTab] = useState('all')
|
const [query, setQuery] = useState('')
|
||||||
const tab = controlledTab ?? internalTab
|
|
||||||
const setTab = onTabChange ?? setInternalTab
|
|
||||||
const [filters, setFilters] = useState<Filter[]>(() =>
|
|
||||||
createDefaultServiceFilters(),
|
|
||||||
)
|
|
||||||
const [expanded, setExpanded] = useState<ExpandedState>({})
|
|
||||||
const filterFields = useServiceFilterFields()
|
|
||||||
|
|
||||||
const flatRows = useMemo(() => {
|
const flatRows = useMemo(() => {
|
||||||
const rows: ServiceCatalogRow[] = []
|
const rows: ServiceCatalogRow[] = []
|
||||||
@@ -247,76 +209,33 @@ export function ServicesGroupedCatalog({
|
|||||||
return rows
|
return rows
|
||||||
}, [data, domainId])
|
}, [data, domainId])
|
||||||
|
|
||||||
const tabCounts = useMemo(() => {
|
|
||||||
const counts: Record<string, number> = {}
|
|
||||||
for (const t of ALL_TABS) {
|
|
||||||
counts[t.id] = flatRows.filter((row) => catalogTabFilter(row, t.id)).length
|
|
||||||
}
|
|
||||||
return counts
|
|
||||||
}, [flatRows])
|
|
||||||
|
|
||||||
const filteredIds = useMemo(() => {
|
const filteredIds = useMemo(() => {
|
||||||
const afterTab = flatRows.filter((row) => catalogTabFilter(row, tab))
|
const afterTab = flatRows.filter((row) => serviceTabFilter(row, activeTab))
|
||||||
const afterFilters = applyFiltersToData(afterTab, filters, (item, field) =>
|
const afterQuery = afterTab.filter((row) =>
|
||||||
serviceFilterFieldValue(item, field),
|
serviceMatchesQuery(row.service, query),
|
||||||
)
|
)
|
||||||
return new Set(afterFilters.map((r) => r.id))
|
return new Set(afterQuery.map((r) => r.id))
|
||||||
}, [flatRows, tab, filters])
|
}, [flatRows, activeTab, query])
|
||||||
|
|
||||||
const treeData = useMemo(
|
const showEmptyGroups =
|
||||||
() => buildTreeRows(data, filteredIds, domainId),
|
activeTab === 'all' && domainId == null && query.trim().length === 0
|
||||||
[data, filteredIds, domainId],
|
|
||||||
|
const units = useMemo(
|
||||||
|
() => buildGroupUnits(data, filteredIds, domainId, showEmptyGroups),
|
||||||
|
[data, filteredIds, domainId, showEmptyGroups],
|
||||||
)
|
)
|
||||||
|
|
||||||
const expandedKey = treeData.map((r) => r.id).join(',')
|
|
||||||
useEffect(() => {
|
|
||||||
setExpanded(defaultExpanded(treeData))
|
|
||||||
}, [expandedKey, treeData])
|
|
||||||
|
|
||||||
const columns = useMemo(
|
|
||||||
() =>
|
|
||||||
createServicesGroupedColumns({
|
|
||||||
onEditService,
|
|
||||||
onDeleteService,
|
|
||||||
onToggleService,
|
|
||||||
onEditGroup,
|
|
||||||
onDeleteGroup,
|
|
||||||
onAddServiceToGroup,
|
|
||||||
togglingId,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
onEditService,
|
|
||||||
onDeleteService,
|
|
||||||
onToggleService,
|
|
||||||
onEditGroup,
|
|
||||||
onDeleteGroup,
|
|
||||||
onAddServiceToGroup,
|
|
||||||
togglingId,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
const table = useReactTable({
|
|
||||||
data: treeData,
|
|
||||||
columns,
|
|
||||||
state: { expanded },
|
|
||||||
onExpandedChange: setExpanded,
|
|
||||||
getSubRows: (row) => (row.kind === 'group' ? row.subRows : undefined),
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getExpandedRowModel: getExpandedRowModel(),
|
|
||||||
getRowId: (row) => row.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Frame dense spacing="sm" className="w-full">
|
<Frame dense spacing="sm" className="w-full">
|
||||||
<FrameHeader>
|
<FrameHeader>
|
||||||
<Skeleton className="h-5 w-48" />
|
<Skeleton className="h-5 w-48" />
|
||||||
<Skeleton className="mt-1 h-4 w-72" />
|
<Skeleton className="h-4 w-72" />
|
||||||
</FrameHeader>
|
</FrameHeader>
|
||||||
<FramePanel className="flex flex-col gap-3 p-4">
|
<FramePanel className="flex flex-col gap-3 p-4">
|
||||||
<Skeleton className="h-9 w-full max-w-md" />
|
<Skeleton className="h-8 w-full max-w-md" />
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
<Skeleton key={i} className="h-10 w-full" />
|
<Skeleton key={i} className="h-36 w-full rounded-xl" />
|
||||||
))}
|
))}
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
@@ -342,112 +261,66 @@ export function ServicesGroupedCatalog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGrid
|
<Frame dense spacing="sm" className="w-full">
|
||||||
table={table}
|
{!hideHeader ? (
|
||||||
recordCount={filteredIds.size}
|
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||||
emptyMessage="Нет записей по выбранным фильтрам."
|
<div className="flex min-w-0 flex-col gap-px">
|
||||||
tableLayout={{ dense: true }}
|
<FrameTitle>Сервисы</FrameTitle>
|
||||||
>
|
<FrameDescription>
|
||||||
<Frame dense spacing="sm" className="w-full">
|
{domainLabel
|
||||||
{!hideHeader ? (
|
? `Каталог сервисов с привязками к ${domainLabel}`
|
||||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
: 'Группы для сортировки; у каждого сервиса — общий домен и IP'}
|
||||||
<div className="flex min-w-0 flex-col gap-px">
|
</FrameDescription>
|
||||||
<FrameTitle>Сервисы</FrameTitle>
|
</div>
|
||||||
<FrameDescription>
|
{primaryAction ? (
|
||||||
{domainLabel
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||||
? `Каталог сервисов с привязками к ${domainLabel}`
|
{primaryAction}
|
||||||
: 'Группы, FQDN и доступность сервисов'}
|
|
||||||
</FrameDescription>
|
|
||||||
</div>
|
</div>
|
||||||
{primaryAction ? (
|
) : null}
|
||||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
</FrameHeader>
|
||||||
{primaryAction}
|
) : null}
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</FrameHeader>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<FramePanel className="p-0 shadow-none!">
|
<FramePanel className="flex flex-col gap-4">
|
||||||
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
|
<InputGroup className="max-w-md">
|
||||||
<CountedLineTabs
|
<InputGroupAddon>
|
||||||
tabs={ALL_TABS.map((t) => ({
|
<InputGroupText>
|
||||||
id: t.id,
|
<SearchIcon aria-hidden />
|
||||||
label: t.label,
|
</InputGroupText>
|
||||||
count: tabCounts[t.id] ?? 0,
|
</InputGroupAddon>
|
||||||
}))}
|
<InputGroupInput
|
||||||
value={tab}
|
value={query}
|
||||||
onValueChange={setTab}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
/>
|
placeholder="Поиск по названию"
|
||||||
</div>
|
aria-label="Поиск по названию"
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
<Separator />
|
{units.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
title="Нет совпадений"
|
||||||
<Filters
|
description="Измените запрос поиска."
|
||||||
filters={filters}
|
/>
|
||||||
fields={filterFields}
|
) : (
|
||||||
onChange={setFilters}
|
<div className="flex flex-col gap-6">
|
||||||
size="default"
|
{units.map((unit) => (
|
||||||
trigger={
|
<ServiceCatalogSection
|
||||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
key={unit.id}
|
||||||
<FilterIcon className="size-4" aria-hidden />
|
group={unit.group}
|
||||||
Фильтры
|
services={unit.services}
|
||||||
</Button>
|
togglingId={togglingId}
|
||||||
}
|
togglingIp={togglingIp}
|
||||||
/>
|
onEditService={onEditService}
|
||||||
<Button
|
onDeleteService={onDeleteService}
|
||||||
type="button"
|
onToggleService={onToggleService}
|
||||||
variant="outline"
|
onToggleServiceIp={onToggleServiceIp}
|
||||||
onClick={() => {
|
onEditGroup={onEditGroup}
|
||||||
setTab('all')
|
onDeleteGroup={onDeleteGroup}
|
||||||
setFilters(createDefaultServiceFilters())
|
onAddServiceToGroup={onAddServiceToGroup}
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FunnelXIcon className="size-4" aria-hidden />
|
|
||||||
Сбросить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{treeData.length === 0 ? (
|
|
||||||
<div className="p-6">
|
|
||||||
<EmptyState
|
|
||||||
title="Нет совпадений"
|
|
||||||
description="Измените фильтры или вкладку."
|
|
||||||
action={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
setTab('all')
|
|
||||||
setFilters(createDefaultServiceFilters())
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Сбросить
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
))}
|
||||||
) : (
|
</div>
|
||||||
<>
|
)}
|
||||||
<DataGridScrollArea>
|
</FramePanel>
|
||||||
<DataGridTable />
|
</Frame>
|
||||||
</DataGridScrollArea>
|
|
||||||
<Separator />
|
|
||||||
<FrameFooter>
|
|
||||||
<DataGridPagination
|
|
||||||
sizes={[5, 10, 20, 50]}
|
|
||||||
rowsPerPageLabel="Строк на странице"
|
|
||||||
info="{from} - {to} of {count}"
|
|
||||||
previousPageLabel="Предыдущая"
|
|
||||||
nextPageLabel="Следующая"
|
|
||||||
/>
|
|
||||||
</FrameFooter>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
</DataGrid>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,308 +0,0 @@
|
|||||||
import { useMemo } from 'react'
|
|
||||||
import { Link } from '@tanstack/react-router'
|
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
|
||||||
import {
|
|
||||||
ChevronRightIcon,
|
|
||||||
FolderIcon,
|
|
||||||
MoreHorizontalIcon,
|
|
||||||
PlusIcon,
|
|
||||||
} from 'lucide-react'
|
|
||||||
|
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
|
||||||
import { HealthCheckBadge } from '@/components/health-check-badge'
|
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
|
||||||
import type { ServiceGroupView, ServiceView } from '@/lib/schemas'
|
|
||||||
import { ServiceFqdnList } from '@/components/services/service-fqdn-list'
|
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@cfdm/ui/components/dropdown-menu'
|
|
||||||
import { Switch } from '@cfdm/ui/components/switch'
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
|
||||||
import { cn } from '@cfdm/ui/lib/utils'
|
|
||||||
|
|
||||||
export type ServiceTreeServiceRow = {
|
|
||||||
kind: 'service'
|
|
||||||
id: string
|
|
||||||
service: ServiceView
|
|
||||||
groupId: number | null
|
|
||||||
groupName: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ServiceTreeGroupRow = {
|
|
||||||
kind: 'group'
|
|
||||||
id: string
|
|
||||||
group: ServiceGroupView | null
|
|
||||||
subRows: ServiceTreeServiceRow[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ServiceCatalogTreeRow = ServiceTreeGroupRow | ServiceTreeServiceRow
|
|
||||||
|
|
||||||
function isServiceRow(row: ServiceCatalogTreeRow): row is ServiceTreeServiceRow {
|
|
||||||
return row.kind === 'service'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createServicesGroupedColumns({
|
|
||||||
onEditService,
|
|
||||||
onDeleteService,
|
|
||||||
onToggleService,
|
|
||||||
onEditGroup,
|
|
||||||
onDeleteGroup,
|
|
||||||
onAddServiceToGroup,
|
|
||||||
togglingId,
|
|
||||||
}: {
|
|
||||||
onEditService: (service: ServiceView) => void
|
|
||||||
onDeleteService: (service: ServiceView) => void
|
|
||||||
onToggleService: (serviceId: number, enabled: boolean) => void
|
|
||||||
onEditGroup: (group: ServiceGroupView) => void
|
|
||||||
onDeleteGroup: (group: ServiceGroupView) => void
|
|
||||||
onAddServiceToGroup: (groupId: number | null) => void
|
|
||||||
togglingId: number | null
|
|
||||||
}): ColumnDef<ServiceCatalogTreeRow>[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: 'name',
|
|
||||||
accessorFn: (row) =>
|
|
||||||
isServiceRow(row) ? row.service.name : (row.group?.name ?? 'Без группы'),
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} title="Группа / сервис" />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const original = row.original
|
|
||||||
if (!isServiceRow(original)) {
|
|
||||||
const title = original.group?.name ?? 'Без группы'
|
|
||||||
const domain = original.group?.domain
|
|
||||||
return (
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
aria-label={
|
|
||||||
row.getIsExpanded() ? `Свернуть ${title}` : `Развернуть ${title}`
|
|
||||||
}
|
|
||||||
aria-expanded={row.getIsExpanded()}
|
|
||||||
className="text-muted-foreground hover:text-foreground size-6 shrink-0 p-0 shadow-none"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
row.getToggleExpandedHandler()()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ChevronRightIcon
|
|
||||||
className={cn(
|
|
||||||
'size-3.5 shrink-0 transition-transform duration-150',
|
|
||||||
row.getIsExpanded() && 'rotate-90',
|
|
||||||
)}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
<FolderIcon
|
|
||||||
className="text-muted-foreground size-4 shrink-0"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<div className="flex min-w-0 flex-col gap-0.5">
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<span className="truncate text-sm font-semibold">{title}</span>
|
|
||||||
<Badge variant="outline" size="xs" className="shrink-0">
|
|
||||||
{original.subRows.length}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
{domain ? (
|
|
||||||
<span className="text-muted-foreground truncate font-mono text-xs">
|
|
||||||
{domain}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-w-0 flex-col gap-0.5 pl-8">
|
|
||||||
<span className="truncate text-sm font-medium">
|
|
||||||
{original.service.name}
|
|
||||||
</span>
|
|
||||||
<ServiceFqdnList service={original.service} emptyLabel="—" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
enableSorting: false,
|
|
||||||
minSize: 260,
|
|
||||||
meta: { headerTitle: 'Группа / сервис', autoSize: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'health',
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} title="Доступность" />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const original = row.original
|
|
||||||
if (!isServiceRow(original)) {
|
|
||||||
return (
|
|
||||||
<HealthCheckBadge
|
|
||||||
status={original.group?.health_status ?? 'unknown'}
|
|
||||||
latencyMs={original.group?.health_latency_ms}
|
|
||||||
size="xs"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<HealthCheckBadge
|
|
||||||
status={original.service.health_status ?? 'unknown'}
|
|
||||||
latencyMs={original.service.health_latency_ms}
|
|
||||||
size="xs"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
size: 120,
|
|
||||||
enableSorting: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'enabled',
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} title="Статус" />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const original = row.original
|
|
||||||
if (!isServiceRow(original)) return null
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
checked={original.service.enabled}
|
|
||||||
disabled={togglingId === original.service.id}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onToggleService(original.service.id, Boolean(checked))
|
|
||||||
}
|
|
||||||
aria-label={
|
|
||||||
original.service.enabled
|
|
||||||
? 'Выключить сервис'
|
|
||||||
: 'Включить сервис'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<StatusBadge
|
|
||||||
status={original.service.enabled ? 'active' : 'disabled'}
|
|
||||||
label={original.service.enabled ? 'Вкл' : 'Выкл'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
size: 140,
|
|
||||||
enableSorting: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
enableSorting: false,
|
|
||||||
header: () => <span className="sr-only">Действия</span>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const original = row.original
|
|
||||||
if (!isServiceRow(original)) {
|
|
||||||
if (!original.group) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
aria-label="Добавить сервис без группы"
|
|
||||||
onClick={() => onAddServiceToGroup(null)}
|
|
||||||
>
|
|
||||||
<PlusIcon aria-hidden />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={`Действия группы ${original.group.name}`}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MoreHorizontalIcon aria-hidden />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => onAddServiceToGroup(original.group!.id)}
|
|
||||||
>
|
|
||||||
<PlusIcon aria-hidden />
|
|
||||||
Добавить сервис
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => onEditGroup(original.group!)}>
|
|
||||||
Изменить группу
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => onDeleteGroup(original.group!)}
|
|
||||||
>
|
|
||||||
Удалить группу
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
aria-label={`Действия ${original.service.name}`}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MoreHorizontalIcon aria-hidden />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
render={
|
|
||||||
<Link
|
|
||||||
to="/services/$serviceId"
|
|
||||||
params={{ serviceId: String(original.service.id) }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Обзор
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => onEditService(original.service)}>
|
|
||||||
Изменить
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => onDeleteService(original.service)}
|
|
||||||
>
|
|
||||||
Удалить
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
size: 56,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useServicesGroupedColumns(
|
|
||||||
args: Parameters<typeof createServicesGroupedColumns>[0],
|
|
||||||
) {
|
|
||||||
return useMemo(() => createServicesGroupedColumns(args), [
|
|
||||||
args.onEditService,
|
|
||||||
args.onDeleteService,
|
|
||||||
args.onToggleService,
|
|
||||||
args.onEditGroup,
|
|
||||||
args.onDeleteGroup,
|
|
||||||
args.onAddServiceToGroup,
|
|
||||||
args.togglingId,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
@@ -94,6 +94,12 @@ export const serviceDomainBindingSchema = z
|
|||||||
: (binding.record_type ?? 'A'),
|
: (binding.record_type ?? 'A'),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
export const serviceIpHealthSchema = z.object({
|
||||||
|
ip: z.string(),
|
||||||
|
status: z.enum(['up', 'down', 'degraded', 'unknown']),
|
||||||
|
latency_ms: z.number().nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
export const serviceViewSchema = serviceSchema.extend({
|
export const serviceViewSchema = serviceSchema.extend({
|
||||||
subdomain: z.string().default(''),
|
subdomain: z.string().default(''),
|
||||||
enabled: z.coerce.boolean().default(false),
|
enabled: z.coerce.boolean().default(false),
|
||||||
@@ -101,6 +107,8 @@ export const serviceViewSchema = serviceSchema.extend({
|
|||||||
domains: z.array(serviceDomainBindingSchema).default([]),
|
domains: z.array(serviceDomainBindingSchema).default([]),
|
||||||
health_status: z.enum(['up', 'down', 'degraded', 'unknown']).default('unknown'),
|
health_status: z.enum(['up', 'down', 'degraded', 'unknown']).default('unknown'),
|
||||||
health_latency_ms: z.number().nullable().default(null),
|
health_latency_ms: z.number().nullable().default(null),
|
||||||
|
ip_health: z.array(serviceIpHealthSchema).default([]),
|
||||||
|
ip_enabled: z.record(z.string(), z.boolean()).default({}),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const serviceGroupViewSchema = serviceGroupSchema.extend({
|
export const serviceGroupViewSchema = serviceGroupSchema.extend({
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/i
|
|||||||
import { Route as AuthServicesIndexRouteImport } from './routes/_auth/services/index'
|
import { Route as AuthServicesIndexRouteImport } from './routes/_auth/services/index'
|
||||||
import { Route as AuthDomainsIndexRouteImport } from './routes/_auth/domains/index'
|
import { Route as AuthDomainsIndexRouteImport } from './routes/_auth/domains/index'
|
||||||
import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations'
|
import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations'
|
||||||
|
import { Route as AuthSettingsHealthRouteImport } from './routes/_auth/settings/health'
|
||||||
import { Route as AuthSettingsAppearanceRouteImport } from './routes/_auth/settings/appearance'
|
import { Route as AuthSettingsAppearanceRouteImport } from './routes/_auth/settings/appearance'
|
||||||
import { Route as AuthGroupsGroupIdRouteImport } from './routes/_auth/groups/$groupId'
|
import { Route as AuthGroupsGroupIdRouteImport } from './routes/_auth/groups/$groupId'
|
||||||
import { Route as AuthServicesServiceIdRouteRouteImport } from './routes/_auth/services/$serviceId/route'
|
import { Route as AuthServicesServiceIdRouteRouteImport } from './routes/_auth/services/$serviceId/route'
|
||||||
@@ -86,6 +87,11 @@ const AuthSettingsIntegrationsRoute =
|
|||||||
path: '/integrations',
|
path: '/integrations',
|
||||||
getParentRoute: () => AuthSettingsRouteRoute,
|
getParentRoute: () => AuthSettingsRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthSettingsHealthRoute = AuthSettingsHealthRouteImport.update({
|
||||||
|
id: '/health',
|
||||||
|
path: '/health',
|
||||||
|
getParentRoute: () => AuthSettingsRouteRoute,
|
||||||
|
} as any)
|
||||||
const AuthSettingsAppearanceRoute = AuthSettingsAppearanceRouteImport.update({
|
const AuthSettingsAppearanceRoute = AuthSettingsAppearanceRouteImport.update({
|
||||||
id: '/appearance',
|
id: '/appearance',
|
||||||
path: '/appearance',
|
path: '/appearance',
|
||||||
@@ -154,6 +160,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/services/$serviceId': typeof AuthServicesServiceIdRouteRouteWithChildren
|
'/services/$serviceId': typeof AuthServicesServiceIdRouteRouteWithChildren
|
||||||
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||||
'/settings/appearance': typeof AuthSettingsAppearanceRoute
|
'/settings/appearance': typeof AuthSettingsAppearanceRoute
|
||||||
|
'/settings/health': typeof AuthSettingsHealthRoute
|
||||||
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||||
'/domains/': typeof AuthDomainsIndexRoute
|
'/domains/': typeof AuthDomainsIndexRoute
|
||||||
'/services/': typeof AuthServicesIndexRoute
|
'/services/': typeof AuthServicesIndexRoute
|
||||||
@@ -174,6 +181,7 @@ export interface FileRoutesByTo {
|
|||||||
'/': typeof AuthIndexRoute
|
'/': typeof AuthIndexRoute
|
||||||
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||||
'/settings/appearance': typeof AuthSettingsAppearanceRoute
|
'/settings/appearance': typeof AuthSettingsAppearanceRoute
|
||||||
|
'/settings/health': typeof AuthSettingsHealthRoute
|
||||||
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||||
'/domains': typeof AuthDomainsIndexRoute
|
'/domains': typeof AuthDomainsIndexRoute
|
||||||
'/services': typeof AuthServicesIndexRoute
|
'/services': typeof AuthServicesIndexRoute
|
||||||
@@ -198,6 +206,7 @@ export interface FileRoutesById {
|
|||||||
'/_auth/services/$serviceId': typeof AuthServicesServiceIdRouteRouteWithChildren
|
'/_auth/services/$serviceId': typeof AuthServicesServiceIdRouteRouteWithChildren
|
||||||
'/_auth/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
'/_auth/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||||
'/_auth/settings/appearance': typeof AuthSettingsAppearanceRoute
|
'/_auth/settings/appearance': typeof AuthSettingsAppearanceRoute
|
||||||
|
'/_auth/settings/health': typeof AuthSettingsHealthRoute
|
||||||
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||||
'/_auth/domains/': typeof AuthDomainsIndexRoute
|
'/_auth/domains/': typeof AuthDomainsIndexRoute
|
||||||
'/_auth/services/': typeof AuthServicesIndexRoute
|
'/_auth/services/': typeof AuthServicesIndexRoute
|
||||||
@@ -222,6 +231,7 @@ export interface FileRouteTypes {
|
|||||||
| '/services/$serviceId'
|
| '/services/$serviceId'
|
||||||
| '/groups/$groupId'
|
| '/groups/$groupId'
|
||||||
| '/settings/appearance'
|
| '/settings/appearance'
|
||||||
|
| '/settings/health'
|
||||||
| '/settings/integrations'
|
| '/settings/integrations'
|
||||||
| '/domains/'
|
| '/domains/'
|
||||||
| '/services/'
|
| '/services/'
|
||||||
@@ -242,6 +252,7 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/groups/$groupId'
|
| '/groups/$groupId'
|
||||||
| '/settings/appearance'
|
| '/settings/appearance'
|
||||||
|
| '/settings/health'
|
||||||
| '/settings/integrations'
|
| '/settings/integrations'
|
||||||
| '/domains'
|
| '/domains'
|
||||||
| '/services'
|
| '/services'
|
||||||
@@ -265,6 +276,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_auth/services/$serviceId'
|
| '/_auth/services/$serviceId'
|
||||||
| '/_auth/groups/$groupId'
|
| '/_auth/groups/$groupId'
|
||||||
| '/_auth/settings/appearance'
|
| '/_auth/settings/appearance'
|
||||||
|
| '/_auth/settings/health'
|
||||||
| '/_auth/settings/integrations'
|
| '/_auth/settings/integrations'
|
||||||
| '/_auth/domains/'
|
| '/_auth/domains/'
|
||||||
| '/_auth/services/'
|
| '/_auth/services/'
|
||||||
@@ -363,6 +375,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport
|
preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport
|
||||||
parentRoute: typeof AuthSettingsRouteRoute
|
parentRoute: typeof AuthSettingsRouteRoute
|
||||||
}
|
}
|
||||||
|
'/_auth/settings/health': {
|
||||||
|
id: '/_auth/settings/health'
|
||||||
|
path: '/health'
|
||||||
|
fullPath: '/settings/health'
|
||||||
|
preLoaderRoute: typeof AuthSettingsHealthRouteImport
|
||||||
|
parentRoute: typeof AuthSettingsRouteRoute
|
||||||
|
}
|
||||||
'/_auth/settings/appearance': {
|
'/_auth/settings/appearance': {
|
||||||
id: '/_auth/settings/appearance'
|
id: '/_auth/settings/appearance'
|
||||||
path: '/appearance'
|
path: '/appearance'
|
||||||
@@ -438,12 +457,14 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
interface AuthSettingsRouteRouteChildren {
|
interface AuthSettingsRouteRouteChildren {
|
||||||
AuthSettingsAppearanceRoute: typeof AuthSettingsAppearanceRoute
|
AuthSettingsAppearanceRoute: typeof AuthSettingsAppearanceRoute
|
||||||
|
AuthSettingsHealthRoute: typeof AuthSettingsHealthRoute
|
||||||
AuthSettingsIntegrationsRoute: typeof AuthSettingsIntegrationsRoute
|
AuthSettingsIntegrationsRoute: typeof AuthSettingsIntegrationsRoute
|
||||||
AuthSettingsIndexRoute: typeof AuthSettingsIndexRoute
|
AuthSettingsIndexRoute: typeof AuthSettingsIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = {
|
const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = {
|
||||||
AuthSettingsAppearanceRoute: AuthSettingsAppearanceRoute,
|
AuthSettingsAppearanceRoute: AuthSettingsAppearanceRoute,
|
||||||
|
AuthSettingsHealthRoute: AuthSettingsHealthRoute,
|
||||||
AuthSettingsIntegrationsRoute: AuthSettingsIntegrationsRoute,
|
AuthSettingsIntegrationsRoute: AuthSettingsIntegrationsRoute,
|
||||||
AuthSettingsIndexRoute: AuthSettingsIndexRoute,
|
AuthSettingsIndexRoute: AuthSettingsIndexRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,29 @@ function setServiceEnabled(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setCatalogServiceIpEnabled(
|
||||||
|
data: ServiceGroupsResponse,
|
||||||
|
serviceId: number,
|
||||||
|
ip: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): ServiceGroupsResponse {
|
||||||
|
const patch = (service: ServiceView): ServiceView =>
|
||||||
|
service.id === serviceId
|
||||||
|
? {
|
||||||
|
...service,
|
||||||
|
ip_enabled: { ...(service.ip_enabled ?? {}), [ip]: enabled },
|
||||||
|
}
|
||||||
|
: service
|
||||||
|
|
||||||
|
return {
|
||||||
|
groups: data.groups.map((group) => ({
|
||||||
|
...group,
|
||||||
|
services: group.services.map(patch),
|
||||||
|
})),
|
||||||
|
ungrouped: data.ungrouped.map(patch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const GROUP_DOT_COLORS = [
|
const GROUP_DOT_COLORS = [
|
||||||
'bg-chart-1',
|
'bg-chart-1',
|
||||||
'bg-chart-2',
|
'bg-chart-2',
|
||||||
@@ -150,6 +173,10 @@ function ServicesPage() {
|
|||||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||||
const [deletingGroupId, setDeletingGroupId] = useState<number | null>(null)
|
const [deletingGroupId, setDeletingGroupId] = useState<number | null>(null)
|
||||||
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
|
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
|
||||||
|
const [togglingIp, setTogglingIp] = useState<{
|
||||||
|
serviceId: number
|
||||||
|
ip: string
|
||||||
|
} | null>(null)
|
||||||
const [bulkToggling, setBulkToggling] = useState(false)
|
const [bulkToggling, setBulkToggling] = useState(false)
|
||||||
const [activeTab, setActiveTab] = useState('all')
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -364,6 +391,54 @@ function ServicesPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const toggleServiceIpMutation = useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
id,
|
||||||
|
ip,
|
||||||
|
enabled,
|
||||||
|
}: {
|
||||||
|
id: number
|
||||||
|
ip: string
|
||||||
|
enabled: boolean
|
||||||
|
}) =>
|
||||||
|
api.patch<ServiceView>(`/api/v1/services/${id}/ips/toggle`, {
|
||||||
|
ip,
|
||||||
|
enabled,
|
||||||
|
}),
|
||||||
|
onMutate: async ({ id, ip, enabled }) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: serviceGroupKeys.all })
|
||||||
|
const previous = queryClient.getQueryData<ServiceGroupsResponse>(
|
||||||
|
serviceGroupKeys.all,
|
||||||
|
)
|
||||||
|
if (previous) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
serviceGroupKeys.all,
|
||||||
|
setCatalogServiceIpEnabled(previous, id, ip, enabled),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return { previous }
|
||||||
|
},
|
||||||
|
onError: (err, _vars, context) => {
|
||||||
|
if (context?.previous) {
|
||||||
|
queryClient.setQueryData(serviceGroupKeys.all, context.previous)
|
||||||
|
}
|
||||||
|
toast.error(
|
||||||
|
err instanceof Error ? err.message : 'Не удалось переключить IP',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSuccess: (_data, { enabled }) => {
|
||||||
|
toast.success(
|
||||||
|
enabled
|
||||||
|
? 'IP включён и добавлен в DNS-привязки'
|
||||||
|
: 'IP выключен и снят с DNS-привязок',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSettled: async () => {
|
||||||
|
setTogglingIp(null)
|
||||||
|
await invalidateAll()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const deleteGroupMutation = useMutation({
|
const deleteGroupMutation = useMutation({
|
||||||
mutationFn: (id: number) => api.delete(`/api/v1/service-groups/${id}`),
|
mutationFn: (id: number) => api.delete(`/api/v1/service-groups/${id}`),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -398,6 +473,15 @@ function ServicesPage() {
|
|||||||
toggleServiceMutation.mutate({ id: serviceId, enabled })
|
toggleServiceMutation.mutate({ id: serviceId, enabled })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleServiceIpToggle(
|
||||||
|
serviceId: number,
|
||||||
|
ip: string,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
setTogglingIp({ serviceId, ip })
|
||||||
|
toggleServiceIpMutation.mutate({ id: serviceId, ip, enabled })
|
||||||
|
}
|
||||||
|
|
||||||
function handleOpenCreateService(groupId: number | null = null) {
|
function handleOpenCreateService(groupId: number | null = null) {
|
||||||
setDefaultGroupId(groupId)
|
setDefaultGroupId(groupId)
|
||||||
setCreateSheetOpen(true)
|
setCreateSheetOpen(true)
|
||||||
@@ -456,7 +540,6 @@ function ServicesPage() {
|
|||||||
board.columns.map((column, index) => ({
|
board.columns.map((column, index) => ({
|
||||||
id: column.id,
|
id: column.id,
|
||||||
title: column.title,
|
title: column.title,
|
||||||
description: column.domain ?? undefined,
|
|
||||||
healthStatus: column.group?.health_status,
|
healthStatus: column.group?.health_status,
|
||||||
healthLatencyMs: column.group?.health_latency_ms,
|
healthLatencyMs: column.group?.health_latency_ms,
|
||||||
dotClassName: GROUP_DOT_COLORS[index % GROUP_DOT_COLORS.length],
|
dotClassName: GROUP_DOT_COLORS[index % GROUP_DOT_COLORS.length],
|
||||||
@@ -494,7 +577,7 @@ function ServicesPage() {
|
|||||||
|
|
||||||
const pageDescription = filteredDomain
|
const pageDescription = filteredDomain
|
||||||
? `Сервисы с привязками к домену ${filteredDomain.zone_name}`
|
? `Сервисы с привязками к домену ${filteredDomain.zone_name}`
|
||||||
: 'Группы, FQDN и доступность сервисов'
|
: 'Группы для сортировки; у каждого сервиса — общий домен и IP'
|
||||||
|
|
||||||
const sheets = (
|
const sheets = (
|
||||||
<>
|
<>
|
||||||
@@ -603,9 +686,11 @@ function ServicesPage() {
|
|||||||
isLoading
|
isLoading
|
||||||
hideHeader
|
hideHeader
|
||||||
togglingId={null}
|
togglingId={null}
|
||||||
|
togglingIp={null}
|
||||||
onEditService={() => {}}
|
onEditService={() => {}}
|
||||||
onDeleteService={() => {}}
|
onDeleteService={() => {}}
|
||||||
onToggleService={() => {}}
|
onToggleService={() => {}}
|
||||||
|
onToggleServiceIp={() => {}}
|
||||||
onEditGroup={() => {}}
|
onEditGroup={() => {}}
|
||||||
onDeleteGroup={() => {}}
|
onDeleteGroup={() => {}}
|
||||||
onAddServiceToGroup={() => {}}
|
onAddServiceToGroup={() => {}}
|
||||||
@@ -703,12 +788,14 @@ function ServicesPage() {
|
|||||||
domainId={domainId}
|
domainId={domainId}
|
||||||
domainLabel={filteredDomain?.zone_name}
|
domainLabel={filteredDomain?.zone_name}
|
||||||
togglingId={togglingServiceId}
|
togglingId={togglingServiceId}
|
||||||
|
togglingIp={togglingIp}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
onTabChange={setActiveTab}
|
onTabChange={setActiveTab}
|
||||||
hideHeader
|
hideHeader
|
||||||
onEditService={setEditingService}
|
onEditService={setEditingService}
|
||||||
onDeleteService={setDeletingService}
|
onDeleteService={setDeletingService}
|
||||||
onToggleService={handleServiceToggle}
|
onToggleService={handleServiceToggle}
|
||||||
|
onToggleServiceIp={handleServiceIpToggle}
|
||||||
onEditGroup={setEditingGroup}
|
onEditGroup={setEditingGroup}
|
||||||
onDeleteGroup={setDeletingGroup}
|
onDeleteGroup={setDeletingGroup}
|
||||||
onAddServiceToGroup={handleOpenCreateService}
|
onAddServiceToGroup={handleOpenCreateService}
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useForm, Controller } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { HeartPulseIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { api } from '@/lib/api-client'
|
||||||
|
import { SettingRow } from '@/components/setting-row'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameFooter,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import {
|
||||||
|
NumberField,
|
||||||
|
NumberFieldDecrement,
|
||||||
|
NumberFieldGroup,
|
||||||
|
NumberFieldIncrement,
|
||||||
|
NumberFieldInput,
|
||||||
|
} from '@/components/reui/number-field'
|
||||||
|
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||||
|
import { Input } from '@cfdm/ui/components/input'
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
healthCheckCron: z.string().trim().min(1, 'Укажите cron').max(64),
|
||||||
|
healthDegradedFailures: z.number().int().min(1).max(20),
|
||||||
|
healthDownFailures: z.number().int().min(1).max(50),
|
||||||
|
healthLatencyWarnMs: z.number().int().min(50).max(60_000),
|
||||||
|
healthSuccessRecoveries: z.number().int().min(1).max(20),
|
||||||
|
}).superRefine((data, ctx) => {
|
||||||
|
if (data.healthDownFailures < data.healthDegradedFailures) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
message: 'Не меньше порога degraded',
|
||||||
|
path: ['healthDownFailures'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof formSchema>
|
||||||
|
|
||||||
|
type SettingsResponse = FormValues & {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_auth/settings/health')({
|
||||||
|
component: HealthSettingsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function CompactNumberInput({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
disabled,
|
||||||
|
onValueChange,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
value: number
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
disabled?: boolean
|
||||||
|
onValueChange: (next: number) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NumberField
|
||||||
|
id={id}
|
||||||
|
size="sm"
|
||||||
|
value={value}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
disabled={disabled}
|
||||||
|
onValueChange={(next) => {
|
||||||
|
if (next != null) onValueChange(next)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NumberFieldGroup className="w-36">
|
||||||
|
<NumberFieldDecrement />
|
||||||
|
<NumberFieldInput />
|
||||||
|
<NumberFieldIncrement />
|
||||||
|
</NumberFieldGroup>
|
||||||
|
</NumberField>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HealthSettingsPage() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['app-settings'],
|
||||||
|
queryFn: () => api.get<SettingsResponse>('/api/v1/settings'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
healthCheckCron: '0 */2 * * * *',
|
||||||
|
healthDegradedFailures: 1,
|
||||||
|
healthDownFailures: 2,
|
||||||
|
healthLatencyWarnMs: 1000,
|
||||||
|
healthSuccessRecoveries: 2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) return
|
||||||
|
form.reset({
|
||||||
|
healthCheckCron: data.healthCheckCron,
|
||||||
|
healthDegradedFailures: data.healthDegradedFailures,
|
||||||
|
healthDownFailures: data.healthDownFailures,
|
||||||
|
healthLatencyWarnMs: data.healthLatencyWarnMs,
|
||||||
|
healthSuccessRecoveries: data.healthSuccessRecoveries,
|
||||||
|
})
|
||||||
|
}, [data, form])
|
||||||
|
|
||||||
|
const saveMut = useMutation({
|
||||||
|
mutationFn: (values: FormValues) =>
|
||||||
|
api.patch<SettingsResponse>('/api/v1/settings', values),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['app-settings'] })
|
||||||
|
toast.success('Настройки health-check сохранены')
|
||||||
|
},
|
||||||
|
onError: (e: unknown) =>
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="flex w-full flex-col gap-4"
|
||||||
|
onSubmit={(event) =>
|
||||||
|
void form.handleSubmit((values) => saveMut.mutate(values))(event)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Frame dense spacing="sm" className="w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle className="flex items-center gap-2">
|
||||||
|
<HeartPulseIcon className="size-4" aria-hidden />
|
||||||
|
Local health-check
|
||||||
|
</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
Расписание и пороги движка. Параметры Cloudflare Health Checks
|
||||||
|
задаются в карточке сервиса.
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="p-0">
|
||||||
|
<FieldGroup className="gap-0">
|
||||||
|
<SettingRow
|
||||||
|
title="Cron"
|
||||||
|
description="Расписание проб (6 полей: сек мин час день месяц день-недели). Env: HEALTH_CHECK_CRON."
|
||||||
|
labelFor="health-cron"
|
||||||
|
stacked
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="health-cron"
|
||||||
|
className="font-mono"
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
disabled={isLoading || saveMut.isPending}
|
||||||
|
{...form.register('healthCheckCron')}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
{form.formState.errors.healthCheckCron ? (
|
||||||
|
<p className="text-destructive px-5 pb-2 text-sm">
|
||||||
|
{form.formState.errors.healthCheckCron.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Ошибок до Slow"
|
||||||
|
description="Подряд неуспешных проб до статуса degraded. Env: HEALTH_DEGRADED_FAILURES."
|
||||||
|
labelFor="health-degraded"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="healthDegradedFailures"
|
||||||
|
render={({ field }) => (
|
||||||
|
<CompactNumberInput
|
||||||
|
id="health-degraded"
|
||||||
|
value={field.value}
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
disabled={isLoading || saveMut.isPending}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Ошибок до Down"
|
||||||
|
description="Подряд неуспешных проб до статуса down. Env: HEALTH_DOWN_FAILURES."
|
||||||
|
labelFor="health-down"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="healthDownFailures"
|
||||||
|
render={({ field }) => (
|
||||||
|
<CompactNumberInput
|
||||||
|
id="health-down"
|
||||||
|
value={field.value}
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
disabled={isLoading || saveMut.isPending}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
{form.formState.errors.healthDownFailures ? (
|
||||||
|
<p className="text-destructive px-5 pb-2 text-sm">
|
||||||
|
{form.formState.errors.healthDownFailures.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Латентность Slow, мс"
|
||||||
|
description="Порог задержки для degraded при успешной пробе. Env: HEALTH_LATENCY_WARN_MS."
|
||||||
|
labelFor="health-latency"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="healthLatencyWarnMs"
|
||||||
|
render={({ field }) => (
|
||||||
|
<CompactNumberInput
|
||||||
|
id="health-latency"
|
||||||
|
value={field.value}
|
||||||
|
min={50}
|
||||||
|
max={60_000}
|
||||||
|
disabled={isLoading || saveMut.isPending}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Успехов для recovery"
|
||||||
|
description="Подряд успешных проб, чтобы выйти из Checking в Healthy. Env: HEALTH_SUCCESS_RECOVERIES."
|
||||||
|
labelFor="health-recoveries"
|
||||||
|
compact
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="healthSuccessRecoveries"
|
||||||
|
render={({ field }) => (
|
||||||
|
<CompactNumberInput
|
||||||
|
id="health-recoveries"
|
||||||
|
value={field.value}
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
disabled={isLoading || saveMut.isPending}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
</FieldGroup>
|
||||||
|
<FrameFooter className="flex flex-row justify-end">
|
||||||
|
<LoadingButton
|
||||||
|
type="submit"
|
||||||
|
isLoading={saveMut.isPending}
|
||||||
|
disabled={isLoading || !form.formState.isDirty}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</LoadingButton>
|
||||||
|
</FrameFooter>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
+8
-6
@@ -20,11 +20,11 @@ Manage Cloudflare zones, DNS records, domain groups, and TLS certificate expiry
|
|||||||
| `ADMIN_USERNAME` | Admin username |
|
| `ADMIN_USERNAME` | Admin username |
|
||||||
| `ADMIN_PASSWORD_HASH` | Argon2 hash (empty = dev `admin`/`admin`) |
|
| `ADMIN_PASSWORD_HASH` | Argon2 hash (empty = dev `admin`/`admin`) |
|
||||||
| `LOG_LEVEL` | Уровень логов API (`info`, `debug`) |
|
| `LOG_LEVEL` | Уровень логов API (`info`, `debug`) |
|
||||||
| `HEALTH_CHECK_CRON` | Cron для health-check (default `*/30 * * * * *`) |
|
| `HEALTH_CHECK_CRON` | Cron для health-check (default `0 */2 * * * *`). Переопределяется в **Настройки → Health-check**. |
|
||||||
| `HEALTH_DEGRADED_FAILURES` | Ошибок подряд до `degraded` (default `1`) |
|
| `HEALTH_DEGRADED_FAILURES` | Ошибок подряд до `degraded` (default `1`). То же в UI. |
|
||||||
| `HEALTH_DOWN_FAILURES` | Ошибок подряд до `down` (default `2`) |
|
| `HEALTH_DOWN_FAILURES` | Ошибок подряд до `down` (default `2`). То же в UI. |
|
||||||
| `HEALTH_SUCCESS_RECOVERIES` | Успехов подряд для recovery `CHECKING → HEALTHY` (default `2`) |
|
| `HEALTH_SUCCESS_RECOVERIES` | Успехов подряд для recovery `CHECKING → HEALTHY` (default `2`). То же в UI. |
|
||||||
| `HEALTH_LATENCY_WARN_MS` | Латентность-порог для `degraded` (default `1000`) |
|
| `HEALTH_LATENCY_WARN_MS` | Латентность-порог для `degraded` (default `1000`). То же в UI. |
|
||||||
|
|
||||||
## Load balancing & health checks
|
## Load balancing & health checks
|
||||||
|
|
||||||
@@ -46,7 +46,9 @@ health-check работают на двух уровнях:
|
|||||||
Режимы LB: `round_robin`, `failover`, `weighted`. В Cloudflare free `weighted`
|
Режимы LB: `round_robin`, `failover`, `weighted`. В Cloudflare free `weighted`
|
||||||
работает как `round_robin` (одна A на IP). `unknown` **не** считается healthy и
|
работает как `round_robin` (одна A на IP). `unknown` **не** считается healthy и
|
||||||
не попадает в пул, пока нет успешных проб; восстановление — `UNHEALTHY → CHECKING → HEALTHY`
|
не попадает в пул, пока нет успешных проб; восстановление — `UNHEALTHY → CHECKING → HEALTHY`
|
||||||
после `HEALTH_SUCCESS_RECOVERIES` (default 2). Reconcile DNS запускается cron-задачей
|
после `HEALTH_SUCCESS_RECOVERIES` (default 2). Пороги и cron движка задаются в
|
||||||
|
**Настройки → Health-check** (env — fallback, пока значения не сохранены в UI).
|
||||||
|
Reconcile DNS запускается cron-задачей
|
||||||
`health-check`. Cloudflare Health Checks — официальный API зоны, Workers не используются.
|
`health-check`. Cloudflare Health Checks — официальный API зоны, Workers не используются.
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|||||||
Vendored
+244
-5
File diff suppressed because one or more lines are too long
Vendored
+101
-10
@@ -187,6 +187,7 @@ var serviceIps = sqliteTable("service_ips", {
|
|||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
service_id: integer("service_id").notNull().references(() => services.id, { onDelete: "cascade" }),
|
service_id: integer("service_id").notNull().references(() => services.id, { onDelete: "cascade" }),
|
||||||
ip: text("ip").notNull(),
|
ip: text("ip").notNull(),
|
||||||
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
created_at: text("created_at").notNull().default(sql`datetime('now')`)
|
created_at: text("created_at").notNull().default(sql`datetime('now')`)
|
||||||
});
|
});
|
||||||
var serviceBindingRecords = sqliteTable(
|
var serviceBindingRecords = sqliteTable(
|
||||||
@@ -269,6 +270,11 @@ var appSettings = sqliteTable("app_settings", {
|
|||||||
show_quick_actions: integer("show_quick_actions", {
|
show_quick_actions: integer("show_quick_actions", {
|
||||||
mode: "boolean"
|
mode: "boolean"
|
||||||
}).notNull().default(true),
|
}).notNull().default(true),
|
||||||
|
health_check_cron: text("health_check_cron"),
|
||||||
|
health_degraded_failures: integer("health_degraded_failures"),
|
||||||
|
health_down_failures: integer("health_down_failures"),
|
||||||
|
health_latency_warn_ms: integer("health_latency_warn_ms"),
|
||||||
|
health_success_recoveries: integer("health_success_recoveries"),
|
||||||
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
created_at: text("created_at").notNull().default(sql`datetime('now')`),
|
||||||
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
|
||||||
});
|
});
|
||||||
@@ -493,7 +499,17 @@ function listAudit(db, opts = {}) {
|
|||||||
// src/settings-repo.ts
|
// src/settings-repo.ts
|
||||||
import { eq as eq2 } from "drizzle-orm";
|
import { eq as eq2 } from "drizzle-orm";
|
||||||
var SETTINGS_ID = "settings-main";
|
var SETTINGS_ID = "settings-main";
|
||||||
function toDto(row) {
|
function coalesceInt(value, fallback) {
|
||||||
|
return value == null || Number.isNaN(value) || value < 1 ? fallback : value;
|
||||||
|
}
|
||||||
|
function toDto(row, fallbacks) {
|
||||||
|
const env = fallbacks ?? {
|
||||||
|
healthCheckCron: "0 */2 * * * *",
|
||||||
|
healthDegradedFailures: 1,
|
||||||
|
healthDownFailures: 2,
|
||||||
|
healthLatencyWarnMs: 1e3,
|
||||||
|
healthSuccessRecoveries: 2
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
vpsTrackerUrl: row.vps_tracker_url?.trim() ?? "",
|
vpsTrackerUrl: row.vps_tracker_url?.trim() ?? "",
|
||||||
@@ -502,18 +518,36 @@ function toDto(row) {
|
|||||||
),
|
),
|
||||||
vpsTrackerSyncEnabled: Boolean(row.vps_tracker_sync_enabled),
|
vpsTrackerSyncEnabled: Boolean(row.vps_tracker_sync_enabled),
|
||||||
vpsTrackerLastSyncAt: row.vps_tracker_last_sync_at,
|
vpsTrackerLastSyncAt: row.vps_tracker_last_sync_at,
|
||||||
showQuickActions: row.show_quick_actions == null ? true : Boolean(row.show_quick_actions)
|
showQuickActions: row.show_quick_actions == null ? true : Boolean(row.show_quick_actions),
|
||||||
|
healthCheckCron: row.health_check_cron?.trim() || env.healthCheckCron,
|
||||||
|
healthDegradedFailures: coalesceInt(
|
||||||
|
row.health_degraded_failures,
|
||||||
|
env.healthDegradedFailures
|
||||||
|
),
|
||||||
|
healthDownFailures: coalesceInt(
|
||||||
|
row.health_down_failures,
|
||||||
|
env.healthDownFailures
|
||||||
|
),
|
||||||
|
healthLatencyWarnMs: coalesceInt(
|
||||||
|
row.health_latency_warn_ms,
|
||||||
|
env.healthLatencyWarnMs
|
||||||
|
),
|
||||||
|
healthSuccessRecoveries: coalesceInt(
|
||||||
|
row.health_success_recoveries,
|
||||||
|
env.healthSuccessRecoveries
|
||||||
|
)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function getAppSettings(db) {
|
function getAppSettings(db, fallbacks) {
|
||||||
const row = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
|
const row = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
|
||||||
if (!row) {
|
if (!row) {
|
||||||
db.insert(appSettings).values({ id: SETTINGS_ID }).run();
|
db.insert(appSettings).values({ id: SETTINGS_ID }).run();
|
||||||
return toDto(
|
return toDto(
|
||||||
db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get()
|
db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get(),
|
||||||
|
fallbacks
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return toDto(row);
|
return toDto(row, fallbacks);
|
||||||
}
|
}
|
||||||
function getAppSettingsSecrets(db) {
|
function getAppSettingsSecrets(db) {
|
||||||
const row = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
|
const row = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
|
||||||
@@ -523,7 +557,7 @@ function getAppSettingsSecrets(db) {
|
|||||||
vpsTrackerSyncEnabled: Boolean(row?.vps_tracker_sync_enabled)
|
vpsTrackerSyncEnabled: Boolean(row?.vps_tracker_sync_enabled)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function updateAppSettings(db, patch) {
|
function updateAppSettings(db, patch, fallbacks) {
|
||||||
const existing = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
|
const existing = db.select().from(appSettings).where(eq2(appSettings.id, SETTINGS_ID)).get();
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
db.insert(appSettings).values({ id: SETTINGS_ID }).run();
|
db.insert(appSettings).values({ id: SETTINGS_ID }).run();
|
||||||
@@ -535,9 +569,14 @@ function updateAppSettings(db, patch) {
|
|||||||
vps_tracker_integration_token: patch.vpsTrackerIntegrationToken !== void 0 && patch.vpsTrackerIntegrationToken.trim() !== "" ? patch.vpsTrackerIntegrationToken : current.vps_tracker_integration_token,
|
vps_tracker_integration_token: patch.vpsTrackerIntegrationToken !== void 0 && patch.vpsTrackerIntegrationToken.trim() !== "" ? patch.vpsTrackerIntegrationToken : current.vps_tracker_integration_token,
|
||||||
vps_tracker_sync_enabled: patch.vpsTrackerSyncEnabled !== void 0 ? patch.vpsTrackerSyncEnabled : current.vps_tracker_sync_enabled,
|
vps_tracker_sync_enabled: patch.vpsTrackerSyncEnabled !== void 0 ? patch.vpsTrackerSyncEnabled : current.vps_tracker_sync_enabled,
|
||||||
show_quick_actions: patch.showQuickActions !== void 0 ? patch.showQuickActions : current.show_quick_actions,
|
show_quick_actions: patch.showQuickActions !== void 0 ? patch.showQuickActions : current.show_quick_actions,
|
||||||
|
health_check_cron: patch.healthCheckCron !== void 0 ? patch.healthCheckCron.trim() : current.health_check_cron,
|
||||||
|
health_degraded_failures: patch.healthDegradedFailures !== void 0 ? patch.healthDegradedFailures : current.health_degraded_failures,
|
||||||
|
health_down_failures: patch.healthDownFailures !== void 0 ? patch.healthDownFailures : current.health_down_failures,
|
||||||
|
health_latency_warn_ms: patch.healthLatencyWarnMs !== void 0 ? patch.healthLatencyWarnMs : current.health_latency_warn_ms,
|
||||||
|
health_success_recoveries: patch.healthSuccessRecoveries !== void 0 ? patch.healthSuccessRecoveries : current.health_success_recoveries,
|
||||||
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
||||||
}).where(eq2(appSettings.id, SETTINGS_ID)).run();
|
}).where(eq2(appSettings.id, SETTINGS_ID)).run();
|
||||||
return getAppSettings(db);
|
return getAppSettings(db, fallbacks);
|
||||||
}
|
}
|
||||||
function touchVpsTrackerSync(db) {
|
function touchVpsTrackerSync(db) {
|
||||||
db.update(appSettings).set({
|
db.update(appSettings).set({
|
||||||
@@ -631,12 +670,14 @@ __export(repos_exports, {
|
|||||||
listGroups: () => listGroups,
|
listGroups: () => listGroups,
|
||||||
listHealthCheckTargets: () => listHealthCheckTargets,
|
listHealthCheckTargets: () => listHealthCheckTargets,
|
||||||
listHealthChecks: () => listHealthChecks,
|
listHealthChecks: () => listHealthChecks,
|
||||||
|
listIpHealthByServiceIds: () => listIpHealthByServiceIds,
|
||||||
listIpHealthStatus: () => listIpHealthStatus,
|
listIpHealthStatus: () => listIpHealthStatus,
|
||||||
listNodes: () => listNodes,
|
listNodes: () => listNodes,
|
||||||
listNotificationLog: () => listNotificationLog,
|
listNotificationLog: () => listNotificationLog,
|
||||||
listOriginIpsForFqdn: () => listOriginIpsForFqdn,
|
listOriginIpsForFqdn: () => listOriginIpsForFqdn,
|
||||||
listRecordsForBinding: () => listRecordsForBinding,
|
listRecordsForBinding: () => listRecordsForBinding,
|
||||||
listServiceGroups: () => listServiceGroups,
|
listServiceGroups: () => listServiceGroups,
|
||||||
|
listServiceIpRows: () => listServiceIpRows,
|
||||||
listServiceIps: () => listServiceIps,
|
listServiceIps: () => listServiceIps,
|
||||||
listServices: () => listServices,
|
listServices: () => listServices,
|
||||||
listServicesByGroup: () => listServicesByGroup,
|
listServicesByGroup: () => listServicesByGroup,
|
||||||
@@ -658,6 +699,7 @@ __export(repos_exports, {
|
|||||||
setServiceEnabled: () => setServiceEnabled,
|
setServiceEnabled: () => setServiceEnabled,
|
||||||
setServiceGroup: () => setServiceGroup,
|
setServiceGroup: () => setServiceGroup,
|
||||||
setServiceGroupEnabled: () => setServiceGroupEnabled,
|
setServiceGroupEnabled: () => setServiceGroupEnabled,
|
||||||
|
setServiceIpEnabled: () => setServiceIpEnabled,
|
||||||
setServiceLb: () => setServiceLb,
|
setServiceLb: () => setServiceLb,
|
||||||
unlinkBindingRecord: () => unlinkBindingRecord,
|
unlinkBindingRecord: () => unlinkBindingRecord,
|
||||||
unlinkGroupDnsRecord: () => unlinkGroupDnsRecord,
|
unlinkGroupDnsRecord: () => unlinkGroupDnsRecord,
|
||||||
@@ -1154,16 +1196,32 @@ function deleteServiceGroup(db, id) {
|
|||||||
function insertServiceIpIfMissing(db, serviceId, ip) {
|
function insertServiceIpIfMissing(db, serviceId, ip) {
|
||||||
const existing = db.select({ ip: serviceIps.ip }).from(serviceIps).where(and2(eq3(serviceIps.service_id, serviceId), eq3(serviceIps.ip, ip))).get();
|
const existing = db.select({ ip: serviceIps.ip }).from(serviceIps).where(and2(eq3(serviceIps.service_id, serviceId), eq3(serviceIps.ip, ip))).get();
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
db.insert(serviceIps).values({ service_id: serviceId, ip }).run();
|
db.insert(serviceIps).values({ service_id: serviceId, ip, enabled: true }).run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function listServiceIpRows(db, serviceId) {
|
||||||
|
return db.select({ ip: serviceIps.ip, enabled: serviceIps.enabled }).from(serviceIps).where(eq3(serviceIps.service_id, serviceId)).all().map((row) => ({ ip: row.ip, enabled: Boolean(row.enabled) }));
|
||||||
|
}
|
||||||
function listServiceIps(db, serviceId) {
|
function listServiceIps(db, serviceId) {
|
||||||
return db.select({ ip: serviceIps.ip }).from(serviceIps).where(eq3(serviceIps.service_id, serviceId)).all().map((r) => r.ip);
|
return listServiceIpRows(db, serviceId).map((row) => row.ip);
|
||||||
|
}
|
||||||
|
function setServiceIpEnabled(db, serviceId, ip, enabled) {
|
||||||
|
const result = db.update(serviceIps).set({ enabled }).where(and2(eq3(serviceIps.service_id, serviceId), eq3(serviceIps.ip, ip))).run();
|
||||||
|
if (result.changes === 0) {
|
||||||
|
throw new NotFoundError(`service ip ${ip}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function replaceServiceIps(db, serviceId, ips) {
|
function replaceServiceIps(db, serviceId, ips) {
|
||||||
|
const previous = new Map(
|
||||||
|
listServiceIpRows(db, serviceId).map((row) => [row.ip, row.enabled])
|
||||||
|
);
|
||||||
db.delete(serviceIps).where(eq3(serviceIps.service_id, serviceId)).run();
|
db.delete(serviceIps).where(eq3(serviceIps.service_id, serviceId)).run();
|
||||||
for (const ip of ips) {
|
for (const ip of ips) {
|
||||||
db.insert(serviceIps).values({ service_id: serviceId, ip }).run();
|
db.insert(serviceIps).values({
|
||||||
|
service_id: serviceId,
|
||||||
|
ip,
|
||||||
|
enabled: previous.get(ip) ?? true
|
||||||
|
}).run();
|
||||||
ensureNode(db, serviceId, ip);
|
ensureNode(db, serviceId, ip);
|
||||||
}
|
}
|
||||||
const keep = new Set(ips);
|
const keep = new Set(ips);
|
||||||
@@ -1822,6 +1880,39 @@ function aggregateIpHealthByServiceIds(db, serviceIds) {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
function listIpHealthByServiceIds(db, serviceIds) {
|
||||||
|
const result = /* @__PURE__ */ new Map();
|
||||||
|
if (serviceIds.length === 0) return result;
|
||||||
|
const idList = sql2.join(
|
||||||
|
serviceIds.map((id) => sql2`${id}`),
|
||||||
|
sql2`, `
|
||||||
|
);
|
||||||
|
const rows = db.all(sql2`
|
||||||
|
SELECT sb.service_id AS service_id,
|
||||||
|
ihs.ip AS ip,
|
||||||
|
${WORST_HEALTH_SQL} AS health_status,
|
||||||
|
MAX(ihs.latency_ms) AS health_latency_ms
|
||||||
|
FROM ip_health_status ihs
|
||||||
|
INNER JOIN service_bindings sb
|
||||||
|
ON ihs.scope = 'binding' AND ihs.ref_id = sb.id
|
||||||
|
WHERE sb.service_id IN (${idList})
|
||||||
|
GROUP BY sb.service_id, ihs.ip
|
||||||
|
`);
|
||||||
|
for (const row of rows) {
|
||||||
|
const parsed = parseHealthAggregateRow({
|
||||||
|
health_status: row.health_status,
|
||||||
|
health_latency_ms: row.health_latency_ms
|
||||||
|
});
|
||||||
|
const list = result.get(row.service_id) ?? [];
|
||||||
|
list.push({
|
||||||
|
ip: row.ip,
|
||||||
|
status: parsed.health_status,
|
||||||
|
latency_ms: parsed.health_latency_ms
|
||||||
|
});
|
||||||
|
result.set(row.service_id, list);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
function mergeHealthAggregates(parts) {
|
function mergeHealthAggregates(parts) {
|
||||||
const rank = {
|
const rank = {
|
||||||
unknown: 0,
|
unknown: 0,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE service_ips ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Local health-check engine settings (cron + state-machine thresholds).
|
||||||
|
-- NULL = inherit from process env (HEALTH_CHECK_CRON / HEALTH_*).
|
||||||
|
ALTER TABLE app_settings ADD COLUMN health_check_cron TEXT;
|
||||||
|
ALTER TABLE app_settings ADD COLUMN health_degraded_failures INTEGER;
|
||||||
|
ALTER TABLE app_settings ADD COLUMN health_down_failures INTEGER;
|
||||||
|
ALTER TABLE app_settings ADD COLUMN health_latency_warn_ms INTEGER;
|
||||||
|
ALTER TABLE app_settings ADD COLUMN health_success_recoveries INTEGER;
|
||||||
@@ -923,17 +923,42 @@ function insertServiceIpIfMissing(db: Db, serviceId: number, ip: string): void {
|
|||||||
.where(and(eq(serviceIps.service_id, serviceId), eq(serviceIps.ip, ip)))
|
.where(and(eq(serviceIps.service_id, serviceId), eq(serviceIps.ip, ip)))
|
||||||
.get();
|
.get();
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
db.insert(serviceIps).values({ service_id: serviceId, ip }).run();
|
db.insert(serviceIps).values({ service_id: serviceId, ip, enabled: true }).run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listServiceIps(db: Db, serviceId: number): string[] {
|
export type ServiceIpRow = {
|
||||||
|
ip: string;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function listServiceIpRows(db: Db, serviceId: number): ServiceIpRow[] {
|
||||||
return db
|
return db
|
||||||
.select({ ip: serviceIps.ip })
|
.select({ ip: serviceIps.ip, enabled: serviceIps.enabled })
|
||||||
.from(serviceIps)
|
.from(serviceIps)
|
||||||
.where(eq(serviceIps.service_id, serviceId))
|
.where(eq(serviceIps.service_id, serviceId))
|
||||||
.all()
|
.all()
|
||||||
.map((r) => r.ip);
|
.map((row) => ({ ip: row.ip, enabled: Boolean(row.enabled) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listServiceIps(db: Db, serviceId: number): string[] {
|
||||||
|
return listServiceIpRows(db, serviceId).map((row) => row.ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setServiceIpEnabled(
|
||||||
|
db: Db,
|
||||||
|
serviceId: number,
|
||||||
|
ip: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): void {
|
||||||
|
const result = db
|
||||||
|
.update(serviceIps)
|
||||||
|
.set({ enabled })
|
||||||
|
.where(and(eq(serviceIps.service_id, serviceId), eq(serviceIps.ip, ip)))
|
||||||
|
.run();
|
||||||
|
if (result.changes === 0) {
|
||||||
|
throw new NotFoundError(`service ip ${ip}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function replaceServiceIps(
|
export function replaceServiceIps(
|
||||||
@@ -941,9 +966,18 @@ export function replaceServiceIps(
|
|||||||
serviceId: number,
|
serviceId: number,
|
||||||
ips: string[],
|
ips: string[],
|
||||||
): void {
|
): void {
|
||||||
|
const previous = new Map(
|
||||||
|
listServiceIpRows(db, serviceId).map((row) => [row.ip, row.enabled]),
|
||||||
|
);
|
||||||
db.delete(serviceIps).where(eq(serviceIps.service_id, serviceId)).run();
|
db.delete(serviceIps).where(eq(serviceIps.service_id, serviceId)).run();
|
||||||
for (const ip of ips) {
|
for (const ip of ips) {
|
||||||
db.insert(serviceIps).values({ service_id: serviceId, ip }).run();
|
db.insert(serviceIps)
|
||||||
|
.values({
|
||||||
|
service_id: serviceId,
|
||||||
|
ip,
|
||||||
|
enabled: previous.get(ip) ?? true,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
ensureNode(db, serviceId, ip);
|
ensureNode(db, serviceId, ip);
|
||||||
}
|
}
|
||||||
const keep = new Set(ips);
|
const keep = new Set(ips);
|
||||||
@@ -2070,6 +2104,55 @@ export function aggregateIpHealthByServiceIds(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ServiceIpHealthRow = {
|
||||||
|
ip: string;
|
||||||
|
status: IpHealthState;
|
||||||
|
latency_ms: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Per-IP binding-scope health, worst status if the same IP is on several bindings. */
|
||||||
|
export function listIpHealthByServiceIds(
|
||||||
|
db: Db,
|
||||||
|
serviceIds: number[],
|
||||||
|
): Map<number, ServiceIpHealthRow[]> {
|
||||||
|
const result = new Map<number, ServiceIpHealthRow[]>();
|
||||||
|
if (serviceIds.length === 0) return result;
|
||||||
|
const idList = sql.join(
|
||||||
|
serviceIds.map((id) => sql`${id}`),
|
||||||
|
sql`, `,
|
||||||
|
);
|
||||||
|
const rows = db.all<{
|
||||||
|
service_id: number;
|
||||||
|
ip: string;
|
||||||
|
health_status: string | null;
|
||||||
|
health_latency_ms: number | null;
|
||||||
|
}>(sql`
|
||||||
|
SELECT sb.service_id AS service_id,
|
||||||
|
ihs.ip AS ip,
|
||||||
|
${WORST_HEALTH_SQL} AS health_status,
|
||||||
|
MAX(ihs.latency_ms) AS health_latency_ms
|
||||||
|
FROM ip_health_status ihs
|
||||||
|
INNER JOIN service_bindings sb
|
||||||
|
ON ihs.scope = 'binding' AND ihs.ref_id = sb.id
|
||||||
|
WHERE sb.service_id IN (${idList})
|
||||||
|
GROUP BY sb.service_id, ihs.ip
|
||||||
|
`);
|
||||||
|
for (const row of rows) {
|
||||||
|
const parsed = parseHealthAggregateRow({
|
||||||
|
health_status: row.health_status,
|
||||||
|
health_latency_ms: row.health_latency_ms,
|
||||||
|
});
|
||||||
|
const list = result.get(row.service_id) ?? [];
|
||||||
|
list.push({
|
||||||
|
ip: row.ip,
|
||||||
|
status: parsed.health_status,
|
||||||
|
latency_ms: parsed.health_latency_ms,
|
||||||
|
});
|
||||||
|
result.set(row.service_id, list);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function mergeHealthAggregates(
|
export function mergeHealthAggregates(
|
||||||
parts: Array<HealthAggregate | undefined | null>,
|
parts: Array<HealthAggregate | undefined | null>,
|
||||||
): HealthAggregate {
|
): HealthAggregate {
|
||||||
|
|||||||
@@ -259,6 +259,7 @@ export const serviceIps = sqliteTable("service_ips", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => services.id, { onDelete: "cascade" }),
|
.references(() => services.id, { onDelete: "cascade" }),
|
||||||
ip: text("ip").notNull(),
|
ip: text("ip").notNull(),
|
||||||
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
created_at: text("created_at")
|
created_at: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`datetime('now')`),
|
.default(sql`datetime('now')`),
|
||||||
@@ -379,6 +380,11 @@ export const appSettings = sqliteTable("app_settings", {
|
|||||||
})
|
})
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(true),
|
.default(true),
|
||||||
|
health_check_cron: text("health_check_cron"),
|
||||||
|
health_degraded_failures: integer("health_degraded_failures"),
|
||||||
|
health_down_failures: integer("health_down_failures"),
|
||||||
|
health_latency_warn_ms: integer("health_latency_warn_ms"),
|
||||||
|
health_success_recoveries: integer("health_success_recoveries"),
|
||||||
created_at: text("created_at")
|
created_at: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`datetime('now')`),
|
.default(sql`datetime('now')`),
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import { appSettings } from "./schema.js";
|
|||||||
|
|
||||||
const SETTINGS_ID = "settings-main";
|
const SETTINGS_ID = "settings-main";
|
||||||
|
|
||||||
|
export type HealthEngineSettings = {
|
||||||
|
healthCheckCron: string;
|
||||||
|
healthDegradedFailures: number;
|
||||||
|
healthDownFailures: number;
|
||||||
|
healthLatencyWarnMs: number;
|
||||||
|
healthSuccessRecoveries: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type AppSettingsDto = {
|
export type AppSettingsDto = {
|
||||||
id: string;
|
id: string;
|
||||||
vpsTrackerUrl: string;
|
vpsTrackerUrl: string;
|
||||||
@@ -11,16 +19,37 @@ export type AppSettingsDto = {
|
|||||||
vpsTrackerSyncEnabled: boolean;
|
vpsTrackerSyncEnabled: boolean;
|
||||||
vpsTrackerLastSyncAt: string | null;
|
vpsTrackerLastSyncAt: string | null;
|
||||||
showQuickActions: boolean;
|
showQuickActions: boolean;
|
||||||
};
|
} & HealthEngineSettings;
|
||||||
|
|
||||||
export type AppSettingsPatch = {
|
export type AppSettingsPatch = {
|
||||||
vpsTrackerUrl?: string;
|
vpsTrackerUrl?: string;
|
||||||
vpsTrackerIntegrationToken?: string;
|
vpsTrackerIntegrationToken?: string;
|
||||||
vpsTrackerSyncEnabled?: boolean;
|
vpsTrackerSyncEnabled?: boolean;
|
||||||
showQuickActions?: boolean;
|
showQuickActions?: boolean;
|
||||||
|
healthCheckCron?: string;
|
||||||
|
healthDegradedFailures?: number;
|
||||||
|
healthDownFailures?: number;
|
||||||
|
healthLatencyWarnMs?: number;
|
||||||
|
healthSuccessRecoveries?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function toDto(row: typeof appSettings.$inferSelect): AppSettingsDto {
|
export type HealthEngineFallbacks = HealthEngineSettings;
|
||||||
|
|
||||||
|
function coalesceInt(value: number | null | undefined, fallback: number): number {
|
||||||
|
return value == null || Number.isNaN(value) || value < 1 ? fallback : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDto(
|
||||||
|
row: typeof appSettings.$inferSelect,
|
||||||
|
fallbacks?: HealthEngineFallbacks,
|
||||||
|
): AppSettingsDto {
|
||||||
|
const env = fallbacks ?? {
|
||||||
|
healthCheckCron: "0 */2 * * * *",
|
||||||
|
healthDegradedFailures: 1,
|
||||||
|
healthDownFailures: 2,
|
||||||
|
healthLatencyWarnMs: 1000,
|
||||||
|
healthSuccessRecoveries: 2,
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
vpsTrackerUrl: row.vps_tracker_url?.trim() ?? "",
|
vpsTrackerUrl: row.vps_tracker_url?.trim() ?? "",
|
||||||
@@ -31,10 +60,30 @@ function toDto(row: typeof appSettings.$inferSelect): AppSettingsDto {
|
|||||||
vpsTrackerLastSyncAt: row.vps_tracker_last_sync_at,
|
vpsTrackerLastSyncAt: row.vps_tracker_last_sync_at,
|
||||||
showQuickActions:
|
showQuickActions:
|
||||||
row.show_quick_actions == null ? true : Boolean(row.show_quick_actions),
|
row.show_quick_actions == null ? true : Boolean(row.show_quick_actions),
|
||||||
|
healthCheckCron: row.health_check_cron?.trim() || env.healthCheckCron,
|
||||||
|
healthDegradedFailures: coalesceInt(
|
||||||
|
row.health_degraded_failures,
|
||||||
|
env.healthDegradedFailures,
|
||||||
|
),
|
||||||
|
healthDownFailures: coalesceInt(
|
||||||
|
row.health_down_failures,
|
||||||
|
env.healthDownFailures,
|
||||||
|
),
|
||||||
|
healthLatencyWarnMs: coalesceInt(
|
||||||
|
row.health_latency_warn_ms,
|
||||||
|
env.healthLatencyWarnMs,
|
||||||
|
),
|
||||||
|
healthSuccessRecoveries: coalesceInt(
|
||||||
|
row.health_success_recoveries,
|
||||||
|
env.healthSuccessRecoveries,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAppSettings(db: Db): AppSettingsDto {
|
export function getAppSettings(
|
||||||
|
db: Db,
|
||||||
|
fallbacks?: HealthEngineFallbacks,
|
||||||
|
): AppSettingsDto {
|
||||||
const row = db
|
const row = db
|
||||||
.select()
|
.select()
|
||||||
.from(appSettings)
|
.from(appSettings)
|
||||||
@@ -44,9 +93,10 @@ export function getAppSettings(db: Db): AppSettingsDto {
|
|||||||
db.insert(appSettings).values({ id: SETTINGS_ID }).run();
|
db.insert(appSettings).values({ id: SETTINGS_ID }).run();
|
||||||
return toDto(
|
return toDto(
|
||||||
db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID)).get()!,
|
db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID)).get()!,
|
||||||
|
fallbacks,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return toDto(row);
|
return toDto(row, fallbacks);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAppSettingsSecrets(db: Db): {
|
export function getAppSettingsSecrets(db: Db): {
|
||||||
@@ -67,7 +117,11 @@ export function getAppSettingsSecrets(db: Db): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateAppSettings(db: Db, patch: AppSettingsPatch): AppSettingsDto {
|
export function updateAppSettings(
|
||||||
|
db: Db,
|
||||||
|
patch: AppSettingsPatch,
|
||||||
|
fallbacks?: HealthEngineFallbacks,
|
||||||
|
): AppSettingsDto {
|
||||||
const existing = db
|
const existing = db
|
||||||
.select()
|
.select()
|
||||||
.from(appSettings)
|
.from(appSettings)
|
||||||
@@ -102,12 +156,32 @@ export function updateAppSettings(db: Db, patch: AppSettingsPatch): AppSettingsD
|
|||||||
patch.showQuickActions !== undefined
|
patch.showQuickActions !== undefined
|
||||||
? patch.showQuickActions
|
? patch.showQuickActions
|
||||||
: current.show_quick_actions,
|
: current.show_quick_actions,
|
||||||
|
health_check_cron:
|
||||||
|
patch.healthCheckCron !== undefined
|
||||||
|
? patch.healthCheckCron.trim()
|
||||||
|
: current.health_check_cron,
|
||||||
|
health_degraded_failures:
|
||||||
|
patch.healthDegradedFailures !== undefined
|
||||||
|
? patch.healthDegradedFailures
|
||||||
|
: current.health_degraded_failures,
|
||||||
|
health_down_failures:
|
||||||
|
patch.healthDownFailures !== undefined
|
||||||
|
? patch.healthDownFailures
|
||||||
|
: current.health_down_failures,
|
||||||
|
health_latency_warn_ms:
|
||||||
|
patch.healthLatencyWarnMs !== undefined
|
||||||
|
? patch.healthLatencyWarnMs
|
||||||
|
: current.health_latency_warn_ms,
|
||||||
|
health_success_recoveries:
|
||||||
|
patch.healthSuccessRecoveries !== undefined
|
||||||
|
? patch.healthSuccessRecoveries
|
||||||
|
: current.health_success_recoveries,
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.where(eq(appSettings.id, SETTINGS_ID))
|
.where(eq(appSettings.id, SETTINGS_ID))
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
return getAppSettings(db);
|
return getAppSettings(db, fallbacks);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function touchVpsTrackerSync(db: Db): void {
|
export function touchVpsTrackerSync(db: Db): void {
|
||||||
|
|||||||
Vendored
+73
-1
@@ -132,6 +132,8 @@ interface ServiceView$1 {
|
|||||||
domains: ServiceDomainBindingView[];
|
domains: ServiceDomainBindingView[];
|
||||||
health_status: IpHealthState;
|
health_status: IpHealthState;
|
||||||
health_latency_ms: number | null;
|
health_latency_ms: number | null;
|
||||||
|
ip_health: ServiceIpHealth$1[];
|
||||||
|
ip_enabled: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
interface SyncJob {
|
interface SyncJob {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -192,6 +194,11 @@ interface IpHealthStatus {
|
|||||||
last_checked_at: string | null;
|
last_checked_at: string | null;
|
||||||
last_error: string | null;
|
last_error: string | null;
|
||||||
}
|
}
|
||||||
|
interface ServiceIpHealth$1 {
|
||||||
|
ip: string;
|
||||||
|
status: IpHealthState;
|
||||||
|
latency_ms: number | null;
|
||||||
|
}
|
||||||
interface ServiceNode {
|
interface ServiceNode {
|
||||||
id: number;
|
id: number;
|
||||||
service_id: number;
|
service_id: number;
|
||||||
@@ -372,6 +379,17 @@ declare const ipHealthStatusSchema: z.ZodObject<{
|
|||||||
last_checked_at: z.ZodNullable<z.ZodString>;
|
last_checked_at: z.ZodNullable<z.ZodString>;
|
||||||
last_error: z.ZodNullable<z.ZodString>;
|
last_error: z.ZodNullable<z.ZodString>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
|
declare const serviceIpHealthSchema: z.ZodObject<{
|
||||||
|
ip: z.ZodString;
|
||||||
|
status: z.ZodEnum<{
|
||||||
|
unknown: "unknown";
|
||||||
|
up: "up";
|
||||||
|
down: "down";
|
||||||
|
degraded: "degraded";
|
||||||
|
}>;
|
||||||
|
latency_ms: z.ZodNullable<z.ZodNumber>;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
type ServiceIpHealth = z.infer<typeof serviceIpHealthSchema>;
|
||||||
declare const groupSchema: z.ZodObject<{
|
declare const groupSchema: z.ZodObject<{
|
||||||
id: z.ZodNumber;
|
id: z.ZodNumber;
|
||||||
name: z.ZodString;
|
name: z.ZodString;
|
||||||
@@ -619,6 +637,17 @@ declare const serviceViewSchema: z.ZodObject<{
|
|||||||
degraded: "degraded";
|
degraded: "degraded";
|
||||||
}>>;
|
}>>;
|
||||||
health_latency_ms: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
|
health_latency_ms: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
|
||||||
|
ip_health: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
|
ip: z.ZodString;
|
||||||
|
status: z.ZodEnum<{
|
||||||
|
unknown: "unknown";
|
||||||
|
up: "up";
|
||||||
|
down: "down";
|
||||||
|
degraded: "degraded";
|
||||||
|
}>;
|
||||||
|
latency_ms: z.ZodNullable<z.ZodNumber>;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
declare const serviceGroupViewSchema: z.ZodObject<{
|
declare const serviceGroupViewSchema: z.ZodObject<{
|
||||||
id: z.ZodNumber;
|
id: z.ZodNumber;
|
||||||
@@ -752,6 +781,17 @@ declare const serviceGroupViewSchema: z.ZodObject<{
|
|||||||
degraded: "degraded";
|
degraded: "degraded";
|
||||||
}>>;
|
}>>;
|
||||||
health_latency_ms: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
|
health_latency_ms: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
|
||||||
|
ip_health: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
|
ip: z.ZodString;
|
||||||
|
status: z.ZodEnum<{
|
||||||
|
unknown: "unknown";
|
||||||
|
up: "up";
|
||||||
|
down: "down";
|
||||||
|
degraded: "degraded";
|
||||||
|
}>;
|
||||||
|
latency_ms: z.ZodNullable<z.ZodNumber>;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
health_status: z.ZodDefault<z.ZodEnum<{
|
health_status: z.ZodDefault<z.ZodEnum<{
|
||||||
unknown: "unknown";
|
unknown: "unknown";
|
||||||
@@ -894,6 +934,17 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
|||||||
degraded: "degraded";
|
degraded: "degraded";
|
||||||
}>>;
|
}>>;
|
||||||
health_latency_ms: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
|
health_latency_ms: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
|
||||||
|
ip_health: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
|
ip: z.ZodString;
|
||||||
|
status: z.ZodEnum<{
|
||||||
|
unknown: "unknown";
|
||||||
|
up: "up";
|
||||||
|
down: "down";
|
||||||
|
degraded: "degraded";
|
||||||
|
}>;
|
||||||
|
latency_ms: z.ZodNullable<z.ZodNumber>;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
health_status: z.ZodDefault<z.ZodEnum<{
|
health_status: z.ZodDefault<z.ZodEnum<{
|
||||||
unknown: "unknown";
|
unknown: "unknown";
|
||||||
@@ -1002,6 +1053,17 @@ declare const serviceGroupsResponseSchema: z.ZodObject<{
|
|||||||
degraded: "degraded";
|
degraded: "degraded";
|
||||||
}>>;
|
}>>;
|
||||||
health_latency_ms: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
|
health_latency_ms: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
|
||||||
|
ip_health: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
||||||
|
ip: z.ZodString;
|
||||||
|
status: z.ZodEnum<{
|
||||||
|
unknown: "unknown";
|
||||||
|
up: "up";
|
||||||
|
down: "down";
|
||||||
|
degraded: "degraded";
|
||||||
|
}>;
|
||||||
|
latency_ms: z.ZodNullable<z.ZodNumber>;
|
||||||
|
}, z.core.$strip>>>;
|
||||||
|
ip_enabled: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodBoolean>>;
|
||||||
}, z.core.$strip>>>;
|
}, z.core.$strip>>>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
declare const domainSchema: z.ZodObject<{
|
declare const domainSchema: z.ZodObject<{
|
||||||
@@ -1495,6 +1557,11 @@ type UpdateServiceGroupInput = z.infer<typeof updateServiceGroupSchema>;
|
|||||||
declare const toggleEnabledSchema: z.ZodObject<{
|
declare const toggleEnabledSchema: z.ZodObject<{
|
||||||
enabled: z.ZodBoolean;
|
enabled: z.ZodBoolean;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
|
declare const toggleServiceIpSchema: z.ZodObject<{
|
||||||
|
ip: z.ZodString;
|
||||||
|
enabled: z.ZodBoolean;
|
||||||
|
}, z.core.$strip>;
|
||||||
|
type ToggleServiceIpInput = z.infer<typeof toggleServiceIpSchema>;
|
||||||
declare const reorderServicesSchema: z.ZodObject<{
|
declare const reorderServicesSchema: z.ZodObject<{
|
||||||
group_id: z.ZodDefault<z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>>>;
|
group_id: z.ZodDefault<z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodNull]>>>;
|
||||||
service_ids: z.ZodArray<z.ZodNumber>;
|
service_ids: z.ZodArray<z.ZodNumber>;
|
||||||
@@ -1711,6 +1778,11 @@ declare const appSettingsPatchSchema: z.ZodObject<{
|
|||||||
vpsTrackerIntegrationToken: z.ZodOptional<z.ZodString>;
|
vpsTrackerIntegrationToken: z.ZodOptional<z.ZodString>;
|
||||||
vpsTrackerSyncEnabled: z.ZodOptional<z.ZodBoolean>;
|
vpsTrackerSyncEnabled: z.ZodOptional<z.ZodBoolean>;
|
||||||
showQuickActions: z.ZodOptional<z.ZodBoolean>;
|
showQuickActions: z.ZodOptional<z.ZodBoolean>;
|
||||||
|
healthCheckCron: z.ZodOptional<z.ZodString>;
|
||||||
|
healthDegradedFailures: z.ZodOptional<z.ZodNumber>;
|
||||||
|
healthDownFailures: z.ZodOptional<z.ZodNumber>;
|
||||||
|
healthLatencyWarnMs: z.ZodOptional<z.ZodNumber>;
|
||||||
|
healthSuccessRecoveries: z.ZodOptional<z.ZodNumber>;
|
||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
type AppSettingsPatch = z.infer<typeof appSettingsPatchSchema>;
|
type AppSettingsPatch = z.infer<typeof appSettingsPatchSchema>;
|
||||||
declare const vpsTrackerEventSchema: z.ZodObject<{
|
declare const vpsTrackerEventSchema: z.ZodObject<{
|
||||||
@@ -1835,4 +1907,4 @@ declare const ingestAuditEventSchema: z.ZodObject<{
|
|||||||
}, z.core.$strip>;
|
}, z.core.$strip>;
|
||||||
type IngestAuditEvent = z.infer<typeof ingestAuditEventSchema>;
|
type IngestAuditEvent = z.infer<typeof ingestAuditEventSchema>;
|
||||||
|
|
||||||
export { AUDIT_SEVERITIES, AUDIT_SOURCE_APPS, AUDIT_TARGET_TYPES, type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type AuditListQuery, type AuditLogEntry, type AuditSeverity, type AuditSourceApp, type AuditTargetType, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfHealthCheck, type CfZone, type CfdmBindingSyncItem, type ChangeDomainInput, type ChangeIpInput, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateDomainMonitorInput, type CreateGroupInput, type CreateOriginHealthCheckInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceNodeInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainEnvironment, type DomainListItem, type DomainMonitor, type DomainMonitorResult, type DomainMonitorType, type Group, type GroupWithStats, type HealthCheckConfig, type HealthCheckProvider, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthStatusQuery, type IngestAuditEvent, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NodeHealthState, type NotificationLog, type OriginHealthCheck, type OriginHealthCheckRecord, type ParsedFqdn, type PatchDnsRecordPayload, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceNode, type ServiceNodeRecord, type ServiceOverview, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateServiceNodeInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, auditListQuerySchema, auditLogEntrySchema, auditSeveritySchema, auditSourceAppSchema, auditTargetTypeSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, changeDomainSchema, changeIpSchema, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createOriginHealthCheckSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceNodeSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckProviderSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthStatusQuerySchema, ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, isIpLiteral, isValidIpv4, lbModeSchema, loginSchema, nodeHealthStateSchema, normalizeDnsRecordName, notificationLogSchema, originHealthCheckSchema, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceNodeSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateServiceNodeSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema };
|
export { AUDIT_SEVERITIES, AUDIT_SOURCE_APPS, AUDIT_TARGET_TYPES, type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, type AuditListQuery, type AuditLogEntry, type AuditSeverity, type AuditSourceApp, type AuditTargetType, type BulkUpdateDomainsInput, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfHealthCheck, type CfZone, type CfdmBindingSyncItem, type ChangeDomainInput, type ChangeIpInput, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateDomainMonitorInput, type CreateGroupInput, type CreateOriginHealthCheckInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceNodeInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainEnvironment, type DomainListItem, type DomainMonitor, type DomainMonitorResult, type DomainMonitorType, type Group, type GroupWithStats, type HealthCheckConfig, type HealthCheckProvider, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthStatusQuery, type IngestAuditEvent, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type NodeHealthState, type NotificationLog, type OriginHealthCheck, type OriginHealthCheckRecord, type ParsedFqdn, type PatchDnsRecordPayload, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceIpHealth, type ServiceNode, type ServiceNodeRecord, type ServiceOverview, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type ToggleServiceIpInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateServiceNodeInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, auditListQuerySchema, auditLogEntrySchema, auditSeveritySchema, auditSourceAppSchema, auditTargetTypeSchema, bindingToFqdn, bulkUpdateDomainsSchema, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, changeDomainSchema, changeIpSchema, createDnsRecordSchema, createDomainMonitorSchema, createDomainSchema, createGroupSchema, createOriginHealthCheckSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceNodeSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainEnvironmentSchema, domainListItemSchema, domainMonitorResultSchema, domainMonitorSchema, domainMonitorTypeSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckProviderSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthStatusQuerySchema, ingestAuditEventSchema, ipHealthStateSchema, ipHealthStatusSchema, isIpLiteral, isValidIpv4, lbModeSchema, loginSchema, nodeHealthStateSchema, normalizeDnsRecordName, notificationLogSchema, originHealthCheckSchema, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceIpHealthSchema, serviceNodeSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, toggleServiceIpSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateServiceNodeSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema };
|
||||||
|
|||||||
Vendored
+28
-2
@@ -192,6 +192,11 @@ var ipHealthStatusSchema = z.object({
|
|||||||
last_checked_at: z.string().nullable(),
|
last_checked_at: z.string().nullable(),
|
||||||
last_error: z.string().nullable()
|
last_error: z.string().nullable()
|
||||||
});
|
});
|
||||||
|
var serviceIpHealthSchema = z.object({
|
||||||
|
ip: z.string(),
|
||||||
|
status: ipHealthStateSchema,
|
||||||
|
latency_ms: z.number().nullable()
|
||||||
|
});
|
||||||
var groupSchema = z.object({
|
var groupSchema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
@@ -277,7 +282,9 @@ var serviceViewSchema = serviceSchema.extend({
|
|||||||
ips: z.array(z.string()).default([]),
|
ips: z.array(z.string()).default([]),
|
||||||
domains: z.array(serviceDomainBindingSchema).default([]),
|
domains: z.array(serviceDomainBindingSchema).default([]),
|
||||||
health_status: ipHealthStateSchema.default("unknown"),
|
health_status: ipHealthStateSchema.default("unknown"),
|
||||||
health_latency_ms: z.number().nullable().default(null)
|
health_latency_ms: z.number().nullable().default(null),
|
||||||
|
ip_health: z.array(serviceIpHealthSchema).default([]),
|
||||||
|
ip_enabled: z.record(z.string(), z.boolean()).default({})
|
||||||
});
|
});
|
||||||
var serviceGroupViewSchema = serviceGroupSchema.extend({
|
var serviceGroupViewSchema = serviceGroupSchema.extend({
|
||||||
services: z.array(serviceViewSchema).default([]),
|
services: z.array(serviceViewSchema).default([]),
|
||||||
@@ -552,6 +559,10 @@ var updateServiceGroupSchema = z.object({
|
|||||||
var toggleEnabledSchema = z.object({
|
var toggleEnabledSchema = z.object({
|
||||||
enabled: z.boolean()
|
enabled: z.boolean()
|
||||||
});
|
});
|
||||||
|
var toggleServiceIpSchema = z.object({
|
||||||
|
ip: ipv4Schema,
|
||||||
|
enabled: z.boolean()
|
||||||
|
});
|
||||||
var reorderServicesSchema = z.object({
|
var reorderServicesSchema = z.object({
|
||||||
group_id: z.union([z.number(), z.null()]).optional().default(null),
|
group_id: z.union([z.number(), z.null()]).optional().default(null),
|
||||||
service_ids: z.array(z.number().int().positive()).min(1)
|
service_ids: z.array(z.number().int().positive()).min(1)
|
||||||
@@ -687,7 +698,20 @@ var appSettingsPatchSchema = z3.object({
|
|||||||
vpsTrackerUrl: z3.string().url().or(z3.literal("")).optional(),
|
vpsTrackerUrl: z3.string().url().or(z3.literal("")).optional(),
|
||||||
vpsTrackerIntegrationToken: z3.string().optional(),
|
vpsTrackerIntegrationToken: z3.string().optional(),
|
||||||
vpsTrackerSyncEnabled: z3.boolean().optional(),
|
vpsTrackerSyncEnabled: z3.boolean().optional(),
|
||||||
showQuickActions: z3.boolean().optional()
|
showQuickActions: z3.boolean().optional(),
|
||||||
|
healthCheckCron: z3.string().trim().min(1).max(64).optional(),
|
||||||
|
healthDegradedFailures: z3.number().int().min(1).max(20).optional(),
|
||||||
|
healthDownFailures: z3.number().int().min(1).max(50).optional(),
|
||||||
|
healthLatencyWarnMs: z3.number().int().min(50).max(6e4).optional(),
|
||||||
|
healthSuccessRecoveries: z3.number().int().min(1).max(20).optional()
|
||||||
|
}).superRefine((data, ctx) => {
|
||||||
|
if (data.healthDegradedFailures != null && data.healthDownFailures != null && data.healthDownFailures < data.healthDegradedFailures) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "\u043E\u0448\u0438\u0431\u043E\u043A \u0434\u043E down \u043D\u0435 \u043C\u0435\u043D\u044C\u0448\u0435, \u0447\u0435\u043C \u0434\u043E degraded",
|
||||||
|
path: ["healthDownFailures"]
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
var vpsTrackerEventSchema = z3.object({
|
var vpsTrackerEventSchema = z3.object({
|
||||||
event: z3.enum(["vps_down", "vps_up"]),
|
event: z3.enum(["vps_down", "vps_up"]),
|
||||||
@@ -843,6 +867,7 @@ export {
|
|||||||
serviceGroupTypeSchema,
|
serviceGroupTypeSchema,
|
||||||
serviceGroupViewSchema,
|
serviceGroupViewSchema,
|
||||||
serviceGroupsResponseSchema,
|
serviceGroupsResponseSchema,
|
||||||
|
serviceIpHealthSchema,
|
||||||
serviceNodeSchema,
|
serviceNodeSchema,
|
||||||
serviceSchema,
|
serviceSchema,
|
||||||
serviceViewSchema,
|
serviceViewSchema,
|
||||||
@@ -850,6 +875,7 @@ export {
|
|||||||
subdomainLabelToFqdn,
|
subdomainLabelToFqdn,
|
||||||
subdomainSchema,
|
subdomainSchema,
|
||||||
toggleEnabledSchema,
|
toggleEnabledSchema,
|
||||||
|
toggleServiceIpSchema,
|
||||||
updateDomainGroupSchema,
|
updateDomainGroupSchema,
|
||||||
updateDomainSchema,
|
updateDomainSchema,
|
||||||
updateServiceConfigSchema,
|
updateServiceConfigSchema,
|
||||||
|
|||||||
@@ -30,6 +30,23 @@ export const appSettingsPatchSchema = z.object({
|
|||||||
vpsTrackerIntegrationToken: z.string().optional(),
|
vpsTrackerIntegrationToken: z.string().optional(),
|
||||||
vpsTrackerSyncEnabled: z.boolean().optional(),
|
vpsTrackerSyncEnabled: z.boolean().optional(),
|
||||||
showQuickActions: z.boolean().optional(),
|
showQuickActions: z.boolean().optional(),
|
||||||
|
healthCheckCron: z.string().trim().min(1).max(64).optional(),
|
||||||
|
healthDegradedFailures: z.number().int().min(1).max(20).optional(),
|
||||||
|
healthDownFailures: z.number().int().min(1).max(50).optional(),
|
||||||
|
healthLatencyWarnMs: z.number().int().min(50).max(60_000).optional(),
|
||||||
|
healthSuccessRecoveries: z.number().int().min(1).max(20).optional(),
|
||||||
|
}).superRefine((data, ctx) => {
|
||||||
|
if (
|
||||||
|
data.healthDegradedFailures != null &&
|
||||||
|
data.healthDownFailures != null &&
|
||||||
|
data.healthDownFailures < data.healthDegradedFailures
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "ошибок до down не меньше, чем до degraded",
|
||||||
|
path: ["healthDownFailures"],
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppSettingsPatch = z.infer<typeof appSettingsPatchSchema>;
|
export type AppSettingsPatch = z.infer<typeof appSettingsPatchSchema>;
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ export const ipHealthStatusSchema = z.object({
|
|||||||
|
|
||||||
export type IpHealthStatus = z.infer<typeof ipHealthStatusSchema>
|
export type IpHealthStatus = z.infer<typeof ipHealthStatusSchema>
|
||||||
|
|
||||||
|
export const serviceIpHealthSchema = z.object({
|
||||||
|
ip: z.string(),
|
||||||
|
status: ipHealthStateSchema,
|
||||||
|
latency_ms: z.number().nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ServiceIpHealth = z.infer<typeof serviceIpHealthSchema>
|
||||||
|
|
||||||
export const groupSchema = z.object({
|
export const groupSchema = z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
@@ -150,6 +158,8 @@ export const serviceViewSchema = serviceSchema.extend({
|
|||||||
domains: z.array(serviceDomainBindingSchema).default([]),
|
domains: z.array(serviceDomainBindingSchema).default([]),
|
||||||
health_status: ipHealthStateSchema.default('unknown'),
|
health_status: ipHealthStateSchema.default('unknown'),
|
||||||
health_latency_ms: z.number().nullable().default(null),
|
health_latency_ms: z.number().nullable().default(null),
|
||||||
|
ip_health: z.array(serviceIpHealthSchema).default([]),
|
||||||
|
ip_enabled: z.record(z.string(), z.boolean()).default({}),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const serviceGroupViewSchema = serviceGroupSchema.extend({
|
export const serviceGroupViewSchema = serviceGroupSchema.extend({
|
||||||
@@ -512,6 +522,13 @@ export const toggleEnabledSchema = z.object({
|
|||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const toggleServiceIpSchema = z.object({
|
||||||
|
ip: ipv4Schema,
|
||||||
|
enabled: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ToggleServiceIpInput = z.infer<typeof toggleServiceIpSchema>
|
||||||
|
|
||||||
export const reorderServicesSchema = z.object({
|
export const reorderServicesSchema = z.object({
|
||||||
group_id: z.union([z.number(), z.null()]).optional().default(null),
|
group_id: z.union([z.number(), z.null()]).optional().default(null),
|
||||||
service_ids: z.array(z.number().int().positive()).min(1),
|
service_ids: z.array(z.number().int().positive()).min(1),
|
||||||
|
|||||||
@@ -196,6 +196,8 @@ export interface ServiceView {
|
|||||||
domains: ServiceDomainBindingView[];
|
domains: ServiceDomainBindingView[];
|
||||||
health_status: IpHealthState;
|
health_status: IpHealthState;
|
||||||
health_latency_ms: number | null;
|
health_latency_ms: number | null;
|
||||||
|
ip_health: ServiceIpHealth[];
|
||||||
|
ip_enabled: Record<string, boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroupWithStats extends Group {
|
export interface GroupWithStats extends Group {
|
||||||
@@ -293,6 +295,12 @@ export interface IpHealthStatus {
|
|||||||
last_error: string | null;
|
last_error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServiceIpHealth {
|
||||||
|
ip: string;
|
||||||
|
status: IpHealthState;
|
||||||
|
latency_ms: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServiceNode {
|
export interface ServiceNode {
|
||||||
id: number;
|
id: number;
|
||||||
service_id: number;
|
service_id: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user